feat(布置作业): 课上老师改了作业,学生页面上弹出改了哪些题,点「刷新」就拿到新题单
Deploy / deploy (push) Canceled after 0s

看板「改 / 新的一节 / 清掉」和后台课堂作业的增改删,前后比较学生看到的那一节,
真变了才按班推 lesson_update(加了、去掉了、语言、顺序),走学生的 /ws/config,老师不收。
没点刷新又改会合并成一条。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 21:14:52 +08:00
1 parent 0fd94ee8bf
commit df8af40d61
12 files changed
+344 -14

No files matched your search

+25 -1
View File
@@ -1,4 +1,4 @@
import type { FlowchartUpdate } from '@oj2/contract'
import type { FlowchartUpdate, LessonUpdate } from '@oj2/contract'
import { redis } from './redis'
@@ -50,6 +50,30 @@ export function parseSessionRevoked(raw: string): SessionRevoked | null {
}
}
/**
* 按班广播:课上老师改了今天这节布置的题(见契约 lessonUpdateSchema)。学生的 /ws/config 连接
* 握手时按自己的班订阅 classEventTopic;换了班要等重连才跟上,课上不会发生。
*/
export const classEventChannel = 'class:events'
export function classEventTopic(className: string) {
return `events:class:${className}`
}
export async function publishLessonUpdate(className: string, data: LessonUpdate) {
await redis.publish(classEventChannel, JSON.stringify({ className, data }))
}
export function parseClassEvent(raw: string): { className: string; data: LessonUpdate } | null {
try {
const value = JSON.parse(raw) as { className: string; data: LessonUpdate }
if (typeof value.className !== 'string' || !value.className || !value.data || typeof value.data !== 'object') return null
return value
} catch {
return null
}
}
interface UserEvent {
userId: number
data: FlowchartUpdate | Record<string, unknown>
+1
View File
@@ -141,6 +141,7 @@ const server = Bun.serve<SubmissionSocketData>({
tokenVersion: session.tokenVersion,
username: user.username,
adminType: user.adminType,
className: user.className,
},
})
) {
+17 -1
View File
@@ -5,7 +5,7 @@ import { Hono } from 'hono'
import { requireTeacher, type AppEnv } from '../../auth/middleware'
import { db, schema } from '../../db'
import { failure, parseBody, success } from '../../http'
import { lessonDoneCounts, resolveLessonProblems } from '../../services/lesson'
import { announceLessonChange, lessonDoneCounts, resolveLessonProblems, todayLessonSnapshot } from '../../services/lesson'
import { localDateTime } from '../../time'
import { queryInteger } from '../helpers'
@@ -125,10 +125,13 @@ adminLessonRoutes.post('/lessons', requireTeacher, async (c) => {
if (!parsed.success) return parsed.response
const result = await lessonValues(parsed.data)
if ('code' in result) return failure(c, 400, result.code, result.message)
const before = await todayLessonSnapshot(result.values.className)
const [row] = await db
.insert(schema.classLesson)
.values({ ...result.values, createdBy: c.get('user')!.id, updatedAt: new Date().toISOString() })
.returning({ id: schema.classLesson.id })
// 补登的是今天、而且是最后一节,学生那边看到的就变了
await announceLessonChange(result.values.className, before)
return success(c, { id: row!.id })
})
@@ -138,6 +141,9 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
if (!parsed.success) return parsed.response
const result = await lessonValues(parsed.data)
if ('code' in result) return failure(c, 400, result.code, result.message)
// 班级能改:原来那个班和新的班学生看到的都可能变,两边各比一次
const classes = await affectedClasses(id, result.values.className)
const before = await Promise.all(classes.map((className) => todayLessonSnapshot(className)))
// 布置的老师不换,和看板上「改」一样认第一次
const [row] = await db
.update(schema.classLesson)
@@ -145,11 +151,21 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
.where(eq(schema.classLesson.id, id))
.returning({ id: schema.classLesson.id })
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
await Promise.all(classes.map((className, index) => announceLessonChange(className, before[index]!)))
return success(c, null)
})
adminLessonRoutes.delete('/lessons/:id', requireTeacher, async (c) => {
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
const [className] = await affectedClasses(id)
const before = className ? await todayLessonSnapshot(className) : null
await db.delete(schema.classLesson).where(eq(schema.classLesson.id, id))
if (className) await announceLessonChange(className, before)
return success(c, null)
})
/** 这一节原来属于哪个班(已经没了就没有),再加上要改成的班,去重 */
async function affectedClasses(id: number, nextClass?: string) {
const [row] = await db.select({ className: schema.classLesson.className }).from(schema.classLesson).where(eq(schema.classLesson.id, id))
return [...new Set([row?.className, nextClass].filter((name): name is string => !!name))]
}
+5 -1
View File
@@ -24,7 +24,7 @@ import { failure, parseBody, success } from '../http'
import { isAccepted } from '../judge/status'
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
import { accepted } from '../services/learning-stats'
import { resolveLessonProblems } from '../services/lesson'
import { announceLessonChange, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time'
export const classroomRoutes = new Hono<AppEnv>()
@@ -744,6 +744,8 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
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} 这个班`)
// 改之前学生看到的那一节,改完比一比、告诉全班改了哪些
const before = await todayLessonSnapshot(className)
// 要改的是哪一行:点名的那一节(只认这个班今天的),新开一节就没有,老写法是今天最后布置的那一节
const [target] = newLesson
@@ -762,6 +764,7 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
.limit(1)
if (!problemDisplayIds.length) {
if (target) await db.delete(schema.classLesson).where(eq(schema.classLesson.id, target.id))
await announceLessonChange(className, before)
return success(c, null)
}
@@ -785,5 +788,6 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
updatedAt: now,
})
}
await announceLessonChange(className, before)
return success(c, null)
})
+66 -3
View File
@@ -1,8 +1,9 @@
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage } from '@oj2/contract'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage, type LessonUpdate } from '@oj2/contract'
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { db, schema } from '../db'
import { dayNumber, dayStart } from '../time'
import { publishLessonUpdate } from '../events'
import { calendarDay, dayNumber, dayStart } from '../time'
import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity'
/**
@@ -97,3 +98,65 @@ export async function lessonDoneCounts(lessons: { id: number; className: string;
`)
return new Map([...result].map((row) => [row.k, row.done]))
}
interface LessonSnapshot {
id: number
problemIds: number[]
language: LessonLanguage | null
}
/**
* 学生此刻看到的那一节:这个班今天最后布置的(和 routes/classroom.ts 的 lessonPlan 同一个取法),
* 没布置为 null。改之前、改之后各拍一张,交给 announceLessonChange 比
*/
export async function todayLessonSnapshot(className: string): Promise<LessonSnapshot | null> {
const [row] = await db
.select({ id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language })
.from(schema.classLesson)
.where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, calendarDay())))
.orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id))
.limit(1)
return row?.problemIds.length ? { ...row, language: row.language ?? null } : null
}
/**
* 改完之后再拍一张,和改之前那张比;学生看到的那一节变了,就告诉这个班改了哪些(见契约 lessonUpdateSchema)。
* 推送失败只记日志:布置已经存下了,不能因为 Redis 抖一下让老师看到报错
*/
export async function announceLessonChange(className: string, before: LessonSnapshot | null) {
try {
const after = await todayLessonSnapshot(className)
if (!before && !after) return
const sameLesson = !!before && !!after && before.id === after.id
if (sameLesson && before.language === after.language && before.problemIds.join() === after.problemIds.join()) return
const ids = [...new Set([...(before?.problemIds ?? []), ...(after?.problemIds ?? [])])]
const rows = ids.length
? await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId, title: schema.problem.title })
.from(schema.problem)
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
: []
const byId = new Map(rows.map((row) => [row.id, { problemDisplayId: row.displayId, title: row.title }]))
const pick = (list: number[]) => list.flatMap((id) => byId.get(id) ?? [])
const afterIds = after?.problemIds ?? []
const beforeIds = sameLesson ? before.problemIds : []
const added = afterIds.filter((id) => !beforeIds.includes(id))
const removed = beforeIds.filter((id) => !afterIds.includes(id))
const kept = afterIds.filter((id) => beforeIds.includes(id))
const keptBefore = beforeIds.filter((id) => afterIds.includes(id))
await publishLessonUpdate(className, {
type: 'lesson_update',
kind: !after ? 'cleared' : sameLesson ? 'changed' : 'assigned',
language: after?.language ?? null,
problems: pick(afterIds),
added: sameLesson ? pick(added) : [],
removed: pick(removed),
languageChanged: sameLesson && before.language !== after.language,
reordered: sameLesson && kept.join() !== keptBefore.join(),
} satisfies LessonUpdate)
} catch (error) {
console.error('Failed to announce lesson change', error)
}
}
+32 -3
View File
@@ -1,4 +1,4 @@
import { submissionUpdateSchema, type FlowchartUpdate } from '@oj2/contract'
import { STUDENT_ROLES, submissionUpdateSchema, type FlowchartUpdate } from '@oj2/contract'
import { and, eq, inArray } from 'drizzle-orm'
import { touchSession } from './auth/session'
@@ -9,7 +9,18 @@ import { parseSubmissionEvent, submissionUpdateChannel, userSubmissionTopic } fr
import { JudgeStatus } from './judge/status'
import { createSubscriberRedis } from './redis'
import { asRecord } from './routes/helpers'
import { configTopic, configUpdateChannel, parseSessionRevoked, parseUserEvent, sessionRevokedChannel, userEventChannel, userEventTopic } from './events'
import {
classEventChannel,
classEventTopic,
configTopic,
configUpdateChannel,
parseClassEvent,
parseSessionRevoked,
parseUserEvent,
sessionRevokedChannel,
userEventChannel,
userEventTopic,
} from './events'
/** 本机的几种写法。开发时 Vite 代理会让 Origin(5173)和 Host(3000)对不上 */
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
@@ -63,6 +74,8 @@ export interface SubmissionSocketData {
/** 握手时从会话里读,三种 kind 都会填;collab 通道用它判断老师身份、拼 room_open 里的姓名 */
username?: string
adminType?: string
/** 握手时的班级。学生的 config 通道按它订阅班级广播(老师改了这节课的题),见 classTopicOf */
className?: string | null
/** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */
roomOwnerId?: number
}
@@ -204,6 +217,12 @@ export function startSessionSweep() {
return timer
}
/** 学生的 config 通道额外挂的班级 topic;老师、没分班的没有 */
function classTopicOf(ws: Bun.ServerWebSocket<SubmissionSocketData>) {
const { className, adminType } = ws.data
return className && adminType && STUDENT_ROLES.includes(adminType as (typeof STUDENT_ROLES)[number]) ? classEventTopic(className) : null
}
export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> {
return {
open(ws) {
@@ -219,6 +238,8 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
}
if (ws.data.kind === 'config') {
ws.subscribe(configTopic)
const classTopic = classTopicOf(ws)
if (classTopic) ws.subscribe(classTopic)
return
}
ws.subscribe(userSubmissionTopic(ws.data.userId))
@@ -264,6 +285,8 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
}
if (ws.data.kind === 'config') {
ws.unsubscribe(configTopic)
const classTopic = classTopicOf(ws)
if (classTopic) ws.unsubscribe(classTopic)
return
}
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
@@ -384,6 +407,12 @@ export async function bridgeSubmissionEvents(server: Bun.Server<SubmissionSocket
server.publish(configTopic, raw)
return
}
if (channel === classEventChannel) {
// 禁用的学生不用查:sweepSessions / publishSessionRevoked 会把那条连接断掉
const event = parseClassEvent(raw)
if (event) server.publish(classEventTopic(event.className), JSON.stringify(event.data))
return
}
if (channel === sessionRevokedChannel) {
const revoked = parseSessionRevoked(raw)
if (!revoked) return
@@ -439,6 +468,6 @@ export async function bridgeSubmissionEvents(server: Bun.Server<SubmissionSocket
})
// 连接层的 error 已经由 createSubscriberRedis 里的 withErrorLogging 打了
// (带连接名),这里再挂一个只会把同一条错误打两遍
await subscriber.subscribe(submissionUpdateChannel, userEventChannel, configUpdateChannel, sessionRevokedChannel)
await subscriber.subscribe(submissionUpdateChannel, userEventChannel, configUpdateChannel, sessionRevokedChannel, classEventChannel)
return subscriber
}
@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { LessonUpdate } from '@oj2/contract'
import { pendingLessonUpdate } from 'shared/composables/lessonUpdate'
import { useThemeVars } from 'naive-ui'
import { LANGUAGE_SHOW_VALUE } from 'utils/constants'
/**
* 课上老师改了这节课的题:顶上居中一张卡,写清改了哪些,点「刷新」重新载入页面拿新的题单。
* 不自己消失 —— 学生可能正低头写代码,几秒就没的提示等于没提示。代码草稿边写边存,刷新不丢。
*/
const update = pendingLessonUpdate
const theme = useThemeVars()
type Item = LessonUpdate['problems'][number]
function names(list: Item[]) {
return list.map((item) => `${item.problemDisplayId} ${item.title}`).join('、')
}
function languageText(value: LessonUpdate) {
return value.language ? LANGUAGE_SHOW_VALUE[value.language] : ''
}
const title = computed(() => {
const value = update.value
if (!value) return ''
if (value.kind === 'assigned') return '老师布置了这节课的题'
if (value.kind === 'cleared') return '老师清掉了这节课的题'
return '老师改了这节课的题'
})
/** 一行一件事:加了哪些、去掉了哪些、语言、顺序 */
const lines = computed(() => {
const value = update.value
if (!value) return []
if (value.kind === 'cleared') return []
if (value.kind === 'assigned') {
const language = languageText(value)
return [{ label: '', text: names(value.problems) }, ...(language ? [{ label: '用', text: language }] : [])]
}
const result: { label: string; text: string }[] = []
if (value.added.length) result.push({ label: '加了', text: names(value.added) })
if (value.removed.length) result.push({ label: '去掉了', text: names(value.removed) })
if (value.languageChanged) result.push({ label: '语言', text: value.language ? `改成 ${languageText(value)}` : '不限了' })
if (value.reordered) result.push({ label: '', text: '换了题目的顺序' })
return result
})
function refresh() {
location.reload()
}
function dismiss() {
update.value = null
}
</script>
<template>
<Transition name="drop">
<div v-if="update" class="notice" role="alert" :style="{ background: theme.cardColor, borderColor: theme.primaryColor }">
<div class="title">{{ title }}</div>
<div v-for="(line, index) in lines" :key="index" class="line">
<span v-if="line.label" class="label">{{ line.label }}</span>
<span>{{ line.text }}</span>
</div>
<div class="actions">
<n-button quaternary @click="dismiss">先不刷新</n-button>
<n-button type="primary" @click="refresh">刷新</n-button>
</div>
</div>
</Transition>
</template>
<style scoped>
.notice {
position: fixed;
/* 顶栏下面、居中:右上角是成就提示的位置,两个一起来不能叠上 */
top: 72px;
left: 50%;
transform: translateX(-50%);
z-index: 3000;
width: min(480px, calc(100vw - 32px));
padding: 16px 20px;
border-radius: var(--oj-radius);
border: 2px solid;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
font-size: var(--oj-fs-body);
}
.title {
font-size: var(--oj-fs-h2);
font-weight: 700;
margin-bottom: 8px;
}
.line {
line-height: 1.7;
}
.label {
font-weight: 600;
margin-right: 8px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.drop-enter-active,
.drop-leave-active {
transition: all 0.3s ease;
}
.drop-enter-from,
.drop-leave-to {
opacity: 0;
transform: translate(-50%, -16px);
}
</style>
@@ -1,6 +1,7 @@
import { useConfigStore } from 'shared/store/config'
import { useUserStore } from 'shared/store/user'
import { useConfigWebSocket, type ConfigUpdate } from 'shared/composables/websocket'
import { receiveLessonUpdate } from 'shared/composables/lessonUpdate'
import { useConfigWebSocket, type ConfigChannelMessage } from 'shared/composables/websocket'
/**
* 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面
@@ -13,7 +14,12 @@ export function useConfigUpdate() {
const configStore = useConfigStore()
const userStore = useUserStore()
const handleConfigUpdate = (data: ConfigUpdate) => {
const handleConfigUpdate = (data: ConfigChannelMessage) => {
// 同一条连接上的班级广播:老师改了这节课的题(只推给学生,见后端 classTopicOf)
if (data.type === 'lesson_update') {
receiveLessonUpdate(data)
return
}
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
if (!(data.key in configStore.config)) return
;(configStore.config as unknown as Record<string, unknown>)[data.key] = data.value
@@ -0,0 +1,40 @@
import type { LessonUpdate } from '@oj2/contract'
/**
* 课上老师改了这节课的题(/ws/config 推来的 lesson_update),等学生点「刷新」。
* useConfigUpdate 收、LessonUpdateNotice 显示,两头不在一个组件里,所以放模块级。
*/
export const pendingLessonUpdate = shallowRef<LessonUpdate | null>(null)
type Item = LessonUpdate['problems'][number]
function minus(list: Item[], other: Item[]) {
const ids = new Set(other.map((item) => item.problemDisplayId))
return list.filter((item) => !ids.has(item.problemDisplayId))
}
/**
* 还没点刷新老师又改了一次:并成一条,免得第二条把第一次加的题盖掉。
* 刚布置(还没刷新)又改 → 仍算刚布置,题目按最新的列;两次「改」→ 加的、去掉的互相抵消后合起来
*/
export function receiveLessonUpdate(next: LessonUpdate) {
const prev = pendingLessonUpdate.value
if (!prev || next.kind !== 'changed' || prev.kind === 'cleared') {
pendingLessonUpdate.value = next
return
}
if (prev.kind === 'assigned') {
pendingLessonUpdate.value = { ...next, kind: 'assigned', added: [], removed: [], languageChanged: false, reordered: false }
return
}
const merged: LessonUpdate = {
...next,
added: [...minus(prev.added, next.removed), ...minus(next.added, prev.removed)],
removed: [...minus(prev.removed, next.added), ...minus(next.removed, prev.added)],
languageChanged: prev.languageChanged || next.languageChanged,
reordered: prev.reordered || next.reordered,
}
// 改回去了:等于没改
const nothing = !merged.added.length && !merged.removed.length && !merged.languageChanged && !merged.reordered
pendingLessonUpdate.value = nothing ? null : merged
}
+8 -3
View File
@@ -1,4 +1,4 @@
import { WS_AUTH_PROTOCOL } from '@oj2/contract'
import { WS_AUTH_PROTOCOL, type LessonUpdate } from '@oj2/contract'
import { createDiscreteApi } from 'naive-ui'
import { ref, getCurrentScope, onScopeDispose, type Ref } from 'vue'
@@ -559,14 +559,19 @@ export interface ConfigUpdate extends WebSocketMessage {
value: any
}
/**
* 这条通道上还有一种按班推的:老师改了这节课的题(学生才收得到,见契约 lessonUpdateSchema)
*/
export type ConfigChannelMessage = ConfigUpdate | LessonUpdate
/**
* 站点配置广播。这条通道是**单向**的:只收后端广播。服务端的消息处理只认
* ping / subscribe,客户端往这里推 config_update 会被回一个 error 帧 ——
* 别拿返回值里的 subscribe 往这条通道上发东西。配置变更走 POST /admin/website,
* 由后端广播给所有人。
*/
export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
return useChannel<ConfigUpdate>('/ws/config', handler)
export function useConfigWebSocket(handler?: MessageHandler<ConfigChannelMessage>) {
return useChannel<ConfigChannelMessage>('/ws/config', handler)
}
export interface CollabRequestItem {
+2
View File
@@ -4,6 +4,7 @@ import Header from '../components/Header.vue'
import Login from '../components/Login.vue'
import Signup from '../components/Signup.vue'
import AchievementToast from '../components/AchievementToast.vue'
import LessonUpdateNotice from '../components/LessonUpdateNotice.vue'
import { useAchievementStore } from 'shared/store/achievement'
import { useUserStore } from 'shared/store/user'
@@ -40,6 +41,7 @@ watch(
<Login />
<Signup />
<AchievementToast />
<LessonUpdateNotice />
<Beian />
</n-layout>
</template>