feat: 实现所有用户共享刷题历史与权重数据后端服务及多端同步
This commit is contained in:
+85
-69
@@ -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<Record<number, number>>({});
|
||||
const [history, setHistory] = useState<boolean[]>([]);
|
||||
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<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>) => {
|
||||
// 随机挑选一道题目(基于权重轮盘赌算法)
|
||||
const pickRandomQuestion = useCallback((latestWeights?: Record<number, number>) => {
|
||||
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<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) => {
|
||||
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 <div className="glass-card">Loading...</div>;
|
||||
if (!currentQuestion) return <div className="glass-card">加载题库中...</div>;
|
||||
|
||||
const userAns = [...selectedOptions].sort().join('');
|
||||
const isCorrect = isSubmitted && userAns === currentQuestion.answer;
|
||||
@@ -146,7 +161,7 @@ export default function App() {
|
||||
<div className="header">
|
||||
<span className="title-tag">{currentQuestion.type}</span>
|
||||
<span style={{color: '#94a3b8', fontSize: '0.875rem'}}>
|
||||
Weight: {weights[currentQuestion.id] || 1}
|
||||
全局权重: {weights[currentQuestion.id] || 1} {isSyncing && '(同步中...)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={i}
|
||||
<div
|
||||
key={i}
|
||||
className={className}
|
||||
onClick={() => handleOptionClick(opt)}
|
||||
>
|
||||
@@ -187,7 +202,7 @@ export default function App() {
|
||||
{!isSubmitted ? (
|
||||
<div className="action-container">
|
||||
{currentQuestion.type !== '单选' && (
|
||||
<button
|
||||
<button
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={selectedOptions.length === 0}
|
||||
>
|
||||
@@ -221,9 +236,10 @@ export default function App() {
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user