diff --git a/apps/api/src/events.ts b/apps/api/src/events.ts index 70d2a2b..b1cbcc9 100644 --- a/apps/api/src/events.ts +++ b/apps/api/src/events.ts @@ -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 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index a7a5f47..c5e1719 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -141,6 +141,7 @@ const server = Bun.serve({ tokenVersion: session.tokenVersion, username: user.username, adminType: user.adminType, + className: user.className, }, }) ) { diff --git a/apps/api/src/routes/admin/lesson.ts b/apps/api/src/routes/admin/lesson.ts index 5a6d32f..3f91d4f 100644 --- a/apps/api/src/routes/admin/lesson.ts +++ b/apps/api/src/routes/admin/lesson.ts @@ -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))] +} diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 4b2e6ac..3273af3 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -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() @@ -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) }) diff --git a/apps/api/src/services/lesson.ts b/apps/api/src/services/lesson.ts index 8f22ca2..face69c 100644 --- a/apps/api/src/services/lesson.ts +++ b/apps/api/src/services/lesson.ts @@ -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 { + 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) + } +} diff --git a/apps/api/src/websocket.ts b/apps/api/src/websocket.ts index 8cc5b10..ddb0161 100644 --- a/apps/api/src/websocket.ts +++ b/apps/api/src/websocket.ts @@ -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) { + 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 { return { open(ws) { @@ -219,6 +238,8 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler +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 +} + + + + + diff --git a/apps/web/src/shared/composables/configUpdate.ts b/apps/web/src/shared/composables/configUpdate.ts index f64fb61..0d6fab4 100644 --- a/apps/web/src/shared/composables/configUpdate.ts +++ b/apps/web/src/shared/composables/configUpdate.ts @@ -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)[data.key] = data.value diff --git a/apps/web/src/shared/composables/lessonUpdate.ts b/apps/web/src/shared/composables/lessonUpdate.ts new file mode 100644 index 0000000..30be709 --- /dev/null +++ b/apps/web/src/shared/composables/lessonUpdate.ts @@ -0,0 +1,40 @@ +import type { LessonUpdate } from '@oj2/contract' + +/** + * 课上老师改了这节课的题(/ws/config 推来的 lesson_update),等学生点「刷新」。 + * useConfigUpdate 收、LessonUpdateNotice 显示,两头不在一个组件里,所以放模块级。 + */ +export const pendingLessonUpdate = shallowRef(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 +} diff --git a/apps/web/src/shared/composables/websocket.ts b/apps/web/src/shared/composables/websocket.ts index 00ceeee..30c1049 100644 --- a/apps/web/src/shared/composables/websocket.ts +++ b/apps/web/src/shared/composables/websocket.ts @@ -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) { - return useChannel('/ws/config', handler) +export function useConfigWebSocket(handler?: MessageHandler) { + return useChannel('/ws/config', handler) } export interface CollabRequestItem { diff --git a/apps/web/src/shared/layout/default.vue b/apps/web/src/shared/layout/default.vue index 2897264..71ff7f4 100644 --- a/apps/web/src/shared/layout/default.vue +++ b/apps/web/src/shared/layout/default.vue @@ -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( + diff --git a/packages/contract/src/classroom.ts b/packages/contract/src/classroom.ts index 1e512c0..0bf2a69 100644 --- a/packages/contract/src/classroom.ts +++ b/packages/contract/src/classroom.ts @@ -251,6 +251,30 @@ export const classTodaySchema = z.object({ ), }) +/** + * 课上老师在看板(或后台)改了**今天这节**布置的题,推给这个班还开着页面的学生:告诉他们改了哪些, + * 点「刷新」重新载入。只有学生看到的那一节(今天最后布置的)变了才推 —— 改的是今天早些时候的一节、 + * 或者改完和原来一样,都不推。走 /ws/config 那条全局常驻的连接。 + * + * - `assigned`:今天这节是新布置的(原来没布置,或者「新的一节」),`problems` 是整张单子 + * - `changed`:同一节改了,看 `added` / `removed` / `languageChanged` / `reordered` + * - `cleared`:清掉了,学生那边退回按同班提交推断 + */ +export const lessonUpdateProblemSchema = z.object({ problemDisplayId: z.string(), title: z.string() }) + +export const lessonUpdateSchema = z.object({ + type: z.literal('lesson_update'), + kind: z.enum(['assigned', 'changed', 'cleared']), + language: lessonLanguageSchema, + problems: z.array(lessonUpdateProblemSchema), + added: z.array(lessonUpdateProblemSchema), + removed: z.array(lessonUpdateProblemSchema), + languageChanged: z.boolean(), + reordered: z.boolean(), +}) + +export type LessonUpdate = z.infer + export type ClassActivity = z.infer export type ClassToday = z.infer export type LastVisit = z.infer