From e600fd24cf8ce06220fd919b35a65904982086b2 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 16 Sep 2026 08:23:45 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E6=8F=90=E4=BA=A4=E5=88=97=E8=A1=A8):=20?= =?UTF-8?q?=E4=BB=8A=E6=97=A5=E6=8F=90=E4=BA=A4=E6=95=B0=E6=97=81=E5=8A=A0?= =?UTF-8?q?=E3=80=8C=E7=BB=9F=E8=AE=A1=E3=80=8D=E6=8C=89=E9=92=AE=EF=BC=8C?= =?UTF-8?q?=E5=BC=B9=E6=A1=86=E7=BB=99=E4=BB=8A=E5=A4=A9=E7=9A=84=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 筛到今天之后标签旁边出现「统计」,弹框里是全站今天的提交概况:总提交 / 正确 / 判题中 / 正确率 / 参与人数,外加按钟点的 24 格分布、按语言、按判题结果,以及 今天最热的 10 道题。 新接口 GET /submissions/today-statistics,公开、只出聚合数,口径和那颗标签一致 (东八区今天 + 非比赛提交): - 钟点分桶走 time.ts 的 localTime()。`extract(hour from create_time)` 按会话时区 算,容器是 UTC,整张分布图会左移 8 小时; - 正确率的分母摘掉未判完的条数,正确数含 AST_CHECK_FAILED; - 热门题只算 visible 的题目 —— 这个接口不需要登录,不能拿它探未发布题目的标题; 「提交列表对学生全开」关掉时这张表整个不下发,跟提交列表同一个开关(数字照给, 否则标签说 21、弹框说 0)。 前端组件异步加载,不进本路由的关键路径;小时分布是纯 CSS 柱状图,没有引 chart.js。 柱子和基线取 useThemeVars(),深浅色都跟着走,「现在」那一格是基线上一段主色刻度。 流程图那档不给这颗按钮 —— 流程图提交在另一张表、只有 AI 评级没有判题状态。 Co-Authored-By: Claude Opus 5 --- apps/api/src/routes/submission.ts | 97 ++++++- apps/web/src/oj/api.ts | 6 + .../submission/components/TodayStatistics.vue | 253 ++++++++++++++++++ apps/web/src/oj/submission/list.vue | 60 ++++- packages/contract/src/submission.ts | 49 ++++ 5 files changed, 451 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/oj/submission/components/TodayStatistics.vue diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 04385b7..8e9cbec 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -10,6 +10,7 @@ import { type SubmissionListItem, type SubmissionStatistics, type SubmissionStatisticsItems, + type TodaySubmissionStatistics, } from "@oj2/contract" import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm" import { Hono } from "hono" @@ -36,7 +37,7 @@ import { import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" import { consumeToken } from "../services/throttling" -import { todayStart } from "../time" +import { localTime, todayStart } from "../time" import { asFilterValue, isAdminRole, @@ -182,6 +183,100 @@ function judgedRate(accepted: number, judged: number) { return judged > 0 ? rounded((accepted / judged) * 100) : 0 } +/** + * 「今日提交数」标签点开的统计。**公开、只出聚合数**(没有用户名、没有代码, + * 热门题只算公开可见的题),口径和那颗标签一致:东八区今天 + 非比赛提交。 + * + * 按钟点切用 `localTime()`,不能写 `extract(hour from create_time)` —— + * 后者按数据库会话时区算,容器是 UTC,整张分布图会整体左移 8 小时。 + */ +submissionRoutes.get("/submissions/today-statistics", optionalAuth, async (c) => { + /** + * 「提交列表对学生全开」关掉时(考试那种场合)不给热门题这张表 —— 总数、正确率 + * 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近 + * 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。 + */ + const showProblems = + (await getBooleanOption("submission_list_show_all", true)) || isAdminRole(c.get("user")) + const where = and( + isNull(schema.submission.contestId), + sql`${schema.submission.createTime} >= ${todayStart()}`, + ) + const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})` + const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})` + const hour = sql`extract(hour from ${localTime(schema.submission.createTime)})::int` + + const [[totals], hourRows, languageRows, resultRows, problemRows] = await Promise.all([ + db + .select({ + total: count(), + accepted: acceptedFilter.mapWith(Number), + judging: judgingFilter.mapWith(Number), + userCount: sql`count(distinct ${schema.submission.userId})`.mapWith(Number), + }) + .from(schema.submission) + .where(where), + db + .select({ hour, value: count() }) + .from(schema.submission) + .where(where) + .groupBy(hour), + db + .select({ language: schema.submission.language, value: count() }) + .from(schema.submission) + .where(where) + .groupBy(schema.submission.language) + .orderBy(desc(count())), + db + .select({ result: schema.submission.result, value: count() }) + .from(schema.submission) + .where(where) + .groupBy(schema.submission.result) + .orderBy(desc(count())), + showProblems + ? db + .select({ + displayId: schema.problem.displayId, + title: schema.problem.title, + value: count(), + accepted: acceptedFilter.mapWith(Number), + }) + .from(schema.submission) + .innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) + // 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西 + .where(and(where, eq(schema.problem.visible, true))) + .groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title) + .orderBy(desc(count())) + .limit(10) + : [], + ]) + + const total = totals?.total ?? 0 + const judging = totals?.judging ?? 0 + const hours = Array.from({ length: 24 }, () => 0) + for (const row of hourRows) hours[row.hour] = row.value + + return success( + c, + { + total, + accepted: totals?.accepted ?? 0, + judging, + correctRate: judgedRate(totals?.accepted ?? 0, total - judging), + userCount: totals?.userCount ?? 0, + hours, + languages: languageRows.map((row) => ({ language: row.language, count: row.value })), + results: resultRows.map((row) => ({ result: row.result, count: row.value })), + problems: problemRows.map((row) => ({ + problem: row.displayId, + problemTitle: row.title, + count: row.value, + acceptedCount: row.accepted, + })), + } satisfies TodaySubmissionStatistics, + ) +}) + /** * 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。 */ diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 10e9699..0cd0eb3 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -38,6 +38,7 @@ import { type FlowchartStatistics, type SubmissionStatistics, type SubmissionStatisticsItems, + type TodaySubmissionStatistics, } from "@oj2/contract" import api from "utils/api" import { contract } from "utils/contract" @@ -159,6 +160,11 @@ export function getTodaySubmissionCount(language?: string) { return api.get("submissions/today-count", { params: { language } }) } +/** 「今日提交数」标签点开的统计。公开接口,口径同那颗标签:今天 + 非比赛提交 */ +export function getTodaySubmissionStatistics() { + return api.get("submissions/today-statistics") +} + export function adminRejudge(id: string) { return api.post<{ ok: boolean }>( `submissions/${encodeURIComponent(id)}/rejudge`, diff --git a/apps/web/src/oj/submission/components/TodayStatistics.vue b/apps/web/src/oj/submission/components/TodayStatistics.vue new file mode 100644 index 0000000..625ad64 --- /dev/null +++ b/apps/web/src/oj/submission/components/TodayStatistics.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/apps/web/src/oj/submission/list.vue b/apps/web/src/oj/submission/list.vue index 1d76071..046c40b 100644 --- a/apps/web/src/oj/submission/list.vue +++ b/apps/web/src/oj/submission/list.vue @@ -49,6 +49,9 @@ const StatisticsPanel = defineAsyncComponent( const FlowchartStatisticsPanel = defineAsyncComponent( () => import("shared/components/FlowchartStatisticsPanel.vue"), ) +const TodayStatistics = defineAsyncComponent( + () => import("./components/TodayStatistics.vue"), +) const SubmissionDetail = defineAsyncComponent(() => import("./detail.vue")) const FlowchartScoreDetail = defineAsyncComponent( () => import("./components/FlowchartScoreDetail.vue"), @@ -114,6 +117,8 @@ const { query, clearQuery } = usePagination({ const submissionID = ref("") const problemDisplayID = ref("") const [statisticPanel, toggleStatisticPanel] = useToggle(false) +// 「今日提交数」旁边那颗「统计」按钮的弹框 +const [todayPanel, toggleTodayPanel] = useToggle(false) const [codePanel, toggleCodePanel] = useToggle(false) const [scoreDetailPanel, toggleScoreDetailPanel] = useToggle(false) @@ -241,6 +246,12 @@ function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) { } } +// 今日统计弹框里点题目。那颗按钮只在 route.name === "submissions" 上出现 +// (今日提交数本身就只在那一页拉),所以不用管比赛里的题目路由 +function openProblem(displayId: string) { + window.open("/problem/" + displayId, "_blank") +} + function showCodePanel(id: string, problem: string) { toggleCodePanel(true) submissionID.value = id @@ -581,19 +592,33 @@ const flowchartColumns = computed(() => { - - - 今日提交数:{{ todayCount }} - - - + + + + 今日提交数:{{ todayCount }} + + + + + + 统计 + + { :username="query.username" /> + + + export type SubmissionUpdate = z.infer export type SubmissionStatistics = z.infer +export type TodaySubmissionStatistics = z.infer< + typeof todaySubmissionStatisticsSchema +> export type SubmissionStatisticsUser = z.infer< typeof submissionStatisticsUserSchema >