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

BIN
figma/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -37,23 +37,28 @@ export function CenterModal({
} }
useEffect(() => { useEffect(() => {
if (!open || !onClose || typeof document === 'undefined') { if (!open || typeof document === 'undefined') {
return return
} }
const previousOverflow = document.body.style.overflow const previousOverflow = document.body.style.overflow
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
onClose() onClose?.()
} }
} }
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
if (onClose) {
window.addEventListener('keydown', handleKeyDown)
}
return () => { return () => {
document.body.style.overflow = previousOverflow document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', handleKeyDown) if (onClose) {
window.removeEventListener('keydown', handleKeyDown)
}
} }
}, [open, onClose]) }, [open, onClose])

View File

@@ -1,7 +1,8 @@
import { type AppLanguage, supportedLanguages } from '@/i18n' import { SUPPORTED_LANGUAGES } from '@/constants'
import type { AppLanguage } from '@/i18n'
const languagePrefixPattern = new RegExp( const languagePrefixPattern = new RegExp(
`^/(${supportedLanguages.join('|')})(?=/|$)`, `^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
) )
interface LanguageLinkProps { interface LanguageLinkProps {

View File

@@ -8,10 +8,8 @@ import {
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { import { NOTIFICATION_EXIT_DURATION_MS } from '@/constants'
NOTIFICATION_EXIT_DURATION_MS, import { useNotificationStore } from '@/lib/notify'
useNotificationStore,
} from '@/lib/notify'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const TONE_CLASS_BY_TYPE = { const TONE_CLASS_BY_TYPE = {

31
src/constants/auth.ts Normal file
View File

@@ -0,0 +1,31 @@
/** @description 认证状态持久化到浏览器时使用的存储键。 */
export const AUTH_STORAGE_KEY = 'auth-session'
/** @description 认证模块调用的后端接口地址集合。 */
export const AUTH_ENDPOINTS = {
login: 'api/user/login',
profile: 'api/user/profile',
refreshToken: 'api/user/refreshToken',
register: 'api/user/register',
} as const
/** @description 获取接口鉴权 auth-token 时使用的接口地址。 */
export const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
/** @description 刷新用户登录态 user-token 时使用的接口地址。 */
export const AUTH_REFRESH_ENDPOINT = AUTH_ENDPOINTS.refreshToken
/** @description 标记当前请求已经尝试过刷新登录态的上下文键。 */
export const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
/** @description 标记当前请求跳过登录态刷新的上下文键。 */
export const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
/** @description 登录态 token 距离过期多久以内提前刷新,单位为毫秒。 */
export const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
/** @description 接口鉴权 token 距离过期多久以内不再复用缓存,单位为毫秒。 */
export const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
/** @description 认证错误翻译 key 的统一前缀。 */
export const AUTH_ERROR_KEY_PREFIX = 'auth.'

321
src/constants/game.ts Normal file
View File

@@ -0,0 +1,321 @@
import { Repeat2, Settings, Trash2 } from 'lucide-react'
import chip1 from '@/assets/game/chip1.webp'
import chip2 from '@/assets/game/chip2.webp'
import chip3 from '@/assets/game/chip3.webp'
import chip4 from '@/assets/game/chip4.webp'
import chip5 from '@/assets/game/chip5.webp'
import chip6 from '@/assets/game/chip6.webp'
import controlLeft from '@/assets/game/control-left.webp'
import controlMid from '@/assets/game/control-mid.webp'
import controlRight from '@/assets/game/control-right.webp'
import hallMusic from '@/assets/music/hall-music.mp3'
import type { DepositWithdrawConfig } from '@/features/game/api/finance-types'
/** @description 游戏棋盘行数。 */
export const GAME_GRID_ROWS = 6
/** @description 游戏棋盘列数。 */
export const GAME_GRID_COLUMNS = 6
/** @description 游戏棋盘总格子数。 */
export const GAME_TOTAL_CELLS = GAME_GRID_ROWS * GAME_GRID_COLUMNS
/** @description 游戏回合阶段枚举。 */
export const ROUND_PHASES = [
'waiting',
'betting',
'locked',
'revealing',
'settled',
] as const
/** @description 游戏格子在前端视图中的状态枚举。 */
export const CELL_STATUSES = [
'idle',
'betting',
'selected',
'locked',
'won',
'lost',
] as const
/** @description 游戏实时连接状态枚举。 */
export const CONNECTION_STATUSES = [
'idle',
'connecting',
'connected',
'reconnecting',
'disconnected',
] as const
/** @description 游戏实时连接传输方式枚举。 */
export const CONNECTION_TRANSPORTS = [
'websocket',
'polling',
'offline',
] as const
/** @description 游戏公告视觉语义枚举。 */
export const ANNOUNCEMENT_TONES = [
'info',
'success',
'warning',
'critical',
] as const
/** @description 下注来源枚举,用于区分本地未提交与服务端已确认下注。 */
export const BET_SOURCES = ['local', 'server'] as const
/** @description 走势方向枚举。 */
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
/** @description 默认筹码颜色,用于后端未返回筹码颜色时兜底。 */
export const DEFAULT_GAME_CHIP_COLORS = [
'#1D4ED8',
'#0F766E',
'#B45309',
'#B91C1C',
'#7C3AED',
'#111827',
] as const
/** @description 默认选中的筹码 ID。 */
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
/** @description 游戏公告默认存活时间,单位为毫秒。 */
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
/** @description 游戏最近开奖记录最大保留条数。 */
export const GAME_RECENT_HISTORY_LIMIT = 12
/** @description 单轮下注最多允许选择的格子数。 */
export const GAME_MAX_SELECTION_CELLS = 5
/** @description 筹码 ID 与图片资源的配置列表。 */
export const CHIP_IMAGE_OPTIONS = [
{ id: 'chip-1', src: chip1 },
{ id: 'chip-2', src: chip2 },
{ id: 'chip-3', src: chip3 },
{ id: 'chip-4', src: chip4 },
{ id: 'chip-5', src: chip5 },
{ id: 'chip-6', src: chip6 },
]
/** @description 按筹码 ID 快速取得筹码图片资源的映射表。 */
export const CHIP_IMAGE_MAP = new Map(
CHIP_IMAGE_OPTIONS.map((chip) => [chip.id, chip.src] as const),
)
/** @description 默认筹码面额与筹码 ID 配置。 */
export const DEFAULT_CHIP_AMOUNTS = [
{ amount: 1, id: 'chip-1' },
{ amount: 5, id: 'chip-2' },
{ amount: 10, id: 'chip-3' },
{ amount: 25, id: 'chip-4' },
{ amount: 50, id: 'chip-5' },
{ amount: 100, id: 'chip-6' },
] as const
/** @description 游戏控制栏动作按钮配置。 */
export const ACTION_OPTIONS = [
{
id: 'clear',
labelKey: 'gameDesktop.control.actions.clear',
Icon: Trash2,
bg: controlLeft,
},
{
id: 'repeat',
labelKey: 'gameDesktop.control.actions.repeat',
Icon: Repeat2,
bg: controlMid,
},
{
id: 'auto-spin',
labelKey: 'gameDesktop.control.actions.auto-spin',
Icon: Settings,
bg: controlRight,
},
]
/** @description 游戏业务接口地址集合。 */
export const GAME_API_ENDPOINTS = {
announcements: 'game/announcements',
betMyOrders: 'api/game/betMyOrders',
betPlaceLegacy: 'api/game/betPlace',
bootstrap: 'game/bootstrap',
lobbyInit: 'api/game/lobbyInit',
noticeConfirm: 'api/notice/noticeConfirm',
noticeDetail: 'api/notice/noticeDetail',
noticeList: 'api/notice/noticeList',
placeBet: 'api/game/placeBet',
roundFeed: 'game/round-feed',
} as const
/** @description 充值提现业务接口地址集合。 */
export const FINANCE_API_ENDPOINTS = {
depositCreate: 'api/finance/depositCreate',
depositList: 'api/finance/depositList',
depositTierList: 'api/finance/depositTierList',
depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
legacyCashierConfig: 'api/finance/cashierConfig',
withdrawCreate: 'api/finance/withdrawCreate',
withdrawList: 'api/finance/withdrawList',
} as const
/** @description 游戏实时接口不可用时的兜底轮询间隔,单位为毫秒。 */
export const FALLBACK_POLL_INTERVAL_MS = 10_000
/** @description 游戏实时通信主题集合。 */
export 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
/** @description 游戏实时通信主题白名单。 */
export const GAME_SOCKET_TOPIC_VALUES = new Set<string>(
Object.values(GAME_SOCKET_TOPICS),
)
/** @description 当前 H5 游戏页实际订阅的玩家侧实时主题。 */
export 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
/** @description 游戏实时连接延迟断开的等待时间,单位为毫秒。 */
export const SOCKET_DISCONNECT_DELAY_MS = 150
/** @description 游戏实时连接重连退避的最大等待时间,单位为毫秒。 */
export const MAX_RECONNECT_DELAY_MS = 10_000
/** @description 游戏实时连接延迟探测间隔,单位为毫秒。 */
export const LATENCY_PROBE_INTERVAL_MS = 3_000
/** @description 游戏实时连接延迟探测超时时间,单位为毫秒。 */
export const LATENCY_PROBE_TIMEOUT_MS = 10_000
/** @description 桌面端中奖动画遮罩展示时长,单位为毫秒。 */
export const REWARD_OVERLAY_DURATION_MS = 5_000
/** @description 入场公告确认时间戳在 localStorage 中使用的基础存储键。 */
export const ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY =
'36-character-flower:entry-notice:last-confirmed-at'
/** @description 已登录用户重复展示入场公告的最小间隔,单位为毫秒。 */
export const ENTRY_NOTICE_CONFIRM_INTERVAL_MS = 24 * 60 * 60 * 1000
/** @description 游戏投注记录每页加载条数。 */
export const GAME_HISTORY_PAGE_SIZE = 20
/** @description 提现页快捷法币金额选项。 */
export const QUICK_FIAT_AMOUNTS = [3, 30, 50, 100, 200, 500] as const
/** @description 后端提现配置缺失时使用的默认提现配置。 */
export 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',
},
}
/** @description 游戏状态栏各回合阶段的翻译 key 与颜色样式配置。 */
export 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
/** @description 游戏音频资源 ID 枚举。 */
export type AudioAssetId = 'hall-bgm'
/** @description 游戏音频资源配置结构。 */
export type AudioAssetDefinition = {
id: AudioAssetId
loop?: boolean
src: string
volume?: number
}
/** @description 游戏全局音频资源配置列表。 */
export const AUDIO_ASSET_DEFINITIONS: AudioAssetDefinition[] = [
{
id: 'hall-bgm',
src: hallMusic,
loop: true,
volume: 1,
},
]

View File

@@ -1,137 +1,3 @@
import { Repeat2, Settings, Trash2 } from 'lucide-react' export * from './auth'
import chip1 from '@/assets/game/chip1.webp' export * from './game'
import chip2 from '@/assets/game/chip2.webp' export * from './system'
import chip3 from '@/assets/game/chip3.webp'
import chip4 from '@/assets/game/chip4.webp'
import chip5 from '@/assets/game/chip5.webp'
import chip6 from '@/assets/game/chip6.webp'
import controlLeft from '@/assets/game/control-left.webp'
import controlMid from '@/assets/game/control-mid.webp'
import controlRight from '@/assets/game/control-right.webp'
import enUS from '@/assets/system/en-US.png'
import idID from '@/assets/system/id-ID.webp'
import msMY from '@/assets/system/ms-MY.png'
import zhCN from '@/assets/system/zh-CN.png'
import type { AppLanguage } from '@/i18n'
/** @description 应用启动阶段使用的根节点常量。 */
export const APP_ROOT_ELEMENT_ID = 'root'
/** @description 应用名称,用于文档标题和分享元信息。 */
export const APP_NAME = '36字花'
/** @description 应用默认的页面描述,用于 SEO 和分享卡片。 */
export const APP_DEFAULT_DESCRIPTION =
'36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand.'
/** @description 认证状态持久化到浏览器时使用的存储键。 */
export const AUTH_STORAGE_KEY = 'auth-session'
/** @description 应用偏好持久化到浏览器时使用的存储键。 */
export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences'
/** @description 音频偏好持久化到浏览器时使用的存储键。 */
export const AUDIO_PREFERENCES_STORAGE_KEY = 'audio-preferences'
/** @description 接口请求的默认超时时间,单位为毫秒。 */
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
/** @description 请求默认声明可接收的响应内容类型。 */
export const DEFAULT_REQUEST_ACCEPT_HEADER = 'application/json'
/** @description 请求层统一使用的错误提示文案。 */
export const API_ERROR_MESSAGES = {
timeout: 'Request timed out',
unexpected: 'Unexpected request error',
} as const
/** @description TanStack Query 默认的缓存新鲜时间。 */
export const QUERY_DEFAULT_STALE_TIME_MS = 30_000
/** @description TanStack Query 默认的缓存回收时间。 */
export const QUERY_DEFAULT_GC_TIME_MS = 5 * 60_000
/** @description 查询请求默认允许的最大重试次数。 */
export const QUERY_RETRY_LIMIT = 2
/** @description 可被视为瞬时失败并允许重试的状态码集合。 */
export const QUERY_RETRYABLE_STATUS_CODES = [
408, 429, 500, 502, 503, 504,
] as const
/** @description 桌面端布局切换起始断点。 */
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
export const CHIP_IMAGE_OPTIONS = [
{ id: 'chip-1', src: chip1 },
{ id: 'chip-2', src: chip2 },
{ id: 'chip-3', src: chip3 },
{ id: 'chip-4', src: chip4 },
{ id: 'chip-5', src: chip5 },
{ id: 'chip-6', src: chip6 },
]
export const CHIP_IMAGE_MAP = new Map(
CHIP_IMAGE_OPTIONS.map((chip) => [chip.id, chip.src] as const),
)
export const DEFAULT_CHIP_AMOUNTS = [
{ amount: 1, id: 'chip-1' },
{ amount: 5, id: 'chip-2' },
{ amount: 10, id: 'chip-3' },
{ amount: 25, id: 'chip-4' },
{ amount: 50, id: 'chip-5' },
{ amount: 100, id: 'chip-6' },
] as const
export const ACTION_OPTIONS = [
{
id: 'clear',
labelKey: 'gameDesktop.control.actions.clear',
Icon: Trash2,
bg: controlLeft,
},
{
id: 'repeat',
labelKey: 'gameDesktop.control.actions.repeat',
Icon: Repeat2,
bg: controlMid,
},
{
id: 'auto-spin',
labelKey: 'gameDesktop.control.actions.auto-spin',
Icon: Settings,
bg: controlRight,
},
]
export const LANGUAGE_OPTIONS: Array<{
code: AppLanguage
icon: string
labelKey: string
}> = [
{
code: 'zh-CN',
icon: zhCN,
labelKey: 'language.zhCN',
},
{
code: 'en-US',
icon: enUS,
labelKey: 'language.enUS',
},
{
code: 'ms-MY',
icon: msMY,
labelKey: 'language.msMY',
},
{
code: 'id-ID',
icon: idID,
labelKey: 'language.idID',
},
]
export const LANGUAGE_ICON_MAP = new Map(
LANGUAGE_OPTIONS.map((language) => [language.code, language.icon] as const),
)

128
src/constants/system.ts Normal file
View File

@@ -0,0 +1,128 @@
import enUS from '@/assets/system/en-US.png'
import idID from '@/assets/system/id-ID.webp'
import msMY from '@/assets/system/ms-MY.png'
import zhCN from '@/assets/system/zh-CN.png'
/** @description 应用启动阶段使用的根节点 ID。 */
export const APP_ROOT_ELEMENT_ID = 'root'
/** @description 应用名称,用于文档标题和分享元信息。 */
export const APP_NAME = '36字花'
/** @description 应用默认页面描述,用于 SEO 和分享卡片。 */
export const APP_DEFAULT_DESCRIPTION =
'36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand.'
/** @description 应用偏好持久化到浏览器时使用的存储键。 */
export const APP_PREFERENCES_STORAGE_KEY = 'app-preferences'
/** @description 音频偏好持久化到浏览器时使用的存储键。 */
export const AUDIO_PREFERENCES_STORAGE_KEY = 'audio-preferences'
/** @description 接口请求默认超时时间,单位为毫秒。 */
export const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
/** @description 请求默认声明可接收的响应内容类型。 */
export const DEFAULT_REQUEST_ACCEPT_HEADER = 'application/json'
/** @description 请求层统一使用的错误提示文案。 */
export const API_ERROR_MESSAGES = {
timeout: 'Request timed out',
unexpected: 'Unexpected request error',
} as const
/** @description TanStack Query 默认缓存新鲜时间,单位为毫秒。 */
export const QUERY_DEFAULT_STALE_TIME_MS = 30_000
/** @description TanStack Query 默认缓存回收时间,单位为毫秒。 */
export const QUERY_DEFAULT_GC_TIME_MS = 5 * 60_000
/** @description 查询请求默认允许的最大重试次数。 */
export const QUERY_RETRY_LIMIT = 2
/** @description 可被视为瞬时失败并允许重试的 HTTP 状态码集合。 */
export const QUERY_RETRYABLE_STATUS_CODES = [
408, 429, 500, 502, 503, 504,
] as const
/** @description 桌面端布局切换起始断点,单位为像素。 */
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024
/** @description 应用支持的语言代码列表。 */
export const SUPPORTED_LANGUAGES = ['zh-CN', 'en-US', 'ms-MY', 'id-ID'] as const
/** @description 应用无法解析用户语言时使用的默认语言。 */
export const DEFAULT_APP_LANGUAGE = 'zh-CN'
/** @description 语言切换面板展示的语言选项配置。 */
export const LANGUAGE_OPTIONS: Array<{
code: (typeof SUPPORTED_LANGUAGES)[number]
icon: string
labelKey: string
}> = [
{
code: 'zh-CN',
icon: zhCN,
labelKey: 'language.zhCN',
},
{
code: 'en-US',
icon: enUS,
labelKey: 'language.enUS',
},
{
code: 'ms-MY',
icon: msMY,
labelKey: 'language.msMY',
},
{
code: 'id-ID',
icon: idID,
labelKey: 'language.idID',
},
]
/** @description 按语言代码快速取得国旗图标资源的映射表。 */
export const LANGUAGE_ICON_MAP = new Map(
LANGUAGE_OPTIONS.map((language) => [language.code, language.icon] as const),
)
/** @description 浏览器全屏状态变更事件名,包含 WebKit/Firefox/IE 兼容事件。 */
export const FULLSCREEN_CHANGE_EVENTS = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange',
] as const
/** @description 通知弹窗默认停留时间,单位为毫秒。 */
export const DEFAULT_ALERT_DURATION_MS = 2600
/** @description 通知弹窗关闭动画持续时间,单位为毫秒。 */
export const NOTIFICATION_EXIT_DURATION_MS = 220
/** @description 全局弹窗注册键列表,用于统一控制弹窗开关状态。 */
export const MODAL_KEYS = [
'desktopLogin',
'desktopRegister',
'desktopLanguage',
'desktopRules',
'desktopUserInfo',
'desktopNotice',
'desktopAutoSetting',
'desktopProcedures',
'desktopWithdrawTopup',
] as const
/** @description 全局弹窗默认可见状态。 */
export const INITIAL_MODAL_VISIBILITY = {
desktopLogin: false,
desktopRegister: false,
desktopLanguage: false,
desktopRules: false,
desktopUserInfo: false,
desktopNotice: false,
desktopAutoSetting: false,
desktopProcedures: false,
desktopWithdrawTopup: false,
} as const

View File

View File

@@ -1,3 +1,4 @@
import { AUTH_ENDPOINTS, AUTH_SKIP_REFRESH_CONTEXT_KEY } from '@/constants'
import { api } from '@/lib/api/api-client' import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error' import { ApiError } from '@/lib/api/api-error'
import type { AuthSessionInput } from '@/store/auth' import type { AuthSessionInput } from '@/store/auth'
@@ -20,13 +21,6 @@ import {
normalizeRefreshAuthSession, normalizeRefreshAuthSession,
} from './types' } from './types'
const AUTH_ENDPOINTS = {
login: 'api/user/login',
profile: 'api/user/profile',
refreshToken: 'api/user/refreshToken',
register: 'api/user/register',
} as const
const shouldLogAuthLifecycle = const shouldLogAuthLifecycle =
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true' import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
@@ -158,7 +152,7 @@ export async function refreshAuthSession(
AUTH_ENDPOINTS.refreshToken, AUTH_ENDPOINTS.refreshToken,
{ {
context: { context: {
skipAuthRefresh: true, [AUTH_SKIP_REFRESH_CONTEXT_KEY]: true,
}, },
json: { json: {
refresh_token: refreshToken, refresh_token: refreshToken,

View File

@@ -1,9 +1,8 @@
import { AUTH_ERROR_KEY_PREFIX } from '@/constants'
import { ApiError } from '@/lib/api/api-error' import { ApiError } from '@/lib/api/api-error'
type AuthSubmitContext = 'login' | 'register' type AuthSubmitContext = 'login' | 'register'
const AUTH_ERROR_KEY_PREFIX = 'auth.'
function isTranslationKey(value: unknown): value is string { function isTranslationKey(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX) return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX)
} }

View File

@@ -1,3 +1,4 @@
import { FINANCE_API_ENDPOINTS } from '@/constants'
import { api } from '@/lib/api/api-client' import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error' import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type' import type { ApiResponse } from '@/type'
@@ -10,6 +11,9 @@ import type {
DepositWithdrawConfig, DepositWithdrawConfig,
DepositWithdrawConfigDto, DepositWithdrawConfigDto,
FinanceCurrencyConfigDto, FinanceCurrencyConfigDto,
FinanceOrderItemDto,
FinanceOrderList,
FinanceOrderListDto,
FinancePayChannelDto, FinancePayChannelDto,
FinanceRateConfigDto, FinanceRateConfigDto,
FinanceWithdrawBankDto, FinanceWithdrawBankDto,
@@ -18,14 +22,6 @@ import type {
WithdrawCreateResponseDto, WithdrawCreateResponseDto,
} from './finance-types' } from './finance-types'
export const FINANCE_API_ENDPOINTS = {
depositCreate: 'api/finance/depositCreate',
depositTierList: 'api/finance/depositTierList',
depositWithdrawConfig: 'api/finance/depositWithdrawConfig',
legacyCashierConfig: 'api/finance/cashierConfig',
withdrawCreate: 'api/finance/withdrawCreate',
} as const
function unwrapFinanceEnvelope<T>( function unwrapFinanceEnvelope<T>(
response: ApiResponse<T>, response: ApiResponse<T>,
fallbackMessage = 'Finance request failed', fallbackMessage = 'Finance request failed',
@@ -181,6 +177,25 @@ function normalizeDepositTierItem(
} }
} }
function normalizeFinanceOrderItem(dto: FinanceOrderItemDto) {
return {
amount: String(dto.amount ?? ''),
bonusAmount: String(dto.bonus_amount ?? ''),
orderNo: dto.order_no,
}
}
function normalizeFinanceOrderList(dto: FinanceOrderListDto): FinanceOrderList {
return {
list: (dto.list ?? []).map(normalizeFinanceOrderItem),
pagination: {
page: dto.pagination?.page ?? 1,
page_size: dto.pagination?.page_size ?? 20,
total: dto.pagination?.total ?? 0,
},
}
}
export async function getDepositWithdrawConfig() { export async function getDepositWithdrawConfig() {
const response = await api.post<DepositWithdrawConfigDto>( const response = await api.post<DepositWithdrawConfigDto>(
FINANCE_API_ENDPOINTS.depositWithdrawConfig, FINANCE_API_ENDPOINTS.depositWithdrawConfig,
@@ -230,6 +245,48 @@ export async function createDeposit(payload: DepositCreateRequestDto) {
return dto return dto
} }
export async function getDepositOrderList(params?: {
page?: number
pageSize?: number
}) {
const response = await api.get<FinanceOrderListDto>(
FINANCE_API_ENDPOINTS.depositList,
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
},
},
)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<FinanceOrderListDto>,
'Failed to load deposit order list',
)
return normalizeFinanceOrderList(dto)
}
export async function getWithdrawOrderList(params?: {
page?: number
pageSize?: number
}) {
const response = await api.get<FinanceOrderListDto>(
FINANCE_API_ENDPOINTS.withdrawList,
{
searchParams: {
page: String(params?.page ?? 1),
page_size: String(params?.pageSize ?? 20),
},
},
)
const dto = unwrapFinanceEnvelope(
response as ApiResponse<FinanceOrderListDto>,
'Failed to load withdraw order list',
)
return normalizeFinanceOrderList(dto)
}
export async function createWithdraw(payload: WithdrawCreateRequestDto) { export async function createWithdraw(payload: WithdrawCreateRequestDto) {
const response = await api.post< const response = await api.post<
WithdrawCreateResponseDto, WithdrawCreateResponseDto,

View File

@@ -162,6 +162,34 @@ export interface DepositCreateResponseDto {
total_amount: number total_amount: number
} }
export interface FinanceOrderItemDto {
amount: number | string
bonus_amount: number | string
order_no: string
}
export interface FinanceOrderPaginationDto {
page: number
page_size: number
total: number
}
export interface FinanceOrderListDto {
list: FinanceOrderItemDto[]
pagination: FinanceOrderPaginationDto
}
export interface FinanceOrderItem {
amount: string
bonusAmount: string
orderNo: string
}
export interface FinanceOrderList {
list: FinanceOrderItem[]
pagination: FinanceOrderPaginationDto
}
export interface WithdrawCreateRequestDto { export interface WithdrawCreateRequestDto {
bank_code: string bank_code: string
channel_code: string channel_code: string

View File

@@ -1,3 +1,4 @@
import { GAME_API_ENDPOINTS } from '@/constants'
import { api } from '@/lib/api/api-client' import { api } from '@/lib/api/api-client'
import { ApiError } from '@/lib/api/api-error' import { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type' import type { ApiResponse } from '@/type'
@@ -79,19 +80,6 @@ function assertLobbyInitDto(
} }
} }
export const GAME_API_ENDPOINTS = {
announcements: 'game/announcements',
betMyOrders: 'api/game/betMyOrders',
betPlaceLegacy: 'api/game/betPlace',
bootstrap: 'game/bootstrap',
lobbyInit: 'api/game/lobbyInit',
noticeConfirm: 'api/notice/noticeConfirm',
noticeDetail: 'api/notice/noticeDetail',
noticeList: 'api/notice/noticeList',
placeBet: 'api/game/placeBet',
roundFeed: 'game/round-feed',
} as const
export interface GameLobbyInitResult { export interface GameLobbyInitResult {
runtimeEnabled: boolean runtimeEnabled: boolean
serverTime: number serverTime: number

View File

@@ -125,9 +125,11 @@ export interface GameAnnouncementsDto {
} }
export interface NoticeListItemDto { export interface NoticeListItemDto {
content?: string
is_read: boolean is_read: boolean
must_confirm?: boolean
notice_id: number notice_id: number
notice_type: 'silent' | 'popout' notice_type: 'silent' | 'popout' | (string & {})
publish_time: number publish_time: number
title: string title: string
} }
@@ -140,7 +142,7 @@ export interface NoticeDetailDto {
content: string content: string
must_confirm: boolean must_confirm: boolean
notice_id: number notice_id: number
notice_type: 'silent' | 'popout' notice_type: 'silent' | 'popout' | (string & {})
publish_time: number publish_time: number
title: string title: string
} }

View File

@@ -1,19 +1,5 @@
import hallMusic from '@/assets/music/hall-music.mp3' export {
AUDIO_ASSET_DEFINITIONS,
export type AudioAssetId = 'hall-bgm' type AudioAssetDefinition,
type AudioAssetId,
export type AudioAssetDefinition = { } from '@/constants/game'
id: AudioAssetId
loop?: boolean
src: string
volume?: number
}
export const AUDIO_ASSET_DEFINITIONS: AudioAssetDefinition[] = [
{
id: 'hall-bgm',
src: hallMusic,
loop: true,
volume: 1,
},
]

View File

@@ -1,11 +1,22 @@
import { TriangleAlert } from 'lucide-react' import { TriangleAlert } from 'lucide-react'
import { motion } from 'motion/react' import { motion } from 'motion/react'
import { useMemo } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp'
import enStopImage from '@/assets/game/en-stop.webp'
import zhStopImage from '@/assets/game/zh-stop.webp'
import diamondIcon from '@/assets/system/diamond.webp' import diamondIcon from '@/assets/system/diamond.webp'
import { LottiePlayer } from '@/components/lottie-player'
import { SmartImage } from '@/components/smart-image' import { SmartImage } from '@/components/smart-image'
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm' import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
const revealBorderPath = new URL(
'../../../../assets/lottie/test.json',
import.meta.url,
).href
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', { const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true, eager: true,
@@ -24,8 +35,25 @@ const animalImageList = Object.entries(animalModules)
.filter((item) => item.id > 0) .filter((item) => item.id > 0)
.sort((left, right) => left.id - right.id) .sort((left, right) => left.id - right.id)
function getRandomAnimalId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
return null
}
if (ids.length === 1) {
return ids[0] ?? null
}
let nextId = currentId
while (nextId === currentId) {
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
}
return nextId
}
interface DesktopAnimalProps { interface DesktopAnimalProps {
activeId?: number | null
className?: string className?: string
itemClassName?: string itemClassName?: string
imageClassName?: string imageClassName?: string
@@ -33,14 +61,34 @@ interface DesktopAnimalProps {
} }
export function DesktopAnimal({ export function DesktopAnimal({
activeId,
className, className,
itemClassName, itemClassName,
imageClassName, imageClassName,
onSelect, onSelect,
}: DesktopAnimalProps) { }: DesktopAnimalProps) {
const { t } = useTranslation() const { i18n, t } = useTranslation()
const animalIds = useMemo(() => animalImageList.map((item) => item.id), []) const animalIds = useMemo(() => animalImageList.map((item) => item.id), [])
const containerRef = useRef<HTMLElement | null>(null)
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
const [revealCellId, setRevealCellId] = useState<number | null>(null)
const [revealFrame, setRevealFrame] = useState<{
height: number
left: number
top: number
width: number
} | null>(null)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const roundPhase = useGameRoundStore((state) => state.round.phase)
const roundId = useGameRoundStore((state) => state.round.id)
const lastBetPeriodNo = useAuthStore(
(state) => state.currentUser?.lastBetPeriodNo,
)
const finishRevealAnimation = useGameRoundStore(
(state) => state.finishRevealAnimation,
)
const { const {
cellWarning, cellWarning,
handleSelect, handleSelect,
@@ -51,9 +99,112 @@ export function DesktopAnimal({
selectionByCell, selectionByCell,
showStandbyState, showStandbyState,
} = useAnimalVm(animalIds, onSelect) } = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' || revealPhase === 'stopping'
const isRevealResult = revealPhase === 'result'
const hasSubmittedCurrentRound =
roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId
const showStopOverlay =
hasSubmittedCurrentRound ||
roundPhase === 'locked' ||
roundPhase === 'revealing'
const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
? zhStopImage
: enStopImage
useEffect(() => {
if (revealPhase === 'idle') {
setRevealCellId(null)
return
}
if (revealPhase === 'result') {
setRevealCellId(revealWinningCellId)
return
}
if (revealPhase === 'spinning') {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
}, 70)
return () => {
window.clearInterval(intervalId)
}
}
if (revealWinningCellId === null) {
return
}
let elapsedMs = 0
const timeoutIds: number[] = []
for (let index = 0; index < 14; index += 1) {
elapsedMs += 42 + index * 13
timeoutIds.push(
window.setTimeout(() => {
setRevealCellId((currentId) =>
getRandomAnimalId(animalIds, currentId),
)
}, elapsedMs),
)
}
elapsedMs += 160
timeoutIds.push(
window.setTimeout(() => {
setRevealCellId(revealWinningCellId)
finishRevealAnimation()
}, elapsedMs),
)
return () => {
for (const timeoutId of timeoutIds) {
window.clearTimeout(timeoutId)
}
}
}, [animalIds, finishRevealAnimation, revealPhase, revealWinningCellId])
useLayoutEffect(() => {
if (revealCellId === null) {
setRevealFrame(null)
return
}
const syncRevealFrame = () => {
const container = containerRef.current
const cell = cellRefs.current.get(revealCellId)
if (!container || !cell) {
setRevealFrame(null)
return
}
const containerRect = container.getBoundingClientRect()
const cellRect = cell.getBoundingClientRect()
setRevealFrame({
height: cellRect.height,
left: cellRect.left - containerRect.left,
top: cellRect.top - containerRect.top,
width: cellRect.width,
})
}
syncRevealFrame()
window.addEventListener('resize', syncRevealFrame)
return () => {
window.removeEventListener('resize', syncRevealFrame)
}
}, [revealCellId])
return ( return (
<section <section
ref={containerRef}
className={cn( className={cn(
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset', 'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
className, className,
@@ -62,8 +213,8 @@ export function DesktopAnimal({
{animalImageList.map((item) => { {animalImageList.map((item) => {
const selectionMeta = selectionByCell[item.id] const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta) const hasPlacedSelection = Boolean(selectionMeta)
const isActive = item.id === activeId || hasPlacedSelection
const isMarqueeActive = showStandbyState && item.id === marqueeId const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner = isRevealResult && revealWinningCellId === item.id
const warningType = const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null const showCellWarning = warningType !== null
@@ -75,8 +226,15 @@ export function DesktopAnimal({
return ( return (
<motion.button <motion.button
key={item.id} key={item.id}
ref={(node) => {
if (node) {
cellRefs.current.set(item.id, node)
} else {
cellRefs.current.delete(item.id)
}
}}
type="button" type="button"
disabled={lockInteraction} disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)} onClick={() => handleSelect(item.id)}
animate={ animate={
showCellWarning showCellWarning
@@ -100,20 +258,34 @@ export function DesktopAnimal({
: { duration: 0.16, ease: 'easeOut' } : { duration: 0.16, ease: 'easeOut' }
} }
className={cn( className={cn(
'relative flex flex-col items-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150', 'relative flex h-design-112 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
lockInteraction lockInteraction
? 'cursor-not-allowed opacity-90' ? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]', : 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive && isMarqueeActive &&
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]', 'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isActive && isRevealRunning &&
'border-[rgba(255,187,61,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,175,52,0.82),0_0_calc(var(--design-unit)*30)_rgba(255,151,15,0.46),inset_0_0_calc(var(--design-unit)*20)_rgba(255,177,70,0.58)]', 'border-[rgba(104,255,249,0.9)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(68,244,255,0.68),0_0_calc(var(--design-unit)*26)_rgba(37,214,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(115,255,247,0.24)] brightness-125 saturate-150',
isRevealWinner &&
'shadow-[0_0_calc(var(--design-unit)*14)_rgba(81,248,255,0.72),0_0_calc(var(--design-unit)*24)_rgba(30,199,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.34)] brightness-125 saturate-150',
showCellWarning && showCellWarning &&
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]', 'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
!showStandbyState && !hasPlacedSelection && 'opacity-95', !showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName, itemClassName,
)} )}
> >
<SmartImage
src={animalBorderImage}
alt=""
aria-hidden="true"
priority
showSkeleton={false}
className="pointer-events-none absolute inset-0 z-20 h-full w-full"
imgClassName="object-fill"
/>
<span className="pointer-events-none absolute left-design-24 top-design-16 z-30 text-design-32 font-bold leading-none text-[#4BFFFE]">
{String(item.id).padStart(2, '0')}
</span>
<motion.span <motion.span
aria-hidden="true" aria-hidden="true"
animate={ animate={
@@ -135,8 +307,10 @@ export function DesktopAnimal({
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150', 'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive && isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]', 'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isActive && isRevealRunning &&
'bg-[radial-gradient(circle_at_center,rgba(255,207,116,0.42)_0%,rgba(255,181,61,0.16)_42%,transparent_74%)] opacity-100', 'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(77,244,255,0.24)_40%,rgba(27,183,255,0.1)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*16)_rgba(95,249,255,0.72),inset_0_0_calc(var(--design-unit)*22)_rgba(151,255,250,0.4)]',
isRevealWinner &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.5)_0%,rgba(67,226,255,0.24)_38%,rgba(25,131,255,0.1)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*14)_rgba(92,248,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(126,255,250,0.4)]',
showCellWarning && showCellWarning &&
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100', 'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
)} )}
@@ -151,9 +325,14 @@ export function DesktopAnimal({
src={item.url} src={item.url}
alt={`animal-${item.id}`} alt={`animal-${item.id}`}
className={cn( className={cn(
'relative z-10 h-design-112 w-design-223 rounded-2xl object-contain', 'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]',
isRevealRunning &&
'brightness-125 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*10)_rgba(101,250,255,0.62)]',
isRevealWinner &&
'brightness-140 saturate-150 drop-shadow-[0_0_calc(var(--design-unit)*12)_rgba(106,250,255,0.72)]',
imageClassName, imageClassName,
)} )}
imgClassName="object-fill"
/> />
{showCellWarning ? ( {showCellWarning ? (
<motion.span <motion.span
@@ -210,21 +389,135 @@ export function DesktopAnimal({
) )
})} })}
{revealFrame ? (
<div
aria-hidden="true"
className="pointer-events-none absolute z-40 transition-[height,transform,width] duration-75 ease-linear"
style={{
height: revealFrame.height + 16,
transform: `translate(${revealFrame.left - 8}px, ${revealFrame.top - 8}px)`,
width: revealFrame.width + 16,
}}
>
<LottiePlayer
path={revealBorderPath}
renderer="svg"
loop
autoplay
speed={1.8}
className="h-full w-full scale-[1.18] [&>svg]:h-full [&>svg]:w-full"
/>
</div>
) : null}
{showStopOverlay ? (
<div
aria-hidden="true"
className="absolute inset-0 z-50 flex items-center justify-center bg-[rgba(2,8,14,0.72)] px-design-24 backdrop-blur-[2px]"
>
<SmartImage
src={stopImageSrc}
alt="stop betting"
priority
showSkeleton={false}
className="h-design-220 w-design-560 max-w-[78%] overflow-visible"
imgClassName="object-contain drop-shadow-[0_0_calc(var(--design-unit)*22)_rgba(60,235,255,0.28)]"
/>
</div>
) : null}
{showStandbyState ? ( {showStandbyState ? (
<button <button
type="button" type="button"
onClick={handleStart} onClick={handleStart}
className="absolute inset-0 z-10 flex cursor-pointer items-center justify-center bg-[rgba(3,13,20,0.62)]" aria-busy={isRealtimeConnecting}
className="group absolute inset-0 z-10 flex cursor-pointer items-center justify-center overflow-hidden bg-[rgba(3,13,20,0.66)]"
> >
<div className="relative flex flex-col items-center gap-design-8 rounded-[calc(var(--design-unit)*20)] border border-[rgba(111,255,247,0.54)] bg-[linear-gradient(180deg,rgba(6,28,38,0.92),rgba(4,14,20,0.94))] px-design-28 py-design-16 text-center shadow-[0_0_calc(var(--design-unit)*16)_rgba(70,245,255,0.34),0_0_calc(var(--design-unit)*34)_rgba(19,210,232,0.22)] transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-[1px] hover:border-[rgba(141,255,250,0.8)] hover:shadow-[0_0_calc(var(--design-unit)*22)_rgba(88,247,255,0.48),0_0_calc(var(--design-unit)*42)_rgba(32,228,255,0.3)]"> <motion.div
<span className="text-design-14 uppercase tracking-[0.42em] text-[rgba(111,255,247,0.76)]"> aria-hidden="true"
{isRealtimeConnecting ? '' : t('gameDesktop.animal.tapToEnter')} animate={{ rotate: 360 }}
</span> transition={{
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#D2FFFF]"> duration: 18,
repeat: Number.POSITIVE_INFINITY,
ease: 'linear',
}}
className="pointer-events-none absolute inset-[12%] rounded-full bg-[conic-gradient(from_0deg,rgba(129,255,250,0)_0deg,rgba(129,255,250,0.26)_60deg,rgba(129,255,250,0)_120deg,rgba(255,255,255,0)_360deg)] opacity-70 blur-[18px]"
/>
<motion.div
aria-hidden="true"
animate={{
scale: [1, 1.03, 1],
opacity: [0.42, 0.7, 0.42],
}}
transition={{
duration: 2.8,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}}
className="pointer-events-none absolute inset-[22%] rounded-full border border-[rgba(124,255,248,0.22)] shadow-[0_0_calc(var(--design-unit)*22)_rgba(74,245,255,0.12),inset_0_0_calc(var(--design-unit)*18)_rgba(122,255,250,0.14)]"
/>
<div className="relative flex min-w-design-260 flex-col items-center gap-design-10 rounded-[calc(var(--design-unit)*22)] border border-[rgba(111,255,247,0.56)] bg-[linear-gradient(180deg,rgba(8,30,42,0.94),rgba(4,14,20,0.96))] px-design-32 py-design-18 text-center shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.38),0_0_calc(var(--design-unit)*42)_rgba(19,210,232,0.22),inset_0_0_calc(var(--design-unit)*18)_rgba(120,255,249,0.12)] transition-[transform,box-shadow,border-color] duration-200 group-hover:-translate-y-[1px] group-hover:border-[rgba(141,255,250,0.82)] group-hover:shadow-[0_0_calc(var(--design-unit)*24)_rgba(88,247,255,0.5),0_0_calc(var(--design-unit)*52)_rgba(32,228,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(145,255,251,0.16)]">
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*22)] border border-[rgba(226,255,255,0.1)]" />
<span className="pointer-events-none absolute inset-x-design-14 top-design-10 h-design-18 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.16),rgba(255,255,255,0))] opacity-70" />
<span className="text-design-14 uppercase tracking-[0.44em] text-[rgba(132,255,248,0.72)]">
{isRealtimeConnecting {isRealtimeConnecting
? t('gameDesktop.animal.loading') ? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')} : t('gameDesktop.animal.tapToEnter')}
</span> </span>
<div className="flex items-center gap-design-10">
{isRealtimeConnecting ? (
<span className="relative flex h-design-24 w-design-24 items-center justify-center">
<motion.span
animate={{ rotate: 360 }}
transition={{
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'linear',
}}
className="absolute inset-0 rounded-full border-2 border-[rgba(119,255,250,0.22)] border-t-[rgba(119,255,250,0.98)] border-r-[rgba(119,255,250,0.56)]"
/>
<motion.span
animate={{ scale: [0.85, 1, 0.85], opacity: [0.6, 1, 0.6] }}
transition={{
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}}
className="h-design-8 w-design-8 rounded-full bg-[#BFFFFD] shadow-[0_0_calc(var(--design-unit)*10)_rgba(114,255,249,0.72)]"
/>
</span>
) : (
<span className="h-design-10 w-design-10 rounded-full bg-[rgba(126,255,248,0.92)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(114,255,249,0.62)]" />
)}
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#E0FFFF]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')}
</span>
</div>
<div className="flex items-center gap-design-4">
{[0, 1, 2].map((index) => (
<motion.span
key={index}
animate={
isRealtimeConnecting
? { opacity: [0.28, 1, 0.28], y: [0, -2, 0] }
: { opacity: 0.7 }
}
transition={
isRealtimeConnecting
? {
duration: 0.9,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
delay: index * 0.15,
}
: undefined
}
className="h-design-4 w-design-4 rounded-full bg-[rgba(145,255,249,0.86)] shadow-[0_0_calc(var(--design-unit)*8)_rgba(114,255,249,0.48)]"
/>
))}
</div>
</div> </div>
</button> </button>
) : null} ) : null}

View File

@@ -93,8 +93,10 @@ export function DesktopHeader() {
</div> </div>
<div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]"> <div className="flex h-full w-design-175 flex-col items-center justify-center gap-design-5 border-r border-[rgba(128,223,231,0.65)]">
<div>{t('gameDesktop.header.systemTime')}</div> <div className={'text-[#B4E4E9]'}>
<div>{systemTimeLabel}</div> {t('gameDesktop.header.systemTime')}
</div>
<div className={'text-[#D2FCFF] font-bold'}>{systemTimeLabel}</div>
</div> </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"> <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

@@ -1,14 +1,18 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import down5Animation from '@/assets/lottie/down5.json' import down5Animation from '@/assets/lottie/down5.json'
import diamond from '@/assets/system/diamond.webp'
import fire from '@/assets/system/fire.webp'
import lock from '@/assets/system/lock.webp'
import statusCenter from '@/assets/system/status-center.webp' import statusCenter from '@/assets/system/status-center.webp'
import statusLine from '@/assets/system/status-line.webp' import statusLine from '@/assets/system/status-line.webp'
import streakBg from '@/assets/system/streak.webp'
import { LottiePlayer } from '@/components/lottie-player.tsx' import { LottiePlayer } from '@/components/lottie-player.tsx'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx' import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx' import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts' import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
export function DesktopStatusLine() { export function DesktopStatusLine() {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
@@ -36,22 +40,72 @@ export function DesktopStatusLine() {
<SmartBackground <SmartBackground
src={statusLine} src={statusLine}
size="100% 100%" size="100% 100%"
className="w-full h-design-60 bg-no-repeat bg-center flex items-center justify-center" className="w-full h-design-75 bg-no-repeat bg-center flex items-center justify-center"
> >
{/* 状态栏左侧 */}
<div <div
className={'flex-1 flex items-center justify-center gap-design-24'} className={
'relative h-full flex-1 flex items-center justify-center gap-design-50'
}
> >
<div> {/*<div className={'flex-1 absolute z-10 -right-20 -top-6 w-full !h-design-105'} style={{*/}
{t('gameDesktop.status.odds')}: {oddsLabel} {/* backgroundImage: `url(${streakBg})`,*/}
{/* backgroundSize: '100% 110%',*/}
{/* backgroundRepeat: 'no-repeat',*/}
{/*}} >*/}
{/*</div>*/}
<div className={'text-[#CBD3D5] font-bold'}>
{t('gameDesktop.status.odds')}:{' '}
<span className={'text-[#E3D171]'}>{oddsLabel}</span>
</div> </div>
<div> <div
{t('gameDesktop.status.streak')}: {streakLabel} className={
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
}
>
<SmartImage
className={'w-design-37 h-design-47'}
alt={'fire'}
src={fire}
/>
<div>
{t('gameDesktop.status.streak')}:{' '}
<span
className={
'bg-gradient-to-b from-[#EBA661] to-[#FCC785] bg-clip-text text-transparent'
}
>
{streakLabel}
</span>
</div>
</div> </div>
<div>
{t('gameDesktop.status.limit')}: {limitLabel} <div
className={
'flex items-center gap-design-5 text-[#CBD3D5] font-bold'
}
>
<SmartImage
className={'w-design-25 h-design-33'}
alt={'lock'}
src={lock}
/>
<div className={'flex items-center gap-design-10'}>
<div>{t('gameDesktop.status.limit')}:</div>
<div className={'flex items-center gap-design-5'}>
<SmartImage
className={'w-design-35 h-design-35'}
alt={'diamond'}
src={diamond}
/>
<div>{limitLabel}</div>
</div>
</div>
</div> </div>
</div> </div>
<div className="relative flex h-[105px] w-design-360 items-center justify-center">
<div className="relative z-20 flex h-[105px] w-design-360 items-center justify-center">
<SmartBackground <SmartBackground
src={statusCenter} src={statusCenter}
className="pointer-events-none absolute inset-0 z-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out" className="pointer-events-none absolute inset-0 z-0 bg-no-repeat bg-center bg-contain transition-opacity duration-500 ease-out"
@@ -100,7 +154,7 @@ export function DesktopStatusLine() {
</SmartBackground> </SmartBackground>
<div <div
className={ className={
'absolute top-design-60 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16' 'absolute top-design-75 left-1/2 -translate-x-1/2 -z-10 w-full px-design-16'
} }
> >
<DesktopTitle /> <DesktopTitle />

View File

@@ -1,12 +1,19 @@
import { Megaphone } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import broadcast from '@/assets/system/broadcast.webp'
import { SmartImage } from '@/components/smart-image.tsx'
export function DesktopTitle() { export function DesktopTitle() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<section className="common-neon-inset text-design-16 w-full flex h-design-50 items-end gap-design-10 !px-design-20 text-[#FF970F]"> <section className="common-neon-inset text-design-16 w-full flex h-design-65 items-center gap-design-10 !px-design-20 ">
<Megaphone color={'#57B8BF'} /> <SmartImage
<div>{t('gameDesktop.title.announcement')}</div> className={'w-design-24 h-design-24'}
alt={'broadcast'}
src={broadcast}
/>
<div className={'!text-[#FF970F]'}>
{t('gameDesktop.title.announcement')}
</div>
</section> </section>
) )
} }

View File

@@ -1,4 +1,4 @@
export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx' export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts' export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header' export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal' export { EntryNoticeGateModal } from '@/features/game/components/shared/entry-notice-gate-modal'

View File

@@ -0,0 +1,235 @@
import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { RotateCw } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
import checkIcon from '@/assets/system/right.webp'
import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx'
import {
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
} from '@/constants'
import { getNoticeList } from '@/features/game/api'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
function getLastConfirmedAt(storageKey: string) {
if (typeof localStorage === 'undefined') {
return null
}
const value = Number(localStorage.getItem(storageKey))
return Number.isFinite(value) && value > 0 ? value : null
}
function setLastConfirmedAt(storageKey: string, timestamp: number) {
if (typeof localStorage === 'undefined') {
return
}
localStorage.setItem(storageKey, String(timestamp))
}
export function EntryNoticeGateModal() {
const { t } = useTranslation()
const authStatus = useAuthStore((state) => state.status)
const authIsHydrated = useAuthStore((state) => state.isHydrated)
const accessToken = useAuthStore((state) => state.accessToken)
const currentUserId = useAuthStore((state) => state.currentUser?.id)
const [hasEntered, setHasEntered] = useState(false)
const [hasAgreed, setHasAgreed] = useState(false)
const [shouldGateEntry, setShouldGateEntry] = useState(false)
const hasStoredLoginInfo =
authStatus === 'authenticated' && Boolean(accessToken)
const confirmedAtStorageKey = `${ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY}:${
currentUserId ?? 'authenticated'
}`
useEffect(() => {
if (!authIsHydrated) {
return
}
setHasEntered(false)
setHasAgreed(false)
if (!hasStoredLoginInfo) {
setShouldGateEntry(true)
return
}
const lastConfirmedAt = getLastConfirmedAt(confirmedAtStorageKey)
setShouldGateEntry(
!lastConfirmedAt ||
Date.now() - lastConfirmedAt >= ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
)
}, [authIsHydrated, confirmedAtStorageKey, hasStoredLoginInfo])
const noticeListQuery = useQuery({
queryKey: ['game', 'entry-notice-list'],
queryFn: () => getNoticeList({ page: 1, pageSize: 20 }),
enabled: authIsHydrated && shouldGateEntry,
staleTime: 0,
})
const popoutNotices = useMemo(
() =>
(noticeListQuery.data?.list ?? []).filter(
(notice) => notice.notice_type === 'popout',
),
[noticeListQuery.data],
)
const shouldShowModal =
authIsHydrated &&
shouldGateEntry &&
!hasEntered &&
(noticeListQuery.isPending ||
noticeListQuery.isError ||
popoutNotices.length > 0)
const canEnter =
hasAgreed && !noticeListQuery.isPending && popoutNotices.length > 0
if (!shouldShowModal) {
return null
}
return (
<CenterModal
open={shouldShowModal}
isShowClose={false}
isNormalBg={true}
title={
<div className="modal-title-glow text-design-26">
{t('game.modals.entryNotice.title')}
</div>
}
titleAlign="left"
className="h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]"
>
<div className="flex h-full w-full flex-col gap-design-20 px-design-14 pb-design-30 pt-design-8">
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 p-design-18 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]">
{noticeListQuery.isPending ? (
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] items-center justify-center text-design-22 text-[#9CE8F2]">
{t('game.modals.entryNotice.loading')}
</div>
) : noticeListQuery.isError ? (
<div className="flex h-full min-h-[calc(var(--design-unit)*320)] flex-col items-center justify-center gap-design-18 text-center text-[#9CE8F2]">
<div className="text-design-22">
{t('game.modals.entryNotice.loadFailed')}
</div>
<button
type="button"
onClick={() => {
void noticeListQuery.refetch()
}}
className="inline-flex items-center gap-design-8 rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] px-design-18 py-design-10 text-design-18 text-[#D7FFFF] transition hover:bg-[#0E576D]"
>
<RotateCw className="h-design-18 w-design-18" />
{t('game.modals.entryNotice.retry')}
</button>
</div>
) : (
<div className="flex flex-col gap-design-16">
{popoutNotices.map((notice, index) => (
<article
key={notice.notice_id}
className="rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)] p-design-20"
>
<div className="mb-design-12 flex flex-wrap items-center justify-between gap-design-12">
<div className="min-w-0 flex-1 text-design-24 font-semibold leading-tight text-white">
{index + 1}. {notice.title}
</div>
<div className="rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-12 py-design-5 text-design-15 text-[#9CE8F2]">
{dayjs(notice.publish_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)}
</div>
</div>
<div className="whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]">
{notice.content ?? ''}
</div>
</article>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center justify-center gap-design-28">
<label className="inline-flex cursor-pointer items-center gap-design-12 text-design-20 text-[#C4F2F7]">
<input
type="checkbox"
checked={hasAgreed}
disabled={noticeListQuery.isPending || noticeListQuery.isError}
onChange={(event) => setHasAgreed(event.target.checked)}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={cn(
'flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border transition',
hasAgreed
? 'border-[#4AFF49]/80 bg-[#071F11]'
: 'border-[#6CCDCF]/70 bg-[#031D25]',
)}
>
{hasAgreed ? (
<SmartImage
src={checkIcon}
alt=""
priority={true}
showSkeleton={false}
className="h-design-34 w-design-38 overflow-visible"
imgClassName="object-contain"
/>
) : null}
</span>
<span>{t('game.modals.entryNotice.agreement')}</span>
</label>
<SmartBackground
as="button"
type="button"
src={lengthGreenBtn}
size="106% 108%"
repeat="no-repeat"
position="center"
disabled={!canEnter}
onClick={() => {
if (canEnter) {
if (hasStoredLoginInfo) {
setLastConfirmedAt(confirmedAtStorageKey, Date.now())
}
setHasEntered(true)
}
}}
className={cn(
'flex h-design-72 w-design-270 items-center justify-center rounded-md pb-design-5 text-design-22 font-bold transition',
canEnter
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
: 'cursor-not-allowed text-white opacity-80 grayscale',
)}
style={
canEnter
? undefined
: {
filter: 'grayscale(100%)',
WebkitFilter: 'grayscale(100%)',
}
}
>
{t('game.modals.entryNotice.enterGame')}
</SmartBackground>
</div>
</div>
</CenterModal>
)
}

View File

@@ -1,114 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
type GameTone = 'neutral' | 'brand' | 'success' | 'warning' | 'danger'
interface GameOverlayAction {
label: string
onClick: () => void
tone?: GameTone
}
interface GameAnnouncementModalProps {
open: boolean
title: string
description?: ReactNode
eyebrow?: string
tone?: GameTone
primaryAction?: GameOverlayAction
secondaryAction?: GameOverlayAction
children?: ReactNode
}
const toneClasses: Record<GameTone, string> = {
neutral: 'border-white/10 bg-slate-950/92',
brand: 'border-cyan-300/25 bg-slate-950/94',
success: 'border-emerald-300/25 bg-slate-950/94',
warning: 'border-amber-300/25 bg-slate-950/94',
danger: 'border-rose-300/25 bg-slate-950/94',
}
const actionToneClasses: Record<GameTone, string> = {
neutral: 'border-white/10 bg-white/[0.06] text-white hover:bg-white/[0.1]',
brand: 'border-cyan-300/25 bg-cyan-300/14 text-cyan-50 hover:bg-cyan-300/22',
success:
'border-emerald-300/25 bg-emerald-300/14 text-emerald-50 hover:bg-emerald-300/22',
warning:
'border-amber-300/25 bg-amber-300/14 text-amber-50 hover:bg-amber-300/22',
danger: 'border-rose-300/25 bg-rose-300/14 text-rose-50 hover:bg-rose-300/22',
}
function ModalAction({ label, onClick, tone = 'brand' }: GameOverlayAction) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex min-h-12 items-center justify-center rounded-full border px-5 text-sm font-semibold tracking-[0.18em] uppercase transition duration-200',
actionToneClasses[tone],
)}
>
{label}
</button>
)
}
export function GameAnnouncementModal({
open,
title,
description,
eyebrow = '',
tone = 'brand',
primaryAction,
secondaryAction,
children,
}: GameAnnouncementModalProps) {
if (!open) {
return null
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/82 px-4 py-8 backdrop-blur-md">
<div
role="dialog"
aria-modal="true"
aria-labelledby="game-announcement-title"
className={cn(
'w-full max-w-xl rounded-[32px] border p-6 shadow-[0_40px_120px_-40px_rgba(15,23,42,0.95)] sm:p-7',
toneClasses[tone],
)}
>
<div className="space-y-4">
<div className="space-y-2">
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[0.68rem] font-semibold tracking-[0.24em] text-slate-300 uppercase">
{eyebrow}
</span>
<h2
id="game-announcement-title"
className="text-2xl font-semibold tracking-tight text-white sm:text-[2rem]"
>
{title}
</h2>
{description ? (
<div className="text-sm leading-7 text-slate-300">
{description}
</div>
) : null}
</div>
{children ? (
<div className="rounded-[24px] border border-white/8 bg-white/[0.03] p-4">
{children}
</div>
) : null}
{secondaryAction || primaryAction ? (
<div className="flex flex-wrap gap-3 pt-2">
{secondaryAction ? <ModalAction {...secondaryAction} /> : null}
{primaryAction ? <ModalAction {...primaryAction} /> : null}
</div>
) : null}
</div>
</div>
</div>
)
}

View File

@@ -1,8 +1,8 @@
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getGameLobbyInit, getVisibleAnnouncements } from '@/features/game' import { getGameLobbyInit } from '@/features/game'
import { GameAnnouncementModal } from '@/features/game/components' import { EntryNoticeGateModal } from '@/features/game/components'
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx' import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
import { PcEntry } from '@/features/game/entry/pc-entry.tsx' import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts' import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
@@ -11,21 +11,12 @@ import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game' import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const ENABLE_ANNOUNCEMENT_MODAL = false
export function EntryPage() { export function EntryPage() {
const { t } = useTranslation() const { t } = useTranslation()
useGameRealtimeSync() useGameRealtimeSync()
const announcements = useGameSessionStore((state) => state.announcements)
const dismissAnnouncement = useGameSessionStore(
(state) => state.dismissAnnouncement,
)
const hydrateRound = useGameRoundStore((state) => state.hydrateRound) const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
const selectChip = useGameRoundStore((state) => state.selectChip) const selectChip = useGameRoundStore((state) => state.selectChip)
const hydrateSession = useGameSessionStore((state) => state.hydrateSession) const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
const markAnnouncementRead = useGameSessionStore(
(state) => state.markAnnouncementRead,
)
const syncConnection = useGameSessionStore((state) => state.syncConnection) const syncConnection = useGameSessionStore((state) => state.syncConnection)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser) const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const authStatus = useAuthStore((state) => state.status) const authStatus = useAuthStore((state) => state.status)
@@ -39,16 +30,6 @@ export function EntryPage() {
return window.matchMedia('(max-width: 768px)').matches return window.matchMedia('(max-width: 768px)').matches
}) })
const activeAnnouncement = useMemo(
() =>
announcements.items.find(
(item) => item.id === announcements.activeAnnouncementId,
) ??
getVisibleAnnouncements(announcements)[0] ??
null,
[announcements],
)
useDocumentMetadata({ useDocumentMetadata({
title: t('game.metaTitle'), title: t('game.metaTitle'),
description: t('game.metaDescription'), description: t('game.metaDescription'),
@@ -167,41 +148,7 @@ export function EntryPage() {
> >
{isMobile ? <MobileEntry /> : <PcEntry />} {isMobile ? <MobileEntry /> : <PcEntry />}
<GameAnnouncementModal <EntryNoticeGateModal />
open={ENABLE_ANNOUNCEMENT_MODAL && Boolean(activeAnnouncement)}
eyebrow={t('game.modal.eyebrow')}
title={activeAnnouncement?.title ?? ''}
description={activeAnnouncement?.message}
primaryAction={{
label: t('game.modal.acknowledge'),
onClick: () => {
if (!activeAnnouncement) {
return
}
markAnnouncementRead(activeAnnouncement.id)
dismissAnnouncement(activeAnnouncement.id)
},
tone: 'brand',
}}
secondaryAction={{
label: t('game.modal.later'),
onClick: () => {
if (!activeAnnouncement) {
return
}
dismissAnnouncement(activeAnnouncement.id)
},
tone: 'neutral',
}}
tone="warning"
>
<div className="space-y-2 text-sm text-slate-300">
<p>{t('game.modal.line1')}</p>
<p>{t('game.modal.line2')}</p>
</div>
</GameAnnouncementModal>
</section> </section>
) )
} }

View File

@@ -1,4 +1,10 @@
import { DesktopHeader } from '@/features/game/components' import { useEffect, useMemo } from 'react'
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
import {
DesktopHeader,
FullscreenLottieOverlay,
type FullscreenLottieSource,
} from '@/features/game/components'
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx' import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx' import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx' import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
@@ -12,6 +18,77 @@ import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-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 DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx' import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
import { useGameRoundStore } from '@/store/game'
const smallRewardPath = new URL(
'../../../assets/lottie/pc-small-reward.json',
import.meta.url,
).href
const bigRewardPath = new URL(
'../../../assets/lottie/pc-big-reward.json',
import.meta.url,
).href
function DesktopRewardOverlay() {
const rewardType = useGameRoundStore(
(state) => state.revealAnimation.rewardType,
)
const revealKey = useGameRoundStore(
(state) => state.revealAnimation.revealKey,
)
const roundId = useGameRoundStore((state) => state.revealAnimation.roundId)
const clearRewardAnimation = useGameRoundStore(
(state) => state.clearRewardAnimation,
)
const source = useMemo<FullscreenLottieSource | null>(() => {
if (rewardType === 'small') {
return {
id: 'pc-small-reward',
path: smallRewardPath,
loop: false,
autoplay: true,
}
}
if (rewardType === 'big') {
return {
id: 'pc-big-reward',
path: bigRewardPath,
loop: false,
autoplay: true,
}
}
return null
}, [rewardType])
useEffect(() => {
if (rewardType === 'none') {
return
}
const timerId = window.setTimeout(() => {
clearRewardAnimation()
}, REWARD_OVERLAY_DURATION_MS)
return () => {
window.clearTimeout(timerId)
}
}, [clearRewardAnimation, rewardType])
return (
<FullscreenLottieOverlay
open={rewardType !== 'none'}
source={source}
animationKey={`${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`}
zIndex={120}
loop={false}
autoplay
backdropClassName="bg-black/70"
viewportClassName="px-0 py-0"
/>
)
}
export function PcEntry() { export function PcEntry() {
return ( return (
@@ -19,13 +96,15 @@ export function PcEntry() {
<DesktopHeader /> <DesktopHeader />
<div <div
className={ className={
'mx-auto mt-design-30 mb-design-60 w-[calc(100%-40*var(--design-unit))]' 'mx-auto mt-design-20 mb-design-75 w-[calc(100%-40*var(--design-unit))]'
} }
> >
<DesktopStatusLine /> <DesktopStatusLine />
</div> </div>
<div className={'mx-auto w-[calc(100%-72*var(--design-unit))]'}> <div
className={'mx-auto w-[calc(100%-72*var(--design-unit))] mb-design-5'}
>
<div className={'flex w-full items-start gap-design-10'}> <div className={'flex w-full items-start gap-design-10'}>
<div className={'flex-1'}> <div className={'flex-1'}>
<DesktopAnimal /> <DesktopAnimal />
@@ -63,6 +142,7 @@ export function PcEntry() {
<DesktopProceduresModal /> <DesktopProceduresModal />
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */} {/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
<DesktopWithdrawTopupModal /> <DesktopWithdrawTopupModal />
<DesktopRewardOverlay />
</> </>
) )
} }

View File

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

View File

@@ -1,11 +1,11 @@
import { useLocation } from '@tanstack/react-router' import { useLocation } from '@tanstack/react-router'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LANGUAGE_OPTIONS } from '@/constants' import { LANGUAGE_OPTIONS, SUPPORTED_LANGUAGES } from '@/constants'
import { type AppLanguage, supportedLanguages } from '@/i18n' import type { AppLanguage } from '@/i18n'
const languagePrefixPattern = new RegExp( const languagePrefixPattern = new RegExp(
`^/(${supportedLanguages.join('|')})(?=/|$)`, `^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
) )
function resolveNextPathname(pathname: string, language: AppLanguage) { 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 hasSelections = selections.length > 0
const hasEnteredGame = const hasEnteredGame =
shouldConnectRealtime && connectionStatus === 'connected' shouldConnectRealtime && connectionStatus === 'connected'
const hasSubmittedCurrentRound =
Boolean(round.id) && currentUser?.lastBetPeriodNo === round.id
const hasInsufficientBalance = hasSelections && totalBetAmount > balance const hasInsufficientBalance = hasSelections && totalBetAmount > balance
const confirmState: ConfirmState = isSubmitting const confirmState: ConfirmState = isSubmitting
? 'submitting' ? 'submitting'
@@ -141,6 +143,11 @@ export function useGameControlVm() {
return return
} }
if (hasSubmittedCurrentRound) {
notify.warning(t('commonUi.toast.betUnavailable'))
return
}
const groupedSelections = selections.reduce< const groupedSelections = selections.reduce<
Map<string, { betId: number; numbers: number[] }> Map<string, { betId: number; numbers: number[] }>
>((accumulator, selection) => { >((accumulator, selection) => {
@@ -219,6 +226,7 @@ export function useGameControlVm() {
currentUser, currentUser,
hasInsufficientBalance, hasInsufficientBalance,
hasSelections, hasSelections,
hasSubmittedCurrentRound,
round.id, round.id,
round.phase, round.phase,
selections, selections,
@@ -229,7 +237,7 @@ export function useGameControlVm() {
]) ])
const handleRepeatSelections = useCallback(() => { const handleRepeatSelections = useCallback(() => {
if (round.phase !== 'betting') { if (round.phase !== 'betting' || hasSubmittedCurrentRound) {
notify.warning(t('commonUi.toast.betUnavailable')) notify.warning(t('commonUi.toast.betUnavailable'))
return return
} }
@@ -242,16 +250,21 @@ export function useGameControlVm() {
} }
notify.success(t('commonUi.toast.repeatSelectionsRestored')) notify.success(t('commonUi.toast.repeatSelectionsRestored'))
}, [restoreRecentSuccessfulSelections, round.phase, t]) }, [
hasSubmittedCurrentRound,
restoreRecentSuccessfulSelections,
round.phase,
t,
])
const handleOpenAutoSetting = useCallback(() => { const handleOpenAutoSetting = useCallback(() => {
setModalOpen('desktopAutoSetting', true) setModalOpen('desktopAutoSetting', true)
}, [setModalOpen]) }, [setModalOpen])
return { return {
acceptingBets: round.phase === 'betting', acceptingBets: round.phase === 'betting' && !hasSubmittedCurrentRound,
actionsEnabled: hasEnteredGame, actionsEnabled: hasEnteredGame && !hasSubmittedCurrentRound,
canClear: selections.length > 0, canClear: selections.length > 0 && !hasSubmittedCurrentRound,
confirmLabel: confirmLabel:
confirmState === 'idle' confirmState === 'idle'
? t('gameDesktop.control.selectNumbers') ? t('gameDesktop.control.selectNumbers')

View File

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

View File

@@ -1,4 +1,11 @@
import { useEffect, useRef } from 'react' 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 i18n from '@/i18n'
import { prefetchAuthToken } from '@/lib/api/api-client' import { prefetchAuthToken } from '@/lib/api/api-client'
import { import {
@@ -16,51 +23,11 @@ type UserStreakMessageData = {
streakLevel?: number streakLevel?: number
} }
const FALLBACK_POLL_INTERVAL_MS = 10_000 type PeriodEventData = {
const GAME_SOCKET_TOPICS = { openTime: number | null
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。 periodNo: string
periodTick: 'period.tick', resultNumber: number | null
// 本期封盘通知。用于前端立即停止下注。 }
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
let sharedSocketClient: GameSocketClient | null = null let sharedSocketClient: GameSocketClient | null = null
let sharedSocketKey: string | 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) { function extractWalletCoin(message: GameSocketMessage) {
const data = getNestedRecord(message, 'data') const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>) const source = data ?? (message as Record<string, unknown>)
@@ -288,6 +286,90 @@ function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') {
useGameRoundStore.getState().setPhase(phase) 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) { function applyUserStreakMessage(message: GameSocketMessage) {
const streakData = extractUserStreakMessageData(message) const streakData = extractUserStreakMessageData(message)
const currentUser = useAuthStore.getState().currentUser const currentUser = useAuthStore.getState().currentUser
@@ -320,6 +402,8 @@ function applyWalletChangedMessage(message: GameSocketMessage) {
function applyJackpotHitMessage(message: GameSocketMessage) { function applyJackpotHitMessage(message: GameSocketMessage) {
const currentUser = useAuthStore.getState().currentUser const currentUser = useAuthStore.getState().currentUser
const period = extractPeriodEventData(message)
const isJackpot = extractJackpotStatus(message)
if (!currentUser) { if (!currentUser) {
return return
@@ -327,8 +411,12 @@ function applyJackpotHitMessage(message: GameSocketMessage) {
useAuthStore.getState().setCurrentUser({ useAuthStore.getState().setCurrentUser({
...currentUser, ...currentUser,
isJackpot: extractJackpotStatus(message), isJackpot,
}) })
if (isJackpot) {
useGameRoundStore.getState().showJackpotReward(period?.periodNo ?? null)
}
} }
function applyRealtimeMessage(message: GameSocketMessage) { function applyRealtimeMessage(message: GameSocketMessage) {
@@ -336,20 +424,41 @@ function applyRealtimeMessage(message: GameSocketMessage) {
const topic = getMessageTopic(message) const topic = getMessageTopic(message)
switch (topic) { 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) 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 break
}
case GAME_SOCKET_TOPICS.periodLocked: case GAME_SOCKET_TOPICS.periodLocked:
applyPeriodMessage(message, serverTime) applyPeriodLockedMessage(message, serverTime)
applyPeriodPhase('locked')
break break
case GAME_SOCKET_TOPICS.periodOpened: case GAME_SOCKET_TOPICS.periodOpened:
applyPeriodMessage(message, serverTime) applyPeriodOpenedMessage(message, serverTime)
applyPeriodPhase('revealing')
break break
case GAME_SOCKET_TOPICS.periodPayout: case GAME_SOCKET_TOPICS.periodPayout:
applyPeriodMessage(message, serverTime) applyPeriodPayoutMessage(message, serverTime)
applyPeriodPhase('settled')
break break
case GAME_SOCKET_TOPICS.userStreak: case GAME_SOCKET_TOPICS.userStreak:
applyUserStreakMessage(message) applyUserStreakMessage(message)

View File

@@ -1,36 +1,9 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { PHASE_META } from '@/constants'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game' 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() { export function useGameStatusVm() {
const { t } = useTranslation() const { t } = useTranslation()
const cells = useGameRoundStore((state) => state.cells) const cells = useGameRoundStore((state) => state.cells)
@@ -62,6 +35,7 @@ export function useGameStatusVm() {
phaseToneClassName: phaseMeta.toneClassName, phaseToneClassName: phaseMeta.toneClassName,
roundId: round.id || '--', roundId: round.id || '--',
streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--', streakLabel: typeof streakValue === 'number' ? `X${streakValue}` : '--',
streakValue,
} }
}, [cells, currentUser, dashboard, round, t, trends]) }, [cells, currentUser, dashboard, round, t, trends])
} }

View File

@@ -1,43 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DEFAULT_WITHDRAW_CONFIG, QUICK_FIAT_AMOUNTS } from '@/constants'
import type { DepositWithdrawConfig } from '@/features/game/api' import type { DepositWithdrawConfig } from '@/features/game/api'
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config' import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
import { useAuthStore } from '@/store' 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) { function formatNumber(locale: string, value: number) {
return new Intl.NumberFormat(locale).format(value) return new Intl.NumberFormat(locale).format(value)
} }

View File

@@ -0,0 +1,160 @@
import { AnimatePresence, motion } from 'motion/react'
import { useFinanceRecordsVm } from '@/features/game/hooks/use-finance-records-vm'
import { cn } from '@/lib/utils'
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
const vm = useFinanceRecordsVm({ enabled })
return (
<div className={'flex h-full w-full flex-col p-design-10'}>
<div
className={
'mb-design-12 flex items-center justify-between gap-design-16 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-14 py-design-12'
}
>
<div
className={
'relative grid grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-4'
}
>
{vm.recordTypes.map((recordType) => {
const isActive = recordType.key === vm.recordType
return (
<button
key={recordType.key}
type="button"
aria-pressed={isActive}
onClick={() => vm.selectRecordType(recordType.key)}
className={cn(
'relative h-design-44 min-w-design-130 cursor-pointer rounded-md px-design-16 text-design-18 transition-colors duration-200',
isActive
? 'text-white'
: 'text-[#6CCDCF] hover:bg-[#0A4252] hover:text-white',
)}
>
{isActive ? (
<motion.span
layoutId="finance-record-type-active"
className={
'absolute inset-0 rounded-md bg-[linear-gradient(180deg,#3DA5BD,#166477)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(62,175,199,0.26)]'
}
transition={{
type: 'spring',
stiffness: 420,
damping: 34,
}}
/>
) : null}
<span className={'relative z-10'}>{recordType.label}</span>
</button>
)
})}
</div>
<div className={'text-design-16 text-[#7ECAD1]'}>{vm.pageLabel}</div>
</div>
<div className={'min-h-0 flex-1 overflow-auto rounded-md'}>
<div
className={
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-10 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-16 py-design-12 text-design-16 text-[#7ECAD1]'
}
>
<div>{vm.headers.orderNo}</div>
<div>{vm.headers.amount}</div>
<div>{vm.headers.bonusAmount}</div>
</div>
<div
className={
'mt-design-10 flex min-h-[calc(var(--design-unit)*320)] flex-col gap-design-10'
}
>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={vm.recordType}
className={'flex flex-col gap-design-10'}
initial={{
opacity: 0,
x: vm.recordType === 'deposit' ? -18 : 18,
}}
animate={{ opacity: 1, x: 0 }}
exit={{
opacity: 0,
x: vm.recordType === 'deposit' ? 18 : -18,
}}
transition={{ duration: 0.18, ease: 'easeOut' }}
>
{vm.isLoading ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.loadingText}
</div>
) : vm.isError ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.loadFailedText}
</div>
) : vm.items.length === 0 ? (
<div className={'py-design-30 text-center text-[#6CCDCF]'}>
{vm.emptyText}
</div>
) : (
vm.items.map((item, index) => (
<motion.div
key={item.id}
className={
'grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-10 rounded-md bg-[#0A4252] px-design-16 py-design-14 text-design-18 text-[#C4F2F7] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(108,205,207,0.05)]'
}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
delay: Math.min(index, 6) * 0.025,
duration: 0.16,
ease: 'easeOut',
}}
>
<div className={'truncate font-medium text-white'}>
{item.orderNoLabel}
</div>
<div className={'text-[#FEEEB0]'}>{item.amountLabel}</div>
<div className={'text-[#7CFFCF]'}>
{item.bonusAmountLabel}
</div>
</motion.div>
))
)}
</motion.div>
</AnimatePresence>
</div>
</div>
<div
className={'mt-design-12 flex items-center justify-end gap-design-10'}
>
<button
type="button"
disabled={!vm.canGoPreviousPage || vm.isFetching}
onClick={vm.goPreviousPage}
className={
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
}
>
{vm.previousText}
</button>
<button
type="button"
disabled={!vm.canGoNextPage || vm.isFetching}
onClick={vm.goNextPage}
className={
'h-design-40 cursor-pointer rounded-md border border-[#3EAFC7]/35 bg-[#062E39] px-design-16 text-design-16 text-[#86DAE7] transition hover:bg-[#0A4252] disabled:cursor-not-allowed disabled:opacity-45'
}
>
{vm.nextText}
</button>
</div>
</div>
)
}
export default DesktopFinanceRecordsTab

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ArrowLeft, CircleUserRound, Mail } from 'lucide-react' import { ArrowLeft, CircleUserRound, Mail, ReceiptText } from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import avatar from '@/assets/system/avatar.webp' import avatar from '@/assets/system/avatar.webp'
@@ -10,10 +11,11 @@ import { CenterModal } from '@/components/center-modal.tsx'
import { SmartBackground } from '@/components/smart-background.tsx' import { SmartBackground } from '@/components/smart-background.tsx'
import { SmartImage } from '@/components/smart-image.tsx' import { SmartImage } from '@/components/smart-image.tsx'
import { getNoticeDetail, getNoticeList } from '@/features/game/api' import { getNoticeDetail, getNoticeList } from '@/features/game/api'
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store' import { useAuthStore, useModalStore } from '@/store'
type UserInfoTabKey = 'profile' | 'message' type UserInfoTabKey = 'financeRecords' | 'message' | 'profile'
type MessageViewState = 'list' | 'detail' type MessageViewState = 'list' | 'detail'
const USER_INFO_TABS: Array<{ const USER_INFO_TABS: Array<{
@@ -26,6 +28,11 @@ const USER_INFO_TABS: Array<{
labelKey: 'game.modals.userInfo.tabs.profile', labelKey: 'game.modals.userInfo.tabs.profile',
icon: CircleUserRound, icon: CircleUserRound,
}, },
{
key: 'financeRecords',
labelKey: 'game.modals.userInfo.tabs.financeRecords',
icon: ReceiptText,
},
{ {
key: 'message', key: 'message',
labelKey: 'game.modals.userInfo.tabs.message', labelKey: 'game.modals.userInfo.tabs.message',
@@ -118,40 +125,64 @@ function DesktopUserInfoModal() {
type="button" type="button"
onClick={() => setActiveTab(tab.key)} onClick={() => setActiveTab(tab.key)}
className={cn( className={cn(
'relative flex h-design-150 w-full flex-col items-center justify-center gap-design-10 overflow-hidden transition', 'relative flex h-design-150 w-full cursor-pointer flex-col items-center justify-center gap-design-8 overflow-hidden px-design-10 transition-colors duration-200',
isActive isActive
? 'text-[#FEEEB0]' ? 'text-[#FEEEB0]'
: 'text-[#58ADAF] hover:text-[#BFEAEC]', : 'text-[#58ADAF] hover:text-[#BFEAEC]',
)} )}
> >
{isActive ? ( {isActive ? (
<span <motion.span
layoutId="user-info-tab-active-bg"
aria-hidden="true" aria-hidden="true"
className="absolute inset-y-0 right-0 w-full bg-[linear-gradient(to_left,rgba(254,238,176,0.46)_0%,rgba(254,238,176,0.28)_42%,rgba(254,238,176,0.12)_68%,rgba(254,238,176,0)_100%)]" className="absolute inset-y-0 right-0 w-full bg-[linear-gradient(to_left,rgba(254,238,176,0.46)_0%,rgba(254,238,176,0.28)_42%,rgba(254,238,176,0.12)_68%,rgba(254,238,176,0)_100%)]"
transition={{
type: 'spring',
stiffness: 430,
damping: 36,
}}
/> />
) : null} ) : null}
{isActive ? ( {isActive ? (
<span <motion.span
layoutId="user-info-tab-active-indicator"
aria-hidden="true" aria-hidden="true"
className="absolute right-0 top-1/2 h-[72%] w-[calc(var(--design-unit)*3)] -translate-y-1/2 rounded-l-full bg-[linear-gradient(180deg,rgba(255,248,214,0.96)_0%,rgba(254,238,176,0.92)_48%,rgba(232,188,112,0.88)_100%)] shadow-[-2px_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]" className="absolute right-0 top-1/2 h-[72%] w-[calc(var(--design-unit)*3)] -translate-y-1/2 rounded-l-full bg-[linear-gradient(180deg,rgba(255,248,214,0.96)_0%,rgba(254,238,176,0.92)_48%,rgba(232,188,112,0.88)_100%)] shadow-[-2px_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]"
transition={{
type: 'spring',
stiffness: 430,
damping: 36,
}}
/> />
) : null} ) : null}
<Icon <motion.div
className={cn( className={cn(
'relative z-10 transition h-design-50 w-design-50', 'relative z-10 transition',
isActive && isActive &&
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]', 'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
)} )}
/> animate={{
<div scale: isActive ? 1.06 : 1,
y: isActive ? -2 : 0,
}}
transition={{ duration: 0.18, ease: 'easeOut' }}
>
<Icon className={'h-design-40 w-design-40'} />
</motion.div>
<motion.div
className={cn( className={cn(
'relative z-10 text-design-24', 'relative z-10 text-center text-design-20 leading-tight',
isActive && 'modal-title-gold-glow', isActive && 'modal-title-gold-glow',
)} )}
animate={{
scale: isActive ? 1.04 : 1,
y: isActive ? -1 : 0,
}}
transition={{ duration: 0.18, ease: 'easeOut' }}
> >
{t(tab.labelKey)} {t(tab.labelKey)}
</div> </motion.div>
</button> </button>
) )
})} })}
@@ -210,6 +241,10 @@ function DesktopUserInfoModal() {
</div> </div>
</div> </div>
</SmartBackground> </SmartBackground>
) : activeTab === 'financeRecords' ? (
<DesktopFinanceRecordsTab
enabled={open && activeTab === 'financeRecords'}
/>
) : ( ) : (
<div className={'flex h-full w-full flex-col'}> <div className={'flex h-full w-full flex-col'}>
{messageView === 'detail' ? ( {messageView === 'detail' ? (
@@ -224,7 +259,7 @@ function DesktopUserInfoModal() {
void handleReturnToList() void handleReturnToList()
}} }}
className={ className={
'flex items-center gap-design-10 text-[#86DAE7] transition hover:text-white' 'flex cursor-pointer items-center gap-design-10 text-[#86DAE7] transition hover:text-white'
} }
> >
<span <span
@@ -278,7 +313,7 @@ function DesktopUserInfoModal() {
setMessageView('detail') setMessageView('detail')
}} }}
className={ className={
'flex items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]' 'flex cursor-pointer items-center gap-design-20 rounded-md bg-[#0A4252] px-design-15 py-design-15 text-left transition hover:bg-[#0E576D]'
} }
> >
<div <div

View File

@@ -1,59 +1,17 @@
export const GAME_GRID_ROWS = 6 export {
export const GAME_GRID_COLUMNS = 6 ANNOUNCEMENT_TONES,
export const GAME_TOTAL_CELLS = GAME_GRID_ROWS * GAME_GRID_COLUMNS BET_SOURCES,
CELL_STATUSES,
export const ROUND_PHASES = [ CONNECTION_STATUSES,
'waiting', CONNECTION_TRANSPORTS,
'betting', DEFAULT_ACTIVE_CHIP_ID,
'locked', DEFAULT_ANNOUNCEMENT_TTL_MS,
'revealing', DEFAULT_GAME_CHIP_COLORS,
'settled', GAME_GRID_COLUMNS,
] as const GAME_GRID_ROWS,
GAME_MAX_SELECTION_CELLS,
export const CELL_STATUSES = [ GAME_RECENT_HISTORY_LIMIT,
'idle', GAME_TOTAL_CELLS,
'betting', ROUND_PHASES,
'selected', TREND_DIRECTIONS,
'locked', } from '@/constants/game'
'won',
'lost',
] as const
export const CONNECTION_STATUSES = [
'idle',
'connecting',
'connected',
'reconnecting',
'disconnected',
] as const
export const CONNECTION_TRANSPORTS = [
'websocket',
'polling',
'offline',
] as const
export const ANNOUNCEMENT_TONES = [
'info',
'success',
'warning',
'critical',
] as const
export const BET_SOURCES = ['local', 'server'] as const
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
export const DEFAULT_GAME_CHIP_COLORS = [
'#1D4ED8',
'#0F766E',
'#B45309',
'#B91C1C',
'#7C3AED',
'#111827',
] as const
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-5'
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
export const GAME_RECENT_HISTORY_LIMIT = 12
export const GAME_MAX_SELECTION_CELLS = 5

View File

@@ -1,28 +1,26 @@
import i18n from 'i18next' import i18n from 'i18next'
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next'
import { DEFAULT_APP_LANGUAGE, SUPPORTED_LANGUAGES } from '@/constants/system'
import enUSCommon from '@/locales/en-US/common' import enUSCommon from '@/locales/en-US/common'
import idIDCommon from '@/locales/id-ID/common' import idIDCommon from '@/locales/id-ID/common'
import msMYCommon from '@/locales/ms-MY/common' import msMYCommon from '@/locales/ms-MY/common'
import zhCNCommon from '@/locales/zh-CN/common' import zhCNCommon from '@/locales/zh-CN/common'
import { getStoredAppLanguage, setStoredAppLanguage } from '@/store/auth' import { getStoredAppLanguage, setStoredAppLanguage } from '@/store/auth'
export const supportedLanguages = ['zh-CN', 'en-US', 'ms-MY', 'id-ID'] as const export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
export type AppLanguage = (typeof supportedLanguages)[number]
const defaultLanguage: AppLanguage = 'zh-CN'
/** @description 判断给定语言是否在当前应用支持列表中。 */ /** @description 判断给定语言是否在当前应用支持列表中。 */
export function isSupportedLanguage( export function isSupportedLanguage(
value: string | null | undefined, value: string | null | undefined,
): value is AppLanguage { ): value is AppLanguage {
return supportedLanguages.includes(value as AppLanguage) return SUPPORTED_LANGUAGES.includes(value as AppLanguage)
} }
/** @description 从浏览器设置中推断最匹配的语言。 */ /** @description 从浏览器设置中推断最匹配的语言。 */
function detectBrowserLanguage() { function detectBrowserLanguage() {
if (typeof navigator === 'undefined') { if (typeof navigator === 'undefined') {
return defaultLanguage return DEFAULT_APP_LANGUAGE
} }
const browserLanguages = [...navigator.languages, navigator.language] const browserLanguages = [...navigator.languages, navigator.language]
@@ -51,7 +49,7 @@ function detectBrowserLanguage() {
} }
} }
return defaultLanguage return DEFAULT_APP_LANGUAGE
} }
/** @description 获取应用启动时应使用的初始语言。 */ /** @description 获取应用启动时应使用的初始语言。 */
@@ -83,7 +81,7 @@ export function getLanguageFromPathname(pathname: string) {
void i18n.use(initReactI18next).init({ void i18n.use(initReactI18next).init({
lng: getInitialLanguage(), lng: getInitialLanguage(),
fallbackLng: defaultLanguage, fallbackLng: DEFAULT_APP_LANGUAGE,
debug: false, debug: false,
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
@@ -116,7 +114,7 @@ function syncLanguageState(language: string) {
} }
} }
syncLanguageState(i18n.resolvedLanguage ?? defaultLanguage) syncLanguageState(i18n.resolvedLanguage ?? DEFAULT_APP_LANGUAGE)
i18n.on('languageChanged', syncLanguageState) i18n.on('languageChanged', syncLanguageState)
export default i18n export default i18n

View File

@@ -1,6 +1,12 @@
import ky, { HTTPError, type Options, TimeoutError } from 'ky' import ky, { HTTPError, type Options, TimeoutError } from 'ky'
import { import {
ACCESS_TOKEN_REFRESH_SKEW_MS,
API_ERROR_MESSAGES, API_ERROR_MESSAGES,
AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY,
AUTH_REFRESH_ENDPOINT,
AUTH_SKIP_REFRESH_CONTEXT_KEY,
AUTH_TOKEN_CACHE_SKEW_MS,
AUTH_TOKEN_ENDPOINT,
DEFAULT_REQUEST_ACCEPT_HEADER, DEFAULT_REQUEST_ACCEPT_HEADER,
DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS,
} from '@/constants' } from '@/constants'
@@ -24,12 +30,6 @@ type JsonRequestOptions<TBody> = RequestOptions & {
json?: TBody json?: TBody
} }
const AUTH_REFRESH_ATTEMPTED_CONTEXT_KEY = 'authRefreshAttempted'
const AUTH_SKIP_REFRESH_CONTEXT_KEY = 'skipAuthRefresh'
const AUTH_TOKEN_ENDPOINT = 'api/v1/authToken'
const AUTH_REFRESH_ENDPOINT = 'api/user/refreshToken'
const ACCESS_TOKEN_REFRESH_SKEW_MS = 60_000
const AUTH_TOKEN_CACHE_SKEW_MS = 30_000
const appEnv = import.meta.env.VITE_APP_ENV const appEnv = import.meta.env.VITE_APP_ENV
const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim() const authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true' const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'

View File

@@ -1,8 +1,10 @@
import { create } from 'zustand' import { create } from 'zustand'
import {
DEFAULT_ALERT_DURATION_MS,
NOTIFICATION_EXIT_DURATION_MS,
} from '@/constants'
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading' type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
const DEFAULT_ALERT_DURATION_MS = 2600
export const NOTIFICATION_EXIT_DURATION_MS = 220
export interface NotifyOptions { export interface NotifyOptions {
description?: string description?: string

View File

@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from 'clsx' import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { FULLSCREEN_CHANGE_EVENTS } from '@/constants'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -20,13 +21,6 @@ type FullscreenCapableDocument = Document & {
webkitFullscreenElement?: Element | null webkitFullscreenElement?: Element | null
} }
const FULLSCREEN_CHANGE_EVENTS = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange',
] as const
export function isDesktopFullscreen() { export function isDesktopFullscreen() {
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
return false return false

View File

@@ -1,3 +1,9 @@
import {
LATENCY_PROBE_INTERVAL_MS,
LATENCY_PROBE_TIMEOUT_MS,
MAX_RECONNECT_DELAY_MS,
} from '@/constants'
type GameSocketContext = { type GameSocketContext = {
authToken: string authToken: string
deviceId: string deviceId: string
@@ -47,10 +53,6 @@ type GameSocketClientOptions = {
onStatusChange?: (status: GameSocketStatus, reconnectAttempt: number) => void onStatusChange?: (status: GameSocketStatus, reconnectAttempt: number) => void
} }
const MAX_RECONNECT_DELAY_MS = 10_000
const LATENCY_PROBE_INTERVAL_MS = 3_000
const LATENCY_PROBE_TIMEOUT_MS = 10_000
function toQueryString(context: GameSocketContext) { function toQueryString(context: GameSocketContext) {
const params = new URLSearchParams({ const params = new URLSearchParams({
token: context.token, token: context.token,

View File

@@ -103,14 +103,6 @@ export default {
autoModeDemo: 'Auto mode demo', autoModeDemo: 'Auto mode demo',
stopAuto: 'Stop auto', stopAuto: 'Stop auto',
}, },
modal: {
eyebrow: 'Announcement',
acknowledge: 'Acknowledge',
later: 'Later',
line1:
'This will later connect to the real announcement body, confirmation checkbox, and persistence flow.',
line2: 'For now it validates the shared modal structure.',
},
modals: { modals: {
login: { login: {
title: 'Login', title: 'Login',
@@ -124,6 +116,16 @@ export default {
'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.', 'This area will later load the real event announcement body, rich media, and a longer scrollable message. The current version focuses on shared multilingual modal wiring.',
check: 'View', check: 'View',
}, },
entryNotice: {
title: 'Site Notice',
subtitle:
'Please read the following notices and confirm before entering the game.',
loading: 'Loading notices...',
loadFailed: 'Failed to load notices. Please try again.',
retry: 'Reload',
agreement: 'I have read and agree.',
enterGame: 'Enter Game',
},
rules: { rules: {
title: 'Game Rules', title: 'Game Rules',
content: content:
@@ -149,6 +151,7 @@ export default {
title: 'User Info', title: 'User Info',
tabs: { tabs: {
profile: 'Profile', profile: 'Profile',
financeRecords: 'Top Up / Withdraw Records',
message: 'Messages', message: 'Messages',
}, },
profile: { profile: {
@@ -171,6 +174,19 @@ export default {
check: 'View', check: 'View',
deleteRecords: 'Delete records', deleteRecords: 'Delete records',
}, },
financeRecords: {
deposit: 'Top-up Records',
withdraw: 'Withdrawal Records',
orderNo: 'Order No.',
amount: 'Amount',
bonusAmount: 'Bonus Amount',
loading: 'Loading records...',
loadFailed: 'Failed to load records. Please try again later.',
empty: 'No records yet',
page: 'Page {{page}} / {{total}} total',
previous: 'Previous',
next: 'Next',
},
}, },
withdrawTopup: { withdrawTopup: {
applyWithdraw: 'Apply for Withdrawal', applyWithdraw: 'Apply for Withdrawal',

View File

@@ -102,14 +102,6 @@ export default {
autoModeDemo: 'Demo mode auto', autoModeDemo: 'Demo mode auto',
stopAuto: 'Stop auto', stopAuto: 'Stop auto',
}, },
modal: {
eyebrow: 'Pengumuman',
acknowledge: 'Saya paham',
later: 'Nanti',
line1:
'Ini nantinya akan terhubung ke konten pengumuman nyata, checkbox konfirmasi, dan alur penyimpanan status.',
line2: 'Untuk sekarang ini memvalidasi struktur modal bersama.',
},
modals: { modals: {
login: { login: {
title: 'Masuk', title: 'Masuk',
@@ -123,6 +115,16 @@ export default {
'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.', 'Bagian ini nantinya akan memuat konten pengumuman acara yang sebenarnya, materi visual, dan pesan panjang yang dapat digulir. Versi saat ini fokus pada sambungan modal multibahasa.',
check: 'Lihat', check: 'Lihat',
}, },
entryNotice: {
title: 'Pengumuman Situs',
subtitle:
'Silakan baca pengumuman berikut dan konfirmasi sebelum masuk ke game.',
loading: 'Memuat pengumuman...',
loadFailed: 'Gagal memuat pengumuman. Silakan coba lagi.',
retry: 'Muat Ulang',
agreement: 'Saya telah membaca dan setuju.',
enterGame: 'Masuk Game',
},
rules: { rules: {
title: 'Aturan Permainan', title: 'Aturan Permainan',
content: content:
@@ -148,6 +150,7 @@ export default {
title: 'Info Pengguna', title: 'Info Pengguna',
tabs: { tabs: {
profile: 'Profil', profile: 'Profil',
financeRecords: 'Riwayat Isi Ulang / Penarikan',
message: 'Pesan', message: 'Pesan',
}, },
profile: { profile: {
@@ -170,6 +173,19 @@ export default {
check: 'Lihat', check: 'Lihat',
deleteRecords: 'Hapus riwayat', deleteRecords: 'Hapus riwayat',
}, },
financeRecords: {
deposit: 'Riwayat Isi Ulang',
withdraw: 'Riwayat Penarikan',
orderNo: 'No. Pesanan',
amount: 'Jumlah',
bonusAmount: 'Jumlah Bonus',
loading: 'Memuat riwayat...',
loadFailed: 'Gagal memuat riwayat. Silakan coba lagi nanti.',
empty: 'Belum ada riwayat',
page: 'Halaman {{page}} / total {{total}}',
previous: 'Sebelumnya',
next: 'Berikutnya',
},
}, },
withdrawTopup: { withdrawTopup: {
applyWithdraw: 'Ajukan Penarikan', applyWithdraw: 'Ajukan Penarikan',

View File

@@ -105,14 +105,6 @@ export default {
autoModeDemo: 'Demo mod auto', autoModeDemo: 'Demo mod auto',
stopAuto: 'Henti auto', stopAuto: 'Henti auto',
}, },
modal: {
eyebrow: 'Pengumuman',
acknowledge: 'Faham',
later: 'Nanti',
line1:
'Ini akan disambungkan kepada kandungan pengumuman sebenar, kotak pengesahan, dan aliran penyimpanan status.',
line2: 'Buat masa ini, ia mengesahkan struktur modal yang dikongsi.',
},
modals: { modals: {
login: { login: {
title: 'Log Masuk', title: 'Log Masuk',
@@ -126,6 +118,16 @@ export default {
'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.', 'Bahagian ini akan memuatkan kandungan notis acara sebenar, bahan visual, dan mesej boleh skrol yang lebih panjang. Versi semasa memfokuskan sambungan modal pelbagai bahasa.',
check: 'Semak', check: 'Semak',
}, },
entryNotice: {
title: 'Notis Laman',
subtitle:
'Sila baca notis berikut dan sahkan sebelum memasuki permainan.',
loading: 'Memuatkan notis...',
loadFailed: 'Gagal memuatkan notis. Sila cuba lagi.',
retry: 'Muat semula',
agreement: 'Saya telah membaca dan bersetuju.',
enterGame: 'Masuk Permainan',
},
rules: { rules: {
title: 'Peraturan Permainan', title: 'Peraturan Permainan',
content: content:
@@ -151,6 +153,7 @@ export default {
title: 'Maklumat Pengguna', title: 'Maklumat Pengguna',
tabs: { tabs: {
profile: 'Profil', profile: 'Profil',
financeRecords: 'Rekod Tambah Nilai / Pengeluaran',
message: 'Mesej', message: 'Mesej',
}, },
profile: { profile: {
@@ -173,6 +176,19 @@ export default {
check: 'Semak', check: 'Semak',
deleteRecords: 'Padam rekod', deleteRecords: 'Padam rekod',
}, },
financeRecords: {
deposit: 'Rekod Tambah Nilai',
withdraw: 'Rekod Pengeluaran',
orderNo: 'No. Pesanan',
amount: 'Jumlah',
bonusAmount: 'Jumlah Bonus',
loading: 'Memuatkan rekod...',
loadFailed: 'Gagal memuatkan rekod. Sila cuba lagi kemudian.',
empty: 'Belum ada rekod',
page: 'Halaman {{page}} / jumlah {{total}}',
previous: 'Sebelumnya',
next: 'Seterusnya',
},
}, },
withdrawTopup: { withdrawTopup: {
applyWithdraw: 'Mohon Pengeluaran', applyWithdraw: 'Mohon Pengeluaran',

View File

@@ -101,13 +101,6 @@ export default {
autoModeDemo: '自动托管演示', autoModeDemo: '自动托管演示',
stopAuto: '停止托管', stopAuto: '停止托管',
}, },
modal: {
eyebrow: '强制公告',
acknowledge: '已读并进入',
later: '稍后查看',
line1: '这里后续会接真实公告图文、勾选确认和已读状态。',
line2: '当前先用共享弹窗骨架验证结构。',
},
modals: { modals: {
login: { login: {
title: '登录', title: '登录',
@@ -121,6 +114,15 @@ export default {
'这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。', '这里后续将接入真实的活动公告内容、图文素材和滚动阅读区域。当前版本先用多语言结构完成桌面端公告弹窗能力。',
check: '查看', check: '查看',
}, },
entryNotice: {
title: '网站公告',
subtitle: '请先阅读以下公告,勾选确认后即可进入游戏。',
loading: '公告加载中...',
loadFailed: '公告加载失败,请重试',
retry: '重新加载',
agreement: '我已阅读并同意。',
enterGame: '进入游戏',
},
rules: { rules: {
title: '玩法规则', title: '玩法规则',
content: content:
@@ -146,6 +148,7 @@ export default {
title: '用户信息', title: '用户信息',
tabs: { tabs: {
profile: '个人信息', profile: '个人信息',
financeRecords: '充值/提现记录',
message: '站内消息', message: '站内消息',
}, },
profile: { profile: {
@@ -166,6 +169,19 @@ export default {
check: '查看', check: '查看',
deleteRecords: '删除记录', deleteRecords: '删除记录',
}, },
financeRecords: {
deposit: '充值记录',
withdraw: '提现记录',
orderNo: '订单号',
amount: '金额',
bonusAmount: '赠送金额',
loading: '记录加载中...',
loadFailed: '记录加载失败,请稍后重试',
empty: '暂无记录',
page: '第 {{page}} 页 / 共 {{total}} 条',
previous: '上一页',
next: '下一页',
},
}, },
withdrawTopup: { withdrawTopup: {
applyWithdraw: '申请提现', applyWithdraw: '申请提现',

View File

@@ -31,6 +31,29 @@ type GameRoundSlice = Pick<
| 'trends' | 'trends'
> >
export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
export type RewardAnimationType = 'none' | 'small' | 'big'
export interface RevealAnimationState {
pendingRewardType: RewardAnimationType
phase: RevealAnimationPhase
revealKey: string | null
rewardType: RewardAnimationType
roundId: string | null
winningCellId: number | null
}
function createIdleRevealAnimation(): RevealAnimationState {
return {
pendingRewardType: 'none',
phase: 'idle',
revealKey: null,
rewardType: 'none',
roundId: null,
winningCellId: null,
}
}
function resolveRecentActiveChipId( function resolveRecentActiveChipId(
chips: Chip[], chips: Chip[],
selections: BetSelection[], selections: BetSelection[],
@@ -54,14 +77,25 @@ function resolveRecentActiveChipId(
export interface GameRoundStoreState extends GameRoundSlice { export interface GameRoundStoreState extends GameRoundSlice {
activeChipId: string activeChipId: string
clearSelections: () => void clearSelections: () => void
clearRewardAnimation: () => void
finishRevealAnimation: () => void
hydrateRound: (snapshot: GameRoundSlice) => void hydrateRound: (snapshot: GameRoundSlice) => void
placeBet: (cellId: number) => void placeBet: (cellId: number) => void
playPreparedRevealAnimation: (roundId?: string | null) => void
prepareRevealAnimation: (input: {
hasSmallReward: boolean
revealKey: string
roundId: string
winningCellId: number
}) => void
recentSuccessfulSelections: BetSelection[] recentSuccessfulSelections: BetSelection[]
revealAnimation: RevealAnimationState
removeSelectionsForCell: (cellId: number) => void removeSelectionsForCell: (cellId: number) => void
restoreRecentSuccessfulSelections: () => boolean restoreRecentSuccessfulSelections: () => boolean
setRecentSuccessfulSelections: (selections: BetSelection[]) => void setRecentSuccessfulSelections: (selections: BetSelection[]) => void
selectChip: (chipId: string) => void selectChip: (chipId: string) => void
setPhase: (phase: RoundPhase) => void setPhase: (phase: RoundPhase) => void
showJackpotReward: (roundId?: string | null) => void
syncRound: (round: Partial<RoundSnapshot>) => void syncRound: (round: Partial<RoundSnapshot>) => void
upsertSelections: (selections: BetSelection[]) => void upsertSelections: (selections: BetSelection[]) => void
} }
@@ -69,6 +103,7 @@ export interface GameRoundStoreState extends GameRoundSlice {
function createInitialRoundState(): GameRoundSlice & { function createInitialRoundState(): GameRoundSlice & {
activeChipId: string activeChipId: string
recentSuccessfulSelections: BetSelection[] recentSuccessfulSelections: BetSelection[]
revealAnimation: RevealAnimationState
} { } {
const snapshot = createEmptyGameBootstrapSnapshot() const snapshot = createEmptyGameBootstrapSnapshot()
@@ -79,6 +114,7 @@ function createInitialRoundState(): GameRoundSlice & {
history: snapshot.history, history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount, maxSelectionCount: snapshot.maxSelectionCount,
recentSuccessfulSelections: [], recentSuccessfulSelections: [],
revealAnimation: createIdleRevealAnimation(),
round: snapshot.round, round: snapshot.round,
selections: snapshot.selections, selections: snapshot.selections,
trends: snapshot.trends, trends: snapshot.trends,
@@ -90,6 +126,37 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
clearSelections: () => { clearSelections: () => {
set({ selections: [] }) set({ selections: [] })
}, },
clearRewardAnimation: () => {
set((state) => ({
revealAnimation: {
...state.revealAnimation,
rewardType: 'none',
},
}))
},
finishRevealAnimation: () => {
set((state) => {
if (
state.revealAnimation.phase !== 'stopping' &&
state.revealAnimation.phase !== 'spinning'
) {
return state
}
const rewardType =
state.revealAnimation.rewardType === 'big'
? 'big'
: state.revealAnimation.pendingRewardType
return {
revealAnimation: {
...state.revealAnimation,
phase: 'result',
rewardType,
},
}
})
},
hydrateRound: (snapshot) => { hydrateRound: (snapshot) => {
set((state) => ({ set((state) => ({
activeChipId: getChipById(snapshot.chips, state.activeChipId) activeChipId: getChipById(snapshot.chips, state.activeChipId)
@@ -101,6 +168,10 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
chips: snapshot.chips, chips: snapshot.chips,
history: snapshot.history, history: snapshot.history,
maxSelectionCount: snapshot.maxSelectionCount, maxSelectionCount: snapshot.maxSelectionCount,
revealAnimation:
snapshot.round.phase === 'betting' || snapshot.round.phase === 'waiting'
? createIdleRevealAnimation()
: state.revealAnimation,
round: snapshot.round, round: snapshot.round,
selections: snapshot.selections, selections: snapshot.selections,
trends: snapshot.trends, trends: snapshot.trends,
@@ -143,6 +214,61 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
} }
}) })
}, },
playPreparedRevealAnimation: (roundId) => {
set((state) => {
const nextRoundId = roundId ?? state.revealAnimation.roundId
if (
state.revealAnimation.winningCellId === null ||
state.revealAnimation.revealKey === null ||
state.revealAnimation.phase !== 'idle'
) {
return state
}
return {
revealAnimation: {
...state.revealAnimation,
phase: 'stopping',
roundId: nextRoundId,
},
}
})
},
prepareRevealAnimation: ({
hasSmallReward,
revealKey,
roundId,
winningCellId,
}) => {
set((state) => {
if (state.revealAnimation.revealKey === revealKey) {
return state
}
const pendingRewardType =
state.revealAnimation.pendingRewardType === 'big'
? 'big'
: hasSmallReward
? 'small'
: 'none'
return {
revealAnimation: {
...state.revealAnimation,
pendingRewardType,
phase: 'idle',
revealKey,
rewardType:
state.revealAnimation.rewardType === 'big'
? 'big'
: state.revealAnimation.rewardType,
roundId,
winningCellId,
},
}
})
},
recentSuccessfulSelections: [], recentSuccessfulSelections: [],
removeSelectionsForCell: (cellId) => { removeSelectionsForCell: (cellId) => {
set((state) => ({ set((state) => ({
@@ -215,13 +341,38 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
nextState.selections = [] nextState.selections = []
} }
if (phase === 'locked' || phase === 'revealing') {
nextState.selections = []
}
if (phase === 'betting' || phase === 'waiting') {
nextState.revealAnimation = createIdleRevealAnimation()
}
return nextState return nextState
}) })
}, },
showJackpotReward: (roundId) => {
set((state) => {
const nextRoundId =
roundId ?? state.round.id ?? state.revealAnimation.roundId
const isResultReady = state.revealAnimation.phase === 'result'
return {
revealAnimation: {
...state.revealAnimation,
pendingRewardType: 'big',
rewardType: isResultReady ? 'big' : state.revealAnimation.rewardType,
roundId: nextRoundId,
},
}
})
},
syncRound: (round) => { syncRound: (round) => {
set((state) => { set((state) => {
const previousRound = state.round
const nextRound = { const nextRound = {
...state.round, ...previousRound,
...round, ...round,
} }
@@ -233,6 +384,17 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
nextState.selections = [] nextState.selections = []
} }
if (nextRound.phase === 'locked' || nextRound.phase === 'revealing') {
nextState.selections = []
}
if (
nextRound.id !== previousRound.id &&
(nextRound.phase === 'betting' || nextRound.phase === 'waiting')
) {
nextState.revealAnimation = createIdleRevealAnimation()
}
return nextState return nextState
}) })
}, },

View File

@@ -1,43 +1,13 @@
import { create } from 'zustand' import { create } from 'zustand'
import { INITIAL_MODAL_VISIBILITY, MODAL_KEYS } from '@/constants'
import type { WithdrawTopupType } from '@/type' import type { WithdrawTopupType } from '@/type'
export const MODAL_KEYS = [ export { MODAL_KEYS }
/**@description 桌面端登录弹窗*/
'desktopLogin',
/**@description 桌面端注册弹窗*/
'desktopRegister',
/**@description 桌面端多语言弹窗*/
'desktopLanguage',
/**@description 桌面端规则弹窗*/
'desktopRules',
/**@description 桌面端用户信息弹窗*/
'desktopUserInfo',
/**@description 桌面端公告弹窗*/
'desktopNotice',
/**@description 桌面端自动托管弹窗*/
'desktopAutoSetting',
/**@description 桌面端充值提现前置选择弹窗*/
'desktopProcedures',
/**@description 桌面端充值/提现弹窗*/
'desktopWithdrawTopup',
] as const
export type ModalKey = (typeof MODAL_KEYS)[number] export type ModalKey = (typeof MODAL_KEYS)[number]
type ModalVisibilityMap = Record<ModalKey, boolean> type ModalVisibilityMap = Record<ModalKey, boolean>
const INITIAL_MODAL_VISIBILITY: ModalVisibilityMap = {
desktopLogin: false,
desktopRegister: false,
desktopLanguage: false,
desktopRules: false,
desktopUserInfo: false,
desktopNotice: false,
desktopAutoSetting: false,
desktopProcedures: false,
desktopWithdrawTopup: false,
}
export interface ModalStoreState { export interface ModalStoreState {
modals: ModalVisibilityMap modals: ModalVisibilityMap
withdrawTopupType: WithdrawTopupType withdrawTopupType: WithdrawTopupType

View File

@@ -139,7 +139,8 @@
} }
@utility my-design-* { @utility my-design-* {
margin-block: calc(var(--design-unit) * --value(integer)); margin-top: calc(var(--design-unit) * --value(integer));
margin-bottom: calc(var(--design-unit) * --value(integer));
} }
@utility left-design-* { @utility left-design-* {