用户端页面构建完成---待测试
This commit is contained in:
@@ -190,9 +190,21 @@ export class QuizController {
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
const message = error?.message || '生成试卷失败';
|
||||
const status = message.includes('不存在')
|
||||
? 404
|
||||
: [
|
||||
'用户ID不能为空',
|
||||
'subjectId或taskId必须提供其一',
|
||||
'当前时间不在任务有效范围内',
|
||||
'用户未被分派到此任务',
|
||||
'考试次数已用尽',
|
||||
].some((m) => message.includes(m))
|
||||
? 400
|
||||
: 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
message: error.message || '生成试卷失败'
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -390,4 +402,4 @@ export class QuizController {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ export interface ExamTask {
|
||||
selectionConfig?: string; // JSON string
|
||||
}
|
||||
|
||||
export interface UserExamTask extends ExamTask {
|
||||
subjectName: string;
|
||||
totalScore: number;
|
||||
timeLimitMinutes: number;
|
||||
usedAttempts: number;
|
||||
maxAttempts: number;
|
||||
bestScore: number;
|
||||
}
|
||||
|
||||
export interface ExamTaskUser {
|
||||
id: string;
|
||||
taskId: string;
|
||||
@@ -558,6 +567,13 @@ export class ExamTaskModel {
|
||||
);
|
||||
if (!isAssigned) throw new Error('用户未被分派到此任务');
|
||||
|
||||
const attemptRow = await get(
|
||||
`SELECT COUNT(*) as count FROM quiz_records WHERE user_id = ? AND task_id = ?`,
|
||||
[userId, taskId],
|
||||
);
|
||||
const usedAttempts = Number(attemptRow?.count) || 0;
|
||||
if (usedAttempts >= 3) throw new Error('考试次数已用尽');
|
||||
|
||||
const subject = await import('./examSubject').then(({ ExamSubjectModel }) => ExamSubjectModel.findById(task.subjectId));
|
||||
if (!subject) throw new Error('科目不存在');
|
||||
|
||||
@@ -705,27 +721,48 @@ export class ExamTaskModel {
|
||||
};
|
||||
}
|
||||
|
||||
static async getUserTasks(userId: string): Promise<ExamTask[]> {
|
||||
static async getUserTasks(userId: string): Promise<UserExamTask[]> {
|
||||
const now = new Date().toISOString();
|
||||
const rows = await all(`
|
||||
SELECT t.*, s.name as subjectName, s.totalScore, s.timeLimitMinutes
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.subject_id as subjectId,
|
||||
t.start_at as startAt,
|
||||
t.end_at as endAt,
|
||||
t.created_at as createdAt,
|
||||
s.name as subjectName,
|
||||
s.total_score as totalScore,
|
||||
s.duration_minutes as timeLimitMinutes,
|
||||
COALESCE(q.usedAttempts, 0) as usedAttempts,
|
||||
3 as maxAttempts,
|
||||
COALESCE(q.bestScore, 0) as bestScore
|
||||
FROM exam_tasks t
|
||||
INNER JOIN exam_task_users tu ON t.id = tu.task_id
|
||||
INNER JOIN exam_subjects s ON t.subject_id = s.id
|
||||
WHERE tu.user_id = ? AND t.start_at <= ?
|
||||
ORDER BY t.start_at DESC
|
||||
`, [userId, now]);
|
||||
LEFT JOIN (
|
||||
SELECT task_id, COUNT(*) as usedAttempts, MAX(total_score) as bestScore
|
||||
FROM quiz_records
|
||||
WHERE user_id = ?
|
||||
GROUP BY task_id
|
||||
) q ON q.task_id = t.id
|
||||
WHERE tu.user_id = ? AND t.start_at <= ? AND t.end_at >= ?
|
||||
ORDER BY t.start_at ASC, t.end_at ASC
|
||||
`, [userId, userId, now, now]);
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
subjectId: row.subject_id,
|
||||
startAt: row.start_at,
|
||||
endAt: row.end_at,
|
||||
createdAt: row.created_at,
|
||||
subjectId: row.subjectId,
|
||||
startAt: row.startAt,
|
||||
endAt: row.endAt,
|
||||
createdAt: row.createdAt,
|
||||
subjectName: row.subjectName,
|
||||
totalScore: row.totalScore,
|
||||
timeLimitMinutes: row.timeLimitMinutes
|
||||
totalScore: Number(row.totalScore) || 0,
|
||||
timeLimitMinutes: Number(row.timeLimitMinutes) || 0,
|
||||
usedAttempts: Number(row.usedAttempts) || 0,
|
||||
maxAttempts: Number(row.maxAttempts) || 3,
|
||||
bestScore: Number(row.bestScore) || 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
BIN
data/survey.db
BIN
data/survey.db
Binary file not shown.
Binary file not shown.
23
openspec/changes/add-user-exam-workflow/proposal.md
Normal file
23
openspec/changes/add-user-exam-workflow/proposal.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Change: 用户端考试页面流程与统计能力
|
||||
|
||||
## Why
|
||||
当前用户端已具备基础登录、任务列表、生成试卷、交卷与结果页能力,但缺少“按用户匹配考试计划、限次控制、弃考、过程约束、历史回看、个人统计与操作日志”等完整考试流程要求,导致管理端考试任务难以按规则落地执行。
|
||||
|
||||
## What Changes
|
||||
- 新增用户端登录会话规则与登出能力(基于现有用户数据模型)
|
||||
- 新增用户端考试计划(考试任务)匹配与展示规则(仅展示当前时间有效且分派给当前用户的任务)
|
||||
- 新增考试次数限制(每个任务最多 3 次)与最高分规则(取历史最高分为最终分)
|
||||
- 新增考试过程控制:弃考(二次确认、清理进度、不计入统计、次数不减少)、答题进度自动保存、题目导航
|
||||
- 新增考试交互约束:考试中不展示评分信息,交卷后展示评分详情
|
||||
- 新增用户端个人统计与可视化展示(完成率/合格率/优秀率)
|
||||
- 新增关键操作审计日志(登录、生成试卷、弃考、交卷、查看历史)
|
||||
|
||||
## Impact
|
||||
- Affected specs:
|
||||
- `openspec/specs/api_response_schema.yaml`(所有新增/调整接口继续使用统一响应包裹)
|
||||
- `openspec/specs/auth_rules.yaml`(用户端会话与接口访问规则将被补齐为可实现状态)
|
||||
- `openspec/specs/database_schema.yaml`(可能需要新增日志表/补齐考试次数与历史聚合查询)
|
||||
- Affected code (expected):
|
||||
- Frontend: `src/pages/HomePage.tsx`, `src/pages/UserTaskPage.tsx`, `src/pages/SubjectSelectionPage.tsx`, `src/pages/QuizPage.tsx`, `src/pages/ResultPage.tsx`, `src/contexts/*`
|
||||
- Backend: `api/server.ts`, `api/controllers/*`, `api/models/examTask.ts`, `api/models/quiz.ts`
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 用户端登录与会话
|
||||
系统 MUST 支持用户通过手机号与密码登录;登录成功后系统 MUST 建立用户会话并保存用户身份信息,以便在用户端页面刷新后仍可恢复登录态。
|
||||
|
||||
#### Scenario: 登录成功并建立会话
|
||||
- **GIVEN** 用户输入已存在的手机号与正确密码
|
||||
- **WHEN** 用户提交登录
|
||||
- **THEN** 系统 MUST 返回 `success: true` 且在 `data` 中返回用户身份信息
|
||||
- **AND** 系统 MUST 在客户端保存用户会话信息并跳转到考试计划(任务)入口
|
||||
|
||||
#### Scenario: 登录失败不建立会话
|
||||
- **GIVEN** 用户输入不存在的手机号或错误密码
|
||||
- **WHEN** 用户提交登录
|
||||
- **THEN** 系统 MUST 返回 `success: false` 且包含可读的 `message`
|
||||
- **AND** 系统 MUST NOT 建立用户会话
|
||||
|
||||
### Requirement: 考试计划匹配与展示
|
||||
系统 MUST 根据当前登录用户展示其被分派的考试任务,并且 MUST 仅展示当前时间处于有效范围内的任务;任务列表 MUST 按优先级排序展示(优先级规则:按 `startAt` 升序、再按 `endAt` 升序)。
|
||||
|
||||
#### Scenario: 仅展示有效且分派给当前用户的任务
|
||||
- **GIVEN** 当前用户已登录
|
||||
- **WHEN** 用户进入“我的考试任务”页面
|
||||
- **THEN** 系统 MUST 仅返回并展示满足以下条件的任务:已分派给当前用户且当前时间在任务有效时间范围内
|
||||
|
||||
#### Scenario: 任务列表按优先级排序
|
||||
- **GIVEN** 返回的有效任务列表包含多条记录
|
||||
- **WHEN** 页面展示任务列表
|
||||
- **THEN** 系统 MUST 按 `startAt` 升序、再按 `endAt` 升序排序展示
|
||||
|
||||
### Requirement: 考试开始前置校验与次数限制
|
||||
系统 MUST 在用户开始考试时执行前置校验:用户 MUST 被分派到该任务且当前时间在有效范围内;每个任务每个用户最多允许考试 3 次,超过次数后系统 MUST 阻止生成试卷并给出友好提示。
|
||||
|
||||
#### Scenario: 次数未用尽则允许开始考试
|
||||
- **GIVEN** 当前用户已登录且被分派到某任务
|
||||
- **AND** 当前时间在任务有效范围内
|
||||
- **AND** 用户在该任务下已提交答卷次数小于 3
|
||||
- **WHEN** 用户点击“开始考试”
|
||||
- **THEN** 系统 MUST 生成试卷并进入考试界面
|
||||
|
||||
#### Scenario: 次数用尽则阻止生成试卷
|
||||
- **GIVEN** 当前用户已登录且被分派到某任务
|
||||
- **AND** 用户在该任务下已提交答卷次数等于 3
|
||||
- **WHEN** 用户点击“开始考试”
|
||||
- **THEN** 系统 MUST 返回 `success: false` 并提示“考试次数已用尽”
|
||||
|
||||
### Requirement: 弃考与次数恢复
|
||||
系统 MUST 在考试中提供显眼的“弃考”按钮;用户确认弃考后系统 MUST 清空当前考试会话的答题进度与计时信息,且 MUST NOT 生成答题记录或计入统计,因此该次弃考 MUST NOT 消耗考试次数。
|
||||
|
||||
#### Scenario: 弃考不落库且次数不减少
|
||||
- **GIVEN** 用户正在进行考试且存在已保存的答题进度
|
||||
- **WHEN** 用户点击“弃考”并二次确认
|
||||
- **THEN** 系统 MUST 清空该次考试的本地进度与计时信息
|
||||
- **AND** 系统 MUST NOT 写入答题记录与答题明细
|
||||
- **AND** 系统 MUST 保持该任务的已用考试次数不变
|
||||
|
||||
### Requirement: 考试交互与自动保存
|
||||
系统 MUST 支持题目导航与按题型清晰呈现(单选/多选/判断/文字题);系统 MUST 自动保存答题进度并在页面刷新后可恢复;系统 MUST 在考试过程中隐藏评分信息(包括题目分值与实时得分),仅在交卷后展示评分详情。
|
||||
|
||||
#### Scenario: 自动保存与刷新恢复
|
||||
- **GIVEN** 用户正在进行考试并已作答部分题目
|
||||
- **WHEN** 用户刷新页面后重新进入该次考试
|
||||
- **THEN** 系统 MUST 恢复已保存的答题进度与当前题目位置
|
||||
|
||||
#### Scenario: 考试过程中不展示评分
|
||||
- **GIVEN** 用户正在考试中
|
||||
- **WHEN** 用户浏览题目与导航
|
||||
- **THEN** 页面 MUST NOT 展示题目分值、实时得分或任何与评分相关的信息
|
||||
|
||||
### Requirement: 交卷后结果、历史与最高分
|
||||
系统 MUST 在交卷后立即计算并展示本次得分与评分详情;系统 MUST 保存完整答题记录并支持历史答卷回看;系统 MUST 记录同一任务下最多 3 次已提交成绩,且最终得分 MUST 取历史最高分。
|
||||
|
||||
#### Scenario: 交卷后展示评分详情
|
||||
- **GIVEN** 用户完成答题并提交
|
||||
- **WHEN** 后端完成判分
|
||||
- **THEN** 系统 MUST 返回 `success: true` 且在 `data` 中包含答题记录标识
|
||||
- **AND** 用户端 MUST 跳转到结果页并展示得分与明细
|
||||
|
||||
#### Scenario: 最终得分取历史最高分
|
||||
- **GIVEN** 用户在同一任务下完成多次交卷并产生多条成绩记录
|
||||
- **WHEN** 用户查看该任务的成绩汇总
|
||||
- **THEN** 系统 MUST 展示该任务下历史最高分作为“最终得分”
|
||||
|
||||
### Requirement: 个人统计与可视化
|
||||
系统 MUST 提供用户端个人考试统计:完成率 = 已考次数/可考次数;合格率(得分率 ≥ 60% 的占比);优秀率(得分率 ≥ 80% 的占比);统计结果 MUST 提供图表化展示并适配移动端与 PC 端。
|
||||
|
||||
#### Scenario: 展示个人统计
|
||||
- **GIVEN** 当前用户已登录且存在至少一条考试记录
|
||||
- **WHEN** 用户进入个人统计页面
|
||||
- **THEN** 系统 MUST 展示完成率、合格率、优秀率的数值与图表
|
||||
|
||||
### Requirement: 操作日志与二次确认
|
||||
系统 MUST 对关键操作进行审计记录(至少包含:登录、生成试卷、弃考、交卷、查看历史记录);关键操作(弃考与交卷) MUST 二次确认;系统 MUST 在异常情况下提供友好提示且接口响应 MUST 使用统一响应包裹结构。
|
||||
|
||||
#### Scenario: 关键操作写入审计日志
|
||||
- **GIVEN** 当前用户已登录
|
||||
- **WHEN** 用户执行“交卷”
|
||||
- **THEN** 系统 MUST 写入一条审计日志记录,包含操作类型、用户标识与时间戳
|
||||
|
||||
#### Scenario: 弃考需二次确认
|
||||
- **GIVEN** 用户正在考试中
|
||||
- **WHEN** 用户点击“弃考”
|
||||
- **THEN** 系统 MUST 弹出二次确认提示
|
||||
- **AND** 仅在用户确认后才执行弃考清理逻辑
|
||||
|
||||
15
openspec/changes/add-user-exam-workflow/tasks.md
Normal file
15
openspec/changes/add-user-exam-workflow/tasks.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 补齐用户端登录/登出接口与会话存储规则
|
||||
- [ ] 1.2 实现“我的考试任务”仅展示当前用户有效任务
|
||||
- [ ] 1.3 增加考试次数限制(每任务 3 次)与最高分聚合
|
||||
- [ ] 1.4 增加弃考(二次确认、清理进度、次数不减少)
|
||||
- [ ] 1.5 增加题目导航与答题进度自动保存/恢复
|
||||
- [ ] 1.6 考试中隐藏评分信息,交卷后展示评分详情
|
||||
- [ ] 1.7 增加历史答卷列表与详情回看入口
|
||||
- [ ] 1.8 增加个人统计接口与图表页面
|
||||
- [ ] 1.9 增加关键操作审计日志(前后端打点与落库)
|
||||
|
||||
## 2. Tests
|
||||
- [ ] 2.1 后端:任务匹配、次数限制、弃考不落库、最高分聚合
|
||||
- [ ] 2.2 前端:进度自动保存/恢复、题目导航、关键按钮二次确认
|
||||
|
||||
@@ -19,6 +19,7 @@ interface QuizContextType {
|
||||
setCurrentQuestionIndex: (index: number) => void;
|
||||
answers: Record<string, string | string[]>;
|
||||
setAnswer: (questionId: string, answer: string | string[]) => void;
|
||||
setAnswers: (answers: Record<string, string | string[]>) => void;
|
||||
clearQuiz: () => void;
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ export const QuizProvider = ({ children }: { children: ReactNode }) => {
|
||||
setCurrentQuestionIndex,
|
||||
answers,
|
||||
setAnswer,
|
||||
setAnswers,
|
||||
clearQuiz
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -105,6 +105,15 @@ export const quizAPI = {
|
||||
api.get('/quiz/records', { params }),
|
||||
};
|
||||
|
||||
export const examSubjectAPI = {
|
||||
getSubjects: () => api.get('/exam-subjects'),
|
||||
};
|
||||
|
||||
export const examTaskAPI = {
|
||||
getTasks: () => api.get('/exam-tasks'),
|
||||
getUserTasks: (userId: string) => api.get(`/exam-tasks/user/${userId}`),
|
||||
};
|
||||
|
||||
// 管理员相关API
|
||||
export const adminAPI = {
|
||||
login: (data: { username: string; password: string }) => api.post('/admin/login', data),
|
||||
|
||||
@@ -205,6 +205,47 @@ test('管理员任务分页统计接口返回结构正确', async () => {
|
||||
assert.equal(invalidEndAtStart.status, 400);
|
||||
assert.equal(invalidEndAtStart.json?.success, false);
|
||||
|
||||
const firstGenerate = await jsonFetch(baseUrl, '/api/quiz/generate', {
|
||||
method: 'POST',
|
||||
body: { userId: userA.id, taskId: activeTaskId },
|
||||
});
|
||||
assert.equal(firstGenerate.status, 200);
|
||||
assert.equal(firstGenerate.json?.success, true);
|
||||
assert.ok(Array.isArray(firstGenerate.json?.data?.questions));
|
||||
|
||||
await run(
|
||||
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[randomUUID(), userA.id, subjectId, activeTaskId, 10, 2, 20, new Date(now - 1000).toISOString()],
|
||||
);
|
||||
await run(
|
||||
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[randomUUID(), userA.id, subjectId, activeTaskId, 30, 6, 20, new Date(now - 900).toISOString()],
|
||||
);
|
||||
await run(
|
||||
`INSERT INTO quiz_records (id, user_id, subject_id, task_id, total_score, correct_count, total_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[randomUUID(), userA.id, subjectId, activeTaskId, 20, 4, 20, new Date(now - 800).toISOString()],
|
||||
);
|
||||
|
||||
const userTasks = await jsonFetch(baseUrl, `/api/exam-tasks/user/${userA.id}`);
|
||||
assert.equal(userTasks.status, 200);
|
||||
assert.equal(userTasks.json?.success, true);
|
||||
assert.ok(Array.isArray(userTasks.json?.data));
|
||||
assert.equal(userTasks.json?.data?.[0]?.id, activeTaskId);
|
||||
assert.equal(userTasks.json?.data?.[0]?.usedAttempts, 3);
|
||||
assert.equal(userTasks.json?.data?.[0]?.maxAttempts, 3);
|
||||
assert.equal(userTasks.json?.data?.[0]?.bestScore, 30);
|
||||
|
||||
const fourthGenerate = await jsonFetch(baseUrl, '/api/quiz/generate', {
|
||||
method: 'POST',
|
||||
body: { userId: userA.id, taskId: activeTaskId },
|
||||
});
|
||||
assert.equal(fourthGenerate.status, 400);
|
||||
assert.equal(fourthGenerate.json?.success, false);
|
||||
assert.ok(String(fourthGenerate.json?.message || '').includes('考试次数已用尽'));
|
||||
|
||||
const invalidPageFallback = await jsonFetch(baseUrl, '/api/admin/tasks/history-stats?page=0&limit=0');
|
||||
assert.equal(invalidPageFallback.status, 200);
|
||||
assert.equal(invalidPageFallback.json?.pagination?.page, 1);
|
||||
|
||||
Reference in New Issue
Block a user