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)}
+
-
- )}
+ )}
+