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(() => {
if (!open || !onClose || typeof document === 'undefined') {
if (!open || typeof document === 'undefined') {
return
}
const previousOverflow = document.body.style.overflow
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
onClose?.()
}
}
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
if (onClose) {
window.addEventListener('keydown', handleKeyDown)
}
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', handleKeyDown)
if (onClose) {
window.removeEventListener('keydown', handleKeyDown)
}
}
}, [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(
`^/(${supportedLanguages.join('|')})(?=/|$)`,
`^/(${SUPPORTED_LANGUAGES.join('|')})(?=/|$)`,
)
interface LanguageLinkProps {

View File

@@ -8,10 +8,8 @@ import {
import { motion } from 'motion/react'
import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
NOTIFICATION_EXIT_DURATION_MS,
useNotificationStore,
} from '@/lib/notify'
import { NOTIFICATION_EXIT_DURATION_MS } from '@/constants'
import { useNotificationStore } from '@/lib/notify'
import { cn } from '@/lib/utils'
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'
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 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),
)
export * from './auth'
export * from './game'
export * from './system'

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 { ApiError } from '@/lib/api/api-error'
import type { AuthSessionInput } from '@/store/auth'
@@ -20,13 +21,6 @@ import {
normalizeRefreshAuthSession,
} 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 =
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
@@ -158,7 +152,7 @@ export async function refreshAuthSession(
AUTH_ENDPOINTS.refreshToken,
{
context: {
skipAuthRefresh: true,
[AUTH_SKIP_REFRESH_CONTEXT_KEY]: true,
},
json: {
refresh_token: refreshToken,

View File

@@ -1,9 +1,8 @@
import { AUTH_ERROR_KEY_PREFIX } from '@/constants'
import { ApiError } from '@/lib/api/api-error'
type AuthSubmitContext = 'login' | 'register'
const AUTH_ERROR_KEY_PREFIX = 'auth.'
function isTranslationKey(value: unknown): value is string {
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 { ApiError } from '@/lib/api/api-error'
import type { ApiResponse } from '@/type'
@@ -10,6 +11,9 @@ import type {
DepositWithdrawConfig,
DepositWithdrawConfigDto,
FinanceCurrencyConfigDto,
FinanceOrderItemDto,
FinanceOrderList,
FinanceOrderListDto,
FinancePayChannelDto,
FinanceRateConfigDto,
FinanceWithdrawBankDto,
@@ -18,14 +22,6 @@ import type {
WithdrawCreateResponseDto,
} 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>(
response: ApiResponse<T>,
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() {
const response = await api.post<DepositWithdrawConfigDto>(
FINANCE_API_ENDPOINTS.depositWithdrawConfig,
@@ -230,6 +245,48 @@ export async function createDeposit(payload: DepositCreateRequestDto) {
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) {
const response = await api.post<
WithdrawCreateResponseDto,

View File

@@ -162,6 +162,34 @@ export interface DepositCreateResponseDto {
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 {
bank_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 { ApiError } from '@/lib/api/api-error'
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 {
runtimeEnabled: boolean
serverTime: number

View File

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

View File

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

View File

@@ -1,11 +1,22 @@
import { TriangleAlert } from 'lucide-react'
import { motion } from 'motion/react'
import { useMemo } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
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 { LottiePlayer } from '@/components/lottie-player'
import { SmartImage } from '@/components/smart-image'
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
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', {
eager: true,
@@ -24,8 +35,25 @@ const animalImageList = Object.entries(animalModules)
.filter((item) => item.id > 0)
.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 {
activeId?: number | null
className?: string
itemClassName?: string
imageClassName?: string
@@ -33,14 +61,34 @@ interface DesktopAnimalProps {
}
export function DesktopAnimal({
activeId,
className,
itemClassName,
imageClassName,
onSelect,
}: DesktopAnimalProps) {
const { t } = useTranslation()
const { i18n, t } = useTranslation()
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 {
cellWarning,
handleSelect,
@@ -51,9 +99,112 @@ export function DesktopAnimal({
selectionByCell,
showStandbyState,
} = 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 (
<section
ref={containerRef}
className={cn(
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
className,
@@ -62,8 +213,8 @@ export function DesktopAnimal({
{animalImageList.map((item) => {
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isActive = item.id === activeId || hasPlacedSelection
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner = isRevealResult && revealWinningCellId === item.id
const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null
@@ -75,8 +226,15 @@ export function DesktopAnimal({
return (
<motion.button
key={item.id}
ref={(node) => {
if (node) {
cellRefs.current.set(item.id, node)
} else {
cellRefs.current.delete(item.id)
}
}}
type="button"
disabled={lockInteraction}
disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)}
animate={
showCellWarning
@@ -100,20 +258,34 @@ export function DesktopAnimal({
: { duration: 0.16, ease: 'easeOut' }
}
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
? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]',
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)]',
isActive &&
'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)]',
isRevealRunning &&
'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 &&
'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',
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
aria-hidden="true"
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',
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)]',
isActive &&
'bg-[radial-gradient(circle_at_center,rgba(255,207,116,0.42)_0%,rgba(255,181,61,0.16)_42%,transparent_74%)] opacity-100',
isRevealRunning &&
'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 &&
'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}
alt={`animal-${item.id}`}
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,
)}
imgClassName="object-fill"
/>
{showCellWarning ? (
<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 ? (
<button
type="button"
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)]">
<span className="text-design-14 uppercase tracking-[0.42em] text-[rgba(111,255,247,0.76)]">
{isRealtimeConnecting ? '' : t('gameDesktop.animal.tapToEnter')}
</span>
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#D2FFFF]">
<motion.div
aria-hidden="true"
animate={{ rotate: 360 }}
transition={{
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
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')}
: t('gameDesktop.animal.tapToEnter')}
</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>
</button>
) : null}

View File

@@ -93,8 +93,10 @@ export function DesktopHeader() {
</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>{t('gameDesktop.header.systemTime')}</div>
<div>{systemTimeLabel}</div>
<div className={'text-[#B4E4E9]'}>
{t('gameDesktop.header.systemTime')}
</div>
<div className={'text-[#D2FCFF] font-bold'}>{systemTimeLabel}</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">

View File

@@ -1,14 +1,18 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 statusLine from '@/assets/system/status-line.webp'
import streakBg from '@/assets/system/streak.webp'
import { LottiePlayer } from '@/components/lottie-player.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 { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
export function DesktopStatusLine() {
const { t } = useTranslation()
const {
@@ -36,22 +40,72 @@ export function DesktopStatusLine() {
<SmartBackground
src={statusLine}
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
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>
{t('gameDesktop.status.odds')}: {oddsLabel}
{/*<div className={'flex-1 absolute z-10 -right-20 -top-6 w-full !h-design-105'} style={{*/}
{/* 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>
{t('gameDesktop.status.streak')}: {streakLabel}
<div
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>
{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 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
src={statusCenter}
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>
<div
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 />

View File

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

View File

@@ -1,4 +1,4 @@
export { FullscreenLottieOverlay } from '@/components/fullscreen-lottie-overlay.tsx'
export type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
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 { getGameLobbyInit, getVisibleAnnouncements } from '@/features/game'
import { GameAnnouncementModal } from '@/features/game/components'
import { getGameLobbyInit } from '@/features/game'
import { EntryNoticeGateModal } from '@/features/game/components'
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
@@ -11,21 +11,12 @@ import { notify } from '@/lib/notify'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const ENABLE_ANNOUNCEMENT_MODAL = false
export function EntryPage() {
const { t } = useTranslation()
useGameRealtimeSync()
const announcements = useGameSessionStore((state) => state.announcements)
const dismissAnnouncement = useGameSessionStore(
(state) => state.dismissAnnouncement,
)
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
const selectChip = useGameRoundStore((state) => state.selectChip)
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
const markAnnouncementRead = useGameSessionStore(
(state) => state.markAnnouncementRead,
)
const syncConnection = useGameSessionStore((state) => state.syncConnection)
const setCurrentUser = useAuthStore((state) => state.setCurrentUser)
const authStatus = useAuthStore((state) => state.status)
@@ -39,16 +30,6 @@ export function EntryPage() {
return window.matchMedia('(max-width: 768px)').matches
})
const activeAnnouncement = useMemo(
() =>
announcements.items.find(
(item) => item.id === announcements.activeAnnouncementId,
) ??
getVisibleAnnouncements(announcements)[0] ??
null,
[announcements],
)
useDocumentMetadata({
title: t('game.metaTitle'),
description: t('game.metaDescription'),
@@ -167,41 +148,7 @@ export function EntryPage() {
>
{isMobile ? <MobileEntry /> : <PcEntry />}
<GameAnnouncementModal
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>
<EntryNoticeGateModal />
</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 { DesktopControl } from '@/features/game/components/desktop/desktop-control.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 DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-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() {
return (
@@ -19,13 +96,15 @@ export function PcEntry() {
<DesktopHeader />
<div
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 />
</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-1'}>
<DesktopAnimal />
@@ -63,6 +142,7 @@ export function PcEntry() {
<DesktopProceduresModal />
{/* 桌面端充值/提现业务弹窗:承载具体的充值或提现内容 */}
<DesktopWithdrawTopupModal />
<DesktopRewardOverlay />
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,11 @@
import { useEffect, useRef } from 'react'
import {
FALLBACK_POLL_INTERVAL_MS,
GAME_SOCKET_TOPIC_VALUES,
GAME_SOCKET_TOPICS,
PLAYER_SOCKET_TOPICS,
SOCKET_DISCONNECT_DELAY_MS,
} from '@/constants'
import i18n from '@/i18n'
import { prefetchAuthToken } from '@/lib/api/api-client'
import {
@@ -16,51 +23,11 @@ type UserStreakMessageData = {
streakLevel?: number
}
const FALLBACK_POLL_INTERVAL_MS = 10_000
const GAME_SOCKET_TOPICS = {
// 对局状态心跳。每秒推送当前期号、状态、倒计时、runtime_enabled 等。
periodTick: 'period.tick',
// 本期封盘通知。用于前端立即停止下注。
periodLocked: 'period.locked',
// 本期开奖通知。用于同步开奖号码、所属期号等阶段结果。
periodOpened: 'period.opened',
// 本期派彩完成通知。用于结算阶段同步。
periodPayout: 'period.payout',
// 当前玩家连胜与赔率信息。通常在结算后或演示帧刷新。
userStreak: 'user.streak',
// 下注成功通知。仅当前用户可见,通常伴随扣款结果。
betAccepted: 'bet.accepted',
// 余额变化通知。充值、下注、派彩都会走这条流。
walletChanged: 'wallet.changed',
// 自动托管进度通知。包含托管开关、执行状态等。
autoSpinProgress: 'auto.spin.progress',
// 大奖命中通知。仅当本期存在中大奖用户时推送。
jackpotHit: 'jackpot.hit',
// 后台实时页全量快照。仅 admin live 页面使用,当前 H5 前台不订阅。
adminLiveSnapshot: 'admin.live.snapshot',
// 后台开奖结果通知。仅 admin live 页面使用,当前 H5 前台不订阅。
adminLiveOpened: 'admin.live.opened',
} as const
const GAME_SOCKET_TOPIC_VALUES = new Set<string>(
Object.values(GAME_SOCKET_TOPICS),
)
// 当前 H5 游戏页实际需要的用户侧事件。
// 后台专用事件保持在 GAME_SOCKET_TOPICS 中做口径对齐,但不在这里订阅。
const PLAYER_SOCKET_TOPICS = [
GAME_SOCKET_TOPICS.periodTick,
GAME_SOCKET_TOPICS.userStreak,
GAME_SOCKET_TOPICS.periodOpened,
GAME_SOCKET_TOPICS.periodLocked,
GAME_SOCKET_TOPICS.periodPayout,
GAME_SOCKET_TOPICS.betAccepted,
GAME_SOCKET_TOPICS.walletChanged,
GAME_SOCKET_TOPICS.autoSpinProgress,
GAME_SOCKET_TOPICS.jackpotHit,
] as const
const SOCKET_DISCONNECT_DELAY_MS = 150
type PeriodEventData = {
openTime: number | null
periodNo: string
resultNumber: number | null
}
let sharedSocketClient: GameSocketClient | null = null
let sharedSocketKey: string | null = null
@@ -186,6 +153,37 @@ function extractPeriodTick(
}
}
function extractPeriodEventData(
message: GameSocketMessage,
): PeriodEventData | null {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
const periodNo =
typeof source.period_no === 'string'
? source.period_no
: typeof source.periodNo === 'string'
? source.periodNo
: null
if (!periodNo) {
return null
}
const resultNumber = toOptionalNumber(
source.result_number ?? source.resultNumber,
)
const openTime = toOptionalNumber(source.open_time ?? source.openTime)
return {
openTime: openTime ?? null,
periodNo,
resultNumber:
typeof resultNumber === 'number' && Number.isInteger(resultNumber)
? resultNumber
: null,
}
}
function extractWalletCoin(message: GameSocketMessage) {
const data = getNestedRecord(message, 'data')
const source = data ?? (message as Record<string, unknown>)
@@ -288,6 +286,90 @@ function applyPeriodPhase(phase: 'locked' | 'revealing' | 'settled') {
useGameRoundStore.getState().setPhase(phase)
}
function applyPeriodLockedMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
const roundState = useGameRoundStore.getState()
const roundId = period?.periodNo ?? roundState.round.id
if (roundId) {
roundState.syncRound({
id: roundId,
phase: 'locked',
})
} else {
roundState.setPhase('locked')
}
}
function applyPeriodOpenedMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
if (!period || period.resultNumber === null) {
applyPeriodPhase('revealing')
return
}
const roundState = useGameRoundStore.getState()
const openedAt = toIsoFromUnixSeconds(
period.openTime ?? serverTime ?? Math.floor(Date.now() / 1000),
)
const hasSmallReward = roundState.selections.some(
(selection) => selection.cellId === period.resultNumber,
)
const revealKey = `${period.periodNo}:${period.resultNumber}`
roundState.syncRound({
id: period.periodNo,
phase: 'revealing',
revealingAt: openedAt,
winningCellId: period.resultNumber,
})
useGameRoundStore.getState().prepareRevealAnimation({
hasSmallReward,
revealKey,
roundId: period.periodNo,
winningCellId: period.resultNumber,
})
}
function applyPeriodPayoutMessage(
message: GameSocketMessage,
serverTime: number | null,
) {
applyPeriodMessage(message, serverTime)
const period = extractPeriodEventData(message)
if (period?.resultNumber !== null && period?.resultNumber !== undefined) {
const roundState = useGameRoundStore.getState()
const revealKey = `${period.periodNo}:${period.resultNumber}`
roundState.prepareRevealAnimation({
hasSmallReward: roundState.selections.some(
(selection) => selection.cellId === period.resultNumber,
),
revealKey,
roundId: period.periodNo,
winningCellId: period.resultNumber,
})
}
const roundId = period?.periodNo ?? useGameRoundStore.getState().round.id
applyPeriodPhase('settled')
useGameRoundStore.getState().playPreparedRevealAnimation(roundId || null)
}
function applyUserStreakMessage(message: GameSocketMessage) {
const streakData = extractUserStreakMessageData(message)
const currentUser = useAuthStore.getState().currentUser
@@ -320,6 +402,8 @@ function applyWalletChangedMessage(message: GameSocketMessage) {
function applyJackpotHitMessage(message: GameSocketMessage) {
const currentUser = useAuthStore.getState().currentUser
const period = extractPeriodEventData(message)
const isJackpot = extractJackpotStatus(message)
if (!currentUser) {
return
@@ -327,8 +411,12 @@ function applyJackpotHitMessage(message: GameSocketMessage) {
useAuthStore.getState().setCurrentUser({
...currentUser,
isJackpot: extractJackpotStatus(message),
isJackpot,
})
if (isJackpot) {
useGameRoundStore.getState().showJackpotReward(period?.periodNo ?? null)
}
}
function applyRealtimeMessage(message: GameSocketMessage) {
@@ -336,20 +424,41 @@ function applyRealtimeMessage(message: GameSocketMessage) {
const topic = getMessageTopic(message)
switch (topic) {
case GAME_SOCKET_TOPICS.periodTick:
case GAME_SOCKET_TOPICS.periodTick: {
const period = extractPeriodTick(message)
const resultNumber =
typeof period?.result_number === 'number' ? period.result_number : null
const shouldStartSettledReveal =
period?.status === 'settled' && resultNumber !== null
const hasSmallReward = shouldStartSettledReveal
? useGameRoundStore
.getState()
.selections.some((selection) => selection.cellId === resultNumber)
: false
applyPeriodMessage(message, serverTime)
if (shouldStartSettledReveal) {
useGameRoundStore.getState().prepareRevealAnimation({
hasSmallReward,
revealKey: `${period.period_no}:${resultNumber}`,
roundId: period.period_no,
winningCellId: resultNumber,
})
useGameRoundStore
.getState()
.playPreparedRevealAnimation(period.period_no)
}
break
}
case GAME_SOCKET_TOPICS.periodLocked:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('locked')
applyPeriodLockedMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.periodOpened:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('revealing')
applyPeriodOpenedMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.periodPayout:
applyPeriodMessage(message, serverTime)
applyPeriodPhase('settled')
applyPeriodPayoutMessage(message, serverTime)
break
case GAME_SOCKET_TOPICS.userStreak:
applyUserStreakMessage(message)

View File

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

View File

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

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 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 { useTranslation } from 'react-i18next'
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 { SmartImage } from '@/components/smart-image.tsx'
import { getNoticeDetail, getNoticeList } from '@/features/game/api'
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
import { cn } from '@/lib/utils'
import { useAuthStore, useModalStore } from '@/store'
type UserInfoTabKey = 'profile' | 'message'
type UserInfoTabKey = 'financeRecords' | 'message' | 'profile'
type MessageViewState = 'list' | 'detail'
const USER_INFO_TABS: Array<{
@@ -26,6 +28,11 @@ const USER_INFO_TABS: Array<{
labelKey: 'game.modals.userInfo.tabs.profile',
icon: CircleUserRound,
},
{
key: 'financeRecords',
labelKey: 'game.modals.userInfo.tabs.financeRecords',
icon: ReceiptText,
},
{
key: 'message',
labelKey: 'game.modals.userInfo.tabs.message',
@@ -118,40 +125,64 @@ function DesktopUserInfoModal() {
type="button"
onClick={() => setActiveTab(tab.key)}
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
? 'text-[#FEEEB0]'
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
)}
>
{isActive ? (
<span
<motion.span
layoutId="user-info-tab-active-bg"
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%)]"
transition={{
type: 'spring',
stiffness: 430,
damping: 36,
}}
/>
) : null}
{isActive ? (
<span
<motion.span
layoutId="user-info-tab-active-indicator"
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)]"
transition={{
type: 'spring',
stiffness: 430,
damping: 36,
}}
/>
) : null}
<Icon
<motion.div
className={cn(
'relative z-10 transition h-design-50 w-design-50',
'relative z-10 transition',
isActive &&
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
)}
/>
<div
animate={{
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(
'relative z-10 text-design-24',
'relative z-10 text-center text-design-20 leading-tight',
isActive && 'modal-title-gold-glow',
)}
animate={{
scale: isActive ? 1.04 : 1,
y: isActive ? -1 : 0,
}}
transition={{ duration: 0.18, ease: 'easeOut' }}
>
{t(tab.labelKey)}
</div>
</motion.div>
</button>
)
})}
@@ -210,6 +241,10 @@ function DesktopUserInfoModal() {
</div>
</div>
</SmartBackground>
) : activeTab === 'financeRecords' ? (
<DesktopFinanceRecordsTab
enabled={open && activeTab === 'financeRecords'}
/>
) : (
<div className={'flex h-full w-full flex-col'}>
{messageView === 'detail' ? (
@@ -224,7 +259,7 @@ function DesktopUserInfoModal() {
void handleReturnToList()
}}
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
@@ -278,7 +313,7 @@ function DesktopUserInfoModal() {
setMessageView('detail')
}}
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

View File

@@ -1,59 +1,17 @@
export const GAME_GRID_ROWS = 6
export const GAME_GRID_COLUMNS = 6
export const GAME_TOTAL_CELLS = GAME_GRID_ROWS * GAME_GRID_COLUMNS
export const ROUND_PHASES = [
'waiting',
'betting',
'locked',
'revealing',
'settled',
] as const
export const CELL_STATUSES = [
'idle',
'betting',
'selected',
'locked',
'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
export {
ANNOUNCEMENT_TONES,
BET_SOURCES,
CELL_STATUSES,
CONNECTION_STATUSES,
CONNECTION_TRANSPORTS,
DEFAULT_ACTIVE_CHIP_ID,
DEFAULT_ANNOUNCEMENT_TTL_MS,
DEFAULT_GAME_CHIP_COLORS,
GAME_GRID_COLUMNS,
GAME_GRID_ROWS,
GAME_MAX_SELECTION_CELLS,
GAME_RECENT_HISTORY_LIMIT,
GAME_TOTAL_CELLS,
ROUND_PHASES,
TREND_DIRECTIONS,
} from '@/constants/game'

View File

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

View File

@@ -1,6 +1,12 @@
import ky, { HTTPError, type Options, TimeoutError } from 'ky'
import {
ACCESS_TOKEN_REFRESH_SKEW_MS,
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_TIMEOUT_MS,
} from '@/constants'
@@ -24,12 +30,6 @@ type JsonRequestOptions<TBody> = RequestOptions & {
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 authSecret = import.meta.env.VITE_AUTH_TOKEN_SECRET?.trim()
const shouldLogRequests = import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'

View File

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

View File

@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { FULLSCREEN_CHANGE_EVENTS } from '@/constants'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -20,13 +21,6 @@ type FullscreenCapableDocument = Document & {
webkitFullscreenElement?: Element | null
}
const FULLSCREEN_CHANGE_EVENTS = [
'fullscreenchange',
'webkitfullscreenchange',
'mozfullscreenchange',
'MSFullscreenChange',
] as const
export function isDesktopFullscreen() {
if (typeof document === 'undefined') {
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 = {
authToken: string
deviceId: string
@@ -47,10 +53,6 @@ type GameSocketClientOptions = {
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) {
const params = new URLSearchParams({
token: context.token,

View File

@@ -103,14 +103,6 @@ export default {
autoModeDemo: 'Auto mode demo',
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: {
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.',
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: {
title: 'Game Rules',
content:
@@ -149,6 +151,7 @@ export default {
title: 'User Info',
tabs: {
profile: 'Profile',
financeRecords: 'Top Up / Withdraw Records',
message: 'Messages',
},
profile: {
@@ -171,6 +174,19 @@ export default {
check: 'View',
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: {
applyWithdraw: 'Apply for Withdrawal',

View File

@@ -102,14 +102,6 @@ export default {
autoModeDemo: 'Demo mode 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: {
login: {
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.',
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: {
title: 'Aturan Permainan',
content:
@@ -148,6 +150,7 @@ export default {
title: 'Info Pengguna',
tabs: {
profile: 'Profil',
financeRecords: 'Riwayat Isi Ulang / Penarikan',
message: 'Pesan',
},
profile: {
@@ -170,6 +173,19 @@ export default {
check: 'Lihat',
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: {
applyWithdraw: 'Ajukan Penarikan',

View File

@@ -105,14 +105,6 @@ export default {
autoModeDemo: 'Demo mod 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: {
login: {
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.',
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: {
title: 'Peraturan Permainan',
content:
@@ -151,6 +153,7 @@ export default {
title: 'Maklumat Pengguna',
tabs: {
profile: 'Profil',
financeRecords: 'Rekod Tambah Nilai / Pengeluaran',
message: 'Mesej',
},
profile: {
@@ -173,6 +176,19 @@ export default {
check: 'Semak',
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: {
applyWithdraw: 'Mohon Pengeluaran',

View File

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

View File

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

View File

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

View File

@@ -139,7 +139,8 @@
}
@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-* {