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 SubmissionListItem,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
type SubmissionStatisticsItems,
|
type SubmissionStatisticsItems,
|
||||||
|
type TodaySubmissionStatistics,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -36,7 +37,7 @@ import {
|
|||||||
import { CodeFormatError, formatCode } from "../services/format-code"
|
import { CodeFormatError, formatCode } from "../services/format-code"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { todayStart } from "../time"
|
import { localTime, todayStart } from "../time"
|
||||||
import {
|
import {
|
||||||
asFilterValue,
|
asFilterValue,
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
@@ -182,6 +183,100 @@ function judgedRate(accepted: number, judged: number) {
|
|||||||
return judged > 0 ? rounded((accepted / judged) * 100) : 0
|
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` 可选(不给就是「全部时段」)。
|
* 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type FlowchartStatistics,
|
type FlowchartStatistics,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
type SubmissionStatisticsItems,
|
type SubmissionStatisticsItems,
|
||||||
|
type TodaySubmissionStatistics,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import api from "utils/api"
|
import api from "utils/api"
|
||||||
import { contract } from "utils/contract"
|
import { contract } from "utils/contract"
|
||||||
@@ -159,6 +160,11 @@ export function getTodaySubmissionCount(language?: string) {
|
|||||||
return api.get<number>("submissions/today-count", { params: { language } })
|
return api.get<number>("submissions/today-count", { params: { language } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「今日提交数」标签点开的统计。公开接口,口径同那颗标签:今天 + 非比赛提交 */
|
||||||
|
export function getTodaySubmissionStatistics() {
|
||||||
|
return api.get<TodaySubmissionStatistics>("submissions/today-statistics")
|
||||||
|
}
|
||||||
|
|
||||||
export function adminRejudge(id: string) {
|
export function adminRejudge(id: string) {
|
||||||
return api.post<{ ok: boolean }>(
|
return api.post<{ ok: boolean }>(
|
||||||
`submissions/${encodeURIComponent(id)}/rejudge`,
|
`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(
|
const FlowchartStatisticsPanel = defineAsyncComponent(
|
||||||
() => import("shared/components/FlowchartStatisticsPanel.vue"),
|
() => import("shared/components/FlowchartStatisticsPanel.vue"),
|
||||||
)
|
)
|
||||||
|
const TodayStatistics = defineAsyncComponent(
|
||||||
|
() => import("./components/TodayStatistics.vue"),
|
||||||
|
)
|
||||||
const SubmissionDetail = defineAsyncComponent(() => import("./detail.vue"))
|
const SubmissionDetail = defineAsyncComponent(() => import("./detail.vue"))
|
||||||
const FlowchartScoreDetail = defineAsyncComponent(
|
const FlowchartScoreDetail = defineAsyncComponent(
|
||||||
() => import("./components/FlowchartScoreDetail.vue"),
|
() => import("./components/FlowchartScoreDetail.vue"),
|
||||||
@@ -114,6 +117,8 @@ const { query, clearQuery } = usePagination<SubmissionQuery>({
|
|||||||
const submissionID = ref("")
|
const submissionID = ref("")
|
||||||
const problemDisplayID = ref("")
|
const problemDisplayID = ref("")
|
||||||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
||||||
|
// 「今日提交数」旁边那颗「统计」按钮的弹框
|
||||||
|
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
||||||
|
|
||||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||||
const [scoreDetailPanel, toggleScoreDetailPanel] = 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) {
|
function showCodePanel(id: string, problem: string) {
|
||||||
toggleCodePanel(true)
|
toggleCodePanel(true)
|
||||||
submissionID.value = id
|
submissionID.value = id
|
||||||
@@ -581,19 +592,33 @@ const flowchartColumns = computed(() => {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
<n-tag
|
<n-flex v-if="todayCount > 0" align="center" :size="8">
|
||||||
v-if="todayCount > 0"
|
<n-tag
|
||||||
checkable
|
checkable
|
||||||
:checked="query.today === '1'"
|
:checked="query.today === '1'"
|
||||||
type="success"
|
type="success"
|
||||||
size="large"
|
size="large"
|
||||||
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
|
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
|
||||||
>
|
>
|
||||||
<n-gradient-text v-if="query.today !== '1'" type="success">
|
<n-gradient-text v-if="query.today !== '1'" type="success">
|
||||||
今日提交数:{{ todayCount }}
|
今日提交数:{{ todayCount }}
|
||||||
</n-gradient-text>
|
</n-gradient-text>
|
||||||
<template v-else>今日提交数:{{ todayCount }}</template>
|
<template v-else>今日提交数:{{ todayCount }}</template>
|
||||||
</n-tag>
|
</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-space>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
v-if="query.language === 'Flowchart'"
|
v-if="query.language === 'Flowchart'"
|
||||||
@@ -636,6 +661,15 @@ const flowchartColumns = computed(() => {
|
|||||||
:username="query.username"
|
:username="query.username"
|
||||||
/>
|
/>
|
||||||
</n-modal>
|
</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
|
<n-modal
|
||||||
v-model:show="codePanel"
|
v-model:show="codePanel"
|
||||||
preset="card"
|
preset="card"
|
||||||
|
|||||||
@@ -331,6 +331,52 @@ export const submissionStatisticsSchema = z.object({
|
|||||||
dataAttempted: z.array(attemptedStudentSchema),
|
dataAttempted: z.array(attemptedStudentSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交列表那颗「今日提交数」标签点开之后的统计弹框(GET /submissions/today-statistics)。
|
||||||
|
*
|
||||||
|
* **公开接口,只下发聚合数** —— 没有用户名、没有代码、没有隐藏题目的标题,学生和
|
||||||
|
* 匿名访客看到的和教师一样。教师那套按班级/按题号钻取的口径在
|
||||||
|
* `submissionStatisticsSchema`,两者不是一回事,别把这个当它的简版去加字段。
|
||||||
|
*
|
||||||
|
* 口径跟着那颗标签走:**东八区今天、非比赛提交、不分语言**(语言分布就是
|
||||||
|
* `languages` 这张表本身)。
|
||||||
|
*/
|
||||||
|
export const todaySubmissionStatisticsSchema = z.object({
|
||||||
|
total: z.number().int(),
|
||||||
|
/** 通过的条数,含 AST_CHECK_FAILED(那也是答案对了) */
|
||||||
|
accepted: z.number().int(),
|
||||||
|
/**
|
||||||
|
* 还没判完的条数(PENDING / JUDGING)。`total` 把它算在内,`correctRate` 的分母
|
||||||
|
* 不算 —— 全班同时交卷的那几秒,分母涨了分子没涨,正确率会凭空掉一截。
|
||||||
|
*/
|
||||||
|
judging: z.number().int(),
|
||||||
|
correctRate: z.number(),
|
||||||
|
/** 今天交过东西的人数,按 user_id 去重 */
|
||||||
|
userCount: z.number().int(),
|
||||||
|
/** 按东八区钟点分的 24 个桶,**下标就是钟点**,没有提交的钟点是 0 */
|
||||||
|
hours: z.array(z.number().int()).length(24),
|
||||||
|
/** 按语言,提交数倒序。零提交的语言不在表里 */
|
||||||
|
languages: z.array(
|
||||||
|
z.object({ language: problemLanguageSchema, count: z.number().int() }),
|
||||||
|
),
|
||||||
|
/** 按判题结果,条数倒序 */
|
||||||
|
results: z.array(
|
||||||
|
z.object({ result: judgeStatusSchema, count: z.number().int() }),
|
||||||
|
),
|
||||||
|
/**
|
||||||
|
* 今天最热的几道题,提交数倒序,最多 10 道。
|
||||||
|
* **只含公开可见的题目** —— 这个接口不需要登录,不能拿它探未发布题目的标题。
|
||||||
|
*/
|
||||||
|
problems: z.array(
|
||||||
|
z.object({
|
||||||
|
problem: z.string(),
|
||||||
|
problemTitle: z.string(),
|
||||||
|
count: z.number().int(),
|
||||||
|
acceptedCount: z.number().int(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
export const formatCodeRequestSchema = z.object({
|
export const formatCodeRequestSchema = z.object({
|
||||||
code: z.string().max(1024 * 1024),
|
code: z.string().max(1024 * 1024),
|
||||||
language: z.enum(["python", "c", "cpp", "sql"]),
|
language: z.enum(["python", "c", "cpp", "sql"]),
|
||||||
@@ -346,6 +392,9 @@ export type CreateSubmissionRequest = z.infer<
|
|||||||
export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
|
export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
|
||||||
export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>
|
export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>
|
||||||
export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema>
|
export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema>
|
||||||
|
export type TodaySubmissionStatistics = z.infer<
|
||||||
|
typeof todaySubmissionStatisticsSchema
|
||||||
|
>
|
||||||
export type SubmissionStatisticsUser = z.infer<
|
export type SubmissionStatisticsUser = z.infer<
|
||||||
typeof submissionStatisticsUserSchema
|
typeof submissionStatisticsUserSchema
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user