Files
Web_BLV_OA_Exam_Prod/src/pages/QuizPage.tsx

635 lines
20 KiB
TypeScript
Raw Normal View History

import { useState, useEffect, useMemo, useRef, type TouchEventHandler } from 'react';
2025-12-29 00:17:39 +08:00
import { Card, Button, Modal, message } 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';
2025-12-29 00:17:39 +08:00
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;
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<number | null>(null);
const [timeLimit, setTimeLimit] = useState<number | null>(null);
const [subjectId, setSubjectId] = useState<string>('');
const [taskId, setTaskId] = useState<string>('');
const lastTickSavedAtMsRef = useRef<number>(0);
const saveDebounceTimerRef = useRef<number | null>(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<HTMLHeadingElement | null>(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 getTagColor = (type: string) => {
switch (type) {
2025-12-29 00:17:39 +08:00
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 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 answeredCount = useMemo(() => {
return questions.reduce((count, q) => (answers[q.id] ? 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 (
<UserLayout>
<div className="flex items-center justify-center h-full min-h-[500px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-mars-600 mx-auto mb-4"></div>
<p className="text-gray-700">...</p>
</div>
</div>
</UserLayout>
);
}
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 (
2025-12-29 00:17:39 +08:00
<div className="min-h-screen bg-gray-50 flex flex-col">
<QuizHeader
current={currentQuestionIndex + 1}
total={questions.length}
timeLeft={timeLeft}
onGiveUp={handleGiveUp}
/>
<QuizProgress
current={currentQuestionIndex + 1}
total={questions.length}
/>
<div className="flex-1 overflow-y-auto pb-24 safe-area-bottom">
<div className="max-w-md mx-auto px-4">
<div
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
2025-12-29 00:17:39 +08:00
className={`
transition-all duration-300 ease-out
${enterAnimationOn
? 'opacity-100 translate-x-0'
: navDirection === 'next'
? 'opacity-0 translate-x-4'
: 'opacity-0 -translate-x-4'
}
`}
>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 min-h-[350px]">
<div className="mb-4">
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${getTagColor(currentQuestion.type)}`}>
2025-12-29 00:17:39 +08:00
{questionTypeMap[currentQuestion.type]}
</span>
2025-12-29 00:17:39 +08:00
{currentQuestion.category && (
<span className="ml-2 inline-block px-1.5 py-0.5 bg-gray-50 text-gray-600 text-xs rounded border border-gray-100">
2025-12-29 00:17:39 +08:00
{currentQuestion.category}
</span>
)}
</div>
2025-12-29 00:17:39 +08:00
<h2
ref={questionHeadingRef}
tabIndex={-1}
className="text-base font-medium text-gray-900 leading-relaxed mb-6 outline-none"
2025-12-29 00:17:39 +08:00
>
{currentQuestion.content}
</h2>
<OptionList
type={currentQuestion.type}
options={currentQuestion.options}
value={answers[currentQuestion.id]}
onChange={(val) => handleAnswerChange(currentQuestion.id, val)}
/>
</div>
</div>
</div>
2025-12-29 00:17:39 +08:00
</div>
2025-12-29 00:17:39 +08:00
<QuizFooter
current={currentQuestionIndex}
total={questions.length}
onPrev={handlePrevious}
onNext={handleNext}
onSubmit={() => handleSubmit()}
onOpenSheet={() => setAnswerSheetOpen(true)}
answeredCount={answeredCount}
/>
<Modal
title="答题卡"
open={answerSheetOpen}
onCancel={() => setAnswerSheetOpen(false)}
footer={null}
centered
width={340}
2025-12-29 00:17:39 +08:00
destroyOnClose
>
<div className="flex items-center justify-between mb-3">
<div className="text-xs text-gray-700">
2025-12-29 00:17:39 +08:00
<span className="text-[#00897B] font-medium">{answeredCount}</span> / {questions.length}
</div>
<Button
type="primary"
onClick={() => setAnswerSheetOpen(false)}
className="bg-[#00897B] hover:bg-[#00796B] text-xs h-7 px-3"
>
2025-12-29 00:17:39 +08:00
</Button>
</div>
<div className="grid grid-cols-5 gap-2">
2025-12-29 00:17:39 +08:00
{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 ';
2025-12-29 00:17:39 +08:00
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 (
<button
key={q.id}
type="button"
className={className}
onClick={() => {
setAnswerSheetOpen(false);
handleJumpTo(idx);
}}
aria-label={`跳转到第 ${idx + 1}`}
>
{idx + 1}
</button>
);
})}
</div>
</Modal>
</div>
);
};
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<string, string | string[]>;
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<QuizProgressV1, 'savedAt'>) => {
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);
};