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,163 @@
<script lang="ts">
import type { Submission } from "utils/types"
/**
* 代码按提交 id 缓存,整页共用:交了就不会再变,老师在一排方块之间来回对比时
* 不用每次都等网络。失败的不进缓存,下次停上去再试。
*/
const codeCache = new Map<string, Submission>()
</script>
<script setup lang="ts">
import { getSubmission } from "oj/api"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
import { parseTime } from "utils/functions"
import type { SUBMISSION_RESULT } from "utils/types"
import { useTone, type Tone } from "oj/submission/composables/tone"
/**
* 一个人在一道题上的每一次提交,一格一次、从早到晚(设计稿「统计重设计」)。
* 鼠标停在一格上看那一次的代码,点一下新页面打开那条提交。
*/
const props = withDefaults(
defineProps<{
items: Array<{ id: string; result: SUBMISSION_RESULT; createTime: string }>
size?: number
}>(),
{ size: 14 },
)
const tone = useTone()
function toneOf(result: number): Tone {
if (result === SubmissionStatus.accepted) return "success"
if (
result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error ||
result === SubmissionStatus.ast_check_failed ||
result === SubmissionStatus.partial_accepted
) {
return "warning"
}
if (result === SubmissionStatus.pending || result === SubmissionStatus.judging) return "info"
return "error"
}
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
async function load(id: string) {
const hit = codeCache.get(id)
if (hit) {
code[id] = hit
return
}
if (code[id] === "loading") return
code[id] = "loading"
try {
const res = await getSubmission(id)
codeCache.set(id, res)
code[id] = res
} catch {
code[id] = "failed"
}
}
function open(id: string) {
window.open("/submission/" + id, "_blank", "noopener")
}
const cells = computed(() =>
props.items.map((item, index) => ({
...item,
index: index + 1,
color: tone(toneOf(item.result)).solid,
})),
)
</script>
<template>
<span class="squares">
<n-popover
v-for="cell in cells"
:key="cell.id"
:delay="200"
:style="{ maxWidth: '560px' }"
@update:show="(show: boolean) => show && load(cell.id)"
>
<template #trigger>
<button
class="square"
:style="{ width: `${size}px`, height: `${size}px`, background: cell.color }"
:aria-label="`第 ${cell.index} 次:${JUDGE_STATUS[cell.result]?.name ?? ''}`"
@click.stop="open(cell.id)"
></button>
</template>
<div class="pop">
<div class="pop-head">
<b>{{ JUDGE_STATUS[cell.result]?.name }}</b>
<span class="muted"
>{{ parseTime(cell.createTime, "M月D日 HH:mm") }} · 第 {{ cell.index }} 次</span
>
</div>
<n-code
v-if="typeof code[cell.id] === 'object'"
class="pop-code"
:code="(code[cell.id] as Submission).code"
:language="LANGUAGE_FORMAT_VALUE[(code[cell.id] as Submission).language]"
show-line-numbers
/>
<span v-else-if="code[cell.id] === 'failed'" class="muted">代码没拉下来,点方块打开看</span>
<span v-else class="muted">代码加载中…</span>
<span class="muted small">点方块在新页面打开这一条</span>
</div>
</n-popover>
</span>
</template>
<style scoped>
.squares {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.square {
flex: none;
padding: 0;
border: 0;
border-radius: 3px;
cursor: pointer;
}
.square:hover {
outline: 2px solid currentColor;
outline-offset: 1px;
}
.pop {
display: flex;
flex-direction: column;
gap: 8px;
}
.pop-head {
display: flex;
align-items: center;
gap: 10px;
}
.pop-code {
max-height: 360px;
overflow: auto;
font-size: 14px;
}
.muted {
opacity: 0.65;
}
.small {
font-size: 12px;
}
</style>