feat(课堂求助): 教师端接单直接跳题目页协作,删掉 CollabModal
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
@@ -25,6 +25,12 @@ import {
|
||||
type Room,
|
||||
} from "./state"
|
||||
|
||||
/**
|
||||
* `type: "error"` 的 message **会被前端原样弹成 toast**(store 的 case "error"
|
||||
* → setNotice → CollabHost 的 message.info),所以这里一律写中文、写成学生看得懂的
|
||||
* 话。协议层的校验错误(格式不对、题号不对)正常前端触发不到,但真触发了也得是
|
||||
* 一句人话 —— 原来那几条是 "Invalid problemId" 这样的英文,直接糊在学生脸上。
|
||||
*/
|
||||
function isTeacher(ws: CollabSocket) {
|
||||
return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? ""))
|
||||
}
|
||||
@@ -191,11 +197,17 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
||||
problemId?: unknown
|
||||
studentId?: unknown
|
||||
language?: unknown
|
||||
reason?: unknown
|
||||
}
|
||||
try {
|
||||
message = JSON.parse(raw) as typeof message
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }))
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "消息格式不对,请刷新页面重试",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,10 +242,15 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
||||
await handleReject(ws, message.studentId)
|
||||
return
|
||||
case "leave":
|
||||
handleLeave(ws)
|
||||
handleLeave(ws, message.reason)
|
||||
return
|
||||
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,
|
||||
) {
|
||||
if (typeof problemId !== "string" || !problemId) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" }))
|
||||
ws.send(
|
||||
JSON.stringify({ type: "error", message: "题号不对,请刷新页面重试" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (isTeacher(ws)) {
|
||||
@@ -338,7 +357,12 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
||||
return
|
||||
}
|
||||
if (typeof studentId !== "number") {
|
||||
ws.send(JSON.stringify({ type: "error", message: "Invalid studentId" }))
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "学生标识不对,请刷新页面重试",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -366,7 +390,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
||||
|
||||
// 老师同时只能在一个房间
|
||||
if (roomOf(ws)) {
|
||||
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" }))
|
||||
ws.send(JSON.stringify({ type: "error", message: "请先结束当前协作" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -440,32 +464,63 @@ async function handleReject(ws: CollabSocket, studentId: unknown) {
|
||||
broadcastRequests()
|
||||
}
|
||||
|
||||
/** 主动退出房间。老师点关闭、学生点结束都走这里 */
|
||||
function handleLeave(ws: CollabSocket) {
|
||||
/**
|
||||
* 主动退出房间。**两种语义,靠 reason 分**:
|
||||
*
|
||||
* - 不带 reason(或 `"done"`)—— 有人点了「结束协作」,这次帮忙到此结束,
|
||||
* 求助记录一并清掉;
|
||||
* - `"left"` —— 人只是离开了这道题的页面(教师端「页面即协作现场」,跳走就不在
|
||||
* 房间里了)。**这跟他掉线是同一件事**,所以走同一条收尾:教师离开 → 求助退回
|
||||
* 排队,学生不用重新举手,老师回来再点一次就接上;学生离开 → 求助随人清掉。
|
||||
*
|
||||
* 分开是因为两者对学生的意义完全不同:前者是「搞定了」,后者是「老师先走一下」,
|
||||
* 而原来都按前者处理 —— 老师点一下「提交信息」,学生就得重新举手。
|
||||
*/
|
||||
function handleLeave(ws: CollabSocket, reason: unknown) {
|
||||
const room = roomOf(ws)
|
||||
if (!room) return
|
||||
if (reason !== "left") {
|
||||
teardownRoom(room, "done")
|
||||
return
|
||||
}
|
||||
const side = ws === room.teacherSocket ? "teacher" : "student"
|
||||
teardownRoom(room, "peer_left", side, ws)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆房间。reason 决定两端看到什么:
|
||||
* done —— 有人主动结束,双方都收到,请求一并清除
|
||||
* peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose /
|
||||
* handleCollabBinary。offlineSide 是消失的那一方:老师消失,
|
||||
* 请求退回排队;学生消失,请求随人清掉。不传时(当前只有
|
||||
* handleLeave 走 "done")不做这一步,只拆房间
|
||||
* handleCollabBinary
|
||||
* peer_left —— 有人离开了这道题的页面(handleLeave 的 "left")
|
||||
*
|
||||
* offlineSide 是消失的那一方,决定请求的去向:老师消失 → 退回排队;学生消失 →
|
||||
* 随人清掉。不传时只拆房间。
|
||||
*
|
||||
* initiator 是主动发起的那条连接:**他收到的 reason 不一样** —— 点了「结束协作」
|
||||
* 是 `done`,离开页面是 `self_left`。对他来说这是「我自己干的」,不该看到一句
|
||||
* 「对方离开了」,也不该看到「老师已结束这次帮忙」。
|
||||
*/
|
||||
function teardownRoom(
|
||||
room: Room,
|
||||
reason: "done" | "peer_offline",
|
||||
reason: "done" | "peer_offline" | "peer_left",
|
||||
offlineSide?: "student" | "teacher",
|
||||
initiator?: CollabSocket,
|
||||
) {
|
||||
closeRoom(room.studentId)
|
||||
room.studentSocket.data.roomOwnerId = undefined
|
||||
room.teacherSocket.data.roomOwnerId = undefined
|
||||
const frame = JSON.stringify({ type: "room_closed", reason })
|
||||
room.studentSocket.send(frame)
|
||||
room.teacherSocket.send(frame)
|
||||
// 发起方收到的是「我自己干的」那一版:点了结束就是 done,离开页面是 self_left。
|
||||
// 不能跟对面收同一条 —— 学生自己切走了却看到「老师已结束这次帮忙」是假话
|
||||
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") {
|
||||
removeRequest(room.studentId)
|
||||
} else if (offlineSide === "teacher") {
|
||||
|
||||
@@ -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,11 +148,15 @@ const menuOptions = computed<DropdownOption[]>(() => {
|
||||
label: "复制代码",
|
||||
key: "copy",
|
||||
})
|
||||
// 协作中的教师不给「重置代码」:那会儿编辑器里是**学生的**代码,而 v-model
|
||||
// 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回
|
||||
if (!showCollabBar.value) {
|
||||
options.push({
|
||||
label: "重置代码",
|
||||
key: "reset",
|
||||
})
|
||||
}
|
||||
}
|
||||
if (isDesktop.value && userStore.isSuperAdmin) {
|
||||
options.push({
|
||||
label: "编辑题目",
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 进来的话,整套 CodeMirror(view / state / language /
|
||||
* autocomplete / lang-*)就跟着 App.vue 进了入口 chunk —— 首屏白白多下 640 KB
|
||||
* (gzip 后 210 KB),而下面那个 v-if 决定了学生根本不渲染它 —— 机房那批老机器
|
||||
* 解析这些字节是实打实的开销。
|
||||
*
|
||||
* 拆成异步之后首页的 JS 从 1.9 MB 降到 1.3 MB(gzip 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>
|
||||
|
||||
@@ -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.vue:EditorView 是类实例,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 的 showHelpButton),problemId 一定是公开
|
||||
* 题目的展示 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 的 view,start() 静默绑到死编辑器上,
|
||||
* 老师对着空白框干等,服务端那边房间却是活的。
|
||||
*/
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Extension } from "@codemirror/state"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
|
||||
/**
|
||||
* 语言对应的高亮扩展。学生端(SyncCodeEditor)和教师端(CollabModal)共用同一份 ——
|
||||
* 语言对应的高亮扩展。学生端和教师端共用同一份(两边都是题目页上的 SyncCodeEditor) ——
|
||||
* 两边各选各的,就是老师看到的高亮和补全跟学生手里那份对不上。
|
||||
*
|
||||
* Java / Golang / JavaScript 没有单独的包,落到 cpp(),是既有行为,不是遗漏。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -216,7 +216,7 @@ teacherSockets: Set<socket> // 在线老师,用于推 requests 与判断 no_
|
||||
|---|---|
|
||||
| `shared/store/collab.ts` | pinia store:持有 WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间 |
|
||||
| `shared/components/HelpRequestList.vue` | 顶栏红点 + 下拉列表,含同题聚合 |
|
||||
| `shared/components/CollabModal.vue` | 老师端协作模态框 |
|
||||
| ~~`shared/components/CollabModal.vue`~~ | 老师端协作模态框。**2026-09-16 删除**,见文末的变更 |
|
||||
| `shared/composables/collab.ts` | Y.Doc 与 yCollab 绑定,取代 `sync.ts` |
|
||||
|
||||
### 5.2 改动
|
||||
@@ -298,3 +298,89 @@ teacherSockets: Set<socket> // 在线老师,用于推 requests 与判断 no_
|
||||
的 Django 后端),其中「Yjs + y-webrtc for collaborative editing **in the flowchart
|
||||
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` 决定。
|
||||
|
||||
Reference in New Issue
Block a user