feat: 实现所有用户共享刷题历史与权重数据后端服务及多端同步

This commit is contained in:
2026-08-17 09:20:54 +08:00
parent b13b519046
commit 8cabc3840e
4 changed files with 262 additions and 74 deletions
+4 -5
View File
@@ -22,8 +22,7 @@ __pycache__/
venv/ venv/
env/ env/
# IDE & OS # Data
.DS_Store data/
Thumbs.db **/data/
.vscode/
.idea/
+75 -59
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import questionsData from './questions.json'; import questionsData from './questions.json';
import './index.css'; import './index.css';
@@ -21,37 +21,14 @@ export default function App() {
const [weights, setWeights] = useState<Record<number, number>>({}); const [weights, setWeights] = useState<Record<number, number>>({});
const [history, setHistory] = useState<boolean[]>([]); const [history, setHistory] = useState<boolean[]>([]);
const [totalAnswered, setTotalAnswered] = useState(0); const [totalAnswered, setTotalAnswered] = useState(0);
const [isSyncing, setIsSyncing] = useState(false);
// Load weights from localStorage on init // 随机挑选一道题目(基于权重轮盘赌算法)
useEffect(() => { const pickRandomQuestion = useCallback((latestWeights?: Record<number, number>) => {
const savedWeights = localStorage.getItem('quiz_weights');
if (savedWeights) {
setWeights(JSON.parse(savedWeights));
} else {
const initialWeights: Record<number, number> = {};
questions.forEach(q => {
initialWeights[q.id] = 1;
});
setWeights(initialWeights);
}
const savedHistory = localStorage.getItem('quiz_history');
if (savedHistory) {
setHistory(JSON.parse(savedHistory));
}
const savedTotal = localStorage.getItem('quiz_total');
if (savedTotal) {
setTotalAnswered(JSON.parse(savedTotal));
}
}, [questions]);
// Pick a random question based on weights
const pickRandomQuestion = (latestWeights?: Record<number, number>) => {
if (questions.length === 0) return; if (questions.length === 0) return;
// Calculate total weight
let totalWeight = 0;
const currentWeights = latestWeights || weights; const currentWeights = latestWeights || weights;
let totalWeight = 0;
questions.forEach(q => { questions.forEach(q => {
totalWeight += (currentWeights[q.id] || 1); totalWeight += (currentWeights[q.id] || 1);
}); });
@@ -70,26 +47,48 @@ export default function App() {
setCurrentQuestion(selectedQ); setCurrentQuestion(selectedQ);
setSelectedOptions([]); setSelectedOptions([]);
setIsSubmitted(false); setIsSubmitted(false);
}; }, [questions, weights]);
// Initial load // 从服务端拉取全局统计与权重数据
useEffect(() => { const fetchGlobalStats = useCallback(async () => {
if (Object.keys(weights).length > 0 && !currentQuestion) { try {
pickRandomQuestion(); const res = await fetch('/api/stats');
if (res.ok) {
const data = await res.json();
if (data.weights) setWeights(data.weights);
if (data.history) setHistory(data.history);
if (typeof data.totalAnswered === 'number') setTotalAnswered(data.totalAnswered);
return data.weights;
}
} catch (e) {
console.warn('获取全局统计数据失败,使用本地兜底:', e);
} }
}, [weights]);
// 兜底本地逻辑
const initialWeights: Record<number, number> = {};
questions.forEach(q => {
initialWeights[q.id] = 1;
});
setWeights(initialWeights);
return initialWeights;
}, [questions]);
// 初始化加载
useEffect(() => {
fetchGlobalStats().then((loadedWeights) => {
pickRandomQuestion(loadedWeights);
});
}, [fetchGlobalStats, pickRandomQuestion]);
const handleOptionClick = (optionLabel: string) => { const handleOptionClick = (optionLabel: string) => {
if (isSubmitted) return; if (isSubmitted) return;
// extract letter A, B, C...
const letter = optionLabel.charAt(0); const letter = optionLabel.charAt(0);
if (currentQuestion?.type === '单选') { if (currentQuestion?.type === '单选') {
setSelectedOptions([letter]); setSelectedOptions([letter]);
handleSubmit([letter]); handleSubmit([letter]);
} else { } else {
// Multi-select
setSelectedOptions(prev => setSelectedOptions(prev =>
prev.includes(letter) prev.includes(letter)
? prev.filter(l => l !== letter) ? prev.filter(l => l !== letter)
@@ -98,45 +97,61 @@ export default function App() {
} }
}; };
const handleSubmit = (autoSubmitOptions?: string[]) => { const handleSubmit = async (autoSubmitOptions?: string[]) => {
const currentSelection = Array.isArray(autoSubmitOptions) ? autoSubmitOptions : selectedOptions; const currentSelection = Array.isArray(autoSubmitOptions) ? autoSubmitOptions : selectedOptions;
if (currentSelection.length === 0 || !currentQuestion) return; if (currentSelection.length === 0 || !currentQuestion || isSubmitted) return;
const userAns = [...currentSelection].sort().join(''); const userAns = [...currentSelection].sort().join('');
const isCorrect = userAns === currentQuestion.answer; const isCorrect = userAns === currentQuestion.answer;
setIsSubmitted(true); setIsSubmitted(true);
// Update stats (history of last 100) // 本地乐观更新
const newHistory = [...history, isCorrect].slice(-100); const localNewHistory = [...history, isCorrect].slice(-100);
setHistory(newHistory); setHistory(localNewHistory);
localStorage.setItem('quiz_history', JSON.stringify(newHistory)); setTotalAnswered(prev => prev + 1);
const newTotal = totalAnswered + 1; const localWeights = { ...weights };
setTotalAnswered(newTotal); const currentWeight = localWeights[currentQuestion.id] || 1;
localStorage.setItem('quiz_total', JSON.stringify(newTotal)); localWeights[currentQuestion.id] = isCorrect ? Math.max(1, currentWeight - 1) : currentWeight + 3;
setWeights(localWeights);
// Update weights // 异步同步到后端服务端
const newWeights = { ...weights }; setIsSyncing(true);
const currentWeight = newWeights[currentQuestion.id] || 1; let nextWeights = localWeights;
try {
const res = await fetch('/api/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
questionId: currentQuestion.id,
isCorrect
})
});
if (isCorrect) { if (res.ok) {
newWeights[currentQuestion.id] = Math.max(1, currentWeight - 1); const data = await res.json();
} else { if (data.weights) {
newWeights[currentQuestion.id] = currentWeight + 3; // Increase weight heavily on error setWeights(data.weights);
nextWeights = data.weights;
}
if (data.history) setHistory(data.history);
if (typeof data.totalAnswered === 'number') setTotalAnswered(data.totalAnswered);
}
} catch (err) {
console.warn('向服务端同步作答数据失败:', err);
} finally {
setIsSyncing(false);
} }
setWeights(newWeights);
localStorage.setItem('quiz_weights', JSON.stringify(newWeights));
if (isCorrect) { if (isCorrect) {
setTimeout(() => { setTimeout(() => {
pickRandomQuestion(newWeights); pickRandomQuestion(nextWeights);
}, 1000); }, 1000);
} }
}; };
if (!currentQuestion) return <div className="glass-card">Loading...</div>; if (!currentQuestion) return <div className="glass-card">加载题库中...</div>;
const userAns = [...selectedOptions].sort().join(''); const userAns = [...selectedOptions].sort().join('');
const isCorrect = isSubmitted && userAns === currentQuestion.answer; const isCorrect = isSubmitted && userAns === currentQuestion.answer;
@@ -146,7 +161,7 @@ export default function App() {
<div className="header"> <div className="header">
<span className="title-tag">{currentQuestion.type}</span> <span className="title-tag">{currentQuestion.type}</span>
<span style={{color: '#94a3b8', fontSize: '0.875rem'}}> <span style={{color: '#94a3b8', fontSize: '0.875rem'}}>
Weight: {weights[currentQuestion.id] || 1} 全局权重: {weights[currentQuestion.id] || 1} {isSyncing && '(同步中...)'}
</span> </span>
</div> </div>
@@ -221,9 +236,10 @@ export default function App() {
)} )}
<div className="stats"> <div className="stats">
<span>已刷题:{totalAnswered} 道</span> <span>全局已刷题:{totalAnswered} 道</span>
<span>近100题正确率:{history.length > 0 ? Math.round((history.filter(Boolean).length / history.length) * 100) : 0}%</span> <span>近100题正确率:{history.length > 0 ? Math.round((history.filter(Boolean).length / history.length) * 100) : 0}%</span>
</div> </div>
</div> </div>
); );
} }
+12
View File
@@ -0,0 +1,12 @@
{
"name": "bigdata-quiz-server",
"version": "1.0.0",
"description": "Shared quiz statistics and weights backend service",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC"
}
+161
View File
@@ -0,0 +1,161 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 3001;
const DATA_DIR = path.join(__dirname, '../data');
const DATA_FILE = path.join(DATA_DIR, 'quiz_stats.json');
const QUESTIONS_FILE = path.join(__dirname, '../questions.json');
// 保证数据目录存在
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// 内存中的状态缓存
let state = {
weights: {},
history: [],
totalAnswered: 0
};
// 初始化状态
function initState() {
if (fs.existsSync(DATA_FILE)) {
try {
const content = fs.readFileSync(DATA_FILE, 'utf-8');
state = JSON.parse(content);
console.log(`[Init] 已从文件载入全局数据,累计答题数: ${state.totalAnswered}`);
return;
} catch (e) {
console.error('[Init] 读取已有数据文件失败,将重新初始化:', e);
}
}
// 初始化默认权重
let defaultWeights = {};
if (fs.existsSync(QUESTIONS_FILE)) {
try {
const qData = JSON.parse(fs.readFileSync(QUESTIONS_FILE, 'utf-8'));
qData.forEach(q => {
defaultWeights[q.id] = 1;
});
} catch (e) {
console.error('[Init] 读取 questions.json 失败:', e);
}
}
state = {
weights: defaultWeights,
history: [],
totalAnswered: 0
};
saveState();
}
// 异步持久化保存
function saveState() {
const tmpFile = `${DATA_FILE}.tmp`;
fs.writeFile(tmpFile, JSON.stringify(state, null, 2), 'utf-8', (err) => {
if (err) {
console.error('[Save] 保存临时数据文件失败:', err);
return;
}
fs.rename(tmpFile, DATA_FILE, (renameErr) => {
if (renameErr) {
console.error('[Save] 原子重命名数据文件失败:', renameErr);
}
});
});
}
initState();
const server = http.createServer((req, res) => {
// 设置 CORS 标头
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname.replace(/^\/api/, ''); // 统一兼容 /api 前缀与无前缀
if (req.method === 'GET' && (pathname === '/stats' || pathname === '/stats/')) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(state));
return;
}
if (req.method === 'POST' && (pathname === '/answer' || pathname === '/answer/')) {
let body = '';
req.on('data', chunk => {
body += chunk;
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const questionId = Number(payload.questionId);
const isCorrect = Boolean(payload.isCorrect);
if (!questionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing questionId' }));
return;
}
// 更新历史记录(保留最近 100 条)
state.history.push(isCorrect);
if (state.history.length > 100) {
state.history = state.history.slice(-100);
}
// 更新全局总数
state.totalAnswered = (state.totalAnswered || 0) + 1;
// 更新题目权重
const currentWeight = state.weights[questionId] || 1;
if (isCorrect) {
state.weights[questionId] = Math.max(1, currentWeight - 1);
} else {
state.weights[questionId] = currentWeight + 3; // 答错大幅增加权重
}
saveState();
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({
success: true,
weights: state.weights,
history: state.history,
totalAnswered: state.totalAnswered
}));
} catch (err) {
console.error('[Error] 处理 /api/answer 异常:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Internal Server Error' }));
}
});
return;
}
if (req.method === 'POST' && (pathname === '/reset' || pathname === '/reset/')) {
initState();
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ success: true, message: 'Stats reset' }));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' }));
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`[Server] 全局刷题服务已启动,监听端口: ${PORT}`);
});