feat(统计面板): 展开一个学生后按题目分组,每道题画一条「状态轨迹」
Some checks failed
Deploy / deploy (push) Has been cancelled

老师展开一个学生,原来看到的是一排 12 位十六进制的提交编号按钮。编号本身没有信息量,
而一节课里学生常在好几道题之间来回跳,那一排看不出他到底卡在哪一道。

现在一道题一行:题号、标题、交了几次、过没过,后面跟一排按时间**从早到晚**的小方块,
颜色就是判题状态(绿=通过 红=答错 黄=编译失败/超时 灰=判题中)。
「八绿一红一绿」和「五黄到底」一眼分得开。

- **排序按老师的用法来**:没过的排前面,其中交得越多越靠前 —— 卡得最久的那道顶到眼前;
  已通过的沉底,它们只是「做完了」。
- 鼠标悬停出「状态 · 时间 · 提交号」,点击照旧打开提交详情。
- 「语法未过」(ast_check_failed) 算做出来了,和表格上「已解决」那一列口径一致。
- 方块用内联样式而不是 class:它们是 h() 出来、挂在 NDataTable 展开槽里渲染的,
  <style scoped> 能不能盖到并不确定。色值沿用 Naive 的语义色,和 ExerciseMatch.vue 一致。

为此 GET /submissions/statistics/items 多带三个字段(problem / problemTitle /
createTime)—— 原来只有 id 和 result,分组和悬停都无从谈起。innerJoin problem 不会漏行:
submission.problem_id 是 NOT NULL 且外键 NO ACTION,题目删不掉。

## 验证

起全栈在浏览器里真点过(提交列表 → 数据统计 → 提交记录 → 展开 student):

- 五道题各成一行,顺序是 1005(5次未过) → 1006(4次) → 1020(1次) → 1018(1次) →
  1004(10次已通过),符合「没过的在前、交得多的在前、已过的沉底」;
- 现造一条「先错后对」:轨迹读出来是 绿绿绿绿绿绿绿绿红绿,新交的 WA→AC 落在末尾,
  确认是从早到晚而不是倒序;
- 悬停取到「编译失败 · 09-02 22:03:14 · 9f02da4c5f01」。

tsc、check:routes、vue-tsc、vite build、单二进制编译均通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
This commit is contained in:
2026-09-10 20:09:09 -06:00
parent f581029eb2
commit 8eae4bea7b
3 changed files with 145 additions and 20 deletions

View File

@@ -641,9 +641,18 @@ submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c)
: eq(schema.submission.username, username) : eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了 // 多取一条,好知道是不是被截断了
// innerJoin 不会漏行submission.problem_id 是 NOT NULL 且外键是 NO ACTION
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db const rows = await db
.select({ id: schema.submission.id, result: schema.submission.result }) .select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problem: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission) .from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(and(...scope.filters, identity)) .where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id)) .orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1) .limit(STATISTICS_ITEMS_LIMIT + 1)

View File

@@ -204,8 +204,9 @@ import { useConfigStore } from "../store/config"
import { useHiddenStudents } from "../composables/hiddenStudents" import { useHiddenStudents } from "../composables/hiddenStudents"
import { Doughnut } from "vue-chartjs" import { Doughnut } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js" import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { NButton, NFlex, NTag, NText, type DataTableRowKey } from "naive-ui" import { NFlex, NTag, NText, NTooltip, type DataTableRowKey } from "naive-ui"
import { JUDGE_STATUS } from "utils/constants" import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { parseTime } from "utils/functions"
import type { import type {
AttemptedStudent, AttemptedStudent,
SubmissionStatisticsItems, SubmissionStatisticsItems,
@@ -231,6 +232,62 @@ const options: SelectOption[] = [
{ label: "全部时段", value: "all" }, { label: "全部时段", value: "all" },
] ]
/**
* 轨迹方块的颜色。取值就是 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, {
problem: string
problemTitle: string
items: SubmissionStatisticsItems["items"]
}>()
for (const item of list) {
const group = groups.get(item.problem)
if (group) group.items.push(item)
else groups.set(item.problem, {
problem: item.problem,
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,
)
}
function openSubmission(id: string) { function openSubmission(id: string) {
window.open(`/submission/${id}`, "_blank", "noopener") window.open(`/submission/${id}`, "_blank", "noopener")
} }
@@ -241,24 +298,68 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
renderExpand: (row) => { renderExpand: (row) => {
const loaded = items[row.username] const loaded = items[row.username]
if (!loaded) return h(NText, { depth: 3 }, () => "加载中…") if (!loaded) return h(NText, { depth: 3 }, () => "加载中…")
return h(NFlex, { vertical: true, size: "small" }, () => [ return h(NFlex, { vertical: true, size: "medium" }, () => [
h(NFlex, { size: "small", wrap: true }, () => ...groupByProblem(loaded.items).map((group) =>
loaded.items.map((item) => // 题头一栏、轨迹一栏,**外层不换行**:一道题交了几十发时方块要在自己那一栏里
// 折行,折下来的一行才会和上一行对齐
h(NFlex, { size: "small", align: "flex-start", wrap: false }, () => [
h( h(
NButton, NFlex,
{ { size: 4, align: "center", wrap: false, style: "width: 200px; flex: none" },
size: "small", () => [
tertiary: true, h(NTag, { size: "small", bordered: false }, () => group.problem),
type: JUDGE_STATUS[item.result]?.type ?? "default", h(
style: "width: 120px", NText,
onClick: (event: MouseEvent) => { {
event.stopPropagation() depth: 2,
openSubmission(item.id) style:
}, "overflow: hidden; text-overflow: ellipsis; white-space: nowrap",
}, },
() => item.id.toString().slice(0, 12), () => 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(
NTooltip,
{ delay: 200 },
{
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: () =>
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
`${item.id.toString().slice(0, 12)}`,
},
),
),
),
]),
), ),
loaded.truncated loaded.truncated
? h( ? h(

View File

@@ -273,7 +273,22 @@ export const submissionStatisticsUserSchema = z.object({
* 免得老师以为这人就交了这么多。 * 免得老师以为这人就交了这么多。
*/ */
export const submissionStatisticsItemsSchema = z.object({ export const submissionStatisticsItemsSchema = z.object({
items: z.array(z.object({ id: z.string(), result: judgeStatusSchema })), /**
* 展开某个学生时列出他这段时间的提交。**带上题目**:一节课里学生往往在好几道题
* 之间来回跳,一串只有编号的按钮看不出他卡在哪一道 —— 前端按题目分组展示。
*
* 字段名沿用 submissionListItemSchema 的口径:`problem` 是展示用题号problem._id
* `problemTitle` 是标题。
*/
items: z.array(
z.object({
id: z.string(),
result: judgeStatusSchema,
createTime: z.string(),
problem: z.string(),
problemTitle: z.string(),
}),
),
truncated: z.boolean(), truncated: z.boolean(),
}) })