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