From 800e7374708ac0221de370ea489035ffa1d26937 Mon Sep 17 00:00:00 2001 From: JiaJun <2394389886@qq.com> Date: Fri, 12 Jun 2026 11:51:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=B7=BB=E5=8A=A0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了游戏历史记录的实时推送和展示功能 - 添加了总奖池金额显示在历史记录界面中 - 新增 PeriodPayoutHistoryEntry 和 PeriodPayoutEventData 类型定义 - 在游戏状态管理中添加了待处理赔付历史记录状态 - 实现了 WebSocket 消息解析器来处理赔付数据 - 添加了移动端和桌面端历史记录组件的实时数据展示 - 集成了注册邀请码查询参数功能并优化了注册表单逻辑 - 重构了历史记录视图模型以支持实时数据流处理 --- src/constants/auth.ts | 5 +- .../desktop/desktop-game-history.tsx | 10 +- .../components/mobile/mobile-game-history.tsx | 13 ++- src/hooks/use-game-history-vm.ts | 100 ++++++++++++++---- src/hooks/use-game-realtime-sync.ts | 19 +++- src/hooks/use-register-form.ts | 15 +-- src/hooks/use-register-invite-code.ts | 41 +++++++ src/lib/ws/message-parsers.ts | 53 ++++++++++ src/main/main-entry-page.tsx | 2 + src/store/game/game-round-store.ts | 23 +++- src/type/game.type.ts | 29 +++++ 11 files changed, 267 insertions(+), 43 deletions(-) create mode 100644 src/hooks/use-register-invite-code.ts diff --git a/src/constants/auth.ts b/src/constants/auth.ts index 739bbbf..a263395 100644 --- a/src/constants/auth.ts +++ b/src/constants/auth.ts @@ -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 diff --git a/src/features/game/components/desktop/desktop-game-history.tsx b/src/features/game/components/desktop/desktop-game-history.tsx index fd192ec..6803e2a 100644 --- a/src/features/game/components/desktop/desktop-game-history.tsx +++ b/src/features/game/components/desktop/desktop-game-history.tsx @@ -231,6 +231,14 @@ export function DesktopGameHistory() { )}
+
+ + {t('gameDesktop.history.totalPoolAmount')} + + + {item.betAmountLabel} + +
{t('gameDesktop.history.payout')} @@ -239,7 +247,7 @@ export function DesktopGameHistory() { {item.winAmountLabel}
-
+
{t('gameDesktop.history.winningResult')} diff --git a/src/features/game/components/mobile/mobile-game-history.tsx b/src/features/game/components/mobile/mobile-game-history.tsx index cf9b130..5c9a901 100644 --- a/src/features/game/components/mobile/mobile-game-history.tsx +++ b/src/features/game/components/mobile/mobile-game-history.tsx @@ -26,7 +26,7 @@ function HistoryRewardNumber({ return (
+
+ + {t('gameDesktop.history.totalPoolAmount')} + + + {item.betAmountLabel} + +
+
{t('gameDesktop.history.payout')} @@ -194,7 +203,7 @@ export function MobileGameHistory() {
-
+
{t('gameDesktop.history.winningResult')} diff --git a/src/hooks/use-game-history-vm.ts b/src/hooks/use-game-history-vm.ts index a66991c..5d84c79 100644 --- a/src/hooks/use-game-history-vm.ts +++ b/src/hooks/use-game-history-vm.ts @@ -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(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, ]) diff --git a/src/hooks/use-game-realtime-sync.ts b/src/hooks/use-game-realtime-sync.ts index 99998da..2060857 100644 --- a/src/hooks/use-game-realtime-sync.ts +++ b/src/hooks/use-game-realtime-sync.ts @@ -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 diff --git a/src/hooks/use-register-form.ts b/src/hooks/use-register-form.ts index 9f5d948..e918a79 100644 --- a/src/hooks/use-register-form.ts +++ b/src/hooks/use-register-form.ts @@ -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 = {}) { diff --git a/src/hooks/use-register-invite-code.ts b/src/hooks/use-register-invite-code.ts new file mode 100644 index 0000000..c670d77 --- /dev/null +++ b/src/hooks/use-register-invite-code.ts @@ -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(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]) +} diff --git a/src/lib/ws/message-parsers.ts b/src/lib/ws/message-parsers.ts index 3f84942..252d5ee 100644 --- a/src/lib/ws/message-parsers.ts +++ b/src/lib/ws/message-parsers.ts @@ -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) + const root = message as Record + + 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 { diff --git a/src/main/main-entry-page.tsx b/src/main/main-entry-page.tsx index da98962..bec055f 100644 --- a/src/main/main-entry-page.tsx +++ b/src/main/main-entry-page.tsx @@ -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) diff --git a/src/store/game/game-round-store.ts b/src/store/game/game-round-store.ts index 068282c..0313ef5 100644 --- a/src/store/game/game-round-store.ts +++ b/src/store/game/game-round-store.ts @@ -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()((set) => ({ +export const useGameRoundStore = create()((set, get) => ({ ...createInitialRoundState(), adjustBetQuantity: (delta) => { set((state) => { @@ -181,6 +184,18 @@ export const useGameRoundStore = create()((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()((set) => ({ } }) }, + pendingPayoutHistoryEntry: null, placeBet: (cellId) => { set((state) => { const activeChip = @@ -328,7 +344,7 @@ export const useGameRoundStore = create()((set) => ({ })) }, restoreRecentSuccessfulSelections: () => { - const state = useGameRoundStore.getState() + const state = get() if ( state.round.phase !== 'betting' || @@ -468,6 +484,9 @@ export const useGameRoundStore = create()((set) => ({ } }) }, + setPendingPayoutHistoryEntry: (entry) => { + set({ pendingPayoutHistoryEntry: entry }) + }, syncRound: (round) => { set((state) => { const previousRound = state.round diff --git a/src/type/game.type.ts b/src/type/game.type.ts index e74f415..308e307 100644 --- a/src/type/game.type.ts +++ b/src/type/game.type.ts @@ -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) => void upsertSelections: (selections: BetSelection[]) => void }