- 代码统计「班级完成度」圆环的已完成改成做完的人数,和上面「完成人数」一致 - 流程图统计「班级人数」减掉请假隐藏的,和完成度的分母一致 - 流程图统计打开就查、开着自动刷新(一天以内的时间段,30 秒一次),按钮带转圈 - 两个面板统计失败时在按钮旁说一句,不再无声无息停在旧数字上 - 没完成的人全被隐藏时不再显示「全都完成了」 - 流程图统计的「未完成」页签在全班做完后不再整个消失 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
814 lines
29 KiB
Vue
814 lines
29 KiB
Vue
<template>
|
||
<n-flex align="center">
|
||
<n-select
|
||
placeholder="班级或用户(可选)"
|
||
v-model:value="query.username"
|
||
:options="classOptions"
|
||
style="width: 190px"
|
||
filterable
|
||
tag
|
||
clearable
|
||
/>
|
||
<n-input
|
||
placeholder="题号(可选,逗号分隔)"
|
||
v-model:value="query.problem"
|
||
style="width: 200px"
|
||
clearable
|
||
/>
|
||
<n-select style="width: 120px" v-model:value="query.duration" :options="options" />
|
||
<n-button type="primary" :loading="loading" @click="handleStatistics"> 统计 </n-button>
|
||
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions"> 前往提交列表 </n-button>
|
||
<n-text v-if="failed" type="error">统计没拉下来,15 秒后会自动再试</n-text>
|
||
</n-flex>
|
||
|
||
<n-empty v-if="!hasResult" description="暂无数据" style="margin: 40px 0" />
|
||
|
||
<template v-if="hasResult">
|
||
<n-divider style="margin: 16px 0" />
|
||
<n-flex justify="space-around">
|
||
<div class="stat-item">
|
||
<n-text>总提交</n-text>
|
||
<n-gradient-text type="info" font-size="28">{{ count.total }}</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>正确提交</n-text>
|
||
<n-gradient-text type="primary" font-size="28">{{ 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="personCount > 0">
|
||
<div class="stat-item">
|
||
<n-text>完成人数</n-text>
|
||
<n-gradient-text type="error" font-size="28">{{ doneList.length }}</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>班级人数</n-text>
|
||
<n-gradient-text type="warning" font-size="28">{{ adjustedPersonCount }}</n-gradient-text>
|
||
</div>
|
||
<div class="stat-item">
|
||
<n-text>完成度</n-text>
|
||
<n-gradient-text type="success" font-size="28">{{ adjustedPersonRate }}</n-gradient-text>
|
||
</div>
|
||
</template>
|
||
</n-flex>
|
||
<n-divider style="margin: 16px 0" />
|
||
|
||
<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 v-if="count.total > 0">
|
||
<n-card title="提交正确率">
|
||
<Doughnut :data="pieChartData" :options="pieChartOptions" />
|
||
</n-card>
|
||
</n-gi>
|
||
<n-gi v-if="personCount > 0">
|
||
<n-card title="班级完成度">
|
||
<Doughnut :data="completionChartData" :options="completionChartOptions" />
|
||
</n-card>
|
||
</n-gi>
|
||
</n-grid>
|
||
</n-tab-pane>
|
||
|
||
<!--
|
||
窗口里交过东西的**所有人**,一行一个,展开是他这段时间的全部提交(对的错的
|
||
都在)。没做完的人也在表里 —— 一次没对的学生正是老师最想点开看的那个。
|
||
隔壁「未完成」是点名用的名单,只有名字,两栏用途不同。
|
||
-->
|
||
<n-tab-pane name="submissions" :tab="`提交记录(${list.length})`">
|
||
<n-data-table
|
||
v-if="list.length"
|
||
striped
|
||
:columns="columns"
|
||
:data="list"
|
||
:row-key="rowKey"
|
||
:expanded-row-keys="expandedRowKeys"
|
||
@update:expanded-row-keys="updateExpandedRowKeys"
|
||
: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="`未完成(${unfinishedTotal})`">
|
||
<n-flex align="center" style="margin: 12px 0">
|
||
<n-switch v-model:value="hideMode" size="large">
|
||
<template #checked>请假隐藏中</template>
|
||
<template #unchecked>请假隐藏</template>
|
||
</n-switch>
|
||
<n-button v-if="hiddenCount > 0" size="small" type="info" @click="showAll">
|
||
恢复 {{ hiddenCount }} 位
|
||
</n-button>
|
||
</n-flex>
|
||
<!-- 名单上的人全被「请假隐藏」了不等于全做完了,别让老师误以为全班过了 -->
|
||
<n-text v-if="unfinishedGroups.length === 0 && hiddenCount > 0" depth="3">
|
||
没完成的 {{ hiddenCount }} 位都隐藏了
|
||
</n-text>
|
||
<n-gradient-text v-else-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?.problemDisplayId }} ·
|
||
{{ 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>
|
||
</template>
|
||
<script setup lang="ts">
|
||
import { h } from "vue"
|
||
import { formatISO, sub, type Duration } from "date-fns"
|
||
import { getSubmission, getSubmissionStatistics, getSubmissionStatisticsItems } from "oj/api"
|
||
import { LANGUAGE_FORMAT_VALUE, PANEL_DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
|
||
import { useConfigStore } from "../store/config"
|
||
import { useHiddenStudents } from "../composables/hiddenStudents"
|
||
import { useStatisticsQuery } from "../composables/statisticsQuery"
|
||
import { Doughnut } from "vue-chartjs"
|
||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
|
||
import { NCode, NFlex, NPopover, NTag, NText, type DataTableRowKey } from "naive-ui"
|
||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||
import { durationFromValue, parseTime } from "utils/functions"
|
||
import type {
|
||
AttemptedStudent,
|
||
SubmissionStatisticsItems,
|
||
SubmissionStatisticsUser,
|
||
UnacceptedStudent,
|
||
} from "@oj2/contract"
|
||
|
||
// 注册 Chart.js 组件
|
||
ChartJS.register(ArcElement, Title, Tooltip, Legend)
|
||
|
||
interface Props {
|
||
problem: string
|
||
username: string
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
const options: SelectOption[] = [...PANEL_DURATION_OPTIONS]
|
||
|
||
/**
|
||
* 轨迹方块的颜色。取值就是 JUDGE_STATUS 里那个 type,色号沿用 Naive UI 的语义色
|
||
* (项目里 ExerciseMatch.vue 等处也是直接写这几个值)。
|
||
*/
|
||
const ATTEMPT_COLORS: Record<string, string> = {
|
||
success: "#18a058",
|
||
error: "#d03050",
|
||
warning: "#f0a020",
|
||
info: "#2080f0",
|
||
default: "#909399",
|
||
}
|
||
|
||
/**
|
||
* 把一个学生的提交按题目聚成组,给展开行画「状态轨迹」用。
|
||
*
|
||
* 原来展开行是一排提交编号按钮 —— 12 位十六进制本身没有信息量,一节课里学生在好几道
|
||
* 题之间来回跳,那一排看不出他到底卡在哪。现在一道题一行:题号、标题、交了几次、
|
||
* 过没过,后面跟一排按时间**从早到晚**的小方块,颜色就是判题状态。
|
||
* 「三红一绿」和「七红到底」一眼分得开。
|
||
*
|
||
* 排序按老师的用法来:**没过的排前面**,其中交得越多越靠前 —— 卡得最久的那道顶到眼前;
|
||
* 已通过的沉底,它们只是「做完了」,不需要再看。
|
||
*/
|
||
function groupByProblem(list: SubmissionStatisticsItems["items"]) {
|
||
const groups = new Map<
|
||
string,
|
||
{
|
||
problemDisplayId: string
|
||
problemTitle: string
|
||
items: SubmissionStatisticsItems["items"]
|
||
}
|
||
>()
|
||
for (const item of list) {
|
||
const group = groups.get(item.problemDisplayId)
|
||
if (group) group.items.push(item)
|
||
else
|
||
groups.set(item.problemDisplayId, {
|
||
problemDisplayId: item.problemDisplayId,
|
||
problemTitle: item.problemTitle,
|
||
items: [item],
|
||
})
|
||
}
|
||
return [...groups.values()]
|
||
.map((group) => ({
|
||
...group,
|
||
// 后端按时间倒序给,轨迹要从早到晚读,所以翻过来
|
||
items: [...group.items].reverse(),
|
||
// 「语法未过」(ast_check_failed) 也算做出来了 —— 答案对了,只是没按要求写;
|
||
// 和统计表格上「已解决」那一列的口径保持一致
|
||
solved: group.items.some(
|
||
(item) =>
|
||
item.result === SubmissionStatus.accepted ||
|
||
item.result === SubmissionStatus.ast_check_failed,
|
||
),
|
||
}))
|
||
.sort((a, b) => Number(a.solved) - Number(b.solved) || b.items.length - a.items.length)
|
||
}
|
||
|
||
/**
|
||
* 悬停方块时看到的代码,按提交编号缓存。
|
||
*
|
||
* 原来悬停只有「状态 · 时间 · 编号」,想看学生写了什么非得点开新页面,老师在一排
|
||
* 红方块之间来回对比时要开一堆标签页。代码**悬停时才拉**、不随明细一起下发:
|
||
* 明细一次最多 200 条、展开着每 15 秒还要重拉,而真正会被悬停的只是其中几个。
|
||
* 代码交了就不会再变,所以缓存不随重新统计清空(和 items 不一样)。
|
||
*/
|
||
type CodeState =
|
||
| { status: "loading" }
|
||
| { status: "error" }
|
||
| { status: "done"; code: string; language: string }
|
||
|
||
const codes = reactive<Record<string, CodeState>>({})
|
||
|
||
async function loadCode(id: string) {
|
||
const cached = codes[id]
|
||
if (cached && cached.status !== "error") return
|
||
codes[id] = { status: "loading" }
|
||
try {
|
||
const submission = await getSubmission(id)
|
||
codes[id] = {
|
||
status: "done",
|
||
code: submission.code,
|
||
language: LANGUAGE_FORMAT_VALUE[submission.language],
|
||
}
|
||
} catch {
|
||
codes[id] = { status: "error" }
|
||
}
|
||
}
|
||
|
||
function renderCode(id: string) {
|
||
const cached = codes[id]
|
||
if (!cached || cached.status === "loading") return h(NText, { depth: 3 }, () => "代码加载中…")
|
||
if (cached.status === "error")
|
||
return h(NText, { type: "error" }, () => "代码没拉下来,点方块打开看")
|
||
return h(
|
||
"div",
|
||
// 太长就在浮层里滚:鼠标移进浮层它不会消失,可以接着往下翻
|
||
{ style: "max-height: 360px; overflow: auto" },
|
||
h(NCode, { code: cached.code, language: cached.language, showLineNumbers: true }),
|
||
)
|
||
}
|
||
|
||
function openSubmission(id: string) {
|
||
window.open(`/submission/${id}`, "_blank", "noopener")
|
||
}
|
||
|
||
const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||
{
|
||
type: "expand",
|
||
renderExpand: (row) => {
|
||
const loaded = items[row.username]
|
||
if (!loaded) return h(NText, { depth: 3 }, () => "加载中…")
|
||
return h(NFlex, { vertical: true, size: "medium" }, () => [
|
||
...groupByProblem(loaded.items).map((group) =>
|
||
// 题头一栏、轨迹一栏,**外层不换行**:一道题交了几十发时方块要在自己那一栏里
|
||
// 折行,折下来的一行才会和上一行对齐
|
||
h(NFlex, { size: "small", align: "flex-start", wrap: false }, () => [
|
||
h(
|
||
NFlex,
|
||
{
|
||
size: 4,
|
||
align: "center",
|
||
wrap: false,
|
||
style: "width: 200px; flex: none",
|
||
},
|
||
() => [
|
||
h(NTag, { size: "small", bordered: false }, () => group.problemDisplayId),
|
||
h(
|
||
NText,
|
||
{
|
||
depth: 2,
|
||
style: "overflow: hidden; text-overflow: ellipsis; white-space: nowrap",
|
||
},
|
||
() => group.problemTitle,
|
||
),
|
||
],
|
||
),
|
||
h(
|
||
NText,
|
||
{
|
||
depth: 3,
|
||
style: "width: 104px; flex: none",
|
||
},
|
||
() => `${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
|
||
),
|
||
h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () =>
|
||
group.items.map((item) =>
|
||
h(
|
||
NPopover,
|
||
{
|
||
delay: 200,
|
||
// 代码比一行提示宽得多;限宽让长行在浮层里横向滚,不把浮层撑出屏幕
|
||
style: "max-width: 560px",
|
||
onUpdateShow: (show: boolean) => {
|
||
if (show) loadCode(item.id)
|
||
},
|
||
},
|
||
{
|
||
trigger: () =>
|
||
h("button", {
|
||
// 内联样式而不是 class:这些方块是 h() 出来、挂在 NDataTable 的
|
||
// 展开槽里渲染的,<style scoped> 能不能盖到它并不确定
|
||
style: {
|
||
width: "14px",
|
||
height: "14px",
|
||
padding: "0",
|
||
border: "none",
|
||
borderRadius: "3px",
|
||
cursor: "pointer",
|
||
background: ATTEMPT_COLORS[JUDGE_STATUS[item.result]?.type ?? "default"],
|
||
},
|
||
onClick: (event: MouseEvent) => {
|
||
event.stopPropagation()
|
||
openSubmission(item.id)
|
||
},
|
||
}),
|
||
default: () =>
|
||
h(NFlex, { vertical: true, size: "small" }, () => [
|
||
h(
|
||
NText,
|
||
{ depth: 3 },
|
||
() =>
|
||
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
|
||
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
|
||
`${item.id.toString().slice(0, 12)}`,
|
||
),
|
||
renderCode(item.id),
|
||
]),
|
||
},
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
loaded.truncated
|
||
? h(
|
||
NText,
|
||
{ depth: 3 },
|
||
() => `只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
|
||
)
|
||
: null,
|
||
])
|
||
},
|
||
},
|
||
{ title: "用户", key: "username" },
|
||
// 做完没做完在同一张表里,用标签区分 —— 少了它这张表看不出谁还卡着
|
||
{
|
||
title: "完成",
|
||
key: "done",
|
||
render: (row) =>
|
||
h(
|
||
NTag,
|
||
{
|
||
size: "small",
|
||
type: row.done ? "success" : "default",
|
||
bordered: false,
|
||
},
|
||
() => (row.done ? "已完成" : "未完成"),
|
||
),
|
||
},
|
||
{
|
||
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: "正确率",
|
||
key: "correctRate",
|
||
render: (row) => `${row.correctRate}%`,
|
||
},
|
||
]
|
||
|
||
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}`,
|
||
})) ?? [],
|
||
)
|
||
|
||
// 班级、题号、时间段记一小时,理由见 useStatisticsQuery
|
||
const { query, save: saveQuery } = useStatisticsQuery(STORAGE_KEY.STATISTICS_QUERY, props)
|
||
|
||
const count = reactive({
|
||
total: 0,
|
||
accepted: 0,
|
||
// 还在判题队列里的条数。total 含它,rate 的分母不含 —— 全班同时交卷的那几秒,
|
||
// 分母涨了分子没涨,正确率会凭空掉一截
|
||
judging: 0,
|
||
rate: 0,
|
||
})
|
||
// 花名册人数。后端只下发这一个分母,完成度在前端算 —— 「请假隐藏」要从分母里
|
||
// 减人,那是浏览器本地状态,后端算不出来
|
||
const personCount = ref(0)
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
// 交过东西的所有人(做没做完看 done)
|
||
const list = ref<SubmissionStatisticsUser[]>([])
|
||
// 「完成人数」「完成度」算的是做完了的那些,不是整张表的行数
|
||
const doneList = computed(() => list.value.filter((row) => row.done))
|
||
const listUnaccepted = ref<UnacceptedStudent[]>([])
|
||
// 交了但一次没对的。和上面那一栏合起来才是「未完成」的全部
|
||
const listAttempted = ref<AttemptedStudent[]>([])
|
||
const expandedRowKeys = ref<DataTableRowKey[]>([])
|
||
|
||
/**
|
||
* 展开行的明细,按人缓存。**每次重新统计都清空** —— 时间窗滚过之后旧明细就不对了;
|
||
* 清完如果还有展开着的行,顺手把那一行重拉一遍,让它跟着自动刷新一起活着。
|
||
*/
|
||
const items = reactive<Record<string, SubmissionStatisticsItems>>({})
|
||
|
||
// 点一行会同时走 rowProps 的 onClick 和表格的 update:expanded-row-keys,
|
||
// 两边都想拉一次;去重放在这里,调用方不用各自判
|
||
const itemsLoading = new Set<string>()
|
||
|
||
async function loadItems(username: string) {
|
||
if (items[username] || itemsLoading.has(username)) return
|
||
itemsLoading.add(username)
|
||
const current = Date.now()
|
||
const duration =
|
||
query.duration === "all"
|
||
? { end: formatISO(current) }
|
||
: {
|
||
start: formatISO(sub(current, subOptions.value)),
|
||
end: formatISO(current),
|
||
}
|
||
try {
|
||
items[username] = await getSubmissionStatisticsItems(duration, username, query.problem)
|
||
} catch {
|
||
// 拉不到就当空的:展开行显示不出东西,但不该把整个面板带崩
|
||
items[username] = { items: [], truncated: false }
|
||
} finally {
|
||
itemsLoading.delete(username)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 「查出东西了吗」。**不能只看提交数** —— 一节课刚开始时一条提交都没有,但后端
|
||
* 已经把整份花名册当作「未完成」返回了,而那正是老师最想看名单的时刻。
|
||
* 原来整个面板 v-if 在 count.total > 0 上,那会儿只显示「暂无数据」。
|
||
*/
|
||
const hasResult = computed(() => count.total > 0 || listUnaccepted.value.length > 0)
|
||
|
||
const { hideMode, hideStudent, showAll, notHidden } = useHiddenStudents("oj_hidden_students")
|
||
|
||
const visibleUnaccepted = computed(() => listUnaccepted.value.filter(notHidden))
|
||
const visibleAttempted = computed(() => listAttempted.value.filter(notHidden))
|
||
|
||
// 请假的人两栏都要藏 —— 他们同样占着班级人数这个分母
|
||
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%"
|
||
const rate = Math.min(100, (doneList.value.length / adjustedPersonCount.value) * 100)
|
||
return `${Math.round(rate * 100) / 100}%`
|
||
})
|
||
|
||
onMounted(() => {
|
||
// 过期清理在 useHiddenStudents 里,这里只管「打开就查一次」——
|
||
// 老师是投在屏幕上盯着看的,不该还要先点一下按钮
|
||
handleStatistics()
|
||
})
|
||
|
||
/**
|
||
* 课堂上「还剩几个没交」每十几秒就在变,所以面板自己刷。
|
||
* 时间窗是相对当下算的,每次刷都是新的窗口,不是同一份快照重放。
|
||
* 页面切到后台就停 —— 那会儿没人在看,白跑而已。
|
||
*/
|
||
const visibility = useDocumentVisibility()
|
||
useIntervalFn(() => {
|
||
if (visibility.value === "visible") handleStatistics()
|
||
}, 15000)
|
||
|
||
// 饼图数据 - 提交正确率分布
|
||
const pieChartData = computed(() => {
|
||
// 判题中的既不算对也不算错,和正确率同一个口径
|
||
const wrongCount = count.total - count.accepted - count.judging
|
||
return {
|
||
labels: ["正确提交", "错误提交"],
|
||
datasets: [
|
||
{
|
||
label: "提交数",
|
||
data: [count.accepted, wrongCount],
|
||
backgroundColor: ["rgba(75, 192, 192, 0.6)", "rgba(255, 99, 132, 0.6)"],
|
||
borderColor: ["rgba(75, 192, 192, 1)", "rgba(255, 99, 132, 1)"],
|
||
borderWidth: 2,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const pieChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: "bottom" as const,
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function (context: any) {
|
||
const label = context.label || ""
|
||
const value = context.parsed || 0
|
||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||
const percentage = ((value / total) * 100).toFixed(1)
|
||
return `${label}: ${value} (${percentage}%)`
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
// 环形图数据 - 班级完成度。「已完成」和上面数字行的「完成人数」是同一个数(做完的),
|
||
// 原来这里用的是 list.length —— 那是所有交过的人,交了没对的也被画成了已完成
|
||
const completionChartData = computed(() => {
|
||
const completedCount = doneList.value.length
|
||
const uncompletedCount = Math.max(0, adjustedPersonCount.value - completedCount)
|
||
return {
|
||
labels: ["已完成", "未完成"],
|
||
datasets: [
|
||
{
|
||
label: "人数",
|
||
data: [completedCount, uncompletedCount],
|
||
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
|
||
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
|
||
borderWidth: 2,
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const completionChartOptions = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: "bottom" as const,
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function (context: any) {
|
||
const label = context.label || ""
|
||
const value = context.parsed || 0
|
||
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
|
||
const percentage = ((value / total) * 100).toFixed(1)
|
||
return `${label}: ${value} (${percentage}%)`
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
const subOptions = computed<Duration>(
|
||
// 认不出来(含 all)就退回列表第一档,和原来 `?? options[0]` 一致;
|
||
// all 实际不会走到这里,handleStatistics 先分支掉了
|
||
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
|
||
)
|
||
|
||
function goSubmissions() {
|
||
router.push({
|
||
name: "submissions",
|
||
query: {
|
||
username: query.username,
|
||
problem: query.problem,
|
||
},
|
||
})
|
||
}
|
||
const loading = ref(false)
|
||
|
||
/**
|
||
* 上一次统计失败了。原来没 catch,请求一失败面板上什么都不说,老师看着的还是旧数字。
|
||
* 不弹 toast:每 15 秒自动刷新一次,断网时会一直弹
|
||
*/
|
||
const failed = ref(false)
|
||
|
||
async function handleStatistics() {
|
||
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
|
||
if (loading.value) return
|
||
loading.value = true
|
||
try {
|
||
await fetchStatistics()
|
||
failed.value = false
|
||
} catch {
|
||
failed.value = true
|
||
} 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)
|
||
saveQuery()
|
||
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
|
||
listAttempted.value = res.dataAttempted
|
||
personCount.value = res.personCount
|
||
|
||
const expanded = expandedRowKeys.value[0]
|
||
for (const key of Object.keys(items)) delete items[key]
|
||
if (typeof expanded === "string") loadItems(expanded)
|
||
}
|
||
|
||
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
|
||
return row.username
|
||
}
|
||
|
||
function updateExpandedRowKeys(keys: DataTableRowKey[]) {
|
||
expandedRowKeys.value = keys.slice(-1)
|
||
const opened = expandedRowKeys.value[0]
|
||
if (typeof opened === "string") loadItems(opened)
|
||
}
|
||
|
||
function rowProps(row: SubmissionStatisticsUser) {
|
||
return {
|
||
style: "cursor: pointer;",
|
||
onClick: () => {
|
||
const key = rowKey(row)
|
||
const isExpanded = expandedRowKeys.value.includes(key)
|
||
expandedRowKeys.value = isExpanded ? [] : [key]
|
||
if (!isExpanded) loadItems(row.username)
|
||
},
|
||
}
|
||
}
|
||
</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;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
</style>
|