统计面板继续迭代

This commit is contained in:
2025-12-23 00:35:57 +08:00
parent 24984796cf
commit e2a1555b46
15 changed files with 875 additions and 251 deletions

View File

@@ -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(

View File

@@ -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)
);

View File

@@ -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
}));
}
}
}

View File

@@ -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();
}