From 7fff13afb763532365e2b972b2842a273e7a3a1b Mon Sep 17 00:00:00 2001 From: XuJiacheng Date: Tue, 30 Dec 2025 11:10:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E8=80=83=E8=AF=95?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=A1=B5=E9=9D=A2=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=8A=B6=E6=80=81=E5=A4=84=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7=E4=BB=BB=E5=8A=A1=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/models/examTask.ts | 5 +- data/survey.db | Bin 647168 -> 647168 bytes package.json | 2 +- src/pages/QuizPage.tsx | 10 ++- src/pages/ResultPage.tsx | 73 +++++++++++++----- src/pages/SubjectSelectionPage.tsx | 41 ++++++---- src/services/api.ts | 25 ++++-- test/admin-task-stats.test.ts | 9 ++- test/user-tasks.test.ts | 119 +++++++++++++++++++++++++++++ 9 files changed, 234 insertions(+), 50 deletions(-) create mode 100644 test/user-tasks.test.ts diff --git a/api/models/examTask.ts b/api/models/examTask.ts index 73d1429..58a2367 100644 --- a/api/models/examTask.ts +++ b/api/models/examTask.ts @@ -582,7 +582,6 @@ export class ExamTaskModel { } static async getUserTasks(userId: string): Promise { - const now = new Date().toISOString(); const rows = await all(` SELECT t.id, @@ -606,9 +605,9 @@ export class ExamTaskModel { 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 >= ? + WHERE tu.user_id = ? ORDER BY t.start_at ASC, t.end_at ASC - `, [userId, userId, now, now]); + `, [userId, userId]); return rows.map(row => ({ id: row.id, diff --git a/data/survey.db b/data/survey.db index 0a93c873b54453a82d29bf239064bfae3d9846e5..7b37a2b2d84e74bdc11b8a7a31c1295c06d12e6c 100644 GIT binary patch delta 5379 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 H5Ay#5vy{cb delta 1548 zcma)6U1%It6rR~+v$OMa&$gsVtBFl;rAh0x^K)l*CdQ%&@waFngb0mhXJ@7rqit+_ zsDf#*rubmp7{k~WEG6xOrMiX%H&Lho1yiW0Mbub?wy}K>OHpC%OCX+^Rm_uy%bABe z-~G-x-@WHMFuUo%?55)F{M1;>gZ$J~|HxgtPDQ^>?YNuKQ-rqy@_~Da&~lbseTt zRaFgB_nG5cni%d7_>S`Q*~j@BWgpU&U^Jp8i)02#FCyhojdFa$e6~D2A7P=BgJ0kx z%)kkF69(aFcmO(K4PvWgeBjwk%Iq@qE>kOaT>dH3W2O#r^4CID`TJk^Kz%OGqs$)14gAclrQ9yWSeqhy#KfCT47Rr zo9tS-P?hV+xaDC#uyTGO2x!4^UCMmjr+Lo~Xka*oUC>-++I~03rC)MA$1p8sP{Y+M z1o{Szeo{SWF)v^aV+HkI1U|xaJ@7TlqMDxbC{taN+P0xmtzcN1?l^fhZ+0QDEpO+I z;k@a%)b><^GTpYR8<;wE0#)@2ni`m{>2REI%Q79?@Jx@Yj)}QdRin0!H)MDQb5zv{ z+`MliaGv>r?|7_0Jy$JIW-#ogz-($8y5j~pEP$Cg1fD`_%eF1Mg?h}(QD){HOzY=p zL9+~9GgPx++2z-7jRAlz#rFzL(HoD3hwPF$NlYI>Wp|{O z!?q0H(ufmUB5>8{t}b^LN~hn*S`!O(e6;QG4$U`TK_GAbsKGfuZN9ma{B1ywb#XRL(T> zy>oKC+{2NZB&~GFJ($XpD?K;(vvqPk^l>3;jmMU{w65aKU0-32DX6P1+=o||mp$nX-IY#)+mPEF4bz9<2zEM!d zIAu(^OwK_w_)sR4yoyZwmCD%m#J;t)GHUMA_iq^?>`7$94~{1`howD{XRaf8x~N$*^Kf_F9}QDyxJL38Cnaz{ui?vNjx!$~uflw7n$`z6G8vA?`GcKrd{C z2DpIeE2OgYenN=sZZ`)@Fc0V8OZXf^Zj3h<_ uMygUQ2}5m7X|a`qXUJCsl)du2yj6Nj{7!7e_Upt$t(SyK>!tM1$-e-r!>(EY diff --git a/package.json b/package.json index a3e3854..bd745a5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "tsc && vite build", "preview": "vite preview", "start": "node dist/api/server.js", - "test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts", + "test": "node --import tsx --test test/admin-task-stats.test.ts test/question-text-import.test.ts test/swipe-detect.test.ts test/user-tasks.test.ts", "check": "tsc --noEmit" }, "dependencies": { diff --git a/src/pages/QuizPage.tsx b/src/pages/QuizPage.tsx index f353732..ebb5471 100644 --- a/src/pages/QuizPage.tsx +++ b/src/pages/QuizPage.tsx @@ -347,11 +347,17 @@ const QuizPage = () => { subjectId: subjectId || undefined, taskId: taskId || undefined, answers: answersData - }); + }) as any; + + const payload = response?.data ?? response; + const recordId = payload?.recordId; + if (!recordId) { + throw new Error('提交成功但未返回记录ID'); + } message.success('答题提交成功!'); clearActiveProgress(user!.id); - navigate(`/result/${response.data.recordId}`); + navigate(`/result/${recordId}`); } catch (error: any) { message.error(error.message || '提交失败'); } finally { diff --git a/src/pages/ResultPage.tsx b/src/pages/ResultPage.tsx index 6629232..d8aaeca 100644 --- a/src/pages/ResultPage.tsx +++ b/src/pages/ResultPage.tsx @@ -32,6 +32,55 @@ interface QuizAnswer { questionAnalysis?: string; } +const StatusIcon = ({ status }: { status: QuizRecord['status'] }) => { + const colorClass = + status === '优秀' ? 'text-green-500' : status === '合格' ? 'text-blue-500' : 'text-red-500'; + + const renderInner = () => { + if (status === '优秀') { + // 五角星(实心) + return ( + + ); + } + + if (status === '合格') { + // 对号(空心) + return ; + } + + // 感叹号(空心) + return ( + <> + + + + ); + }; + + return ( + + ); +}; + const ResultPage = () => { const { id: recordId } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -53,12 +102,14 @@ const ResultPage = () => { const fetchResultDetail = async () => { try { setLoading(true); - const response = await quizAPI.getRecordDetail(recordId!); - setRecord(response.record); - setAnswers(response.answers); + const response = await quizAPI.getRecordDetail(recordId!) as any; + const payload = response?.data ?? response; + setRecord(payload?.record ?? null); + setAnswers(Array.isArray(payload?.answers) ? payload.answers : []); } catch (error: any) { message.error(error.message || '获取答题结果失败'); - navigate('/'); + setRecord(null); + setAnswers([]); } finally { setLoading(false); } @@ -203,19 +254,7 @@ const ResultPage = () => {
- {record.status === '优秀' ? ( - - ) : record.status === '合格' ? ( - - ) : ( - - )} +
diff --git a/src/pages/SubjectSelectionPage.tsx b/src/pages/SubjectSelectionPage.tsx index 655cead..12c715d 100644 --- a/src/pages/SubjectSelectionPage.tsx +++ b/src/pages/SubjectSelectionPage.tsx @@ -66,19 +66,21 @@ export const SubjectSelectionPage: React.FC = () => { }; const getTaskStatus = (task: ExamTask) => { - const now = new Date(); - const startAt = new Date(task.startAt); - const endAt = new Date(task.endAt); + const nowMs = Date.now(); + const startMs = new Date(task.startAt).getTime(); + const endMs = new Date(task.endAt).getTime(); const usedAttempts = Number(task.usedAttempts) || 0; const maxAttempts = Number(task.maxAttempts) || 3; - if (now < startAt) { - return 'notStarted'; - } else if (now >= startAt && now <= endAt && usedAttempts < maxAttempts) { - return 'ongoing'; - } else { + // 日期解析失败时,按“已完成/不可开始”处理,避免误分组 + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { return 'completed'; } + + if (nowMs < startMs) return 'notStarted'; + if (nowMs > endMs) return 'completed'; + if (usedAttempts >= maxAttempts) return 'completed'; + return 'ongoing'; }; const getTasksByStatus = (status: 'ongoing' | 'completed' | 'notStarted') => { @@ -163,7 +165,10 @@ export const SubjectSelectionPage: React.FC = () => {
- 我的考试任务 +
+ 我的考试任务 + {user && 欢迎,{user.name}} +
diff --git a/src/services/api.ts b/src/services/api.ts index 7a4cd1f..e3926b2 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,14 +1,22 @@ -import axios from 'axios'; +import axios, { AxiosInstance, AxiosResponse } from 'axios'; const API_BASE_URL = '/api'; -const api = axios.create({ +// 创建自定义Axios实例类型,考虑响应拦截器的行为 +type CustomAxiosInstance = AxiosInstance & { + post(url: string, data?: any, config?: any): Promise; + get(url: string, config?: any): Promise; + put(url: string, data?: any, config?: any): Promise; + delete(url: string, config?: any): Promise; +}; + +const api: CustomAxiosInstance = axios.create({ baseURL: API_BASE_URL, timeout: 30000, // 增加超时时间到30秒 headers: { 'Content-Type': 'application/json', }, -}); +}) as unknown as CustomAxiosInstance; // 请求拦截器 api.interceptors.request.use( @@ -95,14 +103,15 @@ export const questionAPI = { // 答题相关API export const quizAPI = { generateQuiz: (userId: string, subjectId?: string, taskId?: string) => - api.post('/quiz/generate', { userId, subjectId, taskId }), + api.post<{ questions: any[]; totalScore: number; timeLimit: number }>('/quiz/generate', { userId, subjectId, taskId }), submitQuiz: (data: { userId: string; subjectId?: string; taskId?: string; answers: any[] }) => - api.post('/quiz/submit', data), + api.post<{ recordId: string; totalScore: number; correctCount: number; totalCount: number }>('/quiz/submit', data), getUserRecords: (userId: string, params?: { page?: number; limit?: number }) => - api.get(`/quiz/records/${userId}`, { params }), - getRecordDetail: (recordId: string) => api.get(`/quiz/records/detail/${recordId}`), + api.get(`/quiz/records/${userId}`, { params }), + getRecordDetail: (recordId: string) => + api.get<{ record: any; answers: any[] }>(`/quiz/records/detail/${recordId}`), getAllRecords: (params?: { page?: number; limit?: number }) => - api.get('/quiz/records', { params }), + api.get(`/quiz/records`, { params }), }; export const examSubjectAPI = { diff --git a/test/admin-task-stats.test.ts b/test/admin-task-stats.test.ts index bf0602b..064a043 100644 --- a/test/admin-task-stats.test.ts +++ b/test/admin-task-stats.test.ts @@ -288,10 +288,11 @@ test('管理员任务分页统计接口返回结构正确', async () => { 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 activeTaskRow = (userTasks.json?.data as any[]).find((t) => t.id === activeTaskId); + assert.ok(activeTaskRow); + assert.equal(activeTaskRow.usedAttempts, 3); + assert.equal(activeTaskRow.maxAttempts, 3); + assert.equal(activeTaskRow.bestScore, 30); const fourthGenerate = await jsonFetch(baseUrl, '/api/quiz/generate', { method: 'POST', diff --git a/test/user-tasks.test.ts b/test/user-tasks.test.ts new file mode 100644 index 0000000..4716668 --- /dev/null +++ b/test/user-tasks.test.ts @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; + +process.env.NODE_ENV = 'test'; +process.env.DB_PATH = ':memory:'; + +const jsonFetch = async ( + baseUrl: string, + path: string, + options?: { method?: string; body?: unknown }, +) => { + const res = await fetch(`${baseUrl}${path}`, { + method: options?.method ?? 'GET', + headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + + const text = await res.text(); + let json: any = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { status: res.status, json, text }; +}; + +test('用户任务接口返回未开始/进行中/已结束任务(由前端分组)', async () => { + const { initDatabase, run } = await import('../api/database'); + await initDatabase(); + + const { app } = await import('../api/server'); + const server = app.listen(0); + + try { + const addr = server.address(); + assert.ok(addr && typeof addr === 'object'); + const baseUrl = `http://127.0.0.1:${addr.port}`; + + const now = Date.now(); + const userId = randomUUID(); + const subjectId = randomUUID(); + + const historyTaskId = randomUUID(); + const upcomingTaskId = randomUUID(); + const activeTaskId = randomUUID(); + + await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [ + userId, + '测试用户', + '13800138099', + '', + ]); + + await run( + `INSERT INTO exam_subjects (id, name, type_ratios, category_ratios, total_score, duration_minutes) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + subjectId, + '测试科目', + JSON.stringify({ single: 100 }), + JSON.stringify({ 通用: 100 }), + 100, + 60, + ], + ); + + const historyStartAt = new Date(now - 3 * 24 * 60 * 60 * 1000).toISOString(); + const historyEndAt = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString(); + const upcomingStartAt = new Date(now + 2 * 24 * 60 * 60 * 1000).toISOString(); + const upcomingEndAt = new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(); + const activeStartAt = new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString(); + const activeEndAt = new Date(now + 1 * 24 * 60 * 60 * 1000).toISOString(); + + await run( + `INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config) + VALUES (?, ?, ?, ?, ?, ?)`, + [historyTaskId, '历史任务', subjectId, historyStartAt, historyEndAt, null], + ); + await run( + `INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config) + VALUES (?, ?, ?, ?, ?, ?)`, + [upcomingTaskId, '未开始任务', subjectId, upcomingStartAt, upcomingEndAt, null], + ); + await run( + `INSERT INTO exam_tasks (id, name, subject_id, start_at, end_at, selection_config) + VALUES (?, ?, ?, ?, ?, ?)`, + [activeTaskId, '进行中任务', subjectId, activeStartAt, activeEndAt, null], + ); + + const linkUserToTask = async (taskId: string) => { + await run(`INSERT INTO exam_task_users (id, task_id, user_id) VALUES (?, ?, ?)`, [ + randomUUID(), + taskId, + userId, + ]); + }; + + await linkUserToTask(historyTaskId); + await linkUserToTask(upcomingTaskId); + await linkUserToTask(activeTaskId); + + const res = await jsonFetch(baseUrl, `/api/exam-tasks/user/${userId}`); + assert.equal(res.status, 200); + assert.equal(res.json?.success, true); + assert.ok(Array.isArray(res.json?.data)); + + const names = (res.json?.data as any[]).map((t) => t.name).sort(); + assert.deepEqual(names, ['历史任务', '未开始任务', '进行中任务'].sort()); + + const upcoming = (res.json?.data as any[]).find((t) => t.name === '未开始任务'); + assert.ok(upcoming); + assert.equal(typeof upcoming.startAt, 'string'); + assert.equal(typeof upcoming.endAt, 'string'); + } finally { + server.close(); + } +});