feat(后台首页): 首页接口带上全站的数(学生、题目、提交、比赛、题单、自学、这周做题的人)、最近 30 天每天提交、系统错误、在线人数和正在上课的班
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e6816436e
commit
1d406a50e4
3 files changed
+101
-5
No files matched your search
@@ -11,7 +11,7 @@ import {
|
|||||||
import { randomInt } from "node:crypto"
|
import { randomInt } from "node:crypto"
|
||||||
import { mkdir, readdir, rm, stat } from "node:fs/promises"
|
import { mkdir, readdir, rm, stat } from "node:fs/promises"
|
||||||
import { resolve } from "node:path"
|
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 { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
@@ -21,7 +21,12 @@ import { publishConfigUpdate } from "../../events"
|
|||||||
import { failure, parseBody, success } from "../../http"
|
import { failure, parseBody, success } from "../../http"
|
||||||
import { sniffImageExtension } from "../../services/image"
|
import { sniffImageExtension } from "../../services/image"
|
||||||
import { getWebsiteOptions } from "../../services/options"
|
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"
|
import { classPrefixCondition, queryInteger } from "../helpers"
|
||||||
|
|
||||||
export const adminConfRoutes = new Hono<AppEnv>()
|
export const adminConfRoutes = new Hono<AppEnv>()
|
||||||
@@ -191,10 +196,16 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => {
|
|||||||
|
|
||||||
adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
||||||
const now = new Date().toISOString()
|
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() }).from(schema.user),
|
||||||
db
|
db
|
||||||
.select({ value: count() })
|
.select({
|
||||||
|
value: count(),
|
||||||
|
systemErrors: sql<number>`count(*) filter (where ${eq(
|
||||||
|
schema.submission.result,
|
||||||
|
JudgeStatus.SYSTEM_ERROR,
|
||||||
|
)})`.mapWith(Number),
|
||||||
|
})
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
.where(gte(schema.submission.createTime, dayStart())),
|
.where(gte(schema.submission.createTime, dayStart())),
|
||||||
db
|
db
|
||||||
@@ -205,13 +216,76 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
|||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(schema.judgeServer)
|
.from(schema.judgeServer)
|
||||||
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
|
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
|
||||||
|
onlineUserIds(),
|
||||||
|
suggestActiveClass(),
|
||||||
])
|
])
|
||||||
|
const thirtyDaysAgo = dayStart(Date.now() - 29 * 86_400_000)
|
||||||
|
const day = sql<string>`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<number>`count(*) filter (where ${inArray(schema.user.adminType, [
|
||||||
|
...STUDENT_ROLES,
|
||||||
|
])})`.mapWith(Number),
|
||||||
|
teachers: sql<number>`count(*) filter (where ${inArray(schema.user.adminType, [
|
||||||
|
...TEACHER_ROLES,
|
||||||
|
])})`.mapWith(Number),
|
||||||
|
})
|
||||||
|
.from(schema.user),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
total: count(),
|
||||||
|
visible: sql<number>`count(*) filter (where ${schema.problem.visible})`.mapWith(Number),
|
||||||
|
})
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(isNull(schema.problem.contestId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
total: count(),
|
||||||
|
term: sql<number>`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,前端从未读过,不再下发
|
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
|
||||||
return success(c, {
|
return success(c, {
|
||||||
userCount: users?.value ?? 0,
|
userCount: users?.value ?? 0,
|
||||||
todaySubmissionCount: submissions?.value ?? 0,
|
todaySubmissionCount: submissions?.value ?? 0,
|
||||||
recentContestCount: contests?.value ?? 0,
|
recentContestCount: contests?.value ?? 0,
|
||||||
judgeServerCount: servers?.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)
|
} satisfies DashboardInfo)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -473,7 +473,7 @@ const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
|
|||||||
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
|
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
|
||||||
* 的班悄悄留在框里,老师看的整个是别人的班)。
|
* 的班悄悄留在框里,老师看的整个是别人的班)。
|
||||||
*/
|
*/
|
||||||
async function suggestActiveClass() {
|
export async function suggestActiveClass() {
|
||||||
// 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
|
// 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
|
||||||
const activity = activityRows({
|
const activity = activityRows({
|
||||||
since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
|
since: new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
|
||||||
|
|||||||
@@ -369,6 +369,28 @@ export const dashboardInfoSchema = z.object({
|
|||||||
recentContestCount: z.number().int(),
|
recentContestCount: z.number().int(),
|
||||||
todaySubmissionCount: z.number().int(),
|
todaySubmissionCount: z.number().int(),
|
||||||
judgeServerCount: 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({
|
export const uploadImageResponseSchema = z.object({
|
||||||
|
|||||||
Reference in new issue
Block a user