feat(game): 添加游戏历史记录实时同步功能

- 实现了游戏历史记录的实时推送和展示功能
- 添加了总奖池金额显示在历史记录界面中
- 新增 PeriodPayoutHistoryEntry 和 PeriodPayoutEventData 类型定义
- 在游戏状态管理中添加了待处理赔付历史记录状态
- 实现了 WebSocket 消息解析器来处理赔付数据
- 添加了移动端和桌面端历史记录组件的实时数据展示
- 集成了注册邀请码查询参数功能并优化了注册表单逻辑
- 重构了历史记录视图模型以支持实时数据流处理
This commit is contained in:
JiaJun
2026-06-12 11:51:32 +08:00
parent 5768632313
commit 800e737470
11 changed files with 267 additions and 43 deletions

View File

@@ -50,11 +50,8 @@ export const SMS_CODE_COOLDOWN_FALLBACK_SECONDS = 60
/** @description 发送短信验证码的业务事件类型,当前固定为注册场景。 */
export const SMS_SEND_EVENT_REGISTER = 'user_register'
/** @description 注册邀请码默认值URL 未携带邀请码参数时兜底。 */
export const DEFAULT_REGISTER_INVITE_CODE = 'D97DBC16'
/** @description 注册邀请码在 URL 查询参数中的字段名。 */
export const REGISTER_INVITE_CODE_QUERY_PARAM = 'registerInviteCode'
export const REGISTER_INVITE_CODE_QUERY_PARAM = 'invit_code'
/** @description 触发登录提示(弹窗/Toast的最小去重间隔单位为毫秒。 */
export const LOGIN_PROMPT_DEDUP_MS = 1200

View File

@@ -231,6 +231,14 @@ export function DesktopGameHistory() {
)}
</div>
<div className="grid grid-cols-[1fr_1fr] gap-design-5">
<div className="flex min-h-design-28 items-center justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] border border-[rgba(94,212,230,0.14)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(12,78,94,0.14))] px-design-7 py-design-4">
<span className="shrink-0 text-[#84A2A2]">
{t('gameDesktop.history.totalPoolAmount')}
</span>
<span className="min-w-0 truncate text-right text-design-14 font-bold text-[#D5FBFF] [text-shadow:0_0_calc(var(--design-unit)*8)_rgba(95,233,255,0.18)]">
{item.betAmountLabel}
</span>
</div>
<div className="flex min-h-design-28 items-center justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] border border-[rgba(255,227,117,0.14)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(84,57,8,0.14))] px-design-7 py-design-4">
<span className="shrink-0 text-[#84A2A2]">
{t('gameDesktop.history.payout')}
@@ -239,7 +247,7 @@ export function DesktopGameHistory() {
{item.winAmountLabel}
</span>
</div>
<div className="flex min-h-design-28 items-center justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] border border-[rgba(255,117,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(88,20,28,0.14))] px-design-7 py-design-4">
<div className="col-span-2 flex min-h-design-28 items-center justify-between gap-design-6 rounded-[calc(var(--design-unit)*6)] border border-[rgba(255,117,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(88,20,28,0.14))] px-design-7 py-design-4">
<span className={'shrink-0 text-[#84A2A2]'}>
{t('gameDesktop.history.winningResult')}
</span>

View File

@@ -26,7 +26,7 @@ function HistoryRewardNumber({
return (
<span
className={`inline-flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(95,233,255,0.16)] bg-[rgba(6,28,39,0.38)] p-design-1 align-middle ${className ?? ''}`}
className={`inline-flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)]bg-[rgba(6,28,39,0.38)] p-design-1 align-middle ${className ?? ''}`}
title={label}
>
<RewardSpriteImage
@@ -185,6 +185,15 @@ export function MobileGameHistory() {
</div>
<div className="grid grid-cols-2 gap-design-4">
<div className="flex min-h-design-22 items-center justify-between gap-design-4 rounded-[calc(var(--design-unit)*5)] border border-[rgba(94,212,230,0.14)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(12,78,94,0.14))] px-design-6 py-design-4">
<span className="shrink-0 text-[#84A2A2]">
{t('gameDesktop.history.totalPoolAmount')}
</span>
<span className="min-w-0 truncate text-right text-design-11 font-bold text-[#D5FBFF]">
{item.betAmountLabel}
</span>
</div>
<div className="flex min-h-design-22 items-center justify-between gap-design-4 rounded-[calc(var(--design-unit)*5)] border border-[rgba(255,227,117,0.14)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(84,57,8,0.14))] px-design-6 py-design-4">
<span className="shrink-0 text-[#84A2A2]">
{t('gameDesktop.history.payout')}
@@ -194,7 +203,7 @@ export function MobileGameHistory() {
</span>
</div>
<div className="flex min-h-design-22 items-center justify-between gap-design-4 rounded-[calc(var(--design-unit)*5)] border border-[rgba(255,117,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(88,20,28,0.14))] px-design-6 py-design-4">
<div className="col-span-2 flex min-h-design-22 items-center justify-between gap-design-4 rounded-[calc(var(--design-unit)*5)] border border-[rgba(255,117,117,0.16)] bg-[linear-gradient(90deg,rgba(5,23,33,0.54),rgba(88,20,28,0.14))] px-design-6 py-design-4">
<span className="shrink-0 text-[#84A2A2]">
{t('gameDesktop.history.winningResult')}
</span>

View File

@@ -1,11 +1,25 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getGameBetMyOrders } from '@/api'
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
import type { HistoryResultState } from '@/type'
import type { HistoryResultState, PeriodPayoutHistoryEntry } from '@/type'
interface GameHistoryDisplayItem {
betAmountLabel: string
createdAtLabel: string
id: string
resultState: HistoryResultState
numbersLabel: string
numbers: number[]
orderNo: string
periodNo: string
resultNumber: number | null
resultNumberLabel: string
winAmountLabel: string
}
function formatCreatedTime(timestamp: number, locale: string) {
const date = new Date(timestamp * 1000)
@@ -32,15 +46,45 @@ function formatNumbers(numbers: number[]) {
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
}
function toPayoutHistoryDisplayItem(
entry: PeriodPayoutHistoryEntry,
locale: string,
): GameHistoryDisplayItem {
return {
betAmountLabel: entry.betAmount,
createdAtLabel: formatCreatedTime(entry.serverTime, locale),
id: entry.id,
resultState: entry.betNumbers.includes(entry.resultNumber)
? ('win' satisfies HistoryResultState)
: ('lost' satisfies HistoryResultState),
numbersLabel: formatNumbers(entry.betNumbers),
numbers: entry.betNumbers,
orderNo: entry.id,
periodNo: entry.periodNo,
resultNumber: entry.resultNumber,
resultNumberLabel: String(entry.resultNumber).padStart(2, '0'),
winAmountLabel: entry.winAmount,
}
}
export function useGameHistoryVm() {
const { i18n, t } = useTranslation()
const locale = i18n.resolvedLanguage ?? 'en-US'
const accessToken = useAuthStore((state) => state.accessToken)
const authStatus = useAuthStore((state) => state.status)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealRoundId = useGameRoundStore(
(state) => state.revealAnimation.roundId,
)
const lastRevealedRoundRef = useRef<string | null>(null)
const pendingPayoutHistoryEntryId = useGameRoundStore(
(state) => state.pendingPayoutHistoryEntry?.id ?? null,
)
const consumePendingPayoutHistoryEntry = useGameRoundStore(
(state) => state.consumePendingPayoutHistoryEntry,
)
const [realtimeEntries, setRealtimeEntries] = useState<
PeriodPayoutHistoryEntry[]
>([])
const query = useInfiniteQuery({
queryKey: ['game', 'bet-my-orders', accessToken],
@@ -62,7 +106,7 @@ export function useGameHistoryVm() {
},
})
const items = useMemo(
const apiItems = useMemo(
() =>
(query.data?.pages ?? []).flatMap((page) =>
(page.list ?? []).map((entry) => {
@@ -71,11 +115,8 @@ export function useGameHistoryVm() {
const resultNumber = shouldHideResult ? null : entry.result_number
return {
amountLabel: entry.total_amount,
createdAtLabel: formatCreatedTime(
entry.create_time,
i18n.resolvedLanguage ?? 'en-US',
),
betAmountLabel: entry.bet_amount,
createdAtLabel: formatCreatedTime(entry.create_time, locale),
id: entry.order_no,
resultState:
resultNumber === null
@@ -93,33 +134,52 @@ export function useGameHistoryVm() {
? '--'
: String(resultNumber).padStart(2, '0'),
winAmountLabel: entry.win_amount,
}
} satisfies GameHistoryDisplayItem
}),
),
[i18n.resolvedLanguage, query.data?.pages, revealPhase, revealRoundId],
[locale, query.data?.pages, revealPhase, revealRoundId],
)
const items = useMemo(
() => [
...realtimeEntries.map((entry) =>
toPayoutHistoryDisplayItem(entry, locale),
),
...apiItems.filter(
(item) =>
!realtimeEntries.some((entry) => entry.periodNo === item.periodNo),
),
],
[apiItems, locale, realtimeEntries],
)
useEffect(() => {
if (revealPhase !== 'result' || !revealRoundId) {
if (
revealPhase !== 'result' ||
!revealRoundId ||
!pendingPayoutHistoryEntryId
) {
return
}
if (lastRevealedRoundRef.current === revealRoundId) {
if (authStatus !== 'authenticated') {
return
}
if (authStatus !== 'authenticated' || query.isFetching || query.isLoading) {
const entry = consumePendingPayoutHistoryEntry(revealRoundId)
if (!entry) {
return
}
lastRevealedRoundRef.current = revealRoundId
void query.refetch()
setRealtimeEntries((entries) => [
entry,
...entries.filter((item) => item.periodNo !== entry.periodNo),
])
}, [
authStatus,
query.isFetching,
query.isLoading,
query.refetch,
consumePendingPayoutHistoryEntry,
pendingPayoutHistoryEntryId,
revealPhase,
revealRoundId,
])

View File

@@ -16,6 +16,7 @@ import {
extractBetWinData,
extractJackpotHitData,
extractPeriodEventData,
extractPeriodPayoutData,
extractPeriodTick,
extractServerTime,
extractUserStreakMessageData,
@@ -180,7 +181,8 @@ function applyPeriodPayoutMessage(
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
const payout = extractPeriodPayoutData(message)
const period = payout ?? extractPeriodEventData(message)
if (period?.resultNumber !== null && period?.resultNumber !== undefined) {
const roundState = useGameRoundStore.getState()
@@ -195,6 +197,21 @@ function applyPeriodPayoutMessage(
roundId: period.periodNo,
winningCellId: period.resultNumber,
})
if (payout && payout.betNumbers.length > 0) {
roundState.setPendingPayoutHistoryEntry({
betAmount: payout.betAmount ?? '0.00',
betFlowerNames: payout.betFlowerNames,
betNumbers: payout.betNumbers,
id: `period-payout:${payout.periodNo}`,
periodNo: payout.periodNo,
resultFlowerName: payout.resultFlowerName,
resultNumber: period.resultNumber,
serverTime:
payout.serverTime ?? serverTime ?? Math.floor(Date.now() / 1000),
winAmount: payout.winAmount ?? '0.00',
})
}
}
const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id

View File

@@ -1,28 +1,17 @@
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { registerWithPassword } from '@/api'
import {
DEFAULT_REGISTER_INVITE_CODE,
REGISTER_INVITE_CODE_QUERY_PARAM,
} from '@/constants'
import i18n from '@/i18n'
import { notify } from '@/lib/notify'
import { registerFormSchema } from '@/schema/auth-schema'
import { useAuthStore } from '@/store/auth'
import type { RegisterFormValues, UseRegisterFormOptions } from '@/type'
import { toAuthSubmitErrorKey } from './auth-error-key'
import { getRegisterInviteCodeFromSearch } from './use-register-invite-code'
import { createZodResolver } from './zod-form-resolver'
function getInitialRegisterInviteCode() {
if (typeof window === 'undefined') {
return DEFAULT_REGISTER_INVITE_CODE
}
return (
new URLSearchParams(window.location.search)
.get(REGISTER_INVITE_CODE_QUERY_PARAM)
?.trim() || DEFAULT_REGISTER_INVITE_CODE
)
return getRegisterInviteCodeFromSearch()
}
export function useRegisterForm({ onSuccess }: UseRegisterFormOptions = {}) {

View File

@@ -0,0 +1,41 @@
import { useEffect, useMemo, useRef } from 'react'
import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
import { useAuthStore } from '@/store/auth'
import { useModalStore } from '@/store/modal'
export function getRegisterInviteCodeFromSearch(search?: string) {
const sourceSearch =
search ?? (typeof window === 'undefined' ? '' : window.location.search)
return (
new URLSearchParams(sourceSearch)
.get(REGISTER_INVITE_CODE_QUERY_PARAM)
?.trim() ?? ''
)
}
export function useAutoOpenRegisterByInviteCode() {
const handledInviteCodeRef = useRef<string | null>(null)
const authIsHydrated = useAuthStore((state) => state.isHydrated)
const authStatus = useAuthStore((state) => state.status)
const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
const inviteCode = useMemo(() => getRegisterInviteCodeFromSearch(), [])
useEffect(() => {
if (
!authIsHydrated ||
!inviteCode ||
handledInviteCodeRef.current === inviteCode
) {
return
}
handledInviteCodeRef.current = inviteCode
if (authStatus === 'authenticated') {
return
}
openExclusiveModal('desktopRegister')
}, [authIsHydrated, authStatus, inviteCode, openExclusiveModal])
}

View File

@@ -6,6 +6,7 @@ import type {
JackpotHitEventDataDto,
JackpotHitItemDto,
PeriodEventData,
PeriodPayoutEventData,
UserStreakMessageData,
WalletChangedData,
} from '@/type'
@@ -187,6 +188,58 @@ export function extractPeriodEventData(
}
}
function toNumberArray(value: unknown) {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => toOptionalNumber(item))
.filter(
(item): item is number =>
typeof item === 'number' && Number.isInteger(item),
)
}
function toStringArray(value: unknown) {
if (!Array.isArray(value)) {
return []
}
return value
.map((item) => toOptionalString(item))
.filter((item): item is string => typeof item === 'string')
}
export function extractPeriodPayoutData(
message: GameSocketMessage,
): PeriodPayoutEventData | null {
const period = extractPeriodEventData(message)
if (!period) {
return null
}
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const root = message as Record<string, unknown>
return {
...period,
betAmount: toOptionalString(source.bet_amount) ?? null,
betFlowerNames: toStringArray(source.bet_flower_names),
betNumbers: toNumberArray(source.bet_numbers),
payoutRemainingSeconds:
toOptionalNumber(source.payout_remaining_seconds) ?? null,
payoutSeconds: toOptionalNumber(source.payout_seconds) ?? null,
payoutUntil: toOptionalNumber(source.payout_until) ?? null,
resultFlowerName: toOptionalString(source.result_flower_name) ?? null,
serverTime:
toOptionalNumber(source.server_time ?? root.server_time) ?? null,
winAmount: toOptionalString(source.win_amount) ?? null,
}
}
export function extractWalletChangedData(
message: GameSocketMessage,
): WalletChangedData | null {

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { getGameLobbyInit } from '@/api'
import { MOBILE_LAYOUT_BREAKPOINT_PX } from '@/constants'
import { useGameRealtimeSync } from '@/hooks/use-game-realtime-sync.ts'
import { useAutoOpenRegisterByInviteCode } from '@/hooks/use-register-invite-code'
import { useDocumentMetadata } from '@/lib/head/document-metadata.ts'
import { notify } from '@/lib/notify.ts'
import { useAuthStore } from '@/store/auth'
@@ -24,6 +25,7 @@ const PcEntry = lazy(async () => {
export function MainEntryPage() {
const { t } = useTranslation()
useGameRealtimeSync()
useAutoOpenRegisterByInviteCode()
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
const selectChip = useGameRoundStore((state) => state.selectChip)
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)

View File

@@ -15,6 +15,7 @@ import type {
GameRoundSlice,
GameRoundStoreState,
HistoryEntry,
PeriodPayoutHistoryEntry,
RevealAnimationState,
RoundSnapshot,
TrendEntry,
@@ -119,6 +120,7 @@ function resolveSelectionQuantity(
function createInitialRoundState(): GameRoundSlice & {
activeChipId: string
activeBetQuantity: number
pendingPayoutHistoryEntry: PeriodPayoutHistoryEntry | null
recentSuccessfulSelections: BetSelection[]
revealAnimation: RevealAnimationState
} {
@@ -131,6 +133,7 @@ function createInitialRoundState(): GameRoundSlice & {
chips: snapshot.chips,
history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount,
pendingPayoutHistoryEntry: null,
recentSuccessfulSelections: [],
revealAnimation: createIdleRevealAnimation(),
round: snapshot.round,
@@ -139,7 +142,7 @@ function createInitialRoundState(): GameRoundSlice & {
}
}
export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
export const useGameRoundStore = create<GameRoundStoreState>()((set, get) => ({
...createInitialRoundState(),
adjustBetQuantity: (delta) => {
set((state) => {
@@ -181,6 +184,18 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
},
}))
},
consumePendingPayoutHistoryEntry: (roundId) => {
const state = get()
const entry = state.pendingPayoutHistoryEntry
if (!entry || entry.periodNo !== roundId) {
return null
}
set({ pendingPayoutHistoryEntry: null })
return entry
},
finishRevealAnimation: () => {
set((state) => {
if (
@@ -244,6 +259,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
}
})
},
pendingPayoutHistoryEntry: null,
placeBet: (cellId) => {
set((state) => {
const activeChip =
@@ -328,7 +344,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
}))
},
restoreRecentSuccessfulSelections: () => {
const state = useGameRoundStore.getState()
const state = get()
if (
state.round.phase !== 'betting' ||
@@ -468,6 +484,9 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
}
})
},
setPendingPayoutHistoryEntry: (entry) => {
set({ pendingPayoutHistoryEntry: entry })
},
syncRound: (round) => {
set((state) => {
const previousRound = state.round

View File

@@ -212,6 +212,30 @@ export interface PeriodEventData {
resultNumber: number | null
}
export interface PeriodPayoutHistoryEntry {
betAmount: string
betFlowerNames: string[]
betNumbers: number[]
id: string
periodNo: string
resultFlowerName: string | null
resultNumber: number
serverTime: number
winAmount: string
}
export interface PeriodPayoutEventData extends PeriodEventData {
betAmount: string | null
betFlowerNames: string[]
betNumbers: number[]
payoutRemainingSeconds: number | null
payoutSeconds: number | null
payoutUntil: number | null
resultFlowerName: string | null
serverTime: number | null
winAmount: string | null
}
export interface WalletChangedData {
coin: string
}
@@ -550,6 +574,7 @@ export interface GameRoundStoreState extends GameRoundSlice {
hydrateRound: (snapshot: GameRoundSlice) => void
placeBet: (cellId: number) => void
playPreparedRevealAnimation: (roundId?: string | null) => void
pendingPayoutHistoryEntry: PeriodPayoutHistoryEntry | null
prepareRevealAnimation: (input: {
revealKey: string
roundId: string
@@ -559,6 +584,9 @@ export interface GameRoundStoreState extends GameRoundSlice {
revealAnimation: RevealAnimationState
removeSelectionsForCell: (cellId: number) => void
restoreRecentSuccessfulSelections: () => boolean
consumePendingPayoutHistoryEntry: (
roundId: string,
) => PeriodPayoutHistoryEntry | null
setRecentSuccessfulSelections: (selections: BetSelection[]) => void
selectChip: (chipId: string) => void
setPhase: (phase: RoundPhase) => void
@@ -569,6 +597,7 @@ export interface GameRoundStoreState extends GameRoundSlice {
totalWin: string
winningCellId?: number | null
}) => void
setPendingPayoutHistoryEntry: (entry: PeriodPayoutHistoryEntry | null) => void
syncRound: (round: Partial<RoundSnapshot>) => void
upsertSelections: (selections: BetSelection[]) => void
}