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

@@ -4,29 +4,20 @@ import { useCollabStore } from "shared/store/collab"
import HelpRequestList from "./HelpRequestList.vue"
/**
* 课堂求助的全局界面:一次性提示、新求助 toast、求助列表、教师端协作弹框
* 课堂求助的全局界面:一次性提示、新求助 toast、求助列表。
*
* 教师端的协作**没有弹框**:接单会跳到那道题的页面,在页面自带的编辑器里协作
* (见 HelpRequestList 的 handleAccept、ProblemEditor 的 collabHere
* 原来这里还异步挂一个 CollabModal那个弹框按一下 Esc 就关、协作跟着结束。
*
* 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走,
* 而连接是全局常驻的App.vue 按登录态开关)—— 挂在顶栏里的时候,老师一进
* /admin 就换成了 admin.vue 布局,顶栏连同这几个消费者一起卸载:求助照收,
* 提示、角标、协作弹框全都不出现,正好错过 collab.ts 里写的那句「老师可能
* 提示、角标、协作界面全都不出现,正好错过 collab.ts 里写的那句「老师可能
* 正在后台改题时收到求助」。放在这里才真的全局。
*
* 位置要求n-message-provider 的后代useMessage 需要)。
*/
/**
* 协作弹框异步加载。
*
* 这个组件静态 import 进来的话,整套 CodeMirrorview / state / language /
* autocomplete / lang-*)就跟着 App.vue 进了入口 chunk —— 首屏白白多下 640 KB
* gzip 后 210 KB而下面那个 v-if 决定了学生根本不渲染它 —— 机房那批老机器
* 解析这些字节是实打实的开销。
*
* 拆成异步之后首页的 JS 从 1.9 MB 降到 1.3 MBgzip 642 KB → 428 KB
* 老师那边只是在第一次接单时多一次 chunk 请求。
*/
const CollabModal = defineAsyncComponent(() => import("./CollabModal.vue"))
const collabStore = useCollabStore()
const message = useMessage()
@@ -90,5 +81,4 @@ watch(
v-if="collabStore.isTeacher"
v-model:show="collabStore.helpPanelOpen"
/>
<CollabModal v-if="collabStore.isTeacher" />
</template>

View File

@@ -1,306 +0,0 @@
<script setup lang="ts">
import { bracketMatching } from "@codemirror/language"
import {
autocompletion,
closeBrackets,
completeAnyWord,
} from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror"
import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
import { styleTheme } from "shared/extensions/baseTheme"
import { enhanceCompletion } from "shared/extensions/autocompletion"
import { languageExtension } from "shared/extensions/language"
import { useCollabDoc } from "../composables/collabDoc"
import { useCollabStore } from "shared/store/collab"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { getProblem } from "oj/api"
import type { Problem } from "utils/types"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
const isDark = useDark()
const collabStore = useCollabStore()
const { start, stop, getInitialExtension } = useCollabDoc()
// shallowRef理由见 SyncCodeEditor.vueEditorView 是类实例ref() 的深度
// UnwrapRef 会把它拆成一个丢了原型方法的假类型vue-tsc 报莫名其妙的类型错。
const editorView = shallowRef<EditorView | null>(null)
// 教师端只在自己发起接单时开。学生端的协作在 SyncCodeEditor 里
const show = computed({
get: () => collabStore.isTeacher && collabStore.room !== null,
set: (value: boolean) => {
if (!value) collabStore.leave()
},
})
/**
* 语言跟着学生走room_open 带过来,学生协作期间切语言会再来一条 room_language。
* 取不到时按 C —— 和这里原来写死 cpp() 的表现一致。
*/
const language = computed<LANGUAGE>(() => collabStore.room?.language ?? "C")
const extensions = computed(() => [
styleTheme,
languageExtension(language.value),
bracketMatching(),
closeBrackets(),
isDark.value ? oneDark : smoothy,
// 学生端有的补全这里必须也有:老师是在替学生写代码,缺了下拉菜单只能盲敲。
// 数组一变 vue-codemirror 会整体 reconfigure但 collabDoc 那个 compartment
// 的内容会被 CM6 沿用(@codemirror/state 的 flatten 里 `compartments.get() ||
// ext.inner`),所以切语言、切主题都不会把 yCollab 弄掉
autocompletion({
override: [enhanceCompletion(language.value), completeAnyWord],
}),
getInitialExtension(),
])
/**
* 题面直接放在弹框左侧。原来只有一个「打开题面」链接,老师得在两个标签页之间
* 来回切着对照学生的代码。
*
* 不复用 ProblemContent它读写全局的 problemStore老师接单时可能正开着另一道题
* 的详情页,一写就把那边的题面换掉了;它的「测试」按钮跑的也是 codeStore 里老师
* 自己的代码。这里只要只读的题面。
*
* 求助入口在比赛里是关掉的Form.vue 的 showHelpButtonproblemId 一定是公开
* 题目的展示 ID按非比赛的接口取就行。
*/
const problem = ref<Problem | null>(null)
const problemLoading = ref(false)
watch(
() => collabStore.room?.problemId,
async (problemId) => {
if (!problemId) {
problem.value = null
problemLoading.value = false
return
}
if (problem.value?._id === problemId) return
problem.value = null
problemLoading.value = true
try {
const res = await getProblem(problemId, "")
// 请求在路上时老师结束协作、又接了另一道题的单,旧响应不能盖掉新题面
if (collabStore.room?.problemId === problemId) problem.value = res
} catch {
// 取不到就留空,右上角的「打开题面」还能兜底
} finally {
if (collabStore.room?.problemId === problemId)
problemLoading.value = false
}
},
{ immediate: true },
)
const sqlDisplay = computed(() => problem.value?.sqlDisplay ?? null)
const sqlExpectedQuery = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "columns" in exp ? exp : null
})
const bind = (view: EditorView) => {
if (!collabStore.isTeacher || !collabStore.room) return
// ★ 教师端 seedContent 必须是 null —— 内容全部来自学生端
start({ editorView: view, seedContent: null })
}
/**
* 起点是「编辑器就绪」,不是「房间打开」。
*
* n-modal 默认 display-directive="if",每次打开都会重挂一个全新的 CodeMirror。
* 原来在 watch 里 `await nextTick()` 之后去取 editorView赶上 teleport + 离场
* 过渡没走完,取到的是上一轮那个已经 destroy 的 viewstart() 静默绑到死编辑器上,
* 老师对着空白框干等,服务端那边房间却是活的。
*/
const handleEditorReady = (payload: { view: EditorView }) => {
editorView.value = payload.view
bind(payload.view)
}
watch(
() => collabStore.room,
(room) => {
// 常规路径下开房时编辑器还没挂,这里是 null由 @ready 接手;
// 万一哪天 display-directive 改成 show编辑器常驻这条分支才起作用
if (room && collabStore.isTeacher) {
if (editorView.value) bind(editorView.value)
} else {
stop()
// 编辑器随模态框一起卸载了,留着这个引用下轮就会绑到一个死 view 上
editorView.value = null
}
},
)
onUnmounted(() => {
stop()
editorView.value = null
})
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:style="{ width: '94vw', maxWidth: '1600px' }"
:title="`正在帮 ${collabStore.room?.peerName ?? ''} · ${collabStore.room?.problemId ?? ''} · ${language}`"
>
<template #header-extra>
<n-button
text
tag="a"
target="_blank"
:href="`/problem/${collabStore.room?.problemId}`"
>
打开题面
</n-button>
</template>
<n-split
direction="horizontal"
:default-size="0.4"
:min="0.2"
:max="0.7"
style="height: 70vh"
>
<template #1>
<n-scrollbar style="height: 100%">
<div class="statement">
<n-spin v-if="problemLoading" size="small" />
<n-empty v-else-if="!problem" description="题面加载失败" />
<template v-else>
<h3 class="problemTitle">{{ problem.title }}</h3>
<MdPreview
preview-theme="vuepress"
:model-value="problem.description"
:theme="isDark ? 'dark' : 'light'"
/>
<template v-if="!sqlDisplay">
<template v-if="problem.inputDescription">
<p class="section">输入</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.inputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
<template v-if="problem.outputDescription">
<p class="section">输出</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.outputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
<template
v-for="(sample, index) of problem.samples"
:key="index"
>
<p class="section">例子 {{ index + 1 }}</p>
<n-descriptions bordered :column="2" size="small">
<n-descriptions-item label="输入">
<div class="testcase">{{ sample.input }}</div>
</n-descriptions-item>
<n-descriptions-item label="输出">
<div class="testcase">{{ sample.output }}</div>
</n-descriptions-item>
</n-descriptions>
</template>
</template>
<template v-else>
<p class="section">数据表</p>
<div v-for="t in sqlDisplay.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<template v-if="sqlExpectedQuery">
<p class="section">期望结果</p>
<SQLDataTable
:columns="sqlExpectedQuery.columns"
:rows="sqlExpectedQuery.rows"
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
</template>
</template>
<template v-if="problem.hint">
<p class="section">提示</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.hint"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
</template>
</div>
</n-scrollbar>
</template>
<template #2>
<!--
不绑 v-model这个编辑器的内容完全由 Yjs 文档接管
原来绑了一个跨会话不清的 code ref 模态框重挂时 CodeMirror 拿它当初始
文档 yCollab 只观察 ytext从不反过来用 ytext 覆盖编辑器于是上一个
学生的代码留在文档里新学生的内容作为 delta 插到位置 0两边的偏移从此
对不上教师和学生显示的是两份不同的文档
-->
<Codemirror
indentWithTab
:extensions="extensions"
:tab-size="4"
style="height: 100%; font-size: 18px"
@ready="handleEditorReady"
/>
</template>
</n-split>
<template #footer>
<n-flex justify="end">
<n-button type="primary" @click="collabStore.leave()">
结束协作
</n-button>
</n-flex>
</template>
</n-modal>
</template>
<style scoped>
.statement {
padding-right: 16px;
}
.problemTitle {
margin: 0 0 8px;
}
.section {
font-size: 16px;
font-weight: 600;
margin: 12px 0 6px;
}
.testcase {
font-size: 14px;
white-space: pre;
font-family: Monaco, Consolas, monospace;
}
.sqlTableName {
font-weight: 600;
margin: 8px 0 4px;
font-family: Monaco, Consolas, monospace;
}
</style>

View File

@@ -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 的 showHelpButtonproblemId 一定是公开
* 题目的展示 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>

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>

View File

@@ -5,7 +5,7 @@ import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types"
/**
* 语言对应的高亮扩展。学生端SyncCodeEditor和教师端CollabModal共用同一份 ——
* 语言对应的高亮扩展。学生端和教师端共用同一份(两边都是题目页上的 SyncCodeEditor ——
* 两边各选各的,就是老师看到的高亮和补全跟学生手里那份对不上。
*
* Java / Golang / JavaScript 没有单独的包,落到 cpp(),是既有行为,不是遗漏。

View File

@@ -84,6 +84,37 @@ export const useCollabStore = defineStore("collab", () => {
)
})
/**
* room_closed 的提示语。四种 reason 各自对两个角色说一句准确的话 ——
* 原来不管什么情况都是一句「协作已结束」,学生分不出「老师处理完了」和
* 「老师走开了」。
*
* 服务端保证发起方和对面**收到的 reason 不同**teardownRoom 的 initiator
* 自己干的是 `done` / `self_left`,对面才是 `peer_left` / `peer_offline`。
* 所以这里既要看 reason 也要看角色。
*/
function closedNotice(reason: unknown) {
const teacher = userStore.isTeacherOrAbove
switch (reason) {
case "peer_offline":
return "对方已断开连接"
// 对面离开了这道题:学生那侧要说明求助的去向 —— 服务端紧接着会补一条
// help_status:pending 把他放回队列
case "peer_left":
return teacher
? "学生离开了这道题,协作已结束"
: "老师暂时离开,你的求助已重新排队"
// 自己离开了这道题(跳走页面、把语言切成流程图)
case "self_left":
return teacher
? "你已离开这道题,求助退回队列了"
: "你已离开这道题,协作结束"
// done有人点了「结束协作」
default:
return teacher ? "已结束这次协作" : "老师已结束这次帮忙"
}
}
const handleMessage = (data: CollabMessage) => {
switch (data.type) {
case "requests":
@@ -126,9 +157,7 @@ export const useCollabStore = defineStore("collab", () => {
// 这里先归零,那条 pending 补发会立刻把它纠正回来,不会被这次重置盖掉
room.value = null
helpStatus.value = "idle"
setNotice(
data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束",
)
setNotice(closedNotice(data.reason))
return
case "error":
setNotice(String(data.message ?? ""))
@@ -192,8 +221,16 @@ export const useCollabStore = defineStore("collab", () => {
ws.send({ type: "reject", studentId })
}
function leave() {
ws.send({ type: "leave" })
/**
* 退出房间。**两种语义**(服务端按 reason 分,见 collab/handler.ts 的 handleLeave
*
* - `"done"` —— 点了「结束协作」,这次帮忙结束,求助记录一并清掉;
* - `"left"` —— 只是离开了这道题的页面。教师端「页面即协作现场」,跳走就不在
* 房间里了,但那不等于处理完了:服务端会把求助**退回排队**,老师回来再点一次
* 就接上,学生不用重新举手。
*/
function leave(reason: "done" | "left" = "done") {
ws.send({ type: "leave", reason })
}
function sendBinary(data: Uint8Array) {