feat(课堂): 老师的课堂看板——布置这节课的题、看全班谁还没开始谁卡住了,布置的题直接出现在学生首页

2025 秋平均每节课有 14 个平时在用的学生一道都没交(三分之一),交了 3 次以上还没过的
平均不到 1 个;而老师原来只能一道题一道题打开统计弹窗看。

- 新表 class_lesson(迁移 0023,仅建表):一个班一天一行,存老师布置的 problem.id 与顺序;
  day 存东八区日历日文本,created_by CASCADE
- GET /classroom/board:这个班今天的题 × 全班学生(通过与否不限今天、今天交了几次、
  最后提交时间);不给班级时猜最近两小时提交人数最多的班,不记老师上次选的
- PUT /classroom/lesson:按展示题号布置,对不上的题号点名拒绝,空数组 = 清掉
- /me/class-activity 优先返回老师布置的题(source = teacher),否则照旧推断
- 前端 /classroom:没交过的名单置顶,按 没交过 → 卡住 → 在做 → 做完 排序,15 秒刷新;
  投影模式只显示每题完成人数、不出名字;用户菜单给老师加入口
- 学生首页:老师布置的标题写「今天老师布置的题」,没人交时不显示 0/0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 22:37:59 +08:00
1 parent 7e964fdbdd
commit 80106eaa04
12 files changed
+4992 -69

No files matched your search

+11
View File
@@ -9,6 +9,7 @@ import {
type Metrics,
type TutorialSummary,
type ClassActivity,
type ClassBoard,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
@@ -217,6 +218,16 @@ export function getWeeklyRank(scope: "global" | "class") {
return api.get<WeeklyRank>("rankings/weekly", { params: { scope } })
}
/** 课堂看板(老师)。不给班级时后端猜最近两小时在交题的那个班 */
export function getClassBoard(className?: string) {
return api.get<ClassBoard>("classroom/board", { params: { className } })
}
/** 给这个班布置今天的题,空数组 = 清掉 */
export function setClassLesson(className: string, problemDisplayIds: string[]) {
return api.put<null>("classroom/lesson", { className, problemDisplayIds })
}
export function getClassActivity() {
return api.get<ClassActivity>("me/class-activity")
}
+405
View File
@@ -0,0 +1,405 @@
<script setup lang="ts">
import { getClassBoard, setClassLesson } from "oj/api"
import { useConfigStore } from "shared/store/config"
import { errorMessage } from "utils/api"
import { parseTime } from "utils/functions"
import type { ClassBoard, ClassBoardCell, ClassBoardStudent } from "utils/types"
/**
* 课堂看板(老师):这个班、今天、这节课的几道题 × 全班学生。
*
* 重点是「今天还没交过」的那批人,不是「卡住的」:2025 秋平均每节课有 14 个平时在用的
* 学生一道都没交(占三分之一),交了 3 次以上还没过的平均不到 1 个。所以排序是
* 没交过 → 卡住 → 在做 → 做完,没交过的名单还单独摆在最上面,老师扫一眼就能走过去。
*
* 班级**不记本地**,默认由后端猜最近两小时在交题的班 —— 理由见 StatisticsPanel.vue
* 那段注释(上一节课的班悄悄留在框里,老师看的整个是别人的班)。
*/
/** 同一道题今天交了这么多次还没过,算卡住 */
const STUCK_ATTEMPTS = 3
const REFRESH_MS = 15_000
const configStore = useConfigStore()
const message = useMessage()
const board = ref<ClassBoard | null>(null)
const className = ref<string | null>(null)
const loading = ref(false)
const updatedAt = ref<Date | null>(null)
const projector = ref(false)
const lessonInput = ref("")
const saving = ref(false)
function classLabel(name: string) {
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name
}
const classOptions = computed(() => {
const list = configStore.config?.classList ?? []
const names =
board.value?.className && !list.includes(board.value.className)
? [board.value.className, ...list]
: list
return names.map((name) => ({ label: classLabel(name), value: name }))
})
async function load() {
if (document.hidden) return
loading.value = true
try {
const data = await getClassBoard(className.value ?? undefined)
board.value = data
// 第一次由后端猜,之后就固定在这个班上,别每次刷新都换
className.value = data.className
updatedAt.value = new Date()
} catch (error) {
message.error(errorMessage(error))
} finally {
loading.value = false
}
}
// 换了班要把输入框换成那个班今天布置的题
watch(
() => [board.value?.className, board.value?.source] as const,
() => {
lessonInput.value =
board.value?.source === "teacher"
? board.value.problems.map((problem) => problem.problemDisplayId).join(" ")
: ""
},
)
function changeClass(value: string) {
className.value = value
load()
}
async function saveLesson() {
if (!className.value) return
const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean)
saving.value = true
try {
await setClassLesson(className.value, ids)
message.success(ids.length ? "已布置,学生首页会显示这几道题" : "已清掉,学生那边改回自动推断")
await load()
} catch (error) {
message.error(errorMessage(error))
} finally {
saving.value = false
}
}
onMounted(load)
const { pause } = useIntervalFn(load, REFRESH_MS)
onUnmounted(pause)
// ---------- 分组与排序 ----------
type Group = "idle" | "stuck" | "working" | "done"
function groupOf(student: ClassBoardStudent): Group {
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> = { idle: 0, stuck: 1, working: 2, done: 3 }
function nameOf(student: ClassBoardStudent) {
return student.realName || student.username
}
const students = computed(() =>
[...(board.value?.students ?? [])].sort(
(a, b) =>
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
nameOf(a).localeCompare(nameOf(b), "zh-CN"),
),
)
const idle = computed(() => students.value.filter((student) => groupOf(student) === "idle"))
const stuckCount = computed(
() => students.value.filter((student) => groupOf(student) === "stuck").length,
)
const summary = computed(() => {
const all = board.value?.students ?? []
return (board.value?.problems ?? []).map((problem, i) => ({
...problem,
done: all.filter((student) => student.cells[i]?.status === "accepted").length,
total: all.length,
}))
})
/** 今天之前就通过了的,显示「之前」而不是一个日期 */
function cellText(cell: ClassBoardCell) {
if (cell.status === "accepted") {
const day = cell.acceptedAt ? parseTime(cell.acceptedAt, "YYYY-MM-DD") : ""
return day === board.value?.day ? `✓ ${parseTime(cell.acceptedAt!, "HH:mm")}` : "✓ 之前"
}
if (cell.status === "tried") return cell.attempts ? `✗ ${cell.attempts} 次` : "✗ 之前"
return "·"
}
function cellClass(cell: ClassBoardCell) {
if (cell.status === "accepted") return "cell-done"
if (cell.status === "tried") return cell.attempts >= STUCK_ATTEMPTS ? "cell-stuck" : "cell-tried"
return "cell-none"
}
const GROUP_LABEL: Record<
Group,
{ text: string; type: "default" | "error" | "warning" | "info" | "success" }
> = {
idle: { text: "没交过", type: "default" },
stuck: { text: "卡住了", type: "error" },
working: { text: "在做", type: "info" },
done: { text: "做完了", type: "success" },
}
</script>
<template>
<div class="board" :class="{ projector }">
<n-flex align="center" justify="space-between" class="toolbar">
<n-flex align="center">
<h2 class="title">课堂看板</h2>
<n-select
:value="className"
:options="classOptions"
placeholder="选择班级"
filterable
style="width: 180px"
@update:value="changeClass"
/>
</n-flex>
<n-flex align="center">
<n-text depth="3" class="meta">
每 15 秒刷新<template v-if="updatedAt">
· {{ parseTime(updatedAt, "HH:mm:ss") }}</template
>
</n-text>
<n-button :loading="loading" @click="load">刷新</n-button>
<n-button :type="projector ? 'primary' : 'default'" @click="projector = !projector">
{{ projector ? "退出投影" : "投影模式" }}
</n-button>
</n-flex>
</n-flex>
<n-empty
v-if="board && !board.className"
description="最近两小时没有哪个班在交题,先在上面选一个班"
style="margin: 60px 0"
/>
<template v-else-if="board">
<!-- 布置题目:投影时收起来 -->
<n-card v-if="!projector" size="small" class="section">
<n-flex align="center" :wrap="false">
<n-text strong style="flex-shrink: 0">这节课的题</n-text>
<n-input
v-model:value="lessonInput"
placeholder="输入题号,用空格隔开,比如 8019 8020 8021"
clearable
@keyup.enter="saveLesson"
/>
<n-button type="primary" :loading="saving" @click="saveLesson">布置</n-button>
</n-flex>
<n-text depth="3" class="meta">
<template v-if="board.source === 'teacher'">
学生首页的「班里在做」显示的就是这几道。清空再点布置就改回自动推断。
</template>
<template v-else-if="board.source === 'inferred'">
还没布置。下面这几道是按今天的提交记录推断的(同班 5 人以上做过的题)。
</template>
<template v-else>
布置之后,学生打开首页就能看到这几道题,没听清题号的也能找到。
</template>
</n-text>
</n-card>
<n-empty
v-if="!board.problems.length"
description="还没有题:在上面输入这节课的题号"
style="margin: 40px 0"
/>
<template v-else>
<!-- 每道题的完成度。投影模式下只剩这一块,不出现名字 -->
<div class="summary">
<div v-for="problem in summary" :key="problem.problemId" class="summary-item">
<div class="summary-title">{{ problem.problemDisplayId }} {{ problem.title }}</div>
<n-progress
type="line"
status="success"
:percentage="problem.total ? Math.round((problem.done / problem.total) * 100) : 0"
:show-indicator="false"
:height="projector ? 28 : 10"
/>
<div class="summary-count">{{ problem.done }} / {{ problem.total }} 人做完</div>
</div>
</div>
<template v-if="!projector">
<n-alert
v-if="idle.length"
type="warning"
:title="`今天还没交过(${idle.length} 人)`"
class="section"
>
{{ idle.map(nameOf).join("、") }}
</n-alert>
<n-text v-if="stuckCount" type="error" class="meta">
有 {{ stuckCount }} 人卡住了(同一道题今天交了
{{ STUCK_ATTEMPTS }} 次以上还没过),排在表格最前面
</n-text>
<div class="table-wrap">
<table class="grid">
<thead>
<tr>
<th class="name">学生</th>
<th>状态</th>
<th v-for="problem in board.problems" :key="problem.problemId">
<router-link :to="`/problem/${problem.problemDisplayId}`" class="plain">
{{ problem.problemDisplayId }}
</router-link>
</th>
<th>最后提交</th>
</tr>
</thead>
<tbody>
<tr v-for="student in students" :key="student.userId">
<td class="name">{{ nameOf(student) }}</td>
<td>
<n-tag
size="small"
:bordered="false"
:type="GROUP_LABEL[groupOf(student)].type"
>
{{ GROUP_LABEL[groupOf(student)].text }}
</n-tag>
</td>
<td v-for="(cell, i) in student.cells" :key="i" :class="cellClass(cell)">
{{ cellText(cell) }}
</td>
<td class="meta">
{{ student.lastSubmitAt ? parseTime(student.lastSubmitAt, "HH:mm") : "—" }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
</template>
</template>
</div>
</template>
<style scoped>
.board {
max-width: 1400px;
margin: 0 auto;
}
.toolbar {
margin-bottom: 16px;
}
.title {
margin: 0;
font-size: 20px;
}
.section {
margin-bottom: 16px;
}
.meta {
font-size: 13px;
}
.summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px 24px;
margin-bottom: 16px;
}
.summary-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
}
.summary-count {
font-size: 13px;
opacity: 0.75;
margin-top: 2px;
}
.projector .summary {
grid-template-columns: 1fr;
gap: 28px;
font-size: 28px;
}
.projector .summary-count {
font-size: 24px;
}
.table-wrap {
overflow-x: auto;
margin-top: 8px;
}
.grid {
border-collapse: collapse;
width: 100%;
font-size: 14px;
}
.grid th,
.grid td {
padding: 6px 10px;
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
text-align: center;
white-space: nowrap;
}
.grid th {
font-weight: 500;
opacity: 0.8;
}
.grid .name {
text-align: left;
}
.plain {
color: inherit;
}
.cell-done {
color: #18a058;
}
.cell-tried {
color: #f0a020;
}
.cell-stuck {
color: #d03050;
font-weight: 600;
}
.cell-none {
opacity: 0.35;
}
</style>
+4 -1
View File
@@ -131,6 +131,8 @@ const classActivityTitle = computed(() => {
if (!day) return ""
const now = zonedParts(new Date())!
const today = `${now.year}-${String(now.month).padStart(2, "0")}-${String(now.day).padStart(2, "0")}`
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
if (classActivity.value?.source === "teacher") return "今天老师布置的题"
if (day === today) return "今天班里在做"
const [, month, date] = day.split("-").map(Number)
return `${month}月${date}日班里做了`
@@ -259,7 +261,8 @@ async function openAnnouncement(item: AnnouncementListItem) {
{{ MY_STATUS[item.myStatus].label }}
</n-tag>
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
<n-text depth="3" class="row-meta">
<!-- 老师刚布置、还没人交的时候不显示「0/0 人通过」 -->
<n-text v-if="item.userCount" depth="3" class="row-meta">
{{ item.acceptedCount }}/{{ item.userCount }} 人通过
</n-text>
</router-link>
+6
View File
@@ -69,6 +69,12 @@ export const ojs: RouteRecordRaw = {
name: "rank",
component: () => import("oj/rank/list.vue"),
},
{
path: "classroom",
name: "classroom board",
component: () => import("oj/classroom/board.vue"),
meta: { requiresAuth: true, requiresTeacherAdmin: true },
},
{
path: "class",
name: "class",
@@ -193,6 +193,15 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
onClick: () => (collabStore.helpPanelOpen = true),
},
},
{
label: "课堂看板",
key: "classroom-board",
show: userStore.isTeacherOrAbove,
icon: renderIcon("fluent-emoji:school"),
props: {
onClick: () => router.push("/classroom"),
},
},
{
label: "我的主页",
key: "home",
+3
View File
@@ -290,6 +290,9 @@ export type { ClassComparison, ClassRankItem, ClassUserRank } from "@oj2/contrac
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem } from "@oj2/contract"
/** 课堂看板:一个班今天这节课的题 × 全班学生 */
export type { ClassBoard, ClassBoardStudent, ClassBoardCell } from "@oj2/contract"
/**
* 学生侧的比赛:不含 password / visible。后台用 `AdminContest` ——
* oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生)