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 @@ + + + @@ -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;