1455 lines
47 KiB
Vue
1455 lines
47 KiB
Vue
<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
|
||
/** 开奖号码池上限(1–draw_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>
|