feat(collab): 教师端弹框补上代码提示,语言跟着学生走
Some checks failed
Deploy / deploy (push) Has been cancelled

教师端的协作编辑器只有高亮和括号匹配,没装 autocompletion —— 老师替学生
写代码时没有下拉补全,只能盲敲。而且语言写死 cpp(),学生写 Python 时老师
看到的是 C 的高亮。

- 求助协议带上语言:help_request 记到 HelpRequest,accept 时写进 Room,
  room_open 发给两端;认不出的语言一律当 C(老客户端不带这个字段,而它
  以前就是写死 C 的)
- 新增 help_language / room_language:学生在排队或协作期间切语言,只更新
  语言,不动队列位置和房间;已开房就把新语言推给老师,弹框的高亮和补全
  实时跟着换
- CollabModal 装上 autocompletion,和学生端用同一套 enhanceCompletion +
  completeAnyWord;语言→高亮扩展的映射抽到 shared/extensions/language.ts
  两端共用,免得再分叉
- 学生端求助按钮合并状态提示:原来按钮旁边还挂一个 n-tag,一行工具栏在
  1280 的机房屏上放不下,改成状态全进 label(已求助 · 待接入 / 已求助 ·
  前面 N 人 / xxx 老师帮你中)

扩展数组变了 vue-codemirror 会整体 reconfigure,但 CM6 对已存在的
compartment 取 `compartments.get() || ext.inner`,collabDoc 那个装 yCollab
的 compartment 内容会被沿用,切语言、切主题都不会把协作弄断 —— 实跑验证过
双向同步、补全下拉、协作中切语言三条路径。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015APGLsyaCUa7XFMYAaCho4
This commit is contained in:
2026-08-30 10:28:36 -06:00
parent 2f9b6dbf03
commit 38b81881d5
8 changed files with 181 additions and 38 deletions

View File

@@ -64,16 +64,39 @@ const showHelpButton = computed(
!isContestMode.value,
)
/**
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
* (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。
*/
const helpButtonText = computed(() => {
if (collabStore.helpStatus === "active") return "老师正在帮你"
if (collabStore.helpStatus === "pending") return "取消求助"
if (collabStore.helpStatus === "active") {
const name = collabStore.teacherName
return name ? `${name} 老师帮你中` : "老师正在帮你"
}
if (collabStore.helpStatus === "pending") {
return collabStore.queueAhead > 0
? `已求助 · 前面 ${collabStore.queueAhead}`
: "已求助 · 待接入"
}
return "求助"
})
const helpButtonType = computed(() => {
if (collabStore.helpStatus === "active") return "success"
if (collabStore.helpStatus === "pending") return "warning"
return "default"
})
// 排队中点一下就是取消,这句话再挤进 label 就太长了,挂在原生 title 上。
// active 时按钮是 disabled浏览器不会给 disabled 元素显示 title所以不放
const helpButtonTitle = computed(() =>
collabStore.helpStatus === "pending" ? "点击取消求助" : undefined,
)
const toggleHelp = () => {
if (collabStore.helpStatus === "pending") collabStore.cancelHelp()
else if (collabStore.helpStatus === "idle")
collabStore.requestHelp(problem.value!._id)
collabStore.requestHelp(problem.value!._id, codeStore.code.language)
}
const showGoSubmissionButton = computed(() => {
@@ -251,23 +274,16 @@ onMounted(() => {
<n-button :size="buttonSize">更多操作</n-button>
</n-dropdown>
<template v-if="showHelpButton">
<n-button
:size="buttonSize"
:type="collabStore.helpStatus === 'idle' ? 'default' : 'warning'"
:disabled="collabStore.helpStatus === 'active'"
@click="toggleHelp"
>
{{ helpButtonText }}
</n-button>
<n-tag v-if="collabStore.helpStatus === 'pending'" type="info">
已求助{{ collabStore.queueAhead > 0 ? `,前面还有 ${collabStore.queueAhead}` : ",等待老师接入" }}
</n-tag>
<n-tag v-else-if="collabStore.helpStatus === 'active'" type="success">
{{ collabStore.teacherName }} 老师正在帮你
</n-tag>
</template>
<n-button
v-if="showHelpButton"
:size="buttonSize"
:type="helpButtonType"
:disabled="collabStore.helpStatus === 'active'"
:title="helpButtonTitle"
@click="toggleHelp"
>
{{ helpButtonText }}
</n-button>
</n-flex>
<n-modal

View File

@@ -1,12 +1,18 @@
<script setup lang="ts">
import { cpp } from "@codemirror/lang-cpp"
import { bracketMatching } from "@codemirror/language"
import { closeBrackets } from "@codemirror/autocomplete"
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"
@@ -26,12 +32,25 @@ const show = computed({
},
})
/**
* 语言跟着学生走room_open 带过来,学生协作期间切语言会再来一条 room_language。
* 取不到时按 C —— 和这里原来写死 cpp() 的表现一致。
*/
const language = computed<LANGUAGE>(() => collabStore.room?.language ?? "C")
const extensions = computed(() => [
styleTheme,
cpp(),
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(),
])
@@ -80,7 +99,7 @@ onUnmounted(() => {
v-model:show="show"
preset="card"
:style="{ width: '80vw', maxWidth: '1100px' }"
:title="`正在帮 ${collabStore.room?.peerName ?? ''} · ${collabStore.room?.problemId ?? ''}`"
:title="`正在帮 ${collabStore.room?.peerName ?? ''} · ${collabStore.room?.problemId ?? ''} · ${language}`"
>
<template #header-extra>
<n-button

View File

@@ -1,7 +1,4 @@
<script lang="ts" setup>
import { cpp } from "@codemirror/lang-cpp"
import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql"
import { bracketMatching } from "@codemirror/language"
import { Codemirror } from "vue-codemirror"
import {
@@ -9,13 +6,13 @@ import {
closeBrackets,
completeAnyWord,
} from "@codemirror/autocomplete"
import type { Extension } from "@codemirror/state"
import type { EditorView } from "@codemirror/view"
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"
@@ -40,11 +37,7 @@ const {
} = defineProps<Props>()
const code = defineModel<string>("value")
const langExtension = computed((): Extension => {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp()
})
const langExtension = computed(() => languageExtension(language))
const extensions = computed(() => [
styleTheme,
@@ -96,6 +89,15 @@ watch(
},
)
// 求助期间切了语言:同步给老师,他那边的高亮和补全跟着换。
// 教师端不发 —— 这个组件在教师自己的题目页上也挂着,他的语言和房间无关
watch(
() => language,
(lang) => {
if (!collabStore.isTeacher) collabStore.updateLanguage(lang)
},
)
onUnmounted(() => {
stop()
// 这个组件卸载意味着编辑器没了(切走页面、或者把语言切成流程图),

View File

@@ -726,6 +726,8 @@ export interface CollabMessage extends WebSocketMessage {
| "requests"
| "help_status"
| "room_open"
// 协作期间学生换了语言,只发给教师端
| "room_language"
| "room_closed"
| "error"
}

View File

@@ -0,0 +1,16 @@
import { cpp } from "@codemirror/lang-cpp"
import { python } from "@codemirror/lang-python"
import { sql, SQLite } from "@codemirror/lang-sql"
import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types"
/**
* 语言对应的高亮扩展。学生端SyncCodeEditor和教师端CollabModal共用同一份 ——
* 两边各选各的,就是老师看到的高亮和补全跟学生手里那份对不上。
*
* Java / Golang / JavaScript 没有单独的包,落到 cpp(),是既有行为,不是遗漏。
*/
export function languageExtension(language: LANGUAGE): Extension {
if (language === "SQL") return sql({ dialect: SQLite, upperCaseKeywords: true })
return ["Python2", "Python3"].includes(language) ? python() : cpp()
}

View File

@@ -4,6 +4,7 @@ import {
type CollabRequestItem,
} from "shared/composables/websocket"
import { useUserStore } from "shared/store/user"
import type { LANGUAGE } from "utils/types"
export type HelpStatus = "idle" | "pending" | "active"
@@ -11,6 +12,8 @@ export interface RoomInfo {
peerName: string
peerRole: "student" | "teacher"
problemId: string
/** 学生编辑器的语言。教师端的弹框按它选高亮和补全 */
language: LANGUAGE
}
/**
@@ -98,8 +101,13 @@ export const useCollabStore = defineStore("collab", () => {
peerName: String(data.peer?.name ?? ""),
peerRole: data.peer?.role === "teacher" ? "teacher" : "student",
problemId: String(data.problemId ?? ""),
language: (data.language as LANGUAGE) ?? "C",
}
return
case "room_language":
// 学生在协作期间切了语言。只有教师端收得到这条
if (room.value) room.value.language = (data.language as LANGUAGE) ?? "C"
return
case "room_closed":
// 不管 reason 一律先归位到 idle学生这一侧的 socket 发送失败时,
// 服务端把它从请求表里摘掉却**发不出**任何纠正性的 help_status
@@ -149,8 +157,17 @@ export const useCollabStore = defineStore("collab", () => {
notice.value = ""
}
function requestHelp(problemId: string) {
ws.send({ type: "help_request", problemId })
function requestHelp(problemId: string, language: LANGUAGE) {
ws.send({ type: "help_request", problemId, language })
}
/**
* 求助期间换了语言。老师那边的高亮和补全按这个值选,不同步过去他就只能对着
* 建房那一刻的语言给学生写代码。idle 时不发 —— 服务端压根没有这条求助记录。
*/
function updateLanguage(language: LANGUAGE) {
if (helpStatus.value === "idle") return
ws.send({ type: "help_language", language })
}
function cancelHelp() {
@@ -198,6 +215,7 @@ export const useCollabStore = defineStore("collab", () => {
connect,
disconnect,
requestHelp,
updateLanguage,
cancelHelp,
accept,
reject,