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:
刘焱 2026-05-13 16:22:14 +08:00
parent 09e90abe31
commit c72985df89
7 changed files with 335 additions and 0 deletions

View File

@ -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:

3
.gitignore vendored
View File

@ -31,3 +31,6 @@ yarn-error.log*
# Generated search index
static/doc-index.json
# Feedback data
data/feedback.json

View File

@ -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"]

83
api/feedback/store.js Normal file
View File

@ -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 };

View File

@ -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}`,

View File

@ -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);
}

198
static/admin/index.html Normal file
View File

@ -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>