Files
Web_BLV_OA_Exam_Prod/src/pages/QuizPage.tsx
XuJiacheng abfe6e95f9 fix(答案处理): 改进多答案字符串的分割逻辑
处理类似"同创造,共分享,齐飞扬"格式的答案字符串,统一使用逗号分割并去除空格
2026-01-23 18:14:29 +08:00

796 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
};