246 lines
7.6 KiB
TypeScript
246 lines
7.6 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import questionsData from './questions.json';
|
|
import './index.css';
|
|
|
|
interface Question {
|
|
id: number;
|
|
source_id?: string;
|
|
source_file?: string;
|
|
page_number?: number;
|
|
title: string;
|
|
options: string[];
|
|
type: string;
|
|
answer: string;
|
|
}
|
|
|
|
export default function App() {
|
|
const [questions] = useState<Question[]>(questionsData);
|
|
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
|
|
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
const [weights, setWeights] = useState<Record<number, number>>({});
|
|
const [history, setHistory] = useState<boolean[]>([]);
|
|
const [totalAnswered, setTotalAnswered] = useState(0);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
|
|
// 随机挑选一道题目(基于权重轮盘赌算法)
|
|
const pickRandomQuestion = useCallback((latestWeights?: Record<number, number>) => {
|
|
if (questions.length === 0) return;
|
|
|
|
const currentWeights = latestWeights || weights;
|
|
let totalWeight = 0;
|
|
questions.forEach(q => {
|
|
totalWeight += (currentWeights[q.id] || 1);
|
|
});
|
|
|
|
let randomNum = Math.random() * totalWeight;
|
|
let selectedQ = questions[0];
|
|
|
|
for (const q of questions) {
|
|
randomNum -= (currentWeights[q.id] || 1);
|
|
if (randomNum <= 0) {
|
|
selectedQ = q;
|
|
break;
|
|
}
|
|
}
|
|
|
|
setCurrentQuestion(selectedQ);
|
|
setSelectedOptions([]);
|
|
setIsSubmitted(false);
|
|
}, [questions, weights]);
|
|
|
|
// 从服务端拉取全局统计与权重数据
|
|
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);
|
|
}
|
|
|
|
// 兜底本地逻辑
|
|
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;
|
|
|
|
const letter = optionLabel.charAt(0);
|
|
|
|
if (currentQuestion?.type === '单选') {
|
|
setSelectedOptions([letter]);
|
|
handleSubmit([letter]);
|
|
} else {
|
|
setSelectedOptions(prev =>
|
|
prev.includes(letter)
|
|
? prev.filter(l => l !== letter)
|
|
: [...prev, letter]
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (autoSubmitOptions?: string[]) => {
|
|
const currentSelection = Array.isArray(autoSubmitOptions) ? autoSubmitOptions : selectedOptions;
|
|
if (currentSelection.length === 0 || !currentQuestion || isSubmitted) return;
|
|
|
|
const userAns = [...currentSelection].sort().join('');
|
|
const isCorrect = userAns === currentQuestion.answer;
|
|
|
|
setIsSubmitted(true);
|
|
|
|
// 本地乐观更新
|
|
const localNewHistory = [...history, isCorrect].slice(-100);
|
|
setHistory(localNewHistory);
|
|
setTotalAnswered(prev => prev + 1);
|
|
|
|
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);
|
|
}
|
|
|
|
if (isCorrect) {
|
|
setTimeout(() => {
|
|
pickRandomQuestion(nextWeights);
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
if (!currentQuestion) return <div className="glass-card">加载题库中...</div>;
|
|
|
|
const userAns = [...selectedOptions].sort().join('');
|
|
const isCorrect = isSubmitted && userAns === currentQuestion.answer;
|
|
|
|
return (
|
|
<div className="glass-card">
|
|
<div className="header">
|
|
<span className="title-tag">{currentQuestion.type}</span>
|
|
<span style={{color: '#94a3b8', fontSize: '0.875rem'}}>
|
|
全局权重: {weights[currentQuestion.id] || 1} {isSyncing && '(同步中...)'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="question-title">
|
|
{currentQuestion.title}
|
|
</div>
|
|
|
|
<div className="options-container">
|
|
{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);
|
|
if (isCorrectOption) {
|
|
className += ' correct';
|
|
} else if (isSelected && !isCorrectOption) {
|
|
className += ' incorrect';
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={className}
|
|
onClick={() => handleOptionClick(opt)}
|
|
>
|
|
{opt}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{!isSubmitted ? (
|
|
<div className="action-container">
|
|
{currentQuestion.type !== '单选' && (
|
|
<button
|
|
onClick={() => handleSubmit()}
|
|
disabled={selectedOptions.length === 0}
|
|
>
|
|
提交答案
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className={`feedback ${isCorrect ? 'correct' : 'incorrect'}`}>
|
|
<h3 style={{marginTop: 0, marginBottom: '0.5rem'}}>
|
|
{isCorrect ? '✅ 回答正确!' : '❌ 回答错误'}
|
|
</h3>
|
|
<p style={{margin: 0}}>
|
|
正确答案是:<strong>{currentQuestion.answer}</strong>
|
|
</p>
|
|
{!isCorrect && (
|
|
<div style={{marginTop: '0.5rem', fontSize: '0.9rem', color: '#64748b'}}>
|
|
<div>本题出处:题库第 {currentQuestion.source_id || currentQuestion.id} 题</div>
|
|
<div>来源文件:{currentQuestion.source_file || '未知'}</div>
|
|
<div>PDF页码:第 {currentQuestion.page_number || '?'} 页</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="action-container">
|
|
<button onClick={() => pickRandomQuestion()}>
|
|
{isCorrect ? '下一题 (1s后自动跳转)...' : '下一题'}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="stats">
|
|
<span>全局已刷题:{totalAnswered} 道</span>
|
|
<span>近100题正确率:{history.length > 0 ? Math.round((history.filter(Boolean).length / history.length) * 100) : 0}%</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|