Files
webman-buildadmin/web/src/views/backend/game/live/index.vue
2026-04-23 10:15:01 +08:00

928 lines
28 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="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
/** 开奖号码池上限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 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.jsonOpen 后长期为 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>