928 lines
28 KiB
Vue
928 lines
28 KiB
Vue
<template>
|
||
<div class="default-main">
|
||
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
|
||
<el-alert :type="pushConnected ? 'success' : 'error'" :title="pushConnected ? t('game.live.push_connected') : t('game.live.push_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>
|
||
</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>
|
||
|
||
<aside class="live-control-aside" :class="{ 'is-locked': asideOperationLocked }">
|
||
<div class="aside-title">{{ t('game.live.action_panel') }}</div>
|
||
<el-button
|
||
class="aside-void-btn"
|
||
type="danger"
|
||
plain
|
||
:disabled="asideOperationLocked || !canVoidPeriod || voidSubmitting || runtimeSwitchLoading"
|
||
@click="openVoidDialog"
|
||
>
|
||
{{ t('game.live.void_btn') }}
|
||
</el-button>
|
||
<div class="aside-field">
|
||
<span class="aside-field__label">{{ t('game.live.manual_draw_number') }}</span>
|
||
<el-input-number
|
||
v-model="manualNumber"
|
||
class="aside-field__input"
|
||
:min="1"
|
||
:max="snapshot.draw_number_max ?? 36"
|
||
:step="1"
|
||
:disabled="asideOperationLocked"
|
||
controls-position="right"
|
||
/>
|
||
</div>
|
||
<div class="aside-btns">
|
||
<el-button
|
||
:loading="calcLoading"
|
||
:disabled="asideOperationLocked || !snapshot.can_calculate"
|
||
@click="onCalculate"
|
||
>
|
||
{{ t('game.live.btn_calc') }}
|
||
</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="drawLoading"
|
||
:disabled="asideOperationLocked || !snapshot.can_schedule_draw"
|
||
@click="onDraw"
|
||
>
|
||
{{ t('game.live.btn_draw') }}
|
||
</el-button>
|
||
<el-button :loading="loading" :disabled="asideOperationLocked" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-row :gutter="12">
|
||
<el-col :span="12">
|
||
<el-card shadow="never">
|
||
<template #header>{{ t('game.live.candidate_title') }}</template>
|
||
<el-table :data="snapshot.candidate_numbers" height="420">
|
||
<el-table-column prop="number" :label="t('game.live.number')" width="100" />
|
||
<el-table-column prop="estimated_loss" :label="t('game.live.estimated_loss')" />
|
||
</el-table>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-card shadow="never">
|
||
<template #header>{{ t('game.live.bet_stream_title') }}</template>
|
||
<el-table :data="snapshot.bets" height="420">
|
||
<el-table-column prop="id" :label="t('game.live.bet_id')" width="90" />
|
||
<el-table-column prop="user_id" :label="t('game.live.user_id')" width="90" />
|
||
<el-table-column prop="pick_numbers" :label="t('game.live.pick_numbers')">
|
||
<template #default="scope">
|
||
{{ formatPicks(scope.row.pick_numbers) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="total_amount" :label="t('game.live.total_amount')" width="120" />
|
||
<el-table-column prop="streak_at_bet" :label="t('game.live.streak_at_bet')" width="90" />
|
||
</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, { getPushScriptUrl } 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 pushConnected = 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 runtimeSwitchLoading = ref(false)
|
||
const voidDialogVisible = ref(false)
|
||
const voidReason = ref('')
|
||
const voidSubmitting = ref(false)
|
||
const manualNumber = ref<number | null>(1)
|
||
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 pushClient: any = null
|
||
let pushChannel: any = null
|
||
let pollTimer: number | null = null
|
||
let pushWatchdogTimer: number | null = null
|
||
|
||
function formatPicks(v: unknown): string {
|
||
if (Array.isArray(v)) return JSON.stringify(v)
|
||
if (typeof v === 'string') return v
|
||
return '-'
|
||
}
|
||
|
||
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)
|
||
const dmax = res.data.draw_number_max ?? 36
|
||
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) manualNumber.value = 1
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function onRuntimeSwitch(val: boolean | string | number): void {
|
||
const on = val === true || val === 'true' || val === 1
|
||
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 initPush() {
|
||
const cfgRes = await createAxios({ url: '/admin/game.Live/pushConfig', method: 'get', showCodeMessage: false })
|
||
if (cfgRes.code !== 1 || !cfgRes.data) {
|
||
pushConnected.value = false
|
||
return
|
||
}
|
||
const { url, app_key, channel, event } = cfgRes.data
|
||
|
||
try {
|
||
await loadPushJs()
|
||
} catch {
|
||
pushConnected.value = false
|
||
startPolling()
|
||
return
|
||
}
|
||
const PushCtor = (window as any).Push
|
||
if (!PushCtor) {
|
||
pushConnected.value = false
|
||
startPolling()
|
||
return
|
||
}
|
||
try {
|
||
pushClient = new PushCtor({ url, app_key })
|
||
pushChannel = pushClient.subscribe(channel)
|
||
pushConnected.value = false
|
||
pushChannel.on('pusher:subscription_succeeded', () => {
|
||
pushConnected.value = true
|
||
})
|
||
startPushWatchdog()
|
||
stopPolling()
|
||
pushChannel.on(event, (payload: anyObj) => {
|
||
pushConnected.value = true
|
||
mergeLiveSnapshot(payload)
|
||
})
|
||
} catch {
|
||
pushConnected.value = false
|
||
startPolling()
|
||
}
|
||
}
|
||
|
||
async function loadPushJs() {
|
||
if ((window as any).Push) {
|
||
return
|
||
}
|
||
await new Promise<void>((resolve, reject) => {
|
||
const script = document.createElement('script')
|
||
script.src = getPushScriptUrl()
|
||
script.onload = () => resolve()
|
||
script.onerror = () => reject(new Error('load push.js failed'))
|
||
document.head.appendChild(script)
|
||
})
|
||
}
|
||
|
||
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: manualNumber.value,
|
||
},
|
||
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 onDraw() {
|
||
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: manualNumber.value,
|
||
},
|
||
showSuccessMessage: true,
|
||
})
|
||
await loadSnapshot()
|
||
} finally {
|
||
drawLoading.value = false
|
||
}
|
||
}
|
||
|
||
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 () => {
|
||
clockTimer = window.setInterval(() => {
|
||
clockTick.value++
|
||
}, 1000)
|
||
await loadSnapshot()
|
||
try {
|
||
await initPush()
|
||
} catch {
|
||
pushConnected.value = false
|
||
startPolling()
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
try {
|
||
if (pushClient && typeof pushClient.disconnect === 'function') {
|
||
pushClient.disconnect()
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
stopPolling()
|
||
stopPushWatchdog()
|
||
if (clockTimer !== null) {
|
||
window.clearInterval(clockTimer)
|
||
clockTimer = null
|
||
}
|
||
})
|
||
|
||
function startPolling() {
|
||
if (pollTimer !== null) {
|
||
return
|
||
}
|
||
pollTimer = window.setInterval(() => {
|
||
void loadSnapshot()
|
||
}, 2000)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer !== null) {
|
||
window.clearInterval(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
}
|
||
|
||
function startPushWatchdog() {
|
||
if (pushWatchdogTimer !== null) {
|
||
return
|
||
}
|
||
pushWatchdogTimer = window.setInterval(() => {
|
||
if (!pushClient || !pushClient.connection) {
|
||
pushConnected.value = false
|
||
return
|
||
}
|
||
const state = String(pushClient.connection.state || '')
|
||
// push.js:onOpen 后长期为 connecting,必须收到 pusher:connection_established 才变 connected。
|
||
// 把 connecting 当「已连接」会误报绿条(Nginx 只完成握手、帧未透传时 DevTools 消息列表为空)。
|
||
if (state === 'disconnected') {
|
||
pushConnected.value = false
|
||
}
|
||
}, 1000)
|
||
}
|
||
|
||
function stopPushWatchdog() {
|
||
if (pushWatchdogTimer !== null) {
|
||
window.clearInterval(pushWatchdogTimer)
|
||
pushWatchdogTimer = 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-control-card {
|
||
:deep(.el-card__body) {
|
||
padding-top: 8px;
|
||
}
|
||
}
|
||
|
||
.live-control-layout {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 20px 24px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.live-control-main {
|
||
flex: 1 1 320px;
|
||
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;
|
||
}
|
||
|
||
.live-control-aside {
|
||
flex: 0 0 240px;
|
||
padding-left: 20px;
|
||
border-left: 1px solid var(--el-border-color-lighter);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
|
||
&.is-locked {
|
||
opacity: 0.9;
|
||
filter: grayscale(0.08);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 992px) {
|
||
.live-control-aside {
|
||
flex: 1 1 100%;
|
||
padding-left: 0;
|
||
border-left: none;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--el-border-color-lighter);
|
||
}
|
||
}
|
||
|
||
.aside-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-regular);
|
||
}
|
||
|
||
.aside-void-btn {
|
||
width: 100%;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.aside-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.aside-field__label {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
|
||
.aside-field__input {
|
||
width: 100%;
|
||
}
|
||
|
||
.aside-btns {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: nowrap;
|
||
gap: 8px;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.aside-btns .el-button {
|
||
flex: 1;
|
||
min-width: 0;
|
||
margin-left: 0;
|
||
padding-left: 8px;
|
||
padding-right: 8px;
|
||
}
|
||
|
||
.aside-btns .el-button :deep(span) {
|
||
white-space: normal;
|
||
line-height: 1.25;
|
||
text-align: center;
|
||
}
|
||
</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>
|