Files
webman-buildadmin/web/src/views/backend/game/live/index.vue

1455 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="default-main">
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
<el-alert :type="wsConnected ? 'success' : 'warning'" :title="wsConnected ? t('game.live.ws_connected') : t('game.live.ws_disconnected')" show-icon class="mb-12" />
<el-alert
v-if="snapshot.runtime_enabled === false && !snapshot.maintenance_ui"
type="warning"
:title="t('game.live.runtime_draining_banner')"
show-icon
class="mb-12"
/>
<el-alert
v-if="snapshot.maintenance_ui"
type="warning"
:title="t('game.live.runtime_maintenance_banner')"
show-icon
class="mb-12"
/>
<div class="live-top-toolbar">
<div class="live-top-toolbar__row">
<span class="live-top-toolbar__label">{{ t('game.live.runtime_switch') }}</span>
<el-switch
:model-value="snapshot.runtime_enabled"
:loading="runtimeSwitchLoading"
:disabled="runtimeSwitchLoading || voidSubmitting"
@change="onRuntimeSwitch"
/>
<span v-if="snapshot.maintenance_ui" class="live-top-toolbar__hint">{{ t('game.live.runtime_off_tip') }}</span>
<div class="live-top-toolbar__actions">
<el-button
class="live-top-toolbar__btn-void"
type="danger"
plain
:disabled="asideOperationLocked || !canVoidPeriod || voidSubmitting || runtimeSwitchLoading"
@click="openVoidDialog"
>
{{ t('game.live.void_btn') }}
</el-button>
<el-button :loading="loading" :disabled="asideOperationLocked" @click="loadSnapshot({ force: true })">{{ t('Refresh') }}</el-button>
</div>
</div>
</div>
<el-card shadow="never" class="mb-12 live-control-card">
<el-alert v-if="snapshot.is_payout_phase" type="warning" :title="t('game.live.payout_phase')" show-icon class="mb-12" />
<div class="live-control-layout">
<div class="live-control-main">
<el-descriptions :column="1" border size="small" class="live-desc">
<el-descriptions-item :label="t('game.live.current_record')">
<span class="period-no">{{ snapshot.record?.period_no || '—' }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('game.live.ai_default_number')">
<span class="num-em">{{ snapshot.ai_default_number ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="snapshot.pending_draw_number != null" :label="t('game.live.pending_draw')">
<el-tag type="primary" effect="plain">{{ snapshot.pending_draw_number }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="countdown-block">
<div class="countdown-block__title">{{ t('game.live.countdown') }}</div>
<div v-if="snapshot.maintenance_ui" class="countdown-maintenance">
{{ t('game.live.countdown_maintenance') }}
</div>
<div v-else class="countdown-cards">
<div class="cd-card">
<span class="cd-card__label">{{ t('game.live.bet_countdown') }}</span>
<span class="cd-card__val">{{ countdownParts.bet }}s</span>
</div>
<div class="cd-card">
<span class="cd-card__label">{{ t('game.live.draw_countdown') }}</span>
<span class="cd-card__val">{{ countdownParts.draw }}s</span>
</div>
<div class="cd-card" :class="{ 'is-active': snapshot.is_payout_phase }">
<span class="cd-card__label">{{ t('game.live.payout_countdown') }}</span>
<span class="cd-card__val">{{ countdownParts.payout }}</span>
</div>
</div>
</div>
<div class="calc-result-bar">
<span class="calc-result-bar__item">
<span class="calc-result-bar__k">{{ t('game.live.calc_result_number') }}</span>
<span class="calc-result-bar__v">{{ displayResultNumber ?? '—' }}</span>
</span>
<span class="calc-result-bar__item">
<span class="calc-result-bar__k">{{ t('game.live.calc_estimated_loss') }}</span>
<span class="calc-result-bar__v mono">{{ calcEstimatedLoss }}</span>
</span>
</div>
</div>
</div>
</el-card>
<el-row :gutter="12" class="live-tables-row">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>{{ t('game.live.candidate_title') }}</template>
<el-table
:data="candidateNumbersSorted"
:height="tableHeight"
:row-class-name="candidateRowClassName"
class="candidate-table"
:default-sort="{ prop: 'estimated_loss', order: 'ascending' }"
@sort-change="onCandidateSortChange"
>
<el-table-column prop="number" :label="t('game.live.number')" width="76" align="center" header-align="center" sortable="custom">
<template #default="scope">
<el-tag size="small" effect="plain" class="number-tag">
{{ scope.row.number ?? '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="estimated_loss"
:label="t('game.live.estimated_loss')"
width="110"
align="center"
header-align="center"
sortable="custom"
/>
<el-table-column :label="t('game.live.btn_draw')" width="96" align="center" header-align="center">
<template #default="scope">
<el-switch
:model-value="isScheduledNumber(scope.row.number)"
:loading="drawLoading && pendingSwitchNumber === scope.row.number"
:disabled="asideOperationLocked || !snapshot.can_schedule_draw || drawLoading"
inline-prompt
@change="(v:boolean) => onPickSwitchChange(v, scope.row.number)"
/>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>{{ t('game.live.bet_stream_title') }}</template>
<el-table :data="snapshot.bets" :height="tableHeight" class="bet-stream-table">
<el-table-column prop="username" :label="t('game.live.username')" width="120" align="center" header-align="center">
<template #default="scope">
<span class="bet-user">{{ String(scope.row.username || '-') }}</span>
</template>
</el-table-column>
<el-table-column prop="pick_numbers" :label="t('game.live.pick_numbers')" min-width="150" align="center" header-align="center">
<template #default="scope">
<div class="pick-tags">
<el-tag
v-for="num in parsePickNumbers(scope.row.pick_numbers)"
:key="`pick-${scope.row.id}-${String(num)}`"
size="small"
effect="plain"
class="pick-tags__item"
>
{{ num }}
</el-tag>
<span v-if="parsePickNumbers(scope.row.pick_numbers).length === 0">-</span>
</div>
</template>
</el-table-column>
<el-table-column prop="total_amount" :label="t('game.live.total_amount')" width="92" align="center" header-align="center" />
<el-table-column prop="win_amount" :label="t('game.live.win_amount')" width="92" align="center" header-align="center">
<template #default="scope">
<span class="mono">{{ formatWinAmount(scope.row) }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-dialog
v-model="voidDialogVisible"
class="live-void-dialog"
:title="t('game.live.void_dialog_title')"
width="520px"
align-center
append-to-body
destroy-on-close
:close-on-click-modal="false"
@closed="voidReason = ''"
>
<el-form class="live-void-form" label-position="top" @submit.prevent>
<el-form-item :label="t('game.live.void_reason_label')">
<el-input
v-model="voidReason"
type="textarea"
:rows="5"
:autosize="{ minRows: 4, maxRows: 12 }"
:placeholder="t('game.live.void_reason_placeholder')"
maxlength="255"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<div class="live-void-footer">
<el-button @click="voidDialogVisible = false">{{ t('Cancel') }}</el-button>
<el-button type="primary" :loading="voidSubmitting" @click="submitVoidPeriod">{{ t('game.live.void_submit') }}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import createAxios from '/@/utils/axios'
interface Snapshot {
record: anyObj | null
bets: anyObj[]
candidate_numbers: anyObj[]
ai_default_number: number | null
pending_draw_number: number | null
period_seconds?: number
bet_seconds?: number
pick_max_number_count?: number
/** 开奖号码池上限1draw_number_max与单注可选号码上限无关 */
draw_number_max?: number
remaining_seconds?: number
bet_remaining_seconds?: number
payout_remaining_seconds?: number
is_payout_phase?: boolean
can_calculate?: boolean
can_draw?: boolean
can_schedule_draw?: boolean
server_time?: number
/** 游戏运行开关false 表示已关服(当局仍可进行至派彩结束) */
runtime_enabled?: boolean
/** 完整维护 UI关服且当前无进行中/未结清对局(派彩已全部完成) */
maintenance_ui?: boolean
/** 已开奖号码status≥2 时有值) */
result_number?: number | null
}
const { t } = useI18n()
const loading = ref(false)
const snapshot = reactive<Snapshot>({
record: null,
bets: [],
candidate_numbers: [],
ai_default_number: null,
pending_draw_number: null,
period_seconds: 30,
bet_seconds: 20,
pick_max_number_count: 10,
draw_number_max: 36,
remaining_seconds: 0,
bet_remaining_seconds: 0,
payout_remaining_seconds: 0,
is_payout_phase: false,
can_calculate: false,
can_draw: false,
can_schedule_draw: false,
runtime_enabled: true,
maintenance_ui: false,
result_number: null,
})
const calcLoading = ref(false)
const drawLoading = ref(false)
const pendingSwitchNumber = ref<number | null>(null)
const runtimeSwitchLoading = ref(false)
const pendingRuntimeTarget = ref<boolean | null>(null)
const voidDialogVisible = ref(false)
const voidReason = ref('')
const voidSubmitting = ref(false)
const calcResultNumber = ref<number | null>(null)
const calcEstimatedLoss = ref<string>('0.00')
/** 服务端 Unix 秒 本地 Unix 秒,用于派彩倒计时与服务器对齐 */
const serverSkewSeconds = ref(0)
/** 每秒递增,驱动派彩剩余秒本地刷新 */
const clockTick = ref(0)
let clockTimer: number | null = null
let payoutStuckRefreshTimer: number | null = null
let drawStuckRefreshTimer: number | null = null
let drawStuckSeconds = 0
let payoutPhaseStuckSeconds = 0
let fallbackPollTimer: number | null = null
let betStreamRefreshTimer: number | null = null
/** 合并并发 snapshot 请求,避免 axios 重复请求取消导致控制台报错 */
let snapshotLoadPromise: Promise<void> | null = null
const wsLoading = ref(false)
const wsReady = ref(false)
const wsConnected = ref(false)
const wsUrl = ref('')
const wsTopics = ref<string[]>([])
const wsClient = ref<WebSocket | null>(null)
const isMobile = ref(false)
const candidateSort = ref<{ prop: string; order: 'ascending' | 'descending' | null }>({ prop: 'estimated_loss', order: 'ascending' })
function updateIsMobile(): void {
isMobile.value = window.matchMedia('(max-width: 768px)').matches
}
const tableHeight = computed(() => (isMobile.value ? 320 : 420))
async function reloadWsConfig(): Promise<void> {
wsLoading.value = true
try {
const res = await createAxios({
url: '/admin/game.Live/wsConfig',
method: 'get',
showCodeMessage: true,
})
if (res.code !== 1 || !res.data) {
wsReady.value = false
return
}
wsUrl.value = String(res.data.ws_url || '')
if (Array.isArray(res.data.subscribe_topics)) {
wsTopics.value = res.data.subscribe_topics.filter((topic: unknown): topic is string => typeof topic === 'string' && topic.trim() !== '')
} else {
wsTopics.value = []
}
wsReady.value = wsUrl.value !== ''
} finally {
wsLoading.value = false
}
}
function handleWsPayload(raw: unknown): void {
let parsed: anyObj | null = null
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw)
} catch {
return
}
} else if (raw && typeof raw === 'object') {
parsed = raw as anyObj
}
if (!parsed) {
return
}
const event = typeof parsed.event === 'string' ? parsed.event : ''
if (event === 'admin.live.snapshot' && parsed.data && typeof parsed.data === 'object') {
mergeLiveSnapshot(parsed.data as anyObj)
return
}
if (event === 'admin.live.opened') {
const opened = parsed.data as anyObj
if (typeof opened.result_number === 'number') {
snapshot.result_number = opened.result_number
calcResultNumber.value = opened.result_number
}
void loadSnapshot({ force: true })
return
}
if (event === 'admin.live.finalized' && parsed.data && typeof parsed.data === 'object') {
const fin = parsed.data as anyObj
if (toBool(fin.maintenance_ui) === true) {
snapshot.is_payout_phase = false
snapshot.payout_remaining_seconds = 0
}
void loadSnapshot({ force: true })
return
}
if (event === 'period.payout' && parsed.data && typeof parsed.data === 'object') {
mergePeriodPayoutTick(parsed.data as anyObj)
const payoutData = parsed.data as anyObj
if (typeof payoutData.result_number === 'number') {
snapshot.result_number = payoutData.result_number
calcResultNumber.value = payoutData.result_number
}
snapshot.is_payout_phase = true
return
}
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'))
}
return
}
if (event === 'jackpot.hit' && parsed.data && typeof parsed.data === 'object') {
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'))
}
return
}
if (event === 'period.payout.tick' && parsed.data && typeof parsed.data === 'object') {
mergePeriodPayoutTick(parsed.data as anyObj)
return
}
if (event === 'period.tick' && parsed.data && typeof parsed.data === 'object') {
handlePeriodTickEvent(parsed.data as anyObj)
return
}
if (event === 'bet.accepted' && parsed.data && typeof parsed.data === 'object') {
const betData = parsed.data as anyObj
const periodNo = typeof betData.period_no === 'string' ? betData.period_no : ''
const currentNo = typeof snapshot.record?.period_no === 'string' ? snapshot.record.period_no : ''
if (periodNo !== '' && periodNo === currentNo) {
scheduleBetStreamRefresh()
} else if (!wsConnected.value) {
void loadSnapshot({ force: true })
}
return
}
}
/** 有新下注时防抖拉取快照,补全 WS 每秒快照之间的下注列表 */
function scheduleBetStreamRefresh(): void {
if (betStreamRefreshTimer !== null) {
window.clearTimeout(betStreamRefreshTimer)
}
betStreamRefreshTimer = window.setTimeout(() => {
betStreamRefreshTimer = null
void loadSnapshot({ force: true })
}, 600)
}
/** 用 period.tick 轻量字段刷新倒计时(不触发 HTTP避免与 WS 每秒 snapshot 冲突) */
function mergePeriodTickFields(periodData: anyObj): void {
if (typeof periodData.server_time === 'number') {
syncServerClock(periodData.server_time)
}
if (typeof periodData.countdown === 'number') {
snapshot.remaining_seconds = Math.max(0, periodData.countdown)
}
if (typeof periodData.bet_close_in === 'number') {
snapshot.bet_remaining_seconds = Math.max(0, periodData.bet_close_in)
}
}
function mergePeriodPayoutTick(data: anyObj): void {
if (typeof data.server_time === 'number') {
syncServerClock(data.server_time)
}
const remain = numberValue(data.payout_remaining_seconds)
if (remain !== null) {
snapshot.payout_remaining_seconds = Math.max(0, remain)
snapshot.is_payout_phase = remain > 0
}
const until = readPayoutUntilUnix(data)
if (until !== null && snapshot.record && typeof snapshot.record === 'object') {
snapshot.record.payout_until = until
}
}
function handlePeriodTickEvent(periodData: anyObj): void {
mergePeriodTickFields(periodData)
const status = typeof periodData.status === 'string' ? periodData.status : ''
const periodNo = typeof periodData.period_no === 'string' ? periodData.period_no : ''
const currentNo = typeof snapshot.record?.period_no === 'string' ? snapshot.record.period_no : ''
const runtimeOff =
toBool(snapshot.runtime_enabled) === false || toBool(periodData.runtime_enabled) === false
if (runtimeOff && (status === 'betting' || status === 'locked')) {
return
}
if (status === 'betting' && periodNo !== '' && periodNo !== currentNo) {
void loadSnapshot({ force: true })
return
}
if (status === 'finished') {
snapshot.is_payout_phase = false
snapshot.payout_remaining_seconds = 0
void loadSnapshot({ force: true })
return
}
if (currentNo === '' && periodNo !== '' && !runtimeOff) {
void loadSnapshot({ force: true })
}
}
function connectWs(): void {
if (!wsReady.value || !wsUrl.value) {
return
}
disconnectWs()
const socket = new WebSocket(wsUrl.value)
wsClient.value = socket
socket.onopen = () => {
wsConnected.value = true
const topics = wsTopics.value
const payload = JSON.stringify({ action: 'subscribe', topics })
socket.send(payload)
}
socket.onmessage = (event) => {
handleWsPayload(event.data)
}
socket.onerror = () => {
wsConnected.value = false
}
socket.onclose = () => {
wsConnected.value = false
wsClient.value = null
window.setTimeout(() => {
if (!wsConnected.value) {
connectWs()
}
}, 1200)
}
}
function disconnectWs(): void {
if (wsClient.value) {
wsClient.value.close()
wsClient.value = null
}
wsConnected.value = false
}
function formatPicks(v: unknown): string {
if (Array.isArray(v)) return JSON.stringify(v)
if (typeof v === 'string') return v
return '-'
}
function parsePickNumbers(v: unknown): Array<number | string> {
if (Array.isArray(v)) {
return v
.map((item) => {
if (typeof item === 'number' || typeof item === 'string') {
return item
}
return null
})
.filter((item): item is number | string => item !== null)
}
if (typeof v === 'string') {
const s = v.trim()
if (s === '') {
return []
}
try {
const parsed = JSON.parse(s)
if (Array.isArray(parsed)) {
return parsed
.map((item) => {
if (typeof item === 'number' || typeof item === 'string') {
return item
}
return null
})
.filter((item): item is number | string => item !== null)
}
} catch {
return [s]
}
return [s]
}
return []
}
function numberValue(v: unknown): number | null {
if (typeof v === 'number' && Number.isFinite(v)) {
return v
}
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v)
if (!Number.isNaN(n) && Number.isFinite(n)) {
return n
}
}
return null
}
function estimatedLossSortValue(v: unknown): number {
if (typeof v === 'number' && Number.isFinite(v)) {
return v
}
if (typeof v === 'string' && v.trim() !== '') {
const n = Number(v)
if (!Number.isNaN(n) && Number.isFinite(n)) {
return n
}
}
return Number.MAX_SAFE_INTEGER
}
const candidateNumbersSorted = computed(() => {
const list = Array.isArray(snapshot.candidate_numbers) ? [...snapshot.candidate_numbers] : []
list.sort((a, b) => {
const sp = candidateSort.value.prop
const so = candidateSort.value.order
const dir = so === 'descending' ? -1 : 1
if (sp === 'number') {
const na = numberValue(a?.number)
const nb = numberValue(b?.number)
if (na !== null && nb !== null && na !== nb) {
return (na - nb) * dir
}
} else {
const ea = estimatedLossSortValue(a?.estimated_loss)
const eb = estimatedLossSortValue(b?.estimated_loss)
if (ea !== eb) {
return (ea - eb) * dir
}
}
const ea2 = estimatedLossSortValue(a?.estimated_loss)
const eb2 = estimatedLossSortValue(b?.estimated_loss)
if (ea2 !== eb2) {
return ea2 - eb2
}
const na2 = numberValue(a?.number)
const nb2 = numberValue(b?.number)
if (na2 !== null && nb2 !== null && na2 !== nb2) {
return na2 - nb2
}
return String(a?.number ?? '').localeCompare(String(b?.number ?? ''))
})
return list
})
function onCandidateSortChange(arg: { prop: string; order: 'ascending' | 'descending' | null }): void {
const prop = typeof arg?.prop === 'string' && arg.prop !== '' ? arg.prop : 'estimated_loss'
const order = arg?.order === 'descending' || arg?.order === 'ascending' ? arg.order : 'ascending'
candidateSort.value = { prop, order }
}
function isScheduledNumber(v: unknown): boolean {
const n = numberValue(v)
if (n === null) {
return false
}
return snapshot.pending_draw_number === n
}
const displayResultNumber = computed(() => {
const fromSnap = numberValue(snapshot.result_number)
if (fromSnap !== null) {
return fromSnap
}
const fromRec = numberValue(snapshot.record?.result_number)
if (fromRec !== null) {
return fromRec
}
return calcResultNumber.value
})
function updateCalcLossFromResultNumber(): void {
const rn = displayResultNumber.value
if (rn === null) {
return
}
const row = snapshot.candidate_numbers.find((c) => numberValue(c?.number) === rn)
if (row && row.estimated_loss !== undefined && row.estimated_loss !== null) {
calcEstimatedLoss.value = String(row.estimated_loss)
}
}
function formatWinAmount(row: anyObj): string {
const st = Number(row?.bet_status ?? 0)
const win = row?.win_amount
if (st === 2 || st === 5) {
return win !== undefined && win !== null && String(win) !== '' ? String(win) : '0.00'
}
return '—'
}
function candidateRowClassName(arg: { row: anyObj }): string {
const classes: string[] = []
if (isScheduledNumber(arg.row?.number)) {
classes.push('is-scheduled-row')
}
const result = displayResultNumber.value
const num = numberValue(arg.row?.number)
if (result !== null && num !== null && num === result) {
classes.push('is-result-row')
}
return classes.join(' ')
}
async function onPickSwitchChange(val: boolean, rowNumber: unknown): Promise<void> {
const target = numberValue(rowNumber)
if (target === null) {
return
}
if (!val) {
return
}
if (snapshot.pending_draw_number === target) {
return
}
pendingSwitchNumber.value = target
try {
await onDraw(target)
} finally {
pendingSwitchNumber.value = null
}
}
const canVoidPeriod = computed(() => {
const r = snapshot.record
if (!r) {
return false
}
const s = Number(r.status)
return s === 0 || s === 1
})
/** 派彩结束后的完整维护态:操作区除顶部开关外全部锁定 */
const asideOperationLocked = computed(() => snapshot.maintenance_ui === true)
function toBool(v: unknown): boolean | null {
if (typeof v === 'boolean') {
return v
}
if (typeof v === 'number' && Number.isFinite(v)) {
if (v === 1) return true
if (v === 0) return false
return null
}
if (typeof v === 'string') {
const s = v.trim().toLowerCase()
if (s === '1' || s === 'true') return true
if (s === '0' || s === 'false') return false
}
return null
}
function mergeLiveSnapshot(data: anyObj): void {
const prevPeriodId = snapshot.record?.id != null ? Number(snapshot.record.id) : null
let periodChanged = false
if (data.record !== undefined) {
const nextId = data.record?.id != null ? Number(data.record.id) : null
periodChanged = prevPeriodId !== null && nextId !== null && prevPeriodId !== nextId
const runtimeOff = toBool(data.runtime_enabled) === false || toBool(snapshot.runtime_enabled) === false
const serverMaintenance = data.maintenance_ui === true
if (runtimeOff && serverMaintenance) {
snapshot.record = null
snapshot.is_payout_phase = false
snapshot.payout_remaining_seconds = 0
snapshot.can_calculate = false
snapshot.can_draw = false
snapshot.can_schedule_draw = false
} else {
snapshot.record = data.record
}
}
const incomingBets = Array.isArray(data.bets) ? data.bets : null
if (incomingBets !== null) {
if (incomingBets.length > 0 || periodChanged || prevPeriodId === null) {
snapshot.bets = incomingBets
}
}
const incomingCandidates = Array.isArray(data.candidate_numbers) ? data.candidate_numbers : null
if (incomingCandidates !== null) {
if (incomingCandidates.length > 0 || periodChanged || prevPeriodId === null) {
snapshot.candidate_numbers = incomingCandidates
}
}
snapshot.ai_default_number = data.ai_default_number ?? null
snapshot.pending_draw_number = typeof data.pending_draw_number === 'number' ? data.pending_draw_number : null
snapshot.period_seconds = data.period_seconds ?? 30
snapshot.bet_seconds = data.bet_seconds ?? 20
snapshot.pick_max_number_count = data.pick_max_number_count ?? 10
snapshot.draw_number_max = data.draw_number_max ?? 36
snapshot.remaining_seconds = data.remaining_seconds ?? 0
snapshot.bet_remaining_seconds = data.bet_remaining_seconds ?? 0
snapshot.payout_remaining_seconds = data.payout_remaining_seconds ?? 0
snapshot.is_payout_phase = !!data.is_payout_phase
snapshot.can_calculate = !!data.can_calculate
snapshot.can_draw = !!data.can_draw
snapshot.can_schedule_draw = !!(data.can_schedule_draw || data.can_draw)
const runtimeEnabled = toBool(data.runtime_enabled)
if (pendingRuntimeTarget.value !== null) {
// 开关请求进行中:避免 ws 的旧快照覆盖用户刚切换的状态,导致 UI 回弹
snapshot.runtime_enabled = pendingRuntimeTarget.value
} else if (runtimeEnabled !== null) {
snapshot.runtime_enabled = runtimeEnabled
}
if (typeof data.maintenance_ui === 'boolean') {
if (pendingRuntimeTarget.value !== null) {
// 开启时不应显示维护中;关闭后的维护中应由服务端在对局完全结束后下发
snapshot.maintenance_ui = pendingRuntimeTarget.value ? false : snapshot.maintenance_ui
} else {
snapshot.maintenance_ui = data.maintenance_ui
}
}
if (typeof data.result_number === 'number') {
snapshot.result_number = data.result_number
calcResultNumber.value = data.result_number
} else if (periodChanged) {
snapshot.result_number = null
calcResultNumber.value = null
calcEstimatedLoss.value = '0.00'
}
updateCalcLossFromResultNumber()
syncServerClock(data.server_time)
}
function syncServerClock(serverTime: unknown): void {
if (typeof serverTime === 'number' && Number.isFinite(serverTime)) {
serverSkewSeconds.value = serverTime - Math.floor(Date.now() / 1000)
snapshot.server_time = serverTime
}
}
function readPayoutUntilUnix(rec: anyObj | null): number | null {
if (!rec) {
return null
}
const v = rec.payout_until
if (v === null || v === undefined || v === '') {
return null
}
if (typeof v === 'number' && Number.isFinite(v)) {
return v
}
if (typeof v === 'string' && /^\d+$/.test(v)) {
return parseInt(v, 10)
}
return null
}
/** 派彩剩余秒:优先用 payout_until 与对时后的「服务器当前秒」计算,便于每秒递减 */
const payoutRemainingLive = computed(() => {
clockTick.value
if (!snapshot.is_payout_phase) {
return null
}
const until = readPayoutUntilUnix(snapshot.record)
if (until !== null) {
const serverNow = Math.floor(Date.now() / 1000) + serverSkewSeconds.value
const diff = until - serverNow
return diff > 0 ? diff : 0
}
return snapshot.payout_remaining_seconds ?? 0
})
function isRequestCanceled(err: unknown): boolean {
return axios.isCancel(err) || (err instanceof Error && err.name === 'CanceledError')
}
async function loadSnapshot(options?: { force?: boolean }): Promise<void> {
if (!options?.force && wsConnected.value && snapshot.record) {
return
}
if (snapshotLoadPromise) {
return snapshotLoadPromise
}
snapshotLoadPromise = (async () => {
loading.value = true
try {
const res = await createAxios({
url: '/admin/game.Live/snapshot',
method: 'get',
showCodeMessage: false,
showErrorMessage: false,
cancelDuplicateRequest: false,
})
if (res.code === 1 && res.data) {
mergeLiveSnapshot(res.data as anyObj)
}
} catch (err) {
if (!isRequestCanceled(err)) {
throw err
}
} finally {
loading.value = false
snapshotLoadPromise = null
}
})()
return snapshotLoadPromise
}
async function onRuntimeSwitch(val: boolean | string | number): void {
const on = toBool(val) === true
if (runtimeSwitchLoading.value) {
return
}
// el-switch 为受控组件model-value 来自 snapshot接口返回前先乐观更新避免点击后立刻回弹
snapshot.runtime_enabled = on
if (on) {
snapshot.maintenance_ui = false
}
pendingRuntimeTarget.value = on
runtimeSwitchLoading.value = true
try {
const res = await createAxios({
url: '/admin/game.Live/runtime',
method: 'post',
data: { enabled: on ? 1 : 0 },
showSuccessMessage: true,
})
if (res.code === 1 && res.data) {
mergeLiveSnapshot(res.data as anyObj)
} else {
await loadSnapshot({ force: true })
}
} catch {
await loadSnapshot({ force: true })
} finally {
runtimeSwitchLoading.value = false
pendingRuntimeTarget.value = null
}
}
function openVoidDialog(): void {
voidReason.value = ''
voidDialogVisible.value = true
}
async function submitVoidPeriod(): Promise<void> {
const reason = voidReason.value.trim()
if (reason.length < 2) {
ElMessage.warning(t('game.live.void_reason_too_short'))
return
}
if (!snapshot.record) {
return
}
voidSubmitting.value = true
try {
const res = await createAxios({
url: '/admin/game.Live/voidPeriod',
method: 'post',
data: {
record_id: snapshot.record.id,
void_reason: reason,
},
showSuccessMessage: true,
})
if (res.code === 1 && res.data) {
mergeLiveSnapshot(res.data as anyObj)
const refund = (res.data as anyObj).void_refund
if (refund && typeof refund === 'object') {
const orderCount = Number(refund.order_count ?? 0)
const totalAmount = String(refund.total_amount ?? '0.00')
if (orderCount > 0) {
ElMessage.success(`已退款 ${orderCount} 笔注单,合计 ${totalAmount}`)
}
}
}
voidDialogVisible.value = false
} finally {
voidSubmitting.value = false
}
}
async function onCalculate() {
if (!snapshot.record) return
calcLoading.value = true
try {
const res = await createAxios({
url: '/admin/game.Live/calculate',
method: 'post',
data: {
record_id: snapshot.record.id,
manual_number: numberValue(snapshot.pending_draw_number) ?? numberValue(candidateNumbersSorted.value[0]?.number) ?? 1,
},
showSuccessMessage: true,
})
if (res.code === 1 && res.data) {
snapshot.candidate_numbers = res.data.candidate_numbers || []
snapshot.ai_default_number = res.data.ai_default_number ?? null
calcResultNumber.value = res.data.final_number ?? null
calcEstimatedLoss.value = String(res.data.final_estimated_loss ?? '0.00')
}
} finally {
calcLoading.value = false
}
}
async function onDrawWithNumber(targetNumber: number) {
if (!snapshot.record) return
drawLoading.value = true
try {
await createAxios({
url: '/admin/game.Live/draw',
method: 'post',
data: {
record_id: snapshot.record.id,
manual_number: targetNumber,
},
showSuccessMessage: true,
})
await loadSnapshot({ force: true })
} finally {
drawLoading.value = false
}
}
async function onDraw(targetNumber?: number) {
if (targetNumber !== undefined) {
return onDrawWithNumber(targetNumber)
}
const pending = numberValue(snapshot.pending_draw_number)
if (pending !== null) {
return onDrawWithNumber(pending)
}
const first = candidateNumbersSorted.value[0]
const fallback = numberValue(first?.number) ?? 1
return onDrawWithNumber(fallback)
}
const countdownParts = computed(() => {
const bet = snapshot.bet_remaining_seconds ?? 0
const draw = snapshot.remaining_seconds ?? 0
let payout = t('game.live.payout_na')
if (snapshot.is_payout_phase && payoutRemainingLive.value !== null) {
payout = `${payoutRemainingLive.value}s`
}
return { bet, draw, payout }
})
/** 派彩倒计时从 >0 变为 0 时主动拉 HTTP 快照 */
watch(payoutRemainingLive, (remain, prev) => {
if (!snapshot.is_payout_phase || remain !== 0) {
return
}
if (prev !== null && prev !== undefined && prev > 0) {
schedulePayoutEndRefresh(400)
}
})
function schedulePayoutEndRefresh(delayMs: number): void {
if (payoutStuckRefreshTimer !== null) {
window.clearTimeout(payoutStuckRefreshTimer)
}
payoutStuckRefreshTimer = window.setTimeout(() => {
payoutStuckRefreshTimer = null
if (!snapshot.is_payout_phase) {
return
}
void loadSnapshot({ force: true })
}, delayMs)
}
/** 下注/开奖倒计时均已归零但仍未进入派彩(常见于 live ticker 未跑或开奖锁竞争失败) */
function isPrePayoutDrawStuck(): boolean {
if (snapshot.is_payout_phase || !snapshot.can_calculate) {
return false
}
const bet = snapshot.bet_remaining_seconds ?? 0
const draw = snapshot.remaining_seconds ?? 0
if (bet > 0 || draw > 0) {
return false
}
const st = numberValue(snapshot.record?.status)
return st === 0 || st === 1
}
/** 派彩倒计时已为 0 但 is_payout_phase 仍为 true关服排水时常见 */
function tickPayoutPhaseStuckRecovery(): void {
if (!snapshot.is_payout_phase) {
payoutPhaseStuckSeconds = 0
return
}
const remain = payoutRemainingLive.value
if (remain === null || remain > 0) {
payoutPhaseStuckSeconds = 0
return
}
payoutPhaseStuckSeconds++
if (payoutPhaseStuckSeconds >= 4) {
payoutPhaseStuckSeconds = 0
void loadSnapshot({ force: true })
}
}
function tickPrePayoutDrawStuckRecovery(): void {
if (!isPrePayoutDrawStuck()) {
drawStuckSeconds = 0
if (drawStuckRefreshTimer !== null) {
window.clearTimeout(drawStuckRefreshTimer)
drawStuckRefreshTimer = null
}
return
}
drawStuckSeconds++
if (drawStuckSeconds < 8 || drawStuckRefreshTimer !== null) {
return
}
drawStuckRefreshTimer = window.setTimeout(() => {
drawStuckRefreshTimer = null
drawStuckSeconds = 0
if (isPrePayoutDrawStuck()) {
void loadSnapshot({ force: true })
}
}, 300)
}
onMounted(async () => {
updateIsMobile()
window.addEventListener('resize', updateIsMobile)
clockTimer = window.setInterval(() => {
clockTick.value++
tickPrePayoutDrawStuckRecovery()
tickPayoutPhaseStuckRecovery()
}, 1000)
fallbackPollTimer = window.setInterval(() => {
if (!wsConnected.value) {
void loadSnapshot({ force: true })
}
}, 3000)
await loadSnapshot({ force: true })
await reloadWsConfig()
connectWs()
})
onUnmounted(() => {
window.removeEventListener('resize', updateIsMobile)
disconnectWs()
if (clockTimer !== null) {
window.clearInterval(clockTimer)
clockTimer = null
}
if (fallbackPollTimer !== null) {
window.clearInterval(fallbackPollTimer)
fallbackPollTimer = null
}
if (payoutStuckRefreshTimer !== null) {
window.clearTimeout(payoutStuckRefreshTimer)
payoutStuckRefreshTimer = null
}
if (drawStuckRefreshTimer !== null) {
window.clearTimeout(drawStuckRefreshTimer)
drawStuckRefreshTimer = null
}
if (betStreamRefreshTimer !== null) {
window.clearTimeout(betStreamRefreshTimer)
betStreamRefreshTimer = null
}
})
</script>
<style scoped lang="scss">
.mb-12 {
margin-bottom: 12px;
}
.live-top-toolbar {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color-overlay);
}
.live-top-toolbar__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
}
.live-top-toolbar__label {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.live-top-toolbar__hint {
font-size: 13px;
color: var(--el-text-color-secondary);
flex: 1 1 220px;
line-height: 1.45;
}
.live-top-toolbar__actions {
margin-left: auto;
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.live-top-toolbar__btn-void {
border-color: var(--el-color-danger-light-7);
}
.live-control-card {
:deep(.el-card__body) {
padding-top: 8px;
}
}
.live-control-layout {
display: block;
}
.live-control-main {
min-width: 0;
}
.live-desc {
margin-bottom: 16px;
:deep(.el-descriptions__label) {
width: 120px;
font-weight: 500;
}
}
.period-no {
display: inline-block;
max-width: 100%;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.num-em {
font-size: 16px;
font-weight: 600;
color: var(--el-color-primary);
}
.countdown-block {
margin-bottom: 14px;
}
.countdown-block__title {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.countdown-maintenance {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 16px 12px;
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--el-color-warning-dark-2);
background: var(--el-color-warning-light-9);
}
.countdown-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.cd-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 10px 12px;
background: var(--el-fill-color-blank);
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
text-align: center;
min-width: 0;
}
/* 移动端保持同一排,压缩间距与字号以省纵向空间 */
@media (max-width: 768px) {
.countdown-block {
margin-bottom: 10px;
}
.countdown-block__title {
margin-bottom: 6px;
}
.countdown-cards {
gap: 6px;
}
.cd-card {
padding: 6px 4px;
border-radius: 6px;
gap: 2px;
min-width: 0;
}
.cd-card__label {
font-size: 11px;
line-height: 1.2;
padding: 0 2px;
}
.cd-card__val {
font-size: 15px;
}
}
.cd-card.is-active {
border-color: var(--el-color-warning-light-5);
background: var(--el-color-warning-light-9);
}
.cd-card__label {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.3;
}
.cd-card__val {
font-size: 18px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--el-text-color-primary);
}
.calc-result-bar {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
padding: 10px 14px;
border-radius: 8px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
}
.calc-result-bar__item {
display: inline-flex;
align-items: baseline;
gap: 8px;
}
.calc-result-bar__k {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.calc-result-bar__v {
font-size: 14px;
font-weight: 500;
}
.calc-result-bar__v.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.number-tag {
min-width: 44px;
justify-content: center;
font-variant-numeric: tabular-nums;
}
.pick-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
}
.pick-tags__item {
font-variant-numeric: tabular-nums;
}
.bet-user {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.candidate-table :deep(.is-scheduled-row td) {
background: var(--el-color-primary-light-9);
}
.candidate-table :deep(.is-result-row td) {
background: var(--el-color-success-light-9);
}
.candidate-table :deep(.is-result-row .number-tag) {
border-color: var(--el-color-success);
color: var(--el-color-success);
font-weight: 700;
}
@media (max-width: 768px) {
.live-tables-row .el-col {
margin-bottom: 12px;
}
.pick-tags {
gap: 4px;
}
}
</style>
<style lang="scss">
/* Dialog teleport 到 body独立块以便样式命中 */
.live-void-dialog.el-dialog {
display: flex;
flex-direction: column;
max-height: min(92vh, 720px);
margin: auto;
}
.live-void-dialog .el-dialog__header {
flex-shrink: 0;
padding: 16px 16px 10px;
}
.live-void-dialog .el-dialog__body {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 8px 16px 12px;
-webkit-overflow-scrolling: touch;
}
.live-void-dialog .el-dialog__footer {
flex-shrink: 0;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
}
.live-void-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
width: 100%;
}
.live-void-form .el-form-item {
margin-bottom: 0;
}
@media (max-width: 768px) {
.live-void-dialog.el-dialog {
width: calc(100vw - 24px) !important;
max-width: 520px;
max-height: 90vh;
}
.live-void-dialog .el-dialog__title {
font-size: 16px;
line-height: 1.4;
word-break: break-word;
}
.live-void-footer {
justify-content: stretch;
}
.live-void-footer .el-button {
flex: 1;
min-width: 0;
}
}
</style>