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:
@@ -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` 可选(不给就是「全部时段」)。
|
||||
*/
|
||||
|
||||
@@ -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`,
|
||||
|
||||
253
apps/web/src/oj/submission/components/TodayStatistics.vue
Normal file
253
apps/web/src/oj/submission/components/TodayStatistics.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user