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

+12
View File
@@ -40,6 +40,7 @@ import {
type FlowchartStatistics,
type SubmissionStatistics,
type SubmissionStatisticsItems,
type SubmissionStatisticsGrid,
type TodaySubmissionStatistics,
} from "@oj2/contract"
import api from "utils/api"
@@ -197,6 +198,17 @@ export function getSubmissionStatistics(
})
}
/** 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema) */
export function getSubmissionStatisticsGrid(
duration: { start?: string; end: string },
problemDisplayId?: string,
username?: string,
) {
return api.get<SubmissionStatisticsGrid>("submissions/statistics/grid", {
params: { ...duration, problemDisplayId, username },
})
}
/**
* 全服榜单。上限(100 名)由服务端定,调用方只管翻页 ——
* 「全服 Top10」就是这个榜的第一页,取 limit=10 即可,不需要另一个上限参数。
+66 -19
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { getClassBoard, setClassLesson } from "oj/api"
import { useHiddenStudents } from "shared/composables/hiddenStudents"
import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "shared/store/config"
import { errorMessage } from "utils/api"
@@ -14,7 +15,7 @@ import type { ClassBoard, ClassBoardCell, ClassBoardStudent } from "utils/types"
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
*
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
* 悄悄留在框里,老师看的整个是别人的班。(提交统计弹窗是记一小时的,见 composables/statisticsQuery.ts,
* 悄悄留在框里,老师看的整个是别人的班。(原来的提交统计弹窗是记一小时的,
* 那边是老师自己手选的查询;这里后端本来就猜得出,没必要冒这个险。)
*/
@@ -137,14 +138,27 @@ function nameOf(student: ClassBoardStudent) {
return student.realName || student.username
}
/**
* 请假隐藏(原来在「数据统计」弹框里,统计改成回头看之后挪到这儿 —— 盯这节课的是看板)。
* 请假、转班、学号错了的那几个一直挂在「还没交过」里,会盖住真正要去看的人。
* 藏两个小时(够一节课),下节课自动回来;只记在这台电脑上,换人换机器都不继承。
* 键沿用统计弹框那份,老师上午藏的人这里接着藏着
*/
const { hideMode, hideStudent, showAll, isHidden } = useHiddenStudents("oj_hidden_students")
const hiddenStudents = computed(() =>
(board.value?.students ?? []).filter((student) => isHidden(student.username)),
)
const students = computed(() =>
[...(board.value?.students ?? [])].sort(
(a, b) =>
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
// 同样是没交过,经常不动手的排前面
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
),
[...(board.value?.students ?? [])]
.filter((student) => !isHidden(student.username))
.sort(
(a, b) =>
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
// 同样是没交过,经常不动手的排前面
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
),
)
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
@@ -164,7 +178,8 @@ const stuckCount = computed(
)
const summary = computed(() => {
const all = board.value?.students ?? []
// 分母也减掉请假的,不然「做完 30/38」永远到不了头
const all = students.value
return (board.value?.problems ?? []).map((problem, i) => ({
...problem,
done: all.filter((student) => student.cells[i]?.status === "accepted").length,
@@ -311,20 +326,48 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
<template v-if="!projector">
<n-alert
v-if="idle.length"
v-if="idle.length || hiddenStudents.length"
type="warning"
:title="`今天还没交过(${idle.length} 人)`"
class="section"
>
<div v-if="idleOften.length">
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{
idleOften.map(nameOf).join("、")
}}
</div>
<div v-if="idleOthers.length">
<template v-if="idleOften.length"><b>其他</b>:</template
>{{ idleOthers.map(nameOf).join("、") }}
</div>
<n-flex align="center" :size="10" class="hide-bar">
<n-switch v-model:value="hideMode" size="small">
<template #checked>请假隐藏中 · 点名字旁的 × 藏起来</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<n-button
v-if="hiddenStudents.length"
size="tiny"
text
type="primary"
@click="showAll"
>
隐藏了 {{ hiddenStudents.length }} 位:{{ hiddenStudents.map(nameOf).join("、") }} ·
恢复
</n-button>
</n-flex>
<n-flex v-if="hideMode" :size="8">
<n-tag
v-for="student in idle"
:key="student.userId"
closable
@close="hideStudent(student.username)"
>
{{ nameOf(student) }}
</n-tag>
</n-flex>
<template v-else>
<div v-if="idleOften.length">
<b>经常没交</b>(最近 {{ board.recentLessons }} 节课最多交过 1 节):{{
idleOften.map(nameOf).join("、")
}}
</div>
<div v-if="idleOthers.length">
<template v-if="idleOften.length"><b>其他</b>:</template
>{{ idleOthers.map(nameOf).join("、") }}
</div>
</template>
</n-alert>
<n-text v-if="stuckCount" type="error" class="meta">
@@ -420,6 +463,10 @@ function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string)
text-decoration: underline;
}
.hide-bar {
margin-bottom: 6px;
}
.board {
max-width: 1400px;
margin: 0 auto;
@@ -13,7 +13,7 @@ import { Icon } from "@iconify/vue"
import { NFlex, useThemeVars } from "naive-ui"
import SubmitCode from "./SubmitCode.vue"
import { useProblemPageContext } from "../composables/problemPageContext"
import { statisticsOpen, useEditorMenu } from "../composables/editorMenu"
import { openStatistics, useEditorMenu } from "../composables/editorMenu"
import { useTeacherCollab } from "../composables/teacherCollab"
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
@@ -228,7 +228,7 @@ const changeLanguage = (v: LANGUAGE) => {
<n-button
v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove"
:size="buttonSize"
@click="statisticsOpen = true"
@click="openStatistics(problem!._id)"
>
课堂统计
</n-button>
@@ -12,10 +12,12 @@ import { useTeacherCollab } from "./teacherCollab"
import { useProblemPageContext } from "./problemPageContext"
/**
* 「课堂统计」弹窗开着没有。弹窗挂在题目页上(StatisticsModal),菜单可能在工具栏里、
* 也可能在手机页签行的「⋯」里,所以这个开关放在模块级,大家共用一个
* 「课堂统计」:新标签打开数据统计页,带上这道题(原来是题目页上的弹框)。
* 工具栏按钮和手机页签行的「⋯」都走这一个。新标签是因为老师多半正开着这道题在看
*/
export const statisticsOpen = ref(false)
export function openStatistics(problemDisplayId: string) {
window.open(`/statistics?problem=${encodeURIComponent(problemDisplayId)}`, "_blank")
}
/**
* 编辑器的「更多」菜单:去自测猫 / 复制代码 / 重置代码 / 编辑题目,放不下时再加上课堂统计。
@@ -111,7 +113,7 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
function select(key: string) {
switch (key) {
case "statistics":
statisticsOpen.value = true
openStatistics(problem.value!._id)
return true
case "testcat":
goTestCat()
-2
View File
@@ -15,7 +15,6 @@ import type { RouteLocationNormalized } from "vue-router"
import SubmissionEffects from "./components/SubmissionEffects.vue"
import ResultTabLabel from "./components/ResultTabLabel.vue"
import ContextBar from "./components/ContextBar.vue"
import StatisticsModal from "./components/StatisticsModal.vue"
import { useEditorMenu } from "./composables/editorMenu"
import {
DRAWER_TITLE,
@@ -255,7 +254,6 @@ onBeforeUnmount(() => {
<template>
<template v-if="problem">
<SubmissionEffects />
<StatisticsModal />
<!--
桌面:两栏顶满整个内容区(设计稿「机房(方案 B)」),中间一条 1px 分隔线、可拖动。
布局给内容区留了 16px 内边距,这里用负边距抵掉
@@ -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>
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import type { SubmissionStatisticsGrid } from "@oj2/contract"
import { SubmissionStatus } from "utils/constants"
import { useTone } from "oj/submission/composables/tone"
import { classLabel, submissionDayText } from "oj/submission/utils"
/**
* 没选班级、也没填学生时(多半是从题目页进来,只带了题号):先按班级列出来,
* 点一个班再看人。「一道没交」要花名册人数,后端在这种情况下给 classSizes。
*/
const props = defineProps<{ grid: SubmissionStatisticsGrid }>()
const emit = defineEmits<{ pick: [className: string] }>()
const theme = useThemeVars()
const tone = useTone()
function isAc(result: number) {
return result === SubmissionStatus.accepted || result === SubmissionStatus.ast_check_failed
}
const classes = computed(() => {
const need = props.grid.problems.map((problem) => problem.problemDisplayId)
const groups = new Map<string, SubmissionStatisticsGrid["rows"]>()
for (const row of props.grid.rows) {
const key = row.className ?? ""
groups.set(key, [...(groups.get(key) ?? []), row])
}
return [...groups.entries()]
.map(([className, rows]) => {
let submissions = 0
let accepted = 0
let last = ""
let done = 0
for (const row of rows) {
const solved = new Set(
row.submissions.filter((item) => isAc(item.result)).map((item) => item.problemDisplayId),
)
if (need.every((pid) => solved.has(pid))) done++
submissions += row.submissions.length
accepted += row.submissions.filter((item) => isAc(item.result)).length
const lastTime = row.submissions.at(-1)?.createTime ?? ""
if (lastTime > last) last = lastTime
}
const size = props.grid.classSizes[className] ?? rows.length
return {
className,
label: className ? classLabel(className) : "没有班级",
size,
done,
tried: rows.length - done,
none: Math.max(0, size - rows.length),
rate: submissions ? `${Math.round((accepted / submissions) * 100)}%` : "—",
last,
}
})
.sort((a, b) => b.last.localeCompare(a.last))
})
</script>
<template>
<div class="by-class">
<div class="cols">
<span class="c-class">班级</span>
<span class="c-done">做完</span>
<span class="c-n">交了没对</span>
<span class="c-n">没交</span>
<span class="c-n">正确率</span>
<span class="c-last">最近一次</span>
<span class="spacer"></span>
</div>
<div class="rows">
<div v-for="row in classes" :key="row.className" class="row">
<b class="c-class">{{ row.label }}</b>
<span class="c-done">
<span class="bar">
<span
class="fill"
:style="{ width: `${row.size ? (row.done / row.size) * 100 : 0}%` }"
></span>
</span>
<span class="num">{{ row.done }}/{{ row.size }}</span>
</span>
<span class="c-n" :style="{ color: tone('warning').color }">{{ row.tried }}</span>
<span class="c-n" :style="{ color: tone('error').color }">{{ row.none }}</span>
<span class="c-n muted">{{ row.rate }}</span>
<span class="c-last muted">{{ row.last ? submissionDayText(row.last) : "—" }}</span>
<span class="spacer"></span>
<n-button
v-if="row.className"
size="small"
text
type="primary"
@click="emit('pick', row.className)"
>
看这个班 →
</n-button>
</div>
<div v-if="!classes.length" class="empty">这段时间没有人交</div>
<p class="note">没选班级,先按班级列出来;点一个班再看每个人。「做完」是这几道题都做对了。</p>
</div>
</div>
</template>
<style scoped>
.by-class {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.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 {
height: 44px;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.c-class {
width: 140px;
flex: none;
}
.c-done {
width: 240px;
flex: none;
display: flex;
align-items: center;
gap: 10px;
}
.c-n {
width: 90px;
flex: none;
}
.c-last {
width: 110px;
flex: none;
}
.bar {
width: 150px;
height: 6px;
border-radius: 3px;
background: v-bind("theme.dividerColor");
overflow: hidden;
}
.fill {
display: block;
height: 6px;
background: v-bind("theme.successColor");
}
.num {
font-variant-numeric: tabular-nums;
}
.muted {
color: v-bind("theme.textColor3");
}
.spacer {
flex: 1 1 0;
}
.empty {
padding: 40px 16px;
text-align: center;
color: v-bind("theme.textColor3");
}
.note {
margin: 0;
padding: 10px 20px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
</style>
@@ -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>
@@ -0,0 +1,450 @@
<script setup lang="ts">
import {
ArcElement,
BarElement,
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
RadialLinearScale,
Tooltip,
} from "chart.js"
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
import { useThemeVars } from "naive-ui"
import { Bar, Doughnut, Radar } from "vue-chartjs"
import type { FlowchartStatistics } from "@oj2/contract"
import { useChartTheme } from "shared/composables/chartTheme"
import { FLOWCHART_CRITERIA_ORDER } from "utils/constants"
import { useTone } from "oj/submission/composables/tone"
import RingChart from "./RingChart.vue"
import StatItem from "./StatItem.vue"
ChartJS.register(
ArcElement,
BarElement,
CategoryScale,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
RadialLinearScale,
Tooltip,
WordCloudController,
WordElement,
)
/**
* 流程图统计(设计稿「统计重设计 · 流程图」):等级分布、四项评分、各项平均分、
* 高频问题词云、每人最好的一次。图表都是 chart.js,和站里别的图一套主题。
*/
const props = defineProps<{ data: FlowchartStatistics }>()
const theme = useThemeVars()
const tone = useTone()
const { chartKey } = useChartTheme()
const GRADES = ["S", "A", "B", "C"] as const
const gradeColor = computed(() => ({
S: tone("success").solid,
A: tone("info").solid,
B: tone("warning").solid,
C: tone("error").solid,
}))
/** 拿到 A / S 算完成(和题单、课堂条的口径一致) */
const goodCount = computed(
() => props.data.people.filter((row) => row.bestGrade === "S" || row.bestGrade === "A").length,
)
const personCount = computed(() => props.data.personCount || props.data.people.length)
const gradeData = computed(() => ({
labels: GRADES.map((grade) => `${grade} 级`),
datasets: [
{
data: GRADES.map((grade) => props.data.gradeDistribution[grade] ?? 0),
backgroundColor: GRADES.map((grade) => gradeColor.value[grade]),
borderWidth: 0,
},
],
}))
const gradeOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: "60%",
plugins: { legend: { position: "right" as const } },
}
/** 四项按固定顺序(jsonb 不保留键序,逻辑正确性要在最前) */
const criteria = computed(() =>
FLOWCHART_CRITERIA_ORDER.filter((key) => props.data.criteriaAverages[key]).map((key) => ({
key,
...props.data.criteriaAverages[key]!,
})),
)
const radarData = computed(() => ({
labels: criteria.value.map((item) => item.key),
datasets: [
{
label: "平均得分率",
data: criteria.value.map((item) => Math.round((item.avg / (item.max || 1)) * 100)),
backgroundColor: "rgba(32, 128, 240, 0.18)",
borderColor: tone("info").solid,
pointBackgroundColor: tone("info").solid,
fill: true,
},
],
}))
const radarOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: { label: (context: { parsed: { r: number } }) => `${context.parsed.r}%` },
},
},
scales: { r: { min: 0, max: 100, ticks: { stepSize: 25, display: false } } },
}
const barData = computed(() => ({
labels: criteria.value.map((item) => item.key),
datasets: [
{
label: "平均分",
data: criteria.value.map((item) => item.avg),
backgroundColor: tone("info").solid,
borderRadius: 4,
},
],
}))
const barOptions = computed(() => ({
indexAxis: "y" as const,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: { dataIndex: number; parsed: { x: number | null } }) =>
`${context.parsed.x ?? 0} / ${criteria.value[context.dataIndex]?.max ?? 100}`,
},
},
},
scales: { x: { min: 0, max: Math.max(...criteria.value.map((item) => item.max), 10) } },
}))
// ---------- 词云(chart.js 的 wordCloud 插件,canvas 上画,要自己管实例) ----------
const WORD_COLORS = ["#2080f0", "#18a058", "#d03050", "#f0a020", "#7a3e9d", "#13c2c2"]
const cloudCanvas = useTemplateRef<HTMLCanvasElement>("cloudCanvas")
let cloud: ChartJS | null = null
function drawCloud() {
cloud?.destroy()
cloud = null
const words = props.data.wordFrequencies
if (!cloudCanvas.value || !words.length) return
const max = Math.max(...words.map((word) => word.count))
cloud = new ChartJS(cloudCanvas.value, {
type: "wordCloud" as never,
data: {
labels: words.map((word) => word.word),
datasets: [
{
label: "",
data: words.map((word) => 12 + (word.count / max) * 42),
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
rotate: 0,
} as never,
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: { dataIndex: number }) => {
const word = words[context.dataIndex]
return word ? `${word.word}:${word.count} 次` : ""
},
},
},
},
} as never,
})
}
watch(
() => [props.data.wordFrequencies, chartKey.value],
() => nextTick(drawCloud),
{ immediate: true },
)
onUnmounted(() => cloud?.destroy())
// ---------- 每人最好的一次:没交的在前,其次按分数从低到高 ----------
const people = computed(() => [
...props.data.dataUnaccepted.map((row) => ({
username: row.username,
name: row.realName,
grade: null as string | null,
score: null as number | null,
count: 0,
})),
...props.data.people.map((row) => ({
username: row.username,
name: row.realName,
grade: row.bestGrade,
score: row.bestScore,
count: row.count,
})),
])
function gradeStyle(grade: string | null) {
const map: Record<string, ReturnType<typeof tone>> = {
S: tone("success"),
A: tone("info"),
B: tone("warning"),
C: tone("error"),
}
const t = grade ? map[grade] : null
return t
? { color: t.color, background: t.background }
: { color: theme.value.textColor3, background: theme.value.actionColor }
}
</script>
<template>
<div class="flow-stats">
<div class="summary">
<StatItem label="提交" :value="data.totalCount" unit="张" />
<StatItem label="平均分" :value="data.avgScore" />
<StatItem
label="拿到 A / S"
:value="`${goodCount} / ${personCount}`"
unit="人"
tone="success"
/>
<StatItem
label="一张没交"
:value="data.dataUnaccepted.length"
unit="人"
tone="error"
:hidden="!data.personCount"
/>
<span class="spacer"></span>
<!-- 没选班级就没有花名册,「班级完成度」无从谈起 -->
<span v-if="data.personCount" class="ring-wrap">
<RingChart
:value="goodCount"
:total="personCount"
:color="tone('info').solid"
:labels="['拿到 A / S', '还没有']"
/>
<span class="ring-label">班级<br />完成度</span>
</span>
</div>
<div v-if="!data.totalCount && !data.dataUnaccepted.length" class="empty">
这段时间没有人交流程图
</div>
<div v-else class="body">
<div class="row-cards">
<div class="card grade">
<div class="card-title">等级分布</div>
<div class="chart">
<Doughnut :key="chartKey" :data="gradeData" :options="gradeOptions" />
</div>
</div>
<div v-if="criteria.length" class="card radar">
<div class="card-title">四项评分(平均得分率)</div>
<div class="chart">
<Radar :key="chartKey" :data="radarData" :options="radarOptions" />
</div>
</div>
<div v-if="criteria.length" class="card grow">
<div class="card-title">各项平均分</div>
<div class="chart"><Bar :key="chartKey" :data="barData" :options="barOptions" /></div>
</div>
</div>
<div class="row-cards bottom">
<div class="card grow">
<div class="card-title">常见问题高频词(最近的评语里)</div>
<div class="cloud"><canvas ref="cloudCanvas"></canvas></div>
<div v-if="!data.wordFrequencies.length" class="muted">评语太少,还拼不出词云</div>
</div>
<div class="card people">
<div class="card-title">每人最好的一次(没交的在前)</div>
<div class="people-list">
<div v-for="row in people" :key="row.username" class="person">
<b class="p-name" :title="row.username">{{ row.name }}</b>
<span class="grade-pill" :style="gradeStyle(row.grade)">
{{ row.grade ? `${row.grade} 级 ${row.score ?? 0} 分` : "没交" }}
</span>
<span class="muted">{{ row.count }} 次</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.flow-stats {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.summary {
height: 72px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 36px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.spacer {
flex: 1 1 0;
}
.ring-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.ring-label {
font-size: 12px;
line-height: 1.5;
color: v-bind("theme.textColor3");
}
.body {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
box-sizing: border-box;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.row-cards {
display: flex;
gap: 14px;
height: 220px;
flex: none;
}
.row-cards.bottom {
height: 300px;
}
.card {
box-sizing: border-box;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 8px;
background: v-bind("theme.cardColor");
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.card.grade {
width: 340px;
flex: none;
}
.card.radar {
width: 300px;
flex: none;
}
.card.grow {
flex: 1 1 0;
}
.card.people {
width: 420px;
flex: none;
}
.card-title {
font-size: 13px;
font-weight: 600;
color: v-bind("theme.textColor2");
}
.chart,
.cloud {
flex: 1 1 0;
min-height: 0;
position: relative;
}
.people-list {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
}
.person {
height: 34px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
font-size: 13px;
}
.p-name {
width: 80px;
flex: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grade-pill {
width: 100px;
flex: none;
height: 22px;
border-radius: 11px;
font-size: 12px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.muted {
color: v-bind("theme.textColor3");
font-size: 12px;
}
.empty {
padding: 60px 16px;
text-align: center;
color: v-bind("theme.textColor3");
}
</style>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { ArcElement, Chart as ChartJS, Tooltip } from "chart.js"
import { Doughnut } from "vue-chartjs"
import { useChartTheme } from "shared/composables/chartTheme"
ChartJS.register(ArcElement, Tooltip)
/**
* 数字行右边的小圆环(提交正确率、班级完成度)。中间写百分比,
* 鼠标停上去看两段各是多少 —— 这是 chart.js 现成的提示。
*/
const props = defineProps<{
value: number
total: number
color: string
/** 两段的名字,提示里用:["正确", "没对"] */
labels: [string, string]
size?: number
}>()
const { chartKey, gridColor } = useChartTheme()
const pct = computed(() => (props.total > 0 ? Math.round((props.value / props.total) * 100) : 0))
const data = computed(() => ({
labels: props.labels,
datasets: [
{
data: [props.value, Math.max(0, props.total - props.value)],
backgroundColor: [props.color, gridColor.value],
borderWidth: 0,
},
],
}))
const options = {
responsive: true,
maintainAspectRatio: false,
cutout: "72%",
animation: false as const,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context: { label: string; parsed: number }) =>
`${context.label}:${context.parsed}`,
},
},
},
}
</script>
<template>
<span class="ring" :style="{ width: `${size ?? 48}px`, height: `${size ?? 48}px` }">
<Doughnut :key="chartKey" :data="data" :options="options" />
<span class="center">{{ total > 0 ? `${pct}%` : "—" }}</span>
</span>
</template>
<style scoped>
.ring {
position: relative;
display: inline-block;
flex: none;
}
.center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
pointer-events: none;
}
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useTone, type Tone } from "oj/submission/composables/tone"
/** 数字行的一格:小字标签 + 大号数字 + 单位 */
const props = defineProps<{
label: string
value: string | number
unit?: string
tone?: Tone
/** 算不出来的(没选班级就没有花名册)整格不显示,免得写个 0 让人误会 */
hidden?: boolean
}>()
const theme = useThemeVars()
const toneOf = useTone()
const color = computed(() => (props.tone ? toneOf(props.tone).color : theme.value.textColor1))
</script>
<template>
<div v-if="!hidden" class="stat">
<span class="label">{{ label }}</span>
<span class="value-line">
<b class="value" :style="{ color }">{{ value }}</b>
<span v-if="unit" class="unit">{{ unit }}</span>
</span>
</div>
</template>
<style scoped>
.stat {
display: flex;
flex-direction: column;
gap: 2px;
flex: none;
}
.label,
.unit {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.value-line {
display: flex;
align-items: baseline;
gap: 5px;
}
.value {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
</style>
+397
View File
@@ -0,0 +1,397 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { useRouteQuery } from "@vueuse/router"
import type {
FlowchartStatistics,
SubmissionStatistics,
SubmissionStatisticsGrid,
} from "@oj2/contract"
import {
getFlowchartStatistics,
getSubmissionStatistics,
getSubmissionStatisticsGrid,
} from "oj/api"
import { useConfigStore } from "shared/store/config"
import { fromPickerValue, toPickerValue } from "utils/functions"
import { useTone } from "oj/submission/composables/tone"
import { classLabel } from "oj/submission/utils"
import ByClass from "./components/ByClass.vue"
import CodeStats from "./components/CodeStats.vue"
import RingChart from "./components/RingChart.vue"
import StatItem from "./components/StatItem.vue"
import { DEFAULT_PERIOD, PERIOD_OPTIONS, periodRange, type Period } from "./period"
/**
* 数据统计(设计稿「统计重设计 · 定稿 B」)。从提交列表、题目页的弹框改成独立页面。
*
* 分工:**课堂看板管这节课,这里管回头看**(用户定的)。所以这页不自动刷新、没有
* 投影用的大字名单和请假隐藏 —— 那些在看板上。课堂统计一般看一两节课,时间段默认「两节课」。
*
* 查询条件都在地址栏里:从提交列表、题目页带过来,刷新、转发给别的老师都还是这一份。
*/
const FlowStats = defineAsyncComponent(() => import("./components/FlowStats.vue"))
const router = useRouter()
const configStore = useConfigStore()
const theme = useThemeVars()
const tone = useTone()
const message = useMessage()
const tab = useRouteQuery<string>("tab", "code", { mode: "replace" })
const className = useRouteQuery<string>("className", "", { mode: "replace" })
const username = useRouteQuery<string>("username", "", { mode: "replace" })
const problem = useRouteQuery<string>("problem", "", { mode: "replace" })
const period = useRouteQuery<string>("period", DEFAULT_PERIOD, { mode: "replace" })
const from = useRouteQuery<string>("from", "", { mode: "replace" })
const to = useRouteQuery<string>("to", "", { mode: "replace" })
const periodValue = computed<Period>(() =>
PERIOD_OPTIONS.some((option) => option.value === period.value)
? (period.value as Period)
: DEFAULT_PERIOD,
)
const custom = computed<[number, number] | null>(() =>
from.value && to.value ? [Number(from.value), Number(to.value)] : null,
)
/** n-date-picker 按浏览器时区渲染,存取都要平移到东八区(见 utils/functions 的注释) */
const pickerValue = computed<[number, number] | null>(() =>
custom.value ? [toPickerValue(custom.value[0]), toPickerValue(custom.value[1])] : null,
)
function pickDates(value: [number, number] | null) {
if (!value) {
from.value = ""
to.value = ""
return
}
from.value = String(fromPickerValue(value[0]))
to.value = String(fromPickerValue(value[1]))
}
/**
* 统计接口的「用户名」是 ilike:填 `ks253` 圈一个班。选了班级又填了学生时按学生查,
* 学生名字不带班级前缀的话补上,免得别的班重名的混进来
*/
const usernameParam = computed(() => {
const name = username.value.trim()
if (name) {
return className.value && !name.toLowerCase().startsWith("ks")
? `ks${className.value}${name}`
: name
}
return className.value ? `ks${className.value}` : ""
})
/** 没选班级、没填学生:先按班级汇总(多半是从题目页进来的) */
const byClass = computed(() => !usernameParam.value)
const classOptions = computed(() => {
const list = configStore.config?.classList ?? []
const all = className.value && !list.includes(className.value) ? [className.value, ...list] : list
return all.map((item) => ({ label: classLabel(item), value: item }))
})
// ---------- 取数 ----------
const loading = ref(false)
const failed = ref(false)
const stats = ref<SubmissionStatistics | null>(null)
const grid = ref<SubmissionStatisticsGrid | null>(null)
const flow = ref<FlowchartStatistics | null>(null)
const rangeText = ref("")
const withinToday = ref(false)
let seq = 0
async function load() {
const current = ++seq
const range = periodRange(periodValue.value, custom.value)
rangeText.value = range.text
withinToday.value = range.withinToday
const duration = { start: range.start, end: range.end }
loading.value = true
failed.value = false
try {
if (tab.value === "flow") {
const res = await getFlowchartStatistics(duration, problem.value, usernameParam.value)
if (current !== seq) return
flow.value = res
} else {
const [s, g] = await Promise.all([
getSubmissionStatistics(duration, problem.value, usernameParam.value),
getSubmissionStatisticsGrid(duration, problem.value, usernameParam.value),
])
if (current !== seq) return
stats.value = s
grid.value = g
}
} catch (error) {
if (current !== seq) return
failed.value = true
// 题号打错是最常见的:后端 404 problem-not-found
const code = (error as { error?: string })?.error
if (code === "problem-not-found") message.warning("有题号找不到,检查一下是不是打错了")
} finally {
if (current === seq) loading.value = false
}
}
onMounted(load)
watch([tab, className, periodValue, custom], load)
watchDebounced([username, problem], load, { debounce: 600, maxWait: 1500 })
// ---------- 数字行(代码) ----------
const doneCount = computed(() => stats.value?.data.filter((row) => row.done).length ?? 0)
/**
* 没填题号时后端的「做完」是「至少做对一道」—— 一个月里零零散散几十道题,谈不上
* 「全做完」。这时这一格叫「做对过题」,别和左边「全部 N 道 · 做完 0 人」打架
*/
const hasProblems = computed(() => !!problem.value.trim())
const personCount = computed(() => stats.value?.personCount ?? 0)
const judged = computed(() =>
stats.value ? stats.value.submissionCount - stats.value.judgingCount : 0,
)
// ---------- 两个入口 ----------
/** 1366 以下(机房 1280 的屏)右上两个入口只留图标 */
const narrow = useMediaQuery("(max-width: 1365px)")
function openList() {
const href = router.resolve({
name: "submissions",
query: {
...(tab.value === "flow" ? { language: "Flowchart" } : {}),
...(className.value && !username.value ? { className: className.value } : {}),
...(username.value ? { username: usernameParam.value } : {}),
...(problem.value ? { problem: problem.value } : {}),
...(withinToday.value ? { today: "1" } : {}),
},
}).href
window.open(href, "_blank")
}
function pickClass(value: string) {
className.value = value
username.value = ""
}
</script>
<template>
<div class="page">
<div class="filters">
<span class="title">数据统计</span>
<n-radio-group v-model:value="tab" size="small">
<n-radio-button value="code">代码</n-radio-button>
<n-radio-button value="flow">流程图</n-radio-button>
</n-radio-group>
<span class="vsep"></span>
<n-select
:value="className || null"
class="w-class"
size="small"
:options="classOptions"
placeholder="全部班级"
clearable
filterable
@update:value="(v: string | null) => (className = v ?? '')"
/>
<n-input v-model:value="username" class="w-user" size="small" clearable placeholder="学生">
<template #prefix><Icon icon="ph:magnifying-glass" /></template>
</n-input>
<n-input
v-model:value="problem"
class="w-problem"
size="small"
clearable
placeholder="题号,几道用空格隔开"
>
<template #prefix><Icon icon="ph:hash" /></template>
</n-input>
<n-select
:value="periodValue"
class="w-period"
size="small"
:options="PERIOD_OPTIONS.map((o) => ({ label: o.label, value: o.value }))"
:consistent-menu-width="false"
@update:value="(v: string) => (period = v)"
/>
<n-date-picker
v-if="periodValue === 'custom'"
:value="pickerValue"
type="daterange"
size="small"
class="w-dates"
clearable
@update:value="pickDates"
/>
<span v-else class="range">{{ rangeText }}</span>
<n-spin v-if="loading" :size="14" />
<n-text v-if="failed" type="error" class="range">没拉下来,改一下条件再试</n-text>
<span class="spacer"></span>
<n-button
size="small"
quaternary
title="这节课去课堂看板"
aria-label="这节课去课堂看板"
@click="router.push('/classroom')"
>
<template #icon><Icon icon="ph:monitor" /></template>
<template v-if="!narrow">这节课去课堂看板</template>
</n-button>
<n-button size="small" title="在提交列表里看" aria-label="在提交列表里看" @click="openList">
<template #icon><Icon icon="ph:arrow-square-out" /></template>
<template v-if="!narrow">在提交列表里看</template>
</n-button>
</div>
<template v-if="tab === 'flow'">
<FlowStats v-if="flow" :data="flow" />
</template>
<template v-else-if="stats && grid">
<div class="summary">
<StatItem label="提交" :value="stats.submissionCount" unit="条" />
<StatItem
label="正确率"
:value="`${Math.round(stats.correctRate)}%`"
:unit="stats.judgingCount ? `判题中 ${stats.judgingCount}` : ''"
/>
<template v-if="personCount">
<StatItem
:label="hasProblems ? '做完' : '做对过题'"
:value="`${doneCount} / ${personCount}`"
unit="人"
tone="success"
/>
<StatItem
v-if="hasProblems"
label="完成度"
:value="`${personCount ? Math.round((doneCount / personCount) * 100) : 0}%`"
/>
<StatItem label="一道没交" :value="stats.dataUnaccepted.length" unit="人" tone="error" />
</template>
<StatItem
:label="hasProblems ? '交了没全对' : '一道没做对'"
:value="stats.dataAttempted.length"
unit="人"
tone="warning"
/>
<span class="spacer"></span>
<span class="ring-wrap">
<RingChart
:value="stats.acceptedCount"
:total="judged"
:color="tone('success').solid"
:labels="['正确', '没对']"
/>
<span class="ring-label">提交<br />正确率</span>
</span>
<span v-if="personCount && hasProblems" class="ring-wrap">
<RingChart
:value="doneCount"
:total="personCount"
:color="tone('info').solid"
:labels="['做完', '没做完']"
/>
<span class="ring-label">班级<br />完成度</span>
</span>
</div>
<ByClass v-if="byClass" :grid="grid" @pick="pickClass" />
<CodeStats v-else :stats="stats" :grid="grid" />
</template>
</div>
</template>
<style scoped>
/* 铺满顶栏以下,和提交列表同一个做法(负边距吃掉外层 16px) */
.page {
margin: -16px;
height: calc(100vh - 56px);
display: flex;
flex-direction: column;
background: v-bind("theme.cardColor");
}
.filters {
height: 52px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.title {
font-size: 17px;
font-weight: 700;
margin-right: 6px;
}
.vsep {
width: 1px;
height: 20px;
margin: 0 4px;
background: v-bind("theme.dividerColor");
}
.w-class {
width: 132px;
}
.w-user {
width: 110px;
}
.w-problem {
width: 220px;
}
.w-period {
width: 150px;
}
.w-dates {
width: 240px;
}
@media (max-width: 1365px) {
.w-problem {
width: 190px;
}
}
.range {
font-size: 12px;
color: v-bind("theme.textColor3");
}
.spacer {
flex: 1 1 0;
}
.summary {
height: 72px;
flex: none;
box-sizing: border-box;
padding: 0 20px;
display: flex;
align-items: center;
gap: 36px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.ring-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.ring-label {
font-size: 12px;
line-height: 1.5;
color: v-bind("theme.textColor3");
}
</style>
+101
View File
@@ -0,0 +1,101 @@
import { TIME_ZONE_OFFSET_MINUTES } from "@oj2/contract"
import { parseTime, zonedParts } from "utils/functions"
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const HOUR = 3_600_000
const DAY = 86_400_000
/**
* 统计页的时间段。课堂统计一般看一两节课(用户原话),所以这两档在最前面、
* 默认「两节课」;后面几档是回头看。原来弹框里的 10 / 20 / 30 分钟是上课盯人用的,
* 那件事现在归课堂看板。
*/
export const PERIOD_OPTIONS = [
{ label: "这节课(1 小时内)", value: "1h" },
{ label: "两节课(2 小时内)", value: "2h" },
{ label: "今天", value: "today" },
{ label: "最近 7 天", value: "7d" },
{ label: "最近 30 天", value: "30d" },
{ label: "这学期", value: "term" },
{ label: "全部", value: "all" },
{ label: "自己选日子", value: "custom" },
] as const
export type Period = (typeof PERIOD_OPTIONS)[number]["value"]
export const DEFAULT_PERIOD: Period = "2h"
/** 东八区某天 0 点对应的 UTC 时刻 */
function dayStartOf(year: number, month: number, day: number) {
return Date.UTC(year, month - 1, day) - OFFSET_MS
}
/** 东八区「现在」这一天的 0 点 */
function todayStart(now: number) {
const p = zonedParts(new Date(now))!
return dayStartOf(p.year, p.month, p.day)
}
/** 这学期从哪天起:9 月到次年 2 月算秋季学期(9 月 1 日起),3 到 8 月算春季(3 月 1 日起) */
function termStart(now: number) {
const p = zonedParts(new Date(now))!
if (p.month >= 9) return dayStartOf(p.year, 9, 1)
if (p.month >= 3) return dayStartOf(p.year, 3, 1)
return dayStartOf(p.year - 1, 9, 1)
}
/**
* 时间段换成接口要的 start / end(ISO),外加一句给人看的范围。
* `custom` 用的是 [开始日 0 点, 结束日 24 点),两个都是 date picker 取回来的 UTC 时刻。
*/
export function periodRange(period: Period, custom: [number, number] | null, now = Date.now()) {
let start: number | null
let end = now
switch (period) {
case "1h":
start = now - HOUR
break
case "2h":
start = now - 2 * HOUR
break
case "today":
start = todayStart(now)
break
case "7d":
start = todayStart(now) - 6 * DAY
break
case "30d":
start = todayStart(now) - 29 * DAY
break
case "term":
start = termStart(now)
break
case "custom":
if (custom) {
start = custom[0]
end = custom[1] + DAY
} else {
start = todayStart(now)
}
break
default:
start = null
}
const text = describe(start, end, period)
return {
start: start === null ? undefined : new Date(start).toISOString(),
end: new Date(end).toISOString(),
text,
/** 范围落在今天以内:提交列表那边能用「今天」接上 */
withinToday: start !== null && start >= todayStart(now),
}
}
function describe(start: number | null, end: number, period: Period) {
if (start === null) return ""
if (period === "1h" || period === "2h") {
return `${parseTime(new Date(start), "HH:mm")}–${parseTime(new Date(end), "HH:mm")}`
}
const from = parseTime(new Date(start), "M月D日")
const to = parseTime(new Date(end - 1), "M月D日")
return from === to ? from : `${from}–${to}`
}
+20 -30
View File
@@ -44,10 +44,6 @@ import { classLabel, submissionClockText, submissionDayText } from "./utils"
// (光 chart.js 就 197KB)
const SubmissionPane = defineAsyncComponent(() => import("./components/SubmissionPane.vue"))
const FlowchartPane = defineAsyncComponent(() => import("./components/FlowchartPane.vue"))
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
const FlowchartStatisticsPanel = defineAsyncComponent(
() => import("shared/components/FlowchartStatisticsPanel.vue"),
)
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
interface SubmissionQuery {
@@ -272,11 +268,10 @@ function move(step: 1 | -1) {
query.page = page
}
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
const [todayPanel, toggleTodayPanel] = useToggle(false)
onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => {
if (!isDesktop.value || statisticPanel.value || todayPanel.value) return
if (!isDesktop.value || todayPanel.value) return
// 焦点在输入框、下拉框里时不抢方向键
const target = e.target as HTMLElement | null
if (
@@ -504,6 +499,24 @@ function filterUser(username: string) {
query.username = username
}
/**
* 「数据统计」:新标签打开统计页,带上现在的班级 / 学生 / 题号 / 代码或流程图
* (原来是这一页上的弹框)。「今天」带过去就是统计页的「今天」
*/
function openStatistics() {
const href = router.resolve({
name: "statistics",
query: {
...(flowMode.value ? { tab: "flow" } : {}),
...(query.className ? { className: query.className } : {}),
...(query.username ? { username: query.username } : {}),
...(query.problem ? { problem: query.problem } : {}),
...(query.today === "1" ? { period: "today" } : {}),
},
}).href
window.open(href, "_blank")
}
function filterProblem(displayId: string) {
query.problem = displayId
}
@@ -692,11 +705,7 @@ function dayBreak(index: number) {
<template v-if="!(teacher && narrowBar)">今日统计</template>
<span v-if="todayCount" class="count">{{ todayCount }}</span>
</n-button>
<n-button
v-if="teacher && route.name === 'submissions'"
size="small"
@click="toggleStatisticPanel(true)"
>
<n-button v-if="teacher && route.name === 'submissions'" size="small" @click="openStatistics">
<template #icon><Icon icon="ph:chart-pie-slice" /></template>
数据统计
</n-button>
@@ -929,25 +938,6 @@ function dayBreak(index: number) {
</n-drawer-content>
</n-drawer>
<n-modal
v-if="teacher"
v-model:show="statisticPanel"
preset="card"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="flowMode ? '流程图提交的统计' : '提交记录的统计'"
>
<FlowchartStatisticsPanel
v-if="flowMode"
:problem="query.problem"
:username="query.className ? 'ks' + query.className : query.username"
/>
<StatisticsPanel
v-else
:problem="query.problem"
:username="query.className ? 'ks' + query.className : query.username"
/>
</n-modal>
<n-modal
v-model:show="todayPanel"
preset="card"
+6
View File
@@ -75,6 +75,12 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/classroom/board.vue"),
meta: { requiresAuth: true, requiresTeacherAdmin: true },
},
{
path: "statistics",
name: "statistics",
component: () => import("oj/statistics/index.vue"),
meta: { requiresAuth: true, requiresTeacherAdmin: true },
},
{
path: "class",
name: "class",
-19
View File
@@ -156,8 +156,6 @@ export const STORAGE_KEY = {
ADMIN_SIDER_COLLAPSED: "adminSiderCollapsed",
DEMO_MODE: "demoMode",
LOGIN_CLASS: "loginClass",
STATISTICS_QUERY: "statisticsQuery",
FLOWCHART_STATISTICS_QUERY: "flowchartStatisticsQuery",
}
export const DIFFICULTY = {
@@ -328,23 +326,6 @@ export const DURATION_OPTIONS = [
{ label: "一年内", value: "years:1" },
] as const
/**
* 两个统计面板(提交统计、流程图统计)的时段下拉。
*
* 比通用的那份多了头尾:前面三档分钟级是给**上课当场**用的 —— 老师布置完一道题,
* 想看的就是「刚才这十分钟谁交了」;末尾的 `all` 不是一个时长,`query.duration === "all"`
* 会走各自的分支不带时间条件,所以它永远不会进 durationFromValue()。
*
* 原来这份列表在两个面板里逐字抄了两遍。
*/
export const PANEL_DURATION_OPTIONS = [
{ label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" },
{ label: "30分钟内", value: "minutes:30" },
...DURATION_OPTIONS,
{ label: "全部时段", value: "all" },
] as const
/**
* 榜单和班级对比用的长时段,一周起步。
*