feat(web): 题目页的「开启同步」换成「求助」

学生发起求助,状态与排队位置显示在按钮旁。
教师端入口不在题目页,所以对教师隐藏这个按钮。

顺带修正 handleHelpCancel:去掉 request.socket !== ws 的校验。
学生取消自己的求助,不管从他哪个标签页发起都合法——
getRequest(ws.data.userId) 已经把范围锁在这一个用户上了,
不是跨用户操作,不需要再比对是不是同一条连接。这层校验此前
会让「第二个标签页点取消」被服务端静默丢弃,前端却已经乐观
地把按钮变回「求助」,是一处 UI 说谎;现在两边状态一致。
handleCollabClose 里排队分支的 socket 归属校验不受影响,
那里的关闭事件是顺带触发的,仍然需要认出是不是本人这条连接。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
2026-08-28 06:33:25 -06:00
parent ab8fcc2d42
commit 6f873be367
4 changed files with 46 additions and 165 deletions

View File

@@ -3,8 +3,7 @@ import { storeToRefs } from "pinia"
import { copyToClipboard, utoa } from "utils/functions"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { injectSyncStatus } from "oj/composables/syncStatus"
import { SYNC_MESSAGES } from "shared/composables/sync"
import { useCollabStore } from "shared/store/collab"
import {
ICON_SET,
LANGUAGE_FORMAT_VALUE,
@@ -27,17 +26,14 @@ const SubmitFlowchart = defineAsyncComponent(
interface Props {
storageKey: string
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
}
const { storageKey, isConnected = false } = defineProps<Props>()
const { storageKey } = defineProps<Props>()
// 注入同步状态
const syncStatus = injectSyncStatus()
const collabStore = useCollabStore()
const emit = defineEmits<{
changeLanguage: [v: LANGUAGE]
toggleSync: [v: boolean]
}>()
const message = useMessage()
@@ -50,20 +46,42 @@ const { problem, languages } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
const statisticPanel = ref(false)
// 计算属性
const isContestMode = computed(() => route.name === "contest problem")
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
const showSyncFeature = computed(
// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」——
// 教师端的入口在顶栏,不在题目页
const showHelpButton = computed(
() =>
isDesktop.value &&
userStore.isAuthed &&
!userStore.isTeacherOrAbove &&
codeStore.code.language !== "Flowchart" &&
!isContestMode.value,
)
const helpButtonText = computed(() => {
if (collabStore.helpStatus === "active") return "老师正在帮你"
if (collabStore.helpStatus === "pending") return "取消求助"
return "求助"
})
const toggleHelp = () => {
if (collabStore.helpStatus === "pending") collabStore.cancelHelp()
else if (collabStore.helpStatus === "idle")
collabStore.requestHelp(problem.value!._id)
}
// 服务端的一次性提示(没有老师在线、老师取消了求助)
watch(
() => collabStore.notice,
(text) => {
if (text) message.info(collabStore.consumeNotice())
},
)
const showGoSubmissionButton = computed(() => {
if (isContestMode.value) return true
else if (userStore.isAdminRole) return true
@@ -191,17 +209,6 @@ const goEdit = () => {
window.open(router.resolve(url).href, "_blank")
}
const toggleSync = () => {
syncEnabled.value = !syncEnabled.value
emit("toggleSync", syncEnabled.value)
}
defineExpose({
resetSyncStatus: () => {
syncEnabled.value = false
},
})
onMounted(() => {
if (!languages.value.includes(codeStore.code.language)) {
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
@@ -250,31 +257,22 @@ onMounted(() => {
<n-button :size="buttonSize">更多操作</n-button>
</n-dropdown>
<template v-if="showSyncFeature">
<template v-if="showHelpButton">
<n-button
:size="buttonSize"
:type="syncEnabled ? 'warning' : 'default'"
@click="toggleSync"
:type="collabStore.helpStatus === 'idle' ? 'default' : 'warning'"
:disabled="collabStore.helpStatus === 'active'"
@click="toggleHelp"
>
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
{{ helpButtonText }}
</n-button>
<!-- 同步状态标签 -->
<template v-if="isConnected">
<n-tag v-if="syncStatus.otherUser.value" type="info">
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
</n-tag>
<n-tag
v-if="
userStore.isSuperAdmin &&
!syncStatus.otherUser.value &&
syncStatus.hadConnection.value
"
type="warning"
>
{{ SYNC_MESSAGES.STUDENT_LEFT(syncStatus.lastLeftUser.value?.name) }}
</n-tag>
</template>
<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-flex>

View File

@@ -2,7 +2,6 @@
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { provideSyncStatus } from "oj/composables/syncStatus"
import { SOURCES } from "utils/constants"
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -15,7 +14,6 @@ const FlowchartEditor = defineAsyncComponent(
)
const route = useRoute()
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
const codeStore = useCodeStore()
@@ -24,10 +22,6 @@ const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const sync = ref(false)
// 提供同步状态给子组件使用
const syncStatus = provideSyncStatus()
const contestID = route.params.contestID || null
const storageKey = computed(
() =>
@@ -72,38 +66,13 @@ const changeLanguage = (v: LANGUAGE) => {
)
}
const toggleSync = (value: boolean) => {
sync.value = value
if (!value) {
syncStatus.reset()
}
}
const handleSyncClosed = () => {
sync.value = false
syncStatus.reset()
formRef.value?.resetSyncStatus()
}
const handleSyncStatusChange = (status: {
otherUser?: { name: string; isSuperAdmin: boolean }
}) => {
syncStatus.setOtherUser(status.otherUser)
}
// 提供FlowchartEditor的ref给子组件
provide("flowchartEditorRef", flowchartEditorRef)
</script>
<template>
<n-flex vertical>
<Form
ref="formRef"
:storage-key="storageKey"
:is-connected="sync"
@change-language="changeLanguage"
@toggle-sync="toggleSync"
/>
<Form :storage-key="storageKey" @change-language="changeLanguage" />
<FlowchartEditor
v-if="codeStore.code.language === 'Flowchart'"
ref="flowchartEditorRef"
@@ -111,13 +80,10 @@ provide("flowchartEditorRef", flowchartEditorRef)
<SyncCodeEditor
v-else
v-model:value="codeStore.code.value"
:sync="sync"
:problem="problem!._id"
:language="codeStore.code.language"
:height="editorHeight"
@update:model-value="changeCode"
@sync-closed="handleSyncClosed"
@sync-status-change="handleSyncStatusChange"
/>
</n-flex>
</template>

View File

@@ -3,7 +3,6 @@ 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 { EditorView } from "@codemirror/view"
import { Codemirror } from "vue-codemirror"
import {
autocompletion,
@@ -15,20 +14,11 @@ import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
import { styleTheme } from "shared/extensions/baseTheme"
import { useCodeSync, SYNC_ERROR_CODES } from "../composables/sync"
import { useBreakpoints } from "../composables/breakpoints"
import { enhanceCompletion } from "shared/extensions/autocompletion"
const isDark = useDark()
interface EditorReadyPayload {
view: EditorView
state: any
container: HTMLElement
}
interface Props {
sync: boolean
problem: string
language?: LANGUAGE
fontSize?: number
@@ -38,8 +28,6 @@ interface Props {
}
const {
sync,
problem,
language = "Python3",
fontSize = 20,
height = "100%",
@@ -48,15 +36,6 @@ const {
} = defineProps<Props>()
const code = defineModel<string>("value")
const emit = defineEmits<{
syncClosed: []
syncStatusChange: [
status: { otherUser?: { name: string; isSuperAdmin: boolean } },
]
}>()
const { isDesktop } = useBreakpoints()
const langExtension = computed((): Extension => {
if (language === "SQL")
return sql({ dialect: SQLite, upperCaseKeywords: true })
@@ -72,71 +51,8 @@ const extensions = computed(() => [
autocompletion({
override: [enhanceCompletion(language), completeAnyWord],
}),
getInitialExtension(),
])
const { startSync, stopSync, getInitialExtension } = useCodeSync()
const editorView = ref<EditorView | null>(null)
let cleanupSync: (() => void) | null = null
const cleanupSyncResources = () => {
if (cleanupSync) {
cleanupSync()
cleanupSync = null
}
stopSync()
}
const initSync = async () => {
if (!editorView.value || !problem || !isDesktop.value) return
cleanupSyncResources()
cleanupSync = await startSync({
problemId: problem,
editorView: editorView.value as EditorView,
onStatusChange: (status) => {
// 处理需要断开同步的情况
if (
(status.errorCode === SYNC_ERROR_CODES.SUPER_ADMIN_LEFT ||
status.errorCode === SYNC_ERROR_CODES.MISSING_SUPER_ADMIN) &&
!status.connected
) {
emit("syncClosed")
}
emit("syncStatusChange", { otherUser: status.otherUser })
},
})
}
const handleEditorReady = (payload: EditorReadyPayload) => {
editorView.value = payload.view as EditorView
if (sync) {
initSync()
}
}
watch(
() => sync,
(shouldSync) => {
if (shouldSync) {
initSync()
} else {
cleanupSyncResources()
}
},
)
watch(
() => problem,
(newProblem, oldProblem) => {
if (newProblem !== oldProblem && sync) {
initSync()
}
},
)
onUnmounted(cleanupSyncResources)
</script>
<template>
@@ -148,6 +64,5 @@ onUnmounted(cleanupSyncResources)
:tab-size="4"
:placeholder="placeholder"
:style="{ height, fontSize: `${fontSize}px` }"
@ready="handleEditorReady"
/>
</template>