feat(课堂看板): 看板重做——顶上当场填题号,一排题卡片点开看这道题错在哪,全班一张表按没交/卡住/在做排,点红格子弹出代码讲错误、← → 换人
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 10:02:50 +08:00
1 parent 0a4f528c44
commit 5c1f1d2bf8
5 files changed
+1283 -493

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,200 @@
<script lang="ts">
export interface ExplainPerson {
userId: number
username: string
submissionId: string
/** 错法的短名(errorKind),挂在名字后面 */
label: string
}
</script>
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { onKeyStroke } from "@vueuse/core"
import { useThemeVars } from "naive-ui"
import SubmissionView from "oj/submission/components/SubmissionView.vue"
import UserName from "shared/components/UserName.vue"
import type { ClassBoardProblem } from "utils/types"
/**
* 课堂看板「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
* 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块
* (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。
*/
const props = defineProps<{
problem: ClassBoardProblem | null
people: ExplainPerson[]
}>()
const show = defineModel<boolean>("show", { required: true })
const index = defineModel<number>("index", { required: true })
const theme = useThemeVars()
/**
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
* v-bind 的变量挂在组件根上,传不过去(弹层底色整个透明)
*/
const vars = computed(() => ({
"--card": theme.value.cardColor,
"--text1": theme.value.textColor1,
"--text2": theme.value.textColor2,
"--text3": theme.value.textColor3,
"--divider": theme.value.dividerColor,
"--border": theme.value.borderColor,
"--success": theme.value.successColor,
"--hover": theme.value.hoverColor,
}))
const current = computed(() => props.people[index.value] ?? null)
function step(delta: -1 | 1) {
const next = index.value + delta
if (next >= 0 && next < props.people.length) index.value = next
}
onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
if (!show.value || props.people.length < 2) return
if (event.altKey || event.ctrlKey || event.metaKey) return
event.preventDefault()
step(event.key === "ArrowLeft" ? -1 : 1)
})
</script>
<template>
<n-modal v-model:show="show" :auto-focus="false">
<!-- 无 preset 的 n-modal 直接子节点要是普通元素 -->
<div
class="sheet"
:style="vars"
role="dialog"
:aria-label="problem ? `讲 ${problem.problemDisplayId} 的错误` : '讲错误'"
>
<div class="top">
<span v-if="problem" class="problem">
<b>{{ problem.problemDisplayId }}</b> {{ problem.title }}
<template v-if="people.length > 1"> · 错着的</template>
</span>
<div class="people">
<button
v-for="(person, i) in people"
:key="person.userId"
type="button"
class="person"
:class="{ on: i === index }"
@click="index = i"
>
<UserName :username="person.username" />
<span class="kind">{{ person.label }}</span>
</button>
</div>
<span v-if="people.length > 1" class="keys"><kbd>←</kbd><kbd>→</kbd> 换人</span>
<div class="spacer"></div>
<n-button
v-if="current"
tag="a"
:href="'/submission/' + current.submissionId"
target="_blank"
>
在新页面打开
</n-button>
<n-button quaternary aria-label="关闭" @click="show = false">
<template #icon><Icon icon="ph:x-bold" /></template>
</n-button>
</div>
<SubmissionView v-if="current" :id="current.submissionId" embedded />
</div>
</n-modal>
</template>
<style scoped>
.sheet {
width: min(1240px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
box-sizing: border-box;
padding: 18px 24px 24px;
border-radius: 10px;
background: var(--card);
display: flex;
flex-direction: column;
gap: 14px;
}
.top {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.problem {
flex-shrink: 0;
font-size: 14px;
color: var(--text3);
white-space: nowrap;
}
.problem b {
color: var(--text2);
}
.people {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.person {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid var(--divider);
background: var(--card);
color: var(--text1);
font: inherit;
font-size: 14px;
white-space: nowrap;
cursor: pointer;
}
.person.on {
border-color: var(--success);
background: var(--hover);
}
.kind {
font-size: 12px;
color: var(--text3);
}
.keys {
flex-shrink: 0;
font-size: 12px;
color: var(--text3);
display: inline-flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
kbd {
min-width: 18px;
height: 18px;
padding: 0 4px;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--card);
font: inherit;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.spacer {
flex: 1 1 0;
}
</style>
+107
View File
@@ -0,0 +1,107 @@
import { classifyDiff, type DiffKind } from "oj/problem/utils/wrongAnswer"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import type { ClassBoardCell } from "utils/types"
/**
* 课堂看板:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
* 要的是「这道题全班错在哪」:同一种错讲一遍就够,所以按最后一次提交归类,堆名要短,
* 一眼能读完 —— 长的中文解释在点开之后的弹层里(和提交详情同一份)。
*
* 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。
*/
export type BoardLatest = NonNullable<ClassBoardCell["latest"]>
export interface ErrorKind {
/** 归堆用:同一个 key 是同一种错 */
key: string
label: string
/** 报错指到的行,答案错误没有 */
line: number | null
}
const DIFF_LABEL: Record<DiffKind, string> = {
"no-output": "什么都没输出",
whitespace: "只差空格、换行",
punctuation: "中英文标点不对",
case: "大小写不对",
extra: "多输出了东西",
missing: "少输出了东西",
"number-format": "小数位数不对",
values: "算出来的数不对",
other: "输出和例子不一样",
}
/** runtime-diagnosis.ts 细分出来的 kind 里常见的几种;没列的按异常类型归 */
const KIND_LABEL: Record<string, string> = {
"int-parse": "int() 转换失败",
"float-parse": "float() 转换失败",
unpack: "拆出来的个数对不上",
"unpack-number": "一个数拆给好几个变量",
"str-concat": "文字和数字用 + 连",
operand: "文字和数字一起算",
compare: "文字和数字比大小",
"not-int": "该给整数的地方不是整数",
"format-spec": "保留小数的格式写错",
"not-callable": "把不是函数的当函数用",
"func-value": "函数名后面忘了括号",
"none-value": "拿 None 接着用",
"map-value": "map() 的结果没转成列表",
}
const TYPE_LABEL: Record<string, string> = {
EOFError: "input() 用多了",
IndexError: "下标越界",
ZeroDivisionError: "除以 0",
KeyError: "字典里没有这个键",
RecursionError: "递归停不下来",
UnboundLocalError: "变量还没赋值就用",
ModuleNotFoundError: "导入了没有的模块",
ImportError: "导入了没有的模块",
ValueError: "值不符合要求",
TypeError: "类型不对",
}
/** C / C++ 只有信号:11 段错误、8 除零 */
const SIGNAL_LABEL: Record<number, string> = {
11: "数组越界或指针出错",
8: "除以 0",
}
export function errorKind(latest: BoardLatest): ErrorKind {
const result = latest.result
if (result === SubmissionStatus.wrong_answer) {
const check = latest.sampleCheck
if (!check) return { key: "wa", label: "答案错误", line: null }
if (check.passed) return { key: "wa-hidden", label: "例子都对,错在没公开的测试点", line: null }
const diff = classifyDiff(check.expected ?? "", check.output ?? "")
return { key: `wa-${diff}`, label: DIFF_LABEL[diff], line: null }
}
if (result === SubmissionStatus.runtime_error) {
const rt = latest.runtimeError
const line = rt?.line ?? null
if (!rt) return { key: "re", label: "运行时出错", line }
if (rt.signal && SIGNAL_LABEL[rt.signal]) {
return { key: `re-signal-${rt.signal}`, label: SIGNAL_LABEL[rt.signal]!, line }
}
if (rt.type === "NameError") {
return rt.name
? { key: `re-name-${rt.name}`, label: `「${rt.name}」没有定义`, line }
: { key: "re-name", label: "用了没定义的名字", line }
}
if (rt.kind && KIND_LABEL[rt.kind]) {
return { key: `re-${rt.kind}`, label: KIND_LABEL[rt.kind]!, line }
}
if (rt.type && TYPE_LABEL[rt.type]) {
return { key: `re-${rt.type}`, label: TYPE_LABEL[rt.type]!, line }
}
return { key: "re", label: "运行时出错", line }
}
if (result === SubmissionStatus.compile_error) {
return { key: "ce", label: "编译没通过", line: null }
}
return {
key: `r${result}`,
label: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? "没通过",
line: null,
}
}
@@ -17,13 +17,13 @@ import {
} from "utils/constants"
import { parseTime, submissionCaseResults } from "utils/functions"
import type { Submission } from "utils/types"
import CodeView from "./components/CodeView.vue"
import ExplainNote from "./components/ExplainNote.vue"
import StatusPill from "./components/StatusPill.vue"
import { useCopySubmission } from "./composables/copySubmission"
import { useSubmissionExplain } from "./composables/explain"
import { useTone } from "./composables/tone"
import { classLabel, submissionTimeText } from "./utils"
import { useCopySubmission } from "../composables/copySubmission"
import { useSubmissionExplain } from "../composables/explain"
import { useTone } from "../composables/tone"
import { classLabel, submissionTimeText } from "../utils"
import CodeView from "./CodeView.vue"
import ExplainNote from "./ExplainNote.vue"
import StatusPill from "./StatusPill.vue"
/**
* 单独打开的一条提交(设计稿「提交详情重设计」A,用户改成代码在前、例子在后):一栏到底 ——
@@ -33,8 +33,12 @@ import { classLabel, submissionTimeText } from "./utils"
*
* 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生
* 怎么一次次改对的。
*
* 独立的提交详情页(detail.vue)和课堂看板「讲错误」的弹层共用这一个。`embedded`(弹层里):
* 不出名字和题目那一行(弹层顶上有一排人名),不占 ← →(弹层拿它换人),
* 点「第几次」就地换、不改地址栏。
*/
const props = defineProps<{ submissionID: string }>()
const props = defineProps<{ id: string; embedded?: boolean }>()
const router = useRouter()
const message = useMessage()
@@ -45,6 +49,13 @@ const userStore = useUserStore()
const { isMobile } = useBreakpoints()
const { copyToCat, copyToProblem } = useCopySubmission()
/** 正在看的那一条:页面里跟着地址栏走,弹层里点「第几次」就地换 */
const currentId = ref(props.id)
watch(
() => props.id,
(id) => (currentId.value = id),
)
const detail = ref<Submission | null>(null)
/** forbidden:别人的代码;missing:链接不对 / 已删掉 */
const state = ref<"loading" | "ok" | "forbidden" | "missing" | "failed">("loading")
@@ -65,7 +76,7 @@ async function load(quiet = false) {
clearTimeout(timer)
if (!quiet) state.value = "loading"
try {
const res = await getSubmission(props.submissionID, true)
const res = await getSubmission(currentId.value, true)
if (seq !== loadSeq) return
detail.value = res
state.value = "ok"
@@ -82,11 +93,7 @@ async function load(quiet = false) {
}
}
watch(
() => props.submissionID,
() => load(),
{ immediate: true },
)
watch(currentId, () => load(), { immediate: true })
onBeforeUnmount(() => clearTimeout(timer))
const shown = computed(() => (state.value === "ok" ? detail.value : null))
@@ -144,7 +151,9 @@ function step(delta: -1 | 1) {
}
function openAttempt(id: string) {
if (id !== shown.value?.id) router.replace("/submission/" + id)
if (id === shown.value?.id) return
if (props.embedded) currentId.value = id
else router.replace("/submission/" + id)
}
function typing(event: KeyboardEvent) {
@@ -154,6 +163,7 @@ function typing(event: KeyboardEvent) {
// 这几次是横着排的一串,所以用 ← →(竖着排的列表才用 ↑ ↓,见提交列表)
onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
if (props.embedded) return
if (typing(event) || event.altKey || event.ctrlKey || event.metaKey || attempts.value.length < 2)
return
event.preventDefault()
@@ -228,9 +238,9 @@ function handleMore(key: string) {
</script>
<template>
<div class="oj-page detail" :class="{ narrow: isMobile }">
<div class="detail" :class="{ 'oj-page': !embedded, narrow: isMobile }">
<template v-if="shown">
<div class="head">
<div v-if="!embedded" class="head">
<div class="who">
<a
class="user-link"
@@ -332,7 +342,9 @@ function handleMore(key: string) {
<StatusPill v-else :result="item.result" bare />
<span class="t">{{ attemptTime(item.createTime) }}</span>
</a>
<span v-if="!isMobile && canStep" class="keys"><kbd>←</kbd><kbd>→</kbd> 换一次</span>
<span v-if="!isMobile && !embedded && canStep" class="keys"
><kbd>←</kbd><kbd>→</kbd> 换一次</span
>
</section>
<div v-if="!shown.showLink" class="locked">
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import SubmissionView from "./components/SubmissionView.vue"
/** 单独打开的一条提交,主体在 SubmissionView(课堂看板「讲错误」的弹层也用它) */
defineProps<{ submissionID: string }>()
</script>
<template>
<SubmissionView :id="submissionID" />
</template>