feat: 优化整体项目ui

This commit is contained in:
JiaJun
2026-05-22 17:58:52 +08:00
parent 44c984d59e
commit 046f250ce3
56 changed files with 2149 additions and 700 deletions

View File

@@ -1,4 +1,11 @@
import { useEffect, useRef } from 'react'
import {
FALLBACK_POLL_INTERVAL_MS,
GAME_SOCKET_TOPIC_VALUES,
GAME_SOCKET_TOPICS,
PLAYER_SOCKET_TOPICS,
SOCKET_DISCONNECT_DELAY_MS,
} from '@/constants'
import i18n from '@/i18n'
import { prefetchAuthToken } from '@/lib/api/api-client'
import {
@@ -16,51 +23,11 @@ type UserStreakMessageData = {
streakLevel?: number
}
const FALLBACK_POLL_INTERVAL_MS = 10_000
const GAME_SOCKET_TOPICS = {
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。
periodTick: 'period.tick',
// 本期封盘通知。用于前端立即停止下注。
periodLocked: 'period.locked',
// 本期开奖通知。用于同步开奖号码、所属期号等阶段结果。
periodOpened: 'period.opened',
// 本期派彩完成通知。用于结算阶段同步。
periodPayout: 'period.payout',
// 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。
userStreak: 'user.streak',
// 下注成功通知。仅当前用户可见,通常伴随扣款结果。
betAccepted: 'bet.accepted',
// 余额变化通知。充值、下注、派彩都会走这条流。
walletChanged: 'wallet.changed',
// 自动托管进度通知。包含托管开关、执行状态等。
autoSpinProgress: 'auto.spin.progress',
// 大奖命中通知。仅当本期存在中大奖用户时推送。
jackpotHit: 'jackpot.hit',
// 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。
adminLiveSnapshot: 'admin.live.snapshot',
// 后台开奖结果通知。仅 admin live 页面使用,当前 H5 前台不订阅。
adminLiveOpened: 'admin.live.opened',
} as const
const GAME_SOCKET_TOPIC_VALUES = new Set<string>(
Object.values(GAME_SOCKET_TOPICS),
)
// 当前 H5 游戏页实际需要的用户侧事件。
// 后台专用事件保持在 GAME_SOCKET_TOPICS 中做口径对齐,但不在这里订阅。
const PLAYER_SOCKET_TOPICS = [
GAME_SOCKET_TOPICS.periodTick,
GAME_SOCKET_TOPICS.userStreak,
GAME_SOCKET_TOPICS.periodOpened,
GAME_SOCKET_TOPICS.periodLocked,
GAME_SOCKET_TOPICS.periodPayout,
GAME_SOCKET_TOPICS.betAccepted,
GAME_SOCKET_TOPICS.walletChanged,
GAME_SOCKET_TOPICS.autoSpinProgress,
GAME_SOCKET_TOPICS.jackpotHit,
] as const
const SOCKET_DISCONNECT_DELAY_MS = 150
type PeriodEventData = {
openTime: number | null
periodNo: string
resultNumber: number | null
}
let sharedSocketClient: GameSocketClient | null = null
let sharedSocketKey: string | null = null
@@ -186,6 +153,37 @@ function extractPeriodTick(
}
}
function extractPeriodEventData(
message: GameSocketMessage,
): PeriodEventData | null {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const periodNo =
typeof source.period_no === 'string'
? source.period_no
: typeof source.periodNo === 'string'
? source.periodNo
: null
if (!periodNo) {
return null
}
const resultNumber = toOptionalNumber(
source.result_number ?? source.resultNumber,
)
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
return {
openTime: openTime ?? null,
periodNo,
resultNumber:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
}
}
function extractWalletCoin(message: GameSocketMessage) {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
@@ -288,6 +286,90 @@ function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') {
useGameRoundStore.getState().setPhase(phase)
}
function applyPeriodLockedMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
const roundState = useGameRoundStore.getState()
const roundId = period?.periodNo ?? roundState.round.id
if (roundId) {
roundState.syncRound({
id: roundId,
phase: 'locked',
})
} else {
roundState.setPhase('locked')
}
}
function applyPeriodOpenedMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
if (!period || period.resultNumber === null) {
applyPeriodPhase('revealing')
return
}
const roundState = useGameRoundStore.getState()
const openedAt = toIsoFromUnixSeconds(
period.openTime ?? serverTime ?? Math.floor(Date.now() / 1000),
)
const hasSmallReward = roundState.selections.some(
(selection) => selection.cellId === period.resultNumber,
)
const revealKey = `${period.periodNo}:${period.resultNumber}`
roundState.syncRound({
id: period.periodNo,
phase: 'revealing',
revealingAt: openedAt,
winningCellId: period.resultNumber,
})
useGameRoundStore.getState().prepareRevealAnimation({
hasSmallReward,
revealKey,
roundId: period.periodNo,
winningCellId: period.resultNumber,
})
}
function applyPeriodPayoutMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
if (period?.resultNumber !== null && period?.resultNumber !== undefined) {
const roundState = useGameRoundStore.getState()
const revealKey = `${period.periodNo}:${period.resultNumber}`
roundState.prepareRevealAnimation({
hasSmallReward: roundState.selections.some(
(selection) => selection.cellId === period.resultNumber,
),
revealKey,
roundId: period.periodNo,
winningCellId: period.resultNumber,
})
}
const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id
applyPeriodPhase('settled')
useGameRoundStore.getState().playPreparedRevealAnimation(roundId || null)
}
function applyUserStreakMessage(message: GameSocketMessage) {
const streakData = extractUserStreakMessageData(message)
const currentUser = useAuthStore.getState().currentUser
@@ -320,6 +402,8 @@ function applyWalletChangedMessage(message: GameSocketMessage) {
function applyJackpotHitMessage(message: GameSocketMessage) {
const currentUser = useAuthStore.getState().currentUser
const period = extractPeriodEventData(message)
const isJackpot = extractJackpotStatus(message)
if (!currentUser) {
return
@@ -327,8 +411,12 @@ function applyJackpotHitMessage(message: GameSocketMessage) {
useAuthStore.getState().setCurrentUser({
...currentUser,
isJackpot: extractJackpotStatus(message),
isJackpot,
})
if (isJackpot) {
useGameRoundStore.getState().showJackpotReward(period?.periodNo ?? null)
}
}
function applyRealtimeMessage(message: GameSocketMessage) {
@@ -336,20 +424,41 @@ function applyRealtimeMessage(message: GameSocketMessage) {
const topic = getMessageTopic(message)
switch (topic) {
case GAME_SOCKET_TOPICS.periodTick:
case GAME_SOCKET_TOPICS.periodTick: {
const period = extractPeriodTick(message)
const resultNumber =
typeof period?.result_number === 'number' ? period.result_number : null
const shouldStartSettledReveal =
period?.status === 'settled' && resultNumber !== null
const hasSmallReward = shouldStartSettledReveal
? useGameRoundStore
.getState()
.selections.some((selection) => selection.cellId === resultNumber)
: false
applyPeriodMessage(message, serverTime)
if (shouldStartSettledReveal) {
useGameRoundStore.getState().prepareRevealAnimation({
hasSmallReward,
revealKey: `${period.period_no}:${resultNumber}`,
roundId: period.period_no,
winningCellId: resultNumber,
})
useGameRoundStore
.getState()
.playPreparedRevealAnimation(period.period_no)
}
break
}
case GAME_SOCKET_TOPICS.periodLocked:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('locked')
applyPeriodLockedMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.periodOpened:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('revealing')
applyPeriodOpenedMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.periodPayout:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('settled')
applyPeriodPayoutMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.userStreak:
applyUserStreakMessage(message)