feat(game): 优化界面组件

- 在国际化文件中添加钱包流水相关翻译项
- 在用户个人资料页面添加复制邀请链接功能
- 优化桌面端动物组件的视觉效果和动画参数
- 添加虚拟滚动功能到财务记录标签页提升性能
- 为桌面端控制面板添加投注数量调节按钮
- 更新消息模态框为通知列表和详情展示
- 在头部余额显示旁添加充值图标入口
This commit is contained in:
JiaJun
2026-05-28 11:34:02 +08:00
parent 2b2b86a73d
commit dbfe5701aa
130 changed files with 1357 additions and 597 deletions

View File

@@ -54,6 +54,9 @@ export function useAnimalVm(
)
const setModalOpen = useModalStore((state) => state.setModalOpen)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const chips = useGameRoundStore((state) => state.chips)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const roundId = useGameRoundStore((state) => state.round.id)
@@ -188,7 +191,9 @@ export function useAnimalVm(
return
}
if (totalBetAmount + (activeChip?.amount ?? 0) > balance) {
const nextBetAmount = (activeChip?.amount ?? 0) * activeBetQuantity
if (totalBetAmount + nextBetAmount > balance) {
setCellWarning({
cellId: animalId,
type: 'balance',

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { placeGameBet } from '@/features/game'
@@ -44,32 +44,40 @@ function toBetId(chipId: string) {
return Number.isInteger(betId) && betId >= 1 && betId <= 6 ? betId : null
}
function formatBetAmount(amount: number) {
if (Number.isInteger(amount)) {
return String(amount)
}
return amount.toFixed(2).replace(/\.?0+$/, '')
}
function groupSelections(selections: BetSelection[]) {
return selections.reduce<Map<string, { betId: number; numbers: number[] }>>(
(accumulator, selection) => {
const betId = toBetId(selection.chipId)
if (betId === null) {
return accumulator
}
const groupKey = String(betId)
const current = accumulator.get(groupKey)
if (current) {
current.numbers.push(selection.cellId)
return accumulator
}
accumulator.set(groupKey, {
betId,
numbers: [selection.cellId],
})
return selections.reduce<
Map<string, { amount: number; betId: number; numbers: number[] }>
>((accumulator, selection) => {
const betId = toBetId(selection.chipId)
if (betId === null) {
return accumulator
},
new Map(),
)
}
const groupKey = `${betId}:${selection.amount}`
const current = accumulator.get(groupKey)
if (current) {
current.numbers.push(selection.cellId)
return accumulator
}
accumulator.set(groupKey, {
amount: selection.amount,
betId,
numbers: [selection.cellId],
})
return accumulator
}, new Map())
}
export function useAutoHostingRunner() {
@@ -79,9 +87,10 @@ export function useAutoHostingRunner() {
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const round = useGameRoundStore((state) => state.round)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const balanceAfterBet = useGameAutoHostingStore(
(state) => state.balanceAfterBet,
const lastSingleWinAmount = useGameAutoHostingStore(
(state) => state.lastSingleWinAmount,
)
const lastIsJackpot = useGameAutoHostingStore((state) => state.lastIsJackpot)
const isHosting = useGameAutoHostingStore((state) => state.isHosting)
const lastSubmittedRoundId = useGameAutoHostingStore(
(state) => state.lastSubmittedRoundId,
@@ -92,14 +101,10 @@ export function useAutoHostingRunner() {
(state) => state.markRoundSubmitted,
)
const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
const [isSubmitting, setIsSubmitting] = useState(false)
const previousJackpotRef = useRef(currentUser?.isJackpot === true)
const inFlightRoundIdRef = useRef<string | null>(null)
useEffect(() => {
const isJackpot = currentUser?.isJackpot === true
if (!isHosting) {
previousJackpotRef.current = isJackpot
return
}
@@ -116,30 +121,24 @@ export function useAutoHostingRunner() {
if (
rules.stopIfSingleWinAbove.enabled &&
balanceAfterBet !== null &&
balance - balanceAfterBet > rules.stopIfSingleWinAbove.amount
lastSingleWinAmount !== null &&
lastSingleWinAmount > rules.stopIfSingleWinAbove.amount
) {
stopHosting()
notify.success(t('commonUi.toast.autoHostingStoppedWin'))
return
}
if (
rules.stopOnJackpot &&
isJackpot &&
previousJackpotRef.current === false
) {
if (rules.stopOnJackpot && lastIsJackpot === true) {
stopHosting()
notify.success(t('commonUi.toast.autoHostingStoppedJackpot'))
return
}
previousJackpotRef.current = isJackpot
}, [
balanceAfterBet,
currentUser?.coin,
currentUser?.isJackpot,
isHosting,
lastIsJackpot,
lastSingleWinAmount,
rules,
stopHosting,
t,
@@ -148,7 +147,7 @@ export function useAutoHostingRunner() {
useEffect(() => {
if (
!isHosting ||
isSubmitting ||
inFlightRoundIdRef.current !== null ||
authStatus !== 'authenticated' ||
!currentUser ||
round.phase !== 'betting' ||
@@ -178,11 +177,10 @@ export function useAutoHostingRunner() {
return
}
let cancelled = false
const submittingRoundId = round.id
inFlightRoundIdRef.current = submittingRoundId
const submitAutoBet = async () => {
setIsSubmitting(true)
try {
let latestBalance = currentUser.coin ?? '0'
@@ -190,11 +188,14 @@ export function useAutoHostingRunner() {
const uniqueNumbers = [...new Set(group.numbers)].sort(
(left, right) => left - right,
)
const formattedSingleBetAmount = formatBetAmount(group.amount)
const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
bet_id: group.betId,
idempotency_key: createIdempotencyKey(),
numbers: uniqueNumbers.join(','),
period_no: round.id,
single_bet_amount: formattedSingleBetAmount,
})
if (result.status !== 'accepted') {
@@ -204,42 +205,44 @@ export function useAutoHostingRunner() {
latestBalance = result.balance_after
}
if (cancelled) {
const latestHostingState = useGameAutoHostingStore.getState()
const latestUser = useAuthStore.getState().currentUser
if (
!latestHostingState.isHosting ||
latestHostingState.lastSubmittedRoundId === submittingRoundId ||
!latestUser
) {
return
}
setCurrentUser({
...currentUser,
...latestUser,
coin: latestBalance,
lastBetPeriodNo: round.id,
lastBetPeriodNo: submittingRoundId,
})
markRoundSubmitted(round.id, parseBalance(latestBalance))
markRoundSubmitted(submittingRoundId, parseBalance(latestBalance))
clearSelections()
} catch (error) {
if (!cancelled) {
if (useGameAutoHostingStore.getState().isHosting) {
stopHosting()
notify.error(t('commonUi.toast.autoHostingSubmitFailed'), {
description: error instanceof Error ? error.message : undefined,
})
}
} finally {
if (!cancelled) {
setIsSubmitting(false)
if (inFlightRoundIdRef.current === submittingRoundId) {
inFlightRoundIdRef.current = null
}
}
}
void submitAutoBet()
return () => {
cancelled = true
}
}, [
authStatus,
clearSelections,
currentUser,
isHosting,
isSubmitting,
lastSubmittedRoundId,
markRoundSubmitted,
round.id,

View File

@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import { useInfiniteQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -38,25 +38,33 @@ 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: () =>
const query = useInfiniteQuery({
queryKey: ['finance', 'user-info-order-list', recordType],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
recordType === 'deposit'
? getDepositOrderList({
page,
page: pageParam,
pageSize: FINANCE_RECORD_PAGE_SIZE,
})
: getWithdrawOrderList({
page,
page: pageParam,
pageSize: FINANCE_RECORD_PAGE_SIZE,
}),
enabled,
getNextPageParam: (lastPage) => {
const nextPage = lastPage.pagination.page + 1
const loadedCount =
lastPage.pagination.page * lastPage.pagination.page_size
return loadedCount < lastPage.pagination.total ? nextPage : undefined
},
})
const pagination = query.data?.pagination
const total = pagination?.total ?? 0
const lastPage = query.data?.pages.at(-1)
const total = lastPage?.pagination.total ?? 0
const loadedPage = lastPage?.pagination.page ?? 1
const recordTypes = useMemo(
() =>
@@ -69,58 +77,46 @@ export function useFinanceRecordsVm({ enabled }: { enabled: boolean }) {
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],
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((item, index) => ({
amountLabel: formatFinanceAmount(item.amount, locale),
bonusAmountLabel: formatFinanceAmount(item.bonusAmount, locale),
id: item.orderNo || `${page.pagination.page}-${index}`,
orderNoLabel: item.orderNo || '--',
})),
),
[locale, query.data?.pages],
)
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,
fetchNextPage: query.fetchNextPage,
hasNextPage: query.hasNextPage,
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,
isFetchingNextPage: query.isFetchingNextPage,
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,
page: loadedPage,
total,
}),
nextText: t('game.modals.userInfo.financeRecords.next'),
previousText: t('game.modals.userInfo.financeRecords.previous'),
recordType,
recordTypes,
selectRecordType,

View File

@@ -62,10 +62,16 @@ export function useGameControlVm() {
const { t } = useTranslation()
const chips = useGameRoundStore((state) => state.chips)
const activeChipId = useGameRoundStore((state) => state.activeChipId)
const activeBetQuantity = useGameRoundStore(
(state) => state.activeBetQuantity,
)
const round = useGameRoundStore((state) => state.round)
const maxSelectionCount = useGameRoundStore(
(state) => state.maxSelectionCount,
)
const adjustBetQuantity = useGameRoundStore(
(state) => state.adjustBetQuantity,
)
const selections = useGameRoundStore((state) => state.selections)
const clearSelections = useGameRoundStore((state) => state.clearSelections)
const restoreRecentSuccessfulSelections = useGameRoundStore(
@@ -151,32 +157,10 @@ export function useGameControlVm() {
return
}
const groupedSelections = selections.reduce<
Map<string, { betId: number; numbers: number[] }>
>((accumulator, selection) => {
const betId = toBetId(selection.chipId)
const betId = toBetId(selections[0]?.chipId ?? activeChipId)
const singleBetAmount = selections[0]?.amount ?? selectedChip?.amount ?? 0
if (betId === null) {
return accumulator
}
const groupKey = String(betId)
const current = accumulator.get(groupKey)
if (current) {
current.numbers.push(selection.cellId)
return accumulator
}
accumulator.set(groupKey, {
betId,
numbers: [selection.cellId],
})
return accumulator
}, new Map())
if (groupedSelections.size === 0) {
if (betId === null || singleBetAmount <= 0) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
@@ -186,24 +170,25 @@ export function useGameControlVm() {
try {
let latestBalance = currentUser?.coin ?? '0'
for (const group of groupedSelections.values()) {
const uniqueNumbers = [...new Set(group.numbers)].sort(
(left, right) => left - right,
)
const result = await placeGameBet({
bet_id: group.betId,
idempotency_key: createIdempotencyKey(),
numbers: uniqueNumbers.join(','),
period_no: round.id,
})
const uniqueNumbers = [
...new Set(selections.map((item) => item.cellId)),
].sort((left, right) => left - right)
const formattedSingleBetAmount = formatChipDisplayValue(singleBetAmount)
const result = await placeGameBet({
bet_amount: formattedSingleBetAmount,
bet_id: betId,
idempotency_key: createIdempotencyKey(),
numbers: uniqueNumbers.join(','),
period_no: round.id,
single_bet_amount: formattedSingleBetAmount,
})
if (result.status !== 'accepted') {
throw new Error(t('commonUi.toast.betRejected'))
}
latestBalance = result.balance_after
if (result.status !== 'accepted') {
throw new Error(t('commonUi.toast.betRejected'))
}
latestBalance = result.balance_after
if (currentUser) {
setCurrentUser({
...currentUser,
@@ -223,6 +208,7 @@ export function useGameControlVm() {
setIsSubmitting(false)
}
}, [
activeChipId,
authStatus,
clearSelections,
confirmState,
@@ -233,6 +219,7 @@ export function useGameControlVm() {
round.id,
round.phase,
selections,
selectedChip?.amount,
setRecentSuccessfulSelections,
setCurrentUser,
setModalOpen,
@@ -282,6 +269,7 @@ export function useGameControlVm() {
round.phase === 'betting' &&
!hasSubmittedCurrentRound &&
!isAutoHosting,
canDecreaseBetQuantity: activeBetQuantity > 1,
confirmLabel:
confirmState === 'idle'
? t('gameDesktop.control.selectNumbers')
@@ -293,12 +281,14 @@ export function useGameControlVm() {
confirmState,
isConfirmClickable: confirmState === 'ready' && !isAutoHosting,
onChipSelect: selectChip,
onDecreaseBetQuantity: () => adjustBetQuantity(-1),
onIncreaseBetQuantity: () => adjustBetQuantity(1),
onConfirm: handleConfirm,
onClearSelections: clearSelections,
onOpenAutoSetting: handleOpenAutoSetting,
onRepeatSelections: handleRepeatSelections,
maxSelectionCountLabel: maxSelectionCount,
selectedChipAmountLabel: selectedChip?.valueLabel ?? '--',
selectedBetQuantityLabel: activeBetQuantity,
selectedChipId: activeChipId,
selectedCountLabel: selections.length,
totalBetAmountLabel: formatChipDisplayValue(totalBetAmount),

View File

@@ -13,7 +13,11 @@ import {
type GameSocketMessage,
} from '@/lib/ws/game-socket-client'
import { getAuthDeviceId, useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
import {
useGameAutoHostingStore,
useGameRoundStore,
useGameSessionStore,
} from '@/store/game'
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
import type {
BetWinEventDataDto,
@@ -606,6 +610,10 @@ function applyBetWinMessage(message: GameSocketMessage) {
totalWin: betWinData.total_win,
winningCellId: betWinData.result_number,
})
useGameAutoHostingStore.getState().recordBetWin({
isJackpot: betWinData.is_jackpot,
singleWinAmount: toOptionalNumber(betWinData.total_win) ?? null,
})
if (!currentUser) {
return

View File

@@ -0,0 +1,106 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getWalletRecordList } from '@/features/game/api'
const WALLET_RECORD_PAGE_SIZE = 20
const WALLET_RECORD_TYPE = 'payout'
function formatWalletAmount(value: string, locale: string) {
const numberValue = Number(value)
if (!Number.isFinite(numberValue)) {
return value || '--'
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 4,
}).format(numberValue)
}
function formatWalletRecordTime(value: number | string | null) {
if (value === null || value === '') {
return '--'
}
const numericValue = Number(value)
const timestamp =
Number.isFinite(numericValue) && numericValue > 0
? numericValue < 10_000_000_000
? numericValue * 1000
: numericValue
: value
const formatted = dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')
return formatted === 'Invalid Date' ? String(value) : formatted
}
export function useWalletRecordsVm({ enabled }: { enabled: boolean }) {
const { i18n, t } = useTranslation()
const locale = i18n.resolvedLanguage ?? i18n.language ?? 'en-US'
const query = useInfiniteQuery({
queryKey: ['finance', 'wallet-record-list', WALLET_RECORD_TYPE],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
getWalletRecordList({
page: pageParam,
pageSize: WALLET_RECORD_PAGE_SIZE,
type: WALLET_RECORD_TYPE,
}),
enabled,
getNextPageParam: (lastPage) => {
const nextPage = lastPage.pagination.page + 1
const loadedCount =
lastPage.pagination.page * lastPage.pagination.page_size
return loadedCount < lastPage.pagination.total ? nextPage : undefined
},
})
const lastPage = query.data?.pages.at(-1)
const total = lastPage?.pagination.total ?? 0
const loadedPage = lastPage?.pagination.page ?? 1
const items = useMemo(
() =>
(query.data?.pages ?? []).flatMap((page) =>
page.list.map((item, index) => ({
amountLabel: formatWalletAmount(item.amount, locale),
balanceAfterLabel: formatWalletAmount(item.balanceAfter, locale),
balanceBeforeLabel: formatWalletAmount(item.balanceBefore, locale),
id: item.id || `${page.pagination.page}-${index}`,
remarkLabel: item.remark || '--',
timeLabel: formatWalletRecordTime(item.createdAt),
typeLabel: item.type || WALLET_RECORD_TYPE,
})),
),
[locale, query.data?.pages],
)
return {
emptyText: t('game.modals.userInfo.walletRecords.empty'),
fetchNextPage: query.fetchNextPage,
hasNextPage: query.hasNextPage,
headers: {
amount: t('game.modals.userInfo.walletRecords.amount'),
balanceAfter: t('game.modals.userInfo.walletRecords.balanceAfter'),
balanceBefore: t('game.modals.userInfo.walletRecords.balanceBefore'),
remark: t('game.modals.userInfo.walletRecords.remark'),
time: t('game.modals.userInfo.walletRecords.time'),
type: t('game.modals.userInfo.walletRecords.type'),
},
isError: query.isError,
isFetchingNextPage: query.isFetchingNextPage,
isLoading: query.isLoading,
items,
loadFailedText: t('game.modals.userInfo.walletRecords.loadFailed'),
loadingText: t('game.modals.userInfo.walletRecords.loading'),
pageLabel: t('game.modals.userInfo.walletRecords.page', {
page: loadedPage,
total,
}),
}
}