From c72985df89f05bfcf1942d9fb9f6edfed4da8fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=84=B1?= <1877685089@qq.com> Date: Wed, 13 May 2026 16:22:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=8D=E9=A6=88=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E5=8C=96=EF=BC=8C=E6=B7=BB=E5=8A=A0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=BB=9F=E8=AE=A1=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 api/feedback/store.js 基于 JSON 文件的反馈数据存储 - 新增 POST /api/feedback 提交接口,GET /api/feedback/stats 和 /recent 管理接口 - DocFeedback 组件提交反馈时同步发送到后端 - 新增 /admin/index.html 管理面板,支持查看统计数据和最近反馈 - Dockerfile 添加数据卷持久化,流水线添加 -v 挂载 --- .devops/构建流水线.yml | 2 + .gitignore | 3 + Dockerfile | 4 + api/feedback/store.js | 83 +++++++++++ api/server.js | 31 +++++ src/components/DocFeedback/index.jsx | 14 ++ static/admin/index.html | 198 +++++++++++++++++++++++++++ 7 files changed, 335 insertions(+) create mode 100644 api/feedback/store.js create mode 100644 static/admin/index.html diff --git a/.devops/构建流水线.yml b/.devops/构建流水线.yml index f0e8cae..8353ff7 100644 --- a/.devops/构建流水线.yml +++ b/.devops/构建流水线.yml @@ -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: diff --git a/.gitignore b/.gitignore index 14c2c50..7c2a64b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ yarn-error.log* # Generated search index static/doc-index.json + +# Feedback data +data/feedback.json diff --git a/Dockerfile b/Dockerfile index 1fc0a0a..1e18e0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/api/feedback/store.js b/api/feedback/store.js new file mode 100644 index 0000000..da625e0 --- /dev/null +++ b/api/feedback/store.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 }; diff --git a/api/server.js b/api/server.js index d97dd5b..f0b7974 100644 --- a/api/server.js +++ b/api/server.js @@ -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}`, diff --git a/src/components/DocFeedback/index.jsx b/src/components/DocFeedback/index.jsx index 18c399a..bb276f5 100644 --- a/src/components/DocFeedback/index.jsx +++ b/src/components/DocFeedback/index.jsx @@ -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); } diff --git a/static/admin/index.html b/static/admin/index.html new file mode 100644 index 0000000..0998abe --- /dev/null +++ b/static/admin/index.html @@ -0,0 +1,198 @@ + + + + + + 反馈统计 - GitLink 帮助中心 + + + + +
+

GitLink 帮助中心 — 反馈统计

+ ← 返回帮助中心 +
+ +
+
+

管理员登录

+ + + +
+
+ + + + + + +