feat(web): 协作编辑走 collab 通道

Yjs sync/awareness 协议直接跑在 /ws/collab 上,服务端哑转发。
内容源是学生:学生端先把编辑器内容写进 ytext 再挂 yCollab,
教师端 seedContent 恒为 null —— 这条根治了老实现里谁的代码活下来看运气的问题。

订正 brief 里的一处疏漏:SyncCodeEditor.vue 挂在每个用户的题目页上,教师也不例外
(ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色)。教师接单同样会让
collabStore.room 非空,若不按角色收窄,教师自己停在某道题页面上接单时,这个组件
会把教师自己的编辑器内容当成种子插入文档,还会跟 CollabModal 抢
setBinaryHandler 这个单例槽位。改为 `room && !collabStore.isTeacher` 才起协作,
教师端的协作只归 CollabModal 管。

另外两处修正:
- editorView 用 shallowRef 而非 ref —— CodeMirror 的 EditorView 是带 getter 的类
  实例,ref() 的深度 UnwrapRef 会把它拆成丢了原型方法的假类型,vue-tsc 报错。
- Header.vue 挂 CollabModal 放进根 n-flex 内部而不是同级 —— 组件一旦变成多根
  fragment,default.vue 里 `<Header class="header" />` 的 class 就没有单一根节点
  可以落地,header 行会丢掉居中样式(实测触发了 Vue 的
  Extraneous non-props attributes 警告)。n-modal 默认 teleport 到 body,塞在
  这里不影响其实际渲染位置。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
xuyueandClaude Opus 5 committed 2026-08-28 07:43:08 -06:00
1 parent bfe4468fdb
commit 86857dedf0
6 files changed
+309 -2

No files matched your search

@@ -10,13 +10,18 @@ import {
completeAnyWord,
} from "@codemirror/autocomplete"
import type { Extension } from "@codemirror/state"
import type { EditorView } from "@codemirror/view"
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 { useCollabDoc } from "../composables/collabDoc"
import { useCollabStore } from "shared/store/collab"
const isDark = useDark()
const collabStore = useCollabStore()
const { start, stop, getInitialExtension } = useCollabDoc()
interface Props {
problem: string
@@ -51,8 +56,46 @@ const extensions = computed(() => [
autocompletion({
override: [enhanceCompletion(language), completeAnyWord],
}),
getInitialExtension(),
])
interface EditorReadyPayload {
view: EditorView
}
// shallowRef,不是 ref:CodeMirror 的 EditorView 是带 getter 的类实例,
// Vue 的 UnwrapRef 深度展开会把它结构化成一个丢了原型方法的假类型,
// vue-tsc 会报 "missing dispatchTransactions/_root/..." 这类莫名其妙的错。
// 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。
const editorView = shallowRef<EditorView | null>(null)
const handleEditorReady = (payload: EditorReadyPayload) => {
editorView.value = payload.view
}
// 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。
// 只在学生端启用:这个组件挂在每个用户的题目页上(教师也不例外,
// ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色),
// 教师接单同样会让 collabStore.room 非空 —— 如果这里不按角色收窄,
// 教师自己停在某道题的页面上接单时,这个组件会把**教师自己的编辑器内容**
// 当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 这个单例槽位,
// 两者谁后调用谁把对方顶掉。教师端的协作只归 CollabModal 管。
watch(
() => collabStore.room,
(room) => {
if (room && !collabStore.isTeacher && editorView.value) {
// 学生端:当前编辑器内容就是内容源
start({
editorView: editorView.value,
seedContent: editorView.value.state.doc.toString(),
})
} else {
stop()
}
},
)
onUnmounted(stop)
</script>
<template>
@@ -64,5 +107,6 @@ const extensions = computed(() => [
:tab-size="4"
:placeholder="placeholder"
:style="{ height, fontSize: `${fontSize}px` }"
@ready="handleEditorReady"
/>
</template>