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

@@ -56,6 +56,7 @@ export function useAnimalVm(
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const roundId = useGameRoundStore((state) => state.round.id)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
@@ -106,7 +107,9 @@ export function useAnimalVm(
shouldConnectRealtime &&
(connection.status === 'connecting' || connection.status === 'reconnecting')
const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected
const lockInteraction = showStandbyState
const hasSubmittedCurrentRound =
Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId
const lockInteraction = showStandbyState || hasSubmittedCurrentRound
const selectedCellCount = Object.keys(selectionByCell).length
useEffect(() => {

View File

@@ -1,11 +1,11 @@
import { useLocation } from '@tanstack/react-router'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { LANGUAGE_OPTIONS } from '@/constants'
import { type AppLanguage, supportedLanguages } from '@/i18n'
import { LANGUAGE_OPTIONS, SUPPORTED_LANGUAGES } from '@/constants'
import type { AppLanguage } from '@/i18n'
const languagePrefixPattern = new RegExp(
`^/(${supportedLanguages.join('|')})(?=/|$)`,
`^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
)
function resolveNextPathname(pathname: string, language: AppLanguage) {

View File

@@ -0,0 +1,128 @@
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getDepositOrderList, getWithdrawOrderList } from '@/features/game/api'
export type FinanceRecordType = 'deposit' | 'withdraw'
const FINANCE_RECORD_PAGE_SIZE = 20
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
key: FinanceRecordType
labelKey: string
}> = [
{
key: 'deposit',
labelKey: 'game.modals.userInfo.financeRecords.deposit',
},
{
key: 'withdraw',
labelKey: 'game.modals.userInfo.financeRecords.withdraw',
},
]
function formatFinanceAmount(value: string, locale: string) {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) {
return value || '--'
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 4,
}).format(numberValue)
}
export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
const { i18n, t } = useTranslation()
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
const [recordType, setRecordType] = useState<FinanceRecordType>('deposit')
const [page, setPage] = useState(1)
const query = useQuery({
queryKey: ['finance', 'user-info-order-list', recordType, page],
queryFn: () =>
recordType === 'deposit'
? getDepositOrderList({
page,
pageSize: FINANCE_RECORD_PAGE_SIZE,
})
: getWithdrawOrderList({
page,
pageSize: FINANCE_RECORD_PAGE_SIZE,
}),
enabled,
})
const pagination = query.data?.pagination
const total = pagination?.total ?? 0
const recordTypes = useMemo(
() =>
FINANCE_RECORD_TYPE_OPTIONS.map((option) => ({
key: option.key,
label: t(option.labelKey),
})),
[t],
)
const items = useMemo(
() =>
(query.data?.list ?? []).map((item) => ({
amountLabel: formatFinanceAmount(item.amount, locale),
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
id: item.orderNo,
orderNoLabel: item.orderNo || '--',
})),
[locale, query.data?.list],
)
const selectRecordType = useCallback((type: FinanceRecordType) => {
setRecordType(type)
setPage(1)
}, [])
const goPreviousPage = useCallback(() => {
setPage((currentPage) => Math.max(1, currentPage - 1))
}, [])
const goNextPage = useCallback(() => {
setPage((currentPage) => currentPage + 1)
}, [])
useEffect(() => {
if (!enabled) {
setRecordType('deposit')
setPage(1)
}
}, [enabled])
return {
canGoNextPage: page * FINANCE_RECORD_PAGE_SIZE < total,
canGoPreviousPage: page > 1,
emptyText: t('game.modals.userInfo.financeRecords.empty'),
goNextPage,
goPreviousPage,
headers: {
amount: t('game.modals.userInfo.financeRecords.amount'),
bonusAmount: t('game.modals.userInfo.financeRecords.bonusAmount'),
orderNo: t('game.modals.userInfo.financeRecords.orderNo'),
},
isError: query.isError,
isFetching: query.isFetching,
isLoading: query.isLoading,
items,
loadFailedText: t('game.modals.userInfo.financeRecords.loadFailed'),
loadingText: t('game.modals.userInfo.financeRecords.loading'),
pageLabel: t('game.modals.userInfo.financeRecords.page', {
page: pagination?.page ?? page,
total,
}),
nextText: t('game.modals.userInfo.financeRecords.next'),
previousText: t('game.modals.userInfo.financeRecords.previous'),
recordType,
recordTypes,
selectRecordType,
}
}

View File

@@ -111,6 +111,8 @@ export function useGameControlVm() {
const hasSelections = selections.length > 0
const hasEnteredGame =
shouldConnectRealtime && connectionStatus === 'connected'
const hasSubmittedCurrentRound =
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const confirmState: ConfirmState = isSubmitting
? 'submitting'
@@ -141,6 +143,11 @@ export function useGameControlVm() {
return
}
if (hasSubmittedCurrentRound) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
const groupedSelections = selections.reduce<
Map<string, { betId: number; numbers: number[] }>
>((accumulator, selection) => {
@@ -219,6 +226,7 @@ export function useGameControlVm() {
currentUser,
hasInsufficientBalance,
hasSelections,
hasSubmittedCurrentRound,
round.id,
round.phase,
selections,
@@ -229,7 +237,7 @@ export function useGameControlVm() {
])
const handleRepeatSelections = useCallback(() => {
if (round.phase !== 'betting') {
if (round.phase !== 'betting' || hasSubmittedCurrentRound) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
@@ -242,16 +250,21 @@ export function useGameControlVm() {
}
notify.success(t('commonUi.toast.repeatSelectionsRestored'))
}, [restoreRecentSuccessfulSelections, round.phase, t])
}, [
hasSubmittedCurrentRound,
restoreRecentSuccessfulSelections,
round.phase,
t,
])
const handleOpenAutoSetting = useCallback(() => {
setModalOpen('desktopAutoSetting', true)
}, [setModalOpen])
return {
acceptingBets: round.phase === 'betting',
actionsEnabled: hasEnteredGame,
canClear: selections.length > 0,
acceptingBets: round.phase === 'betting' && !hasSubmittedCurrentRound,
actionsEnabled: hasEnteredGame && !hasSubmittedCurrentRound,
canClear: selections.length > 0 && !hasSubmittedCurrentRound,
confirmLabel:
confirmState === 'idle'
? t('gameDesktop.control.selectNumbers')

View File

@@ -2,12 +2,11 @@ import { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
import { getGameBetMyOrders } from '@/features/game/api/game-api'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
const GAME_HISTORY_PAGE_SIZE = 20
function formatCreatedTime(timestamp: number, locale: string) {
const date = new Date(timestamp * 1000)

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)

View File

@@ -1,36 +1,9 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { PHASE_META } from '@/constants'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const PHASE_META = {
betting: {
descriptionKey: 'gameDesktop.status.phase.betting.description',
labelKey: 'gameDesktop.status.phase.betting.label',
toneClassName: 'text-[#78FF7F]',
},
locked: {
descriptionKey: 'gameDesktop.status.phase.locked.description',
labelKey: 'gameDesktop.status.phase.locked.label',
toneClassName: 'text-[#FFE375]',
},
revealing: {
descriptionKey: 'gameDesktop.status.phase.revealing.description',
labelKey: 'gameDesktop.status.phase.revealing.label',
toneClassName: 'text-[#57E8FF]',
},
settled: {
descriptionKey: 'gameDesktop.status.phase.settled.description',
labelKey: 'gameDesktop.status.phase.settled.label',
toneClassName: 'text-[#FF9C6B]',
},
waiting: {
descriptionKey: 'gameDesktop.status.phase.waiting.description',
labelKey: 'gameDesktop.status.phase.waiting.label',
toneClassName: 'text-[#A7B6C7]',
},
} as const
export function useGameStatusVm() {
const { t } = useTranslation()
const cells = useGameRoundStore((state) => state.cells)
@@ -62,6 +35,7 @@ export function useGameStatusVm() {
phaseToneClassName: phaseMeta.toneClassName,
roundId: round.id || '--',
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
streakValue,
}
}, [cells, currentUser, dashboard, round, t, trends])
}

View File

@@ -1,43 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DEFAULT_WITHDRAW_CONFIG, QUICK_FIAT_AMOUNTS } from '@/constants'
import type { DepositWithdrawConfig } from '@/features/game/api'
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
import { useAuthStore } from '@/store'
const QUICK_FIAT_AMOUNTS = [3, 30, 50, 100, 200, 500] as const
const DEFAULT_WITHDRAW_CONFIG: DepositWithdrawConfig = {
currencies: [
{
code: 'MYR',
depositCoinsPerFiat: '100',
depositCoinsPerFiatValue: 100,
label: 'MYR',
withdrawCoinsPerFiat: '100',
withdrawCoinsPerFiatValue: 100,
},
],
payChannels: [],
platformCoinLabel: '钻石',
rates: [
{
currency: 'MYR',
diamondsPerFiatUnit: '100',
diamondsPerFiatUnitValue: 100,
},
],
withdraw: {
banks: [],
feeNote: 'RM10 - RM99.99 之间的交易将收取最低RM 1的提现手续费',
minBank: '10',
minEwallet: '10',
processingNote: '30s即可到账',
rateHint: '汇率为参考价格,实际以提现时为准。',
rateMode: 'fixed' as const,
},
}
function formatNumber(locale: string, value: number) {
return new Intl.NumberFormat(locale).format(value)
}