Files
OJ2/apps/web/src/oj/classroom/board.vue
T
xuyueandClaude Opus 5.5 309ca0ece2
Deploy / deploy (push) Canceled after 0s
refactor(前端): 全站共用的班级下拉、胶囊配色挪出提交列表目录,编辑器不再偷读题目页的 store
- classLabel / groupClassOptions / classSelectOptions 挪到 utils/classes.ts,useTone 挪到
  shared/composables/tone.ts:原来放在 oj/submission/ 下,被前台、后台、shared 引了 50 多处
- 流程图编辑器的草稿键改由题目页传进来(键名不变,学生存着的草稿照样读得到)。原来编辑器
  自己读题目页的 store 和路由拼,后台出题页也用这个组件,那边 store 是空的
- SQL 表名补全改由题目页传表进来。原来补全直接读题目页的 store,自学页的 SQL 代码块会
  补出上一道看过的题的表名

实跑:提交列表、题目页、排名、后台题目页、SQL 题、流程图题打开无报错;SQL 题输入 stu
补出 students;流程图加一步,草稿照旧存在 flowchart-v2-problem-<题号>。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:59:55 +08:00

1481 lines
43 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getClassBoard, setClassLesson } from "oj/api"
import { useTone } from "shared/composables/tone"
import { groupClassOptions } from "utils/classes"
import UserName from "shared/components/UserName.vue"
import { useLeaveStudents } from "shared/composables/leaveStudents"
import { useTeacherHelp } from "shared/composables/teacherHelp"
import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "shared/store/config"
import type { LessonLanguage } from "@oj2/contract"
import { errorMessage } from "utils/api"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { parseTime } from "utils/functions"
import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from "utils/types"
import ExplainModal, { type ExplainPerson } from "./components/ExplainModal.vue"
import { errorKind, type ErrorKind } from "./errorKind"
/**
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
*
* 用法(用户说的):开在教师电脑上,不投影;看完成情况、谁卡住了,讲没交对的代码错在哪;
* 题号是上课时当场填的。所以:
* - 顶上一行是班级和这节课的题,题号当场填,填完收成一排小块;
* - 一排题卡片(做对 / 错着 / 没做),点一道题,下面把错着的人按错法归堆 —— 同一种错讲一遍;
* - 全班一张表,按 举手 → 没交过 → 卡住 → 在做 → 做完 → 请假 排;
* - 点红格子或堆里的名字,弹层里讲这份代码(和提交详情同一块),← → 换人。
*
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
* 悄悄留在框里,老师看的整个是别人的班。
*/
/** 同一道题今天交了这么多次还没过,算卡住 */
const STUCK_ATTEMPTS = 3
const REFRESH_MS = 15_000
const configStore = useConfigStore()
const collabStore = useCollabStore()
const message = useMessage()
const theme = useThemeVars()
const tone = useTone()
const router = useRouter()
const route = useRoute()
const { help, peek } = useTeacherHelp()
const board = ref<ClassBoard | null>(null)
/**
* 带着 ?className= 进来的(协作条「帮完了」回来、举手面板「讲这道题」)就看那个班,
* 否则第一次由后端猜
*/
const queryText = (value: unknown) => (typeof value === "string" && value ? value : null)
const className = ref<string | null>(queryText(route.query.className))
/** ?problem= 带来的题号:加载完选中那道题,下面展开它的错法(「讲这道题」) */
let wantedProblem = queryText(route.query.problem)
const classOptions = computed(() => {
const list = configStore.config?.classList ?? []
return groupClassOptions(board.value?.className ? [board.value.className, ...list] : list)
})
async function load() {
if (document.hidden) return
try {
const data = await getClassBoard(className.value ?? undefined)
board.value = data
// 第一次由后端猜,之后就固定在这个班上,别每次刷新都换
className.value = data.className
if (wantedProblem) {
const wanted = wantedProblem.toLowerCase()
const hit = data.problems.find((item) => item.problemDisplayId.toLowerCase() === wanted)
if (hit) selectedId.value = hit.problemId
wantedProblem = null
}
} catch (error) {
message.error(errorMessage(error))
}
}
function changeClass(value: string) {
className.value = value
selectedId.value = null
editing.value = false
load()
}
onMounted(load)
// 已经停在看板上时又从举手面板点了「讲这道题」:同一条路由只换 query,组件不重建
watch(
() => route.query,
(query) => {
wantedProblem = queryText(query.problem)
const next = queryText(query.className)
if (next && next !== className.value) {
className.value = next
editing.value = false
}
load()
},
)
/** 举手多久了,每秒走一格 */
const now = useNow({ scheduler: (cb) => useIntervalFn(cb, 1000) })
function waitedText(createdAt: number) {
const seconds = Math.max(0, Math.floor((now.value.getTime() - createdAt) / 1000))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
}
const { pause } = useIntervalFn(load, REFRESH_MS)
onUnmounted(pause)
// ---------- 这节课的题:上课时当场填 ----------
/**
* 这份作业用什么语言做,跟着这一次布置走(用户定的:不是给班级定死的设置)。默认是这个班
* 上一次布置时选的;选了之后看板和学生那边的「做完」都只认这个语言交对的
*/
const lessonLanguage = ref<LessonLanguage>("Python")
const LESSON_LANGUAGES: LessonLanguage[] = ["Python", "C", "C++", "SQL"]
const languageOptions = LESSON_LANGUAGES.map((value) => ({
label: LANGUAGE_SHOW_VALUE[value],
value,
}))
const lessonInput = ref("")
const editing = ref(false)
const saving = ref(false)
/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */
const needLesson = computed(() => !!board.value?.className && !board.value.problems.length)
// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)
watch(
() => [board.value?.className, board.value?.source] as const,
() => resetLessonInput(),
)
function resetLessonInput() {
lessonInput.value =
board.value?.source === "teacher"
? board.value.problems.map((problem) => problem.problemDisplayId).join(" ")
: ""
lessonLanguage.value = board.value?.language ?? board.value?.lastLanguage ?? "Python"
}
function startEdit() {
resetLessonInput()
// 推断出来的题也带进框里,老师多半只是确认一下
if (!lessonInput.value && board.value?.problems.length) {
lessonInput.value = board.value.problems.map((problem) => problem.problemDisplayId).join(" ")
}
editing.value = true
}
async function saveLesson() {
if (!className.value) return
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
saving.value = true
try {
await setClassLesson(className.value, ids, ids.length ? lessonLanguage.value : null)
message.success(ids.length ? "已布置,学生首页会显示这几道题" : "已清掉")
editing.value = false
selectedId.value = null
await load()
} catch (error) {
message.error(errorMessage(error))
} finally {
saving.value = false
}
}
// ---------- 分组与排序 ----------
type Group = "help" | "idle" | "stuck" | "working" | "done" | "leave"
/**
* 正在课堂求助的学生。列表是老师端 WebSocket 实时推的(collabStore,全局常驻连接),
* 不经看板接口 —— 求助只在 serve 进程的内存里,本来就不落库。所以也只看得到连着
* **本站**的学生:服务器和机房各有各的求助队列,和课堂求助本身一样。
*/
function helpOf(student: ClassBoardStudent) {
return collabStore.requests.find((request) => request.studentId === student.userId) ?? null
}
/**
* 经常不动手:最近几节课里最多只交过一节。至少有 3 节课可比才下结论,
* 开学头两节谁都一样。没交过的人里把他们排在前面,「最后交」那格写上来过几节
*/
function oftenIdle(student: ClassBoardStudent) {
const total = board.value?.recentLessons ?? 0
return total >= 3 && student.recentAttended <= 1
}
/**
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
*/
function unseen(student: ClassBoardStudent) {
return !student.seenAt
}
/** 没在线的人最后一次上线是多久前,「在写」那格用 */
function seenText(seenAt: string) {
const minutes = Math.max(0, Math.floor((now.value.getTime() - Date.parse(seenAt)) / 60_000))
return minutes < 60 ? `${minutes} 分钟前在` : `${Math.floor(minutes / 60)} 小时前在`
}
function doneCount(student: ClassBoardStudent) {
return student.cells.filter((cell) => cell.status === "accepted").length
}
function groupOf(student: ClassBoardStudent): Group {
if (absent(student)) return "leave"
if (helpOf(student)) return "help"
const cells = student.cells
if (cells.length && cells.every((cell) => cell.status === "accepted")) return "done"
if (!student.lastSubmitAt) return "idle"
if (cells.some((cell) => cell.status === "tried" && cell.attempts >= STUCK_ATTEMPTS))
return "stuck"
return "working"
}
const GROUP_ORDER: Record<Group, number> = {
help: 0,
idle: 1,
stuck: 2,
working: 3,
done: 4,
leave: 5,
}
const GROUP_LABEL: Record<Group, string> = {
help: "举手了",
idle: "还没交过",
stuck: "卡住了",
working: "在做",
done: "全做完了",
leave: "请假",
}
/**
* 请假:老师在「请假」框里勾上今天不在的人,人数、进度、卡片都不算他们,
* 表格里变灰排到最后(不藏掉 —— 标了谁、标错没有,一眼看得见)。
* 今天有效,只记在这台电脑上(见 leaveStudents.ts)。
*
* **只有今天还没交过的人能算请假**:请假的人一定在这里面。标过的人后来交了题、或者举手了,
* 就是来了(迟到),自动取消标记,回到正常的分组里 —— 老师得看得见他卡住、举手
*/
const { onLeave, setLeave, cancelLeave } = useLeaveStudents("oj_leave_students")
function canLeave(student: ClassBoardStudent) {
return !student.lastSubmitAt && !helpOf(student)
}
function absent(student: ClassBoardStudent) {
return canLeave(student) && onLeave(student.username)
}
const boardStudents = computed(() => board.value?.students ?? [])
const leaveStudents = computed(() => boardStudents.value.filter(absent).sort(byName))
// 标过请假、后来交了题或举手的:来了,把标记清掉(不然名单里一直挂着一个其实在的人)
watchEffect(() => {
const arrived = boardStudents.value
.filter((student) => !canLeave(student) && onLeave(student.username))
.map((student) => student.username)
if (arrived.length) cancelLeave(arrived)
})
const leaveDialog = ref(false)
/** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */
const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName))
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
const leaveSections = computed(() =>
[
{ label: "今天没上线", students: leaveCandidates.value.filter(unseen) },
{
label: "今天上过线",
students: leaveCandidates.value.filter((student) => !unseen(student)),
},
].filter((section) => section.students.length),
)
const leaveSelected = ref<string[]>([])
function selectAll(list: ClassBoardStudent[]) {
leaveSelected.value = [
...new Set([...leaveSelected.value, ...list.map((student) => student.username)]),
]
}
function openLeaveDialog() {
leaveSelected.value = leaveStudents.value.map((student) => student.username)
leaveDialog.value = true
}
function saveLeave() {
setLeave(
boardStudents.value.map((student) => student.username),
leaveSelected.value,
)
leaveDialog.value = false
}
function byName(a: ClassBoardStudent, b: ClassBoardStudent) {
return a.username.localeCompare(b.username, "zh-CN")
}
const students = computed(() =>
[...boardStudents.value].sort(
(a, b) =>
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
// 同样是没交过,今天没上过线的排最前(多半没来,好标请假),再是经常不动手的
Number(unseen(b)) - Number(unseen(a)) ||
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
// 同样在做,做对得少的排前面(只做了一道的那几个)
(groupOf(a) === "working" ? doneCount(a) - doneCount(b) : 0) ||
byName(a, b),
),
)
/** 到了的人:人数、卡片都按这些算 */
const present = computed(() => students.value.filter((student) => groupOf(student) !== "leave"))
const groupCount = computed(() => {
const count: Record<Group, number> = { help: 0, idle: 0, stuck: 0, working: 0, done: 0, leave: 0 }
for (const student of students.value) count[groupOf(student)]++
return count
})
/** 每组第一个人,在他上面插一行组名 */
const groupStarts = computed(() => {
const starts = new Set<number>()
let last: Group | null = null
for (const student of students.value) {
const group = groupOf(student)
if (group !== last) starts.add(student.userId)
last = group
}
return starts
})
// ---------- 题卡片和「错着的人按错法归堆」 ----------
interface WrongPerson {
student: ClassBoardStudent
cell: ClassBoardCell
kind: ErrorKind
}
/**
* 没做对的人:今天交过代码的按最后一次提交归错法。另外两种没有今天的代码可讲,单独成堆,
* 和卡片上「错着 N」的 N 对得上:今天只画了流程图的,以前交过没做对、今天还没动的
*/
function wrongOf(index: number): WrongPerson[] {
return present.value.flatMap((student) => {
const cell = student.cells[index]
if (!cell || cell.status !== "tried") return []
const kind: ErrorKind = cell.latest
? errorKind(cell.latest)
: cell.attempts
? { key: "flowchart", label: "今天只画了流程图", line: null }
: { key: "before", label: "以前交过,今天还没交", line: null }
return [{ student, cell, kind }]
})
}
const problemStats = computed(() =>
(board.value?.problems ?? []).map((problem, i) => {
const done = present.value.filter((student) => student.cells[i]?.status === "accepted").length
const wrong = present.value.filter((student) => student.cells[i]?.status === "tried").length
const total = present.value.length
return { problem, done, wrong, none: total - done - wrong, total }
}),
)
const selectedId = ref<number | null>(null)
const selectedIndex = computed(() =>
(board.value?.problems ?? []).findIndex((problem) => problem.problemId === selectedId.value),
)
function toggleProblem(problemId: number) {
selectedId.value = selectedId.value === problemId ? null : problemId
}
/** 选中那道题错着的人,按错法归堆:人多的堆在前,堆里次数多的在前 */
const wrongGroups = computed(() => {
if (selectedIndex.value < 0) return []
const groups = new Map<string, { kind: ErrorKind; people: WrongPerson[] }>()
for (const person of wrongOf(selectedIndex.value)) {
const group = groups.get(person.kind.key) ?? { kind: person.kind, people: [] }
group.people.push(person)
groups.set(person.kind.key, group)
}
return [...groups.values()]
.map((group) => ({
...group,
people: group.people.sort((a, b) => b.cell.attempts - a.cell.attempts),
// 一堆人报错指到同一行才写「第 N 行」
line: group.people.every((p) => p.kind.line === group.people[0]!.kind.line)
? group.kind.line
: null,
}))
.sort((a, b) => b.people.length - a.people.length)
})
// ---------- 讲错误的弹层 ----------
const explainShow = ref(false)
const explainIndex = ref(0)
const explainProblemIndex = ref(-1)
const explainProblem = computed(() => board.value?.problems[explainProblemIndex.value] ?? null)
/** 弹层顶上那排人:这道题错着的人,顺序和错法堆一样 */
const explainPeople = computed<ExplainPerson[]>(() => {
if (explainProblemIndex.value < 0) return []
const people = wrongOf(explainProblemIndex.value).filter((person) => person.cell.latest)
const order = new Map<string, number>()
for (const person of people) {
order.set(person.kind.key, (order.get(person.kind.key) ?? 0) + 1)
}
return people
.sort(
(a, b) =>
order.get(b.kind.key)! - order.get(a.kind.key)! ||
a.kind.key.localeCompare(b.kind.key) ||
b.cell.attempts - a.cell.attempts,
)
.map((person) => ({
userId: person.student.userId,
username: person.student.username,
submissionId: person.cell.latest!.submissionId,
label: person.kind.label,
}))
})
function openExplain(problemIndex: number, userId: number) {
explainProblemIndex.value = problemIndex
explainIndex.value = Math.max(
0,
explainPeople.value.findIndex((person) => person.userId === userId),
)
explainShow.value = true
}
// ---------- 格子 ----------
/** 今天之前就通过了的,颜色淡一档 */
function acceptedToday(cell: ClassBoardCell) {
return !!cell.acceptedAt && parseTime(cell.acceptedAt, "YYYY-MM-DD") === board.value?.day
}
function cellText(cell: ClassBoardCell) {
if (cell.status === "accepted") return "✓"
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts}` : "✗"
return ""
}
function cellStyle(cell: ClassBoardCell) {
if (cell.status === "accepted") {
const t = tone("success")
return acceptedToday(cell)
? { background: t.background, color: t.color }
: { background: t.background, color: t.color, opacity: 0.5 }
}
if (cell.status === "tried") {
if (cell.attempts >= STUCK_ATTEMPTS) {
return { background: theme.value.errorColor, color: "#ffffff" }
}
const t = tone("warning")
return { background: t.background, color: t.color }
}
return { background: theme.value.actionColor }
}
function cellTitle(cell: ClassBoardCell, student: ClassBoardStudent) {
if (cell.status === "accepted") {
return acceptedToday(cell) ? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对` : "以前就做对了"
}
if (cell.status === "tried") {
if (cell.latest) return `讲讲 ${student.username} 这道题的错`
return cell.attempts ? `今天画了 ${cell.flowchartAttempts} 张流程图` : "以前交过,没做对"
}
return ""
}
/**
* 点格子 / 名字到提交列表看他今天交的。新标签打开:看板一般开着不关,别把它顶掉。
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)。
*
* 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时,
* 点进去是一片「没有符合条件的提交」
*/
function submissionsHref(
student: ClassBoardStudent,
problem: ClassBoardProblem | undefined,
flowchart: boolean,
/** 「以前交过、今天还没交」的人要看以前的,不能只列今天 */
today = true,
) {
// SQL 题不受布置的语言限制,格子数的是 SQL 交的
const language = problem?.isSql ? "SQL" : board.value?.language
return router.resolve({
name: "submissions",
query: {
username: student.username,
exactUsername: "1",
...(today ? { today: "1" } : {}),
// 布置时选了语言的,代码那边只看这个语言交的(和格子里数的一致)
...(flowchart ? { language: "Flowchart" } : language ? { language } : {}),
...(problem ? { problem: problem.problemDisplayId } : {}),
},
}).href
}
function lastText(student: ClassBoardStudent) {
if (student.lastSubmitAt) return parseTime(student.lastSubmitAt, "HH:mm")
if (oftenIdle(student)) {
return `近 ${board.value?.recentLessons} 节交过 ${student.recentAttended} 节`
}
return "—"
}
</script>
<template>
<div class="board">
<div class="toolbar">
<n-select
:value="className"
:options="classOptions"
placeholder="选择班级"
filterable
class="class-select"
@update:value="changeClass"
/>
<!-- 这节课的题:当场填,填完收成一排小块 -->
<div v-if="editing" class="lesson-edit">
<n-select
v-model:value="lessonLanguage"
:options="languageOptions"
aria-label="用什么语言做"
class="lang-select"
/>
<n-input
v-model:value="lessonInput"
placeholder="这节课的题号,用空格隔开"
clearable
autofocus
@keyup.enter="saveLesson"
/>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
<n-button @click="editing = false">取消</n-button>
</div>
<div v-else-if="board?.problems.length" class="lesson">
<span class="lesson-label">{{ board.source === "teacher" ? "这节课" : "今天在做" }}</span>
<span v-if="board.language" class="chip lang">{{
LANGUAGE_SHOW_VALUE[board.language]
}}</span>
<span v-for="problem in board.problems" :key="problem.problemId" class="chip">
{{ problem.problemDisplayId }}
</span>
<span v-if="board.assignedAt" class="assigned">
{{ parseTime(board.assignedAt, "HH:mm") }} 布置
</span>
<n-button text class="edit" @click="startEdit">
<template #icon><Icon icon="ph:pencil-simple" /></template>
{{ board.source === "teacher" ? "改" : "布置" }}
</n-button>
</div>
<div class="spacer"></div>
<n-button
v-if="board?.className"
:type="leaveStudents.length ? 'warning' : 'default'"
:secondary="!!leaveStudents.length"
@click="openLeaveDialog"
>
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : "请假" }}
</n-button>
<!-- 看板只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })">
回头看 →
</n-button>
</div>
<n-empty
v-if="board && !board.className"
description="最近两小时没有哪个班在交题,先在上面选一个班"
style="margin: 60px 0"
/>
<template v-else-if="board">
<!-- 刚上课还没有题:填题号的框是这页最大的东西 -->
<section v-if="needLesson" class="start">
<div class="start-title">这节课做哪几道题?</div>
<div class="start-row">
<n-select
v-model:value="lessonLanguage"
:options="languageOptions"
aria-label="用什么语言做"
size="large"
class="lang-select"
/>
<n-input
v-model:value="lessonInput"
size="large"
placeholder="输入题号,用空格隔开"
class="start-input"
@keyup.enter="saveLesson"
/>
<n-button type="primary" size="large" :loading="saving" @click="saveLesson">
布置
</n-button>
</div>
</section>
<template v-else>
<div class="cards">
<button
v-for="stat in problemStats"
:key="stat.problem.problemId"
type="button"
class="card"
:class="{ on: stat.problem.problemId === selectedId }"
@click="toggleProblem(stat.problem.problemId)"
>
<span class="card-title">
<b>{{ stat.problem.problemDisplayId }}</b>
<span class="muted">{{ stat.problem.title }}</span>
</span>
<span class="card-done">
<b>{{ stat.done }}</b
><span class="muted">/{{ stat.total }} 做对</span>
</span>
<span class="bar">
<i :style="{ width: stat.total ? `${(stat.done / stat.total) * 100}%` : '0' }"></i>
</span>
<span class="card-foot">
<span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
<span class="muted">没做 {{ stat.none }}</span>
</span>
</button>
</div>
<section v-if="selectedIndex >= 0" class="panel">
<div class="panel-head">
<b class="panel-title">
<span class="muted">{{ board.problems[selectedIndex]!.problemDisplayId }}</span>
{{ board.problems[selectedIndex]!.title }}
</b>
<span>
错着的 <b class="wrong">{{ problemStats[selectedIndex]!.wrong }}</b> 人
</span>
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
<div class="spacer"></div>
<n-button
quaternary
size="small"
class="panel-close"
aria-label="收起"
@click="selectedId = null"
>
<template #icon><Icon icon="ph:x-bold" /></template>
</n-button>
</div>
<div v-if="wrongGroups.length" class="groups">
<div
v-for="group in wrongGroups"
:key="group.kind.key"
class="err-group"
:class="{ quiet: group.kind.key === 'before' || group.kind.key === 'flowchart' }"
>
<div class="err-title">
{{ group.kind.label }}
<span class="wrong">{{ group.people.length }} 人</span>
<span v-if="group.line" class="muted"> · 第 {{ group.line }} 行</span>
</div>
<div class="err-people">
<template v-for="person in group.people" :key="person.student.userId">
<button
v-if="person.cell.latest"
type="button"
class="who"
@click="openExplain(selectedIndex, person.student.userId)"
>
<UserName :username="person.student.username" />
<span class="mini" :style="cellStyle(person.cell)"
>✗ {{ person.cell.attempts }}</span
>
</button>
<!-- 没有今天的代码可讲:去提交列表看他交过的 -->
<a
v-else
class="who"
:href="
submissionsHref(
person.student,
board.problems[selectedIndex],
!!person.cell.attempts,
!!person.cell.attempts,
)
"
target="_blank"
>
<UserName :username="person.student.username" />
</a>
</template>
</div>
</div>
</div>
<div v-else class="muted">这道题现在没有人错着</div>
</section>
</template>
<div class="table-wrap">
<table class="grid">
<thead>
<tr>
<th class="name">学生</th>
<th class="narrow-col where-col">在线</th>
<th
v-for="(stat, i) in problemStats"
:key="stat.problem.problemId"
class="col"
:class="{ hl: i === selectedIndex }"
:style="{ width: `${100 / problemStats.length}%` }"
@click="toggleProblem(stat.problem.problemId)"
>
<div class="col-id">{{ stat.problem.problemDisplayId }}</div>
<div class="col-title" :title="stat.problem.title">
{{ stat.problem.title }}
</div>
<div class="col-count">{{ stat.done }}/{{ stat.total }}</div>
<div class="bar">
<i
:style="{ width: stat.total ? `${(stat.done / stat.total) * 100}%` : '0' }"
></i>
</div>
</th>
<th class="narrow-col">做对</th>
<th class="narrow-col">最后交</th>
</tr>
</thead>
<tbody>
<template v-for="student in students" :key="student.userId">
<tr v-if="groupStarts.has(student.userId)" class="group-row">
<td :colspan="board.problems.length + 4">
<span class="group-label">
{{ GROUP_LABEL[groupOf(student)] }}
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
</span>
</td>
</tr>
<tr :class="{ 'leave-row': groupOf(student) === 'leave' }">
<td class="name">
<a
v-if="student.codeToday || student.drawnToday"
class="plain"
:href="submissionsHref(student, undefined, !student.codeToday)"
target="_blank"
:title="`看 ${student.username} 今天交的全部`"
>
<UserName :username="student.username" />
</a>
<UserName v-else :username="student.username" />
<span v-if="helpOf(student)?.status === 'pending'" class="waited">
举手 {{ waitedText(helpOf(student)!.createdAt) }}
</span>
</td>
<!-- 在线:举手的「帮他」;别人在帮的写谁在帮;开着题目的点题号直接打开他正在写的
代码(他没举手也行,他那边会显示「xxx 在看你的代码」);剩下的写在不在线、
多久前在、今天没上过线 -->
<td class="where-col">
<n-button
v-if="helpOf(student)?.status === 'pending'"
type="primary"
size="tiny"
@click="help(student.userId)"
>
帮他 · {{ helpOf(student)!.problemId }}
</n-button>
<span v-else-if="helpOf(student)" class="helping">
{{ helpOf(student)!.teacherName }} 在帮
</span>
<button
v-else-if="student.editing"
type="button"
class="where"
:title="`打开 ${student.username} 正在写的 ${student.editing}`"
@click="peek(student.userId)"
>
{{ student.editing }}
</button>
<span v-else-if="student.online" class="present">在线</span>
<span v-else-if="student.seenAt" class="seen">
{{ seenText(student.seenAt) }}
</span>
<span v-else class="seen">今天没上线</span>
</td>
<td
v-for="(cell, i) in student.cells"
:key="i"
:class="{ hl: i === selectedIndex }"
>
<button
v-if="cell.status === 'tried' && cell.latest"
type="button"
class="cell"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
@click="openExplain(i, student.userId)"
>
{{ cellText(cell) }}
</button>
<a
v-else-if="cell.attempts"
class="cell"
:style="cellStyle(cell)"
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
target="_blank"
:title="cellTitle(cell, student)"
>
{{ cellText(cell) }}
</a>
<span
v-else
class="cell"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
>
{{ cellText(cell) }}
</span>
</td>
<td class="num">{{ doneCount(student) }}/{{ board.problems.length }}</td>
<td class="num muted">{{ lastText(student) }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<ExplainModal
v-model:show="explainShow"
v-model:index="explainIndex"
:problem="explainProblem"
:people="explainPeople"
/>
<n-modal
v-model:show="leaveDialog"
preset="card"
:title="
leaveCandidates.length
? `今天还没交过的 ${leaveCandidates.length} 人里,谁请假了?`
: '今天全班都交过题了'
"
style="width: 640px; max-width: calc(100vw - 32px)"
>
<n-checkbox-group v-if="leaveCandidates.length" v-model:value="leaveSelected">
<div v-for="section in leaveSections" :key="section.label" class="leave-section">
<div v-if="leaveSections.length > 1 || unseen(section.students[0])" class="leave-head">
{{ section.label }} {{ section.students.length }} 人
<n-button
v-if="unseen(section.students[0])"
text
type="primary"
:disabled="
section.students.every((student) => leaveSelected.includes(student.username))
"
@click="selectAll(section.students)"
>
全勾上
</n-button>
</div>
<div class="leave-grid">
<n-checkbox
v-for="student in section.students"
:key="student.userId"
:value="student.username"
:label="student.username"
class="leave-option"
/>
</div>
</div>
</n-checkbox-group>
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
<template #footer>
<n-flex align="center" :wrap="false">
<n-text depth="3" class="meta" style="flex-grow: 1">
已选 {{ leaveSelected.length }} 人 · 只记在这台电脑上,今天有效
</n-text>
<n-button :disabled="!leaveSelected.length" @click="leaveSelected = []">
全部取消
</n-button>
<n-button @click="leaveDialog = false">取消</n-button>
<n-button type="primary" @click="saveLeave">确定</n-button>
</n-flex>
</template>
</n-modal>
</div>
</template>
<style scoped>
.board {
max-width: 1286px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-top: 8px;
}
.muted {
color: v-bind("theme.textColor3");
}
.wrong {
color: v-bind("theme.errorColor");
font-weight: 600;
}
.spacer {
flex: 1 1 0;
}
/* ---------- 顶上一行 ---------- */
.toolbar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.class-select {
width: 160px;
}
.lang-select {
width: 110px;
flex-shrink: 0;
}
.lesson,
.lesson-edit {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.lesson-edit {
flex: 1 1 520px;
gap: 8px;
}
.lesson-label {
margin-right: 4px;
font-size: 14px;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.chip {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 8px;
border-radius: 4px;
background: v-bind("theme.actionColor");
font-size: 13px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.chip.lang {
background: v-bind("tone('success').background");
color: v-bind("tone('success').color");
font-weight: 600;
}
.assigned {
margin-left: 4px;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.edit {
margin-left: 4px;
font-size: 14px;
color: v-bind("theme.textColor2");
}
/* ---------- 刚上课 ---------- */
.start {
padding: 22px 24px;
border-radius: 10px;
border: 1px solid v-bind("tone('success').background");
background: v-bind("theme.cardColor");
display: flex;
flex-direction: column;
gap: 14px;
}
.start-title {
font-size: 18px;
font-weight: 600;
}
.start-row {
display: flex;
align-items: center;
gap: 10px;
}
.start-input {
flex: 1 1 0;
font-size: 18px;
}
/* ---------- 题卡片 ---------- */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
font: inherit;
text-align: left;
min-width: 0;
cursor: pointer;
}
.card:hover {
border-color: v-bind("theme.borderColor");
}
.card.on {
border-color: v-bind("theme.successColor");
box-shadow: inset 0 0 0 1px v-bind("theme.successColor");
}
.card-title {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-title b {
margin-right: 4px;
font-variant-numeric: tabular-nums;
}
.card-done {
display: flex;
align-items: baseline;
gap: 2px;
font-size: 13px;
}
.card-done b {
font-size: 22px;
line-height: 28px;
font-variant-numeric: tabular-nums;
}
.card-foot {
display: flex;
gap: 10px;
font-size: 13px;
}
.bar {
display: block;
height: 4px;
border-radius: 2px;
background: v-bind("theme.actionColor");
overflow: hidden;
}
.bar i {
display: block;
height: 4px;
background: v-bind("theme.successColor");
}
/* ---------- 点开一道题:错着的人按错法归堆 ---------- */
.panel {
padding: 14px 16px;
border-radius: 10px;
border: 1px solid v-bind("tone('success').background");
background: v-bind("theme.cardColor");
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-head {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
}
.panel-title {
font-size: 16px;
}
.groups {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
}
.err-group {
min-width: 260px;
padding: 10px 14px;
border-radius: 8px;
background: v-bind("tone('error').background");
display: flex;
flex-direction: column;
gap: 8px;
}
/* 今天没有代码可讲的两堆(以前交过 / 只画了流程图)不是今天的错,不用红底 */
.err-group.quiet {
background: v-bind("theme.actionColor");
}
.err-title {
font-size: 15px;
font-weight: 600;
}
.err-people {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.who {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.cardColor");
color: v-bind("theme.textColor1");
font: inherit;
font-size: 14px;
white-space: nowrap;
cursor: pointer;
}
a.who {
text-decoration: none;
}
.who:hover {
border-color: v-bind("theme.borderColor");
}
.mini {
height: 22px;
padding: 0 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
font-variant-numeric: tabular-nums;
}
/* ---------- 全班一张表 ---------- */
.table-wrap {
overflow-x: auto;
}
.grid {
border-collapse: separate;
border-spacing: 0;
width: 100%;
font-size: var(--oj-fs-body);
}
.grid th {
padding: 8px 4px;
font-weight: 400;
vertical-align: bottom;
border-bottom: 1px solid v-bind("theme.dividerColor");
white-space: nowrap;
}
.grid td {
height: 40px;
padding: 0 4px;
text-align: center;
border-bottom: 1px solid v-bind("theme.dividerColor");
white-space: nowrap;
}
/* 名字、在线、做对、最后交这几列按内容收紧(width: 1% 是表格收到最窄的写法),
剩下的宽度题目列平分(模板里按题数给百分比),题名才写得开 */
.grid .name {
width: 1%;
padding-left: 12px;
padding-right: 24px;
text-align: left;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.grid td.name {
font-size: inherit;
color: inherit;
}
/* 题多了表格要横着滑,名字钉在左边,不然滑过去就不知道是谁的格子 */
.grid .name {
position: sticky;
left: 0;
z-index: 1;
background: v-bind("theme.cardColor");
}
.grid th.col {
min-width: 110px;
cursor: pointer;
}
.grid th.narrow-col {
width: 1%;
padding: 8px 12px;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.grid .hl {
background: v-bind("tone('success').background");
}
.col-id {
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* 题名写全,长的折成两行;题名中位 7 个字、九成在 13 个字以内,两行放得下 */
.col-title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
max-width: 180px;
margin: 0 auto;
overflow: hidden;
white-space: normal;
line-height: 1.4;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.col-count {
margin: 2px 0 4px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.grid th .bar {
max-width: 160px;
margin: 0 auto;
}
.group-row td {
height: 34px;
padding-left: 12px;
text-align: left;
font-size: 13px;
font-weight: 600;
background: v-bind("theme.actionColor");
}
/* 和名字一样钉在左边:横着滑过去也看得见这一组是什么 */
.group-label {
position: sticky;
left: 12px;
}
.group-row .muted {
margin-left: 4px;
font-weight: 400;
}
.leave-row td {
opacity: 0.45;
}
.cell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 60px;
height: 28px;
box-sizing: border-box;
border: 0;
border-radius: 5px;
font: inherit;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-decoration: none;
vertical-align: middle;
}
button.cell,
a.cell {
cursor: pointer;
}
.num {
font-variant-numeric: tabular-nums;
}
.plain {
color: inherit;
text-decoration: none;
}
.plain:hover {
text-decoration: underline;
}
.waited {
margin-left: 8px;
font-size: 13px;
font-variant-numeric: tabular-nums;
color: v-bind("tone('error').color");
}
.grid .where-col {
width: 1%;
padding-right: 24px;
text-align: left;
}
.where {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-size: 14px;
font-variant-numeric: tabular-nums;
color: v-bind("tone('success').color");
cursor: pointer;
}
.present {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 13px;
color: v-bind("tone('success').color");
}
.seen {
font-size: 13px;
white-space: nowrap;
color: v-bind("theme.textColor3");
}
.where::before,
.present::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.where:hover {
text-decoration: underline;
}
.helping {
font-size: 13px;
font-weight: 600;
color: v-bind("tone('info').color");
}
.leave-section + .leave-section {
margin-top: 18px;
}
.leave-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
font-weight: 600;
}
.leave-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px 8px;
}
.leave-option {
font-size: 15px;
}
.meta {
font-size: var(--oj-fs-sec);
}
/* ---------- 手机 ---------- */
@media (max-width: 768px) {
/* 题名占一行(收起钉在右边),人数挪到第二行,别把「人」挤到下一行去 */
.panel-head {
flex-wrap: wrap;
gap: 6px 12px;
}
.panel-head > span {
order: 2;
white-space: nowrap;
}
.panel-title {
flex: 1 1 calc(100% - 50px);
min-width: 0;
}
.panel-head .spacer {
display: none;
}
.panel-close {
order: 1;
}
.err-group {
min-width: 0;
flex: 1 1 100%;
box-sizing: border-box;
}
.start-row {
flex-wrap: wrap;
}
.start-input {
flex-basis: 100%;
order: -1;
}
.grid .name {
padding-left: 4px;
padding-right: 8px;
}
.grid th.col {
min-width: 64px;
}
.cell {
width: 48px;
}
}
</style>