diff --git a/apps/web/src/oj/contest/pages/class.vue b/apps/web/src/oj/contest/pages/class.vue index 80af22c..88a9db0 100644 --- a/apps/web/src/oj/contest/pages/class.vue +++ b/apps/web/src/oj/contest/pages/class.vue @@ -11,6 +11,7 @@ import { useLeaveStudents } from "shared/composables/leaveStudents" import { ContestStatus, LANGUAGE_FORMAT_VALUE } from "utils/constants" import { parseTime, secondsToDuration } from "utils/functions" import type { Submission } from "utils/types" +import { useVisibleInterval } from "shared/composables/visibleInterval" /** * 老师的「全班情况」,考完叫「成绩」(设计稿「比赛重设计」老师两块)。学生看不到这一页。 @@ -43,7 +44,7 @@ async function load() { const running = computed(() => contestStore.contestStatus === ContestStatus.underway) const ended = computed(() => contestStore.contestStatus === ContestStatus.finished) -const { pause, resume } = useIntervalFn(load, 15_000, { immediate: false }) +const { pause, resume } = useVisibleInterval(load, 15_000) watch( () => [contestStore.canEnter, running.value] as const, ([enter, run]) => { diff --git a/apps/web/src/oj/contest/pages/problems.vue b/apps/web/src/oj/contest/pages/problems.vue index 23d658e..7a27860 100644 --- a/apps/web/src/oj/contest/pages/problems.vue +++ b/apps/web/src/oj/contest/pages/problems.vue @@ -7,6 +7,7 @@ import { useUserStore } from "shared/store/user" import { ContestStatus } from "utils/constants" import { parseTime, secondsToDuration } from "utils/functions" import RankMini from "../components/RankMini.vue" +import { useVisibleInterval } from "shared/composables/visibleInterval" /** * 比赛页「题目」(设计稿「比赛重设计」B,用户选的):一页放下。 @@ -24,12 +25,9 @@ const tone = useTone() const running = computed(() => contestStore.contestStatus === ContestStatus.underway) const ended = computed(() => contestStore.contestStatus === ContestStatus.finished) -const { pause, resume } = useIntervalFn( +const { pause, resume } = useVisibleInterval( () => contestStore.loadScoreboard(props.contestID), 10_000, - { - immediate: false, - }, ) watch( () => [contestStore.canEnter, contestStore.contestStatus] as const, diff --git a/apps/web/src/oj/contest/pages/rank.vue b/apps/web/src/oj/contest/pages/rank.vue index 5e37280..211dbc7 100644 --- a/apps/web/src/oj/contest/pages/rank.vue +++ b/apps/web/src/oj/contest/pages/rank.vue @@ -8,6 +8,7 @@ import { ContestStatus } from "utils/constants" import { secondsToDuration } from "utils/functions" import { classLabel } from "oj/submission/utils" import UserName from "shared/components/UserName.vue" +import { useVisibleInterval } from "shared/composables/visibleInterval" /** * 比赛排名(设计稿「比赛重设计」排行榜两块)。竞争感是用户要的(「我需要竞争」): @@ -26,12 +27,9 @@ const running = computed(() => contestStore.contestStatus === ContestStatus.unde const ended = computed(() => contestStore.contestStatus === ContestStatus.finished) const autoRefresh = ref(true) -const { pause, resume } = useIntervalFn( +const { pause, resume } = useVisibleInterval( () => contestStore.loadScoreboard(props.contestID), 10_000, - { - immediate: false, - }, ) watch( () => [contestStore.canEnter, running.value, autoRefresh.value] as const, diff --git a/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts b/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts index fdde300..f1f2b48 100644 --- a/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts +++ b/apps/web/src/oj/problem/composables/useSubmissionMonitor.ts @@ -1,10 +1,10 @@ -import { ref, computed, getCurrentScope, onScopeDispose } from "vue" -import { useIntervalFn, useTimeoutFn } from "@vueuse/core" +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 @@ -15,33 +15,6 @@ export function useSubmissionMonitor() { const submissionId = ref("") const submission = ref() - // ==================== 轮询机制 ==================== - const { pause: pausePolling, resume: resumePolling } = useIntervalFn( - async () => { - const id = submissionId.value - if (!id) return - - try { - const res = await getSubmission(id) - // 请求在路上时换了题(reset)或者又交了一次:回来的是上一条,扔掉。 - // 不扔的话上一道题判对了,会把新题标成已解决、弹点评 - if (id !== submissionId.value) return - submission.value = res - - const result = res.result - // 判题完成,停止轮询 - if (result !== SubmissionStatus.judging && result !== SubmissionStatus.pending) { - pausePolling() - } - } catch (error) { - console.error("[SubmissionMonitor] 轮询失败:", error) - pausePolling() - } - }, - 2000, - { immediate: false }, - ) - // ==================== WebSocket 处理 ==================== const handleSubmissionUpdate = (data: SubmissionUpdate) => { // push_to_user 复用了 submission_update 这个 channel handler, @@ -64,6 +37,8 @@ export function useSubmissionMonitor() { console.log("[SubmissionMonitor] 提交ID不匹配,忽略") return } + // 订阅后服务端立刻回的那一帧(还在排队也回)就算:WebSocket 是通的,轮询退到慢档 + fallback.confirm() if (!submission.value) { submission.value = {} as Submission @@ -76,7 +51,7 @@ export function useSubmissionMonitor() { console.log(`[SubmissionMonitor] 判题${data.status === "finished" ? "完成" : "出错"}`) // 停止轮询(WebSocket已成功) - pausePolling() + fallback.stop() // 结果已经到手,别让重连再去重放这条早就判完的订阅 unsubscribe() @@ -91,25 +66,23 @@ export function useSubmissionMonitor() { } // 初始化 WebSocket - const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect } = + const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect, status } = useSubmissionWebSocket(handleSubmissionUpdate) - // ==================== 轮询保底启动 ==================== - const { start: startPollingFallback } = useTimeoutFn( - () => { - if ( - submission.value && - (submission.value.result === SubmissionStatus.judging || - submission.value.result === SubmissionStatus.pending || - submission.value.result === SubmissionStatus.submitting) - ) { - console.log("[SubmissionMonitor] WebSocket未及时响应,启动轮询保底") - resumePolling() - } + // ==================== 轮询兜底 ==================== + 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 }, - 5000, - { immediate: false }, - ) + }) // ==================== 启动监控 ==================== const startMonitoring = (id: string) => { @@ -131,8 +104,8 @@ export function useSubmissionMonitor() { // 把各自那个早就判完的旧 submissionId 重新订阅一遍。 subscribe(id) - // 5秒后启动轮询保底(防止WebSocket失败) - startPollingFallback() + // 轮询兜底:WebSocket 通的话 20 秒才问一次,不通才快拉(见 fallbackPoll.ts) + fallback.start() } /** @@ -141,7 +114,7 @@ export function useSubmissionMonitor() { * 判完触发的「通过」处理会把**新题**标成已解决。提交本身照常判完落库,只是这边不再跟。 */ const reset = () => { - pausePolling() + fallback.stop() unsubscribe() // 连接由 store 持有,离开题目页组件卸了它也还在。和判完之后一样,空闲 15 分钟断开; // 不在这里立刻断,是因为刚交的那条判完之后还可能推成就过来 @@ -161,11 +134,6 @@ export function useSubmissionMonitor() { return judging.value || pending.value || submitting.value }) - // ==================== 清理 ==================== - // 现在由 submission store 持有(见 oj/store/submission.ts),store 不销毁就一直在; - // 用 onScopeDispose 是为了在组件里调用时照样能收尾 - if (getCurrentScope()) onScopeDispose(pausePolling) - return { // 状态 submissionId, @@ -179,7 +147,6 @@ export function useSubmissionMonitor() { // 方法 startMonitoring, - pausePolling, reset, } } diff --git a/apps/web/src/oj/store/flowchart.ts b/apps/web/src/oj/store/flowchart.ts index a373d78..7522745 100644 --- a/apps/web/src/oj/store/flowchart.ts +++ b/apps/web/src/oj/store/flowchart.ts @@ -14,6 +14,7 @@ import { getFlowchartScores, getFlowchartSubmission, submitFlowchart } from "oj/ import { useProblemStore } from "oj/store/problem" import { useSubmissionStore } from "oj/store/submission" import { useFlowchartWebSocket, type FlowchartEvaluationUpdate } from "shared/composables/websocket" +import { useFallbackPoll } from "shared/composables/fallbackPoll" import { useMyFlowchartStore } from "shared/store/myFlowchart" import { useUserStore } from "shared/store/user" import { useAchievementStore } from "shared/store/achievement" @@ -50,8 +51,6 @@ export { isFlowchartPass } */ export type FlowchartPhase = "idle" | "evaluating" | "done" | "failed" -/** AI 评分比判题慢得多,轮询放缓一点 */ -const POLL_INTERVAL = 3000 /** 到点还没结果就收手,别无限轮询下去 */ const POLL_TIMEOUT = 3 * 60 * 1000 @@ -141,41 +140,6 @@ export const useFlowchartStore = defineStore("flowchart", () => { /** 正在跟的那一次是哪道题的,结算时再对一遍 */ let monitoringProblemId: number | null = null - const { pause: pausePolling, resume: resumePolling } = useIntervalFn( - async () => { - if (!monitoringId) { - pausePolling() - return - } - try { - const data = await getFlowchartSubmission(monitoringId) - if (data.status === 2) { - settle(data.id, { - ok: true, - score: data.aiScore ?? 0, - grade: data.aiGrade ?? "", - }) - } else if (data.status === 3) { - settle(data.id, { ok: false }) - } - } catch (error) { - console.error("[Flowchart] 轮询失败:", error) - pausePolling() - } - }, - POLL_INTERVAL, - { immediate: false }, - ) - - // WebSocket 正常时压根用不上轮询,先给它 5 秒,到点还没结果才开始拉 - const { start: startPollingFallback, stop: stopPollingFallback } = useTimeoutFn( - () => { - if (monitoringId) resumePolling() - }, - 5000, - { immediate: false }, - ) - const { start: startPollingDeadline, stop: stopPollingDeadline } = useTimeoutFn( () => { if (!monitoringId) return @@ -191,8 +155,7 @@ export const useFlowchartStore = defineStore("flowchart", () => { monitoringId = "" monitoringProblemId = null unsubscribe() - pausePolling() - stopPollingFallback() + fallback.stop() stopPollingDeadline() loading.value = false // 连接挂在 store 上,离开题目页也不会跟着组件断。不立刻断:接着还可能再交一次, @@ -252,6 +215,8 @@ export const useFlowchartStore = defineStore("flowchart", () => { useAchievementStore().enqueue(frame.achievements ?? []) return } + // 订阅后服务端立刻回一帧当前状态(还在评也回 evaluation_update):WebSocket 是通的 + if (monitoringId && data.submissionId === monitoringId) fallback.confirm() if (data.type === "flowchart_evaluation_completed") { settle(data.submissionId, { ok: true, @@ -263,9 +228,27 @@ export const useFlowchartStore = defineStore("flowchart", () => { } } - const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect } = + const { connect, subscribe, unsubscribe, scheduleDisconnect, cancelScheduledDisconnect, status } = useFlowchartWebSocket(handleWebSocketMessage) + // WebSocket 通的话 20 秒才问一次,不通才快拉(见 fallbackPoll.ts) + const fallback = useFallbackPoll({ + status, + poll: async () => { + if (!monitoringId) return true + const data = await getFlowchartSubmission(monitoringId) + if (data.status === 2) { + settle(data.id, { ok: true, score: data.aiScore ?? 0, grade: data.aiGrade ?? "" }) + return true + } + if (data.status === 3) { + settle(data.id, { ok: false }) + return true + } + return false + }, + }) + // ==================== 提交 ==================== /** 交一张图:Mermaid 给 AI 看、给缩略图画,结构树压缩后存着(载回画布、亮路径用) */ async function submit(doc: FlowDoc) { @@ -309,7 +292,7 @@ export const useFlowchartStore = defineStore("flowchart", () => { monitoringId = response.submissionId monitoringProblemId = problem.id subscribe(response.submissionId) - startPollingFallback() + fallback.start() startPollingDeadline() } } catch (error) { diff --git a/apps/web/src/shared/composables/fallbackPoll.ts b/apps/web/src/shared/composables/fallbackPoll.ts new file mode 100644 index 0000000..2485dfd --- /dev/null +++ b/apps/web/src/shared/composables/fallbackPoll.ts @@ -0,0 +1,123 @@ +import { getCurrentScope, onScopeDispose, watch, type Ref } from "vue" +import { useDocumentVisibility } from "@vueuse/core" +import type { ConnectionStatus } from "shared/composables/websocket" + +/** 交上去之后先等 WebSocket 这么久,再开始兜底 */ +const GRACE = 5000 +/** WebSocket 不通时从这个间隔起拉,每次乘 1.5,最慢到 MAX_FAST */ +const MIN_FAST = 2000 +const MAX_FAST = 10_000 +/** WebSocket 好好的:结果会推过来,轮询只是防 pub/sub 丢那一条,隔这么久问一次 */ +const SLOW = 20_000 +/** 连着失败这么多次就不拉了 */ +const MAX_ERRORS = 5 + +/** + * 判题、流程图评分的结果「WebSocket 推、轮询兜底」里的兜底那一半。 + * + * 原来是交上去 5 秒还没结果就每 2 秒(流程图 3 秒)拉一次,分不清「WebSocket 断了」和 + * 「队列排得长」。服务器只有两核,一个班 50 人一起交时判题队列要排一分钟,几乎每个人都 + * 进了轮询,每秒二十几个请求全在和判题抢 CPU。 + * + * 能分清的依据:服务端收到 subscribe 会立刻回一帧当前状态(pending / judging 也回), + * 断线重连后 websocket.ts 会自动重新订阅、再回一帧。所以调用方收到**这一条**的任何帧就 + * `confirm()`,此后只要连接没断,就知道结果会推过来,轮询退到 20 秒一次;连接一断立刻 + * 回到快档。页面藏在后台时不拉:切回来时 WebSocket 会自己重连、重放当前状态。 + * + * `poll` 返回 true 表示有结果了、不用再拉。抛错按快档退避接着拉(掉线那几秒拉失败是 + * 常事,原来一次失败就永久停掉,之后要是又丢了推送就一直转圈),连着失败 + * MAX_ERRORS 次才放弃,别对着一个坏掉的接口一直刷。 + */ +export function useFallbackPoll(options: { + status: Ref + poll: () => Promise +}) { + const visibility = useDocumentVisibility() + let timer: ReturnType | undefined + let active = false + let confirmed = false + let fastDelay = MIN_FAST + let lastPolledAt = 0 + let errors = 0 + + const healthy = () => confirmed && options.status.value === "connected" + + function schedule(delay: number) { + clearTimeout(timer) + timer = setTimeout(tick, delay) + } + + async function tick() { + if (!active) return + if (visibility.value === "hidden") { + schedule(SLOW) + return + } + // 刚失败过的不算健康:WebSocket 看着连着,拉却失败了,多半是网刚断、它还没察觉 + if (healthy() && errors === 0 && Date.now() - lastPolledAt < SLOW) { + schedule(SLOW - (Date.now() - lastPolledAt)) + return + } + lastPolledAt = Date.now() + try { + if (await options.poll()) { + stop() + return + } + errors = 0 + } catch (error) { + console.error("[FallbackPoll] 轮询失败:", error) + if (++errors >= MAX_ERRORS) { + stop() + return + } + } + if (!active) return + if (healthy() && errors === 0) { + schedule(SLOW) + } else { + schedule(fastDelay) + fastDelay = Math.min(MAX_FAST, fastDelay * 1.5) + } + } + + /** 开始跟一条新的。lastPolledAt 记成现在:WebSocket 一直好的话,第一次拉在 20 秒后 */ + function start() { + active = true + confirmed = false + fastDelay = MIN_FAST + errors = 0 + lastPolledAt = Date.now() + schedule(GRACE) + } + + function stop() { + active = false + clearTimeout(timer) + timer = undefined + } + + /** 收到了这一条的推送:WebSocket 是通的 */ + function confirm() { + confirmed = true + } + + // 连接一断,之前的确认就不算数了,回到快档;重连上之后的重放帧会再确认一次。 + // 本来就没确认的不管:连不上时每次重连都要走一遍 connecting → error → disconnected, + // 在这里重置的话退避永远退不上去 + watch(options.status, (status) => { + if (!active || !confirmed || status === "connected") return + confirmed = false + fastDelay = MIN_FAST + schedule(MIN_FAST) + }) + + // 切回前台:WebSocket 还没确认的话别等满 20 秒 + watch(visibility, (value) => { + if (active && value === "visible" && !healthy()) schedule(MIN_FAST) + }) + + if (getCurrentScope()) onScopeDispose(stop) + + return { start, stop, confirm } +} diff --git a/apps/web/src/shared/composables/visibleInterval.ts b/apps/web/src/shared/composables/visibleInterval.ts new file mode 100644 index 0000000..d975105 --- /dev/null +++ b/apps/web/src/shared/composables/visibleInterval.ts @@ -0,0 +1,24 @@ +import { watch } from "vue" +import { useDocumentVisibility, useIntervalFn } from "@vueuse/core" + +/** + * 定时刷新,但页面藏在后台时不拉,切回来立刻补一次。 + * + * 比赛榜单 10 秒一次、比赛班级页 15 秒一次,原来在后台标签页里照样拉:考试时一个班 + * 几十个人开着题目页又开着榜单,后台那几十个请求是白付的,服务器只有两核。 + * 用法和 useIntervalFn 一样,默认不立即开始,由调用方 resume()。 + */ +export function useVisibleInterval(fn: () => unknown, interval: number) { + const visibility = useDocumentVisibility() + const { pause, resume, isActive } = useIntervalFn( + () => { + if (visibility.value === "visible") fn() + }, + interval, + { immediate: false }, + ) + watch(visibility, (value) => { + if (value === "visible" && isActive.value) fn() + }) + return { pause, resume } +}