feat(课堂求助): 教师端接单直接跳题目页协作,删掉 CollabModal
Deploy / deploy (push) Has been cancelled

点求助列表里的一条 = 跳到 /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 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5 committed 2026-09-16 19:13:42 -06:00
1 parent ed56a209ea
commit 88b695d34f
10 files changed
+480 -385

No files matched your search

+112 -22
View File
@@ -26,6 +26,15 @@ interface Props {
height?: string
readonly?: boolean
placeholder?: string
/**
* 当前这个编辑器属于哪道题(题目的展示 ID)。
*
* **协作只在题号对得上时才建立。** 教师端现在也在题目页里协作(原来是单独的
* CollabModal 弹框),他接单时人可能停在别的题上 —— 不比对题号的话那个编辑器
* 会绑上学生的文档,老师看着自己的题面改着别人的代码。学生那边同理:排队期间
* 切到别的题,接通了也不该把这道题的代码交出去。
*/
problemId?: string
}
const {
@@ -34,6 +43,7 @@ const {
height = "100%",
readonly = false,
placeholder = "",
problemId = "",
} = defineProps<Props>()
const code = defineModel<string>("value")
@@ -61,10 +71,50 @@ interface EditorReadyPayload {
// 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。
const editorView = shallowRef<EditorView | null>(null)
/** 房间开着,而且开的就是这道题 */
const roomIsHere = computed(
() =>
collabStore.room !== null &&
(!problemId || collabStore.room.problemId === problemId),
)
/**
* 这个编辑器**接进过**当前这个房间。
*
* 用来区分两件看起来一样的事:「房间从我这儿挪走了」(我得明确结束协作)和
* 「房间在别处开起来了」(跟我无关,不能替别人把协作掐掉)。老师在别的题上
* 接单时后者天天发生 —— room 先 open、再导航过来,那一瞬间旧页面的编辑器
* 看到的就是「房间非空但不是我这道题」。
*/
const bound = ref(false)
const bind = (view: EditorView) => {
if (collabStore.isTeacher || !collabStore.room) return
// 学生端:当前编辑器内容就是内容源
start({ editorView: view, seedContent: view.state.doc.toString() })
if (!roomIsHere.value) return
bound.value = true
// ★ 内容源只有学生一个:学生端拿自己的编辑器内容当种子,教师端必须传 null,
// 理由见 collabDoc.ts 的 StartOptions
start({
editorView: view,
seedContent: collabStore.isTeacher ? null : view.state.doc.toString(),
})
}
/**
* 拆掉本地绑定。**房间还在、只是不在这道题上了**(人走了,不是协作结束了)时
* 补一条 leave:
*
* - 教师从学生这道题切去别的题 —— 不发的话学生那边一直挂着「老师正在帮你」,
* 而老师的编辑器早就不在这个房间里,字一个也过不去;
* - 学生在协作中切去别的题 —— 同理。
*
* `/problem/1001` → `/problem/1003` 是同一条路由换 params,**组件被复用、不卸载**,
* 所以这件事不能只靠 onUnmounted(实测就是这么漏的)。
*/
const detach = () => {
const wasBound = bound.value
bound.value = false
stop()
if (wasBound && collabStore.room) collabStore.leave("left")
}
const handleEditorReady = (payload: EditorReadyPayload) => {
@@ -74,24 +124,40 @@ const handleEditorReady = (payload: EditorReadyPayload) => {
bind(payload.view)
}
// 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。
// 只在学生端启用:这个组件挂在每个用户的题目页上(教师也不例外,
// ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色),
// 教师接单同样会让 collabStore.room 非空 —— 如果这里不按角色收窄,
// 教师自己停在某道题的页面上接单时,这个组件会把**教师自己的编辑器内容**
// 当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 这个单例槽位,
// 两者谁后调用谁把对方顶掉。教师端的协作只归 CollabModal 管。
/**
* 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。
*
* **现在两边都走这里。** 教师端原来是一个独立的 CollabModal(弹框里再挂一个
* CodeMirror),接单改成跳到题目页之后,两边协作的都是页面上这一个编辑器,
* setBinaryHandler 那个单例槽位也只剩一个使用者,不会再有两个编辑器谁后调用
* 谁把对方顶掉的问题。
*
* 所以收窄的条件从「不是教师」换成了「房间开的是这道题」(roomIsHere)——
* 教师停在别的题上时照样不绑。
*/
watch(
() => collabStore.room,
(room) => {
if (room && !collabStore.isTeacher && editorView.value)
bind(editorView.value)
else stop()
// 两个源都要盯:
//
// - `room` 这个对象本身(换人、结束都会整个替换它)—— 同一道题上老师刚结束
// 一个学生又接下一个时,roomIsHere 全程为真,只看它就不会重建文档,
// 老师会留在上一轮那份已经没人的 Y.Doc 上;
// - `roomIsHere` —— 老师接单时人在**另一道题**的页面上,跳过来是
// `/problem/1002` → `/problem/1001`,同一条路由只换 params,**组件被复用**,
// `@ready` 不会再触发一次,而 room 早在导航之前就 open 了。只盯 room 的话
// 老师会停在一个空编辑器上干等(实测就是这样)。
//
// 协作期间学生切语言是就地改 room.language、不替换对象,所以不会误重连。
[() => collabStore.room, roomIsHere],
() => {
if (roomIsHere.value) {
if (editorView.value) bind(editorView.value)
} else detach()
},
)
// 求助期间切了语言:同步给老师,他那边的高亮和补全跟着换。
// 教师端不发 —— 这个组件在教师自己的题目页上也挂着,他的语言和房间无关
// 教师端不发 —— 协作中教师这个编辑器的语言本来就是跟着学生走的
// (ProblemEditor 的 editorLanguage),反过来发一遍只会把学生的语言覆盖掉
watch(
() => language,
(lang) => {
@@ -99,13 +165,37 @@ watch(
},
)
/**
* 学生**排队期间离开了这道题**:撤掉求助。
*
* 那条求助说的是「我卡在这道题」,人不在这道题上了就不成立了 —— 原来它会一直挂在
* 队列里,老师接进来时学生的编辑器不在这道题上、根本不会绑,老师对着一个空编辑器
* 敲字,两边都没有提示。把语言切成流程图同理:那一档连求助按钮都没有,留着他自己
* 也取消不掉。
*
* **只认「离开」这件事,不跟房间状态挂钩**:老师走开时服务端会把求助退回排队,
* 那条刚补上的 pending 不能被这里顺手取消掉。
*/
const cancelPendingIfLeaving = () => {
if (collabStore.isTeacher) return
if (collabStore.helpStatus === "pending") collabStore.cancelHelp()
}
// 同一条路由换 params(切到别的题)组件会被复用,所以这件事不能只靠 onUnmounted
watch(
() => problemId,
(next, previous) => {
if (previous && next !== previous) cancelPendingIfLeaving()
},
)
onUnmounted(() => {
stop()
// 这个组件卸载意味着编辑器没了(切走页面、或者把语言切成流程图),
// CRDT 会话没法接着用:回来时只能新建 Y.Doc,再拿编辑器内容当种子就会和
// 老师那份合并成重复文本。所以不是"悄悄把绑定拆了",而是明确结束协作 ——
// 否则老师那边模态框照开、字照敲,一个也到不了学生那里
if (collabStore.room && !collabStore.isTeacher) collabStore.leave()
cancelPendingIfLeaving()
// 卸载意味着编辑器没了(切走整个页面、或者把语言切成流程图),CRDT 会话没法
// 接着用:回来时只能新建 Y.Doc,再拿编辑器内容当种子就会和对方那份合并成重复
// 文本。所以不是「悄悄把绑定拆了」,而是走 detach 明确结束协作 ——
// 教师端同理,他现在是在题目页里协作,离开这一页就是这次帮忙结束了。
detach()
})
</script>