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