feat: 反馈数据后端化,添加管理统计面板
- 新增 api/feedback/store.js 基于 JSON 文件的反馈数据存储 - 新增 POST /api/feedback 提交接口,GET /api/feedback/stats 和 /recent 管理接口 - DocFeedback 组件提交反馈时同步发送到后端 - 新增 /admin/index.html 管理面板,支持查看统计数据和最近反馈 - Dockerfile 添加数据卷持久化,流水线添加 -v 挂载
This commit is contained in:
parent
09e90abe31
commit
c72985df89
|
|
@ -57,7 +57,9 @@ workflow:
|
|||
ssh_user: '"root"'
|
||||
ssh_cmd: '"docker stop help_center || true && docker rm help_center || true
|
||||
&& docker pull crpi-kn808rklyotb8pg1.cn-hangzhou.personal.cr.aliyuncs.com/gitlink_nudter/help_center:latest
|
||||
&& mkdir -p /root/help_center_data
|
||||
&& docker run -d -p 3000:3000 --env-file /root/.help_center_env
|
||||
-v /root/help_center_data:/gitlink_help_center/data
|
||||
--name help_center
|
||||
crpi-kn808rklyotb8pg1.cn-hangzhou.personal.cr.aliyuncs.com/gitlink_nudter/help_center:latest"'
|
||||
needs:
|
||||
|
|
|
|||
|
|
@ -31,3 +31,6 @@ yarn-error.log*
|
|||
|
||||
# Generated search index
|
||||
static/doc-index.json
|
||||
|
||||
# Feedback data
|
||||
data/feedback.json
|
||||
|
|
|
|||
|
|
@ -15,4 +15,8 @@ RUN node scripts/generate-doc-index.js
|
|||
|
||||
EXPOSE 3000
|
||||
|
||||
RUN mkdir -p /gitlink_help_center/data
|
||||
|
||||
VOLUME ["/gitlink_help_center/data"]
|
||||
|
||||
CMD ["npx", "pm2-runtime", "ecosystem.config.js"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
// api/feedback/store.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DATA_DIR = path.resolve(__dirname, '../../data');
|
||||
const FEEDBACK_FILE = path.join(DATA_DIR, 'feedback.json');
|
||||
const MAX_ENTRIES = 10000;
|
||||
|
||||
function ensureFile() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(FEEDBACK_FILE)) {
|
||||
fs.writeFileSync(FEEDBACK_FILE, '[]', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
function readAll() {
|
||||
ensureFile();
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(entries) {
|
||||
ensureFile();
|
||||
fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(entries, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function addFeedback({ docPath, vote, suggestion }) {
|
||||
const entries = readAll();
|
||||
const entry = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||
docPath,
|
||||
vote,
|
||||
suggestion: suggestion || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
entries.push(entry);
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries.splice(0, entries.length - MAX_ENTRIES);
|
||||
}
|
||||
writeAll(entries);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const entries = readAll();
|
||||
const total = entries.length;
|
||||
const yes = entries.filter(e => e.vote === 'yes').length;
|
||||
const no = entries.filter(e => e.vote === 'no').length;
|
||||
const withSuggestion = entries.filter(e => e.suggestion).length;
|
||||
|
||||
const byPath = {};
|
||||
for (const e of entries) {
|
||||
if (!byPath[e.docPath]) {
|
||||
byPath[e.docPath] = { path: e.docPath, yes: 0, no: 0, suggestions: [] };
|
||||
}
|
||||
if (e.vote === 'yes') byPath[e.docPath].yes++;
|
||||
else byPath[e.docPath].no++;
|
||||
if (e.suggestion) {
|
||||
byPath[e.docPath].suggestions.push({ text: e.suggestion, time: e.timestamp });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
yes,
|
||||
no,
|
||||
helpfulRate: total > 0 ? Math.round((yes / total) * 100) : 0,
|
||||
withSuggestion,
|
||||
byPath: Object.values(byPath).sort((a, b) => (b.yes + b.no) - (a.yes + a.no)),
|
||||
};
|
||||
}
|
||||
|
||||
function getRecent(limit = 50) {
|
||||
const entries = readAll();
|
||||
return entries.slice(-limit).reverse();
|
||||
}
|
||||
|
||||
module.exports = { addFeedback, getStats, getRecent };
|
||||
|
|
@ -3,9 +3,11 @@ const express = require('express');
|
|||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const { search } = require('./search/indexer');
|
||||
const { streamChat } = require('./llm/deepseek');
|
||||
const { addFeedback, getStats, getRecent } = require('./feedback/store');
|
||||
|
||||
const API_PORT = parseInt(process.env.API_PORT || '3000', 10);
|
||||
const SERVE_PORT = parseInt(process.env.SERVE_PORT || '3001', 10);
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'gitlink_admin_2024';
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
const SYSTEM_PROMPT = `你是 GitLink 帮助中心的 AI 助手。请基于提供的文档内容回答用户的问题。
|
||||
|
|
@ -89,6 +91,35 @@ app.post('/api/chat', async (req, res) => {
|
|||
res.end();
|
||||
});
|
||||
|
||||
// Feedback: submit
|
||||
app.post('/api/feedback', (req, res) => {
|
||||
const { docPath, vote, suggestion } = req.body;
|
||||
if (!docPath || !vote || !['yes', 'no'].includes(vote)) {
|
||||
return res.status(400).json({ error: 'docPath and vote (yes/no) are required' });
|
||||
}
|
||||
const entry = addFeedback({ docPath, vote, suggestion });
|
||||
res.json({ success: true, id: entry.id });
|
||||
});
|
||||
|
||||
// Feedback: stats (admin)
|
||||
app.get('/api/feedback/stats', (req, res) => {
|
||||
const token = req.query.token || req.headers['x-admin-token'];
|
||||
if (token !== ADMIN_TOKEN) {
|
||||
return res.status(403).json({ error: 'unauthorized' });
|
||||
}
|
||||
res.json(getStats());
|
||||
});
|
||||
|
||||
// Feedback: recent entries (admin)
|
||||
app.get('/api/feedback/recent', (req, res) => {
|
||||
const token = req.query.token || req.headers['x-admin-token'];
|
||||
if (token !== ADMIN_TOKEN) {
|
||||
return res.status(403).json({ error: 'unauthorized' });
|
||||
}
|
||||
const limit = Math.min(parseInt(req.query.limit || '50', 10), 200);
|
||||
res.json(getRecent(limit));
|
||||
});
|
||||
|
||||
// Proxy all other requests to Docusaurus serve
|
||||
app.use('/', createProxyMiddleware({
|
||||
target: `http://localhost:${SERVE_PORT}`,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,18 @@ import styles from './styles.module.css';
|
|||
|
||||
const STORAGE_PREFIX = 'doc_feedback_';
|
||||
|
||||
async function submitFeedback(docPath, vote, suggestion) {
|
||||
try {
|
||||
await fetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ docPath, vote, suggestion }),
|
||||
});
|
||||
} catch {
|
||||
// silently fail — data stays in localStorage as fallback
|
||||
}
|
||||
}
|
||||
|
||||
export default function DocFeedback() {
|
||||
const location = useLocation();
|
||||
const [voted, setVoted] = useState(null);
|
||||
|
|
@ -22,6 +34,7 @@ export default function DocFeedback() {
|
|||
if (voted) return;
|
||||
localStorage.setItem(STORAGE_PREFIX + location.pathname, type);
|
||||
setVoted(type);
|
||||
submitFeedback(location.pathname, type);
|
||||
if (type === 'no') {
|
||||
setShowSuggestion(true);
|
||||
}
|
||||
|
|
@ -30,6 +43,7 @@ export default function DocFeedback() {
|
|||
function handleSubmitSuggestion() {
|
||||
const key = STORAGE_PREFIX + 'suggestion_' + location.pathname;
|
||||
localStorage.setItem(key, suggestion);
|
||||
submitFeedback(location.pathname, 'no', suggestion);
|
||||
setShowSuggestion(false);
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>反馈统计 - GitLink 帮助中心</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f7fa; color: #333; }
|
||||
|
||||
.header { background: #1b2440; color: #fff; padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between; }
|
||||
.header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
.header a { color: #8b9fd4; text-decoration: none; font-size: 0.85rem; }
|
||||
.header a:hover { color: #fff; }
|
||||
|
||||
.login-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.login-box { background: #fff; padding: 2rem; border-radius: 12px; width: 360px; box-shadow: 0 8px 32px rgba(0,0,0,0.15); }
|
||||
.login-box h2 { margin-bottom: 1rem; font-size: 1.1rem; }
|
||||
.login-box input { width: 100%; padding: 0.6rem 0.8rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.login-box button { width: 100%; padding: 0.6rem; background: #466aff; color: #fff; border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; }
|
||||
.login-box button:hover { background: #3558e0; }
|
||||
.login-error { color: #e74c3c; font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.stat-card { background: #fff; border-radius: 10px; padding: 1.25rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.stat-card .label { font-size: 0.8rem; color: #888; margin-bottom: 0.3rem; }
|
||||
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #1b2440; }
|
||||
.stat-card .value.green { color: #27ae60; }
|
||||
.stat-card .value.red { color: #e74c3c; }
|
||||
.stat-card .value.blue { color: #466aff; }
|
||||
|
||||
.section { background: #fff; border-radius: 10px; padding: 1.25rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); margin-bottom: 1.5rem; }
|
||||
.section h2 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid #eee; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
th { text-align: left; padding: 0.6rem 0.5rem; border-bottom: 2px solid #eee; color: #888; font-weight: 600; font-size: 0.8rem; }
|
||||
td { padding: 0.6rem 0.5rem; border-bottom: 1px solid #f0f0f0; }
|
||||
tr:hover td { background: #f8f9fb; }
|
||||
|
||||
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-yes { background: #e8f8ef; color: #27ae60; }
|
||||
.badge-no { background: #fde8e8; color: #e74c3c; }
|
||||
|
||||
.bar { height: 8px; border-radius: 4px; background: #eee; overflow: hidden; min-width: 60px; }
|
||||
.bar-fill { height: 100%; border-radius: 4px; background: #466aff; }
|
||||
|
||||
.suggestion-text { background: #f8f9fb; padding: 0.4rem 0.6rem; border-radius: 4px; font-size: 0.82rem; color: #555; margin-top: 0.2rem; }
|
||||
.time { color: #aaa; font-size: 0.8rem; }
|
||||
|
||||
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.tab { padding: 0.4rem 1rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer; font-size: 0.85rem; }
|
||||
.tab.active { background: #466aff; color: #fff; border-color: #466aff; }
|
||||
|
||||
.empty { text-align: center; padding: 2rem; color: #aaa; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.container { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>GitLink 帮助中心 — 反馈统计</h1>
|
||||
<a href="/">← 返回帮助中心</a>
|
||||
</div>
|
||||
|
||||
<div id="loginOverlay" class="login-overlay">
|
||||
<div class="login-box">
|
||||
<h2>管理员登录</h2>
|
||||
<div id="loginError" class="login-error" style="display:none"></div>
|
||||
<input type="password" id="tokenInput" placeholder="请输入管理口令" onkeydown="if(event.key==='Enter')doLogin()">
|
||||
<button onclick="doLogin()">登 录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="mainContent" style="display:none">
|
||||
<div class="stats-row" id="statsRow"></div>
|
||||
|
||||
<div class="section">
|
||||
<h2>页面反馈分布</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>页面路径</th><th>有帮助</th><th>没帮助</th><th>帮助率</th><th>建议</th></tr>
|
||||
</thead>
|
||||
<tbody id="byPathBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('all',this)">全部</div>
|
||||
<div class="tab" onclick="switchTab('suggestion',this)">仅含建议</div>
|
||||
</div>
|
||||
<h2>最近反馈</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>时间</th><th>页面</th><th>评价</th><th>建议内容</th></tr>
|
||||
</thead>
|
||||
<tbody id="recentBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let TOKEN = '';
|
||||
let currentFilter = 'all';
|
||||
let recentData = [];
|
||||
|
||||
function doLogin() {
|
||||
TOKEN = document.getElementById('tokenInput').value.trim();
|
||||
loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [statsRes, recentRes] = await Promise.all([
|
||||
fetch('/api/feedback/stats?token=' + TOKEN),
|
||||
fetch('/api/feedback/recent?token=' + TOKEN + '&limit=100'),
|
||||
]);
|
||||
if (!statsRes.ok || !recentRes.ok) {
|
||||
document.getElementById('loginError').style.display = 'block';
|
||||
document.getElementById('loginError').textContent = '口令错误,请重试';
|
||||
return;
|
||||
}
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
document.getElementById('mainContent').style.display = 'block';
|
||||
|
||||
const stats = await statsRes.json();
|
||||
recentData = await recentRes.json();
|
||||
|
||||
renderStats(stats);
|
||||
renderByPath(stats.byPath);
|
||||
renderRecent();
|
||||
} catch (e) {
|
||||
document.getElementById('loginError').style.display = 'block';
|
||||
document.getElementById('loginError').textContent = '网络错误: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(s) {
|
||||
document.getElementById('statsRow').innerHTML = `
|
||||
<div class="stat-card"><div class="label">总反馈数</div><div class="value">${s.total}</div></div>
|
||||
<div class="stat-card"><div class="label">有帮助</div><div class="value green">${s.yes}</div></div>
|
||||
<div class="stat-card"><div class="label">没帮助</div><div class="value red">${s.no}</div></div>
|
||||
<div class="stat-card"><div class="label">帮助率</div><div class="value blue">${s.helpfulRate}%</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderByPath(paths) {
|
||||
const tbody = document.getElementById('byPathBody');
|
||||
if (!paths.length) { tbody.innerHTML = '<tr><td colspan="5" class="empty">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = paths.slice(0, 30).map(p => {
|
||||
const total = p.yes + p.no;
|
||||
const rate = total > 0 ? Math.round((p.yes / total) * 100) : 0;
|
||||
return `<tr>
|
||||
<td><a href="${p.path}" target="_blank">${p.path}</a></td>
|
||||
<td>${p.yes}</td><td>${p.no}</td>
|
||||
<td><div class="bar"><div class="bar-fill" style="width:${rate}%"></div></div> ${rate}%</td>
|
||||
<td>${p.suggestions.length > 0 ? p.suggestions.length + ' 条' : '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRecent() {
|
||||
const tbody = document.getElementById('recentBody');
|
||||
let data = recentData;
|
||||
if (currentFilter === 'suggestion') {
|
||||
data = data.filter(e => e.suggestion);
|
||||
}
|
||||
if (!data.length) { tbody.innerHTML = '<tr><td colspan="4" class="empty">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = data.map(e => `<tr>
|
||||
<td class="time">${new Date(e.timestamp).toLocaleString('zh-CN')}</td>
|
||||
<td><a href="${e.docPath}" target="_blank">${e.docPath}</a></td>
|
||||
<td><span class="badge ${e.vote === 'yes' ? 'badge-yes' : 'badge-no'}">${e.vote === 'yes' ? '有帮助' : '没帮助'}</span></td>
|
||||
<td>${e.suggestion ? '<div class="suggestion-text">' + escHtml(e.suggestion) + '</div>' : '-'}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function switchTab(filter, el) {
|
||||
currentFilter = filter;
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
renderRecent();
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue