diff --git a/.gitignore b/.gitignore index e1dd8e3..18a3ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,7 @@ __pycache__/ venv/ env/ -# IDE & OS -.DS_Store -Thumbs.db -.vscode/ -.idea/ +# Data +data/ +**/data/ + diff --git a/quiz-app/src/App.tsx b/quiz-app/src/App.tsx index 823c287..939bf67 100644 --- a/quiz-app/src/App.tsx +++ b/quiz-app/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import questionsData from './questions.json'; import './index.css'; @@ -21,37 +21,14 @@ export default function App() { const [weights, setWeights] = useState>({}); const [history, setHistory] = useState([]); const [totalAnswered, setTotalAnswered] = useState(0); + const [isSyncing, setIsSyncing] = useState(false); - // Load weights from localStorage on init - useEffect(() => { - const savedWeights = localStorage.getItem('quiz_weights'); - if (savedWeights) { - setWeights(JSON.parse(savedWeights)); - } else { - const initialWeights: Record = {}; - 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) => { + // 随机挑选一道题目(基于权重轮盘赌算法) + const pickRandomQuestion = useCallback((latestWeights?: Record) => { if (questions.length === 0) return; - - // Calculate total weight - let totalWeight = 0; + const currentWeights = latestWeights || weights; + let totalWeight = 0; questions.forEach(q => { totalWeight += (currentWeights[q.id] || 1); }); @@ -70,73 +47,111 @@ export default function App() { setCurrentQuestion(selectedQ); setSelectedOptions([]); setIsSubmitted(false); - }; + }, [questions, weights]); - // Initial load - useEffect(() => { - if (Object.keys(weights).length > 0 && !currentQuestion) { - pickRandomQuestion(); + // 从服务端拉取全局统计与权重数据 + const fetchGlobalStats = useCallback(async () => { + try { + 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 = {}; + questions.forEach(q => { + initialWeights[q.id] = 1; + }); + setWeights(initialWeights); + return initialWeights; + }, [questions]); + + // 初始化加载 + useEffect(() => { + fetchGlobalStats().then((loadedWeights) => { + pickRandomQuestion(loadedWeights); + }); + }, [fetchGlobalStats, pickRandomQuestion]); const handleOptionClick = (optionLabel: string) => { if (isSubmitted) return; - // extract letter A, B, C... const letter = optionLabel.charAt(0); if (currentQuestion?.type === '单选') { setSelectedOptions([letter]); handleSubmit([letter]); } else { - // Multi-select - setSelectedOptions(prev => - prev.includes(letter) + setSelectedOptions(prev => + prev.includes(letter) ? prev.filter(l => l !== letter) : [...prev, letter] ); } }; - const handleSubmit = (autoSubmitOptions?: string[]) => { + const handleSubmit = async (autoSubmitOptions?: string[]) => { 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 isCorrect = userAns === currentQuestion.answer; setIsSubmitted(true); - // Update stats (history of last 100) - const newHistory = [...history, isCorrect].slice(-100); - setHistory(newHistory); - localStorage.setItem('quiz_history', JSON.stringify(newHistory)); - - const newTotal = totalAnswered + 1; - setTotalAnswered(newTotal); - localStorage.setItem('quiz_total', JSON.stringify(newTotal)); + // 本地乐观更新 + const localNewHistory = [...history, isCorrect].slice(-100); + setHistory(localNewHistory); + setTotalAnswered(prev => prev + 1); - // Update weights - const newWeights = { ...weights }; - const currentWeight = newWeights[currentQuestion.id] || 1; - - if (isCorrect) { - newWeights[currentQuestion.id] = Math.max(1, currentWeight - 1); - } else { - newWeights[currentQuestion.id] = currentWeight + 3; // Increase weight heavily on error + const localWeights = { ...weights }; + const currentWeight = localWeights[currentQuestion.id] || 1; + localWeights[currentQuestion.id] = isCorrect ? Math.max(1, currentWeight - 1) : currentWeight + 3; + setWeights(localWeights); + + // 异步同步到后端服务端 + setIsSyncing(true); + 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) { setTimeout(() => { - pickRandomQuestion(newWeights); + pickRandomQuestion(nextWeights); }, 1000); } }; - if (!currentQuestion) return
Loading...
; + if (!currentQuestion) return
加载题库中...
; const userAns = [...selectedOptions].sort().join(''); const isCorrect = isSubmitted && userAns === currentQuestion.answer; @@ -146,7 +161,7 @@ export default function App() {
{currentQuestion.type} - Weight: {weights[currentQuestion.id] || 1} + 全局权重: {weights[currentQuestion.id] || 1} {isSyncing && '(同步中...)'}
@@ -158,10 +173,10 @@ export default function App() { {currentQuestion.options.map((opt, i) => { const letter = opt.charAt(0); const isSelected = selectedOptions.includes(letter); - + let className = 'option'; if (isSelected) className += ' selected'; - + if (isSubmitted) { className += ' disabled'; const isCorrectOption = currentQuestion.answer.includes(letter); @@ -173,8 +188,8 @@ export default function App() { } return ( -
handleOptionClick(opt)} > @@ -187,7 +202,7 @@ export default function App() { {!isSubmitted ? (
{currentQuestion.type !== '单选' && ( -
); } + diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..c1da638 --- /dev/null +++ b/server/package.json @@ -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" +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..aeedc8c --- /dev/null +++ b/server/server.js @@ -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}`); +});