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