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 时会先把本地状态清空等着这条补发,不发的话就永远卡在
|
// 前端 onConnected 时会先把本地状态清空等着这条补发,不发的话就永远卡在
|
||||||
// idle;不迁移 socket 归属的话,旧连接一断,handleHelpCancel 的
|
// idle;不迁移 socket 归属的话,sendHelpStatus/accept 等后续推送会发到一条
|
||||||
// `request.socket === ws` 校验就会认不出这条新连接发起的取消
|
// 已经不用的旧连接上,新连接(新标签页)什么都收不到
|
||||||
const request = getRequest(ws.data.userId)
|
const request = getRequest(ws.data.userId)
|
||||||
if (!request) return
|
if (!request) return
|
||||||
request.socket = ws
|
request.socket = ws
|
||||||
@@ -234,9 +234,11 @@ async function handleHelpRequest(ws: CollabSocket, problemId: unknown) {
|
|||||||
|
|
||||||
function handleHelpCancel(ws: CollabSocket) {
|
function handleHelpCancel(ws: CollabSocket) {
|
||||||
const request = getRequest(ws.data.userId)
|
const request = getRequest(ws.data.userId)
|
||||||
// 同一账号可能开着两个标签页;只能取消自己这条连接发起的请求,不然 B 标签页
|
// 不比对 socket 归属:取消的是这个学生自己的求助,不管从他哪个标签页发起都
|
||||||
// 能把 A 标签页排队中的求助顶掉 —— 和 handleCollabClose 排队分支同一类归属漏洞
|
// 合法——getRequest(ws.data.userId) 已经把范围锁在这一个用户上了,不是跨用户
|
||||||
if (!request || request.socket !== ws || request.status === "active") return
|
// 操作。这里和 handleCollabClose 的排队分支不是同一类问题:那边关闭事件是
|
||||||
|
// 「顺带」触发的,必须认出是不是本人这条连接;这里是用户主动点了取消
|
||||||
|
if (!request || request.status === "active") return
|
||||||
removeRequest(ws.data.userId)
|
removeRequest(ws.data.userId)
|
||||||
broadcastRequests()
|
broadcastRequests()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { storeToRefs } from "pinia"
|
|||||||
import { copyToClipboard, utoa } from "utils/functions"
|
import { copyToClipboard, utoa } from "utils/functions"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { injectSyncStatus } from "oj/composables/syncStatus"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { SYNC_MESSAGES } from "shared/composables/sync"
|
|
||||||
import {
|
import {
|
||||||
ICON_SET,
|
ICON_SET,
|
||||||
LANGUAGE_FORMAT_VALUE,
|
LANGUAGE_FORMAT_VALUE,
|
||||||
@@ -27,17 +26,14 @@ const SubmitFlowchart = defineAsyncComponent(
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
storageKey: string
|
storageKey: string
|
||||||
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { storageKey, isConnected = false } = defineProps<Props>()
|
const { storageKey } = defineProps<Props>()
|
||||||
|
|
||||||
// 注入同步状态
|
const collabStore = useCollabStore()
|
||||||
const syncStatus = injectSyncStatus()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
changeLanguage: [v: LANGUAGE]
|
changeLanguage: [v: LANGUAGE]
|
||||||
toggleSync: [v: boolean]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -50,20 +46,42 @@ const { problem, languages } = storeToRefs(problemStore)
|
|||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const syncEnabled = ref(false) // 用户点击按钮后的意图状态(想要开启/关闭)
|
|
||||||
const statisticPanel = ref(false)
|
const statisticPanel = ref(false)
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isContestMode = computed(() => route.name === "contest problem")
|
const isContestMode = computed(() => route.name === "contest problem")
|
||||||
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
|
||||||
const showSyncFeature = computed(
|
// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」——
|
||||||
|
// 教师端的入口在顶栏,不在题目页
|
||||||
|
const showHelpButton = computed(
|
||||||
() =>
|
() =>
|
||||||
isDesktop.value &&
|
isDesktop.value &&
|
||||||
userStore.isAuthed &&
|
userStore.isAuthed &&
|
||||||
|
!userStore.isTeacherOrAbove &&
|
||||||
codeStore.code.language !== "Flowchart" &&
|
codeStore.code.language !== "Flowchart" &&
|
||||||
!isContestMode.value,
|
!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(() => {
|
const showGoSubmissionButton = computed(() => {
|
||||||
if (isContestMode.value) return true
|
if (isContestMode.value) return true
|
||||||
else if (userStore.isAdminRole) return true
|
else if (userStore.isAdminRole) return true
|
||||||
@@ -191,17 +209,6 @@ const goEdit = () => {
|
|||||||
window.open(router.resolve(url).href, "_blank")
|
window.open(router.resolve(url).href, "_blank")
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSync = () => {
|
|
||||||
syncEnabled.value = !syncEnabled.value
|
|
||||||
emit("toggleSync", syncEnabled.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
resetSyncStatus: () => {
|
|
||||||
syncEnabled.value = false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!languages.value.includes(codeStore.code.language)) {
|
if (!languages.value.includes(codeStore.code.language)) {
|
||||||
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
|
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python3 会被后端拒绝)
|
||||||
@@ -250,31 +257,22 @@ onMounted(() => {
|
|||||||
<n-button :size="buttonSize">更多操作</n-button>
|
<n-button :size="buttonSize">更多操作</n-button>
|
||||||
</n-dropdown>
|
</n-dropdown>
|
||||||
|
|
||||||
<template v-if="showSyncFeature">
|
<template v-if="showHelpButton">
|
||||||
<n-button
|
<n-button
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
:type="syncEnabled ? 'warning' : 'default'"
|
:type="collabStore.helpStatus === 'idle' ? 'default' : 'warning'"
|
||||||
@click="toggleSync"
|
:disabled="collabStore.helpStatus === 'active'"
|
||||||
|
@click="toggleHelp"
|
||||||
>
|
>
|
||||||
{{ syncEnabled ? SYNC_MESSAGES.SYNC_ON : SYNC_MESSAGES.SYNC_OFF }}
|
{{ helpButtonText }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
<!-- 同步状态标签 -->
|
<n-tag v-if="collabStore.helpStatus === 'pending'" type="info">
|
||||||
<template v-if="isConnected">
|
已求助{{ collabStore.queueAhead > 0 ? `,前面还有 ${collabStore.queueAhead} 人` : ",等待老师接入" }}
|
||||||
<n-tag v-if="syncStatus.otherUser.value" type="info">
|
</n-tag>
|
||||||
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
|
<n-tag v-else-if="collabStore.helpStatus === 'active'" type="success">
|
||||||
</n-tag>
|
{{ collabStore.teacherName }} 老师正在帮你
|
||||||
<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>
|
|
||||||
</template>
|
</template>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { provideSyncStatus } from "oj/composables/syncStatus"
|
|
||||||
import { SOURCES } from "utils/constants"
|
import { SOURCES } from "utils/constants"
|
||||||
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
@@ -15,7 +14,6 @@ const FlowchartEditor = defineAsyncComponent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const formRef = useTemplateRef<InstanceType<typeof Form>>("formRef")
|
|
||||||
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
|
const flowchartEditorRef = useTemplateRef("flowchartEditorRef")
|
||||||
|
|
||||||
const codeStore = useCodeStore()
|
const codeStore = useCodeStore()
|
||||||
@@ -24,10 +22,6 @@ const { problem } = storeToRefs(problemStore)
|
|||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const sync = ref(false)
|
|
||||||
// 提供同步状态给子组件使用
|
|
||||||
const syncStatus = provideSyncStatus()
|
|
||||||
|
|
||||||
const contestID = route.params.contestID || null
|
const contestID = route.params.contestID || null
|
||||||
const storageKey = computed(
|
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给子组件
|
// 提供FlowchartEditor的ref给子组件
|
||||||
provide("flowchartEditorRef", flowchartEditorRef)
|
provide("flowchartEditorRef", flowchartEditorRef)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<Form
|
<Form :storage-key="storageKey" @change-language="changeLanguage" />
|
||||||
ref="formRef"
|
|
||||||
:storage-key="storageKey"
|
|
||||||
:is-connected="sync"
|
|
||||||
@change-language="changeLanguage"
|
|
||||||
@toggle-sync="toggleSync"
|
|
||||||
/>
|
|
||||||
<FlowchartEditor
|
<FlowchartEditor
|
||||||
v-if="codeStore.code.language === 'Flowchart'"
|
v-if="codeStore.code.language === 'Flowchart'"
|
||||||
ref="flowchartEditorRef"
|
ref="flowchartEditorRef"
|
||||||
@@ -111,13 +80,10 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
|||||||
<SyncCodeEditor
|
<SyncCodeEditor
|
||||||
v-else
|
v-else
|
||||||
v-model:value="codeStore.code.value"
|
v-model:value="codeStore.code.value"
|
||||||
:sync="sync"
|
|
||||||
:problem="problem!._id"
|
:problem="problem!._id"
|
||||||
:language="codeStore.code.language"
|
:language="codeStore.code.language"
|
||||||
:height="editorHeight"
|
:height="editorHeight"
|
||||||
@update:model-value="changeCode"
|
@update:model-value="changeCode"
|
||||||
@sync-closed="handleSyncClosed"
|
|
||||||
@sync-status-change="handleSyncStatusChange"
|
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { cpp } from "@codemirror/lang-cpp"
|
|||||||
import { python } from "@codemirror/lang-python"
|
import { python } from "@codemirror/lang-python"
|
||||||
import { sql, SQLite } from "@codemirror/lang-sql"
|
import { sql, SQLite } from "@codemirror/lang-sql"
|
||||||
import { bracketMatching } from "@codemirror/language"
|
import { bracketMatching } from "@codemirror/language"
|
||||||
import { EditorView } from "@codemirror/view"
|
|
||||||
import { Codemirror } from "vue-codemirror"
|
import { Codemirror } from "vue-codemirror"
|
||||||
import {
|
import {
|
||||||
autocompletion,
|
autocompletion,
|
||||||
@@ -15,20 +14,11 @@ import type { LANGUAGE } from "utils/types"
|
|||||||
import { oneDark } from "../themes/oneDark"
|
import { oneDark } from "../themes/oneDark"
|
||||||
import { smoothy } from "../themes/smoothy"
|
import { smoothy } from "../themes/smoothy"
|
||||||
import { styleTheme } from "shared/extensions/baseTheme"
|
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"
|
import { enhanceCompletion } from "shared/extensions/autocompletion"
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
|
|
||||||
interface EditorReadyPayload {
|
|
||||||
view: EditorView
|
|
||||||
state: any
|
|
||||||
container: HTMLElement
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sync: boolean
|
|
||||||
problem: string
|
problem: string
|
||||||
language?: LANGUAGE
|
language?: LANGUAGE
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
@@ -38,8 +28,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sync,
|
|
||||||
problem,
|
|
||||||
language = "Python3",
|
language = "Python3",
|
||||||
fontSize = 20,
|
fontSize = 20,
|
||||||
height = "100%",
|
height = "100%",
|
||||||
@@ -48,15 +36,6 @@ const {
|
|||||||
} = defineProps<Props>()
|
} = defineProps<Props>()
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
syncClosed: []
|
|
||||||
syncStatusChange: [
|
|
||||||
status: { otherUser?: { name: string; isSuperAdmin: boolean } },
|
|
||||||
]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
|
|
||||||
const langExtension = computed((): Extension => {
|
const langExtension = computed((): Extension => {
|
||||||
if (language === "SQL")
|
if (language === "SQL")
|
||||||
return sql({ dialect: SQLite, upperCaseKeywords: true })
|
return sql({ dialect: SQLite, upperCaseKeywords: true })
|
||||||
@@ -72,71 +51,8 @@ const extensions = computed(() => [
|
|||||||
autocompletion({
|
autocompletion({
|
||||||
override: [enhanceCompletion(language), completeAnyWord],
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -148,6 +64,5 @@ onUnmounted(cleanupSyncResources)
|
|||||||
:tab-size="4"
|
:tab-size="4"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:style="{ height, fontSize: `${fontSize}px` }"
|
:style="{ height, fontSize: `${fontSize}px` }"
|
||||||
@ready="handleEditorReady"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user