import { useState, useEffect, useMemo, useRef, type TouchEventHandler } from 'react'; import { Card, Button, Modal, App } 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'; import { QuizHeader } from './quiz/components/QuizHeader'; import { QuizProgress } from './quiz/components/QuizProgress'; import { OptionList } from './quiz/components/OptionList'; import { QuizFooter } from './quiz/components/QuizFooter'; interface Question { id: string; content: string; type: 'single' | 'multiple' | 'judgment' | 'text'; options?: string[]; answer: string | string[]; analysis?: string; score: number; category: string; } interface LocationState { questions?: Question[]; totalScore?: number; timeLimit?: number; subjectId?: string; taskId?: string; taskName?: string; } const QuizPage = () => { const navigate = useNavigate(); const { message } = App.useApp(); 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 [taskName, setTaskName] = 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 nextTaskName = state.taskName || ''; const nextTimeLimit = state.timeLimit || 60; setQuestions(state.questions); setAnswers({}); setCurrentQuestionIndex(0); setTimeLimit(nextTimeLimit); setTimeLeft(nextTimeLimit * 60); setSubjectId(nextSubjectId); setTaskId(nextTaskId); setTaskName(nextTaskName); 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 getTagColor = (type: string) => { switch (type) { case 'single': return 'bg-orange-100 text-orange-800 border-orange-200'; case 'multiple': return 'bg-blue-100 text-blue-800 border-blue-200'; case 'judgment': return 'bg-purple-100 text-purple-800 border-purple-200'; case 'text': return 'bg-green-100 text-green-800 border-green-200'; default: return 'bg-gray-100 text-gray-800 border-gray-200'; } }; 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 handleJumpToFirstUnanswered = () => { const firstUnansweredIndex = questions.findIndex(q => Number(q.score) > 0 && !answers[q.id]); if (firstUnansweredIndex !== -1) { handleJumpTo(firstUnansweredIndex); } setAnswerSheetOpen(false); }; const handleSubmit = async (forceSubmit = false) => { try { setSubmitting(true); if (!forceSubmit) { const unansweredQuestions = questions.filter(q => Number(q.score) > 0 && !answers[q.id]); if (unansweredQuestions.length > 0) { message.warning(`还有 ${unansweredQuestions.length} 道题未作答`); return; } } const answersData = questions.map(question => { const userAnswer = (answers[question.id] ?? '') as any; const isCorrect = checkAnswer(question, userAnswer); return { questionId: question.id, userAnswer, score: isCorrect ? question.score : 0, isCorrect }; }); const response = await quizAPI.submitQuiz({ userId: user!.id, subjectId: subjectId || undefined, taskId: taskId || undefined, answers: answersData }) as any; const payload = response?.data ?? response; const recordId = payload?.recordId; if (!recordId) { throw new Error('提交成功但未返回记录ID'); } message.success('答题提交成功!'); clearActiveProgress(user!.id); navigate(`/result/${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 (Number(question.score) === 0) return true; 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 answeredCount = useMemo(() => { return questions.reduce((count, q) => (answers[q.id] || Number(q.score) === 0 ? count + 1 : count), 0); }, [questions, answers]); useEffect(() => { if (!questions.length) return; setEnterAnimationOn(false); const rafId = requestAnimationFrame(() => setEnterAnimationOn(true)); return () => cancelAnimationFrame(rafId); }, [currentQuestionIndex, questions.length]); if (loading || !questions.length) { return (

正在生成试卷...

); } const currentQuestion = questions[currentQuestionIndex]; const handleTouchStart: TouchEventHandler = (e) => { if (e.touches.length !== 1) return; const target = e.target as HTMLElement | null; if (target?.closest('textarea,input,[role="textbox"],.ant-input,.ant-checkbox-wrapper,.ant-radio-wrapper')) return; const touch = e.touches[0]; touchStartRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() }; }; const handleTouchEnd: TouchEventHandler = (e) => { const start = touchStartRef.current; touchStartRef.current = null; if (!start) return; const touch = e.changedTouches[0]; if (!touch) return; const direction = detectHorizontalSwipe({ startX: start.x, startY: start.y, endX: touch.clientX, endY: touch.clientY, elapsedMs: Date.now() - start.time, }); if (direction === 'left') { handleNext(); return; } if (direction === 'right') { handlePrevious(); } }; return (
{questionTypeMap[currentQuestion.type]} {currentQuestion.category && ( {currentQuestion.category} )}

{currentQuestion.content}

handleAnswerChange(currentQuestion.id, val)} />
handleSubmit()} onOpenSheet={() => setAnswerSheetOpen(true)} answeredCount={answeredCount} /> setAnswerSheetOpen(false)} footer={null} centered width={340} destroyOnClose >
已答 {answeredCount} / {questions.length}
{questions.map((q, idx) => { const isCurrent = idx === currentQuestionIndex; const isAnswered = !!answers[q.id]; let className = 'h-9 w-9 rounded-full flex items-center justify-center text-xs font-medium border transition-colors '; if (isCurrent) { className += 'border-[#00897B] bg-[#E0F2F1] text-[#00695C]'; } else if (isAnswered) { className += 'border-[#00897B] bg-[#00897B] text-white'; } else { className += 'border-gray-200 text-gray-600 bg-white hover:border-[#00897B]'; } return ( ); })}
); }; export default QuizPage; const QUIZ_PROGRESS_ACTIVE_PREFIX = 'quiz_progress_active_v1:'; const QUIZ_PROGRESS_PREFIX = 'quiz_progress_v1:'; type QuizProgressV1 = { questions: Question[]; answers: Record; currentQuestionIndex: number; timeLeftSeconds: number; timeLimitMinutes: number; subjectId: string; taskId: string; savedAt: string; }; const buildProgressKey = (userId: string, subjectId: string, taskId: string) => { const scope = taskId ? `task:${taskId}` : `subject:${subjectId || 'none'}`; return `${QUIZ_PROGRESS_PREFIX}${userId}:${scope}`; }; const setActiveProgress = (userId: string, progressKey: string) => { localStorage.setItem(`${QUIZ_PROGRESS_ACTIVE_PREFIX}${userId}`, progressKey); }; const removeActiveProgress = (userId: string) => { localStorage.removeItem(`${QUIZ_PROGRESS_ACTIVE_PREFIX}${userId}`); }; const getActiveProgressKey = (userId: string) => { return localStorage.getItem(`${QUIZ_PROGRESS_ACTIVE_PREFIX}${userId}`) || ''; }; const saveProgress = (progressKey: string, input: Omit) => { const payload: QuizProgressV1 = { ...input, savedAt: new Date().toISOString(), }; localStorage.setItem(progressKey, JSON.stringify(payload)); }; const restoreActiveProgress = (userId: string): QuizProgressV1 | null => { const progressKey = getActiveProgressKey(userId); if (!progressKey) return null; const raw = localStorage.getItem(progressKey); if (!raw) return null; try { const parsed = JSON.parse(raw) as QuizProgressV1; if (!parsed || !Array.isArray(parsed.questions)) return null; if (!parsed.answers || typeof parsed.answers !== 'object') return null; if (typeof parsed.currentQuestionIndex !== 'number') return null; if (typeof parsed.timeLeftSeconds !== 'number') return null; if (typeof parsed.timeLimitMinutes !== 'number') return null; if (typeof parsed.subjectId !== 'string') return null; if (typeof parsed.taskId !== 'string') return null; return parsed; } catch { return null; } }; const clearActiveProgress = (userId: string) => { const progressKey = getActiveProgressKey(userId); if (progressKey) { localStorage.removeItem(progressKey); } removeActiveProgress(userId); };