看板「改 / 新的一节 / 清掉」和后台课堂作业的增改删,前后比较学生看到的那一节, 真变了才按班推 lesson_update(加了、去掉了、语言、顺序),走学生的 /ws/config,老师不收。 没点刷新又改会合并成一条。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0fd94ee8bf
commit
df8af40d61
12 files changed
+344
-14
No files matched your search
+25
-1
@@ -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>
|
||||
|
||||
@@ -141,6 +141,7 @@ const server = Bun.serve<SubmissionSocketData>({
|
||||
tokenVersion: session.tokenVersion,
|
||||
username: user.username,
|
||||
adminType: user.adminType,
|
||||
className: user.className,
|
||||
},
|
||||
})
|
||||
) {
|
||||
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user