看板「改 / 新的一节 / 清掉」和后台课堂作业的增改删,前后比较学生看到的那一节, 真变了才按班推 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'
|
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 {
|
interface UserEvent {
|
||||||
userId: number
|
userId: number
|
||||||
data: FlowchartUpdate | Record<string, unknown>
|
data: FlowchartUpdate | Record<string, unknown>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ const server = Bun.serve<SubmissionSocketData>({
|
|||||||
tokenVersion: session.tokenVersion,
|
tokenVersion: session.tokenVersion,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
adminType: user.adminType,
|
adminType: user.adminType,
|
||||||
|
className: user.className,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Hono } from 'hono'
|
|||||||
import { requireTeacher, type AppEnv } from '../../auth/middleware'
|
import { requireTeacher, type AppEnv } from '../../auth/middleware'
|
||||||
import { db, schema } from '../../db'
|
import { db, schema } from '../../db'
|
||||||
import { failure, parseBody, success } from '../../http'
|
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 { localDateTime } from '../../time'
|
||||||
import { queryInteger } from '../helpers'
|
import { queryInteger } from '../helpers'
|
||||||
|
|
||||||
@@ -125,10 +125,13 @@ adminLessonRoutes.post('/lessons', requireTeacher, async (c) => {
|
|||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const result = await lessonValues(parsed.data)
|
const result = await lessonValues(parsed.data)
|
||||||
if ('code' in result) return failure(c, 400, result.code, result.message)
|
if ('code' in result) return failure(c, 400, result.code, result.message)
|
||||||
|
const before = await todayLessonSnapshot(result.values.className)
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(schema.classLesson)
|
.insert(schema.classLesson)
|
||||||
.values({ ...result.values, createdBy: c.get('user')!.id, updatedAt: new Date().toISOString() })
|
.values({ ...result.values, createdBy: c.get('user')!.id, updatedAt: new Date().toISOString() })
|
||||||
.returning({ id: schema.classLesson.id })
|
.returning({ id: schema.classLesson.id })
|
||||||
|
// 补登的是今天、而且是最后一节,学生那边看到的就变了
|
||||||
|
await announceLessonChange(result.values.className, before)
|
||||||
return success(c, { id: row!.id })
|
return success(c, { id: row!.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,6 +141,9 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
|||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const result = await lessonValues(parsed.data)
|
const result = await lessonValues(parsed.data)
|
||||||
if ('code' in result) return failure(c, 400, result.code, result.message)
|
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
|
const [row] = await db
|
||||||
.update(schema.classLesson)
|
.update(schema.classLesson)
|
||||||
@@ -145,11 +151,21 @@ adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => {
|
|||||||
.where(eq(schema.classLesson.id, id))
|
.where(eq(schema.classLesson.id, id))
|
||||||
.returning({ id: schema.classLesson.id })
|
.returning({ id: schema.classLesson.id })
|
||||||
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
|
if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了')
|
||||||
|
await Promise.all(classes.map((className, index) => announceLessonChange(className, before[index]!)))
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminLessonRoutes.delete('/lessons/:id', requireTeacher, async (c) => {
|
adminLessonRoutes.delete('/lessons/:id', requireTeacher, async (c) => {
|
||||||
const id = queryInteger(c.req.param('id'), 0, { min: 1 })
|
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))
|
await db.delete(schema.classLesson).where(eq(schema.classLesson.id, id))
|
||||||
|
if (className) await announceLessonChange(className, before)
|
||||||
return success(c, null)
|
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 { isAccepted } from '../judge/status'
|
||||||
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
|
import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity'
|
||||||
import { accepted } from '../services/learning-stats'
|
import { accepted } from '../services/learning-stats'
|
||||||
import { resolveLessonProblems } from '../services/lesson'
|
import { announceLessonChange, resolveLessonProblems, todayLessonSnapshot } from '../services/lesson'
|
||||||
import { calendarDay, dayStart, localTime } from '../time'
|
import { calendarDay, dayStart, localTime } from '../time'
|
||||||
|
|
||||||
export const classroomRoutes = new Hono<AppEnv>()
|
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)
|
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 (!exists) return failure(c, 400, 'class-not-found', `没有 ${className} 这个班`)
|
||||||
|
// 改之前学生看到的那一节,改完比一比、告诉全班改了哪些
|
||||||
|
const before = await todayLessonSnapshot(className)
|
||||||
|
|
||||||
// 要改的是哪一行:点名的那一节(只认这个班今天的),新开一节就没有,老写法是今天最后布置的那一节
|
// 要改的是哪一行:点名的那一节(只认这个班今天的),新开一节就没有,老写法是今天最后布置的那一节
|
||||||
const [target] = newLesson
|
const [target] = newLesson
|
||||||
@@ -762,6 +764,7 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
if (!problemDisplayIds.length) {
|
if (!problemDisplayIds.length) {
|
||||||
if (target) await db.delete(schema.classLesson).where(eq(schema.classLesson.id, target.id))
|
if (target) await db.delete(schema.classLesson).where(eq(schema.classLesson.id, target.id))
|
||||||
|
await announceLessonChange(className, before)
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,5 +788,6 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await announceLessonChange(className, before)
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage } from '@oj2/contract'
|
import { isSqlProblem, STUDENT_ROLES, type LessonLanguage, type LessonUpdate } from '@oj2/contract'
|
||||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
import { db, schema } from '../db'
|
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'
|
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]))
|
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 { and, eq, inArray } from 'drizzle-orm'
|
||||||
|
|
||||||
import { touchSession } from './auth/session'
|
import { touchSession } from './auth/session'
|
||||||
@@ -9,7 +9,18 @@ import { parseSubmissionEvent, submissionUpdateChannel, userSubmissionTopic } fr
|
|||||||
import { JudgeStatus } from './judge/status'
|
import { JudgeStatus } from './judge/status'
|
||||||
import { createSubscriberRedis } from './redis'
|
import { createSubscriberRedis } from './redis'
|
||||||
import { asRecord } from './routes/helpers'
|
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)对不上 */
|
/** 本机的几种写法。开发时 Vite 代理会让 Origin(5173)和 Host(3000)对不上 */
|
||||||
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
|
||||||
@@ -63,6 +74,8 @@ export interface SubmissionSocketData {
|
|||||||
/** 握手时从会话里读,三种 kind 都会填;collab 通道用它判断老师身份、拼 room_open 里的姓名 */
|
/** 握手时从会话里读,三种 kind 都会填;collab 通道用它判断老师身份、拼 room_open 里的姓名 */
|
||||||
username?: string
|
username?: string
|
||||||
adminType?: string
|
adminType?: string
|
||||||
|
/** 握手时的班级。学生的 config 通道按它订阅班级广播(老师改了这节课的题),见 classTopicOf */
|
||||||
|
className?: string | null
|
||||||
/** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */
|
/** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */
|
||||||
roomOwnerId?: number
|
roomOwnerId?: number
|
||||||
}
|
}
|
||||||
@@ -204,6 +217,12 @@ export function startSessionSweep() {
|
|||||||
return timer
|
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> {
|
export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSocketData> {
|
||||||
return {
|
return {
|
||||||
open(ws) {
|
open(ws) {
|
||||||
@@ -219,6 +238,8 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
|||||||
}
|
}
|
||||||
if (ws.data.kind === 'config') {
|
if (ws.data.kind === 'config') {
|
||||||
ws.subscribe(configTopic)
|
ws.subscribe(configTopic)
|
||||||
|
const classTopic = classTopicOf(ws)
|
||||||
|
if (classTopic) ws.subscribe(classTopic)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws.subscribe(userSubmissionTopic(ws.data.userId))
|
ws.subscribe(userSubmissionTopic(ws.data.userId))
|
||||||
@@ -264,6 +285,8 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
|
|||||||
}
|
}
|
||||||
if (ws.data.kind === 'config') {
|
if (ws.data.kind === 'config') {
|
||||||
ws.unsubscribe(configTopic)
|
ws.unsubscribe(configTopic)
|
||||||
|
const classTopic = classTopicOf(ws)
|
||||||
|
if (classTopic) ws.unsubscribe(classTopic)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
|
ws.unsubscribe(userSubmissionTopic(ws.data.userId))
|
||||||
@@ -384,6 +407,12 @@ export async function bridgeSubmissionEvents(server: Bun.Server<SubmissionSocket
|
|||||||
server.publish(configTopic, raw)
|
server.publish(configTopic, raw)
|
||||||
return
|
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) {
|
if (channel === sessionRevokedChannel) {
|
||||||
const revoked = parseSessionRevoked(raw)
|
const revoked = parseSessionRevoked(raw)
|
||||||
if (!revoked) return
|
if (!revoked) return
|
||||||
@@ -439,6 +468,6 @@ export async function bridgeSubmissionEvents(server: Bun.Server<SubmissionSocket
|
|||||||
})
|
})
|
||||||
// 连接层的 error 已经由 createSubscriberRedis 里的 withErrorLogging 打了
|
// 连接层的 error 已经由 createSubscriberRedis 里的 withErrorLogging 打了
|
||||||
// (带连接名),这里再挂一个只会把同一条错误打两遍
|
// (带连接名),这里再挂一个只会把同一条错误打两遍
|
||||||
await subscriber.subscribe(submissionUpdateChannel, userEventChannel, configUpdateChannel, sessionRevokedChannel)
|
await subscriber.subscribe(submissionUpdateChannel, userEventChannel, configUpdateChannel, sessionRevokedChannel, classEventChannel)
|
||||||
return subscriber
|
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 { useConfigStore } from 'shared/store/config'
|
||||||
import { useUserStore } from 'shared/store/user'
|
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 —— 超管改完配置,所有开着页面
|
* 收 /ws/config 的站点配置广播,写进 configStore —— 超管改完配置,所有开着页面
|
||||||
@@ -13,7 +14,12 @@ export function useConfigUpdate() {
|
|||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const handleConfigUpdate = (data: ConfigUpdate) => {
|
const handleConfigUpdate = (data: ConfigChannelMessage) => {
|
||||||
|
// 同一条连接上的班级广播:老师改了这节课的题(只推给学生,见后端 classTopicOf)
|
||||||
|
if (data.type === 'lesson_update') {
|
||||||
|
receiveLessonUpdate(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
|
// 认不出来的键直接忽略:后端将来多推一个字段,不该把 store 撑出个野字段
|
||||||
if (!(data.key in configStore.config)) return
|
if (!(data.key in configStore.config)) return
|
||||||
;(configStore.config as unknown as Record<string, unknown>)[data.key] = data.value
|
;(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 { createDiscreteApi } from 'naive-ui'
|
||||||
import { ref, getCurrentScope, onScopeDispose, type Ref } from 'vue'
|
import { ref, getCurrentScope, onScopeDispose, type Ref } from 'vue'
|
||||||
|
|
||||||
@@ -559,14 +559,19 @@ export interface ConfigUpdate extends WebSocketMessage {
|
|||||||
value: any
|
value: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这条通道上还有一种按班推的:老师改了这节课的题(学生才收得到,见契约 lessonUpdateSchema)
|
||||||
|
*/
|
||||||
|
export type ConfigChannelMessage = ConfigUpdate | LessonUpdate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 站点配置广播。这条通道是**单向**的:只收后端广播。服务端的消息处理只认
|
* 站点配置广播。这条通道是**单向**的:只收后端广播。服务端的消息处理只认
|
||||||
* ping / subscribe,客户端往这里推 config_update 会被回一个 error 帧 ——
|
* ping / subscribe,客户端往这里推 config_update 会被回一个 error 帧 ——
|
||||||
* 别拿返回值里的 subscribe 往这条通道上发东西。配置变更走 POST /admin/website,
|
* 别拿返回值里的 subscribe 往这条通道上发东西。配置变更走 POST /admin/website,
|
||||||
* 由后端广播给所有人。
|
* 由后端广播给所有人。
|
||||||
*/
|
*/
|
||||||
export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
|
export function useConfigWebSocket(handler?: MessageHandler<ConfigChannelMessage>) {
|
||||||
return useChannel<ConfigUpdate>('/ws/config', handler)
|
return useChannel<ConfigChannelMessage>('/ws/config', handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollabRequestItem {
|
export interface CollabRequestItem {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Header from '../components/Header.vue'
|
|||||||
import Login from '../components/Login.vue'
|
import Login from '../components/Login.vue'
|
||||||
import Signup from '../components/Signup.vue'
|
import Signup from '../components/Signup.vue'
|
||||||
import AchievementToast from '../components/AchievementToast.vue'
|
import AchievementToast from '../components/AchievementToast.vue'
|
||||||
|
import LessonUpdateNotice from '../components/LessonUpdateNotice.vue'
|
||||||
import { useAchievementStore } from 'shared/store/achievement'
|
import { useAchievementStore } from 'shared/store/achievement'
|
||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ watch(
|
|||||||
<Login />
|
<Login />
|
||||||
<Signup />
|
<Signup />
|
||||||
<AchievementToast />
|
<AchievementToast />
|
||||||
|
<LessonUpdateNotice />
|
||||||
<Beian />
|
<Beian />
|
||||||
</n-layout>
|
</n-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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<typeof lessonUpdateSchema>
|
||||||
|
|
||||||
export type ClassActivity = z.infer<typeof classActivitySchema>
|
export type ClassActivity = z.infer<typeof classActivitySchema>
|
||||||
export type ClassToday = z.infer<typeof classTodaySchema>
|
export type ClassToday = z.infer<typeof classTodaySchema>
|
||||||
export type LastVisit = z.infer<typeof lastVisitSchema>
|
export type LastVisit = z.infer<typeof lastVisitSchema>
|
||||||
|
|||||||
Reference in new issue
Block a user