diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index 21f8269..6054965 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -40,12 +40,34 @@ function serializeRequest(request: HelpRequest) { } } +/** + * 把最新的排队位置推给每个还在等的学生。 + * + * queueAhead 原来只在「建请求 / 重连 / 退回排队」这三处推过,前面的人被接走或被 + * 取消之后不重算 —— 五个人排队、前四个都处理完了,第五个还一直显示「前面还有 4 人」。 + */ +function broadcastQueuePositions() { + for (const request of listRequests()) { + if (request.status !== "pending") continue + sendHelpStatus(request.socket, "pending", { + queueAhead: queueAheadOf(request.studentId), + }) + } +} + +/** + * 队列变了:老师端收全量列表,排队中的学生各自收自己的新位置。 + * + * 两件事捏在一起是因为它们永远同时发生 —— 拆成两个函数分别调,迟早会在某条 + * 路径上漏掉一个。 + */ export function broadcastRequests() { const payload = JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest), }) for (const ws of teacherSockets()) ws.send(payload) + broadcastQueuePositions() } function sendHelpStatus( @@ -77,12 +99,32 @@ export function handleCollabOpen(ws: CollabSocket) { const room = getRoom(ws.data.userId) if (room && room.studentSocket !== ws) { - // 旧 socket 不再是这间房的主人:清掉它的 roomOwnerId,不然它稍后触发的 - // close 会经 roomOf 认出这间刚刚转移出去的房间,把它拆了——一条早该 - // 死透的连接反而有权拆掉正在用的新连接的房间 + // 协作中的学生换了一条连接。**不迁移房间,直接拆掉。** + // + // 原来这里是把 studentSocket 换成新连接就算完,转发确实转到新连接了, + // 但客户端接不住:前端每次连接建立都会把 room 清成 null(旧连接的状态 + // 不该越过重连活下来),而这里只补发了 help_status,没补 room_open —— + // 于是学生页面显示「老师正在帮你」、编辑器却早就把 yCollab 摘了, + // 老师照常敲字、一个字也到不了对面。正是 handleCollabBinary 注释里说的 + // 「看起来在协作、其实各看各的」,比老实断开更糟。 + // + // 而补发 room_open 也修不好:Yjs 的文档状态跟着旧连接一起没了,新连接 + // 只能新建 Y.Doc,再拿学生编辑器里的内容当种子插进去,就会和老师那份 + // 已有内容合并成重复文本(两份 doc 的 item 身份不同,CRDT 不去重)。 + // 续接一个 CRDT 会话不是哑转发层做得到的事。 + // + // 所以退回排队,老师再点一次 —— 和老师掉线走的是同一条路子。学生的代码 + // 一直在他自己的编辑器里,不受影响。 + closeRoom(room.studentId) room.studentSocket.data.roomOwnerId = undefined - room.studentSocket = ws - ws.data.roomOwnerId = ws.data.userId + room.teacherSocket.data.roomOwnerId = undefined + room.teacherSocket.send( + JSON.stringify({ type: "room_closed", reason: "peer_offline" }), + ) + request.status = "pending" + request.teacherId = undefined + request.teacherName = undefined + broadcastRequests() } if (request.status === "pending") { diff --git a/apps/web/src/shared/components/CollabModal.vue b/apps/web/src/shared/components/CollabModal.vue index 179a8ab..0105593 100644 --- a/apps/web/src/shared/components/CollabModal.vue +++ b/apps/web/src/shared/components/CollabModal.vue @@ -14,7 +14,6 @@ const isDark = useDark() const collabStore = useCollabStore() const { start, stop, getInitialExtension } = useCollabDoc() -const code = ref("") // shallowRef,理由见 SyncCodeEditor.vue:EditorView 是类实例,ref() 的深度 // UnwrapRef 会把它拆成一个丢了原型方法的假类型,vue-tsc 报莫名其妙的类型错。 const editorView = shallowRef(null) @@ -36,25 +35,44 @@ const extensions = computed(() => [ getInitialExtension(), ]) +const bind = (view: EditorView) => { + if (!collabStore.isTeacher || !collabStore.room) return + // ★ 教师端 seedContent 必须是 null —— 内容全部来自学生端 + start({ editorView: view, seedContent: null }) +} + +/** + * 起点是「编辑器就绪」,不是「房间打开」。 + * + * n-modal 默认 display-directive="if",每次打开都会重挂一个全新的 CodeMirror。 + * 原来在 watch 里 `await nextTick()` 之后去取 editorView:赶上 teleport + 离场 + * 过渡没走完,取到的是上一轮那个已经 destroy 的 view,start() 静默绑到死编辑器上, + * 老师对着空白框干等,服务端那边房间却是活的。 + */ const handleEditorReady = (payload: { view: EditorView }) => { editorView.value = payload.view + bind(payload.view) } watch( () => collabStore.room, - async (room) => { + (room) => { + // 常规路径下开房时编辑器还没挂,这里是 null,由 @ready 接手; + // 万一哪天 display-directive 改成 show(编辑器常驻),这条分支才起作用 if (room && collabStore.isTeacher) { - await nextTick() - if (!editorView.value) return - // ★ 教师端 seedContent 必须是 null —— 内容全部来自学生端 - start({ editorView: editorView.value as EditorView, seedContent: null }) + if (editorView.value) bind(editorView.value) } else { stop() + // 编辑器随模态框一起卸载了,留着这个引用下轮就会绑到一个死 view 上 + editorView.value = null } }, ) -onUnmounted(stop) +onUnmounted(() => { + stop() + editorView.value = null +}) + { private binaryHandler: ((data: ArrayBuffer) => void) | null = null private connectHandler: (() => void) | null = null + /** + * 还没装 handler 时先收着的二进制帧。 + * + * room_open 是两端同时收到的,但两端把 yCollab 挂上去的时刻并不同步: + * 教师端要等模态框挂出 CodeMirror,学生端要等 y* 那几个 chunk 下载完。 + * 谁先挂好谁就先发 SyncStep1,而对面这时还没有 handler —— + * 服务端只管转发(peer.send() 是成功的),帧就在客户端这儿被静静丢掉了。 + * + * 丢的偏偏是握手:y-protocols 里 A 的内容是靠 **B 发的 Step1** 换回来的。 + * 教师的 Step1 一丢,学生的代码就永远到不了教师那边 —— 教师看到空编辑器, + * 自己敲的字倒是能同步过去,看着像在协作,实际上只有单向。 + * + * 所以在这儿缓一手,等 setBinaryHandler 装上再按序放行。 + */ + private pendingBinary: ArrayBuffer[] = [] constructor() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" @@ -745,6 +760,14 @@ export class CollabWebSocket extends BaseWebSocket { setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) { this.binaryHandler = handler + if (!handler) { + // 协作结束:攒下的帧属于上一轮,放到下一轮去只会污染新文档 + this.pendingBinary = [] + return + } + const buffered = this.pendingBinary + this.pendingBinary = [] + for (const data of buffered) handler(data) } /** @@ -757,11 +780,22 @@ export class CollabWebSocket extends BaseWebSocket { this.connectHandler = handler } + /** 一轮握手也就几帧,给个上限纯粹是防呆:真堆到这个数说明哪里不对 */ + private static readonly MAX_PENDING_BINARY = 64 + protected override onBinary(data: ArrayBuffer) { - this.binaryHandler?.(data) + if (this.binaryHandler) { + this.binaryHandler(data) + return + } + if (this.pendingBinary.length < CollabWebSocket.MAX_PENDING_BINARY) { + this.pendingBinary.push(data) + } } protected override onConnected() { + // 新连接,旧连接攒下的帧一概作废 + this.pendingBinary = [] this.connectHandler?.() } }