diff --git a/api/controllers/quizController.ts b/api/controllers/quizController.ts index 719c321..3111cd6 100644 --- a/api/controllers/quizController.ts +++ b/api/controllers/quizController.ts @@ -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 { }); } } -} \ No newline at end of file +} diff --git a/api/models/examTask.ts b/api/models/examTask.ts index 35c91af..4dde73e 100644 --- a/api/models/examTask.ts +++ b/api/models/examTask.ts @@ -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 { + static async getUserTasks(userId: string): Promise { 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, })); } } diff --git a/data/survey.db b/data/survey.db index c32203f..d93c6d1 100644 Binary files a/data/survey.db and b/data/survey.db differ diff --git a/data/~$威(Boonlive)管理层知识考核题库(1).docx b/data/~$威(Boonlive)管理层知识考核题库(1).docx deleted file mode 100644 index 0a94eda..0000000 Binary files a/data/~$威(Boonlive)管理层知识考核题库(1).docx and /dev/null differ diff --git a/openspec/changes/add-user-exam-workflow/proposal.md b/openspec/changes/add-user-exam-workflow/proposal.md new file mode 100644 index 0000000..302fc41 --- /dev/null +++ b/openspec/changes/add-user-exam-workflow/proposal.md @@ -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` + diff --git a/openspec/changes/add-user-exam-workflow/specs/user-exam-portal/spec.md b/openspec/changes/add-user-exam-workflow/specs/user-exam-portal/spec.md new file mode 100644 index 0000000..3754fee --- /dev/null +++ b/openspec/changes/add-user-exam-workflow/specs/user-exam-portal/spec.md @@ -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** 仅在用户确认后才执行弃考清理逻辑 + diff --git a/openspec/changes/add-user-exam-workflow/tasks.md b/openspec/changes/add-user-exam-workflow/tasks.md new file mode 100644 index 0000000..01537fc --- /dev/null +++ b/openspec/changes/add-user-exam-workflow/tasks.md @@ -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 前端:进度自动保存/恢复、题目导航、关键按钮二次确认 + diff --git a/src/contexts/QuizContext.tsx b/src/contexts/QuizContext.tsx index 1d242da..7e1d251 100644 --- a/src/contexts/QuizContext.tsx +++ b/src/contexts/QuizContext.tsx @@ -19,6 +19,7 @@ interface QuizContextType { setCurrentQuestionIndex: (index: number) => void; answers: Record; setAnswer: (questionId: string, answer: string | string[]) => void; + setAnswers: (answers: Record) => void; clearQuiz: () => void; } @@ -50,6 +51,7 @@ export const QuizProvider = ({ children }: { children: ReactNode }) => { setCurrentQuestionIndex, answers, setAnswer, + setAnswers, clearQuiz }}> {children} diff --git a/src/pages/QuizPage.tsx b/src/pages/QuizPage.tsx index d2d4bab..a4487fe 100644 --- a/src/pages/QuizPage.tsx +++ b/src/pages/QuizPage.tsx @@ -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(null); const [timeLimit, setTimeLimit] = useState(null); const [subjectId, setSubjectId] = useState(''); const [taskId, setTaskId] = useState(''); - const [showAnalysis, setShowAnalysis] = useState(false); + const lastTickSavedAtMsRef = useRef(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} 题

- {timeLeft !== null && ( -
-
剩余时间
-
- {formatTime(timeLeft)} +
+ + {timeLeft !== null && ( +
+
剩余时间
+
+ {formatTime(timeLeft)} +
-
- )} + )} +
{ {questionTypeMap[currentQuestion.type]} - - {currentQuestion.score} 分 - {currentQuestion.category && (
@@ -341,23 +442,6 @@ const QuizPage = () => { {renderQuestion(currentQuestion)}
- {String(currentQuestion.analysis ?? '').trim() ? ( -
- - {showAnalysis ? ( -
- {currentQuestion.analysis} -
- ) : null} -
- ) : null} - {/* 操作按钮 */}
); diff --git a/src/services/api.ts b/src/services/api.ts index f563a4f..7a4cd1f 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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), diff --git a/test/admin-task-stats.test.ts b/test/admin-task-stats.test.ts index c4d9867..d77963a 100644 --- a/test/admin-task-stats.test.ts +++ b/test/admin-task-stats.test.ts @@ -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);