From 5cbb7ea485505e6553ee6b2a85a071830effc94f Mon Sep 17 00:00:00 2001
From: zhenhui <1276357500@qq.com>
Date: Tue, 26 May 2026 15:10:42 +0800
Subject: [PATCH] =?UTF-8?q?1.=E4=BC=98=E5=8C=96=E5=90=8E=E5=8F=B0/admin/ga?=
=?UTF-8?q?me/live?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/common/service/GameLiveService.php | 28 +++--
web/src/lang/backend/en/game/live.ts | 1 +
web/src/lang/backend/zh-cn/game/live.ts | 1 +
web/src/views/backend/game/live/index.vue | 126 ++++++++++++++++++++--
4 files changed, 144 insertions(+), 12 deletions(-)
diff --git a/app/common/service/GameLiveService.php b/app/common/service/GameLiveService.php
index 4ac87e5..ac6bf3f 100644
--- a/app/common/service/GameLiveService.php
+++ b/app/common/service/GameLiveService.php
@@ -270,10 +270,6 @@ final class GameLiveService
GameHotDataRedis::gameRecordRevalidateFromDbIfStale($record, $periodSeconds);
$record = self::resolveRecord($recordId);
}
- if ($record && GameHotDataRedis::isExpiredPayoutRecord($record)) {
- self::finalizePayoutGrace();
- $record = self::resolveRecord($recordId);
- }
if (!$record) {
return self::emptySnapshotPayload();
}
@@ -307,13 +303,13 @@ final class GameLiveService
->where('bo.period_id', $rid)
->order('bo.id', 'desc')
->limit(200)
- ->field('bo.id,bo.user_id,bo.period_no,bo.pick_numbers,bo.total_amount,bo.streak_at_bet,bo.create_time,gu.username as user_username')
+ ->field('bo.id,bo.user_id,bo.period_no,bo.pick_numbers,bo.total_amount,bo.streak_at_bet,bo.win_amount,bo.status as bet_status,bo.create_time,gu.username as user_username')
->select()
->toArray();
$candidates = [];
$canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
- if ($canCalculate) {
+ if (self::shouldBuildCandidateEstimates($status, $elapsed, $betSeconds)) {
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
$loss = self::estimateLossForNumber($bets, $n);
$candidates[] = [
@@ -323,6 +319,8 @@ final class GameLiveService
}
}
+ $resultNumber = isset($record['result_number']) ? (int) $record['result_number'] : 0;
+
$aiLocked = $record['ai_locked_number'] ?? null;
$aiDisplay = null;
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
@@ -357,10 +355,14 @@ final class GameLiveService
'pick_numbers' => $row['pick_numbers'],
'total_amount' => (string) $row['total_amount'],
'streak_at_bet' => (int) $row['streak_at_bet'],
+ 'win_amount' => (string) ($row['win_amount'] ?? '0.00'),
+ 'bet_status' => (int) ($row['bet_status'] ?? 0),
'create_time' => (int) $row['create_time'],
];
}, $bets),
'candidate_numbers' => $candidates,
+ 'result_number' => $resultNumber > 0 ? $resultNumber : null,
+ 'show_settlement_preview' => self::shouldBuildCandidateEstimates($status, $elapsed, $betSeconds),
'ai_default_number' => $aiDisplay,
'calc_number' => $aiDisplay,
'pending_draw_number' => $pendingDraw,
@@ -412,6 +414,8 @@ final class GameLiveService
'can_calculate' => false,
'can_draw' => false,
'can_schedule_draw' => false,
+ 'result_number' => null,
+ 'show_settlement_preview' => false,
'server_time' => time(),
];
}
@@ -1312,6 +1316,18 @@ final class GameLiveService
return $max;
}
+ /**
+ * 封盘至本期完全结束前均展示赔付预估(含已开奖/派彩中),供后台实时对局页保留表格数据。
+ */
+ private static function shouldBuildCandidateEstimates(int $status, int $elapsed, int $betSeconds): bool
+ {
+ if ($elapsed < $betSeconds) {
+ return false;
+ }
+
+ return in_array($status, [0, 1, 2, 3, 4], true);
+ }
+
private static function estimateLossForNumber(array $bets, int $number): string
{
$payout = '0.00';
diff --git a/web/src/lang/backend/en/game/live.ts b/web/src/lang/backend/en/game/live.ts
index 1a43557..423e2a8 100644
--- a/web/src/lang/backend/en/game/live.ts
+++ b/web/src/lang/backend/en/game/live.ts
@@ -34,6 +34,7 @@ export default {
username: 'Username',
pick_numbers: 'Pick numbers',
total_amount: 'Total bet amount',
+ win_amount: 'Payout amount',
streak_at_bet: 'Streak at bet',
runtime_switch: 'Auto-create next round',
countdown_maintenance: 'Maintenance',
diff --git a/web/src/lang/backend/zh-cn/game/live.ts b/web/src/lang/backend/zh-cn/game/live.ts
index eadeecb..333e18d 100644
--- a/web/src/lang/backend/zh-cn/game/live.ts
+++ b/web/src/lang/backend/zh-cn/game/live.ts
@@ -34,6 +34,7 @@ export default {
username: '用户名',
pick_numbers: '下注号码',
total_amount: '下注总额',
+ win_amount: '派彩金额',
streak_at_bet: '下注时连胜',
runtime_switch: '自动创建下一局',
countdown_maintenance: '维护中',
diff --git a/web/src/views/backend/game/live/index.vue b/web/src/views/backend/game/live/index.vue
index d71afa0..21298be 100644
--- a/web/src/views/backend/game/live/index.vue
+++ b/web/src/views/backend/game/live/index.vue
@@ -82,7 +82,7 @@
{{ t('game.live.calc_result_number') }}
- {{ calcResultNumber ?? '—' }}
+ {{ displayResultNumber ?? '—' }}
{{ t('game.live.calc_estimated_loss') }}
@@ -160,6 +160,11 @@
+
+
+ {{ formatWinAmount(scope.row) }}
+
+
@@ -229,6 +234,8 @@ interface Snapshot {
runtime_enabled?: boolean
/** 完整维护 UI:关服且当前无进行中/未结清对局(派彩已全部完成) */
maintenance_ui?: boolean
+ /** 已开奖号码(status≥2 时有值) */
+ result_number?: number | null
}
const { t } = useI18n()
@@ -252,6 +259,7 @@ const snapshot = reactive({
can_schedule_draw: false,
runtime_enabled: true,
maintenance_ui: false,
+ result_number: null,
})
const calcLoading = ref(false)
const drawLoading = ref(false)
@@ -271,6 +279,7 @@ const clockTick = ref(0)
let clockTimer: number | null = null
let payoutStuckRefreshTimer: number | null = null
let fallbackPollTimer: number | null = null
+let betStreamRefreshTimer: number | null = null
/** 合并并发 snapshot 请求,避免 axios 重复请求取消导致控制台报错 */
let snapshotLoadPromise: Promise | null = null
@@ -333,6 +342,11 @@ function handleWsPayload(raw: unknown): void {
return
}
if (event === 'admin.live.opened') {
+ const opened = parsed.data as anyObj
+ if (typeof opened.result_number === 'number') {
+ snapshot.result_number = opened.result_number
+ calcResultNumber.value = opened.result_number
+ }
void loadSnapshot({ force: true })
return
}
@@ -360,13 +374,29 @@ function handleWsPayload(raw: unknown): void {
return
}
if (event === 'bet.accepted' && parsed.data && typeof parsed.data === 'object') {
- if (!wsConnected.value) {
- void loadSnapshot()
+ const betData = parsed.data as anyObj
+ const periodNo = typeof betData.period_no === 'string' ? betData.period_no : ''
+ const currentNo = typeof snapshot.record?.period_no === 'string' ? snapshot.record.period_no : ''
+ if (periodNo !== '' && periodNo === currentNo) {
+ scheduleBetStreamRefresh()
+ } else if (!wsConnected.value) {
+ void loadSnapshot({ force: true })
}
return
}
}
+/** 有新下注时防抖拉取快照,补全 WS 每秒快照之间的下注列表 */
+function scheduleBetStreamRefresh(): void {
+ if (betStreamRefreshTimer !== null) {
+ window.clearTimeout(betStreamRefreshTimer)
+ }
+ betStreamRefreshTimer = window.setTimeout(() => {
+ betStreamRefreshTimer = null
+ void loadSnapshot({ force: true })
+ }, 600)
+}
+
/** 用 period.tick 轻量字段刷新倒计时(不触发 HTTP,避免与 WS 每秒 snapshot 冲突) */
function mergePeriodTickFields(periodData: anyObj): void {
if (typeof periodData.server_time === 'number') {
@@ -568,8 +598,49 @@ function isScheduledNumber(v: unknown): boolean {
return snapshot.pending_draw_number === n
}
+const displayResultNumber = computed(() => {
+ const fromSnap = numberValue(snapshot.result_number)
+ if (fromSnap !== null) {
+ return fromSnap
+ }
+ const fromRec = numberValue(snapshot.record?.result_number)
+ if (fromRec !== null) {
+ return fromRec
+ }
+ return calcResultNumber.value
+})
+
+function updateCalcLossFromResultNumber(): void {
+ const rn = displayResultNumber.value
+ if (rn === null) {
+ return
+ }
+ const row = snapshot.candidate_numbers.find((c) => numberValue(c?.number) === rn)
+ if (row && row.estimated_loss !== undefined && row.estimated_loss !== null) {
+ calcEstimatedLoss.value = String(row.estimated_loss)
+ }
+}
+
+function formatWinAmount(row: anyObj): string {
+ const st = Number(row?.bet_status ?? 0)
+ const win = row?.win_amount
+ if (st === 2 || st === 5) {
+ return win !== undefined && win !== null && String(win) !== '' ? String(win) : '0.00'
+ }
+ return '—'
+}
+
function candidateRowClassName(arg: { row: anyObj }): string {
- return isScheduledNumber(arg.row?.number) ? 'is-scheduled-row' : ''
+ const classes: string[] = []
+ if (isScheduledNumber(arg.row?.number)) {
+ classes.push('is-scheduled-row')
+ }
+ const result = displayResultNumber.value
+ const num = numberValue(arg.row?.number)
+ if (result !== null && num !== null && num === result) {
+ classes.push('is-result-row')
+ }
+ return classes.join(' ')
}
async function onPickSwitchChange(val: boolean, rowNumber: unknown): Promise {
@@ -621,11 +692,29 @@ function toBool(v: unknown): boolean | null {
}
function mergeLiveSnapshot(data: anyObj): void {
+ const prevPeriodId = snapshot.record?.id != null ? Number(snapshot.record.id) : null
+ let periodChanged = false
+
if (data.record !== undefined) {
+ const nextId = data.record?.id != null ? Number(data.record.id) : null
+ periodChanged = prevPeriodId !== null && nextId !== null && prevPeriodId !== nextId
snapshot.record = data.record
}
- snapshot.bets = data.bets || []
- snapshot.candidate_numbers = data.candidate_numbers || []
+
+ const incomingBets = Array.isArray(data.bets) ? data.bets : null
+ if (incomingBets !== null) {
+ if (incomingBets.length > 0 || periodChanged || prevPeriodId === null) {
+ snapshot.bets = incomingBets
+ }
+ }
+
+ const incomingCandidates = Array.isArray(data.candidate_numbers) ? data.candidate_numbers : null
+ if (incomingCandidates !== null) {
+ if (incomingCandidates.length > 0 || periodChanged || prevPeriodId === null) {
+ snapshot.candidate_numbers = incomingCandidates
+ }
+ }
+
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
@@ -654,6 +743,17 @@ function mergeLiveSnapshot(data: anyObj): void {
snapshot.maintenance_ui = data.maintenance_ui
}
}
+
+ if (typeof data.result_number === 'number') {
+ snapshot.result_number = data.result_number
+ calcResultNumber.value = data.result_number
+ } else if (periodChanged) {
+ snapshot.result_number = null
+ calcResultNumber.value = null
+ calcEstimatedLoss.value = '0.00'
+ }
+
+ updateCalcLossFromResultNumber()
syncServerClock(data.server_time)
}
@@ -931,6 +1031,10 @@ onUnmounted(() => {
window.clearTimeout(payoutStuckRefreshTimer)
payoutStuckRefreshTimer = null
}
+ if (betStreamRefreshTimer !== null) {
+ window.clearTimeout(betStreamRefreshTimer)
+ betStreamRefreshTimer = null
+ }
})
@@ -1166,6 +1270,16 @@ onUnmounted(() => {
background: var(--el-color-primary-light-9);
}
+.candidate-table :deep(.is-result-row td) {
+ background: var(--el-color-success-light-9);
+}
+
+.candidate-table :deep(.is-result-row .number-tag) {
+ border-color: var(--el-color-success);
+ color: var(--el-color-success);
+ font-weight: 700;
+}
+
@media (max-width: 768px) {
.live-tables-row .el-col {
margin-bottom: 12px;