Files
OJ2/apps/web/src/oj/classroom/board.vue
T
xuyueandClaude Opus 5.5 1a173f75d7
Deploy / deploy (push) Canceled after 0s
feat(挑战): 这节课做完了给「还能挑战」,挑战中编辑器顶上一条紫色的,老师看板上看得出谁在挑战
做完这节课(或题库里自己做对)之后列出还能挑战的题;去挑战后编辑器顶上写着要求,
没做到标出是哪几行,做到了写本班第几个、全站第几个。课堂看板做到的格子贴一枚紫色筹码、
在挑战的加一圈紫边,题卡片写挑战成功几人。出题页多一块「挑战」,能从做对的代码里找候选。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:52:50 +08:00

1406 lines
41 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 } 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 { 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 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"
/**
* 课堂看板(老师,设计稿「课堂看板重设计」A+B):这个班、今天、这节课的几道题 × 全班学生。
*
* 用法(用户说的):开在教师电脑上,不投影;看完成情况、谁卡住了,讲没交对的代码错在哪;
* 题号是上课时当场填的。所以:
* - 顶上一行是班级和这节课的题,题号当场填,填完收成一排小块;
* - 一排题卡片(做对 / 错着 / 没做),点一道题,下面把错着的人按错法归堆 —— 同一种错讲一遍;
* - 全班一张表,按 举手 → 没交过 → 卡住 → 在做 → 做完 → 请假 排;
* - 点红格子或堆里的名字,弹层里讲这份代码(和提交详情同一块),← → 换人。
*
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 记本地的话,上一节课的班会
* 悄悄留在框里,老师看的整个是别人的班。
*/
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 editing = ref(false)
/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */
const needLesson = computed(() => !!board.value?.className && !board.value.problems.length)
async function lessonSaved() {
editing.value = false
selectedId.value = null
await load()
}
// ---------- 分组与排序 ----------
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))
function saveLeave(selected: string[]) {
setLeave(
boardStudents.value.map((student) => student.username),
selected,
)
}
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
})
/**
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 classBoardCellSchema.challenge)。
* 做完了又没在挑战的,就是空着的
*/
const challengingCount = computed(
() =>
students.value.filter(
(student) =>
groupOf(student) === "done" && student.cells.some((cell) => cell.challenge === "doing"),
).length,
)
/** 每组第一个人,在他上面插一行组名 */
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
// 挑战成功几人:只有配了挑战的题才写
const challenged = problem.hasChallenges
? present.value.filter((student) => student.cells[i]?.challenge === "done").length
: null
return { problem, done, wrong, none: total - done - wrong, total, challenged }
}),
)
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
}
// ---------- 格子(显示规则在 boardCells.ts) ----------
const cellColors = computed(() => ({
tone,
errorColor: theme.value.errorColor,
actionColor: theme.value.actionColor,
}))
const cellStyle = (cell: ClassBoardCell) => cellStyleOf(cell, board.value?.day, cellColors.value)
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)。
*
* 今天只画了流程图、没写代码的,跳到列表的「流程图」那边 —— 不然这节课整班在画流程图时,
* 点进去是一片「没有符合条件的提交」
*/
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"
/>
<!-- 这节课的题:当场填,填完收成一排小块 -->
<LessonForm
v-if="editing && board && className"
:board="board"
:class-name="className"
variant="inline"
class="lesson-edit"
@saved="lessonSaved"
@cancel="editing = false"
/>
<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") }} {{ board.assignedBy }} 布置
</span>
<n-button text type="primary" class="edit" @click="editing = true">
<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="leaveDialog = true"
>
{{ 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>
<LessonForm
v-if="className"
:board="board"
:class-name="className"
variant="start"
class="start-row"
@saved="lessonSaved"
/>
</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 v-if="stat.challenged !== null" class="challenged">
挑战成功 {{ stat.challenged }}
</span>
</span>
</button>
</div>
<section v-if="selectedIndex >= 0" class="panel">
<div class="panel-head">
<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 }}
</a>
<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 v-if="groupOf(student) === 'done' && challengingCount" class="challenged">
在挑战 {{ challengingCount }}
</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"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
@click="openExplain(i, student.userId)"
>
{{ 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"
:data-challenge="cell.challenge ?? undefined"
: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"
:data-challenge="cell.challenge ?? undefined"
: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"
:data-challenge="cell.challenge ?? undefined"
: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"
/>
<LeaveModal
v-model:show="leaveDialog"
:candidates="leaveCandidates"
:current="leaveStudents.map((student) => student.username)"
@save="saveLeave"
/>
</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;
}
.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;
}
/* ---------- 题卡片 ---------- */
.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;
font-weight: 600;
color: v-bind("theme.primaryColor");
text-decoration: none;
}
.panel-title:hover {
text-decoration: underline;
}
.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;
}
/* 挑战:做到了贴一枚紫色筹码,在挑战加一圈紫边(设计稿 StkBoard) */
.cell[data-challenge="done"] {
position: relative;
}
.cell[data-challenge="done"]::after {
content: "";
position: absolute;
right: -6px;
top: -6px;
width: 14px;
height: 14px;
border-radius: 50%;
box-sizing: border-box;
background: #5b34c4;
border: 3px solid #b49cf5;
box-shadow: 0 0 0 2px v-bind("theme.cardColor");
}
.cell[data-challenge="doing"] {
box-shadow:
0 0 0 2px v-bind("theme.cardColor"),
0 0 0 4px #8f72e3;
}
.challenged {
color: #6a45d6;
font-weight: 600;
}
.cell.picked {
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
}
.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");
}
/* ---------- 手机 ---------- */
@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;
}
.grid .name {
padding-left: 4px;
padding-right: 8px;
}
.grid th.col {
min-width: 64px;
}
.cell {
width: 48px;
}
}
</style>