feat(课堂看板): 做对的格子写第几次做对,点开看他今天交的每一次和代码

- 格子:一次就对的还是「✓」,第 9 次才对的写「✓ 9」
- 点做对的格子弹出方块串(一格一次、从早到晚),停在哪格看哪次的代码,
  默认停在做对前最后错的那次;下面能开这次提交、去提交列表看全部
- 方块配色和代码缓存从统计页的 AttemptSquares 抽到 statistics/attempts.ts 两边共用
- 点开一道题后面板里的题名可以点,新标签打开题目

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-19 21:09:35 +08:00
1 parent 940895f643
commit d8e0efd05e
5 files changed
+356 -41

No files matched your search

+69 -2
View File
@@ -16,12 +16,14 @@ import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent }
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
import LeaveModal from "./components/LeaveModal.vue"
import LessonForm from "./components/LessonForm.vue"
import TrailPanel from "./components/TrailPanel.vue"
import { errorKind, type ErrorKind } from "utils/errorKind"
import {
cellStyle as cellStyleOf,
cellText,
cellTitle as cellTitleOf,
STUCK_ATTEMPTS,
trailSummary,
} from "./boardCells"
/**
@@ -378,6 +380,22 @@ const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day,
const cellTitle = (cell: ClassBoardCell, student: ClassBoardStudent) =>
cellTitleOf(cell, student, board.value?.day)
/**
* 点开着轨迹的那个格子(学生 id:第几道题)。做对了、今天交过代码的格子点了不再直接去提交列表,
* 先在格子下面看他是怎么一次次做对的,列表在弹出来的那块里
*/
const trailKey = ref<string | null>(null)
function toggleTrail(key: string) {
trailKey.value = trailKey.value === key ? null : key
}
/** 点到别的格子时,那边自己会换过去;这里只管点到空白处收起 */
function trailOutside(event: MouseEvent) {
if ((event.target as Element | null)?.closest(".cell")) return
trailKey.value = null
}
/**
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
@@ -520,10 +538,19 @@ function lastText(student: ClassBoardStudent) {
<section v-if="selectedIndex >= 0" class="panel">
<div class="panel-head">
<b class="panel-title">
<a
class="panel-title"
:href="
router.resolve({
name: 'problem',
params: { problemID: board.problems[selectedIndex]!.problemDisplayId },
}).href
"
target="_blank"
>
<span class="muted">{{ board.problems[selectedIndex]!.problemDisplayId }}</span>
{{ board.problems[selectedIndex]!.title }}
</b>
</a>
<span>
错着的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
</span>
@@ -688,6 +715,35 @@ function lastText(student: ClassBoardStudent) {
>
{{ cellText(cell) }}
</button>
<n-popover
v-else-if="cell.trail.length"
:show="trailKey === `${student.userId}:${i}`"
trigger="manual"
placement="bottom-start"
:style="{ width: '620px', maxWidth: 'calc(100vw - 32px)' }"
@clickoutside="trailOutside"
>
<template #trigger>
<button
type="button"
class="cell"
:class="{ picked: trailKey === `${student.userId}:${i}` }"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
@click="toggleTrail(`${student.userId}:${i}`)"
>
{{ cellText(cell) }}
</button>
</template>
<TrailPanel
:username="student.username"
:problem="board.problems[i]!"
:cell="cell"
:summary="trailSummary(cell, board.day)"
:list-href="submissionsHref(student, board.problems[i], false)"
@close="trailKey = null"
/>
</n-popover>
<a
v-else-if="cell.attempts"
class="cell"
@@ -941,6 +997,13 @@ function lastText(student: ClassBoardStudent) {
.panel-title {
font-size: 16px;
font-weight: 600;
color: inherit;
text-decoration: none;
}
.panel-title:hover {
color: v-bind("theme.primaryColor");
}
.groups {
@@ -1157,6 +1220,10 @@ a.cell {
cursor: pointer;
}
.cell.picked {
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
}
.num {
font-variant-numeric: tabular-nums;
}
+12 -1
View File
@@ -15,8 +15,9 @@ export function acceptedToday(cell: ClassBoardCell, day: string | undefined) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === day
}
/** 做对了的写第几次做对:「✓ 9」是今天第 9 次才对,一次就对的、以前就对了的还是「✓」 */
export function cellText(cell: ClassBoardCell) {
if (cell.status === "accepted") return "✓"
if (cell.status === "accepted") return (cell.acceptedTry ?? 1) > 1 ? `✓ ${cell.acceptedTry}` : "✓"
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
return ""
}
@@ -62,3 +63,13 @@ export function cellTitle(
}
return ""
}
/** 点开做对的格子,方块串旁边那句 */
export function trailSummary(cell: ClassBoardCell, day: string | undefined) {
const total = cell.trail.length
if (cell.acceptedTry === 1) return total === 1 ? "一次就做对" : `交了 ${total} 次,第 1 次就做对`
if (cell.acceptedTry) return `交了 ${total} 次,第 ${cell.acceptedTry} 次做对`
return acceptedToday(cell, day)
? `今天交了 ${total} 次代码`
: `以前就做对了,今天又交了 ${total} 次`
}
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { useTone } from "shared/composables/tone"
import UserName from "shared/components/UserName.vue"
import { attemptTone, cachedSubmission } from "oj/statistics/attempts"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
import { parseTime } from "utils/functions"
import type { ClassBoardCell, ClassBoardProblem, Submission } from "utils/types"
/**
* 点开一个做对了的格子:他这道题今天交的每一次,一格一次、从早到晚,下面是停着的那一次的代码
* (设计稿「课堂看板重设计」· 做对的格子也看得到轨迹)。
*
* 默认停在做对前最后错的那一次 —— 要讲的一般是「他最后是改了哪儿才对的」。
* 停上去就换,不用点:老师在几格之间来回比;点也行(手机上没有悬停)
*/
const props = defineProps<{
username: string
problem: ClassBoardProblem
cell: ClassBoardCell
summary: string
/** 「在提交列表看这 N 次」:和格子原来点进去的是同一个列表 */
listHref: string
}>()
defineEmits<{ close: [] }>()
const tone = useTone()
const theme = useThemeVars()
const picked = ref(
props.cell.acceptedTry
? Math.max(props.cell.acceptedTry - 2, 0)
: Math.max(props.cell.trail.length - 1, 0),
)
const item = computed(() => props.cell.trail[picked.value] ?? props.cell.trail.at(-1))
const code = reactive<Record<string, Submission | "loading" | "failed">>({})
watch(
() => item.value?.id,
async (id) => {
if (!id || (code[id] && code[id] !== "failed")) return
code[id] = "loading"
try {
code[id] = await cachedSubmission(id)
} catch {
code[id] = "failed"
}
},
{ immediate: true },
)
const shown = computed(() => {
const value = item.value && code[item.value.id]
return typeof value === "object" ? value : null
})
const squares = computed(() =>
props.cell.trail.map((row) => ({
...row,
color: tone(attemptTone(row.result)).solid,
})),
)
</script>
<template>
<div class="trail">
<div class="head">
<UserName :username="username" />
<span class="problem">
<b>{{ problem.problemDisplayId }}</b>
<span class="muted">{{ problem.title }}</span>
</span>
<div class="spacer"></div>
<n-button quaternary size="tiny" aria-label="收起" @click="$emit('close')">
<template #icon><Icon icon="ph:x-bold" /></template>
</n-button>
</div>
<div class="line">
<div class="squares">
<button
v-for="(row, index) in squares"
:key="row.id"
type="button"
class="square"
:class="{ on: index === picked }"
:style="{ background: row.color }"
:aria-label="`第 ${index + 1} 次:${JUDGE_STATUS[row.result]?.name ?? ''}`"
@mouseenter="picked = index"
@click="picked = index"
></button>
</div>
<span class="summary">{{ summary }}</span>
</div>
<div v-if="item" class="code">
<div class="code-head">
<b :style="{ color: tone(attemptTone(item.result)).color }">
{{ JUDGE_STATUS[item.result]?.name }}
</b>
<span class="muted"
>{{ parseTime(item.createTime, "HH:mm") }} · 第 {{ picked + 1 }} 次</span
>
</div>
<n-code
v-if="shown"
class="code-body"
:code="shown.code"
:language="LANGUAGE_FORMAT_VALUE[shown.language]"
show-line-numbers
/>
<span v-else-if="code[item.id] === 'failed'" class="muted">代码没拉下来,点下面打开看</span>
<span v-else class="muted">代码加载中…</span>
</div>
<div class="links">
<a v-if="item" :href="`/submission/${item.id}`" target="_blank">打开这次提交 ↗</a>
<a :href="listHref" target="_blank">在提交列表看这 {{ cell.trail.length }} 次 ↗</a>
</div>
</div>
</template>
<style scoped>
.trail {
display: flex;
flex-direction: column;
gap: 12px;
font-size: 14px;
}
.head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-size: 15px;
white-space: nowrap;
}
.problem {
display: flex;
gap: 6px;
min-width: 0;
font-size: 14px;
}
.problem .muted {
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.line {
display: flex;
align-items: center;
gap: 14px;
}
.squares {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.square {
flex: none;
width: 22px;
height: 22px;
padding: 0;
border: 0;
border-radius: 4px;
cursor: pointer;
}
.square.on {
box-shadow:
0 0 0 2px v-bind("theme.cardColor"),
0 0 0 4px v-bind("theme.textColor1");
}
.summary {
white-space: nowrap;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor2");
}
.code {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 6px;
background: v-bind("theme.actionColor");
}
.code-head {
display: flex;
align-items: center;
gap: 10px;
}
.code-body {
max-height: 320px;
overflow: auto;
font-size: 15px;
}
.links {
display: flex;
gap: 18px;
}
.links a {
color: v-bind("theme.primaryColor");
text-decoration: none;
}
.links a:hover {
text-decoration: underline;
}
.muted {
color: v-bind("theme.textColor3");
}
</style>
+40
View File
@@ -0,0 +1,40 @@
import { getSubmission } from "oj/api"
import { SubmissionStatus } from "utils/constants"
import type { Submission } from "utils/types"
import type { Tone } from "shared/composables/tone"
/**
* 一次提交一格的那串方块(统计页的 AttemptSquares、课堂看板做对格子的轨迹)共用的两样:
* 方块的颜色,和停上去看代码时拉的提交。
*/
/** 对了绿;编译失败、运行时错误、语法规则没过、部分通过橙;判题中蓝;其余红 */
export function attemptTone(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"
}
/**
* 代码按提交 id 缓存,整页共用:交了就不会再变,老师在一排方块之间来回对比时
* 不用每次都等网络。失败的不进缓存,下次停上去再试。
*/
const codeCache = new Map<string, Promise<Submission>>()
export function cachedSubmission(id: string) {
let hit = codeCache.get(id)
if (!hit) {
hit = getSubmission(id)
hit.catch(() => codeCache.delete(id))
codeCache.set(id, hit)
}
return hit
}
@@ -1,20 +1,10 @@
<script lang="ts">
import type { Submission } from "utils/types"
/**
* 代码按提交 id 缓存,整页共用:交了就不会再变,老师在一排方块之间来回对比时
* 不用每次都等网络。失败的不进缓存,下次停上去再试。
*/
const codeCache = new Map<string, Submission>()
</script>
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
import { parseTime } from "utils/functions"
import type { SUBMISSION_RESULT } from "utils/types"
import { useTone, type Tone } from "shared/composables/tone"
import type { Submission, SUBMISSION_RESULT } from "utils/types"
import { useTone } from "shared/composables/tone"
import { attemptTone, cachedSubmission } from "../attempts"
/**
* 一个人在一道题上的每一次提交,一格一次、从早到晚(设计稿「统计重设计」)。
@@ -31,34 +21,13 @@ const props = withDefaults(
const tone = useTone()
const theme = useThemeVars()
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
if (code[id] && code[id] !== "failed") return
code[id] = "loading"
try {
const res = await getSubmission(id)
codeCache.set(id, res)
code[id] = res
code[id] = await cachedSubmission(id)
} catch {
code[id] = "failed"
}
@@ -72,7 +41,7 @@ const cells = computed(() =>
props.items.map((item, index) => ({
...item,
index: index + 1,
color: tone(toneOf(item.result)).solid,
color: tone(attemptTone(item.result)).solid,
})),
)
</script>