第一版提交,答题功能OK,题库管理待完善
This commit is contained in:
371
src/pages/QuizPage.tsx
Normal file
371
src/pages/QuizPage.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
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';
|
||||
|
||||
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-3 rounded-lg border border-gray-200 hover:bg-gray-50 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-3 rounded-lg border border-gray-200 hover:bg-gray-50 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-3 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors">
|
||||
正确
|
||||
</Radio>
|
||||
<Radio value="错误" className="block mb-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 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-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>未知题型</div>;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !questions.length) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">正在生成试卷...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
const progress = ((currentQuestionIndex + 1) / questions.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* 头部信息 */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">在线答题</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
第 {currentQuestionIndex + 1} 题 / 共 {questions.length} 题
|
||||
</p>
|
||||
</div>
|
||||
{timeLeft !== null && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">剩余时间</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
timeLeft < 300 ? 'text-red-600' : 'text-green-600'
|
||||
}`}>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Progress
|
||||
percent={Math.round(progress)}
|
||||
strokeColor="#3b82f6"
|
||||
showInfo={false}
|
||||
className="mt-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 题目卡片 */}
|
||||
<Card className="shadow-sm">
|
||||
<div className="mb-6">
|
||||
<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-600">
|
||||
{currentQuestion.score} 分
|
||||
</span>
|
||||
</div>
|
||||
{currentQuestion.category && (
|
||||
<div className="mb-3">
|
||||
<span className="inline-block px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded">
|
||||
{currentQuestion.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-medium text-gray-900 leading-relaxed">
|
||||
{currentQuestion.content}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
{renderQuestion(currentQuestion)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentQuestionIndex === 0}
|
||||
className="px-6"
|
||||
>
|
||||
上一题
|
||||
</Button>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
{currentQuestionIndex === questions.length - 1 ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => handleSubmit()}
|
||||
loading={submitting}
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 border-none"
|
||||
>
|
||||
提交答案
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 border-none"
|
||||
>
|
||||
下一题
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizPage;
|
||||
Reference in New Issue
Block a user