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
+4977 -54

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE "class_lesson" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "class_lesson_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"class_name" text NOT NULL,
"day" text NOT NULL,
"problem_ids" jsonb NOT NULL,
"created_by" integer NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
CONSTRAINT "class_lesson_class_day_unique" UNIQUE("class_name","day")
);
--> statement-breakpoint
ALTER TABLE "class_lesson" ADD CONSTRAINT "class_lesson_created_by_fk_user_id" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "class_lesson_created_by_idx" ON "class_lesson" USING btree ("created_by");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -162,6 +162,13 @@
"when": 1790512513639,
"tag": "0022_add_tag_category",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1790519287984,
"tag": "0023_add_class_lesson",
"breakpoints": true
}
]
}
+44
View File
@@ -1449,3 +1449,47 @@ export const exerciseAttempt = pgTable(
}).onDelete("cascade"),
],
)
/**
* 老师给某个班「这节课」布置的题(课堂看板里输入的题号),一个班一天一行。
*
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
*
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
* 「哪一天」的口径只在 time.ts 一处定,不交给驱动和会话时区去解释。
* - `problem_ids` 按老师输入的顺序存 problem.id。**不挂外键**(jsonb 挂不了),
* 读的时候按 visible 过滤,题目被删、被藏的自然不出现;写的时候校验过都存在。
* - `created_by` CASCADE:备课记录不是「学生做过什么」的证据,老师账号没了它也没有意义
* (外键分档见 CLAUDE.md「数据库」一节)。
*/
export const classLesson = pgTable(
"class_lesson",
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: "class_lesson_id_seq",
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 2147483647,
cache: 1,
}),
className: text("class_name").notNull(),
day: text().notNull(),
problemIds: jsonb("problem_ids").notNull().$type<number[]>(),
createdBy: integer("created_by").notNull(),
updatedAt: timestamp("updated_at", {
withTimezone: true,
mode: "string",
}).notNull(),
},
(table) => [
unique("class_lesson_class_day_unique").on(table.className, table.day),
index("class_lesson_created_by_idx").on(table.createdBy),
foreignKey({
columns: [table.createdBy],
foreignColumns: [user.id],
name: "class_lesson_created_by_fk_user_id",
}).onDelete("cascade"),
],
)
+326 -53
View File
@@ -1,21 +1,24 @@
import {
classComparisonRequestSchema,
STUDENT_ROLES,
classLessonRequestSchema,
type ClassActivity,
type ClassBoard,
type ClassBoardStudent,
type ClassActivityProblem,
type ClassComparison,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
} from "@oj2/contract"
import { and, eq, gte, inArray, isNull, like, lte, sql } from "drizzle-orm"
import { and, desc, eq, gte, inArray, isNotNull, isNull, like, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, type AppEnv } from "../auth/middleware"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { dayStart, localTime } from "../time"
import { calendarDay, dayStart, localTime } from "../time"
import { queryInteger, rounded } from "./helpers"
export const classroomRoutes = new Hono<AppEnv>()
@@ -156,23 +159,27 @@ const CLASS_ACTIVITY_LIMIT = 8
/** AST_CHECK_FAILED 也是答案对了,与周榜同口径 */
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/**
* 班里最近一次「一起做」的那几道题。课上老师报题号、全班去找 —— 这是 2025 秋
* 七成非比赛提交的来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
*
* 只取最近的**一天**,不把 7 天摊平:摊平了就是一张越来越长的旧题单,
* 「现在该做哪道」反而看不出来。题按那天第一次有人提交的时间排,大致就是老师点题的顺序。
*/
classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
const user = c.get("user")!
if (!user.className) {
return success(c, { className: null, day: null, problems: [] } satisfies ClassActivity)
interface ClassDayGroup {
day: string
problemId: number
firstAt: string
userCount: number
acceptedCount: number
}
const since = dayStart(Date.now() - (CLASS_ACTIVITY_LOOKBACK_DAYS - 1) * 86_400_000)
/**
* 某个班从 `since` 起、按(东八区日, 题)分组的做题人数和通过人数。只算正常状态的学生,
* 不含比赛提交。`minUsers` 是「班里在做」的门槛,给了 `problemIds`(老师布置的题)时
* 不设门槛 —— 那几道题是确定的,一个人都没交也要列出来。
*/
async function classDayGroups(
className: string,
since: string,
options: { minUsers?: number; problemIds?: number[] } = {},
): Promise<ClassDayGroup[]> {
const day = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
const userCount = sql<number>`count(distinct ${schema.submission.userId})::int`
const groups = await db
const query = db
.select({
day,
problemId: schema.submission.problemId,
@@ -186,34 +193,20 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, since),
eq(schema.user.className, user.className),
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
options.problemIds ? inArray(schema.submission.problemId, options.problemIds) : undefined,
),
)
.groupBy(day, schema.submission.problemId)
.having(sql`${userCount} >= ${CLASS_ACTIVITY_MIN_USERS}`)
const latest = groups.reduce<string | null>(
(acc, row) => (!acc || row.day > acc ? row.day : acc),
null,
)
const picked = groups
.filter((row) => row.day === latest)
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
.slice(0, CLASS_ACTIVITY_LIMIT)
if (!picked.length) {
return success(c, {
className: user.className,
day: null,
problems: [],
} satisfies ClassActivity)
return options.minUsers ? query.having(sql`${userCount} >= ${options.minUsers}`) : query
}
const ids = picked.map((row) => row.problemId)
const [problems, mine] = await Promise.all([
// 题目后来被藏起来的就不列了,点进去也是 404
db
/** 按 id 取题,只留学生看得见的(题库里、visible),被藏起来的点进去也是 404 */
async function visibleProblems(ids: number[]) {
if (!ids.length) return new Map<number, { id: number; displayId: string; title: string }>()
const rows = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
@@ -226,8 +219,97 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
),
db
)
return new Map(rows.map((row) => [row.id, row]))
}
/** 老师给这个班今天布置的题(problem.id,按输入顺序);没布置为 null */
async function lessonProblemIds(className: string, day: string) {
const [row] = await db
.select({ problemIds: schema.classLesson.problemIds })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
.limit(1)
return row?.problemIds.length ? row.problemIds : null
}
/**
* 班里「这节课」的题。老师在课堂看板布置过就用老师的(source = teacher),
* 否则从同班提交记录推断(source = inferred):同班同一天 ≥ 5 人做过的题,只取最近的
* **一天**、按那天第一次有人提交的时间排 —— 大致就是老师点题的顺序。
*
* 老师布置的优先,是因为推断在上课头几分钟是空的(还没人交),而那正是没听清题号的
* 学生最需要它的时候。
*/
async function classLessonProblems(className: string, lookbackDays: number) {
const today = calendarDay()
const planned = await lessonProblemIds(className, today)
if (planned) {
const [problems, groups] = await Promise.all([
visibleProblems(planned),
classDayGroups(className, dayStart(), { problemIds: planned }),
])
const groupById = new Map(groups.map((row) => [row.problemId, row]))
return {
source: "teacher" as const,
day: today,
problems: planned.flatMap((id) => {
const problem = problems.get(id)
if (!problem) return []
const group = groupById.get(id)
return [
{
...problem,
userCount: group?.userCount ?? 0,
acceptedCount: group?.acceptedCount ?? 0,
},
]
}),
}
}
const since = dayStart(Date.now() - (lookbackDays - 1) * 86_400_000)
const groups = await classDayGroups(className, since, { minUsers: CLASS_ACTIVITY_MIN_USERS })
const latest = groups.reduce<string | null>(
(acc, row) => (!acc || row.day > acc ? row.day : acc),
null,
)
const picked = groups
.filter((row) => row.day === latest)
.sort((a, b) => a.firstAt.localeCompare(b.firstAt))
.slice(0, CLASS_ACTIVITY_LIMIT)
const problems = await visibleProblems(picked.map((row) => row.problemId))
return {
source: picked.length ? ("inferred" as const) : null,
day: picked.length ? latest : null,
problems: picked.flatMap((row) => {
const problem = problems.get(row.problemId)
return problem
? [{ ...problem, userCount: row.userCount, acceptedCount: row.acceptedCount }]
: []
}),
}
}
/**
* 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
*/
classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
const user = c.get("user")!
if (!user.className) {
return success(c, {
className: null,
day: null,
source: null,
problems: [],
} satisfies ClassActivity)
}
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
const ids = lesson.problems.map((problem) => problem.id)
const mine = ids.length
? await db
.select({
problemId: schema.submission.problemId,
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
@@ -240,31 +322,222 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
isNull(schema.submission.contestId),
),
)
.groupBy(schema.submission.problemId),
])
const problemById = new Map(problems.map((row) => [row.id, row]))
.groupBy(schema.submission.problemId)
: []
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
return success(c, {
className: user.className,
day: latest,
problems: picked.flatMap((row) => {
const problem = problemById.get(row.problemId)
if (!problem) return []
const accepted = mineById.get(row.problemId)
return [
{
day: lesson.day,
source: lesson.source,
problems: lesson.problems.map((problem) => {
const accepted = mineById.get(problem.id)
return {
problemDisplayId: problem.displayId,
title: problem.title,
userCount: row.userCount,
acceptedCount: row.acceptedCount,
userCount: problem.userCount,
acceptedCount: problem.acceptedCount,
myStatus: accepted === undefined ? "none" : accepted ? "accepted" : "tried",
} satisfies ClassActivityProblem,
]
} satisfies ClassActivityProblem
}),
} satisfies ClassActivity)
})
/** 看板没指定班级时,猜「最近这么久里提交人数最多的班」—— 老师多半正在上这个班的课 */
const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/**
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
* 的班悄悄留在框里,老师看的整个是别人的班,见 StatisticsPanel.vue 的注释)。
*/
async function suggestActiveClass() {
const users = sql<number>`count(distinct ${schema.submission.userId})::int`
const [row] = await db
.select({ className: schema.user.className, users })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
gte(
schema.submission.createTime,
new Date(Date.now() - ACTIVE_CLASS_WINDOW_MS).toISOString(),
),
isNull(schema.submission.contestId),
isNotNull(schema.user.className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
)
.groupBy(schema.user.className)
.orderBy(desc(users))
.limit(1)
return row?.className ?? null
}
/**
* 课堂看板:这个班、今天、这节课的几道题 × 全班学生。老师在课上用它看谁还没开始、
* 谁卡住了 —— 2025 秋平均每节课有 14 个平时在用的学生一道都没交(占三分之一),
* 而交了 3 次以上还没过的平均不到 1 个,所以「还没开始」才是这张表的重点,排序在前端。
*/
classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
const day = calendarDay()
const className = c.req.query("className")?.trim() || (await suggestActiveClass())
if (!className) {
return success(c, {
className: null,
day,
source: null,
problems: [],
students: [],
} satisfies ClassBoard)
}
// 推断只看今天(回看 1 天):看板是给这节课用的,昨天的题不该冒出来
const lesson = await classLessonProblems(className, 1)
const ids = lesson.problems.map((problem) => problem.id)
const start = dayStart()
const roster = await db
.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
),
)
const userIds = roster.map((row) => row.userId)
const [cells, lastSubmits] = await Promise.all([
ids.length && userIds.length
? db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
attempts: sql<number>`count(*) filter (where ${gte(schema.submission.createTime, start)})::int`,
firstAcceptedAt: sql<
string | null
>`min(${schema.submission.createTime}) filter (where ${inArray(schema.submission.result, SOLVED_RESULTS)})`,
})
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, ids),
),
)
.groupBy(schema.submission.userId, schema.submission.problemId)
: [],
userIds.length
? db
.select({
userId: schema.submission.userId,
lastAt: sql<string>`max(${schema.submission.createTime})`,
})
.from(schema.submission)
.where(
and(
inArray(schema.submission.userId, userIds),
gte(schema.submission.createTime, start),
),
)
.groupBy(schema.submission.userId)
: [],
])
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
return success(c, {
className,
day,
source: lesson.source,
problems: lesson.problems.map((problem) => ({
problemId: problem.id,
problemDisplayId: problem.displayId,
title: problem.title,
})),
students: roster.map(
(student) =>
({
userId: student.userId,
username: student.username,
realName: student.realName ?? null,
cells: ids.map((id) => {
const cell = cellByKey.get(`${student.userId}:${id}`)
return {
status: !cell ? "none" : cell.firstAcceptedAt ? "accepted" : "tried",
attempts: cell?.attempts ?? 0,
acceptedAt: cell?.firstAcceptedAt ?? null,
}
}),
lastSubmitAt: lastByUser.get(student.userId) ?? null,
}) satisfies ClassBoardStudent,
),
} satisfies ClassBoard)
})
/**
* 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序;
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉,
* 学生那边退回推断。
*/
classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对")
if (!parsed.success) return parsed.response
const user = c.get("user")!
const { className, problemDisplayIds } = parsed.data
const day = calendarDay()
const [exists] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.className, className))
.limit(1)
if (!exists) return failure(c, 400, "class-not-found", `没有 ${className} 这个班`)
if (!problemDisplayIds.length) {
await db
.delete(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day)))
return success(c, null)
}
const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))]
const found = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(sql<string>`lower(${schema.problem.displayId})`, wanted),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
const idByDisplay = new Map(found.map((row) => [row.displayId.toLowerCase(), row.id]))
const missing = wanted.filter((id) => !idByDisplay.has(id))
if (missing.length) {
return failure(c, 400, "problem-not-found", `这些题号不存在或没有公开:${missing.join("、")}`)
}
const problemIds = wanted.map((id) => idByDisplay.get(id)!)
const now = new Date().toISOString()
await db
.insert(schema.classLesson)
.values({ className, day, problemIds, createdBy: user.id, updatedAt: now })
.onConflictDoUpdate({
target: [schema.classLesson.className, schema.classLesson.day],
set: { problemIds, createdBy: user.id, updatedAt: now },
})
return success(c, null)
})
classroomRoutes.post("/classes/comparison", async (c) => {
const parsed = await parseBody(c, classComparisonRequestSchema, "At least one class is required")
if (!parsed.success) return parsed.response
+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,后台要能看到(告诉学生)
+50
View File
@@ -81,15 +81,65 @@ export const classActivityProblemSchema = z.object({
export const classActivitySchema = z.object({
className: z.string().nullable(),
day: z.string().nullable(),
/** teacher = 老师在课堂看板里布置的;inferred = 从同班提交记录推断的 */
source: z.enum(["teacher", "inferred"]).nullable(),
problems: z.array(classActivityProblemSchema),
})
/** 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉 */
export const classLessonRequestSchema = z.object({
className: z.string().trim().min(1),
problemDisplayIds: z.array(z.string().trim().min(1)).max(20),
})
export const classBoardProblemSchema = z.object({
problemId: z.number().int(),
problemDisplayId: z.string(),
title: z.string(),
})
export const classBoardCellSchema = z.object({
/** accepted 看的是题库里(不含比赛)有没有通过过,不限今天 —— 以前做过的也算做完 */
status: z.enum(["accepted", "tried", "none"]),
/** 今天在这道题上交了几次 */
attempts: z.number().int(),
/** 第一次通过的时刻;今天之前通过的,前端显示成「之前」 */
acceptedAt: z.string().nullable(),
})
export const classBoardStudentSchema = z.object({
userId: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
/** 和 problems 同序 */
cells: z.array(classBoardCellSchema),
/** 今天最后一次提交(任何题),没交过为 null */
lastSubmitAt: z.string().nullable(),
})
/**
* 课堂看板:一个班、今天、这节课的几道题 × 全班学生。
* className 为 null = 没指定班级、最近两小时也没有哪个班在交(没法猜)。
*/
export const classBoardSchema = z.object({
className: z.string().nullable(),
day: z.string(),
source: z.enum(["teacher", "inferred"]).nullable(),
problems: z.array(classBoardProblemSchema),
students: z.array(classBoardStudentSchema),
})
export type ClassRankItem = z.infer<typeof classRankItemSchema>
export type ClassUserRank = z.infer<typeof classUserRankSchema>
export type ClassComparison = z.infer<typeof classComparisonSchema>
export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSchema>
export type ClassActivity = z.infer<typeof classActivitySchema>
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema>
export type ClassBoard = z.infer<typeof classBoardSchema>
export type ClassBoardProblem = z.infer<typeof classBoardProblemSchema>
export type ClassBoardStudent = z.infer<typeof classBoardStudentSchema>
export type ClassBoardCell = z.infer<typeof classBoardCellSchema>
export type ClassActivityProblem = z.infer<typeof classActivityProblemSchema>
export type ClassUserRankItem = z.infer<typeof classUserRankItemSchema>