From 88b695d34fb466639f3916031c3212e60e66dd0e Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 16 Sep 2026 19:13:42 -0600 Subject: [PATCH] =?UTF-8?q?feat(=E8=AF=BE=E5=A0=82=E6=B1=82=E5=8A=A9):=20?= =?UTF-8?q?=E6=95=99=E5=B8=88=E7=AB=AF=E6=8E=A5=E5=8D=95=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=B7=B3=E9=A2=98=E7=9B=AE=E9=A1=B5=E5=8D=8F=E4=BD=9C=EF=BC=8C?= =?UTF-8?q?=E5=88=A0=E6=8E=89=20CollabModal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点求助列表里的一条 = 跳到 /problem/:id,在页面自带的那个编辑器里和学生同步。 原来是弹一个 CollabModal(弹框里再挂一个 CodeMirror + 一份只读题面): - 按一下 Esc 弹框就关、协作跟着结束(`show` 的 setter 里 leave 是唯一的关闭语义, Esc 和点遮罩都走它),上课太容易误触; - 弹框里那份题面不能跑测试、不能提交,老师还是得另开标签页对照。 现在结束协作只有两条明确的路:工具栏的「结束协作」按钮、离开这一页。教师端也少了 一个 CodeMirror 实例 —— setBinaryHandler 那个单例槽位只剩 SyncCodeEditor 一个使用者。 改造中撞出两个真问题,根因是同一个:`/problem/1002` → `/problem/1001` 是同一条路由 换 params,**组件被复用、不卸载**。 - 老师在别的题上接单跳过来,`@ready` 不会再触发,而 room 早在导航前就 open 了 —— 老师停在一个空编辑器上干等。watch 的源从 room 一个变成 [room, roomIsHere] 两个。 - 反过来老师协作中切去别的题,onUnmounted 不触发 → 不发 leave,学生一直挂着 「老师正在帮你」而老师的字一个也过不去。加 `bound` 标记区分「房间从我这儿挪走了」 (补发 leave)和「房间在别处开起来了」(跟我无关,接单那一瞬间就是这种)。 `leave` 分两种语义(协议加 reason): | 前端 | 求助记录 | 对面看到 | |---|---|---| | leave("done") 点「结束协作」 | 删除 | 老师已结束这次帮忙 | | leave("left") 离开这道题的页面 | 教师走→退回排队;学生走→删除 | 老师暂时离开,已重新排队 | "left" 复用的是老师掉线那条路(requeueAfterTeacherGone),两件事语义相同。原来两者 都按 done 处理 —— 老师点一下「提交信息」,学生就得重新举手,而他看到的是 「协作已结束」,会以为被处理完了。发起方收到的 reason 单独一份(self_left/done), 不然学生自己切走了却看到「老师已结束这次帮忙」。 其余为这套交互补的闸: - 学生排队期间离开那道题 → 自动取消求助。那条求助说的是「我卡在这道题」,人走了 就不成立;原来它会一直挂在队列里,老师接进来时学生的编辑器不在这道题上、根本 不会绑,老师对着空编辑器敲字,两边都没提示。 - 协作中**教师的语言选择跟着 room.language 走**(写 codeStore,不只是改高亮), 结束后还原。只改高亮的话老师会拿自己那档语言提交学生的代码(学生写 C、老师选的 是 Python,当场 CE),工具栏还可能显示「提交流程图」。学生端一个字不动 —— 服务端的 room_language 只发给教师,学生本地那份停在建房那一刻。 - 协作中不给「重置代码」:v-model 一写回就顺着 Yjs 同步过去,等于一键清空学生的作业。 - 协作中「提交信息」走新标签 —— 那是教师工具栏上唯一会跳路由的按钮。 - 接单时 resetScreenMode():分屏在「题目」「自测」两档时右侧编辑器根本没挂出来, 停在同一道题上接单会落进一个没有编辑器的页面(跳到别的题时 detail.vue 的 init() 会重置,同页不会)。 - 老师已在一个房间里时前端也拦住接下一单(服务端本来就拦),不拦的话前端已经跳到 新题目上,等于把手上那场协作断掉。 - 文案:协议层那四条英文(Invalid problemId 之类)会被原样弹成 toast,改成中文; 「请先退出当前协作」统一成「结束」;room_closed 的提示按 reason × 角色展开。 验证:本机起 api + web,三个账号两两组合实跑 —— 跨题接单 / 同页接单、双向编辑、 连按 Esc、结束协作、老师跳走后再接、学生跳走、学生排队中切题、学生协作中切语言、 分屏在「题目」档接单、协作中重复接单,以及上面每一句提示的实际文案。 api typecheck / check:routes、前端 type-check / build、fmt 全过。 Co-Authored-By: Claude Opus 5 --- apps/api/src/collab/handler.ts | 87 ++++- apps/web/src/oj/problem/components/Form.vue | 54 +++- .../oj/problem/components/ProblemEditor.vue | 85 ++++- apps/web/src/shared/components/CollabHost.vue | 22 +- .../web/src/shared/components/CollabModal.vue | 306 ------------------ .../src/shared/components/HelpRequestList.vue | 40 ++- .../src/shared/components/SyncCodeEditor.vue | 134 ++++++-- apps/web/src/shared/extensions/language.ts | 2 +- apps/web/src/shared/store/collab.ts | 47 ++- .../2026-08-28-collab-help-request-design.md | 88 ++++- 10 files changed, 480 insertions(+), 385 deletions(-) delete mode 100644 apps/web/src/shared/components/CollabModal.vue diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index 534850d..5ef4c5c 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -25,6 +25,12 @@ import { type Room, } from "./state" +/** + * `type: "error"` 的 message **会被前端原样弹成 toast**(store 的 case "error" + * → setNotice → CollabHost 的 message.info),所以这里一律写中文、写成学生看得懂的 + * 话。协议层的校验错误(格式不对、题号不对)正常前端触发不到,但真触发了也得是 + * 一句人话 —— 原来那几条是 "Invalid problemId" 这样的英文,直接糊在学生脸上。 + */ function isTeacher(ws: CollabSocket) { return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? "")) } @@ -191,11 +197,17 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) { problemId?: unknown studentId?: unknown language?: unknown + reason?: unknown } try { message = JSON.parse(raw) as typeof message } catch { - ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })) + ws.send( + JSON.stringify({ + type: "error", + message: "消息格式不对,请刷新页面重试", + }), + ) return } @@ -230,10 +242,15 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) { await handleReject(ws, message.studentId) return case "leave": - handleLeave(ws) + handleLeave(ws, message.reason) return default: - ws.send(JSON.stringify({ type: "error", message: "Invalid message" })) + ws.send( + JSON.stringify({ + type: "error", + message: "不认识的操作,请刷新页面重试", + }), + ) } } @@ -243,7 +260,9 @@ async function handleHelpRequest( language: unknown, ) { if (typeof problemId !== "string" || !problemId) { - ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" })) + ws.send( + JSON.stringify({ type: "error", message: "题号不对,请刷新页面重试" }), + ) return } if (isTeacher(ws)) { @@ -338,7 +357,12 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) { return } if (typeof studentId !== "number") { - ws.send(JSON.stringify({ type: "error", message: "Invalid studentId" })) + ws.send( + JSON.stringify({ + type: "error", + message: "学生标识不对,请刷新页面重试", + }), + ) return } @@ -366,7 +390,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) { // 老师同时只能在一个房间 if (roomOf(ws)) { - ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" })) + ws.send(JSON.stringify({ type: "error", message: "请先结束当前协作" })) return } @@ -440,32 +464,63 @@ async function handleReject(ws: CollabSocket, studentId: unknown) { broadcastRequests() } -/** 主动退出房间。老师点关闭、学生点结束都走这里 */ -function handleLeave(ws: CollabSocket) { +/** + * 主动退出房间。**两种语义,靠 reason 分**: + * + * - 不带 reason(或 `"done"`)—— 有人点了「结束协作」,这次帮忙到此结束, + * 求助记录一并清掉; + * - `"left"` —— 人只是离开了这道题的页面(教师端「页面即协作现场」,跳走就不在 + * 房间里了)。**这跟他掉线是同一件事**,所以走同一条收尾:教师离开 → 求助退回 + * 排队,学生不用重新举手,老师回来再点一次就接上;学生离开 → 求助随人清掉。 + * + * 分开是因为两者对学生的意义完全不同:前者是「搞定了」,后者是「老师先走一下」, + * 而原来都按前者处理 —— 老师点一下「提交信息」,学生就得重新举手。 + */ +function handleLeave(ws: CollabSocket, reason: unknown) { const room = roomOf(ws) if (!room) return - teardownRoom(room, "done") + if (reason !== "left") { + teardownRoom(room, "done") + return + } + const side = ws === room.teacherSocket ? "teacher" : "student" + teardownRoom(room, "peer_left", side, ws) } /** * 拆房间。reason 决定两端看到什么: * done —— 有人主动结束,双方都收到,请求一并清除 * peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose / - * handleCollabBinary。offlineSide 是消失的那一方:老师消失, - * 请求退回排队;学生消失,请求随人清掉。不传时(当前只有 - * handleLeave 走 "done")不做这一步,只拆房间 + * handleCollabBinary + * peer_left —— 有人离开了这道题的页面(handleLeave 的 "left") + * + * offlineSide 是消失的那一方,决定请求的去向:老师消失 → 退回排队;学生消失 → + * 随人清掉。不传时只拆房间。 + * + * initiator 是主动发起的那条连接:**他收到的 reason 不一样** —— 点了「结束协作」 + * 是 `done`,离开页面是 `self_left`。对他来说这是「我自己干的」,不该看到一句 + * 「对方离开了」,也不该看到「老师已结束这次帮忙」。 */ function teardownRoom( room: Room, - reason: "done" | "peer_offline", + reason: "done" | "peer_offline" | "peer_left", offlineSide?: "student" | "teacher", + initiator?: CollabSocket, ) { closeRoom(room.studentId) room.studentSocket.data.roomOwnerId = undefined room.teacherSocket.data.roomOwnerId = undefined - const frame = JSON.stringify({ type: "room_closed", reason }) - room.studentSocket.send(frame) - room.teacherSocket.send(frame) + // 发起方收到的是「我自己干的」那一版:点了结束就是 done,离开页面是 self_left。 + // 不能跟对面收同一条 —— 学生自己切走了却看到「老师已结束这次帮忙」是假话 + const selfReason = reason === "peer_left" ? "self_left" : "done" + for (const socket of [room.studentSocket, room.teacherSocket]) { + socket.send( + JSON.stringify({ + type: "room_closed", + reason: socket === initiator ? selfReason : reason, + }), + ) + } if (reason === "done") { removeRequest(room.studentId) } else if (offlineSide === "teacher") { diff --git a/apps/web/src/oj/problem/components/Form.vue b/apps/web/src/oj/problem/components/Form.vue index fa8f42c..f0be406 100644 --- a/apps/web/src/oj/problem/components/Form.vue +++ b/apps/web/src/oj/problem/components/Form.vue @@ -64,6 +64,20 @@ const showHelpButton = computed( !isContestMode.value, ) +/** + * 教师端:协作就开在这道题上。接单后直接跳到题目页协作(原来是 CollabModal 弹框), + * 所以状态和「结束协作」得摆在题目页的工具栏上 —— 也只有这一个按钮能结束, + * 不像弹框那样按一下 Esc 就把协作关掉了。 + */ +const collabHere = computed( + () => + collabStore.room !== null && + collabStore.room.problemId === problem.value?._id, +) +const showCollabBar = computed( + () => collabHere.value && userStore.isTeacherOrAbove, +) + /** * 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏 * (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。 @@ -134,10 +148,14 @@ const menuOptions = computed(() => { label: "复制代码", key: "copy", }) - options.push({ - label: "重置代码", - key: "reset", - }) + // 协作中的教师不给「重置代码」:那会儿编辑器里是**学生的**代码,而 v-model + // 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回 + if (!showCollabBar.value) { + options.push({ + label: "重置代码", + key: "reset", + }) + } } if (isDesktop.value && userStore.isSuperAdmin) { options.push({ @@ -216,7 +234,15 @@ const goTestCat = () => { const goSubmissions = () => { const name = route.params.contestID ? "contest submissions" : "submissions" - router.push({ name, query: { problem: problem.value!._id } }) + const target = { name, query: { problem: problem.value!._id } } + // 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了 + // (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是 + // 协作时最常点的一个按钮 —— 这是整条工具栏上唯一会跳路由的按钮 + if (showCollabBar.value) { + window.open(router.resolve(target).href, "_blank") + return + } + router.push(target) } const goEdit = () => { @@ -236,11 +262,13 @@ onMounted(() => {