From 38b81881d52e99dc33add665901f453b3ea250ef Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 30 Aug 2026 10:28:36 -0600 Subject: [PATCH] =?UTF-8?q?feat(collab):=20=E6=95=99=E5=B8=88=E7=AB=AF?= =?UTF-8?q?=E5=BC=B9=E6=A1=86=E8=A1=A5=E4=B8=8A=E4=BB=A3=E7=A0=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E8=AF=AD=E8=A8=80=E8=B7=9F=E7=9D=80=E5=AD=A6?= =?UTF-8?q?=E7=94=9F=E8=B5=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 教师端的协作编辑器只有高亮和括号匹配,没装 autocompletion —— 老师替学生 写代码时没有下拉补全,只能盲敲。而且语言写死 cpp(),学生写 Python 时老师 看到的是 C 的高亮。 - 求助协议带上语言:help_request 记到 HelpRequest,accept 时写进 Room, room_open 发给两端;认不出的语言一律当 C(老客户端不带这个字段,而它 以前就是写死 C 的) - 新增 help_language / room_language:学生在排队或协作期间切语言,只更新 语言,不动队列位置和房间;已开房就把新语言推给老师,弹框的高亮和补全 实时跟着换 - CollabModal 装上 autocompletion,和学生端用同一套 enhanceCompletion + completeAnyWord;语言→高亮扩展的映射抽到 shared/extensions/language.ts 两端共用,免得再分叉 - 学生端求助按钮合并状态提示:原来按钮旁边还挂一个 n-tag,一行工具栏在 1280 的机房屏上放不下,改成状态全进 label(已求助 · 待接入 / 已求助 · 前面 N 人 / xxx 老师帮你中) 扩展数组变了 vue-codemirror 会整体 reconfigure,但 CM6 对已存在的 compartment 取 `compartments.get() || ext.inner`,collabDoc 那个装 yCollab 的 compartment 内容会被沿用,切语言、切主题都不会把协作弄断 —— 实跑验证过 双向同步、补全下拉、协作中切语言三条路径。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015APGLsyaCUa7XFMYAaCho4 --- apps/api/src/collab/handler.ts | 45 ++++++++++++++- apps/api/src/collab/state.ts | 31 ++++++++++ apps/web/src/oj/problem/components/Form.vue | 56 ++++++++++++------- .../web/src/shared/components/CollabModal.vue | 27 +++++++-- .../src/shared/components/SyncCodeEditor.vue | 20 ++++--- apps/web/src/shared/composables/websocket.ts | 2 + apps/web/src/shared/extensions/language.ts | 16 ++++++ apps/web/src/shared/store/collab.ts | 22 +++++++- 8 files changed, 181 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/shared/extensions/language.ts diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index 6054965..f6b0333 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -11,6 +11,7 @@ import { getRoom, hasTeacherOnline, listRequests, + normalizeLanguage, openRoom, queueAheadOf, removeRequest, @@ -179,7 +180,12 @@ export function handleCollabClose(ws: CollabSocket) { } export async function handleCollabMessage(ws: CollabSocket, raw: string) { - let message: { type?: unknown; problemId?: unknown; studentId?: unknown } + let message: { + type?: unknown + problemId?: unknown + studentId?: unknown + language?: unknown + } try { message = JSON.parse(raw) as typeof message } catch { @@ -201,7 +207,10 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) { switch (message.type) { case "help_request": - await handleHelpRequest(ws, message.problemId) + await handleHelpRequest(ws, message.problemId, message.language) + return + case "help_language": + handleHelpLanguage(ws, message.language) return case "help_cancel": handleHelpCancel(ws) @@ -220,7 +229,11 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) { } } -async function handleHelpRequest(ws: CollabSocket, problemId: unknown) { +async function handleHelpRequest( + ws: CollabSocket, + problemId: unknown, + language: unknown, +) { if (typeof problemId !== "string" || !problemId) { ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" })) return @@ -266,6 +279,7 @@ async function handleHelpRequest(ws: CollabSocket, problemId: unknown) { className: student?.className ?? null, problemId, problemTitle: problem.title, + language: normalizeLanguage(language), createdAt: Date.now(), status: "pending", socket: ws, @@ -274,6 +288,29 @@ async function handleHelpRequest(ws: CollabSocket, problemId: unknown) { broadcastRequests() } +/** + * 学生在求助期间换了语言。 + * + * 只更新语言,不动队列位置、不动房间 —— 换语言不该让他重新排队。已经在协作中的 + * 话再推一条 room_language,老师端的高亮和补全立刻跟着换;不推的话老师会拿着 + * 建房那一刻的语言,对着一套错的补全替学生写代码。 + */ +function handleHelpLanguage(ws: CollabSocket, language: unknown) { + const request = getRequest(ws.data.userId) + // 比对 socket 归属:同账号的另一个标签页停在别的题上切语言,不该改这条求助 + if (!request || request.socket !== ws) return + const next = normalizeLanguage(language) + if (request.language === next) return + request.language = next + + const room = getRoom(ws.data.userId) + if (!room) return + room.language = next + room.teacherSocket.send( + JSON.stringify({ type: "room_language", language: next }), + ) +} + function handleHelpCancel(ws: CollabSocket) { const request = getRequest(ws.data.userId) // 不比对 socket 归属:取消的是这个学生自己的求助,不管从他哪个标签页发起都 @@ -341,6 +378,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) { studentSocket: request.socket, teacherSocket: ws, problemId: request.problemId, + language: request.language, }) const openFrame = (peerName: string, peerRole: "student" | "teacher") => @@ -348,6 +386,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) { type: "room_open", peer: { name: peerName, role: peerRole }, problemId: request.problemId, + language: request.language, }) request.socket.send(openFrame(request.teacherName, "teacher")) ws.send(openFrame(request.studentName, "student")) diff --git a/apps/api/src/collab/state.ts b/apps/api/src/collab/state.ts index 6418147..fc163a9 100644 --- a/apps/api/src/collab/state.ts +++ b/apps/api/src/collab/state.ts @@ -8,6 +8,30 @@ export type CollabSocket = Bun.ServerWebSocket +/** + * 协作支持的语言。和前端 utils/types.ts 里的 LANGUAGE 对齐,去掉 Flowchart —— + * 流程图题没有代码编辑器,求助入口本身就是隐藏的。 + */ +export const COLLAB_LANGUAGES = [ + "C", + "C++", + "Python2", + "Python3", + "Java", + "JavaScript", + "Golang", + "SQL", +] as const + +export type CollabLanguage = (typeof COLLAB_LANGUAGES)[number] + +/** 认不出来的一律当 C:老客户端不带这个字段,而它以前就是写死 C 的 */ +export function normalizeLanguage(value: unknown): CollabLanguage { + return (COLLAB_LANGUAGES as readonly string[]).includes(value as string) + ? (value as CollabLanguage) + : "C" +} + export interface HelpRequest { studentId: number studentName: string @@ -15,6 +39,11 @@ export interface HelpRequest { /** 题目的展示号(problem._id 列,前端一路用的都是它),不是自增主键 */ problemId: string problemTitle: string + /** + * 学生编辑器当前的语言。决定教师端弹框用哪套高亮和补全 —— 求助时带上, + * 协作期间学生切语言会用 help_language 更新这里 + */ + language: CollabLanguage createdAt: number status: "pending" | "active" teacherId?: number @@ -83,6 +112,8 @@ export interface Room { studentSocket: CollabSocket teacherSocket: CollabSocket problemId: string + /** 建房那一刻学生的语言,之后跟着 help_language 走 */ + language: CollabLanguage } const rooms = new Map() diff --git a/apps/web/src/oj/problem/components/Form.vue b/apps/web/src/oj/problem/components/Form.vue index bb62a4c..fa8f42c 100644 --- a/apps/web/src/oj/problem/components/Form.vue +++ b/apps/web/src/oj/problem/components/Form.vue @@ -64,16 +64,39 @@ const showHelpButton = computed( !isContestMode.value, ) +/** + * 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏 + * (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。 + */ const helpButtonText = computed(() => { - if (collabStore.helpStatus === "active") return "老师正在帮你" - if (collabStore.helpStatus === "pending") return "取消求助" + if (collabStore.helpStatus === "active") { + const name = collabStore.teacherName + return name ? `${name} 老师帮你中` : "老师正在帮你" + } + if (collabStore.helpStatus === "pending") { + return collabStore.queueAhead > 0 + ? `已求助 · 前面 ${collabStore.queueAhead} 人` + : "已求助 · 待接入" + } return "求助" }) +const helpButtonType = computed(() => { + if (collabStore.helpStatus === "active") return "success" + if (collabStore.helpStatus === "pending") return "warning" + return "default" +}) + +// 排队中点一下就是取消,这句话再挤进 label 就太长了,挂在原生 title 上。 +// active 时按钮是 disabled,浏览器不会给 disabled 元素显示 title,所以不放 +const helpButtonTitle = computed(() => + collabStore.helpStatus === "pending" ? "点击取消求助" : undefined, +) + const toggleHelp = () => { if (collabStore.helpStatus === "pending") collabStore.cancelHelp() else if (collabStore.helpStatus === "idle") - collabStore.requestHelp(problem.value!._id) + collabStore.requestHelp(problem.value!._id, codeStore.code.language) } const showGoSubmissionButton = computed(() => { @@ -251,23 +274,16 @@ onMounted(() => { 更多操作 - + + {{ helpButtonText }} + -import { cpp } from "@codemirror/lang-cpp" import { bracketMatching } from "@codemirror/language" -import { closeBrackets } from "@codemirror/autocomplete" +import { + autocompletion, + closeBrackets, + completeAnyWord, +} from "@codemirror/autocomplete" import type { EditorView } from "@codemirror/view" import { Codemirror } from "vue-codemirror" +import type { LANGUAGE } from "utils/types" import { oneDark } from "../themes/oneDark" import { smoothy } from "../themes/smoothy" import { styleTheme } from "shared/extensions/baseTheme" +import { enhanceCompletion } from "shared/extensions/autocompletion" +import { languageExtension } from "shared/extensions/language" import { useCollabDoc } from "../composables/collabDoc" import { useCollabStore } from "shared/store/collab" @@ -26,12 +32,25 @@ const show = computed({ }, }) +/** + * 语言跟着学生走:room_open 带过来,学生协作期间切语言会再来一条 room_language。 + * 取不到时按 C —— 和这里原来写死 cpp() 的表现一致。 + */ +const language = computed(() => collabStore.room?.language ?? "C") + const extensions = computed(() => [ styleTheme, - cpp(), + languageExtension(language.value), bracketMatching(), closeBrackets(), isDark.value ? oneDark : smoothy, + // 学生端有的补全这里必须也有:老师是在替学生写代码,缺了下拉菜单只能盲敲。 + // 数组一变 vue-codemirror 会整体 reconfigure,但 collabDoc 那个 compartment + // 的内容会被 CM6 沿用(@codemirror/state 的 flatten 里 `compartments.get() || + // ext.inner`),所以切语言、切主题都不会把 yCollab 弄掉 + autocompletion({ + override: [enhanceCompletion(language.value), completeAnyWord], + }), getInitialExtension(), ]) @@ -80,7 +99,7 @@ onUnmounted(() => { v-model:show="show" preset="card" :style="{ width: '80vw', maxWidth: '1100px' }" - :title="`正在帮 ${collabStore.room?.peerName ?? ''} · ${collabStore.room?.problemId ?? ''}`" + :title="`正在帮 ${collabStore.room?.peerName ?? ''} · ${collabStore.room?.problemId ?? ''} · ${language}`" >