796 lines
27 KiB
TypeScript
796 lines
27 KiB
TypeScript
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 type { QuizQuestion } 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';
|
||
|
||
type Question = QuizQuestion;
|
||
|
||
interface LocationState {
|
||
questions?: QuizQuestion[];
|
||
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<number | null>(null);
|
||
const [timeLimit, setTimeLimit] = useState<number | null>(null);
|
||
const [subjectId, setSubjectId] = useState<string>('');
|
||
const [taskId, setTaskId] = useState<string>('');
|
||
const [taskName, setTaskName] = 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 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') {
|
||
// 处理正确答案
|
||
let correctAnswers: string[] = [];
|
||
if (Array.isArray(question.answer)) {
|
||
correctAnswers = question.answer;
|
||
} else if (typeof question.answer === 'string') {
|
||
// 尝试按逗号分割(处理如"同创造,共分享,齐飞扬"格式)
|
||
correctAnswers = question.answer.split(',').map(item => item.trim());
|
||
} else {
|
||
correctAnswers = [String(question.answer)];
|
||
}
|
||
|
||
// 处理用户答案
|
||
let userAnswers: string[] = [];
|
||
if (Array.isArray(userAnswer)) {
|
||
userAnswers = userAnswer;
|
||
} else if (typeof userAnswer === 'string') {
|
||
// 尝试按逗号分割(处理如"同创造,共分享,齐飞扬"格式)
|
||
userAnswers = userAnswer.split(',').map(item => item.trim());
|
||
} else {
|
||
userAnswers = [String(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 (
|
||
<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 (
|
||
<div className="min-h-screen bg-gray-50 flex flex-col font-sans">
|
||
<QuizHeader
|
||
current={currentQuestionIndex + 1}
|
||
total={questions.length}
|
||
timeLeft={timeLeft}
|
||
onGiveUp={handleGiveUp}
|
||
taskName={taskName}
|
||
/>
|
||
|
||
{/* Mobile Progress Bar */}
|
||
<div className="lg:hidden">
|
||
<QuizProgress
|
||
current={currentQuestionIndex + 1}
|
||
total={questions.length}
|
||
/>
|
||
</div>
|
||
|
||
<main className="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-8">
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 h-full items-start">
|
||
{/* Left Column: Question Area */}
|
||
<div className="lg:col-span-9 flex flex-col h-full">
|
||
<div
|
||
onTouchStart={handleTouchStart}
|
||
onTouchEnd={handleTouchEnd}
|
||
className={`
|
||
bg-white rounded-2xl shadow-sm border border-gray-200 p-6 sm:p-8 lg:p-10 flex-1 flex flex-col min-h-[60vh] transition-all duration-300 ease-out
|
||
${enterAnimationOn
|
||
? 'opacity-100 translate-x-0'
|
||
: navDirection === 'next'
|
||
? 'opacity-0 translate-x-4'
|
||
: 'opacity-0 -translate-x-4'
|
||
}
|
||
`}
|
||
>
|
||
{/* Question Meta */}
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<span className="text-3xl font-bold text-gray-200 select-none">
|
||
{(currentQuestionIndex + 1).toString().padStart(2, '0')}
|
||
</span>
|
||
<span className={`px-3 py-1 rounded-full text-sm font-medium border ${getTagColor(currentQuestion.type)}`}>
|
||
{questionTypeMap[currentQuestion.type]}
|
||
</span>
|
||
<span className="text-gray-400 text-sm">
|
||
/ 共 {questions.length} 题
|
||
</span>
|
||
{currentQuestion.category && (
|
||
<span className="ml-auto px-2 py-1 bg-gray-50 text-gray-500 text-xs rounded border border-gray-100">
|
||
{currentQuestion.category}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Question Content */}
|
||
<h2
|
||
ref={questionHeadingRef}
|
||
tabIndex={-1}
|
||
className="text-xl sm:text-2xl font-medium text-gray-900 leading-relaxed mb-8 outline-none"
|
||
>
|
||
{currentQuestion.content}
|
||
</h2>
|
||
|
||
{/* Options */}
|
||
<div className="flex-1">
|
||
<OptionList
|
||
type={currentQuestion.type}
|
||
options={currentQuestion.options}
|
||
value={answers[currentQuestion.id]}
|
||
onChange={(val) => handleAnswerChange(currentQuestion.id, val)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Desktop Navigation Buttons */}
|
||
<div className="hidden lg:flex items-center justify-between mt-12 pt-8 border-t border-gray-100">
|
||
<Button
|
||
size="large"
|
||
onClick={handlePrevious}
|
||
disabled={currentQuestionIndex === 0}
|
||
className="px-8 h-12 text-base rounded-xl"
|
||
>
|
||
上一题
|
||
</Button>
|
||
|
||
{currentQuestionIndex === questions.length - 1 ? (
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
onClick={() => handleSubmit()}
|
||
loading={submitting}
|
||
className="px-8 h-12 text-base rounded-xl bg-[#008C8C] hover:bg-[#00796B]"
|
||
>
|
||
提交试卷
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
onClick={handleNext}
|
||
className="px-8 h-12 text-base rounded-xl bg-[#008C8C] hover:bg-[#00796B]"
|
||
>
|
||
下一题
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Column: Sidebar (Desktop Only) */}
|
||
<div className="hidden lg:flex lg:col-span-3 flex-col gap-6 sticky top-24">
|
||
{/* User Info Card */}
|
||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
|
||
<div className="flex items-center gap-4 mb-4">
|
||
<div className="w-12 h-12 rounded-full bg-[#E0F7FA] flex items-center justify-center text-[#008C8C] text-xl font-bold">
|
||
{user?.name?.[0] || 'U'}
|
||
</div>
|
||
<div>
|
||
<div className="font-bold text-gray-900">{user?.name}</div>
|
||
<div className="text-xs text-gray-500">考生</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-between items-center text-sm text-gray-600 bg-gray-50 px-4 py-2 rounded-lg">
|
||
<span>已完成</span>
|
||
<span className="font-bold text-[#008C8C]">{answeredCount} / {questions.length}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Answer Sheet Card */}
|
||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 flex-1">
|
||
<h3 className="font-bold text-gray-900 mb-4">答题卡</h3>
|
||
<div className="grid grid-cols-5 gap-2 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
||
{questions.map((q, idx) => {
|
||
const isCurrent = idx === currentQuestionIndex;
|
||
const isAnswered = !!answers[q.id];
|
||
let className = 'h-10 w-10 rounded-lg flex items-center justify-center text-sm font-medium border transition-all duration-200 ';
|
||
|
||
if (isCurrent) {
|
||
className += 'border-[#008C8C] bg-[#E0F7FA] text-[#006064] ring-2 ring-[#008C8C] ring-offset-2';
|
||
} else if (isAnswered) {
|
||
className += 'border-[#008C8C] bg-[#008C8C] text-white';
|
||
} else {
|
||
className += 'border-gray-200 text-gray-600 bg-gray-50 hover:border-[#008C8C] hover:bg-white';
|
||
}
|
||
|
||
return (
|
||
<button
|
||
key={q.id}
|
||
onClick={() => handleJumpTo(idx)}
|
||
className={className}
|
||
>
|
||
{idx + 1}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="mt-6 pt-6 border-t border-gray-100 grid grid-cols-2 gap-3 text-xs text-gray-500">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded bg-[#008C8C]"></div>
|
||
<span>已作答</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded bg-[#E0F7FA] border border-[#008C8C]"></div>
|
||
<span>当前</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded bg-gray-50 border border-gray-200"></div>
|
||
<span>未作答</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
type="primary"
|
||
block
|
||
size="large"
|
||
className="mt-6 bg-[#008C8C] hover:bg-[#00796B] h-12 rounded-xl font-medium"
|
||
onClick={() => handleSubmit()}
|
||
>
|
||
交卷
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Mobile Footer */}
|
||
<div className="lg:hidden">
|
||
<QuizFooter
|
||
current={currentQuestionIndex}
|
||
total={questions.length}
|
||
onPrev={handlePrevious}
|
||
onNext={handleNext}
|
||
onSubmit={() => handleSubmit()}
|
||
onOpenSheet={() => setAnswerSheetOpen(true)}
|
||
answeredCount={answeredCount}
|
||
/>
|
||
</div>
|
||
|
||
{/* Mobile Answer Sheet Modal */}
|
||
<Modal
|
||
title="答题卡"
|
||
open={answerSheetOpen}
|
||
onCancel={() => setAnswerSheetOpen(false)}
|
||
footer={null}
|
||
centered
|
||
width={340}
|
||
destroyOnClose
|
||
className="mobile-sheet-modal"
|
||
>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="text-sm text-gray-700">
|
||
进度:<span className="text-[#008C8C] font-bold">{answeredCount}</span> / {questions.length}
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleJumpToFirstUnanswered}
|
||
className="bg-[#008C8C] hover:bg-[#00796B] text-xs h-8 px-4 rounded-lg"
|
||
>
|
||
跳转未答
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-5 gap-3">
|
||
{questions.map((q, idx) => {
|
||
const isCurrent = idx === currentQuestionIndex;
|
||
const isAnswered = !!answers[q.id];
|
||
|
||
let className = 'h-10 w-10 rounded-lg flex items-center justify-center text-sm font-medium border transition-colors ';
|
||
if (isCurrent) {
|
||
className += 'border-[#008C8C] bg-[#E0F7FA] text-[#006064]';
|
||
} else if (isAnswered) {
|
||
className += 'border-[#008C8C] bg-[#008C8C] text-white';
|
||
} else {
|
||
className += 'border-gray-200 text-gray-600 bg-white hover:border-[#008C8C]';
|
||
}
|
||
|
||
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);
|
||
};
|