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

@@ -25,6 +25,12 @@ import {
type Room, type Room,
} from "./state" } from "./state"
/**
* `type: "error"` 的 message **会被前端原样弹成 toast**store 的 case "error"
* → setNotice → CollabHost 的 message.info所以这里一律写中文、写成学生看得懂的
* 话。协议层的校验错误(格式不对、题号不对)正常前端触发不到,但真触发了也得是
* 一句人话 —— 原来那几条是 "Invalid problemId" 这样的英文,直接糊在学生脸上。
*/
function isTeacher(ws: CollabSocket) { function isTeacher(ws: CollabSocket) {
return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? "")) return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? ""))
} }
@@ -191,11 +197,17 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
problemId?: unknown problemId?: unknown
studentId?: unknown studentId?: unknown
language?: unknown language?: unknown
reason?: unknown
} }
try { try {
message = JSON.parse(raw) as typeof message message = JSON.parse(raw) as typeof message
} catch { } catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })) ws.send(
JSON.stringify({
type: "error",
message: "消息格式不对,请刷新页面重试",
}),
)
return return
} }
@@ -230,10 +242,15 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
await handleReject(ws, message.studentId) await handleReject(ws, message.studentId)
return return
case "leave": case "leave":
handleLeave(ws) handleLeave(ws, message.reason)
return return
default: default:
ws.send(JSON.stringify({ type: "error", message: "Invalid message" })) ws.send(
JSON.stringify({
type: "error",
message: "不认识的操作,请刷新页面重试",
}),
)
} }
} }
@@ -243,7 +260,9 @@ async function handleHelpRequest(
language: unknown, language: unknown,
) { ) {
if (typeof problemId !== "string" || !problemId) { if (typeof problemId !== "string" || !problemId) {
ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" })) ws.send(
JSON.stringify({ type: "error", message: "题号不对,请刷新页面重试" }),
)
return return
} }
if (isTeacher(ws)) { if (isTeacher(ws)) {
@@ -338,7 +357,12 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
return return
} }
if (typeof studentId !== "number") { if (typeof studentId !== "number") {
ws.send(JSON.stringify({ type: "error", message: "Invalid studentId" })) ws.send(
JSON.stringify({
type: "error",
message: "学生标识不对,请刷新页面重试",
}),
)
return return
} }
@@ -366,7 +390,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
// 老师同时只能在一个房间 // 老师同时只能在一个房间
if (roomOf(ws)) { if (roomOf(ws)) {
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" })) ws.send(JSON.stringify({ type: "error", message: "请先结束当前协作" }))
return return
} }
@@ -440,32 +464,63 @@ async function handleReject(ws: CollabSocket, studentId: unknown) {
broadcastRequests() broadcastRequests()
} }
/** 主动退出房间。老师点关闭、学生点结束都走这里 */ /**
function handleLeave(ws: CollabSocket) { * 主动退出房间。**两种语义,靠 reason 分**
*
* - 不带 reason或 `"done"`)—— 有人点了「结束协作」,这次帮忙到此结束,
* 求助记录一并清掉;
* - `"left"` —— 人只是离开了这道题的页面(教师端「页面即协作现场」,跳走就不在
* 房间里了)。**这跟他掉线是同一件事**,所以走同一条收尾:教师离开 → 求助退回
* 排队,学生不用重新举手,老师回来再点一次就接上;学生离开 → 求助随人清掉。
*
* 分开是因为两者对学生的意义完全不同:前者是「搞定了」,后者是「老师先走一下」,
* 而原来都按前者处理 —— 老师点一下「提交信息」,学生就得重新举手。
*/
function handleLeave(ws: CollabSocket, reason: unknown) {
const room = roomOf(ws) const room = roomOf(ws)
if (!room) return if (!room) return
teardownRoom(room, "done") if (reason !== "left") {
teardownRoom(room, "done")
return
}
const side = ws === room.teacherSocket ? "teacher" : "student"
teardownRoom(room, "peer_left", side, ws)
} }
/** /**
* 拆房间。reason 决定两端看到什么: * 拆房间。reason 决定两端看到什么:
* done —— 有人主动结束,双方都收到,请求一并清除 * done —— 有人主动结束,双方都收到,请求一并清除
* peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose / * peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose /
* handleCollabBinary。offlineSide 是消失的那一方:老师消失, * handleCollabBinary
* 请求退回排队;学生消失,请求随人清掉。不传时(当前只有 * peer_left —— 有人离开了这道题的页面handleLeave 的 "left"
* handleLeave 走 "done")不做这一步,只拆房间 *
* offlineSide 是消失的那一方,决定请求的去向:老师消失 → 退回排队;学生消失 →
* 随人清掉。不传时只拆房间。
*
* initiator 是主动发起的那条连接:**他收到的 reason 不一样** —— 点了「结束协作」
* 是 `done`,离开页面是 `self_left`。对他来说这是「我自己干的」,不该看到一句
* 「对方离开了」,也不该看到「老师已结束这次帮忙」。
*/ */
function teardownRoom( function teardownRoom(
room: Room, room: Room,
reason: "done" | "peer_offline", reason: "done" | "peer_offline" | "peer_left",
offlineSide?: "student" | "teacher", offlineSide?: "student" | "teacher",
initiator?: CollabSocket,
) { ) {
closeRoom(room.studentId) closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined room.teacherSocket.data.roomOwnerId = undefined
const frame = JSON.stringify({ type: "room_closed", reason }) // 发起方收到的是「我自己干的」那一版:点了结束就是 done离开页面是 self_left。
room.studentSocket.send(frame) // 不能跟对面收同一条 —— 学生自己切走了却看到「老师已结束这次帮忙」是假话
room.teacherSocket.send(frame) const selfReason = reason === "peer_left" ? "self_left" : "done"
for (const socket of [room.studentSocket, room.teacherSocket]) {
socket.send(
JSON.stringify({
type: "room_closed",
reason: socket === initiator ? selfReason : reason,
}),
)
}
if (reason === "done") { if (reason === "done") {
removeRequest(room.studentId) removeRequest(room.studentId)
} else if (offlineSide === "teacher") { } else if (offlineSide === "teacher") {

View File

@@ -64,6 +64,20 @@ const showHelpButton = computed(
!isContestMode.value, !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 说明排队情况,一行工具栏 * 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
* (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。 * (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。
@@ -134,10 +148,14 @@ const menuOptions = computed<DropdownOption[]>(() => {
label: "复制代码", label: "复制代码",
key: "copy", key: "copy",
}) })
options.push({ // 协作中的教师不给「重置代码」:那会儿编辑器里是**学生的**代码,而 v-model
label: "重置代码", // 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回
key: "reset", if (!showCollabBar.value) {
}) options.push({
label: "重置代码",
key: "reset",
})
}
} }
if (isDesktop.value && userStore.isSuperAdmin) { if (isDesktop.value && userStore.isSuperAdmin) {
options.push({ options.push({
@@ -216,7 +234,15 @@ const goTestCat = () => {
const goSubmissions = () => { const goSubmissions = () => {
const name = route.params.contestID ? "contest submissions" : "submissions" 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 = () => { const goEdit = () => {
@@ -236,11 +262,13 @@ onMounted(() => {
<template> <template>
<n-flex align="center"> <n-flex align="center">
<!-- 协作中编辑器的语言跟着学生走这个选择器改了也不会生效索性禁掉 -->
<n-select <n-select
v-model:value="codeStore.code.language" v-model:value="codeStore.code.language"
style="width: 120px" style="width: 120px"
:size="buttonSize" :size="buttonSize"
:options="languageOptions" :options="languageOptions"
:disabled="showCollabBar"
@update:value="changeLanguage" @update:value="changeLanguage"
/> />
@@ -274,6 +302,22 @@ onMounted(() => {
<n-button :size="buttonSize">更多操作</n-button> <n-button :size="buttonSize">更多操作</n-button>
</n-dropdown> </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 <n-button
v-if="showHelpButton" v-if="showHelpButton"
:size="buttonSize" :size="buttonSize"

View File

@@ -2,6 +2,7 @@
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { SOURCES } from "utils/constants" import { SOURCES } from "utils/constants"
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue" import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
@@ -18,8 +19,66 @@ const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
const codeStore = useCodeStore() const codeStore = useCodeStore()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const collabStore = useCollabStore()
const { problem } = storeToRefs(problemStore) 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 { isDesktop } = useBreakpoints()
const contestID = route.params.contestID || null const contestID = route.params.contestID || null
@@ -45,17 +104,19 @@ onMounted(loadCode)
watch(() => problem.value?._id, loadCode) watch(() => problem.value?._id, loadCode)
watch( /**
() => codeStore.code.value, * 存本地草稿。**协作中的教师不存** —— 那会儿编辑器里是学生的代码,存下去就把
(v) => { * 老师自己在这道题上的草稿盖掉了(他可能正开着自己的解法)。学生照存,那是他本人的。
storage.set(storageKey.value, v) */
}, const persistCode = (v: string) => {
) if (teacherCollab.value) return
const changeCode = (v: string) => {
storage.set(storageKey.value, v) storage.set(storageKey.value, v)
} }
watch(() => codeStore.code.value, persistCode)
const changeCode = persistCode
const changeLanguage = (v: LANGUAGE) => { const changeLanguage = (v: LANGUAGE) => {
const savedCode = storage.get(storageKey.value) const savedCode = storage.get(storageKey.value)
codeStore.setCode( codeStore.setCode(
@@ -73,6 +134,13 @@ provide("flowchartEditorRef", flowchartEditorRef)
<template> <template>
<n-flex vertical> <n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" /> <Form :storage-key="storageKey" @change-language="changeLanguage" />
<!--
协作中教师这边不会落到流程图分支上面那个 watch 已经把他的语言换成了学生的
而求助入口本身就排掉了流程图Form.vue showHelpButton服务端的
COLLAB_LANGUAGES所以 room.language 不可能是 Flowchart
学生自己切到流程图就是不写代码了编辑器卸载协作正常结束SyncCodeEditor
detach这是原来就有的语义
-->
<FlowchartEditor <FlowchartEditor
v-if="codeStore.code.language === 'Flowchart'" v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef" ref="flowchartEditorRef"
@@ -81,6 +149,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
v-else v-else
v-model:value="codeStore.code.value" v-model:value="codeStore.code.value"
:language="codeStore.code.language" :language="codeStore.code.language"
:problem-id="problem!._id"
:height="editorHeight" :height="editorHeight"
@update:model-value="changeCode" @update:model-value="changeCode"
/> />

View File

@@ -4,29 +4,20 @@ import { useCollabStore } from "shared/store/collab"
import HelpRequestList from "./HelpRequestList.vue" import HelpRequestList from "./HelpRequestList.vue"
/** /**
* 课堂求助的全局界面:一次性提示、新求助 toast、求助列表、教师端协作弹框 * 课堂求助的全局界面:一次性提示、新求助 toast、求助列表。
*
* 教师端的协作**没有弹框**:接单会跳到那道题的页面,在页面自带的编辑器里协作
* (见 HelpRequestList 的 handleAccept、ProblemEditor 的 collabHere
* 原来这里还异步挂一个 CollabModal那个弹框按一下 Esc 就关、协作跟着结束。
* *
* 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走, * 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走,
* 而连接是全局常驻的App.vue 按登录态开关)—— 挂在顶栏里的时候,老师一进 * 而连接是全局常驻的App.vue 按登录态开关)—— 挂在顶栏里的时候,老师一进
* /admin 就换成了 admin.vue 布局,顶栏连同这几个消费者一起卸载:求助照收, * /admin 就换成了 admin.vue 布局,顶栏连同这几个消费者一起卸载:求助照收,
* 提示、角标、协作弹框全都不出现,正好错过 collab.ts 里写的那句「老师可能 * 提示、角标、协作界面全都不出现,正好错过 collab.ts 里写的那句「老师可能
* 正在后台改题时收到求助」。放在这里才真的全局。 * 正在后台改题时收到求助」。放在这里才真的全局。
* *
* 位置要求n-message-provider 的后代useMessage 需要)。 * 位置要求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 collabStore = useCollabStore()
const message = useMessage() const message = useMessage()
@@ -90,5 +81,4 @@ watch(
v-if="collabStore.isTeacher" v-if="collabStore.isTeacher"
v-model:show="collabStore.helpPanelOpen" v-model:show="collabStore.helpPanelOpen"
/> />
<CollabModal v-if="collabStore.isTeacher" />
</template> </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 { Icon } from "@iconify/vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
import { useScreenModeStore } from "shared/store/screenMode"
/** 由顶栏的姓名下拉菜单打开 */ /** 由顶栏的姓名下拉菜单打开 */
const show = defineModel<boolean>("show", { default: false }) const show = defineModel<boolean>("show", { default: false })
const collabStore = useCollabStore() const collabStore = useCollabStore()
const screenModeStore = useScreenModeStore()
const router = useRouter()
const route = useRoute()
const message = useMessage()
// 接单之后要在弹框里替学生写代码,那个编辑器窄屏上没法用 —— 所以窄屏只让看 // 接单之后要在题目页的编辑器里替学生写代码,那个编辑器窄屏上没法用 —— 所以窄屏
// 「谁在等」角标、toast、这张列表照常给接单留到桌面端 // 只让看「谁在等」角标、toast、这张列表照常给接单留到桌面端
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
// 等待时长要每秒走一格,所以自己转一个 now。 // 等待时长要每秒走一格,所以自己转一个 now。
@@ -43,12 +48,37 @@ const waited = (createdAt: number) => {
return `${m}:${String(s).padStart(2, "0")}` 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 if (status === "active" || !isDesktop.value) return
// 一个老师同时只能在一个房间里(服务端也拦,见 handler 的「请先退出当前协作」)。
// 这里先拦一道:不拦的话服务端拒了、前端却已经跳到新那道题上,
// 反而把手上正在进行的那场协作断掉(对老师来说「跳走」就等于结束协作)
if (collabStore.room) {
message.warning("请先结束当前协作")
return
}
collabStore.accept(studentId) collabStore.accept(studentId)
// 接单后马上要弹 CollabModal这个列表得让位 // 协作用的是右侧那个编辑器,而「题目」「自测」两种分屏模式下它根本没挂出来 ——
// 落进那样一个页面就是:学生显示「老师正在帮你」,老师这边什么都没有。
// 跳到另一道题时 detail.vue 的 init() 会重置分屏模式,**停在同一道题上接单
// 不会**(路由没变、组件不重建),所以这里显式重置一次。
screenModeStore.resetScreenMode()
show.value = false show.value = false
// 已经在这道题的页面上就不跳了(老师正投影着这道题时就是这种)。
// 重复导航在 vue-router 里是一个 rejected promise不拦一下控制台会报
const target = `/problem/${problemId}`
if (route.path !== target) router.push(target)
} }
</script> </script>
@@ -97,7 +127,7 @@ const handleAccept = (studentId: number, status: string) => {
cursor: cursor:
item.status === 'active' || !isDesktop ? 'default' : 'pointer', 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-flex vertical :size="2">
<n-text> <n-text>

View File

@@ -26,6 +26,15 @@ interface Props {
height?: string height?: string
readonly?: boolean readonly?: boolean
placeholder?: string placeholder?: string
/**
* 当前这个编辑器属于哪道题(题目的展示 ID
*
* **协作只在题号对得上时才建立。** 教师端现在也在题目页里协作(原来是单独的
* CollabModal 弹框),他接单时人可能停在别的题上 —— 不比对题号的话那个编辑器
* 会绑上学生的文档,老师看着自己的题面改着别人的代码。学生那边同理:排队期间
* 切到别的题,接通了也不该把这道题的代码交出去。
*/
problemId?: string
} }
const { const {
@@ -34,6 +43,7 @@ const {
height = "100%", height = "100%",
readonly = false, readonly = false,
placeholder = "", placeholder = "",
problemId = "",
} = defineProps<Props>() } = defineProps<Props>()
const code = defineModel<string>("value") const code = defineModel<string>("value")
@@ -61,10 +71,50 @@ interface EditorReadyPayload {
// 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。 // 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。
const editorView = shallowRef<EditorView | null>(null) 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) => { const bind = (view: EditorView) => {
if (collabStore.isTeacher || !collabStore.room) return if (!roomIsHere.value) return
// 学生端:当前编辑器内容就是内容源 bound.value = true
start({ editorView: view, seedContent: view.state.doc.toString() }) // ★ 内容源只有学生一个:学生端拿自己的编辑器内容当种子,教师端必须传 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) => { const handleEditorReady = (payload: EditorReadyPayload) => {
@@ -74,24 +124,40 @@ const handleEditorReady = (payload: EditorReadyPayload) => {
bind(payload.view) bind(payload.view)
} }
// 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。 /**
// 只在学生端启用:这个组件挂在每个用户的题目页上(教师也不例外, * 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。
// ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色), *
// 教师接单同样会让 collabStore.room 非空 —— 如果这里不按角色收窄, * **现在两边都走这里。** 教师端原来是一个独立的 CollabModal弹框里再挂一个
// 教师自己停在某道题的页面上接单时,这个组件会把**教师自己的编辑器内容** * CodeMirror接单改成跳到题目页之后两边协作的都是页面上这一个编辑器
// 当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 个单例槽位 * setBinaryHandler 个单例槽位也只剩一个使用者,不会再有两个编辑器谁后调用
// 两者谁后调用谁把对方顶掉。教师端的协作只归 CollabModal 管 * 谁把对方顶掉的问题
*
* 所以收窄的条件从「不是教师」换成了「房间开的是这道题」roomIsHere——
* 教师停在别的题上时照样不绑。
*/
watch( watch(
() => collabStore.room, // 两个源都要盯:
(room) => { //
if (room && !collabStore.isTeacher && editorView.value) // - `room` 这个对象本身(换人、结束都会整个替换它)—— 同一道题上老师刚结束
bind(editorView.value) // 一个学生又接下一个时roomIsHere 全程为真,只看它就不会重建文档,
else stop() // 老师会留在上一轮那份已经没人的 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( watch(
() => language, () => language,
(lang) => { (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(() => { onUnmounted(() => {
stop() cancelPendingIfLeaving()
// 这个组件卸载意味着编辑器没了(切走页面、或者把语言切成流程图), // 卸载意味着编辑器没了(切走整个页面、或者把语言切成流程图),CRDT 会话没法
// CRDT 会话没法接着用:回来时只能新建 Y.Doc再拿编辑器内容当种子就会和 // 接着用:回来时只能新建 Y.Doc再拿编辑器内容当种子就会和对方那份合并成重复
// 老师那份合并成重复文本。所以不是"悄悄把绑定拆了",而是明确结束协作 —— // 文本。所以不是悄悄把绑定拆了,而是走 detach 明确结束协作 ——
// 否则老师那边模态框照开、字照敲,一个也到不了学生那里 // 教师端同理,他现在是在题目页里协作,离开这一页就是这次帮忙结束了。
if (collabStore.room && !collabStore.isTeacher) collabStore.leave() detach()
}) })
</script> </script>

View File

@@ -5,7 +5,7 @@ import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
/** /**
* 语言对应的高亮扩展。学生端SyncCodeEditor和教师端CollabModal共用同一份 —— * 语言对应的高亮扩展。学生端和教师端共用同一份(两边都是题目页上的 SyncCodeEditor ——
* 两边各选各的,就是老师看到的高亮和补全跟学生手里那份对不上。 * 两边各选各的,就是老师看到的高亮和补全跟学生手里那份对不上。
* *
* Java / Golang / JavaScript 没有单独的包,落到 cpp(),是既有行为,不是遗漏。 * 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) => { const handleMessage = (data: CollabMessage) => {
switch (data.type) { switch (data.type) {
case "requests": case "requests":
@@ -126,9 +157,7 @@ export const useCollabStore = defineStore("collab", () => {
// 这里先归零,那条 pending 补发会立刻把它纠正回来,不会被这次重置盖掉 // 这里先归零,那条 pending 补发会立刻把它纠正回来,不会被这次重置盖掉
room.value = null room.value = null
helpStatus.value = "idle" helpStatus.value = "idle"
setNotice( setNotice(closedNotice(data.reason))
data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束",
)
return return
case "error": case "error":
setNotice(String(data.message ?? "")) setNotice(String(data.message ?? ""))
@@ -192,8 +221,16 @@ export const useCollabStore = defineStore("collab", () => {
ws.send({ type: "reject", studentId }) 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) { function sendBinary(data: Uint8Array) {

View File

@@ -216,7 +216,7 @@ teacherSockets: Set<socket> // 在线老师,用于推 requests 与判断 no_
|---|---| |---|---|
| `shared/store/collab.ts` | pinia store持有 WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间 | | `shared/store/collab.ts` | pinia store持有 WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间 |
| `shared/components/HelpRequestList.vue` | 顶栏红点 + 下拉列表,含同题聚合 | | `shared/components/HelpRequestList.vue` | 顶栏红点 + 下拉列表,含同题聚合 |
| `shared/components/CollabModal.vue` | 老师端协作模态框 | | ~~`shared/components/CollabModal.vue`~~ | 老师端协作模态框。**2026-09-16 删除**,见文末的变更 |
| `shared/composables/collab.ts` | Y.Doc 与 yCollab 绑定,取代 `sync.ts` | | `shared/composables/collab.ts` | Y.Doc 与 yCollab 绑定,取代 `sync.ts` |
### 5.2 改动 ### 5.2 改动
@@ -298,3 +298,89 @@ teacherSockets: Set<socket> // 在线老师,用于推 requests 与判断 no_
的 Django 后端其中「Yjs + y-webrtc for collaborative editing **in the flowchart 的 Django 后端其中「Yjs + y-webrtc for collaborative editing **in the flowchart
editor**」一句本就是错的(协作在代码编辑器,流程图被显式排除)。本次至少要把 editor**」一句本就是错的(协作在代码编辑器,流程图被显式排除)。本次至少要把
实时特性与环境变量两节改对,整份文档的翻新另计。 实时特性与环境变量两节改对,整份文档的翻新另计。
## 变更记录
### 2026-09-16教师端不再用弹框接单直接跳题目页
老师点求助列表里的一条 → 跳到 `/problem/:id`,就在**页面自带的那个编辑器**里
和学生同步。`CollabModal.vue` 删除。
为什么改:
- **弹框按一下 Esc 就关,协作跟着结束。** 上课时太容易误触 —— `show` 的 setter
`leave()` 是唯一的关闭语义n-modal 的 Esc / 点遮罩都会走到它。
现在结束协作只有两条明确的路:工具栏的「结束协作」按钮、离开这一页。
- 弹框里那份题面是只读复制品(不能跑测试、不能提交),老师还是得另开一个标签页。
跳到真页面之后,老师看到的就是学生看到的那一页。
- 少一个 CodeMirror 实例:`setBinaryHandler` 那个单例槽位现在只有
`SyncCodeEditor` 一个使用者,教师端也不用再为了首屏体积把整套编辑器拆成
异步 chunk页面本来就要加载它
连带的口径:
- `SyncCodeEditor` 多一个 `problemId` prop**房间的题号对不上就不绑**(老师接单时
可能停在别的题上);种子内容仍然只来自学生端(教师端传 `null`)。
- 协作期间教师那个编辑器的语言跟着 `room.language` 走,语言选择器禁用;
草稿不写 localStorage那会儿编辑器里是学生的代码协作结束后读回教师自己的草稿。
- 协作中的题目页强制走代码编辑器分支,不受教师自己选的「流程图」影响。
### 2026-09-16同日第二轮`leave` 分两种语义
「老师走开」和「老师处理完了」原来都走 `leave``teardownRoom("done")` → 删掉求助
记录,于是老师点一下「提交信息」,学生就得重新举手,而且他看到的是「协作已结束」,
会以为自己被处理完了。
协议加一个 reason
| 前端 | 服务端 | 求助记录 | 对面看到 |
|---|---|---|---|
| `leave("done")`(点「结束协作」) | `teardownRoom("done")` | 删除 | 协作已结束 |
| `leave("left")`(离开这道题的页面) | `teardownRoom("peer_left", side, ws)` | 教师走→**退回排队**;学生走→删除 | 老师暂时离开,已重新排队 |
`"left"` 复用的是老师掉线那条路(`requeueAfterTeacherGone`)—— 两件事语义相同。
**发起方收到的 reason 是 `done`**`teardownRoom``initiator` 参数),他不该看到
一句「对方离开了」。
配套:
- 协作中的「提交信息」改成新标签打开 —— 那是教师工具栏上唯一会跳路由的按钮,
而「看看这学生都交了什么」恰好是协作时最常点的一个;
- **学生排队期间离开那道题 → 自动取消求助**`SyncCodeEditor`
`cancelPendingIfLeaving`)。那条求助说的是「我卡在这道题」,人走了就不成立;
原来它会一直挂在队列里,老师接进来时学生的编辑器不在这道题上、根本不会绑,
老师对着空编辑器敲字,两边都没有提示。只认「离开」这件事、不跟房间状态挂钩:
老师走开时服务端刚补上的那条 pending 不能被它顺手取消掉;
- **协作中教师的语言选择跟着 `room.language` 走**(写 codeStore不只是改编辑器高亮
协作结束后还原成他自己那档。只改高亮的话,老师会拿着自己那档语言提交学生的代码
(学生写 C、老师选的是 Python当场 CE工具栏还可能显示「提交流程图」。
- 「重置代码」协作中不出现在菜单里:`v-model` 一写回就顺着 Yjs 同步过去,
等于一键清空学生的作业。
- 接单时 `resetScreenMode()`:分屏模式是「题目」或「自测」时右侧编辑器根本没挂出来,
停在同一道题上接单会落进一个没有编辑器的页面(跳到别的题时 `detail.vue`
`init()` 会重置,同页不会)。
- 老师已经在一个房间里时,前端也拦住接下一单(服务端本来就拦)—— 不拦的话前端
已经跳到新题目上,等于把手上那场协作断掉。
### 2026-09-16同日第三轮文案
`type: "error"` 的 message **会被前端原样弹成 toast**store 的 `case "error"`
`setNotice``message.info`),所以服务端那几条协议层错误从英文改成中文
`Invalid problemId` → 「题号不对,请刷新页面重试」等四条)。**以后往这个通道加
错误消息一律写中文、写成学生看得懂的话。**
「请先**退出**当前协作」改成「请先**结束**当前协作」,和按钮「结束协作」、前端那道
拦截统一成一个词。
`room_closed` 的提示语按 reason × 角色展开,原来四种情形共用一句「协作已结束」:
| reason | 谁收到 | 老师看到 | 学生看到 |
|---|---|---|---|
| `done` | 对面(有人点了「结束协作」) | 已结束这次协作 | 老师已结束这次帮忙 |
| `self_left` | **发起方**(自己离开了这道题) | 你已离开这道题,求助退回队列了 | 你已离开这道题,协作结束 |
| `peer_left` | 对面(对方离开了这道题) | 学生离开了这道题,协作已结束 | 老师暂时离开,你的求助已重新排队 |
| `peer_offline` | 对面(掉线 / 转发失败) | 对方已断开连接 | 对方已断开连接 |
`self_left` 是这一轮新加的:发起方原来跟对面收同一条 `done`,于是学生自己切走了
却看到「老师已结束这次帮忙」—— 一句假话。发起方收到的 reason 由 `teardownRoom`
`initiator` 决定。