From 1d406a50e4be3d8e769555350810acf096a3dcbb Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 13 Oct 2026 14:17:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E5=8F=B0=E9=A6=96=E9=A1=B5):=20?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E6=8E=A5=E5=8F=A3=E5=B8=A6=E4=B8=8A=E5=85=A8?= =?UTF-8?q?=E7=AB=99=E7=9A=84=E6=95=B0=EF=BC=88=E5=AD=A6=E7=94=9F=E3=80=81?= =?UTF-8?q?=E9=A2=98=E7=9B=AE=E3=80=81=E6=8F=90=E4=BA=A4=E3=80=81=E6=AF=94?= =?UTF-8?q?=E8=B5=9B=E3=80=81=E9=A2=98=E5=8D=95=E3=80=81=E8=87=AA=E5=AD=A6?= =?UTF-8?q?=E3=80=81=E8=BF=99=E5=91=A8=E5=81=9A=E9=A2=98=E7=9A=84=E4=BA=BA?= =?UTF-8?q?=EF=BC=89=E3=80=81=E6=9C=80=E8=BF=91=2030=20=E5=A4=A9=E6=AF=8F?= =?UTF-8?q?=E5=A4=A9=E6=8F=90=E4=BA=A4=E3=80=81=E7=B3=BB=E7=BB=9F=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E3=80=81=E5=9C=A8=E7=BA=BF=E4=BA=BA=E6=95=B0=E5=92=8C?= =?UTF-8?q?=E6=AD=A3=E5=9C=A8=E4=B8=8A=E8=AF=BE=E7=9A=84=E7=8F=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin/conf.ts | 82 +++++++++++++++++++++++++++++-- apps/api/src/routes/classroom.ts | 2 +- packages/contract/src/admin.ts | 22 +++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index a607a1e..f76e282 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -11,7 +11,7 @@ import { import { randomInt } from "node:crypto" import { mkdir, readdir, rm, stat } from "node:fs/promises" import { resolve } from "node:path" -import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm" +import { count, countDistinct, desc, eq, gte, ilike, inArray, isNull, not, sql } from "drizzle-orm" import { Hono } from "hono" import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware" @@ -21,7 +21,12 @@ import { publishConfigUpdate } from "../../events" import { failure, parseBody, success } from "../../http" import { sniffImageExtension } from "../../services/image" import { getWebsiteOptions } from "../../services/options" -import { dayStart } from "../../time" +import { calendarDay, dayStart, localTime, termStart, weekStart } from "../../time" +import { STUDENT_ROLES } from "@oj2/contract" +import { TEACHER_ROLES } from "../helpers" +import { onlineUserIds } from "../../auth/presence" +import { JudgeStatus } from "../../judge/status" +import { suggestActiveClass } from "../classroom" import { classPrefixCondition, queryInteger } from "../helpers" export const adminConfRoutes = new Hono() @@ -191,10 +196,16 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => { adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => { const now = new Date().toISOString() - const [[users], [submissions], [contests], [servers]] = await Promise.all([ + const [[users], [submissions], [contests], [servers], online, activeClass] = await Promise.all([ db.select({ value: count() }).from(schema.user), db - .select({ value: count() }) + .select({ + value: count(), + systemErrors: sql`count(*) filter (where ${eq( + schema.submission.result, + JudgeStatus.SYSTEM_ERROR, + )})`.mapWith(Number), + }) .from(schema.submission) .where(gte(schema.submission.createTime, dayStart())), db @@ -205,13 +216,76 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => { .select({ value: count() }) .from(schema.judgeServer) .where(gte(schema.judgeServer.lastHeartbeat, aliveSince())), + onlineUserIds(), + suggestActiveClass(), ]) + const thirtyDaysAgo = dayStart(Date.now() - 29 * 86_400_000) + const day = sql`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')` + const [[roles], [problems], [subs], [contestTotal], [setTotal], [tutorialTotal], [week], daily] = + await Promise.all([ + db + .select({ + students: sql`count(*) filter (where ${inArray(schema.user.adminType, [ + ...STUDENT_ROLES, + ])})`.mapWith(Number), + teachers: sql`count(*) filter (where ${inArray(schema.user.adminType, [ + ...TEACHER_ROLES, + ])})`.mapWith(Number), + }) + .from(schema.user), + db + .select({ + total: count(), + visible: sql`count(*) filter (where ${schema.problem.visible})`.mapWith(Number), + }) + .from(schema.problem) + .where(isNull(schema.problem.contestId)), + db + .select({ + total: count(), + term: sql`count(*) filter (where ${gte( + schema.submission.createTime, + termStart(), + )})`.mapWith(Number), + }) + .from(schema.submission), + db.select({ value: count() }).from(schema.contest), + db.select({ value: count() }).from(schema.problemset), + db.select({ value: count() }).from(schema.tutorial), + db + .select({ value: countDistinct(schema.submission.userId) }) + .from(schema.submission) + .where(gte(schema.submission.createTime, weekStart())), + db + .select({ day, count: count() }) + .from(schema.submission) + .where(gte(schema.submission.createTime, thirtyDaysAgo)) + .groupBy(day), + ]) + const byDay = new Map(daily.map((row) => [row.day, row.count])) + const days = Array.from({ length: 30 }, (_, i) => calendarDay(Date.now() - (29 - i) * 86_400_000)) // 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发 return success(c, { userCount: users?.value ?? 0, todaySubmissionCount: submissions?.value ?? 0, recentContestCount: contests?.value ?? 0, judgeServerCount: servers?.value ?? 0, + todaySystemErrorCount: submissions?.systemErrors ?? 0, + onlineCount: online.size, + activeClass: activeClass ?? null, + site: { + students: roles?.students ?? 0, + teachers: roles?.teachers ?? 0, + problems: problems?.total ?? 0, + visibleProblems: problems?.visible ?? 0, + submissions: subs?.total ?? 0, + termSubmissions: subs?.term ?? 0, + contests: contestTotal?.value ?? 0, + problemSets: setTotal?.value ?? 0, + tutorials: tutorialTotal?.value ?? 0, + weekActive: week?.value ?? 0, + }, + daily: days.map((d) => ({ day: d, count: byDay.get(d) ?? 0 })), } satisfies DashboardInfo) }) diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 8ef73ac..c0f33e6 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -473,7 +473,7 @@ const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000 * 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课 * 的班悄悄留在框里,老师看的整个是别人的班)。 */ -async function suggestActiveClass() { +export async function suggestActiveClass() { // 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班 const activity = activityRows({ since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(), diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index c65fb58..a8badae 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -369,6 +369,28 @@ export const dashboardInfoSchema = z.object({ recentContestCount: z.number().int(), todaySubmissionCount: z.number().int(), judgeServerCount: z.number().int(), + /** 今天判出系统错误的提交数:判题机出毛病时它先涨,比学生喊「交了没反应」早 */ + todaySystemErrorCount: z.number().int(), + /** 现在在线的人数(5 分钟内有动静,auth/presence.ts) */ + onlineCount: z.number().int(), + /** 正在上课的班(最近两小时同班在交题,和课堂看板猜班同一个办法);没有为 null */ + activeClass: z.string().nullable(), + /** 全站的数(看板那一排)。学生 = 普通用户 + 学生管理员,老师 = 教师管理员 + 超管 */ + site: z.object({ + students: z.number().int(), + teachers: z.number().int(), + problems: z.number().int(), + visibleProblems: z.number().int(), + submissions: z.number().int(), + termSubmissions: z.number().int(), + contests: z.number().int(), + problemSets: z.number().int(), + tutorials: z.number().int(), + /** 这周(周一起)交过题的人数 */ + weekActive: z.number().int(), + }), + /** 最近 30 天每天交了几次(东八区日历日,含今天,没人交的那天是 0) */ + daily: z.array(z.object({ day: z.string(), count: z.number().int() })), }) export const uploadImageResponseSchema = z.object({