点求助列表里的一条 = 跳到 /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:
1 parent
ed56a209ea
commit
88b695d34f
10 files changed
+480
-385
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user