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:
@@ -69,8 +69,8 @@ export function handleCollabOpen(ws: CollabSocket) {
|
||||
// 学生(重)连:如果这个账号名下已经有一条请求(掉线重连回来,或者干脆是
|
||||
// 同账号第二个标签页),把它迁移到这条新连接上,并把当前状态补发回去 ——
|
||||
// 前端 onConnected 时会先把本地状态清空等着这条补发,不发的话就永远卡在
|
||||
// idle;不迁移 socket 归属的话,旧连接一断,handleHelpCancel 的
|
||||
// `request.socket === ws` 校验就会认不出这条新连接发起的取消
|
||||
// idle;不迁移 socket 归属的话,sendHelpStatus/accept 等后续推送会发到一条
|
||||
// 已经不用的旧连接上,新连接(新标签页)什么都收不到
|
||||
const request = getRequest(ws.data.userId)
|
||||
if (!request) return
|
||||
request.socket = ws
|
||||
@@ -234,9 +234,11 @@ async function handleHelpRequest(ws: CollabSocket, problemId: unknown) {
|
||||
|
||||
function handleHelpCancel(ws: CollabSocket) {
|
||||
const request = getRequest(ws.data.userId)
|
||||
// 同一账号可能开着两个标签页;只能取消自己这条连接发起的请求,不然 B 标签页
|
||||
// 能把 A 标签页排队中的求助顶掉 —— 和 handleCollabClose 排队分支同一类归属漏洞
|
||||
if (!request || request.socket !== ws || request.status === "active") return
|
||||
// 不比对 socket 归属:取消的是这个学生自己的求助,不管从他哪个标签页发起都
|
||||
// 合法——getRequest(ws.data.userId) 已经把范围锁在这一个用户上了,不是跨用户
|
||||
// 操作。这里和 handleCollabClose 的排队分支不是同一类问题:那边关闭事件是
|
||||
// 「顺带」触发的,必须认出是不是本人这条连接;这里是用户主动点了取消
|
||||
if (!request || request.status === "active") return
|
||||
removeRequest(ws.data.userId)
|
||||
broadcastRequests()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user