feat(教师统计): 提交统计面板返修 —— 统计口径、未完成分两栏、多题、自动刷新
排查这个面板时发现的一串问题和缺口,一起修掉。改动集中在 `GET /submissions/statistics` 和 `StatisticsPanel.vue`,两边互相咬着, 拆不成独立的 commit。 **「已解决」数的不是题数**(真在显示错数字)。`count(*) filter (accepted)` 按用户名分组、没有 distinct problem_id,同一道题重复 AC 会重复计数。查单道题时 看不出来,老师查「这节课全班」时「已解决 5」可能是同一道题交了 5 次。新增 `solvedCount = count(distinct problem_id) filter (accepted)`,表格那一列换成它; `acceptedCount` 保留,正确率的分子仍然是提交条数。 **正确率把判题中的算进了分母**。PENDING / JUDGING 进分母不进分子,全班同时交卷的 那几秒正确率凭空掉一截 —— 而老师盯着看的就是这个数。分母改成判完的条数, `UNJUDGED_RESULTS` 提到 judge/status.ts 共用。总提交仍是全部条数(交了就算交过, 否则人数口径会跟着变,正在判的学生会掉进「没交」名单),另外下发 `judgingCount`, 非零时面板多显示一块「判题中」,三个数字才对得上。 **「未完成」实际是「一次没交」**。做了但一次没对的学生既不在「完成人数」也不在 未完成名单,等于从屏幕上消失 —— 而那恰恰是最该去看一眼的人。新增 `dataAttempted`,未完成栏拆成「还没交」/「交了没对」两组,tab 计数是两者之和。 请假隐藏对两组同时生效:他们同样占着班级人数这个分母,只藏一半会把完成度算错。 **点名字能看到错在哪**。`dataAttempted` 带上最近一条提交的题号、状态和 `statistic_info.err_info`(截断 400 字),点名字弹出来,还能一步跳到代码。 老师不用再切到提交列表、翻到这个人、点开代码。 **支持一次查几道题**。题号框接受 `1001,1005,1010`(中英文逗号、分号、空格都当 分隔符,投影前手敲不该因为打了全角逗号就查不出来),有一个题号不存在就整体 404。 **完成 = 这几道全解决**;只填一道时和原来完全等价,不填题号时退回「至少做出一道」。 差一道的人落在「交了没全对」里,名字后面缀 `2/3题`。 **零提交时整块面板消失**。判空条件是 `count.total > 0`,可一节课刚开始一条提交都 没有、后端已经把整份花名册当作「未完成」返回了 —— 最该看名单的时刻反而只显示 「暂无数据」。流程图那边更彻底:`/flowcharts/statistics` 的零提交分支把 `dataUnaccepted` 写死成空数组,名单压根没下发。 **面板不会自己刷新**。打开是空的、要点一次按钮,拿到的是那一刻的快照。改成打开即查 + 每 15 秒滚动重查,页面切到后台就停,关掉面板随组件卸载停掉。上一次没回来就跳过 这一次。班级从手打 `ks251` 换成下拉(选项来自网站配置的 class_list,和登录框同一份), 保留自由输入,查过的班级记进 localStorage 下次带上 —— 机房电脑一台对一个班。 **明细查询没有 LIMIT**。展开行用的 submissionItems 原来把窗口内**全部**提交捞进内存 再原样序列化,「全部时段 + 不填条件」就是十几万条。改成只取有 AC 的人、每人最近 50 条, 截断用窗口函数发生在数据库侧;表格「提交数」仍是真实总数。 **顺带**:`personRate` 前端从来没读过(完成度是前端按「减掉请假人数的分母」自己算的), 从契约里删掉;「语法未过」的题数单列出来(AST_CHECK_FAILED 全站仍然算通过,口径没动, 只是让老师看得见谁是绕过语法要求做出来的,那批人教学上没达标)。 实跑验证(dev 全栈 + 浏览器): - 已解决:student 两条 AC 都在题号 5 上 —— 改前显示 2,改后显示 1 - 正确率:临时把两条改成 PENDING/JUDGING,12 条提交 2 条通过 —— 16.67% → 20%, 面板多出「判题中 2」,表格显示「12(2 条判题中)」 - 未完成两栏:student2 没交、student 交了 12 次没对,请假隐藏 student 后 班级人数 2→1、tab 2→1、出现「恢复 1 位」 - 错因弹层:点 student 弹出「最近一次:1020 · 编译失败 / Test case not found / 看代码」 - 多题:`1004` 完成 1 人;`1004,1005` 完成 0 人、交了没全对 student 1/2题 8次; 全角逗号同上;`1004,9999` 报 `Problem 9999 does not exist` - 自动刷新:打开即发请求,之后 04:44:22 → 04:44:36 → 04:44:51 → 04:45:06 每 15 秒 一次且窗口跟着滚;关掉面板后 20 秒请求数不再增长 - 班级下拉:选「25计算机1班」→ ks251,重开面板自动带上并立刻查 tsc / vue-tsc / check:routes 全过。验证用的 dev 库改动已还原。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
This commit is contained in:
@@ -20,13 +20,9 @@
|
||||
<n-button type="primary" @click="handleStatistics">统计</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-empty
|
||||
v-if="data.totalCount === 0"
|
||||
description="暂无数据"
|
||||
style="margin: 40px 0"
|
||||
/>
|
||||
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
|
||||
|
||||
<template v-if="data.totalCount > 0">
|
||||
<template v-if="hasResult">
|
||||
<n-divider style="margin: 16px 0" />
|
||||
<n-flex justify="space-around">
|
||||
<div class="stat-item">
|
||||
@@ -68,7 +64,7 @@
|
||||
<n-tab-pane name="charts" tab="数据图表">
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
|
||||
<!-- 1. Grade pie chart -->
|
||||
<n-gi>
|
||||
<n-gi v-if="data.totalCount > 0">
|
||||
<n-card title="等级分布">
|
||||
<div class="chart-container">
|
||||
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
|
||||
@@ -230,6 +226,14 @@ const data = reactive<FlowchartStatistics>({
|
||||
dataUnaccepted: [],
|
||||
})
|
||||
|
||||
/**
|
||||
* 「查出东西了吗」。和 StatisticsPanel 同一个道理:一节课刚开始时没有任何提交,
|
||||
* 但花名册整份都在 dataUnaccepted 里,那会儿正是老师要看名单的时候。
|
||||
*/
|
||||
const hasResult = computed(
|
||||
() => data.totalCount > 0 || data.dataUnaccepted.length > 0,
|
||||
)
|
||||
|
||||
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
|
||||
let wordcloudChart: ChartJS | null = null
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-input
|
||||
placeholder="用户(可选)"
|
||||
<n-select
|
||||
placeholder="班级或用户(可选)"
|
||||
v-model:value="query.username"
|
||||
style="width: 150px"
|
||||
:options="classOptions"
|
||||
style="width: 190px"
|
||||
filterable
|
||||
tag
|
||||
clearable
|
||||
/>
|
||||
<n-input
|
||||
placeholder="题号(可选)"
|
||||
placeholder="题号(可选,逗号分隔)"
|
||||
v-model:value="query.problem"
|
||||
style="width: 120px"
|
||||
style="width: 200px"
|
||||
clearable
|
||||
/>
|
||||
<n-select
|
||||
@@ -17,19 +20,17 @@
|
||||
v-model:value="query.duration"
|
||||
:options="options"
|
||||
/>
|
||||
<n-button type="primary" @click="handleStatistics">统计</n-button>
|
||||
<n-button type="primary" :loading="loading" @click="handleStatistics">
|
||||
统计
|
||||
</n-button>
|
||||
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions">
|
||||
前往提交列表
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-empty
|
||||
v-if="count.total === 0"
|
||||
description="暂无数据"
|
||||
style="margin: 40px 0"
|
||||
/>
|
||||
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
|
||||
|
||||
<template v-if="count.total > 0">
|
||||
<template v-if="hasResult">
|
||||
<n-divider style="margin: 16px 0" />
|
||||
<n-flex justify="space-around">
|
||||
<div class="stat-item">
|
||||
@@ -44,13 +45,19 @@
|
||||
count.accepted
|
||||
}}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item" v-if="count.judging > 0">
|
||||
<n-text>判题中</n-text>
|
||||
<n-gradient-text type="info" font-size="28">{{
|
||||
count.judging
|
||||
}}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>正确率</n-text>
|
||||
<n-gradient-text type="warning" font-size="28"
|
||||
>{{ count.rate }}%</n-gradient-text
|
||||
>
|
||||
</div>
|
||||
<template v-if="person.count > 0">
|
||||
<template v-if="personCount > 0">
|
||||
<div class="stat-item">
|
||||
<n-text>完成人数</n-text>
|
||||
<n-gradient-text type="error" font-size="28">{{
|
||||
@@ -76,12 +83,12 @@
|
||||
<n-tabs animated type="line">
|
||||
<n-tab-pane name="charts" tab="数据图表">
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
|
||||
<n-gi>
|
||||
<n-gi v-if="count.total > 0">
|
||||
<n-card title="提交正确率">
|
||||
<Doughnut :data="pieChartData" :options="pieChartOptions" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi v-if="person.count > 0">
|
||||
<n-gi v-if="personCount > 0">
|
||||
<n-card title="班级完成度">
|
||||
<Doughnut
|
||||
:data="completionChartData"
|
||||
@@ -104,12 +111,10 @@
|
||||
:row-props="rowProps"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
<n-empty v-else description="还没有人做出来" style="margin: 24px 0" />
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane
|
||||
name="unaccepted"
|
||||
:tab="`未完成(${visibleUnaccepted.length})`"
|
||||
>
|
||||
<n-tab-pane name="unaccepted" :tab="`未完成(${unfinishedTotal})`">
|
||||
<n-flex align="center" style="margin: 12px 0">
|
||||
<n-switch v-model:value="hideMode" size="large">
|
||||
<template #checked>请假隐藏中</template>
|
||||
@@ -124,27 +129,62 @@
|
||||
恢复 {{ hiddenCount }} 位
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex size="large" align="center">
|
||||
<n-gradient-text
|
||||
v-if="visibleUnaccepted.length === 0"
|
||||
font-size="24"
|
||||
type="success"
|
||||
>
|
||||
全都完成了
|
||||
</n-gradient-text>
|
||||
<template v-for="item in visibleUnaccepted" :key="item.username">
|
||||
<n-tag
|
||||
v-if="hideMode"
|
||||
closable
|
||||
size="large"
|
||||
style="font-size: 20px"
|
||||
@close="hideStudent(item.username)"
|
||||
>
|
||||
{{ item.realName }}
|
||||
</n-tag>
|
||||
<span v-else style="font-size: 24px">{{ item.realName }}</span>
|
||||
</template>
|
||||
</n-flex>
|
||||
<n-gradient-text
|
||||
v-if="unfinishedGroups.length === 0"
|
||||
font-size="24"
|
||||
type="success"
|
||||
>
|
||||
全都完成了
|
||||
</n-gradient-text>
|
||||
<template v-for="group in unfinishedGroups" :key="group.title">
|
||||
<n-text depth="3" class="group-title">
|
||||
{{ group.title }}({{ group.items.length }})
|
||||
</n-text>
|
||||
<n-flex size="large" align="center">
|
||||
<template v-for="item in group.items" :key="item.username">
|
||||
<n-popover
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
:disabled="!item.failure"
|
||||
style="max-width: 460px"
|
||||
>
|
||||
<template #trigger>
|
||||
<n-tag
|
||||
v-if="hideMode"
|
||||
closable
|
||||
size="large"
|
||||
style="font-size: 20px"
|
||||
@close="hideStudent(item.username)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</n-tag>
|
||||
<span
|
||||
v-else
|
||||
:class="{ name: true, 'name-clickable': !!item.failure }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
<n-flex vertical size="small">
|
||||
<n-text depth="3">
|
||||
最近一次:{{ item.failure?.problem }} ·
|
||||
{{ statusName(item.failure?.result) }}
|
||||
</n-text>
|
||||
<pre v-if="item.failure?.error" class="failure-error">{{
|
||||
item.failure?.error
|
||||
}}</pre>
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
@click="openFailure(item.failure?.id)"
|
||||
>
|
||||
看代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-popover>
|
||||
</template>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
@@ -153,12 +193,18 @@
|
||||
import { h } from "vue"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import { getSubmissionStatistics } from "oj/api"
|
||||
import { DURATION_OPTIONS } from "utils/constants"
|
||||
import { DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
import { useConfigStore } from "../store/config"
|
||||
import { Doughnut } from "vue-chartjs"
|
||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||||
import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
|
||||
import { JUDGE_STATUS } from "utils/constants"
|
||||
import type { SubmissionStatisticsUser, UnacceptedStudent } from "@oj2/contract"
|
||||
import type {
|
||||
AttemptedStudent,
|
||||
SubmissionStatisticsUser,
|
||||
UnacceptedStudent,
|
||||
} from "@oj2/contract"
|
||||
|
||||
// 注册 Chart.js 组件
|
||||
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
||||
@@ -207,8 +253,24 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||||
},
|
||||
},
|
||||
{ title: "用户", key: "username" },
|
||||
{ title: "提交数", key: "submissionCount" },
|
||||
{ title: "已解决", key: "acceptedCount" },
|
||||
{
|
||||
title: "提交数",
|
||||
key: "submissionCount",
|
||||
render: (row) =>
|
||||
row.judgingCount > 0
|
||||
? `${row.submissionCount}(${row.judgingCount} 条判题中)`
|
||||
: `${row.submissionCount}`,
|
||||
},
|
||||
// 题数,不是通过的提交条数 —— 同一道题重复 AC 只算一道。
|
||||
// 「语法未过」是答案对了但没按要求写、而且最后也没改对的,算在已解决里但教学上没达标
|
||||
{
|
||||
title: "已解决",
|
||||
key: "solvedCount",
|
||||
render: (row) =>
|
||||
row.astOnlyCount > 0
|
||||
? `${row.solvedCount}(${row.astOnlyCount} 题语法未过)`
|
||||
: `${row.solvedCount}`,
|
||||
},
|
||||
// 新后端返回的是数值,百分号在这里补 —— 旧后端直接返回 "85.5%" 字符串
|
||||
{
|
||||
title: "正确率",
|
||||
@@ -217,8 +279,28 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
/**
|
||||
* 班级下拉。值就是用户名前缀(`ks231`),后端那边本来就是 ilike 模糊匹配,
|
||||
* 所以选班级和手打前缀是同一件事。`tag` 留着让老师仍然能直接打某个学生的名字。
|
||||
*/
|
||||
const classOptions = computed<SelectOption[]>(
|
||||
() =>
|
||||
configStore.config?.classList.map((item) => ({
|
||||
label: `${item.slice(0, 2)}计算机${item.slice(2)}班`,
|
||||
value: `ks${item}`,
|
||||
})) ?? [],
|
||||
)
|
||||
|
||||
// 机房电脑一台对一个班,用过的班级记在本地,下次打开就带上(和登录框同一套做法)
|
||||
function lastUsedClass() {
|
||||
const last = storage.get(STORAGE_KEY.STATISTICS_CLASS)
|
||||
return typeof last === "string" ? last : ""
|
||||
}
|
||||
|
||||
const query = reactive({
|
||||
username: props.username,
|
||||
username: props.username || lastUsedClass(),
|
||||
problem: props.problem,
|
||||
duration: options[0].value,
|
||||
})
|
||||
@@ -226,25 +308,38 @@ const query = reactive({
|
||||
const count = reactive({
|
||||
total: 0,
|
||||
accepted: 0,
|
||||
// 还在判题队列里的条数。total 含它,rate 的分母不含 —— 全班同时交卷的那几秒,
|
||||
// 分母涨了分子没涨,正确率会凭空掉一截
|
||||
judging: 0,
|
||||
rate: 0,
|
||||
})
|
||||
const person = reactive({
|
||||
count: 0,
|
||||
rate: 0,
|
||||
})
|
||||
// 花名册人数。后端只下发这一个分母,完成度在前端算 —— 「请假隐藏」要从分母里
|
||||
// 减人,那是浏览器本地状态,后端算不出来
|
||||
const personCount = ref(0)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const list = ref<SubmissionStatisticsUser[]>([])
|
||||
const listUnaccepted = ref<UnacceptedStudent[]>([])
|
||||
// 交了但一次没对的。和上面那一栏合起来才是「未完成」的全部
|
||||
const listAttempted = ref<AttemptedStudent[]>([])
|
||||
const expandedRowKeys = ref<DataTableRowKey[]>([])
|
||||
|
||||
/**
|
||||
* 「查出东西了吗」。**不能只看提交数** —— 一节课刚开始时一条提交都没有,但后端
|
||||
* 已经把整份花名册当作「未完成」返回了,而那正是老师最想看名单的时刻。
|
||||
* 原来整个面板 v-if 在 count.total > 0 上,那会儿只显示「暂无数据」。
|
||||
*/
|
||||
const hasResult = computed(
|
||||
() => count.total > 0 || listUnaccepted.value.length > 0,
|
||||
)
|
||||
|
||||
const HIDE_DURATION = 2 * 60 * 60 * 1000
|
||||
const STORAGE_KEY = "oj_hidden_students"
|
||||
const HIDDEN_KEY = "oj_hidden_students"
|
||||
|
||||
function loadHidden(): Record<string, number> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
|
||||
return JSON.parse(localStorage.getItem(HIDDEN_KEY) ?? "{}")
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
@@ -254,7 +349,7 @@ const hiddenStudents = ref<Record<string, number>>(loadHidden())
|
||||
const hideMode = ref(false)
|
||||
|
||||
function saveHidden(data: Record<string, number>) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||
localStorage.setItem(HIDDEN_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
function hideStudent(username: string) {
|
||||
@@ -270,23 +365,88 @@ function showAll() {
|
||||
saveHidden({})
|
||||
}
|
||||
|
||||
const visibleUnaccepted = computed(() => {
|
||||
const now = Date.now()
|
||||
return listUnaccepted.value.filter((item) => {
|
||||
const exp = hiddenStudents.value[item.username]
|
||||
return !exp || exp <= now
|
||||
})
|
||||
})
|
||||
function notHidden(item: { username: string }) {
|
||||
const exp = hiddenStudents.value[item.username]
|
||||
return !exp || exp <= Date.now()
|
||||
}
|
||||
|
||||
const hiddenCount = computed(() => {
|
||||
const now = Date.now()
|
||||
return listUnaccepted.value.filter((item) => {
|
||||
const exp = hiddenStudents.value[item.username]
|
||||
return !!exp && exp > now
|
||||
}).length
|
||||
})
|
||||
const visibleUnaccepted = computed(() => listUnaccepted.value.filter(notHidden))
|
||||
const visibleAttempted = computed(() => listAttempted.value.filter(notHidden))
|
||||
|
||||
const adjustedPersonCount = computed(() => person.count - hiddenCount.value)
|
||||
// 请假的人两栏都要藏 —— 他们同样占着班级人数这个分母
|
||||
const hiddenCount = computed(
|
||||
() =>
|
||||
listUnaccepted.value.length +
|
||||
listAttempted.value.length -
|
||||
visibleUnaccepted.value.length -
|
||||
visibleAttempted.value.length,
|
||||
)
|
||||
|
||||
const unfinishedTotal = computed(
|
||||
() => visibleUnaccepted.value.length + visibleAttempted.value.length,
|
||||
)
|
||||
|
||||
/**
|
||||
* 这次查的是几道题。**取的是「上次点统计时」的值**,不是输入框的当前内容 ——
|
||||
* 老师改到一半的题号不该把已经查出来的结果重新解释一遍。
|
||||
*/
|
||||
const queriedProblemCount = ref(0)
|
||||
|
||||
/**
|
||||
* 未完成的两组。合成一栏而不是各占一个 tab:点名时来回切 tab 很别扭;
|
||||
* 但也不能混成一个名单 —— 「卡住了」和「还没动手」在课堂上要做的事不一样。
|
||||
* 次数缀在名字后面,教师一眼看得出谁卡得最久(后端按提交数倒序给)。
|
||||
*/
|
||||
type UnfinishedItem = {
|
||||
username: string
|
||||
label: string
|
||||
/** 只有「交了没对」那一组有,点名字弹出来看错在哪 */
|
||||
failure: AttemptedStudent["lastFailure"]
|
||||
}
|
||||
|
||||
const unfinishedGroups = computed<{ title: string; items: UnfinishedItem[] }[]>(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
title: "还没交",
|
||||
items: visibleUnaccepted.value.map((item) => ({
|
||||
username: item.username,
|
||||
label: item.realName,
|
||||
failure: null,
|
||||
})),
|
||||
},
|
||||
{
|
||||
// 查多道题时这一栏混着「一道没对」和「差一道」两种人,标题得说清是「没全对」
|
||||
title: queriedProblemCount.value > 1 ? "交了没全对" : "交了没对",
|
||||
items: visibleAttempted.value.map((item) => ({
|
||||
username: item.username,
|
||||
label: attemptedLabel(item),
|
||||
failure: item.lastFailure,
|
||||
})),
|
||||
},
|
||||
].filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
type FailureResult = NonNullable<AttemptedStudent["lastFailure"]>["result"]
|
||||
|
||||
// 查多道题时把「做出几道」缀上:差一道和一道没做出来,老师要先管的不是同一个人
|
||||
function attemptedLabel(item: AttemptedStudent) {
|
||||
const total = queriedProblemCount.value
|
||||
const progress = total > 1 ? ` ${item.solvedCount}/${total}题` : ""
|
||||
return `${item.realName}${progress} ${item.submissionCount}次`
|
||||
}
|
||||
|
||||
function statusName(result?: FailureResult) {
|
||||
return result === undefined ? "" : (JUDGE_STATUS[result]?.name ?? "未知状态")
|
||||
}
|
||||
|
||||
function openFailure(id?: string) {
|
||||
if (id) openSubmission(id)
|
||||
}
|
||||
|
||||
const adjustedPersonCount = computed(
|
||||
() => personCount.value - hiddenCount.value,
|
||||
)
|
||||
|
||||
const adjustedPersonRate = computed(() => {
|
||||
if (adjustedPersonCount.value <= 0) return "0%"
|
||||
@@ -304,11 +464,24 @@ onMounted(() => {
|
||||
)
|
||||
hiddenStudents.value = cleaned
|
||||
saveHidden(cleaned)
|
||||
// 打开就查一次。老师是投在屏幕上盯着看的,不该还要先点一下按钮
|
||||
handleStatistics()
|
||||
})
|
||||
|
||||
/**
|
||||
* 课堂上「还剩几个没交」每十几秒就在变,所以面板自己刷。
|
||||
* 时间窗是相对当下算的,每次刷都是新的窗口,不是同一份快照重放。
|
||||
* 页面切到后台就停 —— 那会儿没人在看,白跑而已。
|
||||
*/
|
||||
const visibility = useDocumentVisibility()
|
||||
useIntervalFn(() => {
|
||||
if (visibility.value === "visible") handleStatistics()
|
||||
}, 15000)
|
||||
|
||||
// 饼图数据 - 提交正确率分布
|
||||
const pieChartData = computed(() => {
|
||||
const wrongCount = count.total - count.accepted
|
||||
// 判题中的既不算对也不算错,和正确率同一个口径
|
||||
const wrongCount = count.total - count.accepted - count.judging
|
||||
return {
|
||||
labels: ["正确提交", "错误提交"],
|
||||
datasets: [
|
||||
@@ -409,27 +582,48 @@ function goSubmissions() {
|
||||
},
|
||||
})
|
||||
}
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleStatistics() {
|
||||
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await fetchStatistics()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatistics() {
|
||||
const current = Date.now()
|
||||
const end = formatISO(current)
|
||||
const duration =
|
||||
query.duration === "all"
|
||||
? { end }
|
||||
: { start: formatISO(sub(current, subOptions.value)), end }
|
||||
const problems = query.problem
|
||||
.split(/[,,;;\s]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const res = await getSubmissionStatistics(
|
||||
duration,
|
||||
query.problem,
|
||||
query.username,
|
||||
)
|
||||
queriedProblemCount.value = new Set(problems.map((p) => p.toLowerCase())).size
|
||||
count.total = res.submissionCount
|
||||
count.accepted = res.acceptedCount
|
||||
count.judging = res.judgingCount
|
||||
count.rate = res.correctRate
|
||||
// 这里的 res.data 是载荷**自己**的 data 字段(每个学生一行),
|
||||
// 不是原来那层信封 —— 契约 submissionStatisticsSchema 就是这么定的
|
||||
list.value = res.data
|
||||
listUnaccepted.value = res.dataUnaccepted
|
||||
person.count = res.personCount
|
||||
person.rate = res.personRate
|
||||
listAttempted.value = res.dataAttempted
|
||||
personCount.value = res.personCount
|
||||
// 查过的班级记下来,下次打开直接带上
|
||||
if (query.username) storage.set(STORAGE_KEY.STATISTICS_CLASS, query.username)
|
||||
}
|
||||
|
||||
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
|
||||
@@ -452,6 +646,30 @@ function rowProps(row: SubmissionStatisticsUser) {
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.name {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.name-clickable {
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.failure-error {
|
||||
margin: 0;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: block;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -144,6 +144,7 @@ export const STORAGE_KEY = {
|
||||
ADMIN_PROBLEM_TAGS: "adminProblemTags",
|
||||
DEMO_MODE: "demoMode",
|
||||
LOGIN_CLASS: "loginClass",
|
||||
STATISTICS_CLASS: "statisticsClass",
|
||||
}
|
||||
|
||||
export const DIFFICULTY = {
|
||||
|
||||
Reference in New Issue
Block a user