feat: 实现所有用户共享刷题历史与权重数据后端服务及多端同步
This commit is contained in:
+4
-5
@@ -22,8 +22,7 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
|
|
||||||
# IDE & OS
|
# Data
|
||||||
.DS_Store
|
data/
|
||||||
Thumbs.db
|
**/data/
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
|
|||||||
+85
-69
@@ -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,73 +47,111 @@ 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)
|
||||||
: [...prev, letter]
|
: [...prev, letter]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
|
||||||
setTotalAnswered(newTotal);
|
|
||||||
localStorage.setItem('quiz_total', JSON.stringify(newTotal));
|
|
||||||
|
|
||||||
// Update weights
|
const localWeights = { ...weights };
|
||||||
const newWeights = { ...weights };
|
const currentWeight = localWeights[currentQuestion.id] || 1;
|
||||||
const currentWeight = newWeights[currentQuestion.id] || 1;
|
localWeights[currentQuestion.id] = isCorrect ? Math.max(1, currentWeight - 1) : currentWeight + 3;
|
||||||
|
setWeights(localWeights);
|
||||||
if (isCorrect) {
|
|
||||||
newWeights[currentQuestion.id] = Math.max(1, currentWeight - 1);
|
// 异步同步到后端服务端
|
||||||
} else {
|
setIsSyncing(true);
|
||||||
newWeights[currentQuestion.id] = currentWeight + 3; // Increase weight heavily on error
|
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 (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.weights) {
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -158,10 +173,10 @@ export default function App() {
|
|||||||
{currentQuestion.options.map((opt, i) => {
|
{currentQuestion.options.map((opt, i) => {
|
||||||
const letter = opt.charAt(0);
|
const letter = opt.charAt(0);
|
||||||
const isSelected = selectedOptions.includes(letter);
|
const isSelected = selectedOptions.includes(letter);
|
||||||
|
|
||||||
let className = 'option';
|
let className = 'option';
|
||||||
if (isSelected) className += ' selected';
|
if (isSelected) className += ' selected';
|
||||||
|
|
||||||
if (isSubmitted) {
|
if (isSubmitted) {
|
||||||
className += ' disabled';
|
className += ' disabled';
|
||||||
const isCorrectOption = currentQuestion.answer.includes(letter);
|
const isCorrectOption = currentQuestion.answer.includes(letter);
|
||||||
@@ -173,8 +188,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={className}
|
className={className}
|
||||||
onClick={() => handleOptionClick(opt)}
|
onClick={() => handleOptionClick(opt)}
|
||||||
>
|
>
|
||||||
@@ -187,7 +202,7 @@ export default function App() {
|
|||||||
{!isSubmitted ? (
|
{!isSubmitted ? (
|
||||||
<div className="action-container">
|
<div className="action-container">
|
||||||
{currentQuestion.type !== '单选' && (
|
{currentQuestion.type !== '单选' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSubmit()}
|
onClick={() => handleSubmit()}
|
||||||
disabled={selectedOptions.length === 0}
|
disabled={selectedOptions.length === 0}
|
||||||
>
|
>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user