feat(统计): 统计接口补上每人每道题的每一次提交、流程图每人最好的一次,流程图也能一次查几道题

- 新增 GET /submissions/statistics/grid:范围内每个学生的每次提交(只含普通学生,最多 5000 条),
  没选班级时附带各班花名册人数,统计页按「人 × 题」画方块串、按班级汇总
- /flowcharts/statistics 题号可以一次填几道,多返回 people(每人分数最高的那张)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 18:28:27 +08:00
1 parent 99c158498c
commit f09da88ff7
8 files changed
+288 -1492

No files matched your search

@@ -1,31 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { statisticsOpen } from "../composables/editorMenu"
/**
* 教师的「课堂统计」弹窗。入口有两个(工具栏的按钮、「更多」菜单 / 手机「⋯」),
* 弹窗只在题目页上挂一个。
*/
// 只有老师看得见,静态 import 的话每个学生打开题目都要白拉一份 chart.js(~68KB gzip)
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
const userStore = useUserStore()
const { problem } = storeToRefs(useProblemStore())
const { isDesktop } = useBreakpoints()
</script>
<template>
<n-modal
v-if="userStore.isTeacherOrAbove && problem"
v-model:show="statisticsOpen"
preset="card"
title="提交记录的统计"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
>
<StatisticsPanel :problem="problem._id" username="" />
</n-modal>
</template>
@@ -1,513 +0,0 @@
<template>
<n-flex align="center">
<n-input
placeholder="用户(可选)"
v-model:value="query.username"
style="width: 150px"
clearable
/>
<n-input
placeholder="题号(可选)"
v-model:value="query.problem"
style="width: 120px"
clearable
/>
<n-select style="width: 120px" v-model:value="query.duration" :options="durationOptions" />
<n-button type="primary" :loading="loading" @click="handleStatistics">统计</n-button>
<n-text v-if="failed" type="error">统计没拉下来,稍后会自动再试</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">
{{ data.totalCount }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>平均分</n-text>
<n-gradient-text type="primary" font-size="28">
{{ data.avgScore }}
</n-gradient-text>
</div>
<template v-if="data.personCount > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">
{{ data.completedCount }}
</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">
{{ completionRate }}
</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">
<!-- 1. Grade pie chart -->
<n-gi v-if="data.totalCount > 0">
<n-card title="等级分布">
<div class="chart-container">
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
</div>
</n-card>
</n-gi>
<!-- 3. Completion doughnut -->
<n-gi v-if="data.personCount > 0">
<n-card title="班级完成度">
<div class="chart-container">
<Doughnut :data="completionChartData" :options="doughnutOptions" />
</div>
</n-card>
</n-gi>
<!-- 2. Radar chart -->
<n-gi v-if="hasRadarData">
<n-card title="四维评分雷达图">
<div class="chart-container">
<Radar :data="radarChartData" :options="radarOptions" />
</div>
</n-card>
</n-gi>
<!-- 4. Criteria bar chart (only when class exists, pairs with radar) -->
<n-gi v-if="data.personCount > 0 && hasRadarData">
<n-card title="各维度平均得分">
<div class="chart-container">
<Bar :data="criteriaBarChartData" :options="barOptions" />
</div>
</n-card>
</n-gi>
<!-- 4. Word cloud -->
<n-gi :span="2" v-if="data.wordFrequencies.length > 0">
<n-card title="常见问题高频词">
<div class="wordcloud-container">
<canvas ref="wordcloudCanvas"></canvas>
</div>
</n-card>
</n-gi>
</n-grid>
</n-tab-pane>
<!-- 有花名册就一直在(和代码统计面板一样):原来人一做完这个页签就整个消失,
老师找不到「全都完成了」那句 -->
<n-tab-pane
v-if="data.personCount > 0 || data.dataUnaccepted.length > 0"
name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length})`"
>
<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-flex size="large" align="center">
<n-text v-if="visibleUnaccepted.length === 0 && hiddenCount > 0" depth="3">
没完成的 {{ hiddenCount }} 位都隐藏了
</n-text>
<n-gradient-text v-else-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-tab-pane>
</n-tabs>
</template>
</template>
<script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns"
import type { FlowchartStatistics } from "@oj2/contract"
import { getFlowchartStatistics } from "oj/api"
import { PANEL_DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER, STORAGE_KEY } from "utils/constants"
import { durationFromValue } from "utils/functions"
import { useHiddenStudents } from "../composables/hiddenStudents"
import { useStatisticsQuery } from "../composables/statisticsQuery"
import { Doughnut, Radar, Bar } from "vue-chartjs"
import {
Chart as ChartJS,
ArcElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
PointElement,
LineElement,
Filler,
LinearScale,
BarElement,
CategoryScale,
} from "chart.js"
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
ChartJS.register(
ArcElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
PointElement,
LineElement,
Filler,
LinearScale,
BarElement,
CategoryScale,
WordCloudController,
WordElement,
)
interface Props {
problem: string
username: string
}
const props = defineProps<Props>()
const durationOptions: SelectOption[] = [...PANEL_DURATION_OPTIONS]
// 班级、题号、时间段记一小时,理由见 useStatisticsQuery
const { query, save: saveQuery } = useStatisticsQuery(STORAGE_KEY.FLOWCHART_STATISTICS_QUERY, props)
const data = reactive<FlowchartStatistics>({
totalCount: 0,
avgScore: 0,
gradeDistribution: {},
criteriaAverages: {},
personCount: 0,
completedCount: 0,
wordFrequencies: [],
dataUnaccepted: [],
})
/**
* 「查出东西了吗」。和 StatisticsPanel 同一个道理:一节课刚开始时没有任何提交,
* 但花名册整份都在 dataUnaccepted 里,那会儿正是老师要看名单的时候。
*/
const hasResult = computed(() => data.totalCount > 0 || data.dataUnaccepted.length > 0)
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
let wordcloudChart: ChartJS | null = null
const { hideMode, hideStudent, showAll, isHidden, notHidden } = useHiddenStudents(
"oj_hidden_students_flowchart",
)
const visibleUnaccepted = computed(() => data.dataUnaccepted.filter(notHidden))
const hiddenCount = computed(
() => data.dataUnaccepted.filter((item) => isHidden(item.username)).length,
)
const adjustedPersonCount = computed(() => Math.max(0, data.personCount - hiddenCount.value))
const completionRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(100, (data.completedCount / adjustedPersonCount.value) * 100)
return `${Math.round(rate * 100) / 100}%`
})
const GRADE_COLORS: Record<string, { bg: string; border: string }> = {
S: { bg: "rgba(24, 160, 88, 0.6)", border: "rgba(24, 160, 88, 1)" },
A: { bg: "rgba(32, 128, 240, 0.6)", border: "rgba(32, 128, 240, 1)" },
B: { bg: "rgba(240, 160, 32, 0.6)", border: "rgba(240, 160, 32, 1)" },
C: { bg: "rgba(208, 48, 80, 0.6)", border: "rgba(208, 48, 80, 1)" },
}
const gradeChartData = computed(() => {
const grades = ["S", "A", "B", "C"]
const counts = grades.map((g) => data.gradeDistribution[g] || 0)
const labels = grades.map((g) => `${g}级 (${data.gradeDistribution[g] || 0})`)
return {
labels,
datasets: [
{
data: counts,
backgroundColor: grades.map((g) => GRADE_COLORS[g].bg),
borderColor: grades.map((g) => GRADE_COLORS[g].border),
borderWidth: 2,
},
],
}
})
const completionChartData = computed(() => {
const uncompleted = Math.max(0, adjustedPersonCount.value - data.completedCount)
return {
labels: ["已完成", "未完成"],
datasets: [
{
data: [data.completedCount, uncompleted],
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 doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: "bottom" as const },
tooltip: {
callbacks: {
label(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 pct = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${pct}%)`
},
},
},
},
}
// 与评分明细共用同一份顺序,见 utils/constants
const CRITERIA_ORDER = FLOWCHART_CRITERIA_ORDER
const hasRadarData = computed(() => CRITERIA_ORDER.some((k) => k in data.criteriaAverages))
const radarChartData = computed(() => {
const labels = CRITERIA_ORDER
const values = CRITERIA_ORDER.map((k) => {
const item = data.criteriaAverages[k]
if (!item) return 0
return Math.round((item.avg / item.max) * 100)
})
return {
labels,
datasets: [
{
label: "平均得分率 (%)",
data: values,
backgroundColor: "rgba(32, 128, 240, 0.2)",
borderColor: "rgba(32, 128, 240, 1)",
borderWidth: 2,
pointBackgroundColor: "rgba(32, 128, 240, 1)",
},
],
}
})
const radarOptions = {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
beginAtZero: true,
max: 100,
ticks: { stepSize: 20 },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const key = CRITERIA_ORDER[context.dataIndex]
const item = data.criteriaAverages[key]
if (!item) return ""
return `${key}: ${item.avg}/${item.max} (${context.parsed.r}%)`
},
},
},
},
}
const criteriaBarChartData = computed(() => {
const labels = CRITERIA_ORDER.filter((k) => k in data.criteriaAverages)
return {
labels,
datasets: [
{
label: "平均得分",
data: labels.map((k) => data.criteriaAverages[k]?.avg ?? 0),
backgroundColor: labels.map((_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].bg),
borderColor: labels.map((_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].border),
borderWidth: 2,
},
],
}
})
const barOptions = {
responsive: true,
aspectRatio: 1,
scales: {
y: { beginAtZero: true },
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const key = context.label
const item = data.criteriaAverages[key]
if (!item) return ""
return `${item.avg} / ${item.max}`
},
},
},
},
}
const WORD_COLORS = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#722ed1",
"#13c2c2",
"#1890ff",
"#52c41a",
"#faad14",
"#f5222d",
]
function renderWordCloud() {
if (!wordcloudCanvas.value || data.wordFrequencies.length === 0) return
if (wordcloudChart) {
wordcloudChart.destroy()
wordcloudChart = null
}
const words = data.wordFrequencies
const maxCount = Math.max(...words.map((w) => w.count))
wordcloudChart = new ChartJS(wordcloudCanvas.value, {
type: "wordCloud" as any,
data: {
labels: words.map((w) => w.word),
datasets: [
{
label: "",
data: words.map((w) => 10 + (w.count / maxCount) * 50),
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
rotate: 0,
} as any,
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const word = words[context.dataIndex]
return word ? `${word.word}: ${word.count}次` : ""
},
},
},
},
},
})
}
const subOptions = computed<Duration>(
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
)
const loading = ref(false)
/** 上一次统计失败了。不弹 toast:会自动刷新,断网时会一直弹 */
const failed = ref(false)
async function handleStatistics() {
// 自动刷新和手点可能撞上,上一次没回来就跳过这一次
if (loading.value) return
loading.value = true
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all" ? { end } : { start: formatISO(sub(current, subOptions.value)), end }
try {
const res = await getFlowchartStatistics(duration, query.problem, query.username)
Object.assign(data, res)
saveQuery()
failed.value = false
} catch (error) {
failed.value = true
console.error("获取流程图统计失败:", error)
return
} finally {
loading.value = false
}
await nextTick()
renderWordCloud()
}
/**
* 和代码统计面板一样:打开就查、开着就刷(老师是投在屏幕上盯着看的)。
* 流程图统计要给词云分词,比代码统计重,所以 30 秒一次,而且只刷一天以内的时间段 ——
* 查一周、一学年的是回头看,不是盯课堂,不必反复重算
*/
onMounted(handleStatistics)
const visibility = useDocumentVisibility()
useIntervalFn(() => {
if (visibility.value !== "visible") return
if (query.duration === "all") return
const d = subOptions.value
if (d.weeks || d.months || d.years || (d.days ?? 0) > 1) return
handleStatistics()
}, 30_000)
onUnmounted(() => {
if (wordcloudChart) {
wordcloudChart.destroy()
}
})
</script>
<style scoped>
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.chart-container {
height: 280px;
position: relative;
}
.wordcloud-container {
height: 300px;
position: relative;
}
</style>
@@ -1,813 +0,0 @@
<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>
@@ -1,65 +0,0 @@
import { reactive } from "vue"
import { PANEL_DURATION_OPTIONS } from "utils/constants"
import storage from "utils/storage"
/**
* 统计面板的查询条件(班级/用户、题号、时间段),**记一小时**:一小时内再打开,
* 框里是上次查的那一组。
*
* 老师一节课里会反复开关这个弹窗(看一眼、关掉、走下去辅导、回来再开),每次都从空的
* 选班级、敲题号很烦。但也不能一直记着 —— 曾经把上次的班级永久写进 localStorage,
* 下课换了班、或者换个人坐这台机器,上一个人查的班悄悄留在框里,老师没注意就按了
* 「统计」,看到的整个是别人的班。一小时是折中:一节课够用,隔一节课就自己清掉。
* 注意**连堂**时下一节仍在一小时内,框里会是上一个班,这一点是接受了的。
*
* 一小时从「最后一次查询」算起(调用方在每次查完后调 `save`,自动刷新也算),
* 开着面板盯一整节课不会中途过期。
*
* 调用方传进来的初值优先:题目页固定是这道题、提交列表带着自己的筛选条件,
* 那是比缓存更明确的上下文;为空的那几项才用缓存补上。
* 登录框那份 LOGIN_CLASS 是另一回事:那记的是这台机器的身份,不是一次临时查询。
*
* @param storageKey localStorage 的键。**两个面板各用各的** —— 流程图题和代码题的
* 题号不是一批,互相带过去只会查出一片空。
*/
export function useStatisticsQuery(
storageKey: string,
initial: { username: string; problem: string },
) {
const TTL = 60 * 60 * 1000
type Cached = { username: string; problem: string; duration: string; savedAt: number }
function load(): Partial<Cached> {
try {
const cached: Cached | null = storage.get(storageKey)
if (!cached || Date.now() - cached.savedAt > TTL) return {}
return cached
} catch {
return {}
}
}
const cached = load()
const query = reactive({
username: initial.username || cached.username || "",
problem: initial.problem || cached.problem || "",
// 选项表改过之后旧缓存里的值可能已经不在里面了,认不出来就回到第一档
duration: PANEL_DURATION_OPTIONS.some((option) => option.value === cached.duration)
? cached.duration!
: PANEL_DURATION_OPTIONS[0].value,
})
function save() {
storage.set(storageKey, {
// 选择框点了清除是 null,不是空串
username: query.username ?? "",
problem: query.problem ?? "",
duration: query.duration,
savedAt: Date.now(),
} satisfies Cached)
}
return { query, save }
}