统计面板继续迭代
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user