From 7b52abfea3296523a563ba984947c101e74f4282 Mon Sep 17 00:00:00 2001
From: MomoWen
Date: Thu, 25 Dec 2025 21:54:52 +0800
Subject: [PATCH] =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AB=AF=E9=A1=B5=E9=9D=A2?=
=?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=AE=8C=E6=88=90---=E5=BE=85=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
api/controllers/quizController.ts | 18 +-
api/models/examTask.ts | 59 ++++-
data/survey.db | Bin 647168 -> 647168 bytes
...~$威(Boonlive)管理层知识考核题库(1).docx | Bin 162 -> 0 bytes
.../add-user-exam-workflow/proposal.md | 23 ++
.../specs/user-exam-portal/spec.md | 105 ++++++++
.../changes/add-user-exam-workflow/tasks.md | 15 ++
src/contexts/QuizContext.tsx | 2 +
src/pages/QuizPage.tsx | 249 ++++++++++++++----
src/pages/SubjectSelectionPage.tsx | 93 ++++---
src/pages/UserTaskPage.tsx | 38 ++-
src/services/api.ts | 9 +
test/admin-task-stats.test.ts | 41 +++
13 files changed, 539 insertions(+), 113 deletions(-)
delete mode 100644 data/~$威(Boonlive)管理层知识考核题库(1).docx
create mode 100644 openspec/changes/add-user-exam-workflow/proposal.md
create mode 100644 openspec/changes/add-user-exam-workflow/specs/user-exam-portal/spec.md
create mode 100644 openspec/changes/add-user-exam-workflow/tasks.md
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 c32203f49ba9cf501d5eee55245e680b96992fd0..d93c6d10a3e774008f0170b68377853e2c00ae86 100644
GIT binary patch
delta 1762
zcmb7_%WKp?9LF=8-EDSPI~89CdsuO$1sRxEGMOSOh!??|dn}k_6E6k*57G(-9|+1?
z7rm&|2ZGedqJrzA_y>r`-c)o~J^ARtOAk&`9|((#A%~EBC*SYyGc&)LU7DC(nmE4=
zL>I>QgXsQ|8&}H!&KPi}TfOE(JhuTq?noY9m4swd;gU*=&l+4*m=pmtKK#aoK>&-D>(0(h0L!(fN7R
zFbn|K#x1>s#cZ51+*!%re=^{wZuQk`xND2aDnqQ-D_Oa=VnA>7#psUp=hiLNR@!54
z>RtZkdF}Ar!Lq8fIR{28Rhc6gr#@oBA*xcFAkT|@#CZ_eSVXQ(nNHx&p3rJ^wjZ@*
zJiYhp8k8Zy<_?yATZgTPc>)s@aNJUc!a=T32rOn
zoAV^c{+VQB(NYk0S{AcC#<99lq>K@dC__@TTx8>rI3A`f^tld$`L|{x{_yn5;O?`*
z#fSZqH-`ZjH#1wIY=sc2aOtSh2}fKAjHDYnNHEMWbpyNQOC4Jt@z8d;M8d~T`#`)k
z?mwCD-+S`DcWH1o`FMNbeXrL)bA4!-!mltm5VlJ>LjfUbGJNVG$y6fW_DFzzsUQiR
zFjxk5JB;gbzxVj#;^N@+f&%iPcV{qv>fP*#p&7uh47M*@p(GMfM$`&qet=wJ2S|vp
z)ema#h>kJ`q9e?K=m`G?
zqF`iDI`uJdq=U!6x<|8P;OuA~Fz&vyUk_*gaIYWCq9ESjcKNzy`+nn_wr(o&L^lXN6Wty^gY{{Toq
B-Rl4V
delta 1092
zcmZvbO=uHA6vwmq*rwZ>Lv&VJ>l9u(@K9!z-OWzwO~IbLH1&{!
zT5MP)7W`;Yq^D#{&|VY+f+x{>^JWASTM=xP+7>TPHk+hK*Gb6&*(e#ADs#^JXpoME-5jpHvRvjW-U=;rvfImu9xiih
zpQD3x*w=Q@DSXJoY`z+|VKw;wSBBL$J@)rquCNxS4!1T2y3Qm{CC(Q_RP?#BDgt7Bvx_-Y@o6pxD%++o#)fSf5ORM#j8})lmeXjWPxOkki40+?VC7^;SVWLT=IOHhHx3{ditlrdF_m>D2zngXy)U6_Oz0HL%-(z->pO}H1hP)gqj+gCurzu
z*r(TpP*bA!7@c$_?2BK79M8Pq{lTm54$;isU$jky^
bGcd3)qySAyW~czlK-eW 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)}
+
-
- )}
+ )}
+