统计面板继续迭代
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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<ExamTask | null> {
|
||||
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
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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,仅调整写入触发条件与读取方式。
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ tables:
|
||||
selection_config:
|
||||
type: TEXT
|
||||
nullable: true
|
||||
notes: "JSON 字符串;模型在读写该列,但 init.sql 未包含该列"
|
||||
notes: "JSON 字符串;用于记录用户/用户组选择原始配置"
|
||||
created_at:
|
||||
type: DATETIME
|
||||
nullable: false
|
||||
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -51,9 +51,11 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="dashboard" element={<AdminDashboardPage />} />
|
||||
<Route path="questions" element={<QuestionManagePage />} />
|
||||
<Route path="question-bank" element={<QuestionManagePage />} />
|
||||
<Route path="categories" element={<QuestionCategoryPage />} />
|
||||
<Route path="subjects" element={<ExamSubjectPage />} />
|
||||
<Route path="tasks" element={<ExamTaskPage />} />
|
||||
<Route path="exam-tasks" element={<ExamTaskPage />} />
|
||||
<Route path="users" element={<UserManagePage />} />
|
||||
<Route path="config" element={<QuizConfigPage />} />
|
||||
<Route path="statistics" element={<StatisticsPage />} />
|
||||
@@ -72,4 +74,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -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<Statistics | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [overview, setOverview] = useState<DashboardOverview | null>(null);
|
||||
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([]);
|
||||
const [activeTasks, setActiveTasks] = useState<ActiveTaskStat[]>([]);
|
||||
const [historyTasks, setHistoryTasks] = useState<ActiveTaskStat[]>([]);
|
||||
const [upcomingTasks, setUpcomingTasks] = useState<ActiveTaskStat[]>([]);
|
||||
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 (
|
||||
<div className="flex items-center">
|
||||
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-mars-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="font-semibold text-mars-600">{progress}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div className="w-full h-20">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filteredData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={25}
|
||||
outerRadius={35}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{filteredData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
|
||||
))}
|
||||
<Label
|
||||
value={`${completionRate}%`}
|
||||
position="center"
|
||||
className="text-sm font-bold fill-gray-700"
|
||||
/>
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
formatter={(value: any) => [`${value} 人`, '数量']}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
fontSize: '12px',
|
||||
padding: '8px',
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
layout="vertical"
|
||||
verticalAlign="middle"
|
||||
align="right"
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
formatter={(value, entry: any) => (
|
||||
<span className="text-xs text-gray-600 ml-1">
|
||||
{value} {entry.payload.value}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -141,197 +358,144 @@ const AdminDashboardPage = () => {
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<Col span={6}>
|
||||
<Card
|
||||
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate('/admin/users')}
|
||||
>
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={statistics?.totalUsers || 0}
|
||||
prefix={<UserOutlined className="text-mars-400" />}
|
||||
value={overview?.totalUsers || 0}
|
||||
prefix={<TeamOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
|
||||
<Col span={6}>
|
||||
<Card
|
||||
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate('/admin/question-bank')}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-600">题库统计</div>
|
||||
<DatabaseOutlined className="text-mars-400" />
|
||||
</div>
|
||||
<div className="w-full h-24">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryPieData.filter((i) => i.value > 0)}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={25}
|
||||
outerRadius={40}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryPieData.map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={['#008C8C', '#00A3A3', '#3D9D9D', '#8CCCCC'][index % 4]}
|
||||
stroke="none"
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip formatter={(value: any) => [`${value} 题`, '数量']} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card
|
||||
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate('/admin/subjects')}
|
||||
>
|
||||
<Statistic
|
||||
title="答题记录"
|
||||
value={statistics?.totalRecords || 0}
|
||||
prefix={<BarChartOutlined className="text-mars-400" />}
|
||||
title="考试科目"
|
||||
value={overview?.activeSubjectCount || 0}
|
||||
prefix={<BookOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
suffix="个"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<Statistic
|
||||
title="平均得分"
|
||||
value={statistics?.averageScore || 0}
|
||||
precision={1}
|
||||
prefix={<QuestionCircleOutlined className="text-mars-400" />}
|
||||
valueStyle={{ color: '#008C8C' }}
|
||||
suffix="分"
|
||||
/>
|
||||
|
||||
<Col span={6}>
|
||||
<Card
|
||||
className="shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate('/admin/exam-tasks')}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-gray-600">考试任务</div>
|
||||
<CalendarOutlined className="text-mars-400" />
|
||||
</div>
|
||||
<div className="w-full h-24">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={taskStatusPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={25}
|
||||
outerRadius={40}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{taskStatusPieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
|
||||
))}
|
||||
<Label
|
||||
value={`${taskStatusPieData.reduce((sum, i) => sum + i.value, 0)}`}
|
||||
position="center"
|
||||
className="text-sm font-bold fill-gray-700"
|
||||
/>
|
||||
</Pie>
|
||||
<RechartsTooltip formatter={(value: any) => [`${value} 个`, '数量']} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 题型正确率统计 */}
|
||||
{statistics?.typeStats && statistics.typeStats.length > 0 && (
|
||||
<Card title="题型正确率统计" className="mb-8 shadow-sm">
|
||||
<Row gutter={16}>
|
||||
{statistics.typeStats.map((stat) => (
|
||||
<Col span={6} key={stat.type}>
|
||||
<Card size="small" className="text-center hover:shadow-sm transition-shadow">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{stat.type === 'single' && '单选题'}
|
||||
{stat.type === 'multiple' && '多选题'}
|
||||
{stat.type === 'judgment' && '判断题'}
|
||||
{stat.type === 'text' && '文字题'}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-mars-600">
|
||||
{stat.correctRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{stat.correct}/{stat.total}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
<Card title="历史考试任务统计" className="mb-8 shadow-sm">
|
||||
<Table
|
||||
columns={taskStatsColumns}
|
||||
dataSource={historyTasks}
|
||||
rowKey="taskId"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: historyPagination.page,
|
||||
pageSize: 5,
|
||||
total: historyPagination.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => fetchHistoryTasks(page),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 当前有效考试任务统计 */}
|
||||
{activeTasks.length > 0 && (
|
||||
<Card title="当前有效考试任务统计" className="mb-8 shadow-sm">
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
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();
|
||||
// 计算进度百分率,确保在0-100之间
|
||||
const progress = Math.max(0, Math.min(100, Math.round((elapsedDuration / totalDuration) * 100)));
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="w-32 h-4 bg-gray-200 rounded-full mr-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-mars-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="font-semibold text-mars-600">{progress}%</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div className="w-full h-20">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={filteredData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={25}
|
||||
outerRadius={35}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{filteredData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
|
||||
))}
|
||||
<Label
|
||||
value={`${completionRate}%`}
|
||||
position="center"
|
||||
className="text-sm font-bold fill-gray-700"
|
||||
/>
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
formatter={(value: any) => [`${value} 人`, '数量']}
|
||||
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', fontSize: '12px', padding: '8px' }}
|
||||
/>
|
||||
<Legend
|
||||
layout="vertical"
|
||||
verticalAlign="middle"
|
||||
align="right"
|
||||
iconType="circle"
|
||||
iconSize={8}
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
formatter={(value, entry: any) => <span className="text-xs text-gray-600 ml-1">{value} {entry.payload.value}</span>}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={activeTasks}
|
||||
rowKey="taskId"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<Card title="未开始考试任务统计" className="mb-8 shadow-sm">
|
||||
<Table
|
||||
columns={taskStatsColumns}
|
||||
dataSource={upcomingTasks}
|
||||
rowKey="taskId"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: upcomingPagination.page,
|
||||
pageSize: 5,
|
||||
total: upcomingPagination.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => fetchUpcomingTasks(page),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 最近答题记录 */}
|
||||
<Card title="最近答题记录" className="shadow-sm">
|
||||
|
||||
@@ -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 = () => {
|
||||
<TabPane tab="总体概览" key="overview">
|
||||
{/* 图表 */}
|
||||
<Row gutter={16} className="mb-8">
|
||||
<Col span={12}>
|
||||
<Card title="题型正确率对比" className="shadow-sm">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={typeChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => [`${value}%`, '正确率']} />
|
||||
<Bar dataKey="正确率" fill="#1890ff" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col span={24}>
|
||||
<Card title="分数分布" className="shadow-sm">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
166
test/admin-task-stats.test.ts
Normal file
166
test/admin-task-stats.test.ts
Normal file
@@ -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<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user