1070 lines
33 KiB
Vue
1070 lines
33 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">{{ 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
|
||
/** 开奖号码池上限(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
|
||
}
|
||
|
||
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>
|