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 帮助中心
+
+
+
+
+
+
+
+
+
+
+
+
+
页面反馈分布
+
+
+ | 页面路径 | 有帮助 | 没帮助 | 帮助率 | 建议 |
+
+
+
+
+
+
+
+
+
+
+
+