feat(提交列表): 今日提交数旁加「统计」按钮,弹框给今天的提交统计

筛到今天之后标签旁边出现「统计」,弹框里是全站今天的提交概况:总提交 / 正确 /
判题中 / 正确率 / 参与人数,外加按钟点的 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 08:23:45 -06:00
parent 5eee13fe81
commit e600fd24cf
5 changed files with 451 additions and 14 deletions

View File

@@ -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<number>`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<number>`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` 可选(不给就是「全部时段」)。
*/

View File

@@ -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<number>("submissions/today-count", { params: { language } })
}
/** 「今日提交数」标签点开的统计。公开接口,口径同那颗标签:今天 + 非比赛提交 */
export function getTodaySubmissionStatistics() {
return api.get<TodaySubmissionStatistics>("submissions/today-statistics")
}
export function adminRejudge(id: string) {
return api.post<{ ok: boolean }>(
`submissions/${encodeURIComponent(id)}/rejudge`,

View File

@@ -0,0 +1,253 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getTodaySubmissionStatistics } from "oj/api"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { zonedParts } from "utils/functions"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import type { TodaySubmissionStatistics } from "@oj2/contract"
const emit = defineEmits<{ openProblem: [problem: string] }>()
const themeVars = useThemeVars()
const stats = ref<TodaySubmissionStatistics | null>(null)
const loading = ref(true)
// 柱子高度用的像素上限。CSS 里 .hour-bars 的高度跟着它
const BAR_MAX_HEIGHT = 72
// 「现在是几点」按东八区取,不跟浏览器时区走 —— 高亮错一格比不高亮更糟
const currentHour = zonedParts(new Date())!.hour
const maxHour = computed(() => Math.max(1, ...(stats.value?.hours ?? [])))
const maxLanguage = computed(() =>
Math.max(1, ...(stats.value?.languages ?? []).map((row) => row.count)),
)
/** 0 条的钟点不画柱子,横轴那条基线本身就代表「这个钟点没人交」 */
function barHeight(value: number) {
if (value <= 0) return "0px"
return `${Math.max(4, Math.round((value / maxHour.value) * BAR_MAX_HEIGHT))}px`
}
onMounted(async () => {
try {
stats.value = await getTodaySubmissionStatistics()
} finally {
loading.value = false
}
})
</script>
<template>
<n-spin :show="loading">
<n-empty
v-if="stats && stats.total === 0"
description="今天还没有人提交"
style="margin: 40px 0"
/>
<template v-else-if="stats">
<n-flex justify="space-around">
<div class="stat-item">
<n-text>总提交</n-text>
<n-gradient-text type="info" font-size="28">
{{ stats.total }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>正确提交</n-text>
<n-gradient-text type="primary" font-size="28">
{{ stats.accepted }}
</n-gradient-text>
</div>
<div class="stat-item" v-if="stats.judging > 0">
<n-text>判题中</n-text>
<n-gradient-text type="info" font-size="28">
{{ stats.judging }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>正确率</n-text>
<n-gradient-text type="warning" font-size="28">
{{ stats.correctRate }}%
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>参与人数</n-text>
<n-gradient-text type="error" font-size="28">
{{ stats.userCount }}
</n-gradient-text>
</div>
</n-flex>
<n-divider style="margin: 16px 0">按小时</n-divider>
<div class="hours">
<div class="hour" v-for="(value, hour) in stats.hours" :key="hour">
<n-tooltip>
<template #trigger>
<!--
基线是每一格自己的下边框拼出来的整条横轴一根 border 也行但那样
现在这一格就没法单独加粗**现在这一格加粗成主色**
原来是给整格垫一层底色结果那块浅灰看着就像一根柱子
-->
<div
class="hour-bars"
:style="{
borderBottomColor:
hour === currentHour
? themeVars.primaryColor
: themeVars.dividerColor,
}"
>
<div
class="bar"
:style="{
height: barHeight(value),
backgroundColor: themeVars.primaryColor,
}"
></div>
</div>
</template>
{{ hour }}:00 - {{ hour }}:59 {{ value }}
</n-tooltip>
<!-- 3 小时标一个刻度标签占位始终留着柱子才对得齐 -->
<div class="hour-label">{{ hour % 3 === 0 ? hour : "" }}</div>
</div>
</div>
<n-divider style="margin: 16px 0">按语言</n-divider>
<div class="rows">
<div class="row" v-for="row in stats.languages" :key="row.language">
<n-text class="row-name">{{
LANGUAGE_SHOW_VALUE[row.language]
}}</n-text>
<n-progress
class="row-bar"
type="line"
:percentage="(row.count / maxLanguage) * 100"
:show-indicator="false"
:height="10"
/>
<n-text class="row-count">{{ row.count }}</n-text>
</div>
</div>
<n-divider style="margin: 16px 0">按状态</n-divider>
<n-flex align="center">
<n-flex
align="center"
:size="4"
v-for="row in stats.results"
:key="row.result"
>
<SubmissionResultTag :result="row.result" />
<n-text>{{ row.count }}</n-text>
</n-flex>
</n-flex>
<template v-if="stats.problems.length">
<n-divider style="margin: 16px 0">今天最热的题</n-divider>
<div class="rows">
<div class="row" v-for="row in stats.problems" :key="row.problem">
<n-button
class="problem"
text
type="info"
@click="emit('openProblem', row.problem)"
>
{{ row.problem }} {{ row.problemTitle }}
</n-button>
<n-text class="row-count" depth="3">
{{ row.count }} / 正确 {{ row.acceptedCount }}
</n-text>
</div>
</div>
</template>
</template>
<!-- 请求失败时 stats 还是 null转圈停下来总得留句话 -->
<n-empty
v-else-if="!loading"
description="统计拉取失败"
style="margin: 40px 0"
/>
<div v-else style="height: 200px"></div>
</n-spin>
</template>
<style scoped>
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.hours {
display: flex;
align-items: flex-end;
gap: 2px;
}
.hour {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.hour-bars {
height: 72px;
width: 100%;
display: flex;
align-items: flex-end;
cursor: default;
/* 每格一段,拼成整条横轴。「现在」那一格换主色,粗细不动 —— 变粗会让那一格的
柱子底比别人高 1px */
border-bottom: 2px solid;
}
.bar {
width: 100%;
border-radius: 2px 2px 0 0;
transition: height 0.2s;
}
.hour-label {
font-size: 11px;
line-height: 14px;
opacity: 0.6;
}
.rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
gap: 12px;
}
.row-name {
width: 72px;
flex-shrink: 0;
}
.row-bar {
flex: 1;
}
.row-count {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.problem {
flex: 1;
justify-content: flex-start;
overflow: hidden;
}
</style>

View File

@@ -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<SubmissionQuery>({
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(() => {
</n-button>
</n-form-item>
</n-form>
<n-tag
v-if="todayCount > 0"
checkable
:checked="query.today === '1'"
type="success"
size="large"
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
>
<n-gradient-text v-if="query.today !== '1'" type="success">
今日提交数{{ todayCount }}
</n-gradient-text>
<template v-else>今日提交数{{ todayCount }}</template>
</n-tag>
<n-flex v-if="todayCount > 0" align="center" :size="8">
<n-tag
checkable
:checked="query.today === '1'"
type="success"
size="large"
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
>
<n-gradient-text v-if="query.today !== '1'" type="success">
今日提交数{{ todayCount }}
</n-gradient-text>
<template v-else>今日提交数{{ todayCount }}</template>
</n-tag>
<!--
筛到今天之后才出现流程图那档不给 这个统计只算代码提交
流程图提交在另一张表只有 AI 评级没有判题状态点开会是一份
对不上标签数字的统计
-->
<n-button
v-if="query.today === '1' && query.language !== 'Flowchart'"
quaternary
type="success"
@click="toggleTodayPanel(true)"
>
统计
</n-button>
</n-flex>
</n-space>
<n-data-table
v-if="query.language === 'Flowchart'"
@@ -636,6 +661,15 @@ const flowchartColumns = computed(() => {
:username="query.username"
/>
</n-modal>
<n-modal
v-model:show="todayPanel"
preset="card"
:style="{ maxWidth: isDesktop && '700px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
title="今日提交统计"
>
<TodayStatistics @open-problem="(id: string) => openProblem(id)" />
</n-modal>
<n-modal
v-model:show="codePanel"
preset="card"