2025-12-28 23:33:14 +08:00
|
|
|
|
import { useState, useEffect, useMemo, useRef, type TouchEventHandler } from 'react';
|
2025-12-25 21:54:52 +08:00
|
|
|
|
import { Card, Button, Radio, Checkbox, Input, message, Progress, Modal } from 'antd';
|
2025-12-18 19:07:21 +08:00
|
|
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
|
|
|
|
import { useUser, useQuiz } from '../contexts';
|
|
|
|
|
|
import { quizAPI } from '../services/api';
|
|
|
|
|
|
import { questionTypeMap } from '../utils/validation';
|
2025-12-28 23:33:14 +08:00
|
|
|
|
import { detectHorizontalSwipe } from '../utils/swipe';
|
2025-12-19 16:02:38 +08:00
|
|
|
|
import { UserLayout } from '../layouts/UserLayout';
|
2025-12-18 19:07:21 +08:00
|
|
|
|
|
|
|
|
|
|
const { TextArea } = Input;
|
|
|
|
|
|
|
|
|
|
|
|
interface Question {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
content: string;
|
|
|
|
|
|
type: 'single' | 'multiple' | 'judgment' | 'text';
|
|
|
|
|
|
options?: string[];
|
|
|
|
|
|
answer: string | string[];
|
2025-12-25 00:15:14 +08:00
|
|
|
|
analysis?: string;
|
2025-12-18 19:07:21 +08:00
|
|
|
|
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();
|
2025-12-25 21:54:52 +08:00
|
|
|
|
const { questions, setQuestions, currentQuestionIndex, setCurrentQuestionIndex, answers, setAnswer, setAnswers, clearQuiz } = useQuiz();
|
2025-12-18 19:07:21 +08:00
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
2025-12-28 23:33:14 +08:00
|
|
|
|
const [answerSheetOpen, setAnswerSheetOpen] = useState(false);
|
2025-12-18 19:07:21 +08:00
|
|
|
|
const [timeLeft, setTimeLeft] = useState<number | null>(null);
|
|
|
|
|
|
const [timeLimit, setTimeLimit] = useState<number | null>(null);
|
|
|
|
|
|
const [subjectId, setSubjectId] = useState<string>('');
|
|
|
|
|
|
const [taskId, setTaskId] = useState<string>('');
|
2025-12-25 21:54:52 +08:00
|
|
|
|
const lastTickSavedAtMsRef = useRef<number>(0);
|
2025-12-28 23:33:14 +08:00
|
|
|
|
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]);
|
2025-12-18 19:07:21 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
|
message.warning('请先填写个人信息');
|
|
|
|
|
|
navigate('/');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const state = location.state as LocationState;
|
2025-12-25 21:54:52 +08:00
|
|
|
|
|
|
|
|
|
|
clearQuiz();
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
if (state?.questions) {
|
2025-12-25 21:54:52 +08:00
|
|
|
|
const nextSubjectId = state.subjectId || '';
|
|
|
|
|
|
const nextTaskId = state.taskId || '';
|
|
|
|
|
|
const nextTimeLimit = state.timeLimit || 60;
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
setQuestions(state.questions);
|
2025-12-25 21:54:52 +08:00
|
|
|
|
setAnswers({});
|
2025-12-18 19:07:21 +08:00
|
|
|
|
setCurrentQuestionIndex(0);
|
2025-12-25 21:54:52 +08:00
|
|
|
|
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;
|
2025-12-18 19:07:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-25 21:54:52 +08:00
|
|
|
|
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();
|
2025-12-18 19:07:21 +08:00
|
|
|
|
}, [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);
|
2025-12-25 21:54:52 +08:00
|
|
|
|
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: '',
|
|
|
|
|
|
});
|
2025-12-18 19:07:21 +08:00
|
|
|
|
} 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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-25 21:54:52 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!user) return;
|
|
|
|
|
|
if (!questions.length) return;
|
|
|
|
|
|
if (timeLeft === null) return;
|
|
|
|
|
|
|
2025-12-28 23:33:14 +08:00
|
|
|
|
if (saveDebounceTimerRef.current !== null) {
|
|
|
|
|
|
window.clearTimeout(saveDebounceTimerRef.current);
|
|
|
|
|
|
}
|
2025-12-25 21:54:52 +08:00
|
|
|
|
|
2025-12-28 23:33:14 +08:00
|
|
|
|
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]);
|
2025-12-25 21:54:52 +08:00
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
const handleNext = () => {
|
|
|
|
|
|
if (currentQuestionIndex < questions.length - 1) {
|
2025-12-28 23:33:14 +08:00
|
|
|
|
setNavDirection('next');
|
2025-12-18 19:07:21 +08:00
|
|
|
|
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handlePrevious = () => {
|
|
|
|
|
|
if (currentQuestionIndex > 0) {
|
2025-12-28 23:33:14 +08:00
|
|
|
|
setNavDirection('prev');
|
2025-12-18 19:07:21 +08:00
|
|
|
|
setCurrentQuestionIndex(currentQuestionIndex - 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-28 23:33:14 +08:00
|
|
|
|
const handleJumpTo = (index: number) => {
|
|
|
|
|
|
if (index < 0 || index >= questions.length) return;
|
|
|
|
|
|
if (index === currentQuestionIndex) return;
|
|
|
|
|
|
setNavDirection(index > currentQuestionIndex ? 'next' : 'prev');
|
|
|
|
|
|
setCurrentQuestionIndex(index);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
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('答题提交成功!');
|
2025-12-25 21:54:52 +08:00
|
|
|
|
clearActiveProgress(user!.id);
|
2025-12-18 19:07:21 +08:00
|
|
|
|
navigate(`/result/${response.data.recordId}`);
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
message.error(error.message || '提交失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-25 21:54:52 +08:00
|
|
|
|
const handleGiveUp = () => {
|
|
|
|
|
|
if (!user) return;
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: '确认弃考?',
|
|
|
|
|
|
content: '弃考将清空本次答题进度与计时信息,且不计入考试次数。',
|
|
|
|
|
|
okText: '确认弃考',
|
|
|
|
|
|
cancelText: '继续答题',
|
|
|
|
|
|
okButtonProps: { danger: true },
|
|
|
|
|
|
onOk: () => {
|
|
|
|
|
|
clearActiveProgress(user.id);
|
|
|
|
|
|
clearQuiz();
|
|
|
|
|
|
navigate('/tasks');
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
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 (
|
|
|
|
|
|
<Radio.Group
|
|
|
|
|
|
value={currentAnswer as string}
|
|
|
|
|
|
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
>
|
|
|
|
|
|
{question.options?.map((option, index) => (
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<Radio key={index} value={option} className="block mb-3 p-4 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
{String.fromCharCode(65 + index)}. {option}
|
|
|
|
|
|
</Radio>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Radio.Group>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
case 'multiple':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Checkbox.Group
|
|
|
|
|
|
value={currentAnswer as string[] || []}
|
|
|
|
|
|
onChange={(checkedValues) => handleAnswerChange(question.id, checkedValues)}
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
>
|
|
|
|
|
|
{question.options?.map((option, index) => (
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<Checkbox key={index} value={option} className="block mb-3 p-4 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors w-full">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
{String.fromCharCode(65 + index)}. {option}
|
|
|
|
|
|
</Checkbox>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Checkbox.Group>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
case 'judgment':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Radio.Group
|
|
|
|
|
|
value={currentAnswer as string}
|
|
|
|
|
|
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<Radio value="正确" className="block mb-3 p-4 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
正确
|
|
|
|
|
|
</Radio>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<Radio value="错误" className="block mb-3 p-4 min-h-12 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
错误
|
|
|
|
|
|
</Radio>
|
|
|
|
|
|
</Radio.Group>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
case 'text':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TextArea
|
|
|
|
|
|
rows={6}
|
|
|
|
|
|
value={currentAnswer as string || ''}
|
|
|
|
|
|
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
|
|
|
|
|
placeholder="请输入您的答案..."
|
2025-12-19 16:02:38 +08:00
|
|
|
|
className="rounded-lg border-gray-300 focus:border-mars-500 focus:ring-mars-500"
|
2025-12-18 19:07:21 +08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
return <div>未知题型</div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-28 23:33:14 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
if (loading || !questions.length) {
|
|
|
|
|
|
return (
|
2025-12-19 16:02:38 +08:00
|
|
|
|
<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>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<p className="text-gray-700">正在生成试卷...</p>
|
2025-12-19 16:02:38 +08:00
|
|
|
|
</div>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
</div>
|
2025-12-19 16:02:38 +08:00
|
|
|
|
</UserLayout>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const currentQuestion = questions[currentQuestionIndex];
|
|
|
|
|
|
const progress = ((currentQuestionIndex + 1) / questions.length) * 100;
|
|
|
|
|
|
|
2025-12-28 23:33:14 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
return (
|
2025-12-19 16:02:38 +08:00
|
|
|
|
<UserLayout>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<div className="max-w-4xl mx-auto pb-24">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
{/* 头部信息 */}
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<div className="bg-white rounded-xl shadow-sm p-4 md:p-6 mb-4 md:mb-6 border border-gray-100">
|
|
|
|
|
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3 mb-4">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
<div>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<h1 className="text-xl md:text-2xl font-bold text-gray-900">在线答题</h1>
|
|
|
|
|
|
<p className="text-gray-700 mt-1">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
第 {currentQuestionIndex + 1} 题 / 共 {questions.length} 题
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<div className="flex items-center justify-between md:justify-end gap-3">
|
|
|
|
|
|
<Button danger onClick={handleGiveUp} className="h-12 px-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-red-500">
|
2025-12-25 21:54:52 +08:00
|
|
|
|
弃考
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{timeLeft !== null && (
|
|
|
|
|
|
<div className="text-right">
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<div className="text-sm text-gray-700">剩余时间</div>
|
2025-12-25 21:54:52 +08:00
|
|
|
|
<div className={`text-2xl font-bold tabular-nums ${
|
|
|
|
|
|
timeLeft < 300 ? 'text-red-600' : 'text-mars-600'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{formatTime(timeLeft)}
|
|
|
|
|
|
</div>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
</div>
|
2025-12-25 21:54:52 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
percent={Math.round(progress)}
|
2025-12-19 16:02:38 +08:00
|
|
|
|
strokeColor="#008C8C"
|
|
|
|
|
|
trailColor="#f0fcfc"
|
2025-12-18 19:07:21 +08:00
|
|
|
|
showInfo={false}
|
2025-12-19 16:02:38 +08:00
|
|
|
|
className="mt-2"
|
2025-12-18 19:07:21 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 题目卡片 */}
|
2025-12-19 16:02:38 +08:00
|
|
|
|
<Card className="shadow-sm border border-gray-100 rounded-xl">
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<div
|
|
|
|
|
|
onTouchStart={handleTouchStart}
|
|
|
|
|
|
onTouchEnd={handleTouchEnd}
|
|
|
|
|
|
className="mb-6 md:mb-8"
|
|
|
|
|
|
>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
|
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium ${getTagColor(currentQuestion.type)}`}>
|
|
|
|
|
|
{questionTypeMap[currentQuestion.type]}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{currentQuestion.category && (
|
|
|
|
|
|
<div className="mb-3">
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<span className="inline-block px-2 py-1 bg-gray-50 text-gray-700 text-xs rounded border border-gray-100">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
{currentQuestion.category}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<h2
|
|
|
|
|
|
ref={questionHeadingRef}
|
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
|
className={`text-lg md:text-xl font-medium text-gray-900 leading-relaxed transition-all duration-200 ease-out ${
|
|
|
|
|
|
enterAnimationOn
|
|
|
|
|
|
? 'opacity-100 translate-x-0'
|
|
|
|
|
|
: navDirection === 'next'
|
|
|
|
|
|
? 'opacity-0 translate-x-2'
|
|
|
|
|
|
: 'opacity-0 -translate-x-2'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
{currentQuestion.content}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className={`mb-6 md:mb-8 transition-all duration-200 ease-out ${
|
|
|
|
|
|
enterAnimationOn
|
|
|
|
|
|
? 'opacity-100 translate-x-0'
|
|
|
|
|
|
: navDirection === 'next'
|
|
|
|
|
|
? 'opacity-0 translate-x-2'
|
|
|
|
|
|
: 'opacity-0 -translate-x-2'
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
{renderQuestion(currentQuestion)}
|
|
|
|
|
|
</div>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
</Card>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
|
2025-12-28 23:33:14 +08:00
|
|
|
|
<div className="fixed left-0 right-0 bottom-0 z-20">
|
|
|
|
|
|
<div className="bg-white/95 backdrop-blur border-t border-gray-200">
|
|
|
|
|
|
<div className="max-w-4xl mx-auto px-4 py-3">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
2025-12-18 19:07:21 +08:00
|
|
|
|
<Button
|
2025-12-28 23:33:14 +08:00
|
|
|
|
onClick={handlePrevious}
|
|
|
|
|
|
disabled={currentQuestionIndex === 0}
|
|
|
|
|
|
className="h-12 min-w-12 px-4 rounded-lg hover:border-mars-500 hover:text-mars-600 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500"
|
2025-12-18 19:07:21 +08:00
|
|
|
|
>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
上一题
|
2025-12-18 19:07:21 +08:00
|
|
|
|
</Button>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
|
2025-12-18 19:07:21 +08:00
|
|
|
|
<Button
|
2025-12-28 23:33:14 +08:00
|
|
|
|
onClick={() => setAnswerSheetOpen(true)}
|
|
|
|
|
|
className="h-12 flex-1 rounded-lg border-gray-200 text-gray-900 hover:border-mars-400 hover:text-mars-600 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500"
|
2025-12-18 19:07:21 +08:00
|
|
|
|
>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
答题卡({answeredCount}/{questions.length})
|
2025-12-18 19:07:21 +08:00
|
|
|
|
</Button>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
|
|
|
|
|
|
{currentQuestionIndex === questions.length - 1 ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
onClick={() => handleSubmit()}
|
|
|
|
|
|
loading={submitting}
|
|
|
|
|
|
className="h-12 min-w-12 px-4 rounded-lg bg-mars-600 hover:bg-mars-700 border-none shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500"
|
|
|
|
|
|
>
|
|
|
|
|
|
提交
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
onClick={handleNext}
|
|
|
|
|
|
className="h-12 min-w-12 px-4 rounded-lg bg-mars-500 hover:bg-mars-600 border-none shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500"
|
|
|
|
|
|
>
|
|
|
|
|
|
下一题
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-28 23:33:14 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{answerSheetOpen && (
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title="答题卡"
|
|
|
|
|
|
open={answerSheetOpen}
|
|
|
|
|
|
onCancel={() => setAnswerSheetOpen(false)}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
centered
|
|
|
|
|
|
width={560}
|
|
|
|
|
|
destroyOnClose
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
|
<div className="text-sm text-gray-700">
|
|
|
|
|
|
已答 {answeredCount} / 共 {questions.length}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
onClick={() => setAnswerSheetOpen(false)}
|
|
|
|
|
|
className="h-10 px-4 bg-mars-600 hover:bg-mars-700 border-none"
|
|
|
|
|
|
>
|
|
|
|
|
|
回到当前题
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-5 sm:grid-cols-6 gap-2">
|
|
|
|
|
|
{questions.map((q, idx) => {
|
|
|
|
|
|
const isCurrent = idx === currentQuestionIndex;
|
|
|
|
|
|
const isAnswered = !!answers[q.id];
|
|
|
|
|
|
|
|
|
|
|
|
const baseClassName = 'h-12 w-12 rounded-lg border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-mars-500';
|
|
|
|
|
|
const className = isCurrent
|
|
|
|
|
|
? `${baseClassName} border-mars-500 text-mars-700 bg-mars-50`
|
|
|
|
|
|
: isAnswered
|
|
|
|
|
|
? `${baseClassName} border-green-200 text-green-700 bg-green-50 hover:border-green-300`
|
|
|
|
|
|
: `${baseClassName} border-gray-200 text-gray-800 bg-white hover:border-mars-200`;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={q.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={className}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setAnswerSheetOpen(false);
|
|
|
|
|
|
handleJumpTo(idx);
|
|
|
|
|
|
}}
|
|
|
|
|
|
aria-label={`跳转到第 ${idx + 1} 题`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{idx + 1}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
)}
|
2025-12-18 19:07:21 +08:00
|
|
|
|
</div>
|
2025-12-19 16:02:38 +08:00
|
|
|
|
</UserLayout>
|
2025-12-18 19:07:21 +08:00
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-19 16:02:38 +08:00
|
|
|
|
export default QuizPage;
|
2025-12-25 21:54:52 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|