diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 3770664..31f36d7 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -6,10 +6,12 @@ import { useConfigStore } from "shared/store/config" import { useConfigUpdate } from "shared/composables/configUpdate" import { useMaxKB } from "shared/composables/maxkb" import { useUserStore } from "shared/store/user" +import { useCollabStore } from "shared/store/collab" const isDark = useDark() const configStore = useConfigStore() const userStore = useUserStore() +const collabStore = useCollabStore() // 初始化配置和实时更新 onMounted(() => { @@ -23,6 +25,17 @@ onMounted(() => { useConfigUpdate() useMaxKB() +// 课堂求助通道。和 /ws/config 一样是全局常驻的:老师可能正在后台改题时 +// 收到求助,学生也要在排队期间一直挂着,所以不放在题目页里起落 +watch( + () => userStore.isAuthed, + (isAuthed) => { + if (isAuthed) collabStore.connect() + else collabStore.disconnect() + }, + { immediate: true }, +) + // 延迟加载 highlight.js,避免阻塞首屏 const hljsInstance = ref(null) const loadHighlightJS = async () => { diff --git a/apps/web/src/shared/composables/websocket.ts b/apps/web/src/shared/composables/websocket.ts index 5d7eea5..9a885d2 100644 --- a/apps/web/src/shared/composables/websocket.ts +++ b/apps/web/src/shared/composables/websocket.ts @@ -709,3 +709,44 @@ export function useConfigWebSocket(handler?: MessageHandler) { removeHandler: (h: MessageHandler) => ws.removeHandler(h), } } + +export interface CollabRequestItem { + studentId: number + studentName: string + className: string | null + problemId: string + problemTitle: string + createdAt: number + status: "pending" | "active" + teacherName: string | null +} + +export interface CollabMessage extends WebSocketMessage { + type: + | "requests" + | "help_status" + | "room_open" + | "room_closed" + | "error" +} + +/** + * 课堂求助 / 协作通道。和另外两条的区别是它**双向**且**收发二进制** —— + * 控制面是 JSON,Yjs 的 update / awareness 走 sendRaw 与 onBinary。 + */ +export class CollabWebSocket extends BaseWebSocket { + private binaryHandler: ((data: ArrayBuffer) => void) | null = null + + constructor() { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + super({ url: `${protocol}//${window.location.host}/ws/collab` }) + } + + setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) { + this.binaryHandler = handler + } + + protected override onBinary(data: ArrayBuffer) { + this.binaryHandler?.(data) + } +} diff --git a/apps/web/src/shared/store/collab.ts b/apps/web/src/shared/store/collab.ts new file mode 100644 index 0000000..693ea11 --- /dev/null +++ b/apps/web/src/shared/store/collab.ts @@ -0,0 +1,172 @@ +import { + CollabWebSocket, + type CollabMessage, + type CollabRequestItem, +} from "shared/composables/websocket" +import { useUserStore } from "shared/store/user" + +export type HelpStatus = "idle" | "pending" | "active" + +export interface RoomInfo { + peerName: string + peerRole: "student" | "teacher" + problemId: string +} + +/** + * 课堂求助的全局状态。 + * + * 连接是**全局常驻**的,不跟着题目页起落 —— 老师可能正在后台改题时收到求助, + * 学生也需要在等待期间一直挂着。所以这里不用 onUnmounted,由 App.vue 按登录态开关。 + */ +export const useCollabStore = defineStore("collab", () => { + const userStore = useUserStore() + + const ws = new CollabWebSocket() + + /** 老师端:待处理列表 */ + const requests = ref([]) + /** 学生端:自己的求助状态 */ + const helpStatus = ref("idle") + const queueAhead = ref(0) + const teacherName = ref("") + /** 双方:当前房间。null 表示不在协作中 */ + const room = ref(null) + /** 一次性提示,由组件消费后清空 */ + const notice = ref("") + + const pendingCount = computed( + () => requests.value.filter((it) => it.status === "pending").length, + ) + + /** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */ + const groupedRequests = computed(() => { + const groups = new Map() + for (const item of requests.value) { + const group = groups.get(item.problemId) + if (group) group.items.push(item) + else + groups.set(item.problemId, { + problemId: item.problemId, + problemTitle: item.problemTitle, + items: [item], + }) + } + // 人多的题排前面;人数相同按最久等待排 + return Array.from(groups.values()).sort( + (a, b) => + b.items.length - a.items.length || + a.items[0].createdAt - b.items[0].createdAt, + ) + }) + + const handleMessage = (data: CollabMessage) => { + switch (data.type) { + case "requests": + requests.value = (data.list ?? []) as CollabRequestItem[] + return + case "help_status": + if (data.status === "pending") { + helpStatus.value = "pending" + queueAhead.value = Number(data.queueAhead ?? 0) + } else if (data.status === "active") { + helpStatus.value = "active" + teacherName.value = String(data.teacherName ?? "") + } else if (data.status === "cancelled") { + helpStatus.value = "idle" + notice.value = "老师已取消你的求助" + } else if (data.status === "no_teacher") { + helpStatus.value = "idle" + notice.value = "当前没有老师在线" + } + return + case "room_open": + room.value = { + peerName: String(data.peer?.name ?? ""), + peerRole: data.peer?.role === "teacher" ? "teacher" : "student", + problemId: String(data.problemId ?? ""), + } + return + case "room_closed": + room.value = null + // 老师掉线时服务端会另发一条 help_status:pending,这里不抢着改学生状态 + if (data.reason === "done") helpStatus.value = "idle" + notice.value = + data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束" + return + case "error": + notice.value = String(data.message ?? "") + return + } + } + + ws.addHandler(handleMessage) + + function connect() { + ws.connect() + } + + function disconnect() { + ws.disconnect() + requests.value = [] + helpStatus.value = "idle" + room.value = null + } + + function requestHelp(problemId: string) { + ws.send({ type: "help_request", problemId }) + } + + function cancelHelp() { + ws.send({ type: "help_cancel" }) + helpStatus.value = "idle" + } + + function accept(studentId: number) { + ws.send({ type: "accept", studentId }) + } + + function reject(studentId: number) { + ws.send({ type: "reject", studentId }) + } + + function leave() { + ws.send({ type: "leave" }) + } + + function sendBinary(data: Uint8Array) { + ws.sendRaw(data) + } + + function setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) { + ws.setBinaryHandler(handler) + } + + function consumeNotice() { + const value = notice.value + notice.value = "" + return value + } + + return { + requests, + pendingCount, + groupedRequests, + helpStatus, + queueAhead, + teacherName, + room, + notice, + isTeacher: computed(() => userStore.isTeacherOrAbove), + connect, + disconnect, + requestHelp, + cancelHelp, + accept, + reject, + leave, + sendBinary, + setBinaryHandler, + consumeNotice, + } +})