diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue index e5628ff..f9f1f15 100644 --- a/web/src/views/backend/game/live/index.vue +++ b/web/src/views/backend/game/live/index.vue @@ -283,9 +283,16 @@ let snapshotLoadPromise: Promise | null = null const wsLoading = ref(false) const wsReady = ref(false) const wsConnected = ref(false) +/** 同一期只提示一次“中大奖”,避免 bet.win/jackpot.hit 连续触发刷屏 */ +const lastJackpotTipPeriodNo = ref('') +const lastJackpotTipAtMs = ref(0) const wsUrl = ref('') const wsTopics = ref([]) const wsClient = ref(null) +let wsReconnectTimer: number | null = null +let wsReconnectAttempt = 0 +let wsLastConfigReloadAtMs = 0 +let wsManualClosing = false const isMobile = ref(false) const candidateSort = ref<{ prop: string; order: 'ascending' | 'descending' | null }>({ prop: 'estimated_loss', order: 'ascending' }) @@ -376,7 +383,7 @@ function handleWsPayload(raw: unknown): void { if (event === 'bet.win' && parsed.data && typeof parsed.data === 'object') { const winData = parsed.data as anyObj if (winData.is_jackpot === true) { - ElMessage.success(t('game.live.jackpot_hit_tip')) + showJackpotTipOnce(readString(winData.period_no) || readString(snapshot.record?.period_no)) } return } @@ -384,7 +391,7 @@ function handleWsPayload(raw: unknown): void { const jackpotData = parsed.data as anyObj const hits = Array.isArray(jackpotData.hits) ? jackpotData.hits : [] if (hits.length > 0) { - ElMessage.success(t('game.live.jackpot_hit_tip')) + showJackpotTipOnce(readString(jackpotData.period_no) || readString(snapshot.record?.period_no)) } return } @@ -397,6 +404,27 @@ function handleWsPayload(raw: unknown): void { } } +function readString(v: any): string { + return typeof v === 'string' ? v : '' +} + +function showJackpotTipOnce(periodNo: string): void { + const key = String(periodNo || '') + const now = Date.now() + // 同一期只提示一次;同时加 2s 防抖,避免同一瞬间 bet.win + jackpot.hit 连续触发刷屏 + if (key !== '' && lastJackpotTipPeriodNo.value === key) { + return + } + if (now - lastJackpotTipAtMs.value < 2000) { + return + } + if (key !== '') { + lastJackpotTipPeriodNo.value = key + } + lastJackpotTipAtMs.value = now + ElMessage.success(t('game.live.jackpot_hit_tip')) +} + /** 用 period.tick 轻量字段刷新倒计时(不触发 HTTP,避免与 WS 每秒 snapshot 冲突) */ function mergePeriodTickFields(periodData: anyObj): void { if (typeof periodData.server_time === 'number') { @@ -445,11 +473,17 @@ function connectWs(): void { if (!wsReady.value || !wsUrl.value) { return } + if (wsReconnectTimer !== null) { + window.clearTimeout(wsReconnectTimer) + wsReconnectTimer = null + } + wsManualClosing = false disconnectWs() const socket = new WebSocket(wsUrl.value) wsClient.value = socket socket.onopen = () => { wsConnected.value = true + wsReconnectAttempt = 0 const topics = wsTopics.value const payload = JSON.stringify({ action: 'subscribe', topics }) socket.send(payload) @@ -463,24 +497,42 @@ function connectWs(): void { socket.onclose = () => { wsConnected.value = false wsClient.value = null + if (wsManualClosing) { + return + } // 断线后:先刷新 wsConfig 拿新的 admin_ws_token(避免握手 token 已过期反复失败),再重连 - window.setTimeout(async () => { + const attempt = wsReconnectAttempt + wsReconnectAttempt = Math.min(wsReconnectAttempt + 1, 10) + const baseDelay = Math.min(30_000, 1200 * Math.pow(2, attempt)) + const delay = Math.max(1200, Math.floor(baseDelay + Math.random() * 400)) + wsReconnectTimer = window.setTimeout(async () => { + wsReconnectTimer = null if (wsConnected.value) { return } try { - await reloadWsConfig() + const now = Date.now() + // 避免断线风暴下疯狂请求 wsConfig(后台会报错且浏览器控制台刷屏) + if (!wsLoading.value && now - wsLastConfigReloadAtMs > 8000) { + wsLastConfigReloadAtMs = now + await reloadWsConfig() + } } catch { /* ignore;下次重连时再试 */ } if (!wsConnected.value) { connectWs() } - }, 1200) + }, delay) } } function disconnectWs(): void { + wsManualClosing = true + if (wsReconnectTimer !== null) { + window.clearTimeout(wsReconnectTimer) + wsReconnectTimer = null + } if (wsClient.value) { wsClient.value.close() wsClient.value = null