import { useState, useEffect, useMemo, useRef, type TouchEventHandler } from 'react'; import { Card, Button, Radio, Checkbox, Input, message, Progress, Modal } from 'antd'; import { useNavigate, useLocation } from 'react-router-dom'; import { useUser, useQuiz } from '../contexts'; import { quizAPI } from '../services/api'; import { questionTypeMap } from '../utils/validation'; import { detectHorizontalSwipe } from '../utils/swipe'; import { UserLayout } from '../layouts/UserLayout'; const { TextArea } = Input; interface Question { id: string; content: string; type: 'single' | 'multiple' | 'judgment' | 'text'; options?: string[]; answer: string | string[]; analysis?: string; score: number; createdAt: string; category?: string; } interface LocationState { questions?: Question[]; totalScore?: number; timeLimit?: number; subjectId?: string; taskId?: string; } const QuizPage = () => { const navigate = useNavigate(); const location = useLocation(); const { user } = useUser(); const { questions, setQuestions, currentQuestionIndex, setCurrentQuestionIndex, answers, setAnswer, setAnswers, clearQuiz } = useQuiz(); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [answerSheetOpen, setAnswerSheetOpen] = useState(false); const [timeLeft, setTimeLeft] = useState(null); const [timeLimit, setTimeLimit] = useState(null); const [subjectId, setSubjectId] = useState(''); const [taskId, setTaskId] = useState(''); const lastTickSavedAtMsRef = useRef(0); const saveDebounceTimerRef = useRef(null); const [navDirection, setNavDirection] = useState<'next' | 'prev'>('next'); const [enterAnimationOn, setEnterAnimationOn] = useState(false); const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); const questionHeadingRef = useRef(null); useEffect(() => { const el = questionHeadingRef.current; if (!el) return; el.focus(); }, [currentQuestionIndex]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; const active = document.activeElement as HTMLElement | null; const activeTag = (active?.tagName || '').toLowerCase(); const inputType = activeTag === 'input' ? ((active as HTMLInputElement | null)?.type || '').toLowerCase() : ''; const isTextualInputType = activeTag === 'input' ? inputType === '' || inputType === 'text' || inputType === 'search' || inputType === 'tel' || inputType === 'url' || inputType === 'email' || inputType === 'password' || inputType === 'number' : false; const inTextInput = activeTag === 'textarea' || active?.getAttribute('role') === 'textbox' || active?.classList.contains('ant-input') || active?.closest?.('.ant-input') || isTextualInputType || active?.isContentEditable; if (inTextInput) return; if (event.key === 'ArrowLeft') { event.preventDefault(); handlePrevious(); return; } if (event.key === 'ArrowRight') { event.preventDefault(); handleNext(); return; } if (event.key === 'Escape') { if (answerSheetOpen) { event.preventDefault(); setAnswerSheetOpen(false); } } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [answerSheetOpen, currentQuestionIndex, questions.length]); useEffect(() => { if (!user) { message.warning('请先填写个人信息'); navigate('/'); return; } const state = location.state as LocationState; clearQuiz(); if (state?.questions) { const nextSubjectId = state.subjectId || ''; const nextTaskId = state.taskId || ''; const nextTimeLimit = state.timeLimit || 60; setQuestions(state.questions); setAnswers({}); setCurrentQuestionIndex(0); setTimeLimit(nextTimeLimit); setTimeLeft(nextTimeLimit * 60); setSubjectId(nextSubjectId); setTaskId(nextTaskId); const progressKey = buildProgressKey(user.id, nextSubjectId, nextTaskId); setActiveProgress(user.id, progressKey); saveProgress(progressKey, { questions: state.questions, answers: {}, currentQuestionIndex: 0, timeLeftSeconds: nextTimeLimit * 60, timeLimitMinutes: nextTimeLimit, subjectId: nextSubjectId, taskId: nextTaskId, }); return; } const restored = restoreActiveProgress(user.id); if (restored) { setQuestions(restored.questions); setAnswers(restored.answers); setCurrentQuestionIndex(restored.currentQuestionIndex); setTimeLimit(restored.timeLimitMinutes); setTimeLeft(restored.timeLeftSeconds); setSubjectId(restored.subjectId); setTaskId(restored.taskId); return; } generateQuiz(); }, [user, navigate, location]); // 倒计时逻辑 useEffect(() => { if (timeLeft === null || timeLeft <= 0) return; const timer = setInterval(() => { setTimeLeft(prev => { if (prev === null || prev <= 1) { clearInterval(timer); handleTimeUp(); return 0; } return prev - 1; }); }, 1000); return () => clearInterval(timer); }, [timeLeft]); const generateQuiz = async () => { try { setLoading(true); const response = await quizAPI.generateQuiz(user!.id); setQuestions(response.data.questions); setCurrentQuestionIndex(0); setAnswers({}); setTimeLimit(60); setTimeLeft(60 * 60); setSubjectId(''); setTaskId(''); const progressKey = buildProgressKey(user!.id, '', ''); setActiveProgress(user!.id, progressKey); saveProgress(progressKey, { questions: response.data.questions, answers: {}, currentQuestionIndex: 0, timeLeftSeconds: 60 * 60, timeLimitMinutes: 60, subjectId: '', taskId: '', }); } catch (error: any) { message.error(error.message || '生成试卷失败'); } finally { setLoading(false); } }; const handleTimeUp = () => { message.warning('考试时间已到,将自动提交答案'); setTimeout(() => { handleSubmit(true); }, 1000); }; const formatTime = (seconds: number) => { const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; }; const getTagColor = (type: string) => { switch (type) { case 'single': return 'bg-blue-100 text-blue-800'; case 'multiple': return 'bg-purple-100 text-purple-800'; case 'judgment': return 'bg-orange-100 text-orange-800'; case 'text': return 'bg-green-100 text-green-800'; default: return 'bg-gray-100 text-gray-800'; } }; const handleAnswerChange = (questionId: string, value: string | string[]) => { setAnswer(questionId, value); }; useEffect(() => { if (!user) return; if (!questions.length) return; if (timeLeft === null) return; if (saveDebounceTimerRef.current !== null) { window.clearTimeout(saveDebounceTimerRef.current); } saveDebounceTimerRef.current = window.setTimeout(() => { const progressKey = getActiveProgressKey(user.id); if (!progressKey) return; saveProgress(progressKey, { questions, answers, currentQuestionIndex, timeLeftSeconds: timeLeft, timeLimitMinutes: timeLimit || 60, subjectId, taskId, }); saveDebounceTimerRef.current = null; }, 300); return () => { if (saveDebounceTimerRef.current !== null) { window.clearTimeout(saveDebounceTimerRef.current); saveDebounceTimerRef.current = null; } }; }, [user, questions, answers, currentQuestionIndex, subjectId, taskId, timeLeft, timeLimit]); useEffect(() => { if (!user) return; if (!questions.length) return; if (timeLeft === null) return; const now = Date.now(); if (now - lastTickSavedAtMsRef.current < 5000) return; lastTickSavedAtMsRef.current = now; const progressKey = getActiveProgressKey(user.id); if (!progressKey) return; saveProgress(progressKey, { questions, answers, currentQuestionIndex, timeLeftSeconds: timeLeft, timeLimitMinutes: timeLimit || 60, subjectId, taskId, }); }, [user, questions.length, timeLeft]); const handleNext = () => { if (currentQuestionIndex < questions.length - 1) { setNavDirection('next'); setCurrentQuestionIndex(currentQuestionIndex + 1); } }; const handlePrevious = () => { if (currentQuestionIndex > 0) { setNavDirection('prev'); setCurrentQuestionIndex(currentQuestionIndex - 1); } }; const handleJumpTo = (index: number) => { if (index < 0 || index >= questions.length) return; if (index === currentQuestionIndex) return; setNavDirection(index > currentQuestionIndex ? 'next' : 'prev'); setCurrentQuestionIndex(index); }; const handleSubmit = async (forceSubmit = false) => { try { setSubmitting(true); if (!forceSubmit) { // 检查是否所有题目都已回答 const unansweredQuestions = questions.filter(q => !answers[q.id]); if (unansweredQuestions.length > 0) { message.warning(`还有 ${unansweredQuestions.length} 道题未作答`); return; } } // 准备答案数据 const answersData = questions.map(question => { const isCorrect = checkAnswer(question, answers[question.id]); return { questionId: question.id, userAnswer: answers[question.id], score: isCorrect ? question.score : 0, isCorrect }; }); const response = await quizAPI.submitQuiz({ userId: user!.id, subjectId: subjectId || undefined, taskId: taskId || undefined, answers: answersData }); message.success('答题提交成功!'); clearActiveProgress(user!.id); navigate(`/result/${response.data.recordId}`); } catch (error: any) { message.error(error.message || '提交失败'); } finally { setSubmitting(false); } }; const handleGiveUp = () => { if (!user) return; Modal.confirm({ title: '确认弃考?', content: '弃考将清空本次答题进度与计时信息,且不计入考试次数。', okText: '确认弃考', cancelText: '继续答题', okButtonProps: { danger: true }, onOk: () => { clearActiveProgress(user.id); clearQuiz(); navigate('/tasks'); }, }); }; const checkAnswer = (question: Question, userAnswer: string | string[]): boolean => { if (!userAnswer) return false; if (question.type === 'multiple') { const correctAnswers = Array.isArray(question.answer) ? question.answer : [question.answer]; const userAnswers = Array.isArray(userAnswer) ? userAnswer : [userAnswer]; return correctAnswers.length === userAnswers.length && correctAnswers.every(answer => userAnswers.includes(answer)); } else { return userAnswer === question.answer; } }; const renderQuestion = (question: Question) => { const currentAnswer = answers[question.id]; switch (question.type) { case 'single': return ( handleAnswerChange(question.id, e.target.value)} className="w-full" > {question.options?.map((option, index) => ( {String.fromCharCode(65 + index)}. {option} ))} ); case 'multiple': return ( handleAnswerChange(question.id, checkedValues)} className="w-full" > {question.options?.map((option, index) => ( {String.fromCharCode(65 + index)}. {option} ))} ); case 'judgment': return ( handleAnswerChange(question.id, e.target.value)} className="w-full" > 正确 错误 ); case 'text': return (