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

@@ -2,14 +2,19 @@
import { Icon } from "@iconify/vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useCollabStore } from "shared/store/collab"
import { useScreenModeStore } from "shared/store/screenMode"
/** 由顶栏的姓名下拉菜单打开 */
const show = defineModel<boolean>("show", { default: false })
const collabStore = useCollabStore()
const screenModeStore = useScreenModeStore()
const router = useRouter()
const route = useRoute()
const message = useMessage()
// 接单之后要在弹框里替学生写代码,那个编辑器窄屏上没法用 —— 所以窄屏只让看
// 「谁在等」(角标、toast、这张列表照常给),接单留到桌面端
// 接单之后要在题目页的编辑器里替学生写代码,那个编辑器窄屏上没法用 —— 所以窄屏
// 只让看「谁在等」(角标、toast、这张列表照常给),接单留到桌面端
const { isDesktop } = useBreakpoints()
// 等待时长要每秒走一格,所以自己转一个 now。
@@ -43,12 +48,37 @@ const waited = (createdAt: number) => {
return `${m}:${String(s).padStart(2, "0")}`
}
const handleAccept = (studentId: number, status: string) => {
/**
* 接单 = 跳到那道题的页面,在页面自带的编辑器里协作。
*
* 原来是接单后弹一个 CollabModal(弹框里再挂一个 CodeMirror + 一份只读题面),
* 那个弹框按一下 Esc 就关、协作跟着结束,上课时太容易误触;题面也只能看不能用。
* 直接跳题目页之后,老师看到的就是学生看到的那一页。
*
* 求助入口在比赛里是关掉的(Form.vue 的 showHelpButton),problemId 一定是公开
* 题目的展示 ID,走 /problem/:id 就行。
*/
const handleAccept = (studentId: number, problemId: string, status: string) => {
// 已被别的老师接走的不能点
if (status === "active" || !isDesktop.value) return
// 一个老师同时只能在一个房间里(服务端也拦,见 handler 的「请先退出当前协作」)。
// 这里先拦一道:不拦的话服务端拒了、前端却已经跳到新那道题上,
// 反而把手上正在进行的那场协作断掉(对老师来说「跳走」就等于结束协作)
if (collabStore.room) {
message.warning("请先结束当前协作")
return
}
collabStore.accept(studentId)
// 接单后马上要弹 CollabModal,这个列表得让位
// 协作用的是右侧那个编辑器,而「题目」「自测」两种分屏模式下它根本没挂出来 ——
// 落进那样一个页面就是:学生显示「老师正在帮你」,老师这边什么都没有。
// 跳到另一道题时 detail.vue 的 init() 会重置分屏模式,**停在同一道题上接单
// 不会**(路由没变、组件不重建),所以这里显式重置一次。
screenModeStore.resetScreenMode()
show.value = false
// 已经在这道题的页面上就不跳了(老师正投影着这道题时就是这种)。
// 重复导航在 vue-router 里是一个 rejected promise,不拦一下控制台会报
const target = `/problem/${problemId}`
if (route.path !== target) router.push(target)
}
</script>
@@ -97,7 +127,7 @@ const handleAccept = (studentId: number, status: string) => {
cursor:
item.status === 'active' || !isDesktop ? 'default' : 'pointer',
}"
@click="handleAccept(item.studentId, item.status)"
@click="handleAccept(item.studentId, group.problemId, item.status)"
>
<n-flex vertical :size="2">
<n-text>