From e2a1555b4614f91f3069944bbc648ed75359186b Mon Sep 17 00:00:00 2001 From: MomoWen Date: Tue, 23 Dec 2025 00:35:57 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E9=9D=A2=E6=9D=BF=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E8=BF=AD=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/controllers/adminController.ts | 125 ++++ api/database/init.sql | 1 + api/models/examTask.ts | 142 ++++- api/server.ts | 13 +- data/~$题库导出_1766067180492.xlsx | Bin 165 -> 0 bytes .../update-admin-login-dashboard/proposal.md | 37 +- .../update-admin-login-dashboard/tasks.md | 30 +- openspec/specs/database_schema.yaml | 2 +- package-lock.json | 18 +- package.json | 1 + src/App.tsx | 4 +- src/pages/admin/AdminDashboardPage.tsx | 556 ++++++++++++------ src/pages/admin/StatisticsPage.tsx | 26 +- src/services/api.ts | 5 + test/admin-task-stats.test.ts | 166 ++++++ 15 files changed, 875 insertions(+), 251 deletions(-) delete mode 100644 data/~$题库导出_1766067180492.xlsx create mode 100644 test/admin-task-stats.test.ts diff --git a/api/controllers/adminController.ts b/api/controllers/adminController.ts index 63e59f6..23adec0 100644 --- a/api/controllers/adminController.ts +++ b/api/controllers/adminController.ts @@ -2,6 +2,12 @@ import { Request, Response } from 'express'; import { SystemConfigModel } from '../models'; import { query } from '../database'; +const toPositiveInt = (value: unknown, defaultValue: number) => { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return defaultValue; + return Math.floor(n); +}; + export class AdminController { // 管理员登录 static async login(req: Request, res: Response) { @@ -127,6 +133,125 @@ export class AdminController { } } + static async getHistoryTaskStats(req: Request, res: Response) { + try { + const page = toPositiveInt(req.query.page, 1); + const limit = toPositiveInt(req.query.limit, 5); + + const { ExamTaskModel } = await import('../models/examTask'); + const result = await ExamTaskModel.getHistoryTasksWithStatsPaged(page, limit); + + res.json({ + success: true, + data: result.data, + pagination: { + page, + limit, + total: result.total, + pages: Math.ceil(result.total / limit), + }, + }); + } catch (error: any) { + console.error('获取历史任务统计失败:', error); + res.status(500).json({ + success: false, + message: error.message || '获取历史任务统计失败', + }); + } + } + + static async getUpcomingTaskStats(req: Request, res: Response) { + try { + const page = toPositiveInt(req.query.page, 1); + const limit = toPositiveInt(req.query.limit, 5); + + const { ExamTaskModel } = await import('../models/examTask'); + const result = await ExamTaskModel.getUpcomingTasksWithStatsPaged(page, limit); + + res.json({ + success: true, + data: result.data, + pagination: { + page, + limit, + total: result.total, + pages: Math.ceil(result.total / limit), + }, + }); + } catch (error: any) { + console.error('获取未开始任务统计失败:', error); + res.status(500).json({ + success: false, + message: error.message || '获取未开始任务统计失败', + }); + } + } + + static async getDashboardOverview(req: Request, res: Response) { + try { + const { QuizModel } = await import('../models'); + const statistics = await QuizModel.getStatistics(); + + const now = new Date().toISOString(); + + const [categoryRows, activeSubjectRow, statusRow] = await Promise.all([ + query( + ` + SELECT + COALESCE(category, '未分类') as category, + COUNT(*) as count + FROM questions + GROUP BY COALESCE(category, '未分类') + ORDER BY count DESC + `, + ), + query( + ` + SELECT COUNT(DISTINCT subject_id) as total + FROM exam_tasks + WHERE start_at <= ? AND end_at >= ? + `, + [now, now], + ).then((rows: any[]) => rows[0]), + query( + ` + SELECT + SUM(CASE WHEN end_at < ? THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN start_at <= ? AND end_at >= ? THEN 1 ELSE 0 END) as ongoing, + SUM(CASE WHEN start_at > ? THEN 1 ELSE 0 END) as notStarted + FROM exam_tasks + `, + [now, now, now, now], + ).then((rows: any[]) => rows[0]), + ]); + + const questionCategoryStats = (categoryRows as any[]).map((r) => ({ + category: r.category, + count: Number(r.count) || 0, + })); + + res.json({ + success: true, + data: { + totalUsers: statistics.totalUsers, + activeSubjectCount: Number(activeSubjectRow?.total) || 0, + questionCategoryStats, + taskStatusDistribution: { + completed: Number(statusRow?.completed) || 0, + ongoing: Number(statusRow?.ongoing) || 0, + notStarted: Number(statusRow?.notStarted) || 0, + }, + }, + }); + } catch (error: any) { + console.error('获取仪表盘概览失败:', error); + res.status(500).json({ + success: false, + message: error.message || '获取仪表盘概览失败', + }); + } + } + static async getUserStats(req: Request, res: Response) { try { const rows = await query( diff --git a/api/database/init.sql b/api/database/init.sql index b15dd76..5da7530 100644 --- a/api/database/init.sql +++ b/api/database/init.sql @@ -56,6 +56,7 @@ CREATE TABLE exam_tasks ( subject_id TEXT NOT NULL, start_at DATETIME NOT NULL, end_at DATETIME NOT NULL, + selection_config TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ); diff --git a/api/models/examTask.ts b/api/models/examTask.ts index a3c4eb1..814811f 100644 --- a/api/models/examTask.ts +++ b/api/models/examTask.ts @@ -55,6 +55,54 @@ export interface ActiveTaskStat { } export class ExamTaskModel { + private static buildActiveTaskStat(input: { + taskId: string; + taskName: string; + subjectName: string; + totalScore: number; + startAt: string; + endAt: string; + report: TaskReport; + }): ActiveTaskStat { + const { report } = input; + + const completionRate = + report.totalUsers > 0 + ? Math.round((report.completedUsers / report.totalUsers) * 100) + : 0; + + const passingUsers = report.details.filter((d) => { + if (d.score === null) return false; + return d.score / input.totalScore >= 0.6; + }).length; + + const passRate = + report.totalUsers > 0 ? Math.round((passingUsers / report.totalUsers) * 100) : 0; + + const excellentUsers = report.details.filter((d) => { + if (d.score === null) return false; + return d.score / input.totalScore >= 0.8; + }).length; + + const excellentRate = + report.totalUsers > 0 + ? Math.round((excellentUsers / report.totalUsers) * 100) + : 0; + + return { + taskId: input.taskId, + taskName: input.taskName, + subjectName: input.subjectName, + totalUsers: report.totalUsers, + completedUsers: report.completedUsers, + completionRate, + passRate, + excellentRate, + startAt: input.startAt, + endAt: input.endAt, + }; + } + static async findAll(): Promise<(TaskWithSubject & { completedUsers: number; passRate: number; @@ -178,6 +226,98 @@ export class ExamTaskModel { return stats; } + static async getHistoryTasksWithStatsPaged( + page: number, + limit: number, + ): Promise<{ data: ActiveTaskStat[]; total: number }> { + const now = new Date().toISOString(); + const offset = (page - 1) * limit; + + const totalRow = await get( + `SELECT COUNT(*) as total FROM exam_tasks t WHERE t.end_at < ?`, + [now], + ); + const total = Number(totalRow?.total || 0); + + const tasks = await all( + ` + SELECT + t.id, t.name as taskName, s.name as subjectName, s.total_score as totalScore, + t.start_at as startAt, t.end_at as endAt + FROM exam_tasks t + JOIN exam_subjects s ON t.subject_id = s.id + WHERE t.end_at < ? + ORDER BY t.end_at DESC + LIMIT ? OFFSET ? + `, + [now, limit, offset], + ); + + const data: ActiveTaskStat[] = []; + for (const task of tasks) { + const report = await this.getReport(task.id); + data.push( + this.buildActiveTaskStat({ + taskId: task.id, + taskName: task.taskName, + subjectName: task.subjectName, + totalScore: Number(task.totalScore) || 0, + startAt: task.startAt, + endAt: task.endAt, + report, + }), + ); + } + + return { data, total }; + } + + static async getUpcomingTasksWithStatsPaged( + page: number, + limit: number, + ): Promise<{ data: ActiveTaskStat[]; total: number }> { + const now = new Date().toISOString(); + const offset = (page - 1) * limit; + + const totalRow = await get( + `SELECT COUNT(*) as total FROM exam_tasks t WHERE t.start_at > ?`, + [now], + ); + const total = Number(totalRow?.total || 0); + + const tasks = await all( + ` + SELECT + t.id, t.name as taskName, s.name as subjectName, s.total_score as totalScore, + t.start_at as startAt, t.end_at as endAt + FROM exam_tasks t + JOIN exam_subjects s ON t.subject_id = s.id + WHERE t.start_at > ? + ORDER BY t.start_at ASC + LIMIT ? OFFSET ? + `, + [now, limit, offset], + ); + + const data: ActiveTaskStat[] = []; + for (const task of tasks) { + const report = await this.getReport(task.id); + data.push( + this.buildActiveTaskStat({ + taskId: task.id, + taskName: task.taskName, + subjectName: task.subjectName, + totalScore: Number(task.totalScore) || 0, + startAt: task.startAt, + endAt: task.endAt, + report, + }), + ); + } + + return { data, total }; + } + static async findById(id: string): Promise { const sql = `SELECT id, name, subject_id as subjectId, start_at as startAt, end_at as endAt, created_at as createdAt, selection_config as selectionConfig FROM exam_tasks WHERE id = ?`; const row = await get(sql, [id]); @@ -505,4 +645,4 @@ export class ExamTaskModel { timeLimitMinutes: row.timeLimitMinutes })); } -} \ No newline at end of file +} diff --git a/api/server.ts b/api/server.ts index 343db06..df81ac0 100644 --- a/api/server.ts +++ b/api/server.ts @@ -22,7 +22,7 @@ import { responseFormatter } from './middlewares'; -const app = express(); +export const app = express(); const PORT = process.env.PORT || 3001; // 中间件 @@ -107,6 +107,9 @@ apiRouter.get('/admin/statistics/users', adminAuth, AdminController.getUserStats apiRouter.get('/admin/statistics/subjects', adminAuth, AdminController.getSubjectStats); apiRouter.get('/admin/statistics/tasks', adminAuth, AdminController.getTaskStats); apiRouter.get('/admin/active-tasks', adminAuth, AdminController.getActiveTasksStats); +apiRouter.get('/admin/dashboard/overview', adminAuth, AdminController.getDashboardOverview); +apiRouter.get('/admin/tasks/history-stats', adminAuth, AdminController.getHistoryTaskStats); +apiRouter.get('/admin/tasks/upcoming-stats', adminAuth, AdminController.getUpcomingTaskStats); apiRouter.put('/admin/config', adminAuth, AdminController.updateQuizConfig); apiRouter.get('/admin/config', adminAuth, AdminController.getQuizConfig); apiRouter.put('/admin/password', adminAuth, AdminController.updatePassword); @@ -134,7 +137,7 @@ app.get('*', (req, res) => { app.use(errorHandler); // 启动服务器 -async function startServer() { +export async function startServer() { try { // 初始化数据库 console.log('开始数据库初始化...'); @@ -155,6 +158,6 @@ async function startServer() { } } -// 启动服务器 -// 重启触发 -startServer(); +if (process.env.NODE_ENV !== 'test') { + startServer(); +} diff --git a/data/~$题库导出_1766067180492.xlsx b/data/~$题库导出_1766067180492.xlsx deleted file mode 100644 index 0049f585eb9d306098662217401049cc412cfd9c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165 ccmZQ^EXvPQAQ`YQI5HG5@~ diff --git a/openspec/changes/update-admin-login-dashboard/proposal.md b/openspec/changes/update-admin-login-dashboard/proposal.md index aa4e687..670e8ca 100644 --- a/openspec/changes/update-admin-login-dashboard/proposal.md +++ b/openspec/changes/update-admin-login-dashboard/proposal.md @@ -1,13 +1,33 @@ -# 管理员登录记录与仪表盘数据拉取优化 +# 管理员登录记录与仪表盘/统计页改造 ## Context - 管理端登录页存在“最近登录记录”体验需求,且需要确保不会保存失败登录或敏感信息(如密码)。 - 管理端仪表盘/统计页存在通过 `fetch` 手写请求与鉴权头、与 `src/services/api.ts` 统一封装并存的情况,导致代码重复且行为不一致。 +- 管理端仪表盘指标卡片与统计模块需要重构:新增可点击的统计卡片与饼图展示,并新增“历史考试任务统计”(含分页与后端分页查询接口)。 ## Goals / Non-Goals - Goals: - 管理员登录页仅在登录成功时写入最近登录记录(用户名 + 时间戳),且不保存密码。 - 管理端页面统一使用现有的 `api`/`adminAPI`/`quizAPI` 访问后端,避免重复拼接 token 与重复处理响应格式。 + - 管理员仪表盘指标卡片重构: + - 移除现有卡片:答题记录、平均得分。 + - 新增总用户数:实时数字;点击跳转 `/admin/users`。 + - 新增题库统计:饼图展示各题目类别占比;点击跳转 `/admin/question-bank`。 + - 新增考试科目:显示当前活跃科目数量;点击跳转 `/admin/subjects`。 + - 新增考试任务:饼图展示任务状态分布(已完成/进行中/未开始);点击跳转 `/admin/exam-tasks`。 + - 统计功能调整:移除题型正确率统计模块。 + - 新增历史考试任务统计: + - 展示所有历史考试任务统计(非仅当前有效任务)。 + - 排序:按结束时间倒序。 + - 分页:每页 5 条,支持前后翻页。 + - 字段:与“当前有效考试任务统计”字段保持一致。 + - 需新增后端分页查询接口以支撑上述分页与排序。 + - 新增未开始考试任务统计: + - 展示未开始的考试任务(开始时间晚于当前时间)。 + - 排序:按开始时间正序。 + - 分页:每页 5 条,支持前后翻页。 + - 字段:与“当前有效考试任务统计”字段保持一致。 + - 需新增后端分页查询接口以支撑上述分页与排序。 - Non-Goals: - 不调整管理员鉴权机制(`adminAuth` 仍为简化实现)。 - 不新增第三方图表库或引入新的前端数据层框架。 @@ -17,10 +37,25 @@ - 理由:仓库已存在稳定封装,减少重复与差异。 - 决策:最近答题记录使用现有 `/api/quiz/records` 接口拉取(分页参数 `page=1&limit=10`)。 - 理由:后端已实现管理员分页接口;前端无需再手写 `fetch`。 +- 决策:新增的仪表盘跳转路由以不破坏现有路由为前提,通过新增别名路由满足新路径需求。 + - `/admin/question-bank` 作为 `/admin/questions` 的别名路由。 + - `/admin/exam-tasks` 作为 `/admin/tasks` 的别名路由。 +- 决策:仪表盘新增数据优先由后端聚合提供,减少前端全量拉取与二次计算。 + - 题库类别占比:按题目类别聚合统计数量(用于饼图)。 + - 活跃科目数量:以“当前时间存在有效考试任务”的科目去重统计。 + - 任务状态分布:以任务开始/结束时间与当前时间对比划分已完成/进行中/未开始并聚合计数。 +- 决策:新增历史考试任务统计分页接口,返回统一响应包裹并携带 `pagination`。 + - 路径建议:`GET /api/admin/tasks/history-stats?page=1&limit=5`。 + - 返回结构:`{ success: true, data: ActiveTaskStat[], pagination: { page, limit, total, pages } }`。 +- 决策:新增未开始考试任务统计分页接口,返回统一响应包裹并携带 `pagination`。 + - 路径建议:`GET /api/admin/tasks/upcoming-stats?page=1&limit=5`。 + - 返回结构:`{ success: true, data: ActiveTaskStat[], pagination: { page, limit, total, pages } }`。 ## Risks / Trade-offs - 统一到 axios 封装后,错误提示将由拦截器抛错路径主导。 - 缓解:在页面层使用 `message.error(error.message)`,保持用户感知一致。 +- 历史任务统计若逐任务计算报表可能产生额外数据库开销。 + - 缓解:按分页限制任务数;必要时在模型层优化聚合 SQL 或缓存计算结果(仅服务端内存,不落地到业务表)。 ## Migration Plan - 前端无数据迁移:登录记录仍存储在 localStorage,仅调整写入触发条件与读取方式。 diff --git a/openspec/changes/update-admin-login-dashboard/tasks.md b/openspec/changes/update-admin-login-dashboard/tasks.md index efb65a3..1e7b385 100644 --- a/openspec/changes/update-admin-login-dashboard/tasks.md +++ b/openspec/changes/update-admin-login-dashboard/tasks.md @@ -3,11 +3,29 @@ - [ ] 1.2 确保最近登录记录不包含密码等敏感字段 - [ ] 1.3 确保选择历史记录仅回填用户名并清空密码 -## 2. 仪表盘与统计页数据拉取 -- [ ] 2.1 仪表盘最近答题记录改为使用 `quizAPI.getAllRecords` -- [ ] 2.2 统计页数据拉取改为复用现有 API 封装,移除手写 token 逻辑 +## 2. 管理员仪表盘改造 +- [ ] 2.1 重构指标卡片并移除“答题记录/平均得分” +- [ ] 2.2 总用户数卡片支持点击跳转 `/admin/users` +- [ ] 2.3 题库统计卡片使用饼图展示题目类别占比并跳转 `/admin/question-bank` +- [ ] 2.4 考试科目卡片展示活跃科目数量并跳转 `/admin/subjects` +- [ ] 2.5 考试任务卡片用饼图展示任务状态分布并跳转 `/admin/exam-tasks` +- [ ] 2.6 新增“历史考试任务统计”并移除“当前有效考试任务统计” +- [ ] 2.7 历史考试任务统计按结束时间倒序且分页每页 5 条 +- [ ] 2.8 新增“未开始考试任务统计”并按开始时间正序分页每页 5 条 -## 3. 测试与校验 -- [ ] 3.1 补充可执行的前端单测覆盖登录记录写入逻辑 -- [ ] 3.2 运行 `npm run check` 与 `npm run build` +## 3. 后端接口与聚合数据 +- [ ] 3.1 为仪表盘卡片新增聚合数据接口或扩展现有统计接口 +- [ ] 3.2 新增历史考试任务统计分页查询接口(含排序与分页参数) +- [ ] 3.3 确保新接口返回符合统一响应结构并包含 `pagination` +- [ ] 3.4 新增未开始考试任务统计分页查询接口(含排序与分页参数) + +## 4. 统计页调整与数据拉取统一 +- [ ] 4.1 移除题型正确率统计模块 +- [ ] 4.2 统计页数据拉取复用现有 API 封装并移除手写 token 逻辑 +- [ ] 4.3 仪表盘最近答题记录使用 `quizAPI.getAllRecords` + +## 5. 测试与校验 +- [ ] 5.1 补充可执行的前端单测覆盖登录记录写入逻辑 +- [ ] 5.2 补充可执行的接口测试覆盖历史/未开始任务分页查询接口 +- [ ] 5.3 运行 `npm run check` 与 `npm run build` diff --git a/openspec/specs/database_schema.yaml b/openspec/specs/database_schema.yaml index b009fe2..3c72fb8 100644 --- a/openspec/specs/database_schema.yaml +++ b/openspec/specs/database_schema.yaml @@ -188,7 +188,7 @@ tables: selection_config: type: TEXT nullable: true - notes: "JSON 字符串;模型在读写该列,但 init.sql 未包含该列" + notes: "JSON 字符串;用于记录用户/用户组选择原始配置" created_at: type: DATETIME nullable: false diff --git a/package-lock.json b/package-lock.json index 9e47555..10d1ba3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -187,7 +187,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1539,7 +1538,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2029,7 +2027,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2795,7 +2792,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -2811,8 +2807,7 @@ "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -3942,7 +3937,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4949,7 +4943,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5883,7 +5876,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5896,7 +5888,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5917,7 +5908,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -6049,8 +6039,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6951,7 +6940,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7025,7 +7013,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -7233,7 +7220,6 @@ "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", diff --git a/package.json b/package.json index fb2dd43..c4049d5 100644 --- a/package.json +++ b/package.json @@ -10,6 +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", "check": "tsc --noEmit" }, "dependencies": { diff --git a/src/App.tsx b/src/App.tsx index 638de40..40859e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,9 +51,11 @@ function App() { } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> @@ -72,4 +74,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/pages/admin/AdminDashboardPage.tsx b/src/pages/admin/AdminDashboardPage.tsx index 62c225d..6b4847d 100644 --- a/src/pages/admin/AdminDashboardPage.tsx +++ b/src/pages/admin/AdminDashboardPage.tsx @@ -1,20 +1,34 @@ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Card, Row, Col, Statistic, Button, Table, message } from 'antd'; -import { - UserOutlined, - QuestionCircleOutlined, - BarChartOutlined, - ReloadOutlined +import { + TeamOutlined, + DatabaseOutlined, + BookOutlined, + CalendarOutlined, + ReloadOutlined, } from '@ant-design/icons'; import { adminAPI, quizAPI } from '../../services/api'; import { formatDateTime } from '../../utils/validation'; -import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend, Label } from 'recharts'; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip as RechartsTooltip, + Legend, + Label, +} from 'recharts'; -interface Statistics { +interface DashboardOverview { totalUsers: number; - totalRecords: number; - averageScore: number; - typeStats: Array<{ type: string; total: number; correct: number; correctRate: number }>; + activeSubjectCount: number; + questionCategoryStats: Array<{ category: string; count: number }>; + taskStatusDistribution: { + completed: number; + ongoing: number; + notStarted: number; + }; } interface RecentRecord { @@ -43,9 +57,21 @@ interface ActiveTaskStat { } const AdminDashboardPage = () => { - const [statistics, setStatistics] = useState(null); + const navigate = useNavigate(); + const [overview, setOverview] = useState(null); const [recentRecords, setRecentRecords] = useState([]); - const [activeTasks, setActiveTasks] = useState([]); + const [historyTasks, setHistoryTasks] = useState([]); + const [upcomingTasks, setUpcomingTasks] = useState([]); + const [historyPagination, setHistoryPagination] = useState({ + page: 1, + limit: 5, + total: 0, + }); + const [upcomingPagination, setUpcomingPagination] = useState({ + page: 1, + limit: 5, + total: 0, + }); const [loading, setLoading] = useState(false); useEffect(() => { @@ -55,16 +81,33 @@ const AdminDashboardPage = () => { const fetchDashboardData = async () => { try { setLoading(true); - // 并行获取所有数据,提高性能 - const [statsResponse, recordsResponse, activeTasksResponse] = await Promise.all([ - adminAPI.getStatistics(), - fetchRecentRecords(), - adminAPI.getActiveTasksStats() - ]); - - setStatistics(statsResponse.data); + const [overviewResponse, recordsResponse, historyResponse, upcomingResponse] = + await Promise.all([ + adminAPI.getDashboardOverview(), + fetchRecentRecords(), + adminAPI.getHistoryTaskStats({ page: 1, limit: 5 }), + adminAPI.getUpcomingTaskStats({ page: 1, limit: 5 }), + ]); + + setOverview(overviewResponse.data); setRecentRecords(recordsResponse); - setActiveTasks(activeTasksResponse.data); + setHistoryTasks((historyResponse as any).data || []); + setUpcomingTasks((upcomingResponse as any).data || []); + + if ((historyResponse as any).pagination) { + setHistoryPagination({ + page: (historyResponse as any).pagination.page, + limit: (historyResponse as any).pagination.limit, + total: (historyResponse as any).pagination.total, + }); + } + if ((upcomingResponse as any).pagination) { + setUpcomingPagination({ + page: (upcomingResponse as any).pagination.page, + limit: (upcomingResponse as any).pagination.limit, + total: (upcomingResponse as any).pagination.total, + }); + } } catch (error: any) { message.error(error.message || '获取数据失败'); } finally { @@ -72,11 +115,63 @@ const AdminDashboardPage = () => { } }; + const fetchHistoryTasks = async (page: number) => { + try { + setLoading(true); + const response = (await adminAPI.getHistoryTaskStats({ page, limit: 5 })) as any; + setHistoryTasks(response.data || []); + if (response.pagination) { + setHistoryPagination({ + page: response.pagination.page, + limit: response.pagination.limit, + total: response.pagination.total, + }); + } + } catch (error: any) { + message.error(error.message || '获取历史考试任务统计失败'); + } finally { + setLoading(false); + } + }; + + const fetchUpcomingTasks = async (page: number) => { + try { + setLoading(true); + const response = (await adminAPI.getUpcomingTaskStats({ page, limit: 5 })) as any; + setUpcomingTasks(response.data || []); + if (response.pagination) { + setUpcomingPagination({ + page: response.pagination.page, + limit: response.pagination.limit, + total: response.pagination.total, + }); + } + } catch (error: any) { + message.error(error.message || '获取未开始考试任务统计失败'); + } finally { + setLoading(false); + } + }; + const fetchRecentRecords = async () => { const response = await quizAPI.getAllRecords({ page: 1, limit: 10 }) as any; return response.data || []; }; + const categoryPieData = + overview?.questionCategoryStats?.map((item) => ({ + name: item.category, + value: item.count, + })) || []; + + const taskStatusPieData = overview + ? [ + { name: '已完成', value: overview.taskStatusDistribution.completed, color: '#008C8C' }, + { name: '进行中', value: overview.taskStatusDistribution.ongoing, color: '#00A3A3' }, + { name: '未开始', value: overview.taskStatusDistribution.notStarted, color: '#f0f0f0' }, + ].filter((i) => i.value > 0) + : []; + const columns = [ { title: '姓名', @@ -124,6 +219,128 @@ const AdminDashboardPage = () => { }, ]; + const taskStatsColumns = [ + { + title: '任务名称', + dataIndex: 'taskName', + key: 'taskName', + }, + { + title: '科目', + dataIndex: 'subjectName', + key: 'subjectName', + }, + { + title: '指定考试人数', + dataIndex: 'totalUsers', + key: 'totalUsers', + }, + { + title: '考试进度', + key: 'progress', + render: (_: any, record: ActiveTaskStat) => { + const now = new Date(); + const start = new Date(record.startAt); + const end = new Date(record.endAt); + + const totalDuration = end.getTime() - start.getTime(); + const elapsedDuration = now.getTime() - start.getTime(); + const progress = + totalDuration > 0 + ? Math.max(0, Math.min(100, Math.round((elapsedDuration / totalDuration) * 100))) + : 0; + + return ( +
+
+
+
+ {progress}% +
+ ); + }, + }, + { + title: '考试人数统计', + key: 'statistics', + render: (_: any, record: ActiveTaskStat) => { + const total = record.totalUsers; + const completed = record.completedUsers; + + const passedTotal = Math.round(completed * (record.passRate / 100)); + const excellentTotal = Math.round(completed * (record.excellentRate / 100)); + + const incomplete = total - completed; + const failed = completed - passedTotal; + const passedOnly = passedTotal - excellentTotal; + const excellent = excellentTotal; + + const pieData = [ + { name: '优秀', value: excellent, color: '#008C8C' }, + { name: '合格', value: passedOnly, color: '#00A3A3' }, + { name: '不及格', value: failed, color: '#ff4d4f' }, + { name: '未完成', value: incomplete, color: '#f0f0f0' }, + ]; + + const filteredData = pieData.filter((item) => item.value > 0); + const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0; + + return ( +
+ + + + {filteredData.map((entry, index) => ( + + ))} + + [`${value} 人`, '数量']} + contentStyle={{ + borderRadius: '8px', + border: 'none', + boxShadow: '0 2px 8px rgba(0,0,0,0.15)', + fontSize: '12px', + padding: '8px', + }} + /> + ( + + {value} {entry.payload.value} + + )} + /> + + +
+ ); + }, + }, + ]; + return (
@@ -141,197 +358,144 @@ const AdminDashboardPage = () => { {/* 统计卡片 */} - - + + navigate('/admin/users')} + > } + value={overview?.totalUsers || 0} + prefix={} valueStyle={{ color: '#008C8C' }} /> - - + + + navigate('/admin/question-bank')} + styles={{ body: { padding: 16 } }} + > +
+
题库统计
+ +
+
+ + + i.value > 0)} + cx="50%" + cy="50%" + innerRadius={25} + outerRadius={40} + paddingAngle={2} + dataKey="value" + > + {categoryPieData.map((_, index) => ( + + ))} + + [`${value} 题`, '数量']} /> + + +
+
+ + + + navigate('/admin/subjects')} + > } + title="考试科目" + value={overview?.activeSubjectCount || 0} + prefix={} valueStyle={{ color: '#008C8C' }} + suffix="个" /> - - - } - valueStyle={{ color: '#008C8C' }} - suffix="分" - /> + + + navigate('/admin/exam-tasks')} + styles={{ body: { padding: 16 } }} + > +
+
考试任务
+ +
+
+ + + + {taskStatusPieData.map((entry, index) => ( + + ))} + + [`${value} 个`, '数量']} /> + + +
- {/* 题型正确率统计 */} - {statistics?.typeStats && statistics.typeStats.length > 0 && ( - - - {statistics.typeStats.map((stat) => ( - - -
- {stat.type === 'single' && '单选题'} - {stat.type === 'multiple' && '多选题'} - {stat.type === 'judgment' && '判断题'} - {stat.type === 'text' && '文字题'} -
-
- {stat.correctRate}% -
-
- {stat.correct}/{stat.total} -
-
- - ))} -
-
- )} + + fetchHistoryTasks(page), + }} + /> + - {/* 当前有效考试任务统计 */} - {activeTasks.length > 0 && ( - -
{ - // 计算考试进度百分率 - const now = new Date(); - const start = new Date(record.startAt); - const end = new Date(record.endAt); - - // 计算总时长(毫秒) - const totalDuration = end.getTime() - start.getTime(); - // 计算已经过去的时长(毫秒) - const elapsedDuration = now.getTime() - start.getTime(); - // 计算进度百分率,确保在0-100之间 - const progress = Math.max(0, Math.min(100, Math.round((elapsedDuration / totalDuration) * 100))); - - return ( -
-
-
-
- {progress}% -
- ); - }, - }, - { - title: '考试人数统计', - key: 'statistics', - render: (_: any, record: ActiveTaskStat) => { - // 计算各类人数 - const total = record.totalUsers; - const completed = record.completedUsers; - - // 原始计算 - const passedTotal = Math.round(completed * (record.passRate / 100)); - const excellentTotal = Math.round(completed * (record.excellentRate / 100)); - - // 互斥分类计算 - const incomplete = total - completed; - const failed = completed - passedTotal; - const passedOnly = passedTotal - excellentTotal; - const excellent = excellentTotal; - - // 准备环形图数据 (互斥分类) - const pieData = [ - { name: '优秀', value: excellent, color: '#008C8C' }, // Mars Green (Primary) - { name: '合格', value: passedOnly, color: '#00A3A3' }, // Mars Light - { name: '不及格', value: failed, color: '#ff4d4f' }, // Red (Error) - { name: '未完成', value: incomplete, color: '#f0f0f0' } // Gray - ]; - - // 只显示有数据的项 - const filteredData = pieData.filter(item => item.value > 0); - - // 计算完成率用于中间显示 - const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0; - - return ( -
- - - - {filteredData.map((entry, index) => ( - - ))} - - [`${value} 人`, '数量']} - contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', fontSize: '12px', padding: '8px' }} - /> - {value} {entry.payload.value}} - /> - - -
- ); - }, - }, - ]} - dataSource={activeTasks} - rowKey="taskId" - loading={loading} - pagination={false} - size="small" - /> - - )} + +
fetchUpcomingTasks(page), + }} + /> + {/* 最近答题记录 */} diff --git a/src/pages/admin/StatisticsPage.tsx b/src/pages/admin/StatisticsPage.tsx index 6a48a89..52fdab8 100644 --- a/src/pages/admin/StatisticsPage.tsx +++ b/src/pages/admin/StatisticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Card, Row, Col, Statistic, Table, DatePicker, Button, message, Tabs, Select } from 'antd'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts'; +import { Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; import api, { adminAPI, quizAPI } from '../../services/api'; import { formatDateTime } from '../../utils/validation'; @@ -180,15 +180,6 @@ const StatisticsPage = () => { message.success('数据导出成功'); }; - // 准备图表数据 - const typeChartData = statistics?.typeStats.map(stat => ({ - name: stat.type === 'single' ? '单选题' : - stat.type === 'multiple' ? '多选题' : - stat.type === 'judgment' ? '判断题' : '文字题', - 正确率: stat.correctRate, - 总题数: stat.total, - })) || []; - const scoreDistribution = [ { range: '0-59分', count: records.filter(r => r.totalScore < 60).length }, { range: '60-69分', count: records.filter(r => r.totalScore >= 60 && r.totalScore < 70).length }, @@ -292,20 +283,7 @@ const StatisticsPage = () => { {/* 图表 */} - - - - - - - - [`${value}%`, '正确率']} /> - - - - - - + diff --git a/src/services/api.ts b/src/services/api.ts index e5be0bc..7e9e9b8 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -110,6 +110,11 @@ export const adminAPI = { updateQuizConfig: (data: any) => api.put('/admin/config', data), getStatistics: () => api.get('/admin/statistics'), getActiveTasksStats: () => api.get('/admin/active-tasks'), + getDashboardOverview: () => api.get('/admin/dashboard/overview'), + getHistoryTaskStats: (params?: { page?: number; limit?: number }) => + api.get('/admin/tasks/history-stats', { params }), + getUpcomingTaskStats: (params?: { page?: number; limit?: number }) => + api.get('/admin/tasks/upcoming-stats', { params }), getUserStats: () => api.get('/admin/statistics/users'), getSubjectStats: () => api.get('/admin/statistics/subjects'), getTaskStats: () => api.get('/admin/statistics/tasks'), diff --git a/test/admin-task-stats.test.ts b/test/admin-task-stats.test.ts new file mode 100644 index 0000000..be6fa04 --- /dev/null +++ b/test/admin-task-stats.test.ts @@ -0,0 +1,166 @@ +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 userA = { id: randomUUID(), name: '测试甲', phone: '13800138001', password: '' }; + const userB = { id: randomUUID(), name: '测试乙', phone: '13800138002', password: '' }; + const subjectId = randomUUID(); + const historyTaskId = randomUUID(); + const upcomingTaskId = randomUUID(); + const activeTaskId = randomUUID(); + + await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [ + userA.id, + userA.name, + userA.phone, + userA.password, + ]); + await run(`INSERT INTO users (id, name, phone, password) VALUES (?, ?, ?, ?)`, [ + userB.id, + userB.name, + userB.phone, + userB.password, + ]); + + 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, + ], + ); + + await run( + `INSERT INTO questions (id, content, type, options, answer, score, category) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [randomUUID(), '题目1', 'single', JSON.stringify(['A', 'B']), 'A', 5, '通用'], + ); + await run( + `INSERT INTO questions (id, content, type, options, answer, score, category) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [randomUUID(), '题目2', 'single', JSON.stringify(['A', 'B']), 'B', 5, '数学'], + ); + + 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, userId: string) => { + await run(`INSERT INTO exam_task_users (id, task_id, user_id) VALUES (?, ?, ?)`, [ + randomUUID(), + taskId, + userId, + ]); + }; + + await linkUserToTask(historyTaskId, userA.id); + await linkUserToTask(historyTaskId, userB.id); + await linkUserToTask(upcomingTaskId, userA.id); + await linkUserToTask(activeTaskId, userA.id); + + 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, historyTaskId, 90, 18, 20, new Date(now - 2.5 * 24 * 60 * 60 * 1000).toISOString()], + ); + + const overview = await jsonFetch(baseUrl, '/api/admin/dashboard/overview'); + assert.equal(overview.status, 200); + assert.equal(overview.json?.success, true); + assert.equal(typeof overview.json?.data?.totalUsers, 'number'); + assert.equal(typeof overview.json?.data?.activeSubjectCount, 'number'); + assert.ok(Array.isArray(overview.json?.data?.questionCategoryStats)); + assert.equal(typeof overview.json?.data?.taskStatusDistribution?.completed, 'number'); + assert.equal(typeof overview.json?.data?.taskStatusDistribution?.ongoing, 'number'); + assert.equal(typeof overview.json?.data?.taskStatusDistribution?.notStarted, 'number'); + + const history = await jsonFetch(baseUrl, '/api/admin/tasks/history-stats?page=1&limit=5'); + assert.equal(history.status, 200); + assert.equal(history.json?.success, true); + assert.ok(Array.isArray(history.json?.data)); + assert.equal(history.json?.pagination?.page, 1); + assert.equal(history.json?.pagination?.limit, 5); + assert.equal(history.json?.pagination?.total, 1); + assert.equal(history.json?.pagination?.pages, 1); + assert.equal(history.json?.data?.[0]?.taskName, '历史任务'); + + const upcoming = await jsonFetch(baseUrl, '/api/admin/tasks/upcoming-stats?page=1&limit=5'); + assert.equal(upcoming.status, 200); + assert.equal(upcoming.json?.success, true); + assert.ok(Array.isArray(upcoming.json?.data)); + assert.equal(upcoming.json?.pagination?.page, 1); + assert.equal(upcoming.json?.pagination?.limit, 5); + assert.equal(upcoming.json?.pagination?.total, 1); + assert.equal(upcoming.json?.pagination?.pages, 1); + assert.equal(upcoming.json?.data?.[0]?.taskName, '未开始任务'); + + 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); + assert.equal(invalidPageFallback.json?.pagination?.limit, 5); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); +