Files
OJ2/apps/web/src/shared/components/CollabHost.vue
yuetsh 88b695d34f
Some checks failed
Deploy / deploy (push) Has been cancelled
feat(课堂求助): 教师端接单直接跳题目页协作,删掉 CollabModal
点求助列表里的一条 = 跳到 /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>
2026-09-16 19:13:42 -06:00

85 lines
3.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { CollabRequestItem } from "shared/composables/websocket"
import { useCollabStore } from "shared/store/collab"
import HelpRequestList from "./HelpRequestList.vue"
/**
* 课堂求助的全局界面:一次性提示、新求助 toast、求助列表。
*
* 教师端的协作**没有弹框**:接单会跳到那道题的页面,在页面自带的编辑器里协作
* (见 HelpRequestList 的 handleAccept、ProblemEditor 的 collabHere
* 原来这里还异步挂一个 CollabModal那个弹框按一下 Esc 就关、协作跟着结束。
*
* 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走,
* 而连接是全局常驻的App.vue 按登录态开关)—— 挂在顶栏里的时候,老师一进
* /admin 就换成了 admin.vue 布局,顶栏连同这几个消费者一起卸载:求助照收,
* 提示、角标、协作界面全都不出现,正好错过 collab.ts 里写的那句「老师可能
* 正在后台改题时收到求助」。放在这里才真的全局。
*
* 位置要求n-message-provider 的后代useMessage 需要)。
*/
const collabStore = useCollabStore()
const message = useMessage()
/**
* 一次性提示统一在这里消费。
*
* 学生排着队切去看提交记录,老师这时候取消了他的求助,那条「老师已取消你的
* 求助」挂在题目页上就永远没人消费 —— 教师端的 error 提示(比如「请先退出
* 当前协作」)同理。
*/
watch(
() => collabStore.noticeSeq,
() => {
const text = collabStore.consumeNotice()
if (text) message.info(text)
},
)
/**
* 新求助进来只有角标默默 +1上课走动的时候根本注意不到补一条 toast。
*
* 只在数字**变大**时弹:老师自己接单、拒绝、别的老师接走都会让它变小,那些
* 不该打扰人。断线重连后服务端会重推一份全量列表,队里还有人的话这里会再弹
* 一次 —— 那正好是「你刚断过线,这些人还等着」,留着。
*/
watch(
() => collabStore.pendingCount,
(count, previous) => {
if (count <= previous) return
let latest: CollabRequestItem | null = null
for (const item of collabStore.requests) {
if (item.status !== "pending") continue
if (!latest || item.createdAt > latest.createdAt) latest = item
}
const text = latest
? `${latest.studentName} 求助:${latest.problemTitle}`
: "有新的求助"
// 内容传 render 函数naive 的 content 支持),这样整条 toast 可点:
// 点一下直接开求助列表,省得再去点名字、再点菜单。
const notice = message.info(
() =>
h(
"span",
{
style: { cursor: "pointer" },
onClick: () => {
collabStore.helpPanelOpen = true
notice.destroy()
},
},
`${text} · 点击处理`,
),
{ duration: 5000 },
)
},
)
</script>
<template>
<HelpRequestList
v-if="collabStore.isTeacher"
v-model:show="collabStore.helpPanelOpen"
/>
</template>