优化游戏实时对局页面

This commit is contained in:
2026-04-21 10:02:16 +08:00
parent 17eadddaa2
commit aad00e10f8
9 changed files with 622 additions and 41 deletions

View File

@@ -26,4 +26,17 @@ export default {
pick_numbers: 'Pick numbers',
total_amount: 'Total bet amount',
streak_at_bet: 'Streak at bet',
runtime_switch: 'Game runtime',
countdown_maintenance: 'Maintenance',
runtime_draining_banner:
'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.',
runtime_maintenance_banner:
'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.',
runtime_off_tip: 'When turning runtime on with no active round, a new period is created immediately.',
void_btn: 'Void round',
void_dialog_title: 'Void current round',
void_reason_label: 'Reason',
void_reason_placeholder: 'Enter the reason (stored on the record; pending bets will be refunded).',
void_submit: 'Confirm void',
void_reason_too_short: 'Reason must be at least 2 characters',
}

View File

@@ -26,4 +26,16 @@ export default {
pick_numbers: '压注号码',
total_amount: '压注总额',
streak_at_bet: '下注时连胜',
runtime_switch: '游戏运行',
countdown_maintenance: '维护中',
runtime_draining_banner:
'已关闭游戏:当前局将正常进行至开奖、结算并完成派彩;全部结束后进入维护模式(倒计时与操作区将切换为维护中)。',
runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「游戏运行」恢复;若无进行中的局将自动创建新一期。',
runtime_off_tip: '开启「游戏运行」后,若无进行中的局将立即创建新一期。',
void_btn: '作废本局',
void_dialog_title: '作废本局',
void_reason_label: '作废原因',
void_reason_placeholder: '请填写本期作废原因(将写入对局记录并退款待开奖注单)',
void_submit: '确认作废',
void_reason_too_short: '作废原因至少 2 个字符',
}

View File

@@ -2,6 +2,33 @@
<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" />
@@ -21,7 +48,10 @@
<div class="countdown-block">
<div class="countdown-block__title">{{ t('game.live.countdown') }}</div>
<div class="countdown-cards">
<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>
@@ -49,8 +79,17 @@
</div>
</div>
<aside class="live-control-aside">
<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
@@ -59,17 +98,27 @@
: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="!snapshot.can_calculate" @click="onCalculate">
<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="!snapshot.can_schedule_draw" @click="onDraw">
<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" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
<el-button :loading="loading" :disabled="asideOperationLocked" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
</div>
</aside>
</div>
@@ -102,12 +151,45 @@
</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 {
@@ -129,6 +211,10 @@ interface Snapshot {
can_draw?: boolean
can_schedule_draw?: boolean
server_time?: number
/** 游戏运行开关false 表示已关服(当局仍可进行至派彩结束) */
runtime_enabled?: boolean
/** 完整维护 UI关服且当前无进行中/未结清对局(派彩已全部完成) */
maintenance_ui?: boolean
}
const { t } = useI18n()
@@ -151,9 +237,15 @@ const snapshot = reactive<Snapshot>({
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.0000')
@@ -175,6 +267,47 @@ function formatPicks(v: unknown): string {
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)
@@ -219,24 +352,7 @@ async function loadSnapshot() {
try {
const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', showCodeMessage: false })
if (res.code === 1 && res.data) {
snapshot.record = res.data.record
snapshot.bets = res.data.bets || []
snapshot.candidate_numbers = res.data.candidate_numbers || []
snapshot.ai_default_number = res.data.ai_default_number
snapshot.pending_draw_number =
typeof res.data.pending_draw_number === 'number' ? res.data.pending_draw_number : null
snapshot.period_seconds = res.data.period_seconds ?? 30
snapshot.bet_seconds = res.data.bet_seconds ?? 20
snapshot.pick_max_number_count = res.data.pick_max_number_count ?? 10
snapshot.draw_number_max = res.data.draw_number_max ?? 36
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
snapshot.bet_remaining_seconds = res.data.bet_remaining_seconds ?? 0
snapshot.payout_remaining_seconds = res.data.payout_remaining_seconds ?? 0
snapshot.is_payout_phase = !!res.data.is_payout_phase
snapshot.can_calculate = !!res.data.can_calculate
snapshot.can_draw = !!res.data.can_draw
snapshot.can_schedule_draw = !!res.data.can_schedule_draw || !!res.data.can_draw
syncServerClock(res.data.server_time)
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
}
@@ -245,6 +361,62 @@ async function loadSnapshot() {
}
}
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) {
@@ -277,24 +449,7 @@ async function initPush() {
stopPolling()
pushChannel.on(event, (payload: anyObj) => {
pushConnected.value = true
snapshot.record = payload.record || null
snapshot.bets = payload.bets || []
snapshot.candidate_numbers = payload.candidate_numbers || []
snapshot.ai_default_number = payload.ai_default_number ?? null
snapshot.pending_draw_number =
typeof payload.pending_draw_number === 'number' ? payload.pending_draw_number : null
snapshot.period_seconds = payload.period_seconds ?? 30
snapshot.bet_seconds = payload.bet_seconds ?? 20
snapshot.pick_max_number_count = payload.pick_max_number_count ?? 10
snapshot.draw_number_max = payload.draw_number_max ?? 36
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
snapshot.bet_remaining_seconds = payload.bet_remaining_seconds ?? 0
snapshot.payout_remaining_seconds = payload.payout_remaining_seconds ?? 0
snapshot.is_payout_phase = !!payload.is_payout_phase
snapshot.can_calculate = !!payload.can_calculate
snapshot.can_draw = !!payload.can_draw
snapshot.can_schedule_draw = !!payload.can_schedule_draw || !!payload.can_draw
syncServerClock(payload.server_time)
mergeLiveSnapshot(payload)
})
} catch {
pushConnected.value = false
@@ -444,6 +599,34 @@ function stopPushWatchdog() {
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;
@@ -496,6 +679,17 @@ function stopPushWatchdog() {
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));
@@ -602,6 +796,11 @@ function stopPushWatchdog() {
display: flex;
flex-direction: column;
gap: 12px;
&.is-locked {
opacity: 0.9;
filter: grayscale(0.08);
}
}
@media (max-width: 992px) {
@@ -620,6 +819,11 @@ function stopPushWatchdog() {
color: var(--el-text-color-regular);
}
.aside-void-btn {
width: 100%;
margin-bottom: 12px;
}
.aside-field {
display: flex;
flex-direction: column;
@@ -657,3 +861,67 @@ function stopPushWatchdog() {
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>