用户端页面构建完成---待测试

This commit is contained in:
2025-12-25 21:54:52 +08:00
parent de0c7377c9
commit 7b52abfea3
13 changed files with 539 additions and 113 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Card, Button, Radio, Checkbox, Input, message, Progress } from 'antd';
import { useState, useEffect, useRef } from 'react';
import { Card, Button, Radio, Checkbox, Input, message, Progress, Modal } from 'antd';
import { useNavigate, useLocation } from 'react-router-dom';
import { useUser, useQuiz } from '../contexts';
import { quizAPI } from '../services/api';
@@ -32,14 +32,14 @@ const QuizPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useUser();
const { questions, setQuestions, currentQuestionIndex, setCurrentQuestionIndex, answers, setAnswer, clearQuiz } = useQuiz();
const { questions, setQuestions, currentQuestionIndex, setCurrentQuestionIndex, answers, setAnswer, setAnswers, 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>('');
const [showAnalysis, setShowAnalysis] = useState(false);
const lastTickSavedAtMsRef = useRef<number>(0);
useEffect(() => {
if (!user) {
@@ -49,22 +49,49 @@ const QuizPage = () => {
}
const state = location.state as LocationState;
clearQuiz();
if (state?.questions) {
// 如果已经有题目数据(来自科目选择页面)
const nextSubjectId = state.subjectId || '';
const nextTaskId = state.taskId || '';
const nextTimeLimit = state.timeLimit || 60;
setQuestions(state.questions);
setTimeLimit(state.timeLimit || 60);
setTimeLeft((state.timeLimit || 60) * 60); // 转换为秒
setSubjectId(state.subjectId || '');
setTaskId(state.taskId || '');
setAnswers({});
setCurrentQuestionIndex(0);
} else {
// 兼容旧版本,直接生成题目
generateQuiz();
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;
}
// 清除之前的答题状态
clearQuiz();
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]);
// 倒计时逻辑
@@ -85,16 +112,29 @@ const QuizPage = () => {
return () => clearInterval(timer);
}, [timeLeft]);
useEffect(() => {
setShowAnalysis(false);
}, [currentQuestionIndex]);
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 {
@@ -134,6 +174,48 @@ const QuizPage = () => {
setAnswer(questionId, value);
};
useEffect(() => {
if (!user) return;
if (!questions.length) return;
if (timeLeft === null) return;
const progressKey = getActiveProgressKey(user.id);
if (!progressKey) return;
saveProgress(progressKey, {
questions,
answers,
currentQuestionIndex,
timeLeftSeconds: timeLeft,
timeLimitMinutes: timeLimit || 60,
subjectId,
taskId,
});
}, [user, questions, answers, currentQuestionIndex, subjectId, taskId]);
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) {
setCurrentQuestionIndex(currentQuestionIndex + 1);
@@ -178,6 +260,7 @@ const QuizPage = () => {
});
message.success('答题提交成功!');
clearActiveProgress(user!.id);
navigate(`/result/${response.data.recordId}`);
} catch (error: any) {
message.error(error.message || '提交失败');
@@ -186,6 +269,22 @@ const QuizPage = () => {
}
};
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 (!userAnswer) return false;
@@ -293,16 +392,21 @@ const QuizPage = () => {
{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 className="flex items-center space-x-3">
<Button danger onClick={handleGiveUp}>
</Button>
{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>
)}
)}
</div>
</div>
<Progress
@@ -321,9 +425,6 @@ const QuizPage = () => {
<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">
@@ -341,23 +442,6 @@ const QuizPage = () => {
{renderQuestion(currentQuestion)}
</div>
{String(currentQuestion.analysis ?? '').trim() ? (
<div className="mb-6">
<Button
type="link"
onClick={() => setShowAnalysis((v) => !v)}
className="p-0 h-auto text-mars-600 hover:text-mars-700"
>
{showAnalysis ? '收起解析' : '查看解析'}
</Button>
{showAnalysis ? (
<div className="mt-2 p-4 rounded-lg border border-gray-100 bg-gray-50 text-gray-700 whitespace-pre-wrap">
{currentQuestion.analysis}
</div>
) : null}
</div>
) : null}
{/* 操作按钮 */}
<div className="flex justify-between items-center pt-6 border-t border-gray-100">
<Button
@@ -399,3 +483,72 @@ const QuizPage = () => {
};
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);
};

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Card, Button, Typography, Tag, Space, Spin, message, Modal } from 'antd';
import { Card, Button, Typography, Tag, Space, Spin, message } from 'antd';
import { ClockCircleOutlined, BookOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { request } from '../utils/request';
import { useUserStore } from '../stores/userStore';
import { useUser } from '../contexts';
import { examSubjectAPI, examTaskAPI, quizAPI } from '../services/api';
import { UserLayout } from '../layouts/UserLayout';
const { Title, Text } = Typography;
@@ -25,6 +25,9 @@ interface ExamTask {
startAt: string;
endAt: string;
subjectName?: string;
usedAttempts?: number;
maxAttempts?: number;
bestScore?: number;
}
export const SubjectSelectionPage: React.FC = () => {
@@ -35,9 +38,15 @@ export const SubjectSelectionPage: React.FC = () => {
const [selectedTask, setSelectedTask] = useState<string>('');
const navigate = useNavigate();
const location = useLocation();
const { user } = useUserStore();
const { user } = useUser();
useEffect(() => {
if (!user?.id) {
message.warning('请先填写个人信息');
navigate('/');
return;
}
fetchData();
// 如果从任务页面跳转过来,自动选择对应的任务
@@ -46,29 +55,19 @@ export const SubjectSelectionPage: React.FC = () => {
setSelectedTask(state.selectedTask);
setSelectedSubject('');
}
}, []);
}, [user?.id, navigate]);
const fetchData = async () => {
try {
setLoading(true);
if (!user?.id) return;
const [subjectsRes, tasksRes] = await Promise.all([
request.get('/api/exam-subjects'),
request.get('/api/exam-tasks')
]);
examSubjectAPI.getSubjects(),
examTaskAPI.getUserTasks(user.id)
]) as any;
if (subjectsRes.data.success) {
setSubjects(subjectsRes.data.data);
}
if (tasksRes.data.success) {
const now = new Date();
const validTasks = tasksRes.data.data.filter((task: ExamTask) => {
const startAt = new Date(task.startAt);
const endAt = new Date(task.endAt);
return now >= startAt && now <= endAt;
});
setTasks(validTasks);
}
setSubjects(subjectsRes.data);
setTasks(tasksRes.data);
} catch (error) {
message.error('获取数据失败');
} finally {
@@ -83,26 +82,29 @@ export const SubjectSelectionPage: React.FC = () => {
}
try {
const response = await request.post('/api/quiz/generate', {
userId: user?.id,
subjectId: selectedSubject,
taskId: selectedTask
});
if (response.data.success) {
const { questions, totalScore, timeLimit } = response.data.data;
navigate('/quiz', {
state: {
questions,
totalScore,
timeLimit,
subjectId: selectedSubject,
taskId: selectedTask
}
});
if (selectedTask) {
const task = tasks.find((t) => t.id === selectedTask);
const usedAttempts = Number(task?.usedAttempts) || 0;
const maxAttempts = Number(task?.maxAttempts) || 3;
if (usedAttempts >= maxAttempts) {
message.error('考试次数已用尽');
return;
}
}
const response = await quizAPI.generateQuiz(user?.id || '', selectedSubject || undefined, selectedTask || undefined) as any;
const { questions, totalScore, timeLimit } = response.data;
navigate('/quiz', {
state: {
questions,
totalScore,
timeLimit,
subjectId: selectedSubject,
taskId: selectedTask
}
});
} catch (error: any) {
message.error(error.response?.data?.message || '生成试卷失败');
message.error(error.message || '生成试卷失败');
}
};
@@ -209,6 +211,9 @@ export const SubjectSelectionPage: React.FC = () => {
<div className="space-y-4">
{tasks.map((task) => {
const subject = subjects.find(s => s.id === task.subjectId);
const usedAttempts = Number(task.usedAttempts) || 0;
const maxAttempts = Number(task.maxAttempts) || 3;
const attemptsExhausted = usedAttempts >= maxAttempts;
return (
<Card
key={task.id}
@@ -218,6 +223,7 @@ export const SubjectSelectionPage: React.FC = () => {
: 'border-l-transparent hover:border-l-mars-300 hover:shadow-md'
}`}
onClick={() => {
if (attemptsExhausted) return;
setSelectedTask(task.id);
setSelectedSubject('');
}}
@@ -227,6 +233,15 @@ export const SubjectSelectionPage: React.FC = () => {
<Title level={4} className={`mb-2 ${selectedTask === task.id ? 'text-mars-700' : 'text-gray-800'}`}>
{task.name}
</Title>
<div className="mb-2">
<Tag color={attemptsExhausted ? 'red' : 'blue'}>
{usedAttempts}/{maxAttempts}
</Tag>
{typeof task.bestScore === 'number' ? (
<Tag color="green"> {task.bestScore} </Tag>
) : null}
{attemptsExhausted ? <Tag color="red"></Tag> : null}
</div>
<Space direction="vertical" size="small" className="mb-3">
<div className="flex items-center">
<BookOutlined className="mr-2 text-gray-400" />

View File

@@ -2,8 +2,8 @@ import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Space, Spin, message, Typography } from 'antd';
import { ClockCircleOutlined, BookOutlined, CalendarOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { request } from '../utils/request';
import { useUserStore } from '../stores/userStore';
import { useUser } from '../contexts';
import { examTaskAPI } from '../services/api';
import { UserLayout } from '../layouts/UserLayout';
const { Title, Text } = Typography;
@@ -17,15 +17,16 @@ interface ExamTask {
endAt: string;
totalScore: number;
timeLimitMinutes: number;
completed?: boolean;
score?: number;
usedAttempts: number;
maxAttempts: number;
bestScore: number;
}
export const UserTaskPage: React.FC = () => {
const [tasks, setTasks] = useState<ExamTask[]>([]);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const { user } = useUserStore();
const { user } = useUser();
useEffect(() => {
if (user) {
@@ -36,11 +37,9 @@ export const UserTaskPage: React.FC = () => {
const fetchUserTasks = async () => {
try {
setLoading(true);
const response = await request.get(`/api/exam-tasks/user/${user?.id}`);
if (response.data.success) {
setTasks(response.data.data);
}
if (!user?.id) return;
const response = await examTaskAPI.getUserTasks(user.id) as any;
setTasks(response.data);
} catch (error) {
message.error('获取考试任务失败');
} finally {
@@ -151,6 +150,21 @@ export const UserTaskPage: React.FC = () => {
</Tag>
)
},
{
title: '次数',
key: 'attempts',
render: (record: ExamTask) => (
<Text>
{record.usedAttempts}/{record.maxAttempts}
</Text>
),
},
{
title: '最高分',
dataIndex: 'bestScore',
key: 'bestScore',
render: (score: number) => <Text strong>{score}</Text>,
},
{
title: <div style={{ textAlign: 'center' }}></div>,
key: 'action',
@@ -158,7 +172,7 @@ export const UserTaskPage: React.FC = () => {
const now = new Date();
const startAt = new Date(record.startAt);
const endAt = new Date(record.endAt);
const canStart = now >= startAt && now <= endAt;
const canStart = now >= startAt && now <= endAt && record.usedAttempts < record.maxAttempts;
return (
<Space>
@@ -170,7 +184,7 @@ export const UserTaskPage: React.FC = () => {
icon={<CheckCircleOutlined />}
className={canStart ? "bg-mars-500 hover:bg-mars-600 border-none" : ""}
>
{canStart ? '开始考试' : '不可用'}
{canStart ? '开始考试' : record.usedAttempts >= record.maxAttempts ? '次数用尽' : '不可用'}
</Button>
</Space>
);