refactor(game): 重构游戏组件和数据结构

- 移除中心模态框的背景模糊效果并调整透明度
- 为桌面游戏历史组件添加空状态显示组件
- 重构头部时钟显示逻辑,提取为独立组件并优化时间同步
- 移除用户ID遮罩功能,直接使用昵称显示中奖信息
- 调整入口页面的模态框渲染结构和认证状态检查逻辑
- 更新奖池广播数据结构,替换用户ID为昵称字段
- 优化实时同步中的数据验证和映射逻辑
- 调整移动端头部时钟组件的实现方式
This commit is contained in:
JiaJun
2026-06-02 10:02:08 +08:00
parent 72b6de499e
commit 522b8a1f28
12 changed files with 180 additions and 121 deletions

View File

@@ -71,7 +71,7 @@ export function CenterModal({
return createPortal(
<div
className={cn(
'fixed inset-0 z-50 flex items-center justify-center bg-slate-950/72 backdrop-blur-sm',
'fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80',
backdropClassName,
)}
>

View File

@@ -214,10 +214,10 @@ export interface GamePeriodTickDto {
}
export interface JackpotHitItemDto {
nickname: string
period_no: string
result_number: number
total_win: string
user_id: number
}
export interface JackpotHitEventDataDto {

View File

@@ -1,3 +1,4 @@
import { History } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import historyBg from '@/assets/system/history-bg.png'
@@ -43,6 +44,25 @@ function HistoryRewardNumber({
)
}
function HistoryEmptyState({ label }: { label: string }) {
return (
<div className="flex min-h-full w-full flex-1 items-center justify-center px-design-10 py-design-10">
<div className="flex w-fit max-w-full flex-col items-center rounded-[calc(var(--design-unit)*8)] border border-[rgba(94,212,230,0.14)] bg-[rgba(5,23,33,0.3)] px-design-14 py-design-12 text-center">
<div className="mb-design-8 flex h-design-42 w-design-42 items-center justify-center rounded-full border border-[#56EFFF]/18 bg-[#061D29]/55 shadow-[0_0_calc(var(--design-unit)*10)_rgba(86,239,255,0.08)]">
<History
aria-hidden="true"
className="h-design-20 w-design-20 text-[#A8EAF1]"
strokeWidth={1.8}
/>
</div>
<div className="whitespace-nowrap text-design-16 font-semibold text-[#9CCFD4]">
{label}
</div>
</div>
</div>
)
}
export function DesktopGameHistory() {
const { t } = useTranslation()
const {
@@ -99,13 +119,7 @@ export function DesktopGameHistory() {
className="min-h-full flex-1"
/>
) : isEmpty ? (
<div
className={
'flex w-full flex-1 items-center justify-center text-design-18 text-[#84A2A2]'
}
>
{emptyText}
</div>
<HistoryEmptyState label={emptyText} />
) : (
<>
{items.map((item) => {

View File

@@ -14,7 +14,16 @@ import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { useHeaderVm } from '@/features/game/hooks/use-header-vm'
import {
useHeaderClockLabel,
useHeaderVm,
} from '@/features/game/hooks/use-header-vm'
function HeaderClock() {
const systemTimeLabel = useHeaderClockLabel()
return <div className={'text-[#D2FCFF] font-bold'}>{systemTimeLabel}</div>
}
function SignalBars({
activeBars,
@@ -66,12 +75,11 @@ export function DesktopHeader() {
onOpenRules,
onOpenUserInfo,
signalPresentation,
systemTimeLabel,
toggleSoundEnabled,
} = useHeaderVm()
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
<header className="sticky top-0 z-30 border-b border-white/8 bg-[#020B14]/95 ">
<div className="flex h-design-70 w-full items-center px-design-12">
<div className="flex h-full w-design-375 items-center justify-center border-r border-[rgba(128,223,231,0.65)] px-design-10">
<SmartImage
@@ -99,7 +107,7 @@ export function DesktopHeader() {
<div className={'text-[#B4E4E9]'}>
{t('gameDesktop.header.systemTime')}
</div>
<div className={'text-[#D2FCFF] font-bold'}>{systemTimeLabel}</div>
<HeaderClock />
</div>
<div className="flex h-full flex-1 items-center justify-around gap-design-10 border-r border-[rgba(128,223,231,0.65)] px-design-20">

View File

@@ -6,20 +6,6 @@ const winAmountFormatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 6,
})
function maskParticipantLabel(value: number | string) {
const label = String(value).trim()
if (label.length <= 1) {
return '*'
}
const visibleLength = Math.ceil(label.length / 2)
return `${label.slice(0, visibleLength)}${'*'.repeat(
label.length - visibleLength,
)}`
}
function formatWinAmount(value: string) {
const amount = Number(value)
@@ -34,9 +20,9 @@ export function DesktopTitle() {
jackpotBroadcasts.length > 0
? jackpotBroadcasts.map((broadcast) => ({
id: broadcast.id,
message: `Player ${maskParticipantLabel(
broadcast.userId,
)} won ${formatWinAmount(broadcast.totalWin)}`,
message: `Player ${broadcast.nickname} won ${formatWinAmount(
broadcast.totalWin,
)}`,
}))
: [{ id: 'empty', message: '' }]
const marqueeTitles =

View File

@@ -12,7 +12,20 @@ import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import { SmartImage } from '@/components/smart-image.tsx'
import { useHeaderVm } from '@/features/game/hooks/use-header-vm'
import {
useHeaderClockLabel,
useHeaderVm,
} from '@/features/game/hooks/use-header-vm'
function MobileHeaderClock() {
const systemTimeLabel = useHeaderClockLabel()
return (
<div className="mt-design-3 whitespace-nowrap text-design-7 font-bold text-[#D2FCFF]">
{systemTimeLabel}
</div>
)
}
export function MobileHeader() {
const { t } = useTranslation()
@@ -30,7 +43,6 @@ export function MobileHeader() {
onOpenRules,
onOpenUserInfo,
signalPresentation,
systemTimeLabel,
toggleSoundEnabled,
} = useHeaderVm()
@@ -155,9 +167,7 @@ export function MobileHeader() {
<div className="text-[#B4E4E9]">
{t('gameDesktop.header.systemTime')}
</div>
<div className="mt-design-3 whitespace-nowrap text-design-7 font-bold text-[#D2FCFF]">
{systemTimeLabel}
</div>
<MobileHeaderClock />
</div>
<button

View File

@@ -69,8 +69,8 @@ export function EntryNoticeGateModal() {
setHasEntered(false)
setHasAgreed(false)
if (!hasStoredLoginInfo) {
setShouldGateEntry(!shouldSuppressEntryGate)
if (!hasStoredLoginInfo || shouldSuppressEntryGate) {
setShouldGateEntry(false)
return
}

View File

@@ -2,14 +2,54 @@ import { startTransition, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getGameLobbyInit } from '@/features/game'
import { EntryNoticeGateModal } from '@/features/game/components'
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
import { DesktopPeriodHistoryDrawer } from '@/features/game/modal/desktop/desktop-period-history-drawer.tsx'
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
import { useDocumentMetadata } from '@/lib/head/document-metadata'
import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
function EntryModalHost() {
return (
<>
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
<DesktopLoginModal />
{/* 桌面端注册弹窗:用于新用户注册账号 */}
<DesktopRegisterModal />
{/* 桌面端语言切换弹窗:用于选择当前站点展示语言 */}
<DesktopLanguageModal />
{/* 桌面端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
<DesktopRulesModal />
{/* 桌面端用户信息弹窗:展示个人资料与站内消息 */}
<DesktopUserInfoModal />
{/* 桌面端公告弹窗:展示活动公告或运营通知内容 */}
<DesktopNoticeModal />
{/* 桌面端自动托管弹窗:配置自动托管相关条件 */}
<DesktopAutoSettingModal />
{/* 桌面端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
<DesktopProceduresModal />
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
<DesktopWithdrawTopupModal />
{/* 强制弹窗 */}
<EntryNoticeGateModal />
{/* 历史开奖信息弹窗 */}
<DesktopPeriodHistoryDrawer />
</>
)
}
export function EntryPage() {
const { t } = useTranslation()
useGameRealtimeSync()
@@ -19,6 +59,8 @@ export function EntryPage() {
const syncConnection = useGameSessionStore((state) => state.syncConnection)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const authStatus = useAuthStore((state) => state.status)
const authIsHydrated = useAuthStore((state) => state.isHydrated)
const accessToken = useAuthStore((state) => state.accessToken)
const lastUnauthorizedAt = useAuthStore((state) => state.lastUnauthorizedAt)
const isReloginRequired =
authStatus === 'anonymous' && Boolean(lastUnauthorizedAt)
@@ -38,7 +80,13 @@ export function EntryPage() {
})
useEffect(() => {
if (isReloginRequired) {
if (!authIsHydrated) {
setIsHydrating(true)
return
}
if (isReloginRequired || authStatus !== 'authenticated' || !accessToken) {
setIsHydrating(false)
return
@@ -121,6 +169,8 @@ export function EntryPage() {
cancelled = true
}
}, [
accessToken,
authIsHydrated,
authStatus,
hydrateRound,
hydrateSession,
@@ -156,6 +206,7 @@ export function EntryPage() {
className="flex min-h-0 flex-1 flex-col"
>
{isMobile ? <MobileEntry /> : <PcEntry />}
<EntryModalHost />
</section>
)
}

View File

@@ -1,19 +1,9 @@
import { DesktopHeader, EntryNoticeGateModal } from '@/features/game/components'
import { DesktopHeader } from '@/features/game/components'
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
import { DesktopPeriodHistoryDrawer } from '@/features/game/modal/desktop/desktop-period-history-drawer.tsx'
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
export function PcEntry() {
useAutoHostingRunner()
@@ -50,28 +40,6 @@ export function PcEntry() {
>
<DesktopControl />
</div>
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
<DesktopLoginModal />
{/* 桌面端注册弹窗:用于新用户注册账号 */}
<DesktopRegisterModal />
{/* 桌面端语言切换弹窗:用于选择当前站点展示语言 */}
<DesktopLanguageModal />
{/* 桌面端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
<DesktopRulesModal />
{/* 桌面端用户信息弹窗:展示个人资料与站内消息 */}
<DesktopUserInfoModal />
{/* 桌面端公告弹窗:展示活动公告或运营通知内容 */}
<DesktopNoticeModal />
{/* 桌面端自动托管弹窗:配置自动托管相关条件 */}
<DesktopAutoSettingModal />
{/* 桌面端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
<DesktopProceduresModal />
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
<DesktopWithdrawTopupModal />
{/* 强制弹窗 */}
<EntryNoticeGateModal />
{/* 历史开奖信息弹窗 */}
<DesktopPeriodHistoryDrawer />
</>
)
}

View File

@@ -256,7 +256,7 @@ function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
const source = value as Record<string, unknown>
if (
typeof source.user_id !== 'number' ||
typeof source.nickname !== 'string' ||
typeof source.period_no !== 'string' ||
typeof source.total_win !== 'string'
) {
@@ -270,10 +270,10 @@ function extractJackpotHitItem(value: unknown): JackpotHitItemDto | null {
}
return {
nickname: source.nickname,
period_no: source.period_no,
result_number: resultNumber,
total_win: source.total_win,
user_id: source.user_id,
}
}
@@ -286,18 +286,31 @@ function extractJackpotHitData(
return null
}
const serverTime = toOptionalNumber(data.server_time)
const nestedHits = data.hits
const sourceHits = Array.isArray(nestedHits)
? nestedHits
: nestedHits && typeof nestedHits === 'object'
? [nestedHits]
: [data]
const firstHitSource = sourceHits.find(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object',
)
const hits = sourceHits
.map((item) => extractJackpotHitItem(item))
.filter((item): item is JackpotHitItemDto => item !== null)
const root = message as Record<string, unknown>
const serverTime = toOptionalNumber(
data.server_time ?? firstHitSource?.server_time ?? root.server_time,
)
const resultNumber = toOptionalNumber(
data.result_number ?? data['result number'],
)
if (typeof serverTime !== 'number') {
return null
}
const sourceHits = Array.isArray(data.hits) ? data.hits : [data]
const hits = sourceHits
.map((item) => extractJackpotHitItem(item))
.filter((item): item is JackpotHitItemDto => item !== null)
const resultNumber = toOptionalNumber(data.result_number)
return {
hits,
period_id:
@@ -589,11 +602,11 @@ function applyJackpotHitMessage(message: GameSocketMessage) {
if (jackpotHitData?.hits.length) {
useGameSessionStore.getState().pushJackpotBroadcasts(
jackpotHitData.hits.map((hit) => ({
id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.user_id}:${hit.total_win}`,
message: `恭喜${hit.user_id} 用户中奖,获得${hit.total_win}`,
id: `${jackpotHitData.period_no}:${hit.result_number}:${hit.nickname}:${hit.total_win}`,
message: `恭喜${hit.nickname} 用户中奖,获得${hit.total_win}`,
nickname: hit.nickname,
periodNo: hit.period_no,
totalWin: hit.total_win,
userId: hit.user_id,
})),
)
}

View File

@@ -107,7 +107,6 @@ function resolveSignalPresentation(input: {
export function useHeaderVm() {
const [isFullscreen, setIsFullscreen] = useState(false)
const [clockNow, setClockNow] = useState(() => Date.now())
const [isOnline, setIsOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
)
@@ -128,31 +127,6 @@ export function useHeaderVm() {
const setModalOpen = useModalStore((state) => state.setModalOpen)
const { currentLanguageLabel, currentLanguageOption } = useAppLanguage()
const serverClockOffsetMs = useMemo(() => {
if (
connection.status !== 'connected' ||
connection.transport !== 'websocket' ||
!connection.lastMessageAt
) {
return null
}
const serverTimestamp = Date.parse(connection.lastMessageAt)
if (Number.isNaN(serverTimestamp)) {
return null
}
return serverTimestamp - Date.now()
}, [connection.lastMessageAt, connection.status, connection.transport])
const systemTimeLabel = useMemo(() => {
const activeTimestamp =
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
return formatHeaderTime(new Date(activeTimestamp))
}, [clockNow, serverClockOffsetMs])
const signalLatencyMs = useMemo(() => {
if (
typeof connection.latencyMs === 'number' &&
@@ -184,16 +158,6 @@ export function useHeaderVm() {
return subscribeDesktopFullscreenChange(syncFullscreenState)
}, [])
useEffect(() => {
const timer = window.setInterval(() => {
setClockNow(Date.now())
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [])
useEffect(() => {
const syncBrowserNetworkState = () => {
setIsOnline(navigator.onLine)
@@ -238,7 +202,52 @@ export function useHeaderVm() {
onOpenRules: () => setModalOpen('desktopRules', true),
onOpenUserInfo: () => setModalOpen('desktopUserInfo', true),
signalPresentation,
systemTimeLabel,
toggleSoundEnabled,
}
}
export function useHeaderClockLabel() {
const [clockNow, setClockNow] = useState(() => Date.now())
const lastMessageAt = useGameSessionStore(
(state) => state.connection.lastMessageAt,
)
const connectionStatus = useGameSessionStore(
(state) => state.connection.status,
)
const connectionTransport = useGameSessionStore(
(state) => state.connection.transport,
)
const serverClockOffsetMs = useMemo(() => {
if (
connectionStatus !== 'connected' ||
connectionTransport !== 'websocket' ||
!lastMessageAt
) {
return null
}
const serverTimestamp = Date.parse(lastMessageAt)
if (Number.isNaN(serverTimestamp)) {
return null
}
return serverTimestamp - Date.now()
}, [connectionStatus, connectionTransport, lastMessageAt])
useEffect(() => {
const timer = window.setInterval(() => {
setClockNow(Date.now())
}, 1000)
return () => {
window.clearInterval(timer)
}
}, [])
const activeTimestamp =
serverClockOffsetMs === null ? clockNow : clockNow + serverClockOffsetMs
return formatHeaderTime(new Date(activeTimestamp))
}

View File

@@ -23,10 +23,10 @@ const MAX_JACKPOT_BROADCAST_COUNT = 20
export interface JackpotBroadcastItem {
id: string
message: string
nickname: string
periodNo: string
receivedAt: string
totalWin: string
userId: number
}
type JackpotBroadcastInput = Omit<JackpotBroadcastItem, 'receivedAt'>