import { ref, computed } from "vue" import { getSubmission } from "oj/api" import { SubmissionStatus } from "utils/constants" import type { PendingAchievement, Submission } from "utils/types" import { useAchievementStore } from "shared/store/achievement" import { useSubmissionWebSocket, type SubmissionUpdate } from "shared/composables/websocket" import { useFallbackPoll } from "shared/composables/fallbackPoll" /** * 判题监控 Composable * 负责通过 WebSocket + 轮询双保险机制监控判题结果 */ export function useSubmissionMonitor() { // ==================== 状态 ==================== const submissionId = ref("") const submission = ref() // ==================== WebSocket 处理 ==================== const handleSubmissionUpdate = (data: SubmissionUpdate) => { // push_to_user 复用了 submission_update 这个 channel handler, // 其他类型的消息会走同一条 WebSocket 帧进来,必须先分流 const frame = data as unknown as { type: string achievements?: PendingAchievement[] } if (frame.type === "achievement_unlocked") { useAchievementStore().enqueue(frame.achievements ?? []) return } if (frame.type !== "submission_update") { return } console.log("[SubmissionMonitor] 收到WebSocket更新:", data) if (data.submissionId !== submissionId.value) { console.log("[SubmissionMonitor] 提交ID不匹配,忽略") return } // 订阅后服务端立刻回的那一帧(还在排队也回)就算:WebSocket 是通的,轮询退到慢档 fallback.confirm() if (!submission.value) { submission.value = {} as Submission } submission.value.result = data.result as Submission["result"] // 判题完成或出错,获取完整详情 if (data.status === "finished" || data.status === "error") { console.log(`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`) // 停止轮询(WebSocket已成功) fallback.stop() // 结果已经到手,别让重连再去重放这条早就判完的订阅 unsubscribe() const id = submissionId.value getSubmission(id).then((res) => { if (id !== submissionId.value) return // 同上面轮询:换过题就扔掉 submission.value = res // 15分钟无新提交则断开WebSocket(节省资源) scheduleDisconnect(15 * 60 * 1000) }) } } // 初始化 WebSocket const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect, status } = useSubmissionWebSocket(handleSubmissionUpdate) // ==================== 轮询兜底 ==================== const fallback = useFallbackPoll({ status, poll: async () => { const id = submissionId.value if (!id) return true const res = await getSubmission(id) // 请求在路上时换了题(reset)或者又交了一次:回来的是上一条,扔掉。 // 不扔的话上一道题判对了,会把新题标成已解决、弹点评 if (id !== submissionId.value) return false submission.value = res return res.result !== SubmissionStatus.judging && res.result !== SubmissionStatus.pending }, }) // ==================== 启动监控 ==================== const startMonitoring = (id: string) => { submissionId.value = id submission.value = { id, result: SubmissionStatus.submitting } as Submission // 取消之前的断开计划 cancelScheduledDisconnect() // connect() 是幂等的:已经连着就直接返回,顺带把上一次空闲断开留下的状态清掉 connect() // 直接订阅,不必先等 status 变成 connected:连接没就绪时 subscribe() 会把 id // 记在 pendingSubmissionId 上,由 onConnected() 补发(断线重连后同样有效)。 // // 原来这里是 watch(wsStatus, ..., { immediate: true }),而 immediate 的回调在 // watch() **返回之前**就同步跑了 —— 已经连着时 unwatch 还是 null,if 不成立, // 这个 watcher 就永远停不掉:每提交一次泄漏一个,而且往后每次重连时它们都会 // 把各自那个早就判完的旧 submissionId 重新订阅一遍。 subscribe(id) // 轮询兜底:WebSocket 通的话 20 秒才问一次,不通才快拉(见 fallbackPoll.ts) fallback.start() } /** * 换题的时候丢掉当前这条提交。题目页换题(顶栏题号直达、「下一题」)是同一个组件 * 复用、只换路由参数,不清的话上一道题的结果面板原样挂在新题上;而且上一道还在判的话, * 判完触发的「通过」处理会把**新题**标成已解决。提交本身照常判完落库,只是这边不再跟。 */ const reset = () => { fallback.stop() unsubscribe() // 连接由 store 持有,离开题目页组件卸了它也还在。和判完之后一样,空闲 15 分钟断开; // 不在这里立刻断,是因为刚交的那条判完之后还可能推成就过来 scheduleDisconnect(15 * 60 * 1000) submissionId.value = "" submission.value = undefined } // ==================== 计算属性 ==================== const judging = computed(() => submission.value?.result === SubmissionStatus.judging) const pending = computed(() => submission.value?.result === SubmissionStatus.pending) const submitting = computed(() => submission.value?.result === SubmissionStatus.submitting) const isProcessing = computed(() => { return judging.value || pending.value || submitting.value }) return { // 状态 submissionId, submission, // 计算属性 judging, pending, submitting, isProcessing, // 方法 startMonitoring, reset, } }