feat(课堂求助): 教师端接单直接跳题目页协作,删掉 CollabModal
Some checks failed
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:
2026-09-16 19:13:42 -06:00
parent ed56a209ea
commit 88b695d34f
10 changed files with 480 additions and 385 deletions

View File

@@ -64,6 +64,20 @@ const showHelpButton = computed(
!isContestMode.value,
)
/**
* 教师端:协作就开在这道题上。接单后直接跳到题目页协作(原来是 CollabModal 弹框),
* 所以状态和「结束协作」得摆在题目页的工具栏上 —— 也只有这一个按钮能结束,
* 不像弹框那样按一下 Esc 就把协作关掉了。
*/
const collabHere = computed(
() =>
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id,
)
const showCollabBar = computed(
() => collabHere.value && userStore.isTeacherOrAbove,
)
/**
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
* (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。
@@ -134,10 +148,14 @@ const menuOptions = computed<DropdownOption[]>(() => {
label: "复制代码",
key: "copy",
})
options.push({
label: "重置代码",
key: "reset",
})
// 协作中的教师不给「重置代码」:那会儿编辑器里是**学生的**代码,而 v-model
// 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回
if (!showCollabBar.value) {
options.push({
label: "重置代码",
key: "reset",
})
}
}
if (isDesktop.value && userStore.isSuperAdmin) {
options.push({
@@ -216,7 +234,15 @@ const goTestCat = () => {
const goSubmissions = () => {
const name = route.params.contestID ? "contest submissions" : "submissions"
router.push({ name, query: { problem: problem.value!._id } })
const target = { name, query: { problem: problem.value!._id } }
// 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了
// (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是
// 协作时最常点的一个按钮 —— 这是整条工具栏上唯一会跳路由的按钮
if (showCollabBar.value) {
window.open(router.resolve(target).href, "_blank")
return
}
router.push(target)
}
const goEdit = () => {
@@ -236,11 +262,13 @@ onMounted(() => {
<template>
<n-flex align="center">
<!-- 协作中编辑器的语言跟着学生走这个选择器改了也不会生效索性禁掉 -->
<n-select
v-model:value="codeStore.code.language"
style="width: 120px"
:size="buttonSize"
:options="languageOptions"
:disabled="showCollabBar"
@update:value="changeLanguage"
/>
@@ -274,6 +302,22 @@ onMounted(() => {
<n-button :size="buttonSize">更多操作</n-button>
</n-dropdown>
<template v-if="showCollabBar">
<n-tag type="success" :size="buttonSize">
正在帮 {{ collabStore.room!.peerName }} ·
{{ LANGUAGE_SHOW_VALUE[collabStore.room!.language] }}
</n-tag>
<!-- 显式的结束求助记录一并清掉跳走页面发的是 leave("left")
那边只是退回排队 store leave 的注释 -->
<n-button
:size="buttonSize"
type="primary"
@click="collabStore.leave('done')"
>
结束协作
</n-button>
</template>
<n-button
v-if="showHelpButton"
:size="buttonSize"

View File

@@ -2,6 +2,7 @@
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { SOURCES } from "utils/constants"
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -18,8 +19,66 @@ const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const collabStore = useCollabStore()
const { problem } = storeToRefs(problemStore)
/**
* 课堂求助的协作就开在这道题上。
*
* 教师端原来是一个独立的弹框CollabModal里面第二个 CodeMirror现在接单
* 直接跳到题目页、就在页面这一个编辑器里协作 —— 顺带治好了「按一下 Esc 弹框就关、
* 协作跟着结束」:页面上没有弹框可关,结束协作只有工具栏那个按钮和离开这一页两条路。
*/
const collabHere = computed(
() =>
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id,
)
/** 协作中的教师:编辑器里是学生的代码,不是他自己的 */
const teacherCollab = computed(() => collabHere.value && collabStore.isTeacher)
/** 教师接单前自己选的语言,协作结束后连同草稿一起还原 */
let teacherLanguageBefore: LANGUAGE | null = null
/**
* 协作中**教师的语言选择跟着学生走**(选择器同时禁用,见 Form.vue学生中途切了
* 还会再同步一次。
*
* 写的是 codeStore 而不是只改编辑器的高亮:提交、语法检查、去自测猫读的都是
* `codeStore.code.language` —— 只改高亮的话,老师会拿着自己那档语言提交学生的代码
* (学生写 C、老师选的是 Python当场 CE工具栏还可能显示「提交流程图」。
*
* 直接写 store 不会连带重载模板代码:那是 Form 里选择器的 `update:value` 才做的事。
*
* **学生端一个字都不动**(早退出),他的语言本来就是权威那一份 —— 服务端的
* `room_language` 只发给教师,学生本地那份 `room.language` 停在建房那一刻,
* 拿它当依据的话学生一切语言就卡在旧的那套上。
*
* `immediate` 是必须的:老师从别的题跳过来时,房间早就开着了,非立即的 watch
* 在挂载这一轮压根不会触发。
*/
watch(
() => [collabHere.value, collabStore.room?.language] as const,
([here, language], previous) => {
if (!collabStore.isTeacher) return
const wasHere = previous?.[0] ?? false
if (here && language) {
if (!wasHere) teacherLanguageBefore = codeStore.code.language
if (codeStore.code.language !== language)
codeStore.code.language = language
return
}
if (!here && wasHere) {
if (teacherLanguageBefore) codeStore.code.language = teacherLanguageBefore
teacherLanguageBefore = null
// 草稿也读回来。上面那道 persistCode 的闸保证了 storage 里还是老师自己的代码
loadCode()
}
},
{ immediate: true },
)
const { isDesktop } = useBreakpoints()
const contestID = route.params.contestID || null
@@ -45,17 +104,19 @@ onMounted(loadCode)
watch(() => problem.value?._id, loadCode)
watch(
() => codeStore.code.value,
(v) => {
storage.set(storageKey.value, v)
},
)
const changeCode = (v: string) => {
/**
* 存本地草稿。**协作中的教师不存** —— 那会儿编辑器里是学生的代码,存下去就把
* 老师自己在这道题上的草稿盖掉了(他可能正开着自己的解法)。学生照存,那是他本人的。
*/
const persistCode = (v: string) => {
if (teacherCollab.value) return
storage.set(storageKey.value, v)
}
watch(() => codeStore.code.value, persistCode)
const changeCode = persistCode
const changeLanguage = (v: LANGUAGE) => {
const savedCode = storage.get(storageKey.value)
codeStore.setCode(
@@ -73,6 +134,13 @@ provide("flowchartEditorRef", flowchartEditorRef)
<template>
<n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" />
<!--
协作中教师这边不会落到流程图分支上面那个 watch 已经把他的语言换成了学生的
而求助入口本身就排掉了流程图Form.vue showHelpButton服务端的
COLLAB_LANGUAGES所以 room.language 不可能是 Flowchart
学生自己切到流程图就是不写代码了编辑器卸载协作正常结束SyncCodeEditor
detach这是原来就有的语义
-->
<FlowchartEditor
v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef"
@@ -81,6 +149,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
v-else
v-model:value="codeStore.code.value"
:language="codeStore.code.language"
:problem-id="problem!._id"
:height="editorHeight"
@update:model-value="changeCode"
/>