Files
Web_BLV_OA_Exam_Prod/src/pages/QuizPage.tsx

379 lines
12 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from 'react';
import { Card, Button, Radio, Checkbox, Input, message, Progress } 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 { UserLayout } from '../layouts/UserLayout';
const { TextArea } = Input;
interface Question {
id: string;
content: string;
type: 'single' | 'multiple' | 'judgment' | 'text';
options?: string[];
answer: string | 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, clearQuiz } = useQuiz();
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = 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>('');
useEffect(() => {
if (!user) {
message.warning('请先填写个人信息');
navigate('/');
return;
}
const state = location.state as LocationState;
if (state?.questions) {
// 如果已经有题目数据(来自科目选择页面)
setQuestions(state.questions);
setTimeLimit(state.timeLimit || 60);
setTimeLeft((state.timeLimit || 60) * 60); // 转换为秒
setSubjectId(state.subjectId || '');
setTaskId(state.taskId || '');
setCurrentQuestionIndex(0);
} else {
// 兼容旧版本,直接生成题目
generateQuiz();
}
// 清除之前的答题状态
clearQuiz();
}, [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);
} 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);
};
const handleNext = () => {
if (currentQuestionIndex < questions.length - 1) {
setCurrentQuestionIndex(currentQuestionIndex + 1);
}
};
const handlePrevious = () => {
if (currentQuestionIndex > 0) {
setCurrentQuestionIndex(currentQuestionIndex - 1);
}
};
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('答题提交成功!');
navigate(`/result/${response.data.recordId}`);
} catch (error: any) {
message.error(error.message || '提交失败');
} finally {
setSubmitting(false);
}
};
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) => (
<Radio key={index} value={option} className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
{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) => (
<Checkbox key={index} value={option} className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors w-full">
{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"
>
<Radio value="正确" className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
</Radio>
<Radio value="错误" className="block mb-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 hover:border-mars-200 transition-colors">
</Radio>
</Radio.Group>
);
case 'text':
return (
<TextArea
rows={6}
value={currentAnswer as string || ''}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
placeholder="请输入您的答案..."
className="rounded-lg border-gray-300 focus:border-mars-500 focus:ring-mars-500"
/>
);
default:
return <div></div>;
}
};
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-600">...</p>
</div>
</div>
</UserLayout>
);
}
const currentQuestion = questions[currentQuestionIndex];
const progress = ((currentQuestionIndex + 1) / questions.length) * 100;
return (
<UserLayout>
<div className="max-w-4xl mx-auto">
{/* 头部信息 */}
<div className="bg-white rounded-xl shadow-sm p-6 mb-6 border border-gray-100">
<div className="flex justify-between items-center mb-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">线</h1>
<p className="text-gray-500 mt-1">
{currentQuestionIndex + 1} / {questions.length}
</p>
</div>
{timeLeft !== null && (
<div className="text-right">
<div className="text-sm text-gray-500"></div>
<div className={`text-2xl font-bold tabular-nums ${
timeLeft < 300 ? 'text-red-600' : 'text-mars-600'
}`}>
{formatTime(timeLeft)}
</div>
</div>
)}
</div>
<Progress
percent={Math.round(progress)}
strokeColor="#008C8C"
trailColor="#f0fcfc"
showInfo={false}
className="mt-2"
/>
</div>
{/* 题目卡片 */}
<Card className="shadow-sm border border-gray-100 rounded-xl">
<div className="mb-8">
<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>
<span className="text-sm text-gray-500 font-medium">
{currentQuestion.score}
</span>
</div>
{currentQuestion.category && (
<div className="mb-3">
<span className="inline-block px-2 py-1 bg-gray-50 text-gray-500 text-xs rounded border border-gray-100">
{currentQuestion.category}
</span>
</div>
)}
<h2 className="text-xl font-medium text-gray-800 leading-relaxed">
{currentQuestion.content}
</h2>
</div>
<div className="mb-8">
{renderQuestion(currentQuestion)}
</div>
{/* 操作按钮 */}
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
<Button
onClick={handlePrevious}
disabled={currentQuestionIndex === 0}
size="large"
className="px-6 h-10 hover:border-mars-500 hover:text-mars-500"
>
</Button>
<div className="flex space-x-3">
{currentQuestionIndex === questions.length - 1 ? (
<Button
type="primary"
onClick={() => handleSubmit()}
loading={submitting}
size="large"
className="px-8 h-10 bg-mars-600 hover:bg-mars-700 border-none shadow-md"
>
</Button>
) : (
<Button
type="primary"
onClick={handleNext}
size="large"
className="px-8 h-10 bg-mars-500 hover:bg-mars-600 border-none shadow-md"
>
</Button>
)}
</div>
</div>
</Card>
</div>
</UserLayout>
);
};
export default QuizPage;