feat(统计): 数据统计从弹框改成独立页面,左边题目、右边学生,一道题看全班每人的每一次

- 课堂看板管这节课,统计管回头看:时间段默认「两节课」,还有这节课、今天、7 天、30 天、这学期、全部、自己选日子;不再自动刷新
- 选一道题看每人在这道题上的方块串,鼠标停上去看那次的代码;选「全部」每人一行、展开按题看
- 没选班级(从题目页进来)先按班级汇总;流程图统计有等级分布、四项雷达、各项平均分、词云和每人最好的一次
- 提交列表、题目页的「数据统计」改成新标签打开这个页面
- 「请假隐藏」挪到课堂看板:点名字旁的 × 藏两个小时,做完人数的分母一起减掉

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 f09da88ff7
commit ed55ee5af6
16 files changed
+2256 -76

No files matched your search

@@ -0,0 +1,698 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { SubmissionStatistics, SubmissionStatisticsGrid } from "@oj2/contract"
import { SubmissionStatus } from "utils/constants"
import { parseTime } from "utils/functions"
import StatusPill from "oj/submission/components/StatusPill.vue"
import { useTone } from "oj/submission/composables/tone"
import AttemptSquares from "./AttemptSquares.vue"
/**
* 代码统计的主体(设计稿「统计重设计 · 定稿 B」):左边题目,右边学生。
* 选一道题看全班每人在这道题上的每一次;选「全部」看每人几道做完、展开按题看。
*
* 数字口径(做完、没交)跟着统计接口走(stats),这里的方块串和每题做完几人
* 从 grid 里的逐条提交算。
*/
const props = defineProps<{
stats: SubmissionStatistics
grid: SubmissionStatisticsGrid
}>()
const theme = useThemeVars()
const tone = useTone()
type Attempt = SubmissionStatisticsGrid["rows"][number]["submissions"][number]
function isAc(result: number) {
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
}
interface Student {
username: string
name: string
/** 题号 → 这道题上的每一次,从早到晚 */
byProblem: Map<string, Attempt[]>
total: number
accepted: number
last: string | null
}
/** 花名册上的每一个人:交过的从 grid 来,一条没交的从统计接口的 dataUnaccepted 来 */
const students = computed<Student[]>(() => {
const list: Student[] = props.grid.rows.map((row) => {
const byProblem = new Map<string, Attempt[]>()
for (const item of row.submissions) {
const bucket = byProblem.get(item.problemDisplayId) ?? []
bucket.push(item)
byProblem.set(item.problemDisplayId, bucket)
}
return {
username: row.username,
name: row.realName || row.username,
byProblem,
total: row.submissions.length,
accepted: row.submissions.filter((item) => isAc(item.result)).length,
last: row.submissions.at(-1)?.createTime ?? null,
}
})
const seen = new Set(list.map((item) => item.username))
for (const row of props.stats.dataUnaccepted) {
if (seen.has(row.username)) continue
list.push({
username: row.username,
name: row.realName || row.username,
byProblem: new Map(),
total: 0,
accepted: 0,
last: null,
})
}
return list
})
const problems = computed(() => props.grid.problems)
const personCount = computed(() => props.stats.personCount || students.value.length)
function solvedOn(student: Student, pid: string) {
return (student.byProblem.get(pid) ?? []).some((item) => isAc(item.result))
}
function doneCount(student: Student) {
return problems.value.filter((problem) => solvedOn(student, problem.problemDisplayId)).length
}
const problemRows = computed(() =>
problems.value.map((problem) => {
const pid = problem.problemDisplayId
const tried = students.value.filter((student) => student.byProblem.has(pid))
const done = tried.filter((student) => solvedOn(student, pid)).length
const attempts = tried.reduce((sum, student) => sum + student.byProblem.get(pid)!.length, 0)
return {
...problem,
done,
tried: tried.length,
avg: tried.length ? (attempts / tried.length).toFixed(1) : "—",
}
}),
)
const allDone = computed(
() => students.value.filter((student) => doneCount(student) === problems.value.length).length,
)
// ---------- 选中哪道题、筛哪些人 ----------
/** "" = 全部。只有一道题时直接选中它 */
const focus = ref("")
watch(
problems,
(list) => {
if (list.length === 1) focus.value = list[0]!.problemDisplayId
else if (!list.some((problem) => problem.problemDisplayId === focus.value)) focus.value = ""
},
{ immediate: true },
)
/** 默认只看没做完的:复盘要找的是他们,做完的一长串沉在下面也看不见 */
const onlyUnfinished = ref(true)
const focusRow = computed(() =>
problemRows.value.find((row) => row.problemDisplayId === focus.value),
)
/** 这道题上:没过的在前(交得越多越靠前 —— 卡得最久),一次没交的其次,做完的沉底 */
const problemStudents = computed(() => {
const pid = focus.value
const list = students.value.map((student) => {
const items = student.byProblem.get(pid) ?? []
return { student, items, solved: items.some((item) => isAc(item.result)) }
})
const rank = (row: (typeof list)[number]) => (row.solved ? 2 : row.items.length ? 0 : 1)
return list
.filter((row) => !onlyUnfinished.value || !row.solved)
.sort(
(a, b) =>
rank(a) - rank(b) ||
b.items.length - a.items.length ||
a.student.name.localeCompare(b.student.name, "zh-CN"),
)
})
/** 全部题:没做完的在前(交得多的更前),一道没交的其次,全做完的沉底 */
const allStudents = computed(() => {
const n = problems.value.length
const list = students.value.map((student) => ({ student, done: doneCount(student) }))
const rank = (row: (typeof list)[number]) => (row.done === n ? 2 : row.student.total ? 0 : 1)
return list
.filter((row) => !onlyUnfinished.value || row.done < n)
.sort(
(a, b) =>
rank(a) - rank(b) ||
b.student.total - a.student.total ||
a.student.name.localeCompare(b.student.name, "zh-CN"),
)
})
const unfinishedCount = computed(() =>
focus.value
? students.value.filter((student) => !solvedOn(student, focus.value)).length
: students.value.filter((student) => doneCount(student) < problems.value.length).length,
)
const expanded = ref("")
watch([focus, onlyUnfinished], () => (expanded.value = ""))
function toggle(username: string) {
expanded.value = expanded.value === username ? "" : username
}
/** 展开一个人:没过的题在前、交得多的更前 */
function problemsOf(student: Student) {
return [...problems.value].sort((a, b) => {
const sa = solvedOn(student, a.problemDisplayId)
const sb = solvedOn(student, b.problemDisplayId)
if (sa !== sb) return sa ? 1 : -1
return (
(student.byProblem.get(b.problemDisplayId)?.length ?? 0) -
(student.byProblem.get(a.problemDisplayId)?.length ?? 0)
)
})
}
function rate(student: Student) {
return student.total ? `${Math.round((student.accepted / student.total) * 100)}%` : "—"
}
function dayText(time: string | null) {
return time ? parseTime(time, "M月D日 HH:mm") : "—"
}
function pct(done: number, total: number) {
return total > 0 ? `${Math.min(100, (done / total) * 100)}%` : "0%"
}
</script>
<template>
<div class="code-stats">
<!-- 左:题目 -->
<section class="problems">
<div class="col-head">题目(按你填的顺序)</div>
<div class="problem-list">
<button
v-if="problems.length > 1"
class="problem"
:class="{ on: focus === '' }"
@click="focus = ''"
>
<span class="p-line">
<b>全部 {{ problems.length }} 道</b>
<span class="spacer"></span>
<span class="muted">做完 {{ allDone }}/{{ personCount }} 人</span>
</span>
<span class="bar"
><span class="fill" :style="{ width: pct(allDone, personCount) }"></span
></span>
</button>
<button
v-for="row in problemRows"
:key="row.problemDisplayId"
class="problem"
:class="{ on: focus === row.problemDisplayId }"
@click="focus = row.problemDisplayId"
>
<span class="p-line">
<span class="muted">{{ row.problemDisplayId }}</span>
<b class="p-title">{{ row.title }}</b>
<span class="spacer"></span>
<span class="muted">做完 {{ row.done }}/{{ personCount }} · 平均 {{ row.avg }} 次</span>
</span>
<span class="bar"
><span class="fill" :style="{ width: pct(row.done, personCount) }"></span
></span>
</button>
<div v-if="!problems.length" class="empty">这段时间没有人交</div>
</div>
</section>
<!-- 右:学生 -->
<section class="students">
<div class="s-head">
<template v-if="focusRow">
<b class="s-title">{{ focusRow.problemDisplayId }} {{ focusRow.title }}</b>
<span class="muted">
做完 {{ focusRow.done }}/{{ personCount }} · 交了没对
{{ focusRow.tried - focusRow.done }} · 没交
{{ Math.max(0, personCount - focusRow.tried) }}
</span>
</template>
<template v-else>
<b class="s-title">全部 {{ problems.length }} 道</b>
<span class="muted">
做完 {{ allDone }}/{{ personCount }} · 没做完 {{ unfinishedCount }} · 一道没交
{{ stats.dataUnaccepted.length }}
</span>
</template>
<span class="spacer"></span>
<button class="chip" :class="{ on: onlyUnfinished }" @click="onlyUnfinished = true">
没做完 <span class="n">{{ unfinishedCount }}</span>
</button>
<button class="chip" :class="{ on: !onlyUnfinished }" @click="onlyUnfinished = false">
全部 <span class="n">{{ students.length }}</span>
</button>
</div>
<!-- 一道题:每人一行,后面是这道题上的每一次 -->
<template v-if="focusRow">
<div class="cols">
<span class="c-name">学生</span>
<span class="c-last">最后一次</span>
<span class="c-n">次数</span>
<span class="c-seq">每一次(从早到晚)</span>
<span class="c-when">最近一次在</span>
</div>
<div class="rows">
<div v-for="row in problemStudents" :key="row.student.username" class="row">
<span class="c-name name" :title="row.student.username">{{ row.student.name }}</span>
<span class="c-last">
<StatusPill v-if="row.items.length" :result="row.items.at(-1)!.result" />
<b v-else class="none" :style="{ color: tone('error').color }">没交</b>
</span>
<span class="c-n muted">{{ row.items.length || "—" }}</span>
<span class="c-seq"><AttemptSquares :items="row.items" /></span>
<span class="c-when muted">{{ dayText(row.items.at(-1)?.createTime ?? null) }}</span>
</div>
<div v-if="!problemStudents.length" class="empty">全都做完了</div>
</div>
</template>
<!-- 全部题:每人一行,一格一道题;点开按题看方块串 -->
<template v-else>
<div class="cols">
<span class="c-caret"></span>
<span class="c-name">学生</span>
<span class="c-done">做完</span>
<span class="c-n">提交</span>
<span class="c-rate">正确率</span>
<span class="c-grid">{{ problems.map((p) => p.problemDisplayId).join(" · ") }}</span>
</div>
<div class="rows">
<template v-for="row in allStudents" :key="row.student.username">
<div
class="row clickable"
:class="{ on: expanded === row.student.username }"
@click="toggle(row.student.username)"
>
<span class="c-caret">
<Icon
:icon="
expanded === row.student.username ? 'ph:caret-down-bold' : 'ph:caret-right-bold'
"
:width="12"
/>
</span>
<span class="c-name name" :title="row.student.username">{{ row.student.name }}</span>
<span
class="c-done done"
:style="{
color:
row.done === problems.length
? tone('success').color
: row.student.total
? theme.textColor1
: tone('error').color,
}"
>
{{ row.done }}/{{ problems.length }}
</span>
<span class="c-n muted">{{ row.student.total }}</span>
<span class="c-rate muted">{{ rate(row.student) }}</span>
<span class="c-grid cells">
<span
v-for="problem in problems"
:key="problem.problemDisplayId"
class="cell"
:title="problem.problemDisplayId + ' ' + problem.title"
:style="
solvedOn(row.student, problem.problemDisplayId)
? { background: tone('success').solid, color: '#fff' }
: row.student.byProblem.has(problem.problemDisplayId)
? { background: tone('error').background, color: tone('error').color }
: { background: theme.actionColor, color: theme.textColor3 }
"
>
{{
solvedOn(row.student, problem.problemDisplayId)
? "✓"
: (row.student.byProblem.get(problem.problemDisplayId)?.length ?? "·")
}}
</span>
</span>
</div>
<div v-if="expanded === row.student.username" class="detail">
<div
v-for="problem in problemsOf(row.student)"
:key="problem.problemDisplayId"
class="d-line"
>
<span class="d-title"
><span class="muted">{{ problem.problemDisplayId }}</span>
{{ problem.title }}</span
>
<span
class="d-state"
:style="{
color: solvedOn(row.student, problem.problemDisplayId)
? tone('success').color
: tone('error').color,
}"
>
{{ row.student.byProblem.get(problem.problemDisplayId)?.length ?? 0 }} 次 ·
{{
solvedOn(row.student, problem.problemDisplayId)
? "已通过"
: row.student.byProblem.has(problem.problemDisplayId)
? "还没过"
: "没交"
}}
</span>
<AttemptSquares
:items="row.student.byProblem.get(problem.problemDisplayId) ?? []"
/>
</div>
</div>
</template>
<div v-if="!allStudents.length" class="empty">全都做完了</div>
</div>
</template>
<div v-if="grid.truncated" class="truncated">
范围太大,方块串只画了最近 5000 条;上面的人数、正确率仍是全部算的。
</div>
</section>
</div>
</template>
<style scoped>
.code-stats {
flex: 1 1 0;
min-height: 0;
display: flex;
}
.problems {
width: 380px;
flex: none;
display: flex;
flex-direction: column;
border-right: 1px solid v-bind("theme.dividerColor");
min-height: 0;
}
.col-head {
height: 36px;
flex: none;
box-sizing: border-box;
padding: 0 16px 0 20px;
display: flex;
align-items: center;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.problem-list {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.problem {
width: 100%;
height: 50px;
box-sizing: border-box;
padding: 0 16px 0 20px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
border: 0;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.problem:hover {
background: v-bind("theme.hoverColor");
}
.problem.on {
background: v-bind("tone('success').background");
}
.p-line {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
min-width: 0;
}
.p-title {
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.bar {
height: 4px;
border-radius: 2px;
background: v-bind("theme.dividerColor");
overflow: hidden;
}
.fill {
display: block;
height: 4px;
border-radius: 2px;
background: v-bind("theme.successColor");
}
.muted {
color: v-bind("theme.textColor3");
font-size: 12px;
}
.spacer {
flex: 1 1 0;
}
.students {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.s-head {
height: 44px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
white-space: nowrap;
}
.s-title {
font-size: 15px;
}
.chip {
height: 28px;
padding: 0 12px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 14px;
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.chip.on {
border-color: v-bind("theme.primaryColor");
background: v-bind("tone('success').background");
color: v-bind("tone('success').color");
font-weight: 600;
}
.chip .n {
opacity: 0.75;
font-weight: 400;
}
.cols {
height: 32px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.rows {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.row {
min-height: 38px;
box-sizing: border-box;
padding: 6px 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.row.clickable {
cursor: pointer;
}
.row.clickable:hover {
background: v-bind("theme.hoverColor");
}
.row.on {
background: v-bind("tone('success').background");
}
.c-caret {
width: 16px;
flex: none;
display: flex;
color: v-bind("theme.textColor3");
}
.c-name {
width: 110px;
flex: none;
}
.c-last {
width: 120px;
flex: none;
}
.c-n {
width: 50px;
flex: none;
}
.c-seq {
flex: 1 1 0;
min-width: 0;
}
.c-when {
width: 100px;
flex: none;
}
.c-done {
width: 60px;
flex: none;
}
.c-rate {
width: 60px;
flex: none;
}
.c-grid {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.done {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.none {
font-size: 13px;
}
.cells {
display: flex;
gap: 4px;
flex-wrap: wrap;
white-space: normal;
}
.cell {
width: 30px;
height: 18px;
border-radius: 4px;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.detail {
padding: 6px 20px 8px 46px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.d-line {
min-height: 28px;
display: flex;
align-items: center;
gap: 12px;
}
.d-title {
width: 190px;
flex: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.d-state {
width: 100px;
flex: none;
font-size: 12px;
}
.empty {
padding: 40px 16px;
text-align: center;
color: v-bind("theme.textColor3");
}
.truncated {
padding: 8px 20px;
font-size: 12px;
color: v-bind("tone('warning').color");
border-top: 1px solid v-bind("theme.dividerColor");
}
</style>