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

1070 lines
33 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">{{ 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">{{ calcResultNumber ?? '—' }}</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>
</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 } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
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
}
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,
})
const calcLoading = ref(false)
const drawLoading = ref(false)
const pendingSwitchNumber = ref<number | null>(null)
const runtimeSwitchLoading = ref(false)
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
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 === '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.tick' && parsed.data && typeof parsed.data === 'object') {
const periodData = parsed.data as anyObj
if (typeof periodData.server_time === 'number') {
syncServerClock(periodData.server_time)
}
}
}
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
}
function candidateRowClassName(arg: { row: anyObj }): string {
return isScheduledNumber(arg.row?.number) ? 'is-scheduled-row' : ''
}
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 mergeLiveSnapshot(data: anyObj): void {
if (data.record !== undefined) {
snapshot.record = data.record
}
snapshot.bets = data.bets || []
snapshot.candidate_numbers = data.candidate_numbers || []
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)
if (typeof data.runtime_enabled === 'boolean') {
snapshot.runtime_enabled = data.runtime_enabled
}
if (typeof data.maintenance_ui === 'boolean') {
snapshot.maintenance_ui = data.maintenance_ui
}
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
})
async function loadSnapshot() {
loading.value = true
try {
const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', showCodeMessage: false })
if (res.code === 1 && res.data) {
mergeLiveSnapshot(res.data as anyObj)
}
} finally {
loading.value = false
}
}
async function onRuntimeSwitch(val: boolean | string | number): void {
const on = val === true || val === 'true' || val === 1
// 防止某些场景下 model-value 变化触发重复 change 事件,造成 runtime 接口循环调用
if (on === !!snapshot.runtime_enabled) {
return
}
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()
}
} catch {
await loadSnapshot()
} finally {
runtimeSwitchLoading.value = false
}
}
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)
}
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()
} 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 }
})
onMounted(async () => {
updateIsMobile()
window.addEventListener('resize', updateIsMobile)
clockTimer = window.setInterval(() => {
clockTick.value++
}, 1000)
await loadSnapshot()
await reloadWsConfig()
connectWs()
})
onUnmounted(() => {
window.removeEventListener('resize', updateIsMobile)
disconnectWs()
if (clockTimer !== null) {
window.clearInterval(clockTimer)
clockTimer = 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);
}
@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>