refactor(game): 重构项目结构,优化链路, 移动端适配
- 移除 useGameBoardVm 数据层实施说明文档 - 移除核心玩法与前端规则摘要文档 - 移除游戏模块数据与界面分层第一阶段实施稿文档 - 清理与数据层重构相关的技术方案说明 - 删除关于 PC 和 Mobile 界面分离的设计规划 - 移除 view-model hooks 架构设计相关内容
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **36-character-flower** (2639 symbols, 5033 relationships, 226 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **36-character-flower** (3087 symbols, 5931 relationships, 265 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **36-character-flower** (2639 symbols, 5033 relationships, 226 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **36-character-flower** (3087 symbols, 5931 relationships, 265 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
# 36字花 useGameBoardVm 数据层实施说明
|
|
||||||
|
|
||||||
## 1. 目标
|
|
||||||
|
|
||||||
本说明只服务下一步开发:实现 `useGameBoardVm.ts`。
|
|
||||||
|
|
||||||
当前目标很单一:
|
|
||||||
|
|
||||||
- 让桌面选号盘从 store 读取真实业务数据
|
|
||||||
- 让点击格子时真正触发下注动作
|
|
||||||
- 不在 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 内写业务逻辑
|
|
||||||
- 不同时改造 mobile
|
|
||||||
|
|
||||||
本阶段不做:
|
|
||||||
|
|
||||||
- 不改造 mobile
|
|
||||||
- 不新增 `game-ui-store`
|
|
||||||
- 不重写 `DesktopAnimal` 整个展示结构
|
|
||||||
- 不处理 auto-spin / modal
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 相关文件
|
|
||||||
|
|
||||||
本次只围绕以下文件展开:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
|
||||||
- [src/features/game/entry/pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx)
|
|
||||||
- [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts)
|
|
||||||
- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
|
||||||
|
|
||||||
将新增:
|
|
||||||
|
|
||||||
- `src/features/game/hooks/use-game-board-vm.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 当前问题
|
|
||||||
|
|
||||||
当前 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx) 里虽然已经挂载了 `DesktopAnimal`,但还没有把桌面主玩法接到业务链路。
|
|
||||||
|
|
||||||
当前状态:
|
|
||||||
|
|
||||||
- `DesktopAnimal` 只是展示组件
|
|
||||||
- `DesktopAnimal` 支持 `activeId` 和 `onSelect`
|
|
||||||
- `PcEntry` 直接渲染 `<DesktopAnimal />`
|
|
||||||
- 点击格子不会写入真实下注状态
|
|
||||||
|
|
||||||
结果是:
|
|
||||||
|
|
||||||
- 控制栏虽然已经接入了部分业务数据
|
|
||||||
- 状态栏、历史区也开始接入 store
|
|
||||||
- 但桌面最核心的“点击动物下注”链路还没有打通
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. useGameBoardVm 的职责
|
|
||||||
|
|
||||||
`useGameBoardVm` 只做 3 件事:
|
|
||||||
|
|
||||||
1. 从 `game-round-store` 读取选号盘需要的业务数据
|
|
||||||
2. 组织出桌面选号盘可以直接消费的 view-model
|
|
||||||
3. 暴露点击格子的业务动作
|
|
||||||
|
|
||||||
它不负责:
|
|
||||||
|
|
||||||
- 直接渲染 UI
|
|
||||||
- 做 hover / 动画状态
|
|
||||||
- 控制 modal
|
|
||||||
- 处理移动端布局
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 数据来源
|
|
||||||
|
|
||||||
`useGameBoardVm` 第一版只从 [game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts) 读取:
|
|
||||||
|
|
||||||
- `cells`
|
|
||||||
- `round`
|
|
||||||
- `selections`
|
|
||||||
- `trends`
|
|
||||||
- `placeBet`
|
|
||||||
|
|
||||||
可复用的派生逻辑来自 [selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts):
|
|
||||||
|
|
||||||
- `buildGameCellViewModels`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 第一版输出字段
|
|
||||||
|
|
||||||
第一版不追求一步到位,输出保持最小可用。
|
|
||||||
|
|
||||||
建议 `useGameBoardVm` 返回:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
cells: GameCellViewModel[]
|
|
||||||
activeId: number | null
|
|
||||||
canPlaceBets: boolean
|
|
||||||
onCellPress: (cellId: number) => void
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 字段说明
|
|
||||||
|
|
||||||
#### `cells`
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
- `buildGameCellViewModels({ cells, round, selections, trends })`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 给未来第二版 board 组件升级时使用
|
|
||||||
- 即使第一版 `DesktopAnimal` 还没完全吃它,也应该先在 hook 里产出来
|
|
||||||
|
|
||||||
#### `activeId`
|
|
||||||
|
|
||||||
第一版定义:
|
|
||||||
|
|
||||||
- 当前有下注的最后一个格子 id
|
|
||||||
- 如果没有任何下注,则为 `null`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 兼容当前 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 现有接口
|
|
||||||
- 因为这个组件当前只支持单个 `activeId`,还不支持多个已选格子
|
|
||||||
|
|
||||||
#### `canPlaceBets`
|
|
||||||
|
|
||||||
定义:
|
|
||||||
|
|
||||||
- `round.phase === 'betting'`
|
|
||||||
|
|
||||||
作用:
|
|
||||||
|
|
||||||
- 控制点击是否真正触发下注
|
|
||||||
- 也为后续 UI 禁用态预留
|
|
||||||
|
|
||||||
#### `onCellPress`
|
|
||||||
|
|
||||||
定义:
|
|
||||||
|
|
||||||
- 当 `canPlaceBets === true` 时,调用 `placeBet(cellId)`
|
|
||||||
- 否则不执行
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 第一版实现规则
|
|
||||||
|
|
||||||
### 7.1 不在 DesktopAnimal 内直接读 store
|
|
||||||
|
|
||||||
[desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx) 必须继续保持展示组件定位。
|
|
||||||
|
|
||||||
不应该在里面直接写:
|
|
||||||
|
|
||||||
- `useGameRoundStore`
|
|
||||||
- `placeBet`
|
|
||||||
- `buildGameCellViewModels`
|
|
||||||
- `round.phase` 判断
|
|
||||||
|
|
||||||
### 7.2 业务写在 hook,UI 只接 props
|
|
||||||
|
|
||||||
推荐接线方式:
|
|
||||||
|
|
||||||
在 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx) 中:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const { activeId, onCellPress } = useGameBoardVm()
|
|
||||||
|
|
||||||
<DesktopAnimal activeId={activeId} onSelect={onCellPress} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 第一版先兼容现有 DesktopAnimal 接口
|
|
||||||
|
|
||||||
当前 `DesktopAnimal` 只支持:
|
|
||||||
|
|
||||||
- `activeId?: number | null`
|
|
||||||
- `onSelect?: (animalId: number) => void`
|
|
||||||
|
|
||||||
所以第一版不要强行重做它的 props 结构。
|
|
||||||
|
|
||||||
先兼容现状,把业务链路打通即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 第一版文件改动范围
|
|
||||||
|
|
||||||
本次改动建议控制在 2 到 3 个文件:
|
|
||||||
|
|
||||||
1. 新增 `src/features/game/hooks/use-game-board-vm.ts`
|
|
||||||
2. 修改 [pc-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/pc-entry.tsx)
|
|
||||||
3. 如有必要,微调 [desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
|
||||||
|
|
||||||
其中第 3 项不是必须,优先争取只改前两个文件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 第一版完成标准
|
|
||||||
|
|
||||||
完成后应满足:
|
|
||||||
|
|
||||||
- `PcEntry` 不再裸挂 `<DesktopAnimal />`
|
|
||||||
- `PcEntry` 通过 `useGameBoardVm` 把选号盘接到 store
|
|
||||||
- 点击格子会真实调用下注动作
|
|
||||||
- 控制栏中的总下注额会随点击变化
|
|
||||||
- `DesktopAnimal` 仍然保持展示组件定位
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 第二版演进方向
|
|
||||||
|
|
||||||
第一版做完后,下一版再考虑升级 `DesktopAnimal` 接口。
|
|
||||||
|
|
||||||
当前限制:
|
|
||||||
|
|
||||||
- `DesktopAnimal` 只能高亮一个 `activeId`
|
|
||||||
|
|
||||||
第二版建议升级为:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
items: GameCellViewModel[]
|
|
||||||
onSelect: (cellId: number) => void
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
这样就可以支持:
|
|
||||||
|
|
||||||
- 多个已下注格子同时高亮
|
|
||||||
- 依据 `status` 区分 `betting / selected / won / lost`
|
|
||||||
- PC 和 Mobile 共用同一份 board view-model
|
|
||||||
|
|
||||||
但这些都不属于当前这一步。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 结论
|
|
||||||
|
|
||||||
当前最正确的操作顺序是:
|
|
||||||
|
|
||||||
1. 先写 `useGameBoardVm.ts`
|
|
||||||
2. 再在 `pc-entry.tsx` 中接入
|
|
||||||
3. 暂时不把业务写进 `desktop-animal.tsx`
|
|
||||||
|
|
||||||
这样可以在最小改动范围内,把桌面端最核心的“点击格子下注”链路打通,并继续保持“数据层在外、UI 层在内”的改造方向。
|
|
||||||
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
# 36字花核心玩法与前端规则摘要
|
|
||||||
|
|
||||||
## 1. 项目定位
|
|
||||||
|
|
||||||
36字花是一个**单期开奖结果、单期号循环运行**的实时开奖类游戏。
|
|
||||||
|
|
||||||
前端的核心界面是:
|
|
||||||
|
|
||||||
- 36 宫格下注盘
|
|
||||||
- 倒计时与当前期状态栏
|
|
||||||
- 筹码与确认下注区
|
|
||||||
- 开奖历史与走势信息
|
|
||||||
- 公告、规则、自动托管、充值提现等外围模块
|
|
||||||
|
|
||||||
产品运行原则:
|
|
||||||
|
|
||||||
- 全平台共享同一局数据
|
|
||||||
- `PC` 与 `Mobile` 只分界面,不分玩法、不分对局
|
|
||||||
- 前端必须以服务端状态为准,不能靠本地时间自行判断开奖或封盘
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 核心玩法
|
|
||||||
|
|
||||||
### 2.1 基本盘面
|
|
||||||
|
|
||||||
- 游戏共有 **36 个号码格子**
|
|
||||||
- 每个格子代表一个“字花编号”
|
|
||||||
- 编号范围为 **1-36**
|
|
||||||
- 当前前端盘面布局为 **6 x 6**
|
|
||||||
|
|
||||||
相关代码与约束:
|
|
||||||
|
|
||||||
- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts)
|
|
||||||
- `GAME_GRID_ROWS = 6`
|
|
||||||
- `GAME_GRID_COLUMNS = 6`
|
|
||||||
- `GAME_TOTAL_CELLS = 36`
|
|
||||||
|
|
||||||
### 2.2 开奖规则
|
|
||||||
|
|
||||||
- 每一期只会开出 **1 个中奖号码**
|
|
||||||
- 开奖号码属于 1-36 中的一个
|
|
||||||
- 用户只要下注号码集合中包含该号码,即视为中奖
|
|
||||||
|
|
||||||
接口口径见:
|
|
||||||
|
|
||||||
- [docs/36字花-移动端接口设计草案.md](/Users/jiaunun/Desktop/36-character-flower/docs/36字花-移动端接口设计草案.md)
|
|
||||||
- `POST /api/game/placeBet`
|
|
||||||
|
|
||||||
文档原意:
|
|
||||||
|
|
||||||
- 玩家提交的是“号码集合 + 单注金额”
|
|
||||||
- 系统按“单注金额 × 号码数量”计算本笔总扣款
|
|
||||||
- 开奖后只出一个号码
|
|
||||||
- 若该号码命中玩家所选集合,则该笔下注中奖
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 下注模型
|
|
||||||
|
|
||||||
### 3.1 单注结构
|
|
||||||
|
|
||||||
一笔下注至少包含:
|
|
||||||
|
|
||||||
- `period_no`:下注目标期号
|
|
||||||
- `numbers`:本次下注号码集合
|
|
||||||
- `single_bet_amount`:单个号码的下注金额
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- `numbers` 是一个号码集合,而不是单个号码
|
|
||||||
- 多选号码时,总扣款 = `single_bet_amount × numbers数量`
|
|
||||||
- 重复号码应去重
|
|
||||||
|
|
||||||
### 3.2 前端当前数据模型
|
|
||||||
|
|
||||||
当前前端 store 中的单笔选择数据为:
|
|
||||||
|
|
||||||
- `cellId`
|
|
||||||
- `chipId`
|
|
||||||
- `amount`
|
|
||||||
- `placedAt`
|
|
||||||
- `source`
|
|
||||||
|
|
||||||
定义见:
|
|
||||||
|
|
||||||
- [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts)
|
|
||||||
- `BetSelection`
|
|
||||||
|
|
||||||
当前本地 mock 与 store 落地方式是:
|
|
||||||
|
|
||||||
- 每点击一个格子,会追加一条 `selection`
|
|
||||||
- 每条 `selection` 对应一个格子和一个筹码金额
|
|
||||||
|
|
||||||
这与接口文档中的“号码集合提交”并不完全一致。
|
|
||||||
|
|
||||||
因此当前前端可以先采用如下理解:
|
|
||||||
|
|
||||||
- UI 交互阶段:按“逐格选择”记录本地状态
|
|
||||||
- 提交到后端阶段:再把本地多个格子聚合成 `numbers`
|
|
||||||
|
|
||||||
这是后续 `confirm bet` 需要承担的转换逻辑。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 回合状态机
|
|
||||||
|
|
||||||
### 4.1 当前状态枚举
|
|
||||||
|
|
||||||
当前项目中定义的回合阶段为:
|
|
||||||
|
|
||||||
- `waiting`
|
|
||||||
- `betting`
|
|
||||||
- `locked`
|
|
||||||
- `revealing`
|
|
||||||
- `settled`
|
|
||||||
|
|
||||||
定义见:
|
|
||||||
|
|
||||||
- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts)
|
|
||||||
|
|
||||||
接口文档中的状态口径包括:
|
|
||||||
|
|
||||||
- `betting`
|
|
||||||
- `locked`
|
|
||||||
- `settling`
|
|
||||||
- `finished`
|
|
||||||
- `void`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 当前前端内部状态和接口文档状态还未完全统一
|
|
||||||
- 第一阶段前端可继续沿用内部状态机
|
|
||||||
- 后续真实联调时,要补一层接口状态 -> 前端状态的映射
|
|
||||||
|
|
||||||
### 4.2 各阶段前端规则
|
|
||||||
|
|
||||||
#### `BETTING`
|
|
||||||
|
|
||||||
- 允许选格子
|
|
||||||
- 允许切换筹码
|
|
||||||
- 允许清除当前选择
|
|
||||||
- 允许重复上一注
|
|
||||||
- 允许确认下注
|
|
||||||
- 允许开启自动托管
|
|
||||||
|
|
||||||
#### `LOCKED`
|
|
||||||
|
|
||||||
- 已封盘
|
|
||||||
- 前端必须立即停止下注交互
|
|
||||||
- 不应等待后端返回后才禁用点击
|
|
||||||
|
|
||||||
#### `REVEALING / SETTLING`
|
|
||||||
|
|
||||||
- 等待开奖与派彩
|
|
||||||
- 展示开奖结果、跑马灯、中奖态
|
|
||||||
|
|
||||||
#### `SETTLED / FINISHED`
|
|
||||||
|
|
||||||
- 本期结束
|
|
||||||
- 准备进入下一轮
|
|
||||||
- 历史、走势、余额等应刷新
|
|
||||||
|
|
||||||
#### `VOID`
|
|
||||||
|
|
||||||
- 本期作废
|
|
||||||
- 需要退款待开奖本金
|
|
||||||
- 前端要清理本期下注态并正确提示
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 封盘与倒计时规则
|
|
||||||
|
|
||||||
### 5.1 核心原则
|
|
||||||
|
|
||||||
- 前端必须以服务端返回的时间和阶段为准
|
|
||||||
- 到达封盘时间点时,前端应立即锁盘
|
|
||||||
- 即使网络延迟,也不能继续允许下注交互
|
|
||||||
|
|
||||||
这点在需求文档中是明确要求:
|
|
||||||
|
|
||||||
- [docs/frontend-baseline-requirements.md](/Users/jiaunun/Desktop/36-character-flower/docs/frontend-baseline-requirements.md)
|
|
||||||
|
|
||||||
### 5.2 当前前端倒计时模型
|
|
||||||
|
|
||||||
当前前端使用:
|
|
||||||
|
|
||||||
- `round.bettingClosesAt`
|
|
||||||
- `round.revealingAt`
|
|
||||||
- `round.settledAt`
|
|
||||||
|
|
||||||
并通过:
|
|
||||||
|
|
||||||
- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
|
||||||
- `getRoundCountdownMs(round)`
|
|
||||||
|
|
||||||
来计算当前倒计时毫秒数。
|
|
||||||
|
|
||||||
规则如下:
|
|
||||||
|
|
||||||
- `waiting / betting`:倒计时到 `bettingClosesAt`
|
|
||||||
- `locked / revealing`:倒计时到 `revealingAt`
|
|
||||||
- 其余:倒计时到 `settledAt`
|
|
||||||
|
|
||||||
### 5.3 Mock 数据口径
|
|
||||||
|
|
||||||
当前 mock 数据中:
|
|
||||||
|
|
||||||
- 开始后约 18 秒封盘
|
|
||||||
- 约 24 秒开奖
|
|
||||||
- 约 30 秒结算
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
- [src/features/game/shared/mock-data.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/mock-data.ts)
|
|
||||||
|
|
||||||
这只是前端演示节奏,不代表最终真实服务端配置。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 赔率、金额与筹码规则
|
|
||||||
|
|
||||||
### 6.1 当前赔率
|
|
||||||
|
|
||||||
当前 mock 中每个格子赔率固定为:
|
|
||||||
|
|
||||||
- `36`
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
- [src/features/game/shared/mock-data.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/mock-data.ts)
|
|
||||||
- `createGameCells()`
|
|
||||||
|
|
||||||
### 6.2 当前默认筹码
|
|
||||||
|
|
||||||
当前默认筹码面额为:
|
|
||||||
|
|
||||||
- `10`
|
|
||||||
- `25`
|
|
||||||
- `50`
|
|
||||||
- `100`
|
|
||||||
- `200`
|
|
||||||
- `500`
|
|
||||||
|
|
||||||
来源:
|
|
||||||
|
|
||||||
- [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts)
|
|
||||||
- `DEFAULT_GAME_CHIP_AMOUNTS`
|
|
||||||
|
|
||||||
### 6.3 下注限制
|
|
||||||
|
|
||||||
从接口文档与基线需求中可得出以下前端规则:
|
|
||||||
|
|
||||||
- 单次下注号码数量不得超过 `pick_max_number_count`
|
|
||||||
- 单号码下注金额不得超过 `single_number_max_bet`
|
|
||||||
- 总下注额不得超过余额
|
|
||||||
- 封盘后不能继续下注
|
|
||||||
|
|
||||||
文档来源:
|
|
||||||
|
|
||||||
- [docs/36字花-移动端接口设计草案.md](/Users/jiaunun/Desktop/36-character-flower/docs/36字花-移动端接口设计草案.md)
|
|
||||||
- [docs/frontend-baseline-requirements.md](/Users/jiaunun/Desktop/36-character-flower/docs/frontend-baseline-requirements.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 中奖、历史与走势
|
|
||||||
|
|
||||||
### 7.1 历史记录
|
|
||||||
|
|
||||||
每期开奖后应产生一条历史记录,至少包括:
|
|
||||||
|
|
||||||
- `roundId`
|
|
||||||
- `winningCellId`
|
|
||||||
- `settledAt`
|
|
||||||
- `payoutMultiplier`
|
|
||||||
- `totalPoolAmount`
|
|
||||||
|
|
||||||
定义见:
|
|
||||||
|
|
||||||
- [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts)
|
|
||||||
- `HistoryEntry`
|
|
||||||
|
|
||||||
### 7.2 走势
|
|
||||||
|
|
||||||
前端会基于历史数据派生走势信息,包括:
|
|
||||||
|
|
||||||
- `currentStreak`
|
|
||||||
- `hitCount`
|
|
||||||
- `missCount`
|
|
||||||
- `direction`
|
|
||||||
- `lastHitRoundId`
|
|
||||||
|
|
||||||
派生逻辑见:
|
|
||||||
|
|
||||||
- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
|
||||||
- `deriveTrendEntries(history)`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 走势属于纯派生展示数据
|
|
||||||
- 不应由 UI 组件自己重复计算
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 公告、维护与运行开关
|
|
||||||
|
|
||||||
### 8.1 运行开关
|
|
||||||
|
|
||||||
接口文档中存在:
|
|
||||||
|
|
||||||
- `runtime_enabled`
|
|
||||||
|
|
||||||
含义:
|
|
||||||
|
|
||||||
- `true`:游戏正常运行
|
|
||||||
- `false`:后台维护中,禁止下注
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- 维护中不允许新下注
|
|
||||||
- 当前已开盘的一局仍可正常开奖和派彩
|
|
||||||
- 前端应禁用下注入口并提示维护状态
|
|
||||||
|
|
||||||
### 8.2 公告
|
|
||||||
|
|
||||||
公告是前端大厅的一部分,但不属于主玩法。
|
|
||||||
|
|
||||||
当前前端已存在公告模型:
|
|
||||||
|
|
||||||
- `AnnouncementState`
|
|
||||||
- `AnnouncementItem`
|
|
||||||
|
|
||||||
它们属于会话层状态,不应混入下注与回合逻辑。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 自动托管
|
|
||||||
|
|
||||||
接口文档中已定义自动托管能力:
|
|
||||||
|
|
||||||
- `POST /api/game/autoSpin`
|
|
||||||
|
|
||||||
入参包括:
|
|
||||||
|
|
||||||
- `action`
|
|
||||||
- `period_no`
|
|
||||||
- `numbers`
|
|
||||||
- `single_bet_amount`
|
|
||||||
- `rounds`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 自动托管属于建立在主玩法之上的扩展能力
|
|
||||||
- 它依赖同一套选号与下注规则
|
|
||||||
- 当前前端可以先保留 UI 壳层,不需要在主玩法没走通前抢先落业务
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 前端当前最应该优先走通的主玩法链路
|
|
||||||
|
|
||||||
基于上述规则,当前前端最核心、最应该优先走通的是:
|
|
||||||
|
|
||||||
1. 状态栏拿到当前期状态与倒计时
|
|
||||||
2. 控制栏拿到当前筹码与总下注额
|
|
||||||
3. 选号盘点击格子后写入本地下注选择
|
|
||||||
4. 总下注额、选中数量、选号高亮联动刷新
|
|
||||||
5. 后续再补确认下注请求与开奖结果回写
|
|
||||||
|
|
||||||
这也是为什么当前下一步应优先实现:
|
|
||||||
|
|
||||||
- `useGameBoardVm`
|
|
||||||
|
|
||||||
而不是优先改 `mobile` 或外围弹窗。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 总结
|
|
||||||
|
|
||||||
36字花这个项目的核心,不是“36 张图摆出来”,而是下面这条实时对局链路:
|
|
||||||
|
|
||||||
- 同一局
|
|
||||||
- 同一倒计时
|
|
||||||
- 同一开奖结果
|
|
||||||
- 36 格可选号码
|
|
||||||
- 用户用统一筹码模型下注
|
|
||||||
- 封盘、开奖、派奖按服务端状态推进
|
|
||||||
|
|
||||||
前端实现时必须坚持两点:
|
|
||||||
|
|
||||||
1. **玩法规则统一**
|
|
||||||
- PC 和 Mobile 只能换壳,不能换规则
|
|
||||||
|
|
||||||
2. **服务端状态优先**
|
|
||||||
- 前端可以先做本地交互反馈
|
|
||||||
- 但回合状态、封盘、开奖、派彩都必须最终以服务端为准
|
|
||||||
|
|
||||||
@@ -1,846 +0,0 @@
|
|||||||
# 36字花游戏模块数据与界面分层第一阶段实施稿
|
|
||||||
|
|
||||||
## 1. 目标
|
|
||||||
|
|
||||||
第一阶段的目标不是做一次彻底重构,而是先把当前项目中的“业务数据”和“界面渲染”拆出清晰边界,让同一套数据和交互逻辑可以同时服务 `PC` 界面与 `Mobile` 界面。
|
|
||||||
|
|
||||||
本阶段只解决以下问题:
|
|
||||||
|
|
||||||
- 一套业务数据,供 `PC` 与 `Mobile` 共同消费
|
|
||||||
- 统一业务交互逻辑,避免双端各自维护一份
|
|
||||||
- 保持 `PC` 与 `Mobile` 布局、样式、视觉独立
|
|
||||||
- 不大范围推翻现有组件,优先在现有代码上增量改造
|
|
||||||
|
|
||||||
本阶段暂不追求:
|
|
||||||
|
|
||||||
- 所有组件完全抽象成跨端共享组件
|
|
||||||
- 一次性去掉所有本地 `useState`
|
|
||||||
- 一次性重写所有弹窗和表单
|
|
||||||
- 大规模目录迁移导致整仓成本过高
|
|
||||||
|
|
||||||
|
|
||||||
## 2. 当前代码现状
|
|
||||||
|
|
||||||
当前仓库中已经存在一部分良好的基础设施:
|
|
||||||
|
|
||||||
- 数据请求与 DTO 归一化:
|
|
||||||
- [src/features/game/api/game-api.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/game-api.ts)
|
|
||||||
- 共享领域类型与派生逻辑:
|
|
||||||
- [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts)
|
|
||||||
- [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
|
||||||
- 回合与会话状态:
|
|
||||||
- [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts)
|
|
||||||
- [src/store/game/game-session-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-session-store.ts)
|
|
||||||
- 页面入口已统一在:
|
|
||||||
- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx)
|
|
||||||
|
|
||||||
当前主要问题有 4 个:
|
|
||||||
|
|
||||||
1. `Mobile` 界面直接复用了 `Desktop` 组件
|
|
||||||
例如 [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx) 当前直接引用了 `DesktopAnimal`、`DesktopGameHistory`、`DesktopTitle`。这会导致移动端无法形成独立布局层。
|
|
||||||
|
|
||||||
2. 业务状态和界面状态混杂
|
|
||||||
一些状态是业务性的,例如当前选中筹码、是否允许下注;另一些只是表现性的,例如按钮点击动画、过渡效果。当前这两种状态没有被清晰拆开。
|
|
||||||
|
|
||||||
3. 组件层直接拼 store 数据或自己维护业务数据
|
|
||||||
例如 [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) 内部自己维护了部分核心控制状态,导致未来 `Mobile` 难以复用。
|
|
||||||
|
|
||||||
4. 弹窗与 UI 业务状态分散在各组件本地 `useState`
|
|
||||||
比如 `auto setting`、`notice`、`user info`、`procedures` 等弹窗,当前都在各自文件内部自管 `open` 或 tab 状态,不利于双端共享和统一调度。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 第一阶段总体原则
|
|
||||||
|
|
||||||
第一阶段采用四层结构:
|
|
||||||
|
|
||||||
1. `api / dto / normalize`
|
|
||||||
2. `store / domain actions`
|
|
||||||
3. `view-model hooks`
|
|
||||||
4. `pc ui` 与 `mobile ui`
|
|
||||||
|
|
||||||
每层职责如下:
|
|
||||||
|
|
||||||
### 3.1 API 层
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 调用后端接口
|
|
||||||
- DTO 转前端领域模型
|
|
||||||
- 不参与 UI 展示逻辑
|
|
||||||
|
|
||||||
保留现状:
|
|
||||||
|
|
||||||
- [src/features/game/api/game-api.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/game-api.ts)
|
|
||||||
|
|
||||||
### 3.2 Store 层
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 保存当前页面共享的真实业务状态
|
|
||||||
- 提供业务动作
|
|
||||||
- 不直接处理视觉布局
|
|
||||||
|
|
||||||
### 3.3 View-model hooks 层
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 从 store 中读取数据
|
|
||||||
- 聚合派生字段
|
|
||||||
- 封装业务事件
|
|
||||||
- 返回给 `PC` 与 `Mobile` 可直接渲染的数据结构
|
|
||||||
|
|
||||||
这层是本次改造的核心。
|
|
||||||
|
|
||||||
### 3.4 UI 层
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 只负责布局、视觉、交互表现
|
|
||||||
- `PC` 与 `Mobile` 各自独立
|
|
||||||
- 不直接拼底层 store
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 第一阶段目录改造清单
|
|
||||||
|
|
||||||
建议目录结构如下。第一阶段以“新增”为主,不要求立刻大规模迁移原文件。
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/features/game/
|
|
||||||
api/
|
|
||||||
game-api.ts
|
|
||||||
index.ts
|
|
||||||
types.ts
|
|
||||||
|
|
||||||
shared/
|
|
||||||
constants.ts
|
|
||||||
mock-data.ts
|
|
||||||
selectors.ts
|
|
||||||
types.ts
|
|
||||||
|
|
||||||
hooks/
|
|
||||||
use-game-header-vm.ts
|
|
||||||
use-game-status-vm.ts
|
|
||||||
use-game-board-vm.ts
|
|
||||||
use-game-history-vm.ts
|
|
||||||
use-game-control-vm.ts
|
|
||||||
use-game-announcement-vm.ts
|
|
||||||
use-game-auto-spin-vm.ts
|
|
||||||
index.ts
|
|
||||||
|
|
||||||
views/
|
|
||||||
pc/
|
|
||||||
pc-entry.tsx
|
|
||||||
mobile/
|
|
||||||
mobile-entry.tsx
|
|
||||||
|
|
||||||
components/
|
|
||||||
shared/
|
|
||||||
game-board.tsx
|
|
||||||
game-history-list.tsx
|
|
||||||
game-status-bar.tsx
|
|
||||||
game-control-panel.tsx
|
|
||||||
desktop/
|
|
||||||
mobile/
|
|
||||||
|
|
||||||
modal/
|
|
||||||
desktop/
|
|
||||||
mobile/
|
|
||||||
```
|
|
||||||
|
|
||||||
Store 目录建议补齐成:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/store/game/
|
|
||||||
game-round-store.ts
|
|
||||||
game-session-store.ts
|
|
||||||
game-ui-store.ts
|
|
||||||
index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 第一阶段可以继续保留现有 `components/desktop` 与 `entry/` 目录
|
|
||||||
- `views/pc` 与 `views/mobile` 可以先作为新目录开始承接之后的容器入口
|
|
||||||
- `components/shared` 第一阶段只抽少量共用展示结构,不强行全部收拢
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 第一阶段 Store 设计
|
|
||||||
|
|
||||||
### 5.1 `game-round-store`
|
|
||||||
|
|
||||||
文件:
|
|
||||||
|
|
||||||
- [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts)
|
|
||||||
|
|
||||||
该 store 保留为“回合与下注核心业务状态”。
|
|
||||||
|
|
||||||
#### 现有字段
|
|
||||||
|
|
||||||
- `cells`
|
|
||||||
- `chips`
|
|
||||||
- `history`
|
|
||||||
- `round`
|
|
||||||
- `selections`
|
|
||||||
- `trends`
|
|
||||||
- `activeChipId`
|
|
||||||
|
|
||||||
#### 现有动作
|
|
||||||
|
|
||||||
- `hydrateRound`
|
|
||||||
- `selectChip`
|
|
||||||
- `placeBet`
|
|
||||||
- `removeSelectionsForCell`
|
|
||||||
- `clearSelections`
|
|
||||||
- `setPhase`
|
|
||||||
- `syncRound`
|
|
||||||
- `upsertSelections`
|
|
||||||
|
|
||||||
#### 第一阶段建议新增动作
|
|
||||||
|
|
||||||
- `setActiveChip(chipId: string)`
|
|
||||||
- `replaceHistory(history: HistoryEntry[])`
|
|
||||||
- `replaceTrends(trends: TrendEntry[])`
|
|
||||||
- `replaceCells(cells: GameCell[])`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 如果不想增加重复动作,也可以只统一命名 `selectChip`
|
|
||||||
- 新增 `replaceHistory / replaceTrends / replaceCells` 是为了后续轮询、增量同步更清晰
|
|
||||||
|
|
||||||
#### 不应该放在这里的内容
|
|
||||||
|
|
||||||
- 各类 modal 的打开关闭
|
|
||||||
- 用户信息 tab
|
|
||||||
- auto-spin 面板设置
|
|
||||||
- 登录注册表单
|
|
||||||
- 按钮 hover / 点击动画态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.2 `game-session-store`
|
|
||||||
|
|
||||||
文件:
|
|
||||||
|
|
||||||
- [src/store/game/game-session-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-session-store.ts)
|
|
||||||
|
|
||||||
该 store 保留为“会话、连接、公告、面板摘要信息”。
|
|
||||||
|
|
||||||
#### 现有字段
|
|
||||||
|
|
||||||
- `announcements`
|
|
||||||
- `connection`
|
|
||||||
- `dashboard`
|
|
||||||
|
|
||||||
#### 现有动作
|
|
||||||
|
|
||||||
- `hydrateSession`
|
|
||||||
- `dismissAnnouncement`
|
|
||||||
- `markAnnouncementRead`
|
|
||||||
- `setConnectionLatency`
|
|
||||||
- `setConnectionStatus`
|
|
||||||
- `syncConnection`
|
|
||||||
- `syncDashboard`
|
|
||||||
|
|
||||||
#### 第一阶段建议新增字段
|
|
||||||
|
|
||||||
- `serverTimeIso: string | null`
|
|
||||||
- `lastBootstrapAt: string | null`
|
|
||||||
|
|
||||||
#### 第一阶段建议新增动作
|
|
||||||
|
|
||||||
- `setServerTime(serverTimeIso: string)`
|
|
||||||
- `setLastBootstrapAt(iso: string)`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx) 当前展示系统时间,后续建议统一从这里拿
|
|
||||||
- `lastBootstrapAt` 方便后续做重连、刷新、数据同步判断
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.3 新增 `game-ui-store`
|
|
||||||
|
|
||||||
新文件建议:
|
|
||||||
|
|
||||||
- `src/store/game/game-ui-store.ts`
|
|
||||||
|
|
||||||
该 store 专门管理“共享的 UI 业务状态”。
|
|
||||||
|
|
||||||
#### 建议字段
|
|
||||||
|
|
||||||
- `activeModal: GameModalType | null`
|
|
||||||
- `modalPayload: Record<string, unknown> | null`
|
|
||||||
- `selectedUserInfoTab: 'profile' | 'message'`
|
|
||||||
- `isBgmEnabled: boolean`
|
|
||||||
- `isMuted: boolean`
|
|
||||||
- `autoSpinEnabled: boolean`
|
|
||||||
- `autoSpinSettings`
|
|
||||||
- `stopIfBalanceLowerThan: string`
|
|
||||||
- `stopIfSingleWinExceeds: string`
|
|
||||||
- `stopOnAnyJackpot: boolean`
|
|
||||||
- `authForm`
|
|
||||||
- `loginAccount: string`
|
|
||||||
- `loginPassword: string`
|
|
||||||
- `registerAccount: string`
|
|
||||||
- `registerPassword: string`
|
|
||||||
- `registerConfirmPassword: string`
|
|
||||||
- `proceduresType: 'withdraw' | 'topup' | null`
|
|
||||||
|
|
||||||
#### 建议动作
|
|
||||||
|
|
||||||
- `openModal(type, payload?)`
|
|
||||||
- `closeModal()`
|
|
||||||
- `setSelectedUserInfoTab(tab)`
|
|
||||||
- `toggleBgm()`
|
|
||||||
- `setAutoSpinEnabled(enabled)`
|
|
||||||
- `setAutoSpinSetting(key, value)`
|
|
||||||
- `setAuthField(field, value)`
|
|
||||||
- `setProceduresType(type)`
|
|
||||||
- `resetAuthForm()`
|
|
||||||
- `resetAutoSpinSettings()`
|
|
||||||
|
|
||||||
#### 第一阶段要接管的现有状态来源
|
|
||||||
|
|
||||||
- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx)
|
|
||||||
- [src/features/game/modal/desktop/desktop-notice-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-notice-modal.tsx)
|
|
||||||
- [src/features/game/modal/desktop/desktop-userInfo-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-userInfo-modal.tsx)
|
|
||||||
- [src/features/game/modal/desktop/desktop-procedures-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-procedures-modal.tsx)
|
|
||||||
- [src/features/game/modal/desktop/desktop-login-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-login-modal.tsx)
|
|
||||||
- [src/features/game/modal/desktop/desktop-register-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-register-modal.tsx)
|
|
||||||
- [src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx)
|
|
||||||
|
|
||||||
#### 不要放到 `game-ui-store` 的状态
|
|
||||||
|
|
||||||
- `confirmClicked`
|
|
||||||
- `clickedId`
|
|
||||||
- `hidingId`
|
|
||||||
|
|
||||||
这些状态目前位于 [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx),属于纯动画与表现状态,继续留在本地组件即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 第一阶段 Hook 设计
|
|
||||||
|
|
||||||
本阶段重点不是“先抽 shared 组件”,而是先抽统一的 `view-model hooks`。
|
|
||||||
|
|
||||||
这些 hook 的目标:
|
|
||||||
|
|
||||||
- 统一封装 store 读取
|
|
||||||
- 统一组织派生字段
|
|
||||||
- 统一输出 PC/Mobile 都能消费的 props
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.1 `useGameHeaderVm`
|
|
||||||
|
|
||||||
建议文件:
|
|
||||||
|
|
||||||
- `src/features/game/hooks/use-game-header-vm.ts`
|
|
||||||
|
|
||||||
服务组件:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx)
|
|
||||||
- 未来的 `mobile-header.tsx`
|
|
||||||
|
|
||||||
#### 建议暴露字段
|
|
||||||
|
|
||||||
- `latencyMs: number | null`
|
|
||||||
- `latencyLabel: string`
|
|
||||||
- `connectionStatus: 'connected' | 'reconnecting' | 'offline'`
|
|
||||||
- `systemTimeLabel: string`
|
|
||||||
- `username: string`
|
|
||||||
- `balanceLabel: string`
|
|
||||||
- `avatarUrl: string | null`
|
|
||||||
- `unreadMessageCount: number`
|
|
||||||
- `isBgmEnabled: boolean`
|
|
||||||
|
|
||||||
#### 建议暴露动作
|
|
||||||
|
|
||||||
- `onOpenRules()`
|
|
||||||
- `onOpenMessages()`
|
|
||||||
- `onToggleBgm()`
|
|
||||||
- `onOpenProfile()`
|
|
||||||
|
|
||||||
#### 说明
|
|
||||||
|
|
||||||
- `DesktopHeader` 未来不再直接读取 store 或写死文案来源
|
|
||||||
- header 只消费“最终展示值”与“用户点击后的动作”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.2 `useGameStatusVm`
|
|
||||||
|
|
||||||
建议文件:
|
|
||||||
|
|
||||||
- `src/features/game/hooks/use-game-status-vm.ts`
|
|
||||||
|
|
||||||
服务组件:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx)
|
|
||||||
|
|
||||||
#### 建议暴露字段
|
|
||||||
|
|
||||||
- `roundId: string`
|
|
||||||
- `oddsLabel: string`
|
|
||||||
- `streakLabel: string`
|
|
||||||
- `limitLabel: string`
|
|
||||||
- `countdownSeconds: number`
|
|
||||||
- `countdownMs: number`
|
|
||||||
- `phase: RoundPhase`
|
|
||||||
- `phaseLabel: string`
|
|
||||||
- `phaseTone: 'open' | 'locked' | 'settled'`
|
|
||||||
- `acceptingBets: boolean`
|
|
||||||
|
|
||||||
#### 说明
|
|
||||||
|
|
||||||
- 倒计时展示逻辑、回合阶段文案逻辑统一在这里处理
|
|
||||||
- `PC` 和 `Mobile` 不各自重复拼 countdown 与 round phase
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.3 `useGameBoardVm`
|
|
||||||
|
|
||||||
建议文件:
|
|
||||||
|
|
||||||
- `src/features/game/hooks/use-game-board-vm.ts`
|
|
||||||
|
|
||||||
服务组件:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
|
||||||
- 未来 `mobile-board.tsx`
|
|
||||||
|
|
||||||
#### 建议暴露字段
|
|
||||||
|
|
||||||
- `cells`
|
|
||||||
- `id`
|
|
||||||
- `imageUrl`
|
|
||||||
- `status`
|
|
||||||
- `isSelected`
|
|
||||||
- `isWinningCell`
|
|
||||||
- `selectionAmount`
|
|
||||||
- `selectionCount`
|
|
||||||
- `hitCount`
|
|
||||||
- `currentStreak`
|
|
||||||
- `activeCellId: number | null`
|
|
||||||
- `canPlaceBets: boolean`
|
|
||||||
- `totalSelectedCount: number`
|
|
||||||
- `totalSelectedAmount: number`
|
|
||||||
|
|
||||||
#### 建议暴露动作
|
|
||||||
|
|
||||||
- `onCellPress(cellId: number)`
|
|
||||||
- `onCellLongPress?(cellId: number)`
|
|
||||||
- `onCellClear(cellId: number)`
|
|
||||||
|
|
||||||
#### 说明
|
|
||||||
|
|
||||||
- 优先基于 [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts) 中的:
|
|
||||||
- `buildGameCellViewModels`
|
|
||||||
- `groupSelectionsByCell`
|
|
||||||
- `getSelectionTotal`
|
|
||||||
- `DesktopAnimal` 不应该自己管理业务选择逻辑,只负责渲染 cell grid
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.4 `useGameHistoryVm`
|
|
||||||
|
|
||||||
建议文件:
|
|
||||||
|
|
||||||
- `src/features/game/hooks/use-game-history-vm.ts`
|
|
||||||
|
|
||||||
服务组件:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx)
|
|
||||||
- 未来 `mobile-history.tsx`
|
|
||||||
|
|
||||||
#### 建议暴露字段
|
|
||||||
|
|
||||||
- `items`
|
|
||||||
- `orderNo`
|
|
||||||
- `roundId`
|
|
||||||
- `winningCellId`
|
|
||||||
- `betAmountLabel`
|
|
||||||
- `totalAmountLabel`
|
|
||||||
- `winAmountLabel`
|
|
||||||
- `statusLabel`
|
|
||||||
- `createdAtLabel`
|
|
||||||
- `isEmpty: boolean`
|
|
||||||
- `emptyText: string`
|
|
||||||
- `recentWinningCellIds: number[]`
|
|
||||||
|
|
||||||
#### 说明
|
|
||||||
|
|
||||||
- 当前 [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx) 里还是本地 fake data,第一阶段应切换为 VM 数据
|
|
||||||
- `PC` 与 `Mobile` 历史列表的数据格式统一,只是布局不同
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.5 `useGameControlVm`
|
|
||||||
|
|
||||||
建议文件:
|
|
||||||
|
|
||||||
- `src/features/game/hooks/use-game-control-vm.ts`
|
|
||||||
|
|
||||||
服务组件:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx)
|
|
||||||
- 未来 `mobile-control.tsx`
|
|
||||||
|
|
||||||
#### 建议暴露字段
|
|
||||||
|
|
||||||
- `chips`
|
|
||||||
- `id`
|
|
||||||
- `value`
|
|
||||||
- `amount`
|
|
||||||
- `color`
|
|
||||||
- `isSelected`
|
|
||||||
- `isDefault`
|
|
||||||
- `selectedChipId: string | null`
|
|
||||||
- `selectedChipAmountLabel: string`
|
|
||||||
- `selectionTotalLabel: string`
|
|
||||||
- `selectedCountLabel: string`
|
|
||||||
- `actionButtons`
|
|
||||||
- `id`
|
|
||||||
- `label`
|
|
||||||
- `disabled`
|
|
||||||
- `canConfirm: boolean`
|
|
||||||
- `canClear: boolean`
|
|
||||||
- `canRepeat: boolean`
|
|
||||||
- `canOpenAutoSpin: boolean`
|
|
||||||
|
|
||||||
#### 建议暴露动作
|
|
||||||
|
|
||||||
- `onChipSelect(chipId: string)`
|
|
||||||
- `onAddChip()`
|
|
||||||
- `onReduceChip()`
|
|
||||||
- `onClearSelections()`
|
|
||||||
- `onRepeatLastRound()`
|
|
||||||
- `onOpenAutoSpin()`
|
|
||||||
- `onConfirmBet()`
|
|
||||||
|
|
||||||
#### 说明
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx) 内部仍可保留点击动画和短暂视觉反馈
|
|
||||||
- 但“当前选中筹码、是否可确认、操作是否禁用”等必须来自 VM,而不是继续散在组件内部
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.6 `useGameAnnouncementVm`
|
|
||||||
|
|
||||||
建议文件:
|
|
||||||
|
|
||||||
- `src/features/game/hooks/use-game-announcement-vm.ts`
|
|
||||||
|
|
||||||
服务组件:
|
|
||||||
|
|
||||||
- [src/features/game/components/shared/game-announcement-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-announcement-modal.tsx)
|
|
||||||
- header 消息入口
|
|
||||||
- notice modal
|
|
||||||
|
|
||||||
#### 建议暴露字段
|
|
||||||
|
|
||||||
- `activeAnnouncement: AnnouncementItem | null`
|
|
||||||
- `visibleAnnouncements: AnnouncementItem[]`
|
|
||||||
- `unreadCount: number`
|
|
||||||
- `hasUnread: boolean`
|
|
||||||
- `isOpen: boolean`
|
|
||||||
|
|
||||||
#### 建议暴露动作
|
|
||||||
|
|
||||||
- `onOpenAnnouncement(id?: string)`
|
|
||||||
- `onDismissAnnouncement(id: string)`
|
|
||||||
- `onMarkRead(id: string)`
|
|
||||||
- `onCloseAnnouncement()`
|
|
||||||
|
|
||||||
#### 说明
|
|
||||||
|
|
||||||
- 这样公告逻辑不会散落在 [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx) 和多个具体 modal 中
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6.7 `useGameAutoSpinVm`
|
|
||||||
|
|
||||||
建议文件:
|
|
||||||
|
|
||||||
- `src/features/game/hooks/use-game-auto-spin-vm.ts`
|
|
||||||
|
|
||||||
服务组件:
|
|
||||||
|
|
||||||
- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx)
|
|
||||||
- 未来 `mobile-auto-setting-modal.tsx`
|
|
||||||
|
|
||||||
#### 建议暴露字段
|
|
||||||
|
|
||||||
- `open: boolean`
|
|
||||||
- `enabled: boolean`
|
|
||||||
- `stopIfBalanceLowerThan: string`
|
|
||||||
- `stopIfSingleWinExceeds: string`
|
|
||||||
- `stopOnAnyJackpot: boolean`
|
|
||||||
- `canSubmit: boolean`
|
|
||||||
|
|
||||||
#### 建议暴露动作
|
|
||||||
|
|
||||||
- `onOpen()`
|
|
||||||
- `onClose()`
|
|
||||||
- `onToggleEnabled(enabled: boolean)`
|
|
||||||
- `onChangeBalanceThreshold(value: string)`
|
|
||||||
- `onChangeSingleWinThreshold(value: string)`
|
|
||||||
- `onToggleStopOnJackpot(enabled: boolean)`
|
|
||||||
- `onSubmit()`
|
|
||||||
|
|
||||||
#### 说明
|
|
||||||
|
|
||||||
- 这是最适合第一阶段打样的一类场景:数据结构固定、PC/Mobile 表现不同、交互规则相同
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 第一阶段共享展示组件建议
|
|
||||||
|
|
||||||
本阶段不要一口气把所有 `Desktop` 组件变成跨端共享。
|
|
||||||
|
|
||||||
建议只优先抽 2 到 3 个 shared 展示组件:
|
|
||||||
|
|
||||||
- `src/features/game/components/shared/game-board.tsx`
|
|
||||||
- `src/features/game/components/shared/game-history-list.tsx`
|
|
||||||
- `src/features/game/components/shared/game-status-bar.tsx`
|
|
||||||
|
|
||||||
### 7.1 `game-board`
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 只接收 `cells`
|
|
||||||
- 只接收 `onCellPress`
|
|
||||||
- 不直接读 store
|
|
||||||
|
|
||||||
这样:
|
|
||||||
|
|
||||||
- `PC` 可以包一层桌面 grid 样式
|
|
||||||
- `Mobile` 可以包一层移动端布局样式
|
|
||||||
|
|
||||||
### 7.2 `game-history-list`
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 只负责历史列表渲染
|
|
||||||
- 不关心历史数据怎么来
|
|
||||||
|
|
||||||
### 7.3 `game-status-bar`
|
|
||||||
|
|
||||||
职责:
|
|
||||||
|
|
||||||
- 渲染赔率、回合、倒计时、当前状态
|
|
||||||
- 由 `useGameStatusVm` 驱动
|
|
||||||
|
|
||||||
不建议第一阶段就抽 shared 的内容:
|
|
||||||
|
|
||||||
- header
|
|
||||||
- control panel
|
|
||||||
- 大多数 modal
|
|
||||||
|
|
||||||
因为这些区域 `PC/Mobile` 差异通常更大,先共用 hook 比先共用视觉组件更稳。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 第一阶段 UI 层改造边界
|
|
||||||
|
|
||||||
本阶段只定 3 条规则:
|
|
||||||
|
|
||||||
### 8.1 `EntryPage` 可以直接处理 hydration
|
|
||||||
|
|
||||||
文件:
|
|
||||||
|
|
||||||
- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx)
|
|
||||||
|
|
||||||
保留它作为:
|
|
||||||
|
|
||||||
- bootstrap 获取入口
|
|
||||||
- store hydrate 入口
|
|
||||||
- PC/Mobile 分流入口
|
|
||||||
|
|
||||||
### 8.2 容器组件可以调用 VM hooks
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
- `PcEntry`
|
|
||||||
- `MobileEntry`
|
|
||||||
- 各 section 容器
|
|
||||||
- modal host
|
|
||||||
|
|
||||||
这些组件可以:
|
|
||||||
|
|
||||||
- `useGameBoardVm()`
|
|
||||||
- `useGameControlVm()`
|
|
||||||
- `useGameStatusVm()`
|
|
||||||
|
|
||||||
### 8.3 纯展示组件不直接碰 store
|
|
||||||
|
|
||||||
比如:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
|
||||||
- [src/features/game/components/desktop/desktop-game-history.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-history.tsx)
|
|
||||||
- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx)
|
|
||||||
- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx)
|
|
||||||
|
|
||||||
未来都应该尽量演进为:
|
|
||||||
|
|
||||||
- 只吃 props
|
|
||||||
- 不主动拼底层 store
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 第一阶段实施顺序
|
|
||||||
|
|
||||||
建议严格按这个顺序执行,避免改动面过大。
|
|
||||||
|
|
||||||
### Step 1:新增 UI Store 与 Hooks 目录
|
|
||||||
|
|
||||||
新增:
|
|
||||||
|
|
||||||
- `src/store/game/game-ui-store.ts`
|
|
||||||
- `src/features/game/hooks/`
|
|
||||||
|
|
||||||
这一步只建壳子,不急着接业务。
|
|
||||||
|
|
||||||
### Step 2:实现 `useGameBoardVm` 与 `useGameControlVm`
|
|
||||||
|
|
||||||
这两块是主玩法交互核心,优先收益最大。
|
|
||||||
|
|
||||||
优先接的现有文件:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
|
||||||
- [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx)
|
|
||||||
|
|
||||||
### Step 3:让 `DesktopAnimal` 只吃 props
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
- 不直接读 store
|
|
||||||
- 不自己处理业务选择逻辑
|
|
||||||
|
|
||||||
保留字段形态:
|
|
||||||
|
|
||||||
- `cells`
|
|
||||||
- `activeId`
|
|
||||||
- `onSelect`
|
|
||||||
|
|
||||||
### Step 4:让 `DesktopControl` 业务数据来自 VM
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
- 当前筹码列表、已选筹码、总下注额等来自 `useGameControlVm`
|
|
||||||
- 点击动画状态仍然保留本地
|
|
||||||
|
|
||||||
### Step 5:停止 `Mobile` 直接复用 `Desktop` 组件
|
|
||||||
|
|
||||||
文件:
|
|
||||||
|
|
||||||
- [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx)
|
|
||||||
|
|
||||||
当前问题:
|
|
||||||
|
|
||||||
- 直接 import `DesktopAnimal`
|
|
||||||
- 直接 import `DesktopGameHistory`
|
|
||||||
- 直接 import `DesktopTitle`
|
|
||||||
|
|
||||||
第一阶段目标:
|
|
||||||
|
|
||||||
- 改成 `MobileBoardSection`
|
|
||||||
- 改成 `MobileHistorySection`
|
|
||||||
- 共享 hook,不共享 `Desktop` 布局组件
|
|
||||||
|
|
||||||
### Step 6:实现 `useGameStatusVm` 与 `useGameHeaderVm`
|
|
||||||
|
|
||||||
接入文件:
|
|
||||||
|
|
||||||
- [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx)
|
|
||||||
- [src/features/game/components/desktop/desktop-header.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-header.tsx)
|
|
||||||
|
|
||||||
### Step 7:实现 `game-ui-store` + `useGameAutoSpinVm`
|
|
||||||
|
|
||||||
先接一个最合适的 modal:
|
|
||||||
|
|
||||||
- [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx)
|
|
||||||
|
|
||||||
跑通后,再接:
|
|
||||||
|
|
||||||
- notice
|
|
||||||
- user info
|
|
||||||
- procedures
|
|
||||||
- login/register
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 第一阶段优先改造文件清单
|
|
||||||
|
|
||||||
第一阶段最值得优先改的 6 个文件如下:
|
|
||||||
|
|
||||||
1. [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/entry-page.tsx)
|
|
||||||
2. [src/features/game/entry/mobile-entry.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/mobile-entry.tsx)
|
|
||||||
3. [src/features/game/components/desktop/desktop-animal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-animal.tsx)
|
|
||||||
4. [src/features/game/components/desktop/desktop-control.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-control.tsx)
|
|
||||||
5. [src/features/game/components/desktop/desktop-status.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-status.tsx)
|
|
||||||
6. [src/features/game/modal/desktop/desktop-auto-setting-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/modal/desktop/desktop-auto-setting-modal.tsx)
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 它们覆盖了主玩法、主状态、移动端入口和共享 modal 模式
|
|
||||||
- 先把这 6 个改通,数据与界面分层的骨架就建立起来了
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 第一阶段完成后的验收标准
|
|
||||||
|
|
||||||
做到以下几点,即可视为第一阶段完成:
|
|
||||||
|
|
||||||
- `PC` 与 `Mobile` 都不直接拼 API / store 的底层结构
|
|
||||||
- `Mobile` 不再直接 import `DesktopXxx`
|
|
||||||
- 至少 `board / control / status` 这 3 类区域中的 2 类已经改为 “VM 驱动 + 展示组件消费 props”
|
|
||||||
- modal 开关与 tab 这类共享 UI 业务状态已进入 `game-ui-store`
|
|
||||||
- 视觉动画状态与业务状态分层清晰
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 风险与边界说明
|
|
||||||
|
|
||||||
### 12.1 不要在第一阶段做的事情
|
|
||||||
|
|
||||||
- 不要一次性把所有桌面组件都改成共享组件
|
|
||||||
- 不要同时把全部 modal 状态都收进 store
|
|
||||||
- 不要同时重做 API 调用方式
|
|
||||||
- 不要试图在第一阶段解决所有命名和目录历史包袱
|
|
||||||
|
|
||||||
### 12.2 第一阶段最容易犯的错误
|
|
||||||
|
|
||||||
- 把所有状态都塞进 store
|
|
||||||
结果动画、hover、点击反馈这些纯表现状态也被全局化,反而变复杂
|
|
||||||
|
|
||||||
- 先抽 shared UI,而不是先抽 VM
|
|
||||||
这样容易做出一堆仍然耦合 store 的“伪共享组件”
|
|
||||||
|
|
||||||
- Mobile 继续复用 Desktop 组件
|
|
||||||
这会让后面任何布局调整都越来越痛苦
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 结论
|
|
||||||
|
|
||||||
本项目第一阶段的正确方向不是:
|
|
||||||
|
|
||||||
- “PC 一套状态,Mobile 一套状态”
|
|
||||||
|
|
||||||
而是:
|
|
||||||
|
|
||||||
- 一套 `domain state`
|
|
||||||
- 一套 `domain actions`
|
|
||||||
- 一套 `view-model hooks`
|
|
||||||
- 两套 `presentation`
|
|
||||||
|
|
||||||
按本实施稿推进后,后续才适合继续做:
|
|
||||||
|
|
||||||
- 第二阶段:shared UI 组件整理
|
|
||||||
- 第三阶段:modal host 统一
|
|
||||||
- 第四阶段:实时数据同步、轮询或 websocket 接入
|
|
||||||
|
|
||||||
@@ -1,824 +0,0 @@
|
|||||||
# 36字花移动端接口设计草案(V1)
|
|
||||||
|
|
||||||
本文基于 `docs/36字花-数据库与实施计划.md` 与 PRD,先给出移动端可对接的接口清单与字段初设。
|
|
||||||
口径遵循:**全平台单期号、单开奖结果**;渠道仅用于归属、分润与风控,不拆分对局。
|
|
||||||
|
|
||||||
**补充(2026-04)**:§**1.5** 描述服务端 **Redis 热点缓存**(`GameHotDataRedis`),**不改变**各接口 URL、参数与响应字段约定,仅供联调与运维对照。
|
|
||||||
|
|
||||||
## 1. 设计约定
|
|
||||||
|
|
||||||
### 1.1 基础约定
|
|
||||||
- 协议:HTTPS + JSON
|
|
||||||
- 接口命名规范:`/api/{module}/{action}`,且必须满足正则 `^/api/[a-z]+/[a-z]+[A-Z][a-zA-Z]*$`
|
|
||||||
- **请求方法**:所有移动端业务接口(`/api/*`,不含 `/api/v1/authToken`)一律使用 `POST` 调用;查询类接口同时兼容 `GET`(便于浏览器/调试工具直接访问),客户端统一走 `POST`
|
|
||||||
- `POST` 时请求头 `Content-Type: application/json`,参数放在 JSON body
|
|
||||||
- `GET` 兼容模式下,参数走 URL query string
|
|
||||||
- **例外**:公告模块 `/api/notice/noticeList`、`/api/notice/noticeDetail`、`/api/notice/noticeConfirm` 与模拟收银台页 `/api/finance/depositMockPayPage` **仅支持 `GET`**,参数一律走 URL query string
|
|
||||||
- 鉴权类接口 `/api/v1/authToken` 仍为 `GET`
|
|
||||||
- 时间:UTC 时间戳(秒) + 服务端时区配置
|
|
||||||
- 金额:数字传输(如 `"100.00"`),客户端展示统一保留两位小数(存储仍为 `decimal(18,2)`)
|
|
||||||
- 幂等:关键写接口要求 `idempotency_key`
|
|
||||||
- 请求头(必带):
|
|
||||||
- `auth-token`:通过 `GET /api/v1/authToken` 获取的接口鉴权令牌(含义:接口访问的签名鉴权凭证)
|
|
||||||
- `user-token`:用户登录态令牌;需要登录的接口必带
|
|
||||||
- 语言请求头:
|
|
||||||
- `lang=zh`:返回中文(默认)
|
|
||||||
- `lang=en`:返回英文
|
|
||||||
|
|
||||||
### 1.2 通用响应结构
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 1,
|
|
||||||
"message": "ok",
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `code=1` 表示成功,非 1 为业务错误
|
|
||||||
- `/api/*` 所有接口返回文案支持中英双语:默认中文;请求头 `lang=en` 返回英文,`lang=zh` 返回中文
|
|
||||||
- 建议错误码段(按错误性质):
|
|
||||||
- `1000-1099`:参数错误(字段缺失、类型错误、格式错误、超范围)
|
|
||||||
- `1100-1199`:鉴权错误(未登录、token 失效、权限不足)
|
|
||||||
- `2000-2999`:业务错误(余额不足、对局不存在、订单不存在、公告不存在)
|
|
||||||
- `3000-3099`:流程错误(非法流程/状态不允许,如封盘后下注、重复确认、状态跃迁非法)
|
|
||||||
- `5000-5999`:系统错误(服务异常、依赖超时、未知错误)
|
|
||||||
|
|
||||||
- 推荐基础错误码(首版):
|
|
||||||
- `1`:成功
|
|
||||||
- `1001`:参数缺失
|
|
||||||
- `1002`:参数格式错误
|
|
||||||
- `1003`:参数取值非法
|
|
||||||
- `1101`:未登录或登录已过期
|
|
||||||
- `1103`:无权限操作
|
|
||||||
- `2001`:余额不足
|
|
||||||
- `2002`:对局不存在
|
|
||||||
- `2003`:订单不存在
|
|
||||||
- `2004`:公告不存在
|
|
||||||
- `3001`:当前流程不允许该操作
|
|
||||||
- `3002`:已封盘,禁止下注
|
|
||||||
- `3003`:重复请求(幂等冲突)
|
|
||||||
- `5000`:系统繁忙,请稍后重试
|
|
||||||
|
|
||||||
### 1.3 鉴权方式
|
|
||||||
- **接口鉴权(auth-token)**:所有移动端业务接口请求时必须携带请求头 `auth-token`(由 `/api/v1/authToken` 签发)
|
|
||||||
- **用户登录鉴权(user-token)**:需要登录的接口携带请求头 `user-token`;token 失效后调用刷新或重新登录
|
|
||||||
|
|
||||||
### 1.4 获取接口鉴权 Token(auth-token)
|
|
||||||
- **GET** `/api/v1/authToken`
|
|
||||||
- 用途:获取 `auth-token`(所有接口请求头必带)
|
|
||||||
|
|
||||||
请求示例:
|
|
||||||
`/api/v1/authToken?secret=564d14asdasd113e46542asd6das1a2a×tamp=1776331077&device_id=1&signature=AD84C49880896DBC16C59C7B122D1FF7`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `secret`:string(含义:客户端密钥;服务端从环境变量 `AUTH_TOKEN_SECRET` 校验)
|
|
||||||
- `timestamp`:int(含义:请求时间戳;服务端允许与服务器时间误差 ±300 秒)
|
|
||||||
- `device_id`:string(含义:设备码)
|
|
||||||
- `signature`:string(含义:签名值)
|
|
||||||
|
|
||||||
签名算法:
|
|
||||||
- 取参与签名的参数(不含 `signature`):`device_id`、`secret`、`timestamp`
|
|
||||||
- 按参数名 **a-z** 排序后拼接为字符串:`key=value&key=value...`
|
|
||||||
- 计算:`signature = strtoupper(md5(拼接字符串))`
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `auth_token`:string(含义:接口鉴权 token;放到请求头 `auth-token`)
|
|
||||||
- `expires_in`:int(含义:有效期秒数)
|
|
||||||
- `server_time`:int(含义:服务器时间戳,用于校时)
|
|
||||||
|
|
||||||
可能错误码:
|
|
||||||
- `1001` 参数缺失
|
|
||||||
- `1002` 参数格式错误
|
|
||||||
- `1103` 密钥无效/签名错误
|
|
||||||
- `3001` 时间戳无效
|
|
||||||
|
|
||||||
### 1.5 服务端性能与 Redis 热点缓存(实现说明)
|
|
||||||
|
|
||||||
> **对客户端无契约变更**:请求路径、参数、响应 JSON 形状与错误码均不因缓存而改变;本节仅说明服务端如何降延迟、读路径与一致性注意点。
|
|
||||||
|
|
||||||
**与「框架文件缓存」的区别**
|
|
||||||
|
|
||||||
| 配置 | 作用域 |
|
|
||||||
|------|--------|
|
|
||||||
| `CACHE_DRIVER`(`config/cache.php`,如 `file`) | Think-ORM / `get_sys_config()` 等**系统参数表 `config`** 的模型缓存,落盘在 `runtime/cache`,**不参与**本游戏业务热点路径。 |
|
|
||||||
| `GAME_HOT_CACHE_*`(`config/game_hot_cache.php`) | 游戏侧 **`user` / `game_config` / `game_record`** 行级 JSON 缓存,走 **`support\Redis`**(`config/redis.php` 连接),键前缀 `dfw:v1:`。 |
|
|
||||||
|
|
||||||
**服务端缓存覆盖(与移动端直接相关的读路径)**
|
|
||||||
|
|
||||||
- **用户**:会员鉴权优先读 Redis 中的 `user` 行快照,未命中再查库并回填。**余额、连胜、打码量等变更**落库后,统一经 **`GameHotDataCoordinator::afterUserCommitted($userId)`**:先 **`GameHotDataRedis::userReplaceCacheFromDb`** 与 DB 对齐,再向 Redis 写队列投递幂等刷新任务(见 `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`),用于削峰而非替代同步回源。
|
|
||||||
- **游戏配置**:`game_config` 按 `config_key` 缓存。后台直连 `Db` 更新时须 **`GameHotDataCoordinator::afterGameConfigKeyCommitted($key)`**(模型 `GameConfig` 事件与独立表单控制器已接入);独立保存接口在写入前对同一 `config_key` 使用 **`GameHotDataLock`(`TYPE_GAME_CONFIG`)** 互斥。勿仅删除缓存键而不回源,否则最长不一致窗口为 TTL。
|
|
||||||
- **对局**:当前活跃局、按 `id` 的局、最新一条 `game_record` 等;写库后经 **`GameHotDataCoordinator::afterGameRecordCommitted`** 同步刷新相关 Redis 键并入队。开奖/封盘等路径另可按记录 id 使用 **`GameHotDataLock`(`TYPE_GAME_RECORD`)** 串行化。
|
|
||||||
|
|
||||||
**环境变量(示例见仓库根目录 `.env-example`)**
|
|
||||||
|
|
||||||
- `GAME_HOT_CACHE_ENABLED`:是否启用上述 Redis 热点缓存(`false` 时全程回退数据库)。
|
|
||||||
- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`:各类缓存 TTL(秒);**以写后同步回源为主**,TTL 仅作兜底。
|
|
||||||
- `GAME_HOT_CACHE_ENABLE_WRITE_QUEUE` 及队列长度、消费进程间隔等:控制写库后的**幂等刷新任务**是否入队及背压策略(见 `config/game_hot_cache.php`)。
|
|
||||||
|
|
||||||
**一致性提示(联调/测试)**
|
|
||||||
|
|
||||||
- 任何绕过协调入口、只改 DB 不调用 **`GameHotDataCoordinator`** 的手工脚本,都可能与 Redis 短期不一致;生产环境应避免。
|
|
||||||
- **`POST /api/game/betPlace`** 扣款路径使用与后台钱包加减点相同的 **用户维度 Redis 锁**(`GameHotDataRedis::userAdminMutationLockTry`)及 **`WHERE coin = ?` 条件更新**,与并发派彩/后台调账互斥;失败时返回 **§4.2** 所列中文说明。
|
|
||||||
- 客户端仍可按 **§3.2 `dictionaryList` 的 `version`** 做本地缓存;服务端字典另有 Redis 加速,二者可同时存在。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 认证与账户模块(user)
|
|
||||||
|
|
||||||
### 2.1 注册
|
|
||||||
- **POST** `/api/user/register`
|
|
||||||
- 用途:仅手机号注册并绑定邀请归属(admin/channel)
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `username`:string,手机号(含义:注册账号,仅支持大陆手机号)
|
|
||||||
- `password`:string,明文经 HTTPS 传输(含义:登录密码,服务端需加盐哈希存储)
|
|
||||||
- `invite_code`:string,必填(含义:子代理邀请码,用于绑定渠道 `channel_id` 与归属)
|
|
||||||
- `device_id`:string,可选(含义:设备标识,用于风控与登录记录)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `user-token`:string(含义:后续接口登录态令牌;用于需要登录的接口请求头)
|
|
||||||
- `refresh_token`:string,可选(含义:用于刷新访问令牌)
|
|
||||||
- `expires_in`:int(秒,含义:令牌有效期)
|
|
||||||
- `user`:object(仅返回非私密信息,不返回 `id`)
|
|
||||||
- `uuid`:string(含义:用户对外唯一标识,10 位)
|
|
||||||
- `username`:string(含义:用户昵称/展示名)
|
|
||||||
- `coin`:string(含义:当前余额)
|
|
||||||
- `channel_id`:int(含义:归属渠道 ID)
|
|
||||||
- `risk_flags`:int(含义:风控状态位)
|
|
||||||
|
|
||||||
### 2.2 登录
|
|
||||||
- **POST** `/api/user/login`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `username`:string(含义:登录账号,当前支持手机号)
|
|
||||||
- `password`:string(含义:登录密码)
|
|
||||||
- `device_id`:string,可选(含义:设备标识,辅助风控)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `user-token`:string(含义:访问令牌;用于需要登录的接口请求头)
|
|
||||||
- `refresh_token`:string,可选(含义:用于刷新访问令牌)
|
|
||||||
- `expires_in`:int(含义:访问令牌剩余有效秒数)
|
|
||||||
- `user`:object(仅返回非私密信息,不返回 `id`)
|
|
||||||
- `uuid`:string(含义:用户对外唯一标识,10 位)
|
|
||||||
- `username`:string(含义:用户昵称/展示名)
|
|
||||||
- `coin`:string(含义:当前余额)
|
|
||||||
- `channel_id`:int(含义:归属渠道 ID)
|
|
||||||
- `risk_flags`:int(含义:风控状态位)
|
|
||||||
|
|
||||||
### 2.3 获取当前用户信息
|
|
||||||
- **POST** `/api/user/profile`
|
|
||||||
|
|
||||||
返回参数(金额类字段统一 2 位小数字符串,与钱包展示口径一致):
|
|
||||||
|
|
||||||
**基础档案**
|
|
||||||
- `uuid`:string(含义:用户对外唯一标识,10 位)
|
|
||||||
- `username`:string(含义:昵称)
|
|
||||||
- `head_image`:string(含义:头像地址)
|
|
||||||
- `phone`:string(含义:手机号)
|
|
||||||
- `email`:string(含义:邮箱)
|
|
||||||
- `register_invite_code`:string(含义:注册邀请码快照)
|
|
||||||
- `channel_id`:int(含义:归属渠道 ID)
|
|
||||||
- `risk_flags`:int(含义:风控状态位)
|
|
||||||
- `current_streak`:int(含义:当前连胜次数)
|
|
||||||
- `last_bet_period_no`:string(含义:最近一笔有效下注所在期号)
|
|
||||||
- `create_time`:int(含义:注册时间戳)
|
|
||||||
|
|
||||||
**资金与提现配额**
|
|
||||||
- `coin` / `coin_balance`:string(含义:当前余额;两字段同值)
|
|
||||||
- `frozen_balance`:string(含义:冻结余额;无冻结场景,固定 `0.00`)
|
|
||||||
- `total_deposit_coin`:string(含义:累计充值)
|
|
||||||
- `total_withdraw_coin`:string(含义:累计提现;受理后累加)
|
|
||||||
- `bet_flow_coin`:string(含义:打码量/流水;开奖结算后按每注 `total_amount` 1:1 累加)
|
|
||||||
- `max_withdrawable`:string(含义:**当前允许发起的单笔最大提现金额** = `min(coin_balance, max_withdraw_by_flow)`)
|
|
||||||
- `withdraw_flow`:object(含义:打码量 / 提现配额诊断快照,此处额外附 `pending_withdraw`)
|
|
||||||
- `ratio`:string(打码量倍数;`0` 表示不限打码)
|
|
||||||
- `net_deposit`:string(净充值 = max(0, 累计充值 − 累计提现))
|
|
||||||
- `required_bet_flow`:string(按门槛口径所需打码量,纯展示)
|
|
||||||
- `remaining_bet_flow`:string(按门槛口径还差多少打码量,纯展示)
|
|
||||||
- `eligible`:bool(是否满足整体门槛,纯展示;真正放行以 `max_withdrawable` 为准)
|
|
||||||
- `max_withdraw_by_flow`:string/null(仅按打码量折算的上限;`ratio=0` 时为 `null`)
|
|
||||||
- `flow_unlimited`:bool(是否处于"不限打码"状态)
|
|
||||||
- `pending_withdraw`:object
|
|
||||||
- `count`:int(当前待审核提现订单数)
|
|
||||||
- `max`:int(单用户最多允许的待审核提现数,当前为 `3`;超过 `withdrawCreate` 返回 `code=2004`)
|
|
||||||
|
|
||||||
### 2.4 刷新令牌(可选)
|
|
||||||
- **POST** `/api/user/refreshToken`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `refresh_token`:string(含义:续签访问令牌的凭证)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `user-token`:string(含义:新访问令牌)
|
|
||||||
- `expires_in`:int(含义:新令牌有效期)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 游戏大厅与字典模块(game/lobby)
|
|
||||||
|
|
||||||
### 3.1 获取首页初始化数据
|
|
||||||
- **POST** `/api/game/lobbyInit`
|
|
||||||
- 用途:一次返回本局、配置、36字花字典、用户快捷展示
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `server_time`:int(含义:服务端当前时间,用于客户端校时)
|
|
||||||
- `runtime_enabled`:bool(含义:**游戏运行开关**;`false` 时表示后台维护——**禁止下注**,且 idle 时不会自动创建新期、派彩结束后也不会自动创建下一期;**当前已开盘的局仍会开奖、派彩并结算**。移动端应禁用下注入口并提示「维护」类文案)
|
|
||||||
- `period`:object
|
|
||||||
- `period_no`:string(含义:当前全局期号)
|
|
||||||
- `status`:string(`betting`/`locked`/`settling`/`finished`/`void`,含义:当前期状态;`void` 表示该期已作废)
|
|
||||||
- `countdown`:int(含义:当前期倒计时秒数)
|
|
||||||
- `lock_at`:int(含义:封盘时间戳)
|
|
||||||
- `open_at`:int(含义:预计开奖时间戳)
|
|
||||||
- `bet_config`:object
|
|
||||||
- `pick_max_number_count`:int(含义:单注最多可选号码数,来自 `game_config.config_key = pick_max_number_count`,缺省与库内种子一致,通常为 10,合法范围 1–36)
|
|
||||||
- `chips`:array[string](如 `["1.00","5.00"]`,含义:快捷筹码面额)
|
|
||||||
- `single_number_max_bet`:string(含义:单号码最大下注额)
|
|
||||||
- `dictionary`:array<object>
|
|
||||||
- `number`:int(1-36,含义:字花编号)
|
|
||||||
- `name`:string(含义:字花名称)
|
|
||||||
- `category`:string(含义:字花分类)
|
|
||||||
- `icon`:string(含义:图标资源地址)
|
|
||||||
- `user_snapshot`:object(`coin`、`current_streak`,含义:用户状态快照)
|
|
||||||
|
|
||||||
### 3.2 获取36字花字典(可缓存)
|
|
||||||
- **POST** `/api/game/dictionaryList`
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `version`:string(含义:字典版本号,前端可用于缓存比对)
|
|
||||||
- `items`:同 `dictionary`(含义:36字花字典明细)
|
|
||||||
|
|
||||||
## 4. 下注与对局模块(game/bet)
|
|
||||||
|
|
||||||
### 4.1 获取当前期详情
|
|
||||||
- **POST** `/api/game/periodCurrent`
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `runtime_enabled`:bool(含义:同 `lobbyInit.runtime_enabled`)
|
|
||||||
- `period_id`:int(含义:当前期主键 ID)
|
|
||||||
- `period_no`:string(含义:当前期号)
|
|
||||||
- `status`:string(含义:当前期状态,含 `void` 已作废)
|
|
||||||
- `countdown`:int(含义:当前期剩余秒数)
|
|
||||||
- `bet_close_in`:int(含义:距离封盘剩余秒数)
|
|
||||||
- `result_number`:int/null(未开奖为 null,含义:开奖号码)
|
|
||||||
|
|
||||||
### 4.2 提交下注
|
|
||||||
- **POST** `/api/game/placeBet`(兼容旧路径 `/api/game/betPlace`)
|
|
||||||
- 用途:单期手动下注;玩家传入**压注号码**与**单注金额 `single_bet_amount`**。服务端按 `single_bet_amount × numbers数量` 计算本笔总扣款(落库 `total_amount`),开奖只出一个号码,若该号码 ∈ 所选号码集合即视为中奖。
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `period_no`:string(含义:下注目标期号)
|
|
||||||
- `numbers`:string(含义:本次压注号码集合,**英文逗号分隔**,如 `1,8,16`;每个号码为 1–36 的整数,数量不超过 `pick_max_number_count`(同 `lobbyInit.bet_config`),重复号码会去重)
|
|
||||||
- `single_bet_amount`:string(含义:**单注金额**,> 0)
|
|
||||||
- `bet_amount`:string(兼容字段,含义同 `single_bet_amount`)
|
|
||||||
- `idempotency_key`:string(必填,含义:防止重复下单)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `order_no`:string(含义:下注订单号)
|
|
||||||
- `period_no`:string(含义:实际落单期号)
|
|
||||||
- `status`:string(`accepted`/`rejected`,含义:受理结果)
|
|
||||||
- `single_bet_amount`:string(含义:本次单注金额)
|
|
||||||
- `numbers_count`:int(含义:本次号码数量)
|
|
||||||
- `locked_balance`:string(可选,含义:冻结金额)
|
|
||||||
- `balance_after`:string(含义:下单后余额)
|
|
||||||
- `current_streak`:int(含义:下单后连胜快照)
|
|
||||||
|
|
||||||
**可能错误码(补充)**(其余见文档头部错误码分段;扣款与缓存一致性强相关):
|
|
||||||
|
|
||||||
- `3001`:游戏已暂停(`runtime_enabled=false`,后台「游戏实时对局」关闭运行开关或作废本局后未重新开启;与非法流程类错误同段)
|
|
||||||
- `5000`:系统繁忙;或 **用户 Redis 互斥锁**未获取(与后台钱包/并发写同一用户串行,文案与后台一致:「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」);或 **`coin` 条件更新**未命中(并发下注/派彩/后台已改余额:「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」)。
|
|
||||||
|
|
||||||
### 4.3 自动托管
|
|
||||||
- **POST** `/api/game/autoSpin`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `action`:string(`start`/`stop`)
|
|
||||||
- `period_no`:string(`action=start` 时必填)
|
|
||||||
- `numbers`:string(`action=start` 时必填,英文逗号分隔)
|
|
||||||
- `single_bet_amount`:string(`action=start` 时必填,支持兼容字段 `bet_amount`)
|
|
||||||
- `rounds`:int(`action=start` 时必填,>=1)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `status`:string(`scheduled`/`stopped`)
|
|
||||||
- `auto_mode`:bool
|
|
||||||
- `remaining_rounds`:int(仅 `start` 返回)
|
|
||||||
|
|
||||||
### 4.4 查询我的下注记录(最近1个月)
|
|
||||||
- **POST** `/api/game/betMyOrders`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `page`:int(可选,默认 1)
|
|
||||||
- `page_size`:int(可选,默认 20)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `list`:array<object>
|
|
||||||
- `order_no`:string(含义:下注订单号)
|
|
||||||
- `period_no`:string(含义:所属期号)
|
|
||||||
- `numbers`:array[int](含义:下注号码)
|
|
||||||
- `bet_amount`:string(含义:本笔整笔压注金额,与 `total_amount` 相同)
|
|
||||||
- `total_amount`:string(含义:本笔整笔压注金额)
|
|
||||||
- `result_number`:int/null(含义:开奖号码,未开可空)
|
|
||||||
- `win_amount`:string(含义:中奖金额)
|
|
||||||
- `status`:string(含义:订单状态)
|
|
||||||
- `create_time`:int(含义:下注时间)
|
|
||||||
- `pagination`:object(`page`、`page_size`、`total`,含义:分页信息)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 钱包与资金模块(wallet/finance)
|
|
||||||
|
|
||||||
### 5.1 余额同步口径(已移除独立摘要接口)
|
|
||||||
- 已移除 `/api/wallet/balanceSummary`。
|
|
||||||
- 余额同步来源调整为:
|
|
||||||
- 下注返回 `placeBet.balance_after`
|
|
||||||
- WebSocket 推送 `wallet.changed`
|
|
||||||
- 充值/提现详情接口(如 `depositDetail` / `withdrawDetail`)作为业务单据维度核对
|
|
||||||
- 倍数 `ratio` 由后台「游戏配置 → `withdraw_bet_flow_ratio`」维护,修改后对新请求立即生效。
|
|
||||||
- 历史累计类字段(`total_deposit_coin` / `total_withdraw_coin` / `bet_flow_coin`)均为累加语义;若后续审核驳回,回冲逻辑由后台审核流程负责。
|
|
||||||
|
|
||||||
### 5.2 钱包流水
|
|
||||||
- **POST** `/api/wallet/recordList`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `page`:int(可选,默认 1)
|
|
||||||
- `page_size`:int(可选,默认 20)
|
|
||||||
- `type`:string,可选(含义:流水类型筛选,可选值如下;不传表示查询全部)
|
|
||||||
- `deposit`:充值入账(充值订单成功后,金额入账到玩家余额)
|
|
||||||
- `withdraw`:提现出账(提现订单受理/打款后,金额从玩家余额扣除或冻结)
|
|
||||||
- `bet`:下注扣款(提交下注时从玩家余额扣除的投注金额)
|
|
||||||
- `payout`:开奖派彩(中奖后系统将奖金入账到玩家余额)
|
|
||||||
- `adjust`:人工调整(后台管理员加/扣点,对应 `biz_type=admin_credit/admin_deduct`)
|
|
||||||
- `bet_void`:期次作废退款(后台「游戏实时对局」作废本局时,退回待开奖注单本金)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `list`:array<object>
|
|
||||||
- `record_id`:int(含义:钱包流水 ID)
|
|
||||||
- `biz_type`:string(含义:业务类型)
|
|
||||||
- `direction`:int(1入2出,含义:资金方向)
|
|
||||||
- `amount`:string(含义:本次变动金额)
|
|
||||||
- `balance_before`:string(含义:变动前余额)
|
|
||||||
- `balance_after`:string(含义:变动后余额)
|
|
||||||
- `ref_type`:string(含义:关联业务单类型)
|
|
||||||
- `ref_id`:string(含义:关联业务单标识)
|
|
||||||
- `create_time`:int(含义:流水时间)
|
|
||||||
|
|
||||||
补充约定:
|
|
||||||
- 金额字段(`amount`、`balance_before`、`balance_after` 等)客户端显示统一两位小数。
|
|
||||||
- 后台管理员加减点会生成 `biz_type=admin_credit/admin_deduct` 的流水记录,备注默认模板:`后台管理员(操作管理员)加点/扣点100(值)`(示例)。
|
|
||||||
|
|
||||||
### 5.3 充值档位列表
|
|
||||||
- **POST** `/api/finance/depositTierList`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- 由后台「配置管理 → 充值档位」维护,存放在 `game_config.deposit_tier`(JSON 数组)。
|
|
||||||
- 后台表单中的「支付货币」下拉来源于 `game_config.finance_cashier.currencies`(不再前端硬编码)。
|
|
||||||
- 初始化/重建档位时按当前 `finance_cashier` 货币集合生成:**每种货币 6 条档位**(运营可再编辑)。
|
|
||||||
- 仅返回启用状态(`status=1`)的档位,按 `sort` 升序;玩家仅能从中选择。
|
|
||||||
- 档位仅描述"充值规格",不再包含收款账户;具体收款由第三方支付网关返回的 `pay_url` 引导。
|
|
||||||
- **多语言**:后台保存 `title`(中文名)、`title_en`(英文名)、`desc`(中文描述)、`desc_en`(英文描述)。接口返回的 `title` / `desc` 会根据请求头 `lang` 自动适配:
|
|
||||||
- `lang=zh`(默认):返回 `title` / `desc`,若为空则回退到英文
|
|
||||||
- `lang=en`:返回 `title_en` / `desc_en`,若为空则回退到中文
|
|
||||||
- 移动端客户端仅看到单一 `title` / `desc`,无需自行判断语言
|
|
||||||
|
|
||||||
请求参数:无(无需 body 与 query)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `list`:list,档位列表;每一项结构:
|
|
||||||
- `id`:string(含义:档位稳定 ID,创建订单时作为 `tier_id` 原样回传;与 `tier_key` 同值)
|
|
||||||
- `tier_key`:string(含义:与 `id` 相同,兼容旧字段名)
|
|
||||||
- `title`:string(含义:档位名称,已按 `lang` 头切换;例如 `lang=en` 下返回 `"Starter Pack"`,`lang=zh` 下返回 `"新手首充礼包"`)
|
|
||||||
- `currency`:string(含义:标价币种,如 `CNY`)
|
|
||||||
- `pay_amount`:string(2 位小数,含义:对外标价金额,与业务配置一致;展示用)
|
|
||||||
- `amount`:string(2 位小数,含义:玩家本次需支付的充值金额)
|
|
||||||
- `bonus_amount`:string(2 位小数,含义:该档位赠送金额,无赠送为 `0.00`)
|
|
||||||
- `total_amount`:string(2 位小数,含义:到账总额 = amount + bonus_amount,方便前端直接展示"到账 120")
|
|
||||||
- `desc`:string(含义:档位描述/活动文案,已按 `lang` 头切换;可空)
|
|
||||||
- `channels`:array(含义:可用支付渠道列表,用于 `depositCreate` 的 `channel_code`;渠道与档位不再做单独绑定,所有启用渠道自动兼容全部档位)
|
|
||||||
- 每项:`code`(string,渠道代码,小写,与创建订单时传入的 `channel_code` 一致)、`name`(展示名)、`sort`(排序)
|
|
||||||
|
|
||||||
### 5.3A 获取充值/提现配置
|
|
||||||
- **POST** `/api/finance/depositWithdrawConfig`
|
|
||||||
- 兼容旧接口:`POST /api/finance/cashierConfig`(返回结构一致,建议客户端统一切到 `depositWithdrawConfig`)
|
|
||||||
|
|
||||||
用途:
|
|
||||||
- 一次性返回充值与提现页面所需配置:货币列表、汇率、可用充值渠道、提现银行、提现限额与文案配置。
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `platform_coin_label`:string(平台币名称,按 `lang` 适配)
|
|
||||||
- `currencies`:array
|
|
||||||
- `code`:string(货币代码)
|
|
||||||
- `label`:string(货币展示名,按 `lang` 适配)
|
|
||||||
- `deposit_coins_per_fiat`:string(充值汇率)
|
|
||||||
- `withdraw_coins_per_fiat`:string(提现汇率)
|
|
||||||
- `rates`:array(兼容字段)
|
|
||||||
- `currency`:string
|
|
||||||
- `diamonds_per_fiat_unit`:string
|
|
||||||
- `pay_channels`:array(充值渠道)
|
|
||||||
- `code`:string(渠道代码)
|
|
||||||
- `name`:string(展示名)
|
|
||||||
- `sort`:int(排序)
|
|
||||||
- `status`:int(启用状态,1=启用)
|
|
||||||
- `tier_ids`:array(兼容字段;当前固定空数组,表示自动兼容全部充值档位)
|
|
||||||
- `withdraw`:object
|
|
||||||
- `banks`:array(提现银行)
|
|
||||||
- `min_ewallet`:string(电子钱包最低提现)
|
|
||||||
- `min_bank`:string(银行卡最低提现)
|
|
||||||
- `rate_hint`:string(汇率提示文案)
|
|
||||||
- `processing_note`:string(到账提示文案)
|
|
||||||
- `fee_note`:string(手续费提示文案)
|
|
||||||
- `rate_mode`:string(`fixed` / `live`)
|
|
||||||
- `fields`:object(提现表单必填项开关)
|
|
||||||
|
|
||||||
### 5.4 创建充值订单
|
|
||||||
- **POST** `/api/finance/depositCreate`
|
|
||||||
- `Content-Type: application/json`(推荐)、`application/x-www-form-urlencoded` 或 **`multipart/form-data`**(如 Apifox 的 form-data);字段名与下表一致即可,服务端通过统一参数名读取,**不限制**为某一种 body 类型。
|
|
||||||
|
|
||||||
说明(与真实「创建订单 → 调起三方 → 异步回调」一致):
|
|
||||||
- **创建单**:`depositCreate` 仅写入 **待支付** 订单(`status=pending`),**不在此请求内入账**。返回体中 `paid=false`,`pay_url` 为 **模拟第三方收银台** 完整 URL(HMAC 防篡改,见下 §5.4.1)。
|
|
||||||
- **客户端**:在 WebView/系统浏览器中打开 `pay_url`;用户完成支付后(模拟页为「确认支付」按钮),由服务端 `depositMockNotify` 验签后调用 `DepositSettlement::settle` 入账,并推送 `wallet.changed`;客户端可轮询 `depositDetail` 或依赖推送更新余额。
|
|
||||||
- **未来接入真实第三方支付**:将 `pay_url` 与回调 URL 替换为真网关,入账仍**仅**在回调/验签成功路径中调用 `DepositSettlement::settle`(与当前模拟回调一致)。
|
|
||||||
- 档位与渠道取自 `depositTierList`:创建订单时须选择返回 `channels` 中某一渠道的 `code` 作为 `channel_code` 传入;服务端会校验档位存在、启用且渠道已启用。
|
|
||||||
- **HMAC 密钥**:模拟链路与签名校验使用环境变量 **`DEPOSIT_MOCK_HMAC_KEY`**(或 `config('app.deposit_mock_hmac_key')`);生产环境务必配置,与代码中默认值区分。
|
|
||||||
- **并发上限**:同一用户最多同时存在 **3 笔待支付充值单**(`status=0`);超过后创建接口返回 `code=2005`。
|
|
||||||
- **超时失效**:充值单创建后 **60 秒内未支付**将自动置为失败(`status=failed`),并在订单备注记录失败原因(`[timeout] unpaid over 60s`)。
|
|
||||||
- **定时任务兜底**:服务端进程 `depositOrderExpireTicker` 每 **10 秒**主动扫描超时待支付单,保证即使用户不访问任何充值接口也会准时失效。
|
|
||||||
|
|
||||||
请求参数(**三者缺一不可**,任一为空或空白即 `code=1001` 参数缺失):
|
|
||||||
- `tier_id`:string,必填(含义:玩家选择的充值档位 ID,取自 `depositTierList` 的 `id`;也可用同义字段名 `tier_key`)
|
|
||||||
- `channel_code`:string,必填(含义:支付渠道代码,**小写**;须与所选档位在 `depositTierList` 返回的 `channels[].code` 之一一致,例如默认内置渠道常为 `directpay`)
|
|
||||||
- `idempotency_key`:string,必填,≤64(含义:客户端生成的唯一键,短时间内同 `idempotency_key` 不会重复下单;建议 UUID。**调试工具中若使用变量,请确保解析后非空**)
|
|
||||||
|
|
||||||
> **常见 1001 原因**:只传了 `tier_id` + `idempotency_key`,**漏传 `channel_code`**。请先调 `depositTierList`,用对应档位下 `channels` 中某项的 `code` 作为 `channel_code`。
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `order_no`:string(含义:充值订单号)
|
|
||||||
- `amount`:string(2 位小数,含义:玩家本次支付的充值金额,与所选档位 `amount` 一致)
|
|
||||||
- `bonus_amount`:string(2 位小数,含义:本次赠送金额,与所选档位 `bonus_amount` 一致,无赠送为 `0.00`)
|
|
||||||
- `total_amount`:string(2 位小数,含义:实际入账总额 = amount + bonus_amount)
|
|
||||||
- `pay_channel`:string(含义:支付通道标识,与请求中选择的 `channel_code` 一致,落库在订单上)
|
|
||||||
- `paid`:bool(含义:当前单据是否已到账;`true` 表示钱包已入账、`status=paid`;`false` 表示待玩家在第三方支付页面完成支付)
|
|
||||||
- `pay_url`:string(含义:第三方支付收银台地址;**`paid=false`(待支付)** 时返回**完整 URL**(如 `https://你的域名/api/finance/depositMockPayPage?order_no=...&sign=...`);`paid=true` 时为空串)
|
|
||||||
- `status`:string(`pending`/`paid`/`failed`,含义:本接口创建成功时为 `pending`,入账完成后为 `paid`)
|
|
||||||
- `create_time`:int(含义:订单创建时间,秒级时间戳)
|
|
||||||
- `pay_time`:int(含义:订单到账时间,未到账为 0)
|
|
||||||
|
|
||||||
#### 5.4.1 模拟第三方:收银台页与「异步通知」回调(开发/无真网关时使用)
|
|
||||||
|
|
||||||
- **GET** `/api/finance/depositMockPayPage`
|
|
||||||
- **Query**:`order_no`(与 `depositCreate` 返回一致)、`sign`(HMAC,与 `pay_url` 中一致;**不要自行拼接,须完整使用 `depositCreate` 返回的 `pay_url` 或同接口再次查询到的地址**)
|
|
||||||
- 无需 `auth-token` / `user-token`(外跳浏览器使用)。
|
|
||||||
- 返回:HTML 页面,用户点击 **「确认支付(模拟成功)」** 即提交到下方 `depositMockNotify`。
|
|
||||||
|
|
||||||
- **POST** `/api/finance/depositMockNotify`
|
|
||||||
- **Body**(`application/x-www-form-urlencoded` 或 JSON 均可,字段名一致即可):`order_no`、`sign`(与上页/支付链接一致)
|
|
||||||
- 无需 `user-token`;`auth-token` 可选(当前实现不校验)。
|
|
||||||
- 验签成功后:对 `status=0` 的订单执行入账(`DepositSettlement::settle`,`source=third_party` 语义),并推送 `wallet.changed`。已入账订单**幂等**再调返回当前订单信息。
|
|
||||||
- 成功响应:与 `depositCreate` 成功体相同结构(`code=1` + `data` 为统一充值订单结构)。
|
|
||||||
|
|
||||||
错误码约定:
|
|
||||||
- `1001`:缺少必填参数(`tier_id`(或 `tier_key`)、`channel_code`、`idempotency_key` 任一未传或为空字符串)
|
|
||||||
- `1002`:`idempotency_key` 过长,或与其他玩家的订单冲突
|
|
||||||
- `1003`:模拟回调/链接参数非法(如 `sign` 与 `order_no` 不匹配)——`depositMockNotify` 与无效支付链接
|
|
||||||
- `2000`:订单落库或入账失败(事务回滚后返回原始错误描述)
|
|
||||||
- `2003`:所选 `tier_id` 不存在、已停用或不在启用列表中
|
|
||||||
- `2004`:`channel_code` 未配置或已禁用
|
|
||||||
- `2005`:待支付充值单超过上限(`data.max_pending`、`data.pending_count`、`data.expire_seconds`)
|
|
||||||
|
|
||||||
### 5.5 查看充值订单详情
|
|
||||||
- **POST** `/api/finance/depositDetail`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `order_no`:string,必填(含义:充值订单号)
|
|
||||||
|
|
||||||
返回参数(与 `depositCreate` 统一结构):
|
|
||||||
- `order_no`:string(含义:充值订单号)
|
|
||||||
- `amount`:string(2 位小数,含义:本单充值金额)
|
|
||||||
- `bonus_amount`:string(2 位小数,含义:本单赠送金额,无赠送为 `0.00`)
|
|
||||||
- `total_amount`:string(2 位小数,含义:入账总额)
|
|
||||||
- `pay_channel`:string(含义:支付通道标识)
|
|
||||||
- `paid`:bool(含义:是否已到账)
|
|
||||||
- `pay_url`:string(含义:第三方支付页面地址,已到账为空串)
|
|
||||||
- `status`:string(`pending`/`paid`/`failed`)
|
|
||||||
- `create_time`:int(含义:订单创建时间)
|
|
||||||
- `pay_time`:int(含义:订单到账时间,未到账为 0)
|
|
||||||
|
|
||||||
### 5.6 查询充值订单列表
|
|
||||||
- **POST** `/api/finance/depositList`
|
|
||||||
|
|
||||||
用于我的充值记录页的分页列表;列表含订单状态,到账时间/支付通道等完整字段请再调用 `/api/finance/depositDetail` 获取。
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `page`:int,选填,默认 `1`(含义:页码,从 1 开始)
|
|
||||||
- `page_size`:int,选填,默认 `20`,最大 `100`(含义:每页数量,超出范围回退为 `20`)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `list`:array(含义:充值订单列表,按 `id desc` 排序)
|
|
||||||
- `order_no`:string(含义:充值订单号)
|
|
||||||
- `amount`:string(2 位小数,含义:本单充值金额)
|
|
||||||
- `bonus_amount`:string(2 位小数,含义:本单赠送金额,无赠送为 `0.00`)
|
|
||||||
- `status`:string(含义:订单状态,与 `depositDetail` 一致:`pending`/`paid`/`failed`)
|
|
||||||
- `pagination`:object(含义:分页信息)
|
|
||||||
- `page`:int(含义:当前页码)
|
|
||||||
- `page_size`:int(含义:每页数量)
|
|
||||||
- `total`:int(含义:总记录数)
|
|
||||||
|
|
||||||
### 5.7 提现申请
|
|
||||||
- **POST** `/api/finance/withdrawCreate`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `withdraw_coin`:string(含义:申请提现金额,必须 > 0)
|
|
||||||
- `receive_account`:string(含义:收款账号)
|
|
||||||
- `receive_type`:string(`bank`/`ewallet`/`crypto`,含义:收款类型)
|
|
||||||
- `idempotency_key`:string(含义:防重复提交提现)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `order_no`:string(含义:提现订单号)
|
|
||||||
- `status`:string(`pending_review`/`processing`,含义:提现状态)
|
|
||||||
- `fee_coin`:string(含义:手续费)
|
|
||||||
- `actual_arrival_coin`:string(含义:实到账金额)
|
|
||||||
- `risk_review_required`:bool(含义:是否命中人工审核)
|
|
||||||
|
|
||||||
校验顺序(任一失败即返回对应错误码,不再创建订单):
|
|
||||||
1. 参数完整性与金额合法性(`code=1001`;金额必须为数值且 > 0)
|
|
||||||
2. **待审核订单数限制**:同一用户 `status=0`(待审核)的 `withdraw_order` 不得超过 3 笔,否则 `code=2004 Too many pending withdraw orders`,`data` 中回传:
|
|
||||||
- `max_pending`:上限值(当前为 `3`)
|
|
||||||
- `pending_count`:当前待审核订单数
|
|
||||||
3. `coin_balance >= withdraw_coin`,否则 `code=2001 Insufficient balance`
|
|
||||||
4. **单笔上限校验**:`withdraw_coin <= max_withdrawable`,否则 `code=2002 Withdraw exceeds available bet flow`,`data` 中回传:
|
|
||||||
- `max_withdrawable`:**当前允许的单笔最大提现金额**(= `min(coin_balance, max_withdraw_by_flow)`,前端据此提示"最大可提现金额为 XXX")
|
|
||||||
- `coin_balance`、`bet_flow_coin`、`total_withdraw_coin`、`ratio`
|
|
||||||
- `max_withdraw_by_flow`:仅按打码量折算的上限(= `max(0, bet_flow_coin / ratio - total_withdraw_coin)`);`ratio=0` 时为 `null`
|
|
||||||
5. 以上全通过后在同一事务内:
|
|
||||||
- `withdraw_order` 写入:`amount` / `fee`(默认 0.5%) / `actual_amount = amount - fee` / `status=0`(待审核) / `channel_id` 取自用户归属渠道快照。
|
|
||||||
- `user` 表原子更新:`coin -= withdraw_coin` 且 `total_withdraw_coin += withdraw_coin`(WHERE `coin >= withdraw_coin` 防止并发超额扣减)。
|
|
||||||
- `user_wallet_record` 写入 `biz_type=withdraw`、`direction=2`、`amount=withdraw_coin`、`ref_type=withdraw_order`、`idempotency_key=wd_apply_{order_no}`,代表"冻结"动作。
|
|
||||||
|
|
||||||
说明(打码量即提现配额模型):
|
|
||||||
- 单笔最大可提现 `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`;每笔提现按 `withdraw_coin × ratio` 消耗打码配额,已消耗部分累积在 `total_withdraw_coin`。
|
|
||||||
- `ratio = 0` 时视为"不限打码",单笔上限仅受 `coin_balance` 约束。
|
|
||||||
- 采用"申请即冻结"语义:提现在移动端提交后立即从 `user.coin` 中扣减并写出金流水;后台审核 **拒绝** 时由管理端在同一事务中回冲余额、`total_withdraw_coin` 与流水,不出现"等待审核期间用户还能把这笔钱再下注"的漏洞。
|
|
||||||
- 后台审核 **通过** 时不再额外触碰余额;若管理员调整了 `amount` 或 `fee`,按新旧差额再生成一条 `withdraw` / `withdraw_refund` 流水以保持账务平衡,并同步修正 `total_withdraw_coin`。
|
|
||||||
- `withdraw_bet_flow_ratio` 由后台「游戏配置」维护,默认 `1.00`,修改后对新请求立即生效。
|
|
||||||
|
|
||||||
### 5.8 查看提现订单详情
|
|
||||||
- **POST** `/api/finance/withdrawDetail`
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `order_no`:string,必填(含义:提现订单号)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `order_no`:string(含义:提现订单号)
|
|
||||||
- `status`:string(`pending_review`/`approved`/`rejected`,含义:审核状态;`status=3 已打款` 暂未对外暴露,合并到 `approved`)
|
|
||||||
- `withdraw_coin`:string(含义:申请提现金额,与后台 `withdraw_order.amount` 对齐)
|
|
||||||
- `fee_coin`:string(含义:手续费,与后台 `withdraw_order.fee` 对齐)
|
|
||||||
- `actual_arrival_coin`:string(含义:实际到账金额 = 申请金额 - 手续费;后台审核调整后会同步刷新)
|
|
||||||
- `reject_reason`:string/null(含义:拒绝原因,`status=rejected` 时取自 `withdraw_order.remark`,否则为 `null`)
|
|
||||||
- `create_time`:int(含义:申请时间)
|
|
||||||
- `review_time`:int/null(含义:审核时间戳,未审核为 `null`)
|
|
||||||
|
|
||||||
### 5.9 查询提现订单列表
|
|
||||||
- **POST** `/api/finance/withdrawList`
|
|
||||||
|
|
||||||
用于我的提现记录页的分页列表;列表含审核/打款状态摘要,手续费、实到账、拒绝原因等请再调用 `/api/finance/withdrawDetail` 获取。
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
- `page`:int,选填,默认 `1`(含义:页码,从 1 开始)
|
|
||||||
- `page_size`:int,选填,默认 `20`,最大 `100`(含义:每页数量,超出范围回退为 `20`)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `list`:array(含义:提现订单列表,按 `id desc` 排序)
|
|
||||||
- `order_no`:string(含义:提现订单号)
|
|
||||||
- `amount`:string(2 位小数,含义:申请提现金额,与后台 `withdraw_order.amount` 对齐)
|
|
||||||
- `status`:string(含义:订单状态,与 `withdrawDetail` 一致:`pending_review`/`approved`/`rejected`;后台已打款 `status=3` 合并为 `approved`)
|
|
||||||
- `pagination`:object(含义:分页信息)
|
|
||||||
- `page`:int(含义:当前页码)
|
|
||||||
- `page_size`:int(含义:每页数量)
|
|
||||||
- `total`:int(含义:总记录数)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 公告与消息模块(operation/notice)
|
|
||||||
|
|
||||||
### 6.1 拉取公告列表
|
|
||||||
- **GET** `/api/notice/noticeList`
|
|
||||||
|
|
||||||
请求参数(query string):
|
|
||||||
- `page`:int(可选,默认 1)
|
|
||||||
- `page_size`:int(可选,默认 20)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `list`:array<object>
|
|
||||||
- `notice_id`:int(含义:公告 ID)
|
|
||||||
- `title`:string(含义:公告标题)
|
|
||||||
- `notice_type`:string(`silent`/`popout`,含义:公告类型)
|
|
||||||
- `is_read`:bool(含义:当前用户是否已读)
|
|
||||||
- `publish_time`:int(含义:发布时间)
|
|
||||||
|
|
||||||
### 6.2 公告详情
|
|
||||||
- **GET** `/api/notice/noticeDetail`
|
|
||||||
|
|
||||||
请求参数(query string):
|
|
||||||
- `id`:int,必填(含义:公告 ID)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `notice_id`:int(含义:公告 ID)
|
|
||||||
- `title`:string(含义:公告标题)
|
|
||||||
- `content`:string(含义:公告正文)
|
|
||||||
- `notice_type`:string(含义:公告类型)
|
|
||||||
- `must_confirm`:bool(含义:是否必须手动确认)
|
|
||||||
- `publish_time`:int(含义:发布时间)
|
|
||||||
|
|
||||||
### 6.3 强弹窗确认已读
|
|
||||||
- **GET** `/api/notice/noticeConfirm`
|
|
||||||
|
|
||||||
请求参数(query string):
|
|
||||||
- `notice_id`:int(含义:待确认公告 ID)
|
|
||||||
|
|
||||||
返回参数:
|
|
||||||
- `notice_id`:int(含义:已确认公告 ID)
|
|
||||||
- `confirmed`:bool(含义:确认结果)
|
|
||||||
- `confirm_time`:int(含义:确认时间)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. WebSocket(H5)与状态同步
|
|
||||||
|
|
||||||
> 本版本已移除 webman/push 频道模式;H5 前端使用原生 WebSocket 直连,HTTP 轮询仅作为弱网兜底。
|
|
||||||
|
|
||||||
### 7.1 WebSocket 连接与消息
|
|
||||||
|
|
||||||
- **连接地址**:由服务端配置下发(后台测试页读取 `H5_WEBSOCKET_URL`)
|
|
||||||
- **客户端**:浏览器原生 `WebSocket`(`ws://` / `wss://`)
|
|
||||||
- **连接时携带参数(建议)**:
|
|
||||||
- URL Query:`token`(用户登录态 user-token)、`auth_token`(接口鉴权)、`device_id`(设备标识)、`lang`(`zh/en`)
|
|
||||||
- 示例:`wss://ws.example.com/game?token=xxx&auth_token=xxx&device_id=ios_001&lang=zh`
|
|
||||||
- **连接成功返回(服务端首帧建议)**:
|
|
||||||
- `event`:`ws.connected`
|
|
||||||
- `connection_id`:连接唯一标识
|
|
||||||
- `server_time`:服务器时间戳(秒)
|
|
||||||
- `heartbeat_interval`:心跳间隔(秒)
|
|
||||||
- **连接失败返回(建议)**:
|
|
||||||
- `event`:`ws.error`
|
|
||||||
- `code`:错误码(如 `1101` 未登录、`1103` 鉴权失败)
|
|
||||||
- `message`:错误描述
|
|
||||||
- **建议消息**:
|
|
||||||
- 心跳:`{"action":"ping"}`
|
|
||||||
- 订阅状态流:`{"action":"subscribe","topics":["period.tick","period.opened"]}`
|
|
||||||
- 订阅资金流:`{"action":"subscribe","topics":["bet.accepted","wallet.changed"]}`
|
|
||||||
- 订阅托管流:`{"action":"subscribe","topics":["auto.spin.progress","wallet.changed"]}`
|
|
||||||
|
|
||||||
#### 7.1.1 消息协议字段定义(联调口径)
|
|
||||||
|
|
||||||
- 客户端 -> 服务端:
|
|
||||||
- `action`:动作名(当前约定 `ping` / `subscribe`)
|
|
||||||
- `topics`:仅 `subscribe` 时必填,表示要订阅的主题列表(数组)
|
|
||||||
- 服务端 -> 客户端:
|
|
||||||
- `event`:事件名(如 `period.tick`、`wallet.changed`、`jackpot.hit`)
|
|
||||||
- `topic`:所属主题(通常与 `event` 一致;用于前端按主题路由)
|
|
||||||
- `data`:业务载荷(对象)
|
|
||||||
- `server_time`:服务端时间戳(秒,倒计时与对时基准)
|
|
||||||
|
|
||||||
#### 7.1.2 订阅行为说明
|
|
||||||
|
|
||||||
- **仅建立连接不会自动下发全部业务消息**;客户端需要发送 `subscribe` 明确订阅主题。
|
|
||||||
- 成功订阅后服务端返回:`{"event":"ws.subscribed","topics":[...]}`。
|
|
||||||
- 若未订阅主题,通常只能收到握手首帧(`ws.connected`)和心跳回包(`pong`)。
|
|
||||||
|
|
||||||
#### 7.1.3 推送频率与触发规则(当前实现)
|
|
||||||
|
|
||||||
- `period.tick`:**每秒一次**(用于倒计时、状态同步)。
|
|
||||||
- `admin.live.snapshot`:**每秒一次**(后台实时对局页全量快照)。
|
|
||||||
- `period.opened` / `period.payout` / `admin.live.opened`:按开奖流程阶段触发(事件触发型,非固定频率)。
|
|
||||||
- `wallet.changed`:仅在余额发生变更时推送(如下注扣款、充值入账、派彩入账)。
|
|
||||||
- `jackpot.hit`:**仅在本期存在中大奖命中用户时推送**;无命中不推送。
|
|
||||||
|
|
||||||
### 7.1A 后台连接方式(管理端联调)
|
|
||||||
|
|
||||||
- 后台菜单:仅保留一个菜单 `连接服务器websocket`,用于统一联调 WebSocket
|
|
||||||
- 后台连接入口:
|
|
||||||
- `/admin/test.GameCurrentStatus/wsConfig`
|
|
||||||
- 后台页面能力:
|
|
||||||
- 读取 `ws_url`、`connect_tip`、`sample_messages`
|
|
||||||
- 手动连接/断开 WebSocket
|
|
||||||
- 手动发送订阅与心跳报文
|
|
||||||
- 实时查看服务端返回帧内容(用于联调事件格式)
|
|
||||||
|
|
||||||
### 7.2 HTTP 兜底接口
|
|
||||||
|
|
||||||
- 本版本已移除以下兜底接口:`/api/game/currentStatus`、`/api/game/periodHistory`、`/api/wallet/balanceSummary`。
|
|
||||||
- 状态与余额统一以 WebSocket 推送为主,HTTP 仅保留业务动作/详情查询接口(如 `placeBet`、`depositDetail`、`withdrawDetail`)。
|
|
||||||
|
|
||||||
### 7.3 一致性规则
|
|
||||||
|
|
||||||
- 倒计时以服务端下发时间为准,不信任本地时钟累计。
|
|
||||||
- 下注成功后以 `placeBet` 返回的 `balance_after` 为准,并等待 `wallet.changed` 同步。
|
|
||||||
- WebSocket 断线后立即重连并重新订阅主题,不再依赖 `currentStatus/periodHistory/balanceSummary` 回补。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 移动端完整调用流程
|
|
||||||
|
|
||||||
## 8.1 首次进入游戏
|
|
||||||
1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token`
|
|
||||||
2. `POST /api/user/login` 登录(请求头带 `auth-token`)
|
|
||||||
3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token`)
|
|
||||||
4. 建立 WebSocket(H5)连接,发送订阅消息监听状态流
|
|
||||||
5. 用户下注调用 `POST /api/game/placeBet`
|
|
||||||
6. 下单后以 `placeBet.balance_after` 与 `wallet.changed` 同步余额
|
|
||||||
7. 断线或页面回前台时,重连 WebSocket 并重新订阅主题回补实时状态
|
|
||||||
|
|
||||||
## 8.2 充值到下注到提现闭环
|
|
||||||
1. 拉取档位:`POST /api/finance/depositTierList`(玩家选择一档,并记下该档 `channels[].code`)
|
|
||||||
2. 创建订单:`POST /api/finance/depositCreate`(`tier_id` + `channel_code` + `idempotency_key`,三者为必填;可用 JSON / form-data / x-www-form-urlencoded)
|
|
||||||
- 返回 `paid=false`、`status=pending`、**非空 `pay_url`**:客户端在 WebView/浏览器中打开 `pay_url`(`GET /api/finance/depositMockPayPage`);用户在模拟页点击确认后,由 `POST /api/finance/depositMockNotify` 完成入账,或轮询 `depositDetail` / 等 `wallet.changed` 再刷新余额
|
|
||||||
- 未来接真实第三方:将 `pay_url` 换为真网关,入账仅在支付平台 **异步通知** 中调用 `DepositSettlement::settle`(与当前 `depositMockNotify` 路径一致)
|
|
||||||
3. 客户端可选轮询 `POST /api/finance/depositDetail` 兜底确认状态;入账成功后会收到 `wallet.changed`
|
|
||||||
4. 下注:`POST /api/game/placeBet`
|
|
||||||
5. 监听余额:`wallet.changed`(或按订单详情接口核对)
|
|
||||||
6. 查询流水:`POST /api/wallet/recordList`
|
|
||||||
7. 提现:`POST /api/finance/withdrawCreate`(即时冻结 `user.coin` 与写出 `withdraw` 流水) -> `POST /api/finance/withdrawDetail`
|
|
||||||
|
|
||||||
## 8.3 公告强触达流程
|
|
||||||
1. 客户端监听 `notice.popout`
|
|
||||||
2. 拉取详情 `GET /api/notice/noticeDetail`
|
|
||||||
3. 用户勾选确认 `GET /api/notice/noticeConfirm?notice_id=...`
|
|
||||||
4. 未确认前可由前端阻断下注入口
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 游戏时序流程图(WebSocket + HTTP兜底)
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[用户登录 /api/user/login] --> B[拉初始化 /api/game/lobbyInit]
|
|
||||||
B --> C[连接 WebSocket 并订阅主题]
|
|
||||||
C --> D{0-20秒下注期?}
|
|
||||||
D -- 是 --> E[提交下注 /api/game/placeBet]
|
|
||||||
E --> F[等待 wallet.changed 同步余额]
|
|
||||||
D -- 否 --> G[进入封盘与开奖阶段]
|
|
||||||
G --> H[服务端算票与开奖]
|
|
||||||
H --> I[WebSocket 推送状态变化]
|
|
||||||
I --> J[断线重连并重新订阅]
|
|
||||||
J --> C
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 后台渠道分红比例配置(管理端补充)
|
|
||||||
|
|
||||||
> 本节为管理后台 `/admin/channel`「分配比例」弹窗补充口径,用于便于管理员按角色层级设置二次分红比例。
|
|
||||||
|
|
||||||
### 10.1 角色组展示规则
|
|
||||||
|
|
||||||
- 表格列顺序调整为:`角色组层级` -> `负责人` -> `状态` -> `分配比例(%)`
|
|
||||||
- `角色组层级` 在 `负责人` 前展示,降低识别与分配成本
|
|
||||||
- 层级路径使用 `/` 拼接,如:`顶级组 / 运营组 / 一组`
|
|
||||||
- 同一负责人若存在多个角色组,按多标签展示多条路径
|
|
||||||
- 无角色组时显示 `-`
|
|
||||||
|
|
||||||
### 10.2 接口:读取渠道管理员分配配置
|
|
||||||
|
|
||||||
- **GET** `/admin/channel/channelAdminShareList?id={channel_id}`
|
|
||||||
|
|
||||||
返回参数(`data.list[]`)新增:
|
|
||||||
- `group_paths`:array<string>(负责人所属角色组层级路径列表)
|
|
||||||
- `group_paths_text`:string(层级路径拼接文本,`|` 分隔,用于兼容纯文本场景)
|
|
||||||
|
|
||||||
返回示例(节选):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 1,
|
|
||||||
"message": "ok",
|
|
||||||
"data": {
|
|
||||||
"channel_id": 1,
|
|
||||||
"channel_name": "渠道A",
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"admin_id": 12,
|
|
||||||
"username": "zhuguan1",
|
|
||||||
"group_paths": ["顶级组 / 运营组 / A组"],
|
|
||||||
"group_paths_text": "顶级组 / 运营组 / A组",
|
|
||||||
"status": 1,
|
|
||||||
"share_rate": "30.00"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10.3 保存约束(沿用现有)
|
|
||||||
|
|
||||||
- **POST** `/admin/channel/saveChannelAdminShare`
|
|
||||||
- 仅 `status=1` 的行参与占比汇总
|
|
||||||
- 启用项分配比例总和必须严格等于 `100.00`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. 需要你确认的实现口径(进入接口开发前)
|
|
||||||
|
|
||||||
1. **登录方式**:仅账号密码,还是要短信/邮箱验证码?
|
|
||||||
2. **提现收款类型**:首版只做银行卡,还是同时支持电子钱包/加密地址?
|
|
||||||
3. **自动托管**:是否首期上线;若不上线可先隐藏 `auto-bet` 接口。
|
|
||||||
4. **WebSocket 主题定义**:状态流、资金流、托管流的 topic 与消息体是否按本文固定。
|
|
||||||
5. **错误码规范**:是否已有公司统一错误码表;若有需对齐替换本草案码段。
|
|
||||||
|
|
||||||
确认后可进入下一步:按该文档落地 controller + validate + service + 路由。
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
# 《36字花》前端开发基线需求文档
|
|
||||||
|
|
||||||
## 1. 文档目的
|
|
||||||
|
|
||||||
本文件基于以下两份前期文档整理:
|
|
||||||
|
|
||||||
- `docs/《_36字花_ 前端开发对接与交互逻辑说明书》.docx`
|
|
||||||
- `docs/《36字花》用户前端 (User Portal) UI_UX .docx`
|
|
||||||
|
|
||||||
目标是移除营销型表述和非关键修辞,只保留后续 UI 设计、前端开发、联调和验收所需的核心设计要求、交互逻辑和技术栈建议。
|
|
||||||
|
|
||||||
## 2. 核心结论
|
|
||||||
|
|
||||||
### 2.1 产品形态
|
|
||||||
|
|
||||||
- 产品为实时开奖类游戏前端,主场景是移动端 H5,同时要求兼容桌面端响应式布局。
|
|
||||||
- 页面核心是围绕单局倒计时循环运行的 36 宫格下注界面。
|
|
||||||
- 前端必须以服务器状态为准,不能依赖客户端本地时间做开奖判断。
|
|
||||||
|
|
||||||
### 2.2 设计方向
|
|
||||||
|
|
||||||
- 视觉风格保留为深色背景、高对比强调色的娱乐场风格。
|
|
||||||
- 颜色建议以深海蓝/黑为底,青色和金色作为强调色。
|
|
||||||
- 视觉参考中的“神鼎、宝箱、卷轴、巨龙、赛博朋克”等描述,只作为美术方向参考,不作为开发阻塞项。
|
|
||||||
- 所有文案容器必须支持多语言伸缩,禁止固定宽度写死。
|
|
||||||
|
|
||||||
### 2.3 交付优先级
|
|
||||||
|
|
||||||
后续设计与开发应按以下优先级执行:
|
|
||||||
|
|
||||||
1. 玩法状态正确
|
|
||||||
2. 倒计时与服务端同步正确
|
|
||||||
3. 下单、封盘、开奖、派彩状态切换正确
|
|
||||||
4. 移动端可用性与性能达标
|
|
||||||
5. 动效和视觉强化
|
|
||||||
|
|
||||||
## 3. 推荐技术栈
|
|
||||||
|
|
||||||
## 3.1 前端框架
|
|
||||||
|
|
||||||
- `React + TypeScript + Vite`
|
|
||||||
- 原因:移动 H5 交互复杂、状态密集、联调频繁,Vite 启动快,React 生态成熟,TypeScript 适合管理复杂状态和接口约束。
|
|
||||||
|
|
||||||
## 3.2 路由与页面组织
|
|
||||||
|
|
||||||
- `React Router`
|
|
||||||
- 页面结构建议按大厅主界面、公告弹窗、规则面板、用户侧滑面板拆分。
|
|
||||||
|
|
||||||
## 3.3 状态管理
|
|
||||||
|
|
||||||
- `Zustand` 作为全局业务状态容器
|
|
||||||
- `XState` 或等价状态机方案用于维护单局生命周期状态
|
|
||||||
- 原因:本项目存在明确的 4 段式回合状态机、局内局外状态切换、自动托管覆盖态、网络重连恢复,单纯依赖局部状态容易错乱。
|
|
||||||
|
|
||||||
建议状态拆分:
|
|
||||||
|
|
||||||
- `roundStore`:期号、阶段、倒计时、开奖数据
|
|
||||||
- `betStore`:选中格子、锁定格子、筹码、总注额、按钮状态
|
|
||||||
- `userStore`:余额、连赢状态、下注上限、公告状态
|
|
||||||
- `uiStore`:弹窗、抽屉、Toast、动画开关、自动托管蒙层
|
|
||||||
|
|
||||||
## 3.4 数据请求与实时通信
|
|
||||||
|
|
||||||
- `TanStack Query`:管理普通 HTTP 请求、缓存与重拉
|
|
||||||
- `WebSocket`:接收当前局状态、开奖、余额变动、派彩、自动托管进度
|
|
||||||
- 如果后端使用 Socket.IO,则前端改用 `socket.io-client`
|
|
||||||
|
|
||||||
## 3.5 样式与动画
|
|
||||||
|
|
||||||
- 页面布局:`Tailwind CSS`
|
|
||||||
- 复杂状态样式:`CSS Modules` 或同级方案
|
|
||||||
- 盘面高频动画:优先 `CSS Animation / Transition / SVG`
|
|
||||||
- 全屏粒子特效:`PixiJS` 或 `tsParticles`
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- 禁止用 JS 逐帧操作 DOM 做 36 宫格边框动画
|
|
||||||
- 动效必须优先使用合成层友好的属性,如 `transform`、`opacity`、`filter`
|
|
||||||
|
|
||||||
## 3.6 国际化与时间处理
|
|
||||||
|
|
||||||
- `react-i18next`
|
|
||||||
- `dayjs`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- `dayjs` 只用于显示格式化,不用于决定回合状态。
|
|
||||||
- 回合倒计时必须基于服务端时间戳推导。
|
|
||||||
|
|
||||||
## 3.7 测试与质量保障
|
|
||||||
|
|
||||||
- 单元测试:按项目需要自行接入
|
|
||||||
- 组件测试:按项目需要自行接入
|
|
||||||
- 端到端测试:`Playwright`
|
|
||||||
- 线上监控:建议接入 `Sentry`
|
|
||||||
|
|
||||||
## 4. 页面与模块范围
|
|
||||||
|
|
||||||
## 4.1 主游戏界面
|
|
||||||
|
|
||||||
必须包含以下区域:
|
|
||||||
|
|
||||||
- 顶部导航栏
|
|
||||||
- 开奖展示区
|
|
||||||
- 中控信息区
|
|
||||||
- 36 宫格下注区
|
|
||||||
- 筹码与操作栏
|
|
||||||
- 右侧历史区
|
|
||||||
- 底部单双走势区
|
|
||||||
|
|
||||||
## 4.2 次级界面
|
|
||||||
|
|
||||||
必须包含以下弹层或侧栏:
|
|
||||||
|
|
||||||
- 强制公告弹窗
|
|
||||||
- 自动托管运行浮层
|
|
||||||
- 玩法与规则面板
|
|
||||||
- 用户 Dashboard 侧滑面板
|
|
||||||
|
|
||||||
## 5. 单局生命周期状态机
|
|
||||||
|
|
||||||
后台可配置单局时长,前端默认按 30 秒一局建模,并支持服务端参数覆盖。
|
|
||||||
|
|
||||||
### 5.1 游戏阶段枚举
|
|
||||||
|
|
||||||
- `BETTING`:下注期
|
|
||||||
- `LOCKED`:封盘锁定
|
|
||||||
- `DRAWING`:算票与开奖
|
|
||||||
- `PAYOUT`:派彩与收尾
|
|
||||||
|
|
||||||
### 5.2 各阶段规则
|
|
||||||
|
|
||||||
`BETTING`
|
|
||||||
|
|
||||||
- 允许选格子、换筹码、清除未确认、重复上一注、确认下注、开启自动托管。
|
|
||||||
- 显示倒计时。
|
|
||||||
- 跑马灯处于常规速度。
|
|
||||||
|
|
||||||
`LOCKED`
|
|
||||||
|
|
||||||
- 到达封盘时间点后,前端必须立即锁盘,不等待后端响应。
|
|
||||||
- 所有点击事件、按钮、输入行为全部禁用。
|
|
||||||
- 所有 `PRE_SELECTED` 未确认格子必须清空。
|
|
||||||
- 显示“停止下注”提示。
|
|
||||||
|
|
||||||
`DRAWING`
|
|
||||||
|
|
||||||
- 等待 WebSocket 推送开奖结果。
|
|
||||||
- 收到开奖后,盘面跑马灯加速。
|
|
||||||
- 最终定格中奖格子,进入中奖态。
|
|
||||||
|
|
||||||
`PAYOUT`
|
|
||||||
|
|
||||||
- 接收余额和连赢状态更新。
|
|
||||||
- 播放中奖特效。
|
|
||||||
- 更新历史和走势。
|
|
||||||
- 为下一局做状态重置。
|
|
||||||
|
|
||||||
## 6. 36 宫格下注区需求
|
|
||||||
|
|
||||||
## 6.1 网格结构
|
|
||||||
|
|
||||||
- 固定为 `6 x 6`
|
|
||||||
- 每个格子包含:编号、动物名、动物图
|
|
||||||
- 格子需要支持轻量分组提示,用于区分业务分类,但不影响下注逻辑
|
|
||||||
|
|
||||||
## 6.2 单格状态枚举
|
|
||||||
|
|
||||||
建议统一实现 `CellStatus`:
|
|
||||||
|
|
||||||
- `IDLE`:默认闲置
|
|
||||||
- `MARQUEE`:跑马灯焦点
|
|
||||||
- `HOVER`:PC 悬浮
|
|
||||||
- `PRE_SELECTED`:已选未确认
|
|
||||||
- `LOCKED`:已确认下注
|
|
||||||
- `DISABLED`:不可操作
|
|
||||||
- `ERROR`:错误反馈
|
|
||||||
- `WINNING`:中奖高亮
|
|
||||||
- `LOSER`:未中奖弱化
|
|
||||||
- `AUTO_ACTIVE`:自动托管执行态
|
|
||||||
|
|
||||||
## 6.3 单格交互规则
|
|
||||||
|
|
||||||
- 玩家单局最多选择 5 个格子。
|
|
||||||
- `PRE_SELECTED + LOCKED` 的总数不得超过 5。
|
|
||||||
- 达到 5 个后,其余可选格子全部置为 `DISABLED`。
|
|
||||||
- 点击第 6 个格子时,不得选中,必须触发错误反馈。
|
|
||||||
- 余额不足或超限点击时,必须触发错误反馈。
|
|
||||||
- 已确认格子在本局内不可取消。
|
|
||||||
- 若本局尚未封盘且未满 5 个,允许继续追加下注并再次确认。
|
|
||||||
|
|
||||||
## 7. 筹码与下注逻辑
|
|
||||||
|
|
||||||
## 7.1 筹码区
|
|
||||||
|
|
||||||
标准筹码档位:
|
|
||||||
|
|
||||||
- `1`
|
|
||||||
- `5`
|
|
||||||
- `10`
|
|
||||||
- `25`
|
|
||||||
- `50`
|
|
||||||
- `100`
|
|
||||||
|
|
||||||
## 7.2 统一下注金额同步
|
|
||||||
|
|
||||||
- 全局维护 `currentChipValue`
|
|
||||||
- 已处于 `PRE_SELECTED` 的所有格子,其显示筹码必须跟随 `currentChipValue` 实时同步
|
|
||||||
- 筹码切换后,总下注金额必须同步刷新
|
|
||||||
|
|
||||||
## 7.3 连赢上限与余额限制
|
|
||||||
|
|
||||||
需要实时校验:
|
|
||||||
|
|
||||||
- `selectedCount * currentChipValue <= streakMaxBetLimit`
|
|
||||||
- `totalBetAmount <= balance`
|
|
||||||
|
|
||||||
当不满足时:
|
|
||||||
|
|
||||||
- 对应不可选的大额筹码必须禁用
|
|
||||||
- 确认下注按钮进入错误态或禁用态
|
|
||||||
- 格子点击需给出明确错误反馈
|
|
||||||
|
|
||||||
## 8. 确认下注主按钮状态机
|
|
||||||
|
|
||||||
按钮需要独立维护以下 4 个状态:
|
|
||||||
|
|
||||||
- `DISABLED`:未选任何格子
|
|
||||||
- `READY`:已选格子且余额足够,可点击提交
|
|
||||||
- `ERROR`:总下注金额大于余额,文案显示“余额不足”
|
|
||||||
- `SUCCESS`:下注成功后维持成功态直到本局结束
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- 禁止自动提交
|
|
||||||
- 必须由用户手动点击确认
|
|
||||||
- 成功后已确认格子不可撤销
|
|
||||||
|
|
||||||
## 9. 自动托管需求
|
|
||||||
|
|
||||||
## 9.1 功能行为
|
|
||||||
|
|
||||||
- 调用自动托管接口提交:下注格子、金额、局数
|
|
||||||
- 前端进入 `AUTO_MODE`
|
|
||||||
- 主键盘和筹码区整体进入不可编辑状态
|
|
||||||
- 自动托管中的目标格子显示 `AUTO_ACTIVE`
|
|
||||||
- 前端展示当前进度,例如 `12 / 50`
|
|
||||||
- 必须提供显式“停止托管”操作
|
|
||||||
|
|
||||||
## 9.2 视觉与交互约束
|
|
||||||
|
|
||||||
- 使用全局玻璃遮罩阻断手动操作
|
|
||||||
- 自动托管目标格子需要穿透遮罩高亮显示
|
|
||||||
- 自动托管态必须与手动锁定态有视觉区分
|
|
||||||
|
|
||||||
## 10. 中控信息区需求
|
|
||||||
|
|
||||||
必须展示以下信息:
|
|
||||||
|
|
||||||
- 当前余额
|
|
||||||
- 当前赔率
|
|
||||||
- 当前连赢次数
|
|
||||||
- 连赢限额提示
|
|
||||||
- 当前倒计时
|
|
||||||
- 当前期号
|
|
||||||
- 当前阶段状态,如 `OPEN` / `CLOSED`
|
|
||||||
|
|
||||||
倒计时要求:
|
|
||||||
|
|
||||||
- 最后 5 秒需要强化提示
|
|
||||||
- 倒计时只展示服务器推导结果
|
|
||||||
|
|
||||||
## 11. 历史与走势需求
|
|
||||||
|
|
||||||
## 11.1 右侧历史区
|
|
||||||
|
|
||||||
- 显示最近开奖记录
|
|
||||||
- 每条至少包含时间、号码、动物名
|
|
||||||
- 最新一条高亮
|
|
||||||
- 需要支持滚动
|
|
||||||
|
|
||||||
## 11.2 底部单双走势
|
|
||||||
|
|
||||||
- 保留最近 30 局
|
|
||||||
- 奇数显示红色圆点
|
|
||||||
- 偶数显示蓝色圆点
|
|
||||||
- 新增一条数据时,最后一个点需要入场动画
|
|
||||||
|
|
||||||
## 12. 弹窗与侧栏需求
|
|
||||||
|
|
||||||
## 12.1 强制公告弹窗
|
|
||||||
|
|
||||||
功能要求:
|
|
||||||
|
|
||||||
- 首屏进入时请求公告接口
|
|
||||||
- 若存在未读公告,必须强制弹出
|
|
||||||
- 不允许点击遮罩关闭
|
|
||||||
- 不允许提供右上角关闭按钮
|
|
||||||
- 勾选“已阅读并同意”前,进入游戏按钮必须禁用
|
|
||||||
- 关闭后要记录已读状态
|
|
||||||
- 无新公告时,不重复弹出
|
|
||||||
|
|
||||||
布局要求:
|
|
||||||
|
|
||||||
- 支持图文内容
|
|
||||||
- 内容超长时支持滚动
|
|
||||||
- 移动端建议复选框和主按钮上下排列,避免多语言挤压
|
|
||||||
|
|
||||||
## 12.2 规则面板
|
|
||||||
|
|
||||||
- 提供玩法规则、赔率说明、连胜机制、大奖说明
|
|
||||||
- 允许分页或滚动
|
|
||||||
- 结构化展示,不得纯长文堆叠
|
|
||||||
|
|
||||||
## 12.3 用户 Dashboard 侧滑面板
|
|
||||||
|
|
||||||
至少包含:
|
|
||||||
|
|
||||||
- 资产信息
|
|
||||||
- 充值入口
|
|
||||||
- 提现入口与手续费说明
|
|
||||||
- 最近 1 个月投注历史
|
|
||||||
- 站内信列表
|
|
||||||
- 公告信箱入口
|
|
||||||
|
|
||||||
## 13. 异常与容错要求
|
|
||||||
|
|
||||||
## 13.1 本地锁盘优先
|
|
||||||
|
|
||||||
- 到达封盘时间点时,前端必须立刻锁盘
|
|
||||||
- 即使网络延迟,也不能继续允许下注交互
|
|
||||||
|
|
||||||
## 13.2 压秒点击失败处理
|
|
||||||
|
|
||||||
场景:
|
|
||||||
|
|
||||||
- 用户在接近封盘时点击确认
|
|
||||||
- 请求发出,但服务器实际已封盘
|
|
||||||
|
|
||||||
前端处理:
|
|
||||||
|
|
||||||
- 先进入锁盘和加载态
|
|
||||||
- 若稍后收到失败响应,必须撤销未成功下注的本地状态
|
|
||||||
- 明确提示“网络延迟,下注失败,未扣款”
|
|
||||||
- 严禁误显示为下注成功
|
|
||||||
|
|
||||||
## 13.3 断线重连恢复
|
|
||||||
|
|
||||||
触发条件:
|
|
||||||
|
|
||||||
- 页面重新可见
|
|
||||||
- WebSocket 断开后重连
|
|
||||||
|
|
||||||
前端处理:
|
|
||||||
|
|
||||||
- 立即调用全量状态接口重新同步
|
|
||||||
- 重置倒计时、期号、余额、连赢、走势、当前盘面状态
|
|
||||||
- 禁止依赖本地累计时间继续运行
|
|
||||||
|
|
||||||
## 13.4 余额不足场景
|
|
||||||
|
|
||||||
- 确认按钮进入错误态
|
|
||||||
- 充值入口需要有明显引导
|
|
||||||
- 相关格子或筹码点击时给出即时反馈
|
|
||||||
|
|
||||||
## 14. 接口与事件依赖
|
|
||||||
|
|
||||||
以下为前端开发所需的最小接口能力,命名可与后端协商,但能力不可缺失。
|
|
||||||
|
|
||||||
## 14.1 HTTP 接口
|
|
||||||
|
|
||||||
- `GET /api/user/announcement`
|
|
||||||
- `GET /api/game/current_status`
|
|
||||||
- `POST /api/bet/place`
|
|
||||||
- `POST /api/auto_spin`
|
|
||||||
- `POST /api/announcement/read`
|
|
||||||
|
|
||||||
## 14.2 WebSocket 事件
|
|
||||||
|
|
||||||
- `round_status`:当前阶段、期号、服务器时间、剩余时间
|
|
||||||
- `draw_result`:开奖结果、中奖格子
|
|
||||||
- `balance_changed`:余额变化
|
|
||||||
- `streak_changed`:连赢状态与限额变化
|
|
||||||
- `trend_updated`:最新走势数据
|
|
||||||
- `auto_spin_progress`:自动托管局数进度
|
|
||||||
|
|
||||||
## 15. 性能与实现约束
|
|
||||||
|
|
||||||
- 移动端目标帧率:`60 FPS`
|
|
||||||
- 36 宫格状态切换不得出现明显掉帧
|
|
||||||
- 高亮、缩放、呼吸、闪烁等动画优先使用 GPU 友好属性
|
|
||||||
- 避免大面积重排和重绘
|
|
||||||
- 长列表区域应考虑虚拟化或分段渲染
|
|
||||||
- 全局状态更新必须避免引起整盘 36 格不必要重渲染
|
|
||||||
|
|
||||||
## 16. 多语言与响应式要求
|
|
||||||
|
|
||||||
- 所有按钮、标签、提示文案必须支持长度扩展
|
|
||||||
- 不允许固定像素宽度导致文案截断
|
|
||||||
- 文本区域需预留至少 40% 的横向伸缩空间
|
|
||||||
- 设计需优先保证移动端单手操作
|
|
||||||
- PC 端可补充 Hover 态,移动端不依赖 Hover 完成交互
|
|
||||||
|
|
||||||
## 17. 开发验收基线
|
|
||||||
|
|
||||||
满足以下条件才可进入测试或交付:
|
|
||||||
|
|
||||||
- 单局状态机完整跑通
|
|
||||||
- 封盘时前端能本地立即锁盘
|
|
||||||
- 服务端时间同步准确
|
|
||||||
- 下注数量限制与统一筹码机制无误
|
|
||||||
- 余额与连赢上限限制生效
|
|
||||||
- 自动托管可启动、运行、停止
|
|
||||||
- 公告弹窗强阻断逻辑正确
|
|
||||||
- 断线重连后状态可恢复
|
|
||||||
- 走势与历史区数据更新正确
|
|
||||||
- 移动端核心流程可稳定使用
|
|
||||||
|
|
||||||
## 18. 建议的开发顺序
|
|
||||||
|
|
||||||
1. 搭建项目骨架、路由、状态层、接口层
|
|
||||||
2. 先实现 30 秒回合状态机和服务端时间同步
|
|
||||||
3. 完成 36 宫格、筹码区、确认按钮的核心下注流程
|
|
||||||
4. 接入开奖、派彩、历史、走势
|
|
||||||
5. 实现公告弹窗、规则面板、用户侧栏
|
|
||||||
6. 实现自动托管
|
|
||||||
7. 最后补齐粒子特效、强化动画和视觉细节
|
|
||||||
|
|
||||||
## 19. 明确降级为“视觉参考”的内容
|
|
||||||
|
|
||||||
以下内容不应阻塞前端逻辑开发,可在视觉设计阶段再细化:
|
|
||||||
|
|
||||||
- 神坛主体到底是神鼎、宝箱还是法槌
|
|
||||||
- 是否采用卷轴、金属边框或全息科技框
|
|
||||||
- Jackpot 动画具体表现形式
|
|
||||||
- 动物头像是否 3D、半写实或插画风
|
|
||||||
- 特效音、粒子素材、品牌化图标样式
|
|
||||||
|
|
||||||
以上内容只影响视觉表现,不影响本文件定义的交互和功能边界。
|
|
||||||
373
docs/本次代码变更说明.md
373
docs/本次代码变更说明.md
@@ -1,373 +0,0 @@
|
|||||||
# 本次代码变更说明
|
|
||||||
|
|
||||||
本文档总结当前这一次工作中,对仓库代码所做的主要改动,重点说明:
|
|
||||||
|
|
||||||
- 新增了哪些文件
|
|
||||||
- 修改了哪些已有文件
|
|
||||||
- 每个模块新增了什么能力
|
|
||||||
- 当前已经验证过什么
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 下文基于当前 Git 工作区中的变更整理
|
|
||||||
- 其中一部分文件为“新增文件”,一部分为“在已有文件基础上的修改”
|
|
||||||
- `src/routeTree.gen.ts` 属于路由生成产物,不是手写业务文件
|
|
||||||
|
|
||||||
## 1. 本次改动目标
|
|
||||||
|
|
||||||
本次工作的核心目标,是把项目从通用脚手架推进到“36 字花”游戏前端的业务骨架阶段,采用的落地方案是:
|
|
||||||
|
|
||||||
- 统一业务路由,不按设备拆成不同 URL
|
|
||||||
- 同一路由下按设备加载移动端 / 桌面端不同视图
|
|
||||||
- 共用一套游戏状态、数据模型、mock 数据和接口层
|
|
||||||
- 将页面内写死的中英文文案逐步收敛到 `react-i18next` 的语言包里
|
|
||||||
|
|
||||||
## 2. 新增模块总览
|
|
||||||
|
|
||||||
本次新增的核心模块有 5 个:
|
|
||||||
|
|
||||||
1. `src/features/game/shared`
|
|
||||||
说明:
|
|
||||||
- 定义 36 字花游戏的共享常量、类型、mock 数据、派生计算函数
|
|
||||||
- 作为游戏业务层的基础模型
|
|
||||||
|
|
||||||
2. `src/store`
|
|
||||||
说明:
|
|
||||||
- 基于 Zustand 实现游戏的状态容器
|
|
||||||
- 按模块拆分目录
|
|
||||||
- 当前分为 `src/store/auth` 和 `src/store/game`
|
|
||||||
|
|
||||||
3. `src/features/game/api`
|
|
||||||
说明:
|
|
||||||
- 建立游戏相关接口层和 DTO 映射
|
|
||||||
- 提供 mock bootstrap 获取函数,便于在未接真实后端前先跑 UI
|
|
||||||
|
|
||||||
4. `src/features/game/components`
|
|
||||||
说明:
|
|
||||||
- 新增共享展示组件
|
|
||||||
- 同时新增移动端页面壳和桌面端页面壳
|
|
||||||
|
|
||||||
5. `src/features/game/entry`
|
|
||||||
说明:
|
|
||||||
- 增加游戏路由适配页
|
|
||||||
- 负责把共享状态、共享组件和双端壳层接起来
|
|
||||||
|
|
||||||
## 3. 新增文件清单与说明
|
|
||||||
|
|
||||||
### 3.1 通用组件
|
|
||||||
|
|
||||||
#### [src/components/language-link.tsx](/Users/jiaunun/Desktop/36-character-flower/src/components/language-link.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 抽出语言切换按钮组件
|
|
||||||
- 从 `/$lang` 布局文件中拆出,避免路由文件内混入过多内部组件
|
|
||||||
|
|
||||||
#### [src/components/nav-link.tsx](/Users/jiaunun/Desktop/36-character-flower/src/components/nav-link.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 抽出顶部导航按钮组件
|
|
||||||
- 供语言布局页复用
|
|
||||||
|
|
||||||
#### [src/components/stat-card.tsx](/Users/jiaunun/Desktop/36-character-flower/src/components/stat-card.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 抽出首页信息卡片组件
|
|
||||||
- 用于首页业务入口页展示
|
|
||||||
|
|
||||||
### 3.2 设备识别
|
|
||||||
|
|
||||||
#### [src/lib/device/use-device-type.ts](/Users/jiaunun/Desktop/36-character-flower/src/lib/device/use-device-type.ts)
|
|
||||||
新增内容:
|
|
||||||
- 基于窗口宽度判断当前设备是 `mobile` 还是 `desktop`
|
|
||||||
- 为同一路由下渲染不同视图提供支持
|
|
||||||
|
|
||||||
### 3.3 游戏共享模型
|
|
||||||
|
|
||||||
#### [src/features/game/shared/constants.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/constants.ts)
|
|
||||||
新增内容:
|
|
||||||
- 36 宫格基础尺寸常量
|
|
||||||
- 回合阶段枚举
|
|
||||||
- 格子状态枚举
|
|
||||||
- 连接状态枚举
|
|
||||||
- 筹码默认配置等
|
|
||||||
|
|
||||||
#### [src/features/game/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/types.ts)
|
|
||||||
新增内容:
|
|
||||||
- 游戏核心类型定义
|
|
||||||
- 包括格子、筹码、下注、历史、公告、连接、dashboard、bootstrap 快照等
|
|
||||||
|
|
||||||
#### [src/features/game/shared/mock-data.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/mock-data.ts)
|
|
||||||
新增内容:
|
|
||||||
- 游戏 mock 启动数据
|
|
||||||
- 包括 36 个格子、筹码、历史记录、当前回合、公告、连接状态、桌面信息
|
|
||||||
|
|
||||||
#### [src/features/game/shared/selectors.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/selectors.ts)
|
|
||||||
新增内容:
|
|
||||||
- 各种纯函数派生逻辑
|
|
||||||
- 包括格子 view model、倒计时、公告筛选、趋势计算、下注汇总等
|
|
||||||
|
|
||||||
#### [src/features/game/shared/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/shared/index.ts)
|
|
||||||
新增内容:
|
|
||||||
- 对 `shared` 子模块统一导出
|
|
||||||
|
|
||||||
### 3.4 游戏状态层
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- 游戏相关 store 已统一放入 `src/store`
|
|
||||||
- 并进一步按模块拆为子目录:
|
|
||||||
- `src/store/auth`
|
|
||||||
- `src/store/game`
|
|
||||||
- `src/features/game/model/untils.ts` 现在仅作为过渡导出层,用于维持 `features/game` 对外接口不变
|
|
||||||
|
|
||||||
#### [src/store/game/game-round-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-round-store.ts)
|
|
||||||
新增内容:
|
|
||||||
- 回合相关状态
|
|
||||||
- 包括格子、筹码、回合阶段、历史、趋势、下注选择
|
|
||||||
- 提供切换筹码、下注、清空、同步回合等动作
|
|
||||||
|
|
||||||
#### [src/store/game/game-session-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-session-store.ts)
|
|
||||||
新增内容:
|
|
||||||
- 会话类状态
|
|
||||||
- 包括公告、连接状态、dashboard 信息
|
|
||||||
- 提供已读公告、关闭公告、同步连接、同步 dashboard 等动作
|
|
||||||
|
|
||||||
#### [src/store/game/game-ui-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/game-ui-store.ts)
|
|
||||||
新增内容:
|
|
||||||
- UI 控制类状态
|
|
||||||
- 当前只放了自动托管浮层、dashboard、规则面板开关
|
|
||||||
|
|
||||||
#### [src/store/auth/auth-store.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/auth/auth-store.ts)
|
|
||||||
说明:
|
|
||||||
- 认证相关 store
|
|
||||||
- 原有 `auth-store` 已归入 `auth` 子目录
|
|
||||||
|
|
||||||
#### [src/store/auth/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/auth/index.ts)
|
|
||||||
说明:
|
|
||||||
- 统一导出认证模块 store
|
|
||||||
|
|
||||||
#### [src/store/game/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/index.ts)
|
|
||||||
说明:
|
|
||||||
- 统一导出游戏模块 store
|
|
||||||
|
|
||||||
#### [src/store/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/index.ts)
|
|
||||||
新增内容:
|
|
||||||
- 统一导出 `src/store` 下各模块
|
|
||||||
- 当前包括 `auth` 和 `game`
|
|
||||||
|
|
||||||
#### [src/features/game/model/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/model/index.ts)
|
|
||||||
新增内容:
|
|
||||||
- 作为过渡导出层,继续对 `features/game` 暴露游戏 store
|
|
||||||
|
|
||||||
### 3.5 游戏接口层
|
|
||||||
|
|
||||||
#### [src/features/game/api/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/types.ts)
|
|
||||||
新增内容:
|
|
||||||
- 游戏接口 DTO 类型定义
|
|
||||||
|
|
||||||
#### [src/features/game/api/game-api.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/game-api.ts)
|
|
||||||
新增内容:
|
|
||||||
- 游戏 API 包装
|
|
||||||
- 包括 bootstrap、round feed、announcement 的响应映射
|
|
||||||
- 提供 `getMockGameBootstrap()` 用于当前阶段 UI 接线
|
|
||||||
|
|
||||||
#### [src/features/game/api/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/api/index.ts)
|
|
||||||
新增内容:
|
|
||||||
- 对 `api` 子模块统一导出
|
|
||||||
|
|
||||||
### 3.6 游戏共享 UI 组件
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-action-bar.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-action-bar.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 筹码区、主按钮、次按钮、附加 slot 区域
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-announcement-modal.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-announcement-modal.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 强制公告弹层骨架
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-auto-spin-overlay.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-auto-spin-overlay.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 自动托管运行遮罩骨架
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-board-cell.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-board-cell.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 单个格子组件
|
|
||||||
- 支持状态、徽标、倍率、点击等展示
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-board.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-board.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 36 宫格容器组件
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-history-list.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-history-list.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 开奖历史列表组件
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-panel-card.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-panel-card.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 通用游戏面板容器
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-status-card.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-status-card.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 顶部状态卡片组件
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/game-trend-list.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/game-trend-list.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 走势列表组件
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/types.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/types.ts)
|
|
||||||
新增内容:
|
|
||||||
- 共享展示组件的 props 类型
|
|
||||||
|
|
||||||
#### [src/features/game/components/shared/utils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/shared/utils.ts)
|
|
||||||
新增内容:
|
|
||||||
- 简单的 `cn` 工具
|
|
||||||
|
|
||||||
### 3.7 双端页面壳
|
|
||||||
|
|
||||||
#### [src/features/game/components/mobile/mobile-game-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/mobile/mobile-game-page.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 移动端游戏页壳层
|
|
||||||
- 负责纵向编排
|
|
||||||
|
|
||||||
#### [src/features/game/components/desktop/desktop-game-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/desktop/desktop-game-page.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 桌面端游戏页壳层
|
|
||||||
- 负责多栏编排
|
|
||||||
|
|
||||||
#### [src/features/game/components/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/index.ts)
|
|
||||||
新增内容:
|
|
||||||
- 对组件模块统一导出
|
|
||||||
|
|
||||||
### 3.8 游戏入口与总导出
|
|
||||||
|
|
||||||
#### [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 游戏路由页适配层
|
|
||||||
- 负责:
|
|
||||||
- 读取 Zustand 状态
|
|
||||||
- 拉 mock bootstrap
|
|
||||||
- 组装状态卡片、历史、走势、面板内容
|
|
||||||
- 根据设备类型切换移动端 / 桌面端壳层
|
|
||||||
- 接公告弹窗、自动托管遮罩
|
|
||||||
- 使用 i18n 文案
|
|
||||||
|
|
||||||
#### [src/features/game/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/index.ts)
|
|
||||||
新增内容:
|
|
||||||
- 对 `game` 功能模块统一导出
|
|
||||||
|
|
||||||
### 3.9 新游戏路由
|
|
||||||
|
|
||||||
#### [src/routes/$lang/game.tsx](/Users/jiaunun/Desktop/36-character-flower/src/routes/%24lang/game.tsx)
|
|
||||||
新增内容:
|
|
||||||
- 新增 `/$lang/game` 路由
|
|
||||||
- 作为游戏大厅的统一业务入口
|
|
||||||
|
|
||||||
## 4. 修改文件清单与说明
|
|
||||||
|
|
||||||
### 4.1 基础常量与全局信息
|
|
||||||
|
|
||||||
#### [src/constants/untils.ts](/Users/jiaunun/Desktop/36-character-flower/src/constants/index.ts)
|
|
||||||
修改内容:
|
|
||||||
- 将应用名称从通用模板名改为 `36字花`
|
|
||||||
- 更新默认描述文案
|
|
||||||
- 新增桌面断点常量 `DESKTOP_LAYOUT_MIN_WIDTH_PX`
|
|
||||||
|
|
||||||
#### [index.html](/Users/jiaunun/Desktop/36-character-flower/index.html)
|
|
||||||
修改内容:
|
|
||||||
- 更新站点标题
|
|
||||||
- 更新默认 description / OG / Twitter meta 信息
|
|
||||||
|
|
||||||
### 4.2 样式层
|
|
||||||
|
|
||||||
#### [src/styles.css](/Users/jiaunun/Desktop/36-character-flower/src/styles.css)
|
|
||||||
修改内容:
|
|
||||||
- 保持 `html/body/#root` 占满视口
|
|
||||||
- 增加全局游戏主题变量
|
|
||||||
- 增加游戏外壳、面板、光效等 utility 样式
|
|
||||||
- 增加深色游戏背景基础视觉
|
|
||||||
|
|
||||||
### 4.3 路由与页面
|
|
||||||
|
|
||||||
#### [src/routes/$lang/route.tsx](/Users/jiaunun/Desktop/36-character-flower/src/routes/%24lang/route.tsx)
|
|
||||||
修改内容:
|
|
||||||
- 把原来的简单 `Outlet` 布局升级成业务壳层
|
|
||||||
- 增加顶部导航
|
|
||||||
- 增加语言切换按钮
|
|
||||||
- 使用共享导航组件
|
|
||||||
|
|
||||||
#### [src/routes/$lang/index.tsx](/Users/jiaunun/Desktop/36-character-flower/src/routes/%24lang/index.tsx)
|
|
||||||
修改内容:
|
|
||||||
- 把原来的占位首页改成业务入口首页
|
|
||||||
- 增加进入游戏大厅 CTA
|
|
||||||
- 展示当前项目架构说明
|
|
||||||
|
|
||||||
#### [src/routeTree.gen.ts](/Users/jiaunun/Desktop/36-character-flower/src/routeTree.gen.ts)
|
|
||||||
修改内容:
|
|
||||||
- 因新增路由自动重新生成
|
|
||||||
- 非手写文件
|
|
||||||
|
|
||||||
### 4.4 国际化
|
|
||||||
|
|
||||||
#### [src/locales/zh-CN/common.ts](/Users/jiaunun/Desktop/36-character-flower/src/locales/zh-CN/common.ts)
|
|
||||||
修改内容:
|
|
||||||
- 更新首页和壳层文案
|
|
||||||
- 新增整套 `game.*` 文案
|
|
||||||
- 包括:
|
|
||||||
- 游戏大厅标题、副标题
|
|
||||||
- 状态卡片文案
|
|
||||||
- 盘面、历史、走势文案
|
|
||||||
- 公告弹窗、自动托管文案
|
|
||||||
- 页脚说明文案
|
|
||||||
- phase 展示文案
|
|
||||||
|
|
||||||
#### [src/locales/en-US/common.ts](/Users/jiaunun/Desktop/36-character-flower/src/locales/en-US/common.ts)
|
|
||||||
修改内容:
|
|
||||||
- 与中文语言包同步补齐英文版本 `game.*` 文案
|
|
||||||
|
|
||||||
## 5. 本次特别修复
|
|
||||||
|
|
||||||
### 5.1 修复 `/$lang/game` 的循环更新错误
|
|
||||||
|
|
||||||
涉及文件:
|
|
||||||
- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
|
||||||
|
|
||||||
修复内容:
|
|
||||||
- 之前直接把会返回新引用的派生 selector 传给 Zustand hook,导致 React 触发无限更新
|
|
||||||
- 现已改成:
|
|
||||||
- 只从 store 读取原始 state
|
|
||||||
- 在组件内通过 `useMemo` 派生数据
|
|
||||||
|
|
||||||
### 5.2 清理页面里的硬编码双语判断
|
|
||||||
|
|
||||||
涉及文件:
|
|
||||||
- [src/features/game/entry/entry-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
|
|
||||||
- [src/routes/$lang/route.tsx](/Users/jiaunun/Desktop/36-character-flower/src/routes/%24lang/route.tsx)
|
|
||||||
- [src/locales/zh-CN/common.ts](/Users/jiaunun/Desktop/36-character-flower/src/locales/zh-CN/common.ts)
|
|
||||||
- [src/locales/en-US/common.ts](/Users/jiaunun/Desktop/36-character-flower/src/locales/en-US/common.ts)
|
|
||||||
|
|
||||||
修复内容:
|
|
||||||
- 将大量 `i18n.language === 'zh-CN' ? ... : ...` 改为统一走 `t('...')`
|
|
||||||
- 避免将用户可见文案写死在业务代码里
|
|
||||||
|
|
||||||
## 6. 当前完成度
|
|
||||||
|
|
||||||
本次完成的是“业务骨架阶段”,不是最终成品。当前已经具备:
|
|
||||||
|
|
||||||
- 统一游戏路由
|
|
||||||
- 双端页面壳
|
|
||||||
- 共享游戏状态模型
|
|
||||||
- 共享 mock 数据和接口映射
|
|
||||||
- 公告、自动托管、历史、走势等模块骨架
|
|
||||||
- 国际化接线
|
|
||||||
|
|
||||||
当前尚未完成的内容包括:
|
|
||||||
|
|
||||||
- 真实后端接口联调
|
|
||||||
- WebSocket 实时同步
|
|
||||||
- 完整的回合状态机
|
|
||||||
- 完整下注规则约束
|
|
||||||
- 最终视觉打磨和高级动效
|
|
||||||
|
|
||||||
## 7. 已验证结果
|
|
||||||
|
|
||||||
本次代码在当前状态下已完成以下验证:
|
|
||||||
|
|
||||||
- `pnpm lint` 通过
|
|
||||||
- `pnpm build` 通过
|
|
||||||
- `http://localhost:5174/zh-CN/game` 可正常打开
|
|
||||||
BIN
figma/img.jpg
BIN
figma/img.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 880 KiB |
BIN
figma/img.png
BIN
figma/img.png
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB |
@@ -6,11 +6,17 @@ import {
|
|||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import { api } from '@/lib/api/api-client'
|
import { api } from '@/lib/api/api-client'
|
||||||
import { ApiError } from '@/lib/api/api-error'
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
import type { AuthSessionInput } from '@/store/auth'
|
import {
|
||||||
|
mergeAuthUsers,
|
||||||
|
normalizeAuthSession,
|
||||||
|
normalizeAuthUserProfile,
|
||||||
|
normalizeRefreshAuthSession,
|
||||||
|
} from '@/lib/auth/auth-normalizers'
|
||||||
import { getAuthDeviceId } from '@/store/auth'
|
import { getAuthDeviceId } from '@/store/auth'
|
||||||
import type { ApiResponse } from '@/type'
|
|
||||||
import type {
|
import type {
|
||||||
|
ApiResponse,
|
||||||
AuthSessionDto,
|
AuthSessionDto,
|
||||||
|
AuthSessionInput,
|
||||||
AuthUserProfileDto,
|
AuthUserProfileDto,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
LoginRequestDto,
|
LoginRequestDto,
|
||||||
@@ -24,13 +30,7 @@ import type {
|
|||||||
SendSmsCodePayload,
|
SendSmsCodePayload,
|
||||||
SendSmsCodeRequestDto,
|
SendSmsCodeRequestDto,
|
||||||
SendSmsCodeResult,
|
SendSmsCodeResult,
|
||||||
} from './types'
|
} from '@/type'
|
||||||
import {
|
|
||||||
mergeAuthUsers,
|
|
||||||
normalizeAuthSession,
|
|
||||||
normalizeAuthUserProfile,
|
|
||||||
normalizeRefreshAuthSession,
|
|
||||||
} from './types'
|
|
||||||
|
|
||||||
const shouldLogAuthLifecycle =
|
const shouldLogAuthLifecycle =
|
||||||
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
import.meta.env.VITE_ENABLE_REQUEST_LOG === 'true'
|
||||||
@@ -5,9 +5,8 @@ import {
|
|||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import { api } from '@/lib/api/api-client'
|
import { api } from '@/lib/api/api-client'
|
||||||
import { ApiError } from '@/lib/api/api-error'
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
import type { ApiResponse } from '@/type'
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ApiResponse,
|
||||||
DepositCreateRequestDto,
|
DepositCreateRequestDto,
|
||||||
DepositCreateResponseDto,
|
DepositCreateResponseDto,
|
||||||
DepositTierItem,
|
DepositTierItem,
|
||||||
@@ -28,7 +27,7 @@ import type {
|
|||||||
WalletRecordType,
|
WalletRecordType,
|
||||||
WithdrawCreateRequestDto,
|
WithdrawCreateRequestDto,
|
||||||
WithdrawCreateResponseDto,
|
WithdrawCreateResponseDto,
|
||||||
} from './finance-types'
|
} from '@/type'
|
||||||
|
|
||||||
function unwrapFinanceEnvelope<T>(
|
function unwrapFinanceEnvelope<T>(
|
||||||
response: ApiResponse<T>,
|
response: ApiResponse<T>,
|
||||||
@@ -1,55 +1,53 @@
|
|||||||
import {
|
import {
|
||||||
API_SUCCESS_CODE,
|
API_SUCCESS_CODE,
|
||||||
|
DEFAULT_GAME_CHIP_COLORS,
|
||||||
DEFAULT_LIST_PAGE_SIZE,
|
DEFAULT_LIST_PAGE_SIZE,
|
||||||
GAME_API_ENDPOINTS,
|
GAME_API_ENDPOINTS,
|
||||||
|
GAME_GRID_COLUMNS,
|
||||||
|
GAME_MAX_SELECTION_CELLS,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
|
import {
|
||||||
|
createEmptyGameBootstrapSnapshot,
|
||||||
|
deriveTrendEntries,
|
||||||
|
} from '@/features/game/shared'
|
||||||
import { api } from '@/lib/api/api-client'
|
import { api } from '@/lib/api/api-client'
|
||||||
import { ApiError } from '@/lib/api/api-error'
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
import type { ApiResponse } from '@/type'
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AnnouncementItem,
|
AnnouncementItem,
|
||||||
AnnouncementState,
|
AnnouncementState,
|
||||||
BetSelection,
|
|
||||||
ConnectionState,
|
|
||||||
DashboardState,
|
|
||||||
GameBootstrapSnapshot,
|
|
||||||
GameCell,
|
|
||||||
HistoryEntry,
|
|
||||||
RoundPhase,
|
|
||||||
RoundSnapshot,
|
|
||||||
TrendEntry,
|
|
||||||
} from '../shared'
|
|
||||||
import {
|
|
||||||
createEmptyGameBootstrapSnapshot,
|
|
||||||
DEFAULT_GAME_CHIP_COLORS,
|
|
||||||
deriveTrendEntries,
|
|
||||||
GAME_GRID_COLUMNS,
|
|
||||||
GAME_MAX_SELECTION_CELLS,
|
|
||||||
} from '../shared'
|
|
||||||
import type {
|
|
||||||
AnnouncementStateDto,
|
AnnouncementStateDto,
|
||||||
|
ApiResponse,
|
||||||
|
BetSelection,
|
||||||
BetSelectionDto,
|
BetSelectionDto,
|
||||||
ChipDto,
|
ChipDto,
|
||||||
|
ConnectionState,
|
||||||
ConnectionStateDto,
|
ConnectionStateDto,
|
||||||
|
DashboardState,
|
||||||
DashboardStateDto,
|
DashboardStateDto,
|
||||||
GameAnnouncementsDto,
|
GameAnnouncementsDto,
|
||||||
GameBetOrdersDto,
|
GameBetOrdersDto,
|
||||||
GameBootstrapDto,
|
GameBootstrapDto,
|
||||||
|
GameBootstrapSnapshot,
|
||||||
|
GameCell,
|
||||||
GameCellDto,
|
GameCellDto,
|
||||||
GameLobbyInitDto,
|
GameLobbyInitDto,
|
||||||
|
GameLobbyInitResult,
|
||||||
GameLobbyPeriodDto,
|
GameLobbyPeriodDto,
|
||||||
GamePeriodTickDto,
|
GamePeriodTickDto,
|
||||||
GamePlaceBetDto,
|
GamePlaceBetDto,
|
||||||
GamePlaceBetRequestDto,
|
GamePlaceBetRequestDto,
|
||||||
GameRoundFeedDto,
|
GameRoundFeedDto,
|
||||||
|
HistoryEntry,
|
||||||
HistoryEntryDto,
|
HistoryEntryDto,
|
||||||
NoticeConfirmDto,
|
NoticeConfirmDto,
|
||||||
NoticeDetailDto,
|
NoticeDetailDto,
|
||||||
NoticeListDto,
|
NoticeListDto,
|
||||||
|
RoundPhase,
|
||||||
|
RoundSnapshot,
|
||||||
RoundSnapshotDto,
|
RoundSnapshotDto,
|
||||||
|
TrendEntry,
|
||||||
TrendEntryDto,
|
TrendEntryDto,
|
||||||
} from './types'
|
} from '@/type'
|
||||||
|
|
||||||
function unwrapGameEnvelope<T>(
|
function unwrapGameEnvelope<T>(
|
||||||
response: ApiResponse<T>,
|
response: ApiResponse<T>,
|
||||||
@@ -86,13 +84,6 @@ function assertLobbyInitDto(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameLobbyInitResult {
|
|
||||||
runtimeEnabled: boolean
|
|
||||||
serverTime: number
|
|
||||||
snapshot: GameBootstrapSnapshot
|
|
||||||
userSnapshot: GameLobbyInitDto['user_snapshot']
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeGameCell(dto: GameCellDto) {
|
function normalizeGameCell(dto: GameCellDto) {
|
||||||
return dto satisfies GameCell
|
return dto satisfies GameCell
|
||||||
}
|
}
|
||||||
4
src/api/index.ts
Normal file
4
src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './auth-api'
|
||||||
|
export * from './finance-api'
|
||||||
|
export * from './game-api'
|
||||||
|
export * from './period-history-api'
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
import { API_SUCCESS_CODE, GAME_API_ENDPOINTS } from '@/constants'
|
import { API_SUCCESS_CODE, GAME_API_ENDPOINTS } from '@/constants'
|
||||||
import { api } from '@/lib/api/api-client'
|
import { api } from '@/lib/api/api-client'
|
||||||
import { ApiError } from '@/lib/api/api-error'
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
import type { ApiResponse } from '@/type'
|
import type { ApiResponse, GamePeriodHistoryItemDto } from '@/type'
|
||||||
|
|
||||||
export interface GamePeriodHistoryItemDto {
|
|
||||||
open_time: number
|
|
||||||
period_no: string
|
|
||||||
result_number: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GamePeriodHistoryDto {
|
interface GamePeriodHistoryDto {
|
||||||
list: GamePeriodHistoryItemDto[]
|
list: GamePeriodHistoryItemDto[]
|
||||||
BIN
src/assets/system/mobile-modal-header.webp
Normal file
BIN
src/assets/system/mobile-modal-header.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
170
src/components/mobile-center-modal.tsx
Normal file
170
src/components/mobile-center-modal.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { type ReactNode, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import mobileModalHeader from '@/assets/system/mobile-modal-header.webp'
|
||||||
|
import modalClose from '@/assets/system/modal-close.webp'
|
||||||
|
import { acquireBodyScrollLock } from '@/lib/dom/body-scroll-lock'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface MobileCenterModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose?: () => void
|
||||||
|
title?: ReactNode
|
||||||
|
titleAlign?: 'left' | 'center'
|
||||||
|
isShowClose?: boolean
|
||||||
|
isNormalBg?: boolean
|
||||||
|
children?: ReactNode
|
||||||
|
className?: string
|
||||||
|
backdropClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_MODAL_HEADER_HEIGHT = 'calc(var(--design-unit)*82)'
|
||||||
|
const MOBILE_MODAL_CONTENT_TOP = 'calc(var(--design-unit)*40)'
|
||||||
|
|
||||||
|
export function MobileCenterModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
titleAlign = 'left',
|
||||||
|
isShowClose = true,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
backdropClassName,
|
||||||
|
}: MobileCenterModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const onCloseRef = useRef(onClose)
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || typeof document === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseBodyScrollLock = acquireBodyScrollLock()
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onCloseRef.current?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
releaseBodyScrollLock()
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open || typeof document === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 px-design-12 py-design-14',
|
||||||
|
backdropClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={
|
||||||
|
typeof title === 'string'
|
||||||
|
? title
|
||||||
|
: t('commonUi.modal.defaultAriaLabel')
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'relative flex min-h-0 w-full flex-col overflow-visible rounded-[calc(var(--design-unit)*12)] text-white shadow-[0_0_calc(var(--design-unit)*22)_rgba(21,213,232,0.18)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative h-full min-h-0 w-full overflow-visible rounded-b-[calc(var(--design-unit)*10)] bg-[linear-gradient(180deg,rgba(5,37,47,0.98)_0%,rgba(2,21,31,0.98)_58%,rgba(1,12,20,0.99)_100%)] shadow-[inset_0_0_calc(var(--design-unit)*24)_rgba(30,206,222,0.18)]',
|
||||||
|
title ? '' : 'pt-design-40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 z-[1] rounded-b-[calc(var(--design-unit)*10)] border-x border-b border-[rgba(108,205,207,0.72)]"
|
||||||
|
style={{
|
||||||
|
top: MOBILE_MODAL_CONTENT_TOP,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute z-20 shrink-0"
|
||||||
|
style={{
|
||||||
|
top: 'calc(var(--design-unit)*-2)',
|
||||||
|
left: 'calc(var(--design-unit)*-3)',
|
||||||
|
right: 'calc(var(--design-unit)*-3)',
|
||||||
|
height: 'calc(var(--design-unit)*86)',
|
||||||
|
backgroundImage: `url(${mobileModalHeader})`,
|
||||||
|
backgroundPosition: 'top center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: '100% 100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-0 z-30 shrink-0 px-design-22"
|
||||||
|
style={{
|
||||||
|
left: 'calc(var(--design-unit)*-8)',
|
||||||
|
right: 'calc(var(--design-unit)*-8)',
|
||||||
|
height: MOBILE_MODAL_HEADER_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-auto flex h-design-44 w-full items-center text-design-22 font-semibold tracking-[0.05em] text-cyan-50',
|
||||||
|
titleAlign === 'center'
|
||||||
|
? 'justify-center text-center'
|
||||||
|
: 'justify-start text-left',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isShowClose && onClose ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('commonUi.modal.close')}
|
||||||
|
onClick={handleClose}
|
||||||
|
className="pointer-events-auto absolute top-design-11 right-design-15 inline-flex h-design-24 w-design-34 cursor-pointer items-center justify-center rounded-full transition hover:scale-105 active:scale-95"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={modalClose}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="modal-close-glow relative z-10 h-design-32 w-design-26 object-contain"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative z-10 min-h-0 w-full overflow-hidden"
|
||||||
|
style={{
|
||||||
|
height: `calc(100% - ${MOBILE_MODAL_CONTENT_TOP})`,
|
||||||
|
marginTop: MOBILE_MODAL_CONTENT_TOP,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="h-full min-h-0 w-full overflow-y-auto overscroll-contain">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import controlLeft from '@/assets/game/control-left.webp'
|
|||||||
import controlMid from '@/assets/game/control-mid.webp'
|
import controlMid from '@/assets/game/control-mid.webp'
|
||||||
import controlRight from '@/assets/game/control-right.webp'
|
import controlRight from '@/assets/game/control-right.webp'
|
||||||
import hallMusic from '@/assets/music/hall-music.mp3'
|
import hallMusic from '@/assets/music/hall-music.mp3'
|
||||||
import type { DepositWithdrawConfig } from '@/features/game/api/finance-types'
|
import type { DepositWithdrawConfig } from '@/type'
|
||||||
|
|
||||||
/** @description 游戏棋盘行数。 */
|
/** @description 游戏棋盘行数。 */
|
||||||
export const GAME_GRID_ROWS = 6
|
export const GAME_GRID_ROWS = 6
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Eye, EyeOff } from 'lucide-react'
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import { type ComponentProps, type ReactNode, useState } from 'react'
|
import { type ComponentProps, type ReactNode, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import rightImg from '@/assets/system/right.webp'
|
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
|
||||||
import { Input } from '@/components/ui/input.tsx'
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -42,33 +40,6 @@ export function DesktopAuthInputError({ message }: { message?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopAuthFooterLinks({
|
|
||||||
primaryLabel,
|
|
||||||
secondaryLabel,
|
|
||||||
}: {
|
|
||||||
primaryLabel: string
|
|
||||||
secondaryLabel: string
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={'flex items-center justify-around'}>
|
|
||||||
{[primaryLabel, secondaryLabel].map((label) => (
|
|
||||||
<div key={label} className={'flex items-center gap-design-10'}>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
'flex items-center justify-center bg-[#040B0F] border border-[#549195] rounded-md w-design-38 h-design-38'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SmartImage alt={t('auth.common.arrowIconAlt')} src={rightImg} />
|
|
||||||
</div>
|
|
||||||
<div className={'text-[#549195]'}>{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DesktopAuthPasswordInputProps = Omit<ComponentProps<'input'>, 'type'>
|
type DesktopAuthPasswordInputProps = Omit<ComponentProps<'input'>, 'type'>
|
||||||
|
|
||||||
export function DesktopAuthPasswordInput({
|
export function DesktopAuthPasswordInput({
|
||||||
@@ -104,23 +75,3 @@ export function DesktopAuthPasswordInput({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopAuthSubmitError({
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
message?: string | null
|
|
||||||
}) {
|
|
||||||
if (!message) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'w-full rounded-md border border-[#B93F44] bg-[rgba(78,17,23,0.35)] px-design-20 py-design-14 text-design-18 text-[#FFD2D2]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useController } from 'react-hook-form'
|
import { useController } from 'react-hook-form'
|
||||||
|
import { useLoginForm } from '@/hooks/use-login-form'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
import { useLoginForm } from '../hooks/use-login-form'
|
|
||||||
import { DesktopLoginFormView } from './desktop-login-form-view'
|
import { DesktopLoginFormView } from './desktop-login-form-view'
|
||||||
|
|
||||||
interface DesktopLoginFormProps {
|
interface DesktopLoginFormProps {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useController } from 'react-hook-form'
|
import { useController } from 'react-hook-form'
|
||||||
|
import { useRegisterForm } from '@/hooks/use-register-form'
|
||||||
|
import { useSendSmsCode } from '@/hooks/use-send-sms-code'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
import { useRegisterForm } from '../hooks/use-register-form'
|
|
||||||
import { useSendSmsCode } from '../hooks/use-send-sms-code'
|
|
||||||
import { DesktopRegisterFormView } from './desktop-register-form-view'
|
import { DesktopRegisterFormView } from './desktop-register-form-view'
|
||||||
|
|
||||||
interface DesktopRegisterFormProps {
|
interface DesktopRegisterFormProps {
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { type ComponentProps, type ReactNode, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function MobileAuthFieldRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-design-5">
|
||||||
|
<span className="text-design-12 font-medium leading-none text-[#67C9CE]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileAuthInputError({ message }: { message?: string }) {
|
||||||
|
if (!message) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-design-10 leading-tight text-[#FF7777]">{message}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MobileAuthPasswordInputProps = Omit<ComponentProps<'input'>, 'type'>
|
||||||
|
|
||||||
|
export function MobileAuthPasswordInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MobileAuthPasswordInputProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
type={isVisible ? 'text' : 'password'}
|
||||||
|
className={cn('pr-design-36', className)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t(
|
||||||
|
isVisible
|
||||||
|
? 'auth.common.passwordVisibility.hide'
|
||||||
|
: 'auth.common.passwordVisibility.show',
|
||||||
|
)}
|
||||||
|
onClick={() => setIsVisible((value) => !value)}
|
||||||
|
className="absolute right-design-8 top-1/2 flex h-design-24 w-design-24 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-[#A9E8EA] transition-colors duration-200 ease-out hover:text-[#F2FFFF] focus-visible:outline-none focus-visible:ring-[calc(var(--design-unit)*1.5)] focus-visible:ring-[rgba(110,255,255,0.4)]"
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
|
<EyeOff aria-hidden="true" className="h-design-15 w-design-15" />
|
||||||
|
) : (
|
||||||
|
<Eye aria-hidden="true" className="h-design-15 w-design-15" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
src/features/auth/components/mobile/mobile-login-form-view.tsx
Normal file
145
src/features/auth/components/mobile/mobile-login-form-view.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { motion, useReducedMotion } from 'motion/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import loginBg from '@/assets/system/login-bg.webp'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
import {
|
||||||
|
MobileAuthFieldRow,
|
||||||
|
MobileAuthInputError,
|
||||||
|
MobileAuthPasswordInput,
|
||||||
|
} from './mobile-auth-form-parts'
|
||||||
|
|
||||||
|
interface MobileLoginFormViewProps {
|
||||||
|
errors: {
|
||||||
|
password?: string
|
||||||
|
username?: string
|
||||||
|
}
|
||||||
|
isSubmitting: boolean
|
||||||
|
onPasswordChange: (value: string) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
onSwitchToRegister: () => void
|
||||||
|
onUsernameChange: (value: string) => void
|
||||||
|
password: string
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileLoginFormView({
|
||||||
|
errors,
|
||||||
|
isSubmitting,
|
||||||
|
onPasswordChange,
|
||||||
|
onSubmit,
|
||||||
|
onSwitchToRegister,
|
||||||
|
onUsernameChange,
|
||||||
|
password,
|
||||||
|
username,
|
||||||
|
}: MobileLoginFormViewProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const inputClassName =
|
||||||
|
'h-design-36 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] px-design-10 py-0 text-left text-design-14 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(129,239,243,0.05)]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}}
|
||||||
|
className="relative isolate flex h-full min-h-0 flex-col justify-between gap-design-8 px-design-14 pb-design-12 pt-design-2"
|
||||||
|
>
|
||||||
|
<div className="relative min-h-0 w-full flex-1 overflow-hidden rounded-[calc(var(--design-unit)*12)] border border-[#214B53] mt-design-10 bg-[linear-gradient(180deg,rgba(7,21,27,0.94)_0%,rgba(5,15,20,0.82)_100%)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(6,112,126,0.14),inset_0_0_0_calc(var(--design-unit)*1)_rgba(120,222,227,0.08)] backdrop-blur-[calc(var(--design-unit)*8)]">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(93,211,218,0.16),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(63,109,137,0.22),transparent_34%)]" />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-design-80 bg-[linear-gradient(180deg,rgba(87,196,201,0.12),transparent)]" />
|
||||||
|
|
||||||
|
<div className="relative flex h-full min-h-0 flex-col overflow-y-auto px-design-18 py-design-14">
|
||||||
|
<div className="flex items-center gap-design-10 mb-design-10">
|
||||||
|
<div className="h-design-6 w-design-6 rounded-full bg-[#6EE4E6] shadow-[0_0_calc(var(--design-unit)*10)_rgba(110,228,230,0.75)]" />
|
||||||
|
<div className="h-px flex-1 bg-[linear-gradient(90deg,rgba(110,228,230,0.5),rgba(110,228,230,0))]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobileAuthFieldRow label={t('auth.login.fields.username.label')}>
|
||||||
|
<Input
|
||||||
|
id="mobile-login-username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
spellCheck={false}
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => onUsernameChange(event.target.value)}
|
||||||
|
placeholder={t('auth.login.fields.username.placeholder')}
|
||||||
|
aria-describedby="mobile-login-username-error"
|
||||||
|
aria-invalid={Boolean(errors.username)}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="mobile-login-username-error"
|
||||||
|
className="relative h-design-16 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0">
|
||||||
|
<MobileAuthInputError
|
||||||
|
message={errors.username ? t(errors.username) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileAuthFieldRow>
|
||||||
|
|
||||||
|
<MobileAuthFieldRow label={t('auth.login.fields.password.label')}>
|
||||||
|
<MobileAuthPasswordInput
|
||||||
|
id="mobile-login-password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
|
placeholder={t('auth.login.fields.password.placeholder')}
|
||||||
|
aria-describedby="mobile-login-password-error"
|
||||||
|
aria-invalid={Boolean(errors.password)}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="mobile-login-password-error"
|
||||||
|
className="relative h-design-16 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0">
|
||||||
|
<MobileAuthInputError
|
||||||
|
message={errors.password ? t(errors.password) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileAuthFieldRow>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
||||||
|
className="mt-auto flex items-center justify-center gap-design-7 text-center text-design-12 text-[#6DB5B9]"
|
||||||
|
>
|
||||||
|
<div className="h-px w-design-42 bg-[linear-gradient(90deg,rgba(109,181,185,0),rgba(109,181,185,0.7))]" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToRegister}
|
||||||
|
className="cursor-pointer underline underline-offset-[calc(var(--design-unit)*3)] transition-colors duration-200 ease-out hover:text-[#90DBDE] focus-visible:outline-none focus-visible:text-[#90DBDE]"
|
||||||
|
>
|
||||||
|
{t('auth.login.footer.registerAccount')}
|
||||||
|
</button>
|
||||||
|
<div className="h-px w-design-42 bg-[linear-gradient(90deg,rgba(109,181,185,0.7),rgba(109,181,185,0))]" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex w-full shrink-0 justify-center">
|
||||||
|
<div className="pointer-events-none absolute inset-x-[20%] top-1/2 h-design-22 -translate-y-1/2 rounded-full bg-[rgba(76,213,216,0.22)] blur-[calc(var(--design-unit)*14)]" />
|
||||||
|
<SmartBackground
|
||||||
|
as={motion.button}
|
||||||
|
type="submit"
|
||||||
|
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
||||||
|
src={loginBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className="relative z-10 flex h-design-58 w-design-210 cursor-pointer items-center justify-center overflow-hidden pb-design-2 text-design-18 font-bold text-[#F2FFFF] duration-200 ease-out hover:brightness-110 disabled:pointer-events-none disabled:opacity-60"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<span className="modal-title-glow text-design-15">
|
||||||
|
{isSubmitting
|
||||||
|
? t('auth.common.actions.submitting')
|
||||||
|
: t('auth.login.actions.submit')}
|
||||||
|
</span>
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/features/auth/components/mobile/mobile-login-form.tsx
Normal file
43
src/features/auth/components/mobile/mobile-login-form.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useController } from 'react-hook-form'
|
||||||
|
import { useLoginForm } from '@/hooks/use-login-form'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
import { MobileLoginFormView } from './mobile-login-form-view'
|
||||||
|
|
||||||
|
interface MobileLoginFormProps {
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileLoginForm({ onSuccess }: MobileLoginFormProps) {
|
||||||
|
const { form, isSubmitting, onSubmit } = useLoginForm({
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
|
||||||
|
const usernameField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'username',
|
||||||
|
})
|
||||||
|
const passwordField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'password',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSwitchToRegister() {
|
||||||
|
openExclusiveModal('desktopRegister')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileLoginFormView
|
||||||
|
username={usernameField.field.value ?? ''}
|
||||||
|
password={passwordField.field.value ?? ''}
|
||||||
|
errors={{
|
||||||
|
password: form.formState.errors.password?.message,
|
||||||
|
username: form.formState.errors.username?.message,
|
||||||
|
}}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onPasswordChange={passwordField.field.onChange}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onSwitchToRegister={handleSwitchToRegister}
|
||||||
|
onUsernameChange={usernameField.field.onChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { motion, useReducedMotion } from 'motion/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import loginBg from '@/assets/system/login-bg.webp'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
MobileAuthFieldRow,
|
||||||
|
MobileAuthInputError,
|
||||||
|
MobileAuthPasswordInput,
|
||||||
|
} from './mobile-auth-form-parts'
|
||||||
|
|
||||||
|
interface MobileRegisterFormViewProps {
|
||||||
|
captcha: string
|
||||||
|
confirmPassword: string
|
||||||
|
errors: {
|
||||||
|
captcha?: string
|
||||||
|
confirmPassword?: string
|
||||||
|
inviteCode?: string
|
||||||
|
mobile?: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
inviteCode: string
|
||||||
|
isSendingSmsCode: boolean
|
||||||
|
isSubmitting: boolean
|
||||||
|
mobile: string
|
||||||
|
onCaptchaChange: (value: string) => void
|
||||||
|
onConfirmPasswordChange: (value: string) => void
|
||||||
|
onInviteCodeChange: (value: string) => void
|
||||||
|
onMobileChange: (value: string) => void
|
||||||
|
onPasswordChange: (value: string) => void
|
||||||
|
onSendSmsCode: () => Promise<unknown>
|
||||||
|
onSubmit: () => void
|
||||||
|
onSwitchToLogin: () => void
|
||||||
|
password: string
|
||||||
|
smsCodeCanSend: boolean
|
||||||
|
smsCodeRemainingSeconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorSlotClassName = 'relative h-design-12 overflow-hidden'
|
||||||
|
|
||||||
|
export function MobileRegisterFormView({
|
||||||
|
captcha,
|
||||||
|
confirmPassword,
|
||||||
|
errors,
|
||||||
|
inviteCode,
|
||||||
|
isSendingSmsCode,
|
||||||
|
isSubmitting,
|
||||||
|
mobile,
|
||||||
|
onCaptchaChange,
|
||||||
|
onConfirmPasswordChange,
|
||||||
|
onInviteCodeChange,
|
||||||
|
onMobileChange,
|
||||||
|
onPasswordChange,
|
||||||
|
onSendSmsCode,
|
||||||
|
onSubmit,
|
||||||
|
onSwitchToLogin,
|
||||||
|
password,
|
||||||
|
smsCodeCanSend,
|
||||||
|
smsCodeRemainingSeconds,
|
||||||
|
}: MobileRegisterFormViewProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const inputClassName =
|
||||||
|
'h-design-32 border border-transparent bg-[linear-gradient(180deg,rgba(23,98,105,0.72),rgba(11,62,68,0.82))] px-design-10 py-0 text-left text-design-13 shadow-[inset_0_0_calc(var(--design-unit)*8)_rgba(129,239,243,0.05)]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}}
|
||||||
|
className="relative isolate flex h-full min-h-0 flex-col justify-between gap-design-6 px-design-14 pb-design-10 pt-design-10"
|
||||||
|
>
|
||||||
|
<div className="relative min-h-0 w-full flex-1 overflow-hidden rounded-[calc(var(--design-unit)*12)] border border-[#214B53] bg-[linear-gradient(180deg,rgba(7,21,27,0.94)_0%,rgba(5,15,20,0.82)_100%)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(6,112,126,0.14),inset_0_0_0_calc(var(--design-unit)*1)_rgba(120,222,227,0.08)] backdrop-blur-[calc(var(--design-unit)*8)]">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(93,211,218,0.16),transparent_30%),radial-gradient(circle_at_bottom_left,rgba(63,109,137,0.22),transparent_34%)]" />
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-design-80 bg-[linear-gradient(180deg,rgba(87,196,201,0.12),transparent)]" />
|
||||||
|
|
||||||
|
<div className="relative flex h-full min-h-0 flex-col overflow-y-auto px-design-18 py-design-10">
|
||||||
|
<div className="flex items-center gap-design-10 mb-design-5">
|
||||||
|
<div className="h-design-6 w-design-6 rounded-full bg-[#6EE4E6] shadow-[0_0_calc(var(--design-unit)*10)_rgba(110,228,230,0.75)]" />
|
||||||
|
<div className="h-px flex-1 bg-[linear-gradient(90deg,rgba(110,228,230,0.5),rgba(110,228,230,0))]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobileAuthFieldRow label={t('auth.register.fields.mobile.label')}>
|
||||||
|
<Input
|
||||||
|
id="mobile-register-mobile"
|
||||||
|
name="mobile"
|
||||||
|
autoComplete="username"
|
||||||
|
inputMode="tel"
|
||||||
|
spellCheck={false}
|
||||||
|
value={mobile}
|
||||||
|
onChange={(event) => onMobileChange(event.target.value)}
|
||||||
|
placeholder={t('auth.register.fields.mobile.placeholder')}
|
||||||
|
aria-describedby="mobile-register-mobile-error"
|
||||||
|
aria-invalid={Boolean(errors.mobile)}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="mobile-register-mobile-error"
|
||||||
|
className={errorSlotClassName}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0">
|
||||||
|
<MobileAuthInputError
|
||||||
|
message={errors.mobile ? t(errors.mobile) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileAuthFieldRow>
|
||||||
|
|
||||||
|
<MobileAuthFieldRow label={t('auth.register.fields.captcha.label')}>
|
||||||
|
<div className="flex min-w-0 gap-design-8">
|
||||||
|
<Input
|
||||||
|
id="mobile-register-captcha"
|
||||||
|
name="captcha"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
spellCheck={false}
|
||||||
|
value={captcha}
|
||||||
|
onChange={(event) => onCaptchaChange(event.target.value)}
|
||||||
|
placeholder={t('auth.register.fields.captcha.placeholder')}
|
||||||
|
aria-describedby="mobile-register-captcha-error"
|
||||||
|
aria-invalid={Boolean(errors.captcha)}
|
||||||
|
className={cn(inputClassName, 'min-w-0 flex-1')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onSendSmsCode()}
|
||||||
|
disabled={!mobile.trim() || !smsCodeCanSend}
|
||||||
|
className={cn(
|
||||||
|
'h-design-32 w-design-94 shrink-0 cursor-pointer rounded-md border border-[#3F8E93] bg-[linear-gradient(180deg,rgba(37,116,122,0.9),rgba(16,75,82,0.9))] px-design-5 text-design-11 font-semibold leading-tight text-[#E8FFFF] transition duration-200 ease-out hover:brightness-110 focus-visible:outline-none focus-visible:ring-[calc(var(--design-unit)*1.5)] focus-visible:ring-[rgba(110,255,255,0.35)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-55',
|
||||||
|
smsCodeRemainingSeconds > 0 && 'text-[#9AC7CA]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{smsCodeRemainingSeconds > 0
|
||||||
|
? t('auth.register.sms.countdown', {
|
||||||
|
seconds: smsCodeRemainingSeconds,
|
||||||
|
})
|
||||||
|
: isSendingSmsCode
|
||||||
|
? t('auth.register.sms.sending')
|
||||||
|
: t('auth.register.sms.send')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="mobile-register-captcha-error"
|
||||||
|
className={errorSlotClassName}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0">
|
||||||
|
<MobileAuthInputError
|
||||||
|
message={errors.captcha ? t(errors.captcha) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileAuthFieldRow>
|
||||||
|
|
||||||
|
<MobileAuthFieldRow label={t('auth.register.fields.password.label')}>
|
||||||
|
<MobileAuthPasswordInput
|
||||||
|
id="mobile-register-password"
|
||||||
|
name="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
|
placeholder={t('auth.register.fields.password.placeholder')}
|
||||||
|
aria-describedby="mobile-register-password-error"
|
||||||
|
aria-invalid={Boolean(errors.password)}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="mobile-register-password-error"
|
||||||
|
className={errorSlotClassName}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0">
|
||||||
|
<MobileAuthInputError
|
||||||
|
message={errors.password ? t(errors.password) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileAuthFieldRow>
|
||||||
|
|
||||||
|
<MobileAuthFieldRow
|
||||||
|
label={t('auth.register.fields.confirmPassword.label')}
|
||||||
|
>
|
||||||
|
<MobileAuthPasswordInput
|
||||||
|
id="mobile-register-confirm-password"
|
||||||
|
name="confirmPassword"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => onConfirmPasswordChange(event.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
'auth.register.fields.confirmPassword.placeholder',
|
||||||
|
)}
|
||||||
|
aria-describedby="mobile-register-confirm-password-error"
|
||||||
|
aria-invalid={Boolean(errors.confirmPassword)}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="mobile-register-confirm-password-error"
|
||||||
|
className={errorSlotClassName}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0">
|
||||||
|
<MobileAuthInputError
|
||||||
|
message={
|
||||||
|
errors.confirmPassword
|
||||||
|
? t(errors.confirmPassword)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileAuthFieldRow>
|
||||||
|
|
||||||
|
<MobileAuthFieldRow
|
||||||
|
label={t('auth.register.fields.inviteCode.label')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="mobile-register-invite-code"
|
||||||
|
name="inviteCode"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(event) => onInviteCodeChange(event.target.value)}
|
||||||
|
placeholder={t('auth.register.fields.inviteCode.placeholder')}
|
||||||
|
aria-describedby="mobile-register-invite-code-error"
|
||||||
|
aria-invalid={Boolean(errors.inviteCode)}
|
||||||
|
className={inputClassName}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="mobile-register-invite-code-error"
|
||||||
|
className={errorSlotClassName}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-x-0 top-0">
|
||||||
|
<MobileAuthInputError
|
||||||
|
message={errors.inviteCode ? t(errors.inviteCode) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileAuthFieldRow>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
whileTap={prefersReducedMotion ? undefined : { scale: 0.98 }}
|
||||||
|
className="flex items-center justify-center gap-design-6 text-center text-design-11 text-[#6DB5B9]"
|
||||||
|
>
|
||||||
|
<div className="h-px w-design-30 bg-[linear-gradient(90deg,rgba(109,181,185,0),rgba(109,181,185,0.7))]" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSwitchToLogin}
|
||||||
|
className="cursor-pointer underline underline-offset-[calc(var(--design-unit)*3)] transition-colors duration-200 ease-out hover:text-[#90DBDE] focus-visible:outline-none focus-visible:text-[#90DBDE]"
|
||||||
|
>
|
||||||
|
{t('auth.register.footer.alreadyHaveAccount')}
|
||||||
|
</button>
|
||||||
|
<div className="h-px w-design-30 bg-[linear-gradient(90deg,rgba(109,181,185,0.7),rgba(109,181,185,0))]" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex w-full shrink-0 justify-center">
|
||||||
|
<div className="pointer-events-none absolute inset-x-[22%] top-1/2 h-design-20 -translate-y-1/2 rounded-full bg-[rgba(76,213,216,0.22)] blur-[calc(var(--design-unit)*12)]" />
|
||||||
|
<SmartBackground
|
||||||
|
as={motion.button}
|
||||||
|
type="submit"
|
||||||
|
whileTap={prefersReducedMotion ? undefined : { scale: 0.97 }}
|
||||||
|
src={loginBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className="relative z-10 flex h-design-52 w-design-198 cursor-pointer items-center justify-center overflow-hidden pb-design-2 text-design-17 font-bold text-[#F2FFFF] duration-200 ease-out hover:brightness-110 disabled:pointer-events-none disabled:opacity-60"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<span className="modal-title-glow text-design-14">
|
||||||
|
{isSubmitting
|
||||||
|
? t('auth.common.actions.submitting')
|
||||||
|
: t('auth.register.actions.submit')}
|
||||||
|
</span>
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
70
src/features/auth/components/mobile/mobile-register-form.tsx
Normal file
70
src/features/auth/components/mobile/mobile-register-form.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useController } from 'react-hook-form'
|
||||||
|
import { useRegisterForm } from '@/hooks/use-register-form'
|
||||||
|
import { useSendSmsCode } from '@/hooks/use-send-sms-code'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
import { MobileRegisterFormView } from './mobile-register-form-view'
|
||||||
|
|
||||||
|
interface MobileRegisterFormProps {
|
||||||
|
onSuccess?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileRegisterForm({ onSuccess }: MobileRegisterFormProps) {
|
||||||
|
const { form, isSubmitting, onSubmit } = useRegisterForm({
|
||||||
|
onSuccess,
|
||||||
|
})
|
||||||
|
const smsCode = useSendSmsCode()
|
||||||
|
const openExclusiveModal = useModalStore((state) => state.openExclusiveModal)
|
||||||
|
const mobileField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'mobile',
|
||||||
|
})
|
||||||
|
const captchaField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'captcha',
|
||||||
|
})
|
||||||
|
const passwordField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'password',
|
||||||
|
})
|
||||||
|
const confirmPasswordField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'confirmPassword',
|
||||||
|
})
|
||||||
|
const inviteCodeField = useController({
|
||||||
|
control: form.control,
|
||||||
|
name: 'inviteCode',
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSwitchToLogin() {
|
||||||
|
openExclusiveModal('desktopLogin')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileRegisterFormView
|
||||||
|
mobile={mobileField.field.value ?? ''}
|
||||||
|
captcha={captchaField.field.value ?? ''}
|
||||||
|
password={passwordField.field.value ?? ''}
|
||||||
|
confirmPassword={confirmPasswordField.field.value ?? ''}
|
||||||
|
inviteCode={inviteCodeField.field.value ?? ''}
|
||||||
|
errors={{
|
||||||
|
captcha: form.formState.errors.captcha?.message,
|
||||||
|
confirmPassword: form.formState.errors.confirmPassword?.message,
|
||||||
|
inviteCode: form.formState.errors.inviteCode?.message,
|
||||||
|
mobile: form.formState.errors.mobile?.message,
|
||||||
|
password: form.formState.errors.password?.message,
|
||||||
|
}}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
isSendingSmsCode={smsCode.isSending}
|
||||||
|
onCaptchaChange={captchaField.field.onChange}
|
||||||
|
onConfirmPasswordChange={confirmPasswordField.field.onChange}
|
||||||
|
onInviteCodeChange={inviteCodeField.field.onChange}
|
||||||
|
onMobileChange={mobileField.field.onChange}
|
||||||
|
onPasswordChange={passwordField.field.onChange}
|
||||||
|
onSendSmsCode={() => smsCode.send(mobileField.field.value ?? '')}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onSwitchToLogin={handleSwitchToLogin}
|
||||||
|
smsCodeCanSend={smsCode.canSend}
|
||||||
|
smsCodeRemainingSeconds={smsCode.remainingSeconds}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './finance-api'
|
|
||||||
export * from './finance-types'
|
|
||||||
export * from './game-api'
|
|
||||||
export * from './types'
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
import type {
|
|
||||||
AnnouncementState,
|
|
||||||
BetSelection,
|
|
||||||
Chip,
|
|
||||||
ConnectionState,
|
|
||||||
DashboardState,
|
|
||||||
GameBootstrapSnapshot,
|
|
||||||
GameCell,
|
|
||||||
HistoryEntry,
|
|
||||||
RoundSnapshot,
|
|
||||||
TrendEntry,
|
|
||||||
} from '../shared'
|
|
||||||
|
|
||||||
export interface GameCellDto {
|
|
||||||
column: number
|
|
||||||
id: number
|
|
||||||
label: string
|
|
||||||
odds: number
|
|
||||||
row: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChipDto {
|
|
||||||
amount: number
|
|
||||||
color: string
|
|
||||||
id: string
|
|
||||||
is_default?: boolean
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BetSelectionDto {
|
|
||||||
amount: number
|
|
||||||
cell_id: number
|
|
||||||
chip_id: string
|
|
||||||
id: string
|
|
||||||
placed_at: string
|
|
||||||
source: BetSelection['source']
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoundSnapshotDto {
|
|
||||||
betting_closes_at: string
|
|
||||||
id: string
|
|
||||||
phase: RoundSnapshot['phase']
|
|
||||||
revealing_at: string
|
|
||||||
settled_at: string | null
|
|
||||||
started_at: string
|
|
||||||
winning_cell_id: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryEntryDto {
|
|
||||||
payout_multiplier: number
|
|
||||||
round_id: string
|
|
||||||
settled_at: string
|
|
||||||
total_pool_amount: number
|
|
||||||
winning_cell_id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendEntryDto {
|
|
||||||
cell_id: number
|
|
||||||
current_streak: number
|
|
||||||
direction: TrendEntry['direction']
|
|
||||||
hit_count: number
|
|
||||||
last_hit_round_id: string | null
|
|
||||||
miss_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnouncementItemDto {
|
|
||||||
created_at: string
|
|
||||||
expires_at: string | null
|
|
||||||
id: string
|
|
||||||
is_pinned?: boolean
|
|
||||||
is_read?: boolean
|
|
||||||
message: string
|
|
||||||
title: string
|
|
||||||
tone: 'info' | 'success' | 'warning' | 'critical'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnouncementStateDto {
|
|
||||||
active_announcement_id: string | null
|
|
||||||
items: AnnouncementItemDto[]
|
|
||||||
last_updated_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardStateDto {
|
|
||||||
countdown_ms: number
|
|
||||||
featured_cell_id: number | null
|
|
||||||
online_players: number
|
|
||||||
table_limit_max: number
|
|
||||||
table_limit_min: number
|
|
||||||
total_pool_amount: number
|
|
||||||
updated_at: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionStateDto {
|
|
||||||
connected_at: string | null
|
|
||||||
last_error: string | null
|
|
||||||
last_message_at: string | null
|
|
||||||
latency_ms: number | null
|
|
||||||
reconnect_attempt: number
|
|
||||||
status: ConnectionState['status']
|
|
||||||
transport: ConnectionState['transport']
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameBootstrapDto {
|
|
||||||
announcements: AnnouncementStateDto
|
|
||||||
cells: GameCellDto[]
|
|
||||||
chips: ChipDto[]
|
|
||||||
connection: ConnectionStateDto
|
|
||||||
dashboard: DashboardStateDto
|
|
||||||
history: HistoryEntryDto[]
|
|
||||||
max_selection_count?: number
|
|
||||||
round: RoundSnapshotDto
|
|
||||||
selections: BetSelectionDto[]
|
|
||||||
trends: TrendEntryDto[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameRoundFeedDto {
|
|
||||||
history: HistoryEntryDto[]
|
|
||||||
round: RoundSnapshotDto
|
|
||||||
selections: BetSelectionDto[]
|
|
||||||
trends: TrendEntryDto[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameAnnouncementsDto {
|
|
||||||
announcements: AnnouncementStateDto
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoticeListItemDto {
|
|
||||||
content?: string
|
|
||||||
is_read: boolean
|
|
||||||
must_confirm?: boolean
|
|
||||||
notice_id: number
|
|
||||||
notice_type: 'silent' | 'popout' | (string & {})
|
|
||||||
publish_time: number
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoticeListDto {
|
|
||||||
list: NoticeListItemDto[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoticeDetailDto {
|
|
||||||
content: string
|
|
||||||
must_confirm: boolean
|
|
||||||
notice_id: number
|
|
||||||
notice_type: 'silent' | 'popout' | (string & {})
|
|
||||||
publish_time: number
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoticeConfirmDto {
|
|
||||||
confirm_time: number
|
|
||||||
confirmed: boolean
|
|
||||||
notice_id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GamePeriodStatus =
|
|
||||||
| 'betting'
|
|
||||||
| 'locked'
|
|
||||||
| 'settling'
|
|
||||||
| 'payouting'
|
|
||||||
| 'finished'
|
|
||||||
| 'void'
|
|
||||||
| (string & {})
|
|
||||||
|
|
||||||
export interface GameLobbyPeriodDto {
|
|
||||||
countdown: number
|
|
||||||
lock_at: number
|
|
||||||
open_at: number
|
|
||||||
period_no: string
|
|
||||||
status: GamePeriodStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameLobbyBetConfigDto {
|
|
||||||
chips: Record<string, string>
|
|
||||||
default_bet_chip_id: number
|
|
||||||
max_bet_per_number: string
|
|
||||||
min_bet_per_number: string
|
|
||||||
pick_max_number_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameLobbyDictionaryItemDto {
|
|
||||||
category: string
|
|
||||||
icon: string
|
|
||||||
name: string
|
|
||||||
number: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameLobbyUserSnapshotDto {
|
|
||||||
coin: string
|
|
||||||
current_streak: number
|
|
||||||
is_jackpot?: boolean
|
|
||||||
odds_factor?: number
|
|
||||||
streak_level?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameLobbyInitDto {
|
|
||||||
bet_config: GameLobbyBetConfigDto
|
|
||||||
dictionary: GameLobbyDictionaryItemDto[]
|
|
||||||
period?: GameLobbyPeriodDto | null
|
|
||||||
runtime_enabled: boolean
|
|
||||||
server_time: number
|
|
||||||
user_snapshot: GameLobbyUserSnapshotDto
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GamePeriodTickDto {
|
|
||||||
bet_close_in: number
|
|
||||||
countdown: number
|
|
||||||
period_id: number | null
|
|
||||||
period_no: string
|
|
||||||
result_number: number | null
|
|
||||||
runtime_enabled: boolean
|
|
||||||
server_time: number
|
|
||||||
status: GamePeriodStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JackpotHitItemDto {
|
|
||||||
nickname: string
|
|
||||||
period_no: string
|
|
||||||
result_number: number
|
|
||||||
total_win: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JackpotHitEventDataDto {
|
|
||||||
hits: JackpotHitItemDto[]
|
|
||||||
period_id: number | null
|
|
||||||
period_no: string
|
|
||||||
result_number: number | null
|
|
||||||
server_time: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JackpotHitEventDto {
|
|
||||||
data: JackpotHitEventDataDto
|
|
||||||
event: 'jackpot.hit'
|
|
||||||
server_time: number
|
|
||||||
topic?: 'jackpot.hit'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BetWinItemDto {
|
|
||||||
bet_id: number
|
|
||||||
win_amount: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BetWinEventDataDto {
|
|
||||||
balance_after?: string
|
|
||||||
bets: BetWinItemDto[]
|
|
||||||
current_streak?: number
|
|
||||||
is_jackpot: boolean
|
|
||||||
is_win: boolean
|
|
||||||
odds_factor?: number
|
|
||||||
payout_pending_review: boolean
|
|
||||||
period_id?: number
|
|
||||||
period_no: string
|
|
||||||
result_number: number | null
|
|
||||||
server_time?: number
|
|
||||||
streak_level?: number
|
|
||||||
total_win: string
|
|
||||||
user_id?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BetWinEventDto {
|
|
||||||
data: BetWinEventDataDto
|
|
||||||
event: 'bet.win'
|
|
||||||
server_time: number
|
|
||||||
topic?: 'bet.win'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameBetOrderDto {
|
|
||||||
bet_amount: string
|
|
||||||
create_time: number
|
|
||||||
numbers: number[]
|
|
||||||
order_no: string
|
|
||||||
period_no: string
|
|
||||||
result_number: number | null
|
|
||||||
status: string
|
|
||||||
total_amount: string
|
|
||||||
win_amount: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameBetOrdersPaginationDto {
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameBetOrdersDto {
|
|
||||||
list: GameBetOrderDto[]
|
|
||||||
pagination: GameBetOrdersPaginationDto
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GamePlaceBetRequestDto {
|
|
||||||
bet_amount?: string
|
|
||||||
bet_id: number
|
|
||||||
idempotency_key: string
|
|
||||||
numbers: string
|
|
||||||
period_no: string
|
|
||||||
single_bet_amount?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GamePlaceBetDto {
|
|
||||||
balance_after: string
|
|
||||||
current_streak: number
|
|
||||||
locked_balance?: string
|
|
||||||
numbers_count: number
|
|
||||||
order_no: string
|
|
||||||
period_no: string
|
|
||||||
status: 'accepted' | 'rejected' | (string & {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {
|
|
||||||
AnnouncementState,
|
|
||||||
Chip,
|
|
||||||
DashboardState,
|
|
||||||
GameBootstrapSnapshot,
|
|
||||||
GameCell,
|
|
||||||
HistoryEntry,
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,2 @@
|
|||||||
export {
|
export { AUDIO_ASSET_DEFINITIONS } from '@/constants/game'
|
||||||
AUDIO_ASSET_DEFINITIONS,
|
export type { AudioAssetDefinition, AudioAssetId } from '@/type'
|
||||||
type AudioAssetDefinition,
|
|
||||||
type AudioAssetId,
|
|
||||||
} from '@/constants/game'
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { SmartBackground } from '@/components/smart-background.tsx'
|
|||||||
import { SmartImage } from '@/components/smart-image'
|
import { SmartImage } from '@/components/smart-image'
|
||||||
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
|
import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
|
||||||
import {
|
import {
|
||||||
type BetSelection,
|
|
||||||
FLOWER_IMAGE_BY_ID,
|
FLOWER_IMAGE_BY_ID,
|
||||||
groupSelectionsByCell,
|
groupSelectionsByCell,
|
||||||
} from '@/features/game/shared'
|
} from '@/features/game/shared'
|
||||||
@@ -28,6 +27,7 @@ import {
|
|||||||
useGameAutoHostingStore,
|
useGameAutoHostingStore,
|
||||||
useGameRoundStore,
|
useGameRoundStore,
|
||||||
} from '@/store/game'
|
} from '@/store/game'
|
||||||
|
import type { BetSelection } from '@/type'
|
||||||
|
|
||||||
const REWARD_OVERLAY_FADE_OUT_MS = 300
|
const REWARD_OVERLAY_FADE_OUT_MS = 300
|
||||||
const REWARD_CHILDREN_FADE_IN_MS = 2_000
|
const REWARD_CHILDREN_FADE_IN_MS = 2_000
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import diamondIcon from '@/assets/system/diamond.webp'
|
|||||||
import { SmartImage } from '@/components/smart-image'
|
import { SmartImage } from '@/components/smart-image'
|
||||||
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
|
import { DesktopAnimalOverlay } from '@/features/game/components/desktop/desktop-animal-overlay.tsx'
|
||||||
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
||||||
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
|
|
||||||
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
|
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
|
||||||
|
import { useAnimalVm } from '@/hooks/use-animal-vm'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useGameRoundStore } from '@/store/game'
|
import { useGameRoundStore } from '@/store/game'
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import diamond from '@/assets/system/diamond.webp'
|
|||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { ACTION_OPTIONS } from '@/constants'
|
import { ACTION_OPTIONS } from '@/constants'
|
||||||
import { useGameControlVm } from '@/features/game/hooks/use-game-control-vm.ts'
|
import { useGameControlVm } from '@/hooks/use-game-control-vm.ts'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import historyBg from '@/assets/system/history-bg.png'
|
|||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image'
|
import { SmartImage } from '@/components/smart-image'
|
||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
import { useGameHistoryVm } from '@/features/game/hooks/use-game-history-vm.ts'
|
|
||||||
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
|
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
|
||||||
|
import { useGameHistoryVm } from '@/hooks/use-game-history-vm.ts'
|
||||||
|
|
||||||
function HistoryRewardNumber({
|
function HistoryRewardNumber({
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ import chatImage from '@/assets/system/chat.webp'
|
|||||||
import diamond from '@/assets/system/diamond.webp'
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
import logo from '@/assets/system/logo.webp'
|
import logo from '@/assets/system/logo.webp'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import {
|
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
|
||||||
useHeaderClockLabel,
|
|
||||||
useHeaderVm,
|
|
||||||
} from '@/features/game/hooks/use-header-vm'
|
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
function HeaderClock() {
|
function HeaderClock() {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SmartBackground } from '@/components/smart-background.tsx'
|
|||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
import { DesktopCountdown } from '@/features/game/components/desktop/desktop-countdown.tsx'
|
||||||
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
|
||||||
import { useGameStatusVm } from '@/features/game/hooks/use-game-status-vm.ts'
|
import { useGameStatusVm } from '@/hooks/use-game-status-vm.ts'
|
||||||
import { cn } from '@/lib/utils.ts'
|
import { cn } from '@/lib/utils.ts'
|
||||||
|
|
||||||
export function DesktopStatusLine() {
|
export function DesktopStatusLine() {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { createDeposit, type DepositTierItem } from '@/api'
|
||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
import { createDeposit, type DepositTierItem } from '@/features/game/api'
|
import { useDepositTierList } from '@/hooks/use-deposit-tier-list'
|
||||||
import { useDepositTierList } from '@/features/game/hooks/use-deposit-tier-list'
|
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select.tsx'
|
} from '@/components/ui/select.tsx'
|
||||||
import { useWithdrawSubmit } from '@/features/game/hooks/use-withdraw-submit'
|
import { useWithdrawSubmit } from '@/hooks/use-withdraw-submit'
|
||||||
import { useWithdrawVm } from '@/features/game/hooks/use-withdraw-vm'
|
import { useWithdrawVm } from '@/hooks/use-withdraw-vm'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
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 { EntryNoticeGateModal } from '@/features/game/components/shared/entry-notice-gate-modal'
|
|
||||||
@@ -7,15 +7,15 @@ import {
|
|||||||
VolumeX,
|
VolumeX,
|
||||||
Wifi,
|
Wifi,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import avatar from '@/assets/system/avatar.webp'
|
import avatar from '@/assets/system/avatar.webp'
|
||||||
|
import chatImage from '@/assets/system/chat.webp'
|
||||||
import diamond from '@/assets/system/diamond.webp'
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
import logo from '@/assets/system/logo.webp'
|
import logo from '@/assets/system/logo.webp'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import {
|
import { useHeaderClockLabel, useHeaderVm } from '@/hooks/use-header-vm'
|
||||||
useHeaderClockLabel,
|
import { useModalStore } from '@/store'
|
||||||
useHeaderVm,
|
|
||||||
} from '@/features/game/hooks/use-header-vm'
|
|
||||||
|
|
||||||
function MobileHeaderClock() {
|
function MobileHeaderClock() {
|
||||||
const systemTimeLabel = useHeaderClockLabel()
|
const systemTimeLabel = useHeaderClockLabel()
|
||||||
@@ -29,6 +29,7 @@ function MobileHeaderClock() {
|
|||||||
|
|
||||||
export function MobileHeader() {
|
export function MobileHeader() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
const {
|
const {
|
||||||
authStatus,
|
authStatus,
|
||||||
currentLanguageLabel,
|
currentLanguageLabel,
|
||||||
@@ -53,7 +54,7 @@ export function MobileHeader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 h-design-62">
|
<header className="sticky top-0 z-30 h-design-62">
|
||||||
<div className="border-b-2 border-[#787553] bg-[#020B14]] bg-[#020B14] flex h-design-33 w-full items-center">
|
<div className="border-b-2 border-[#787553] bg-[#020B14] flex h-design-33 w-full items-center">
|
||||||
<div className="flex h-design-23 w-design-130 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)] px-design-10">
|
<div className="flex h-design-23 w-design-130 shrink-0 items-center justify-center border-r border-[rgba(128,223,231,0.45)] px-design-10">
|
||||||
<SmartImage
|
<SmartImage
|
||||||
src={logo}
|
src={logo}
|
||||||
@@ -64,6 +65,20 @@ export function MobileHeader() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenLanguage}
|
||||||
|
className={`${actionButtonClassName} !px-design-10 justify-between`}
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
src={currentLanguageOption.icon}
|
||||||
|
alt={currentLanguageLabel}
|
||||||
|
className="h-design-14 w-design-14 shrink-0 rounded-full"
|
||||||
|
imgClassName="object-cover"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{authStatus === 'authenticated' ? (
|
{authStatus === 'authenticated' ? (
|
||||||
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-7 px-design-9">
|
<div className="flex h-full min-w-0 flex-1 items-center justify-end gap-design-7 px-design-9">
|
||||||
<button
|
<button
|
||||||
@@ -213,19 +228,20 @@ export function MobileHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<motion.button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenLanguage}
|
onClick={() => setModalOpen('desktopSupport', true)}
|
||||||
className={`${actionButtonClassName} !px-design-10 justify-between`}
|
whileTap={{
|
||||||
|
scale: 0.95,
|
||||||
|
}}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
>
|
>
|
||||||
<SmartImage
|
<SmartImage
|
||||||
src={currentLanguageOption.icon}
|
className={'h-design-20 w-design-20 cursor-pointer'}
|
||||||
alt={currentLanguageLabel}
|
alt={'chatImage'}
|
||||||
className="h-design-14 w-design-14 shrink-0 rounded-full"
|
src={chatImage}
|
||||||
imgClassName="object-cover"
|
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 truncate">{currentLanguageLabel}</div>
|
</motion.button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
186
src/features/game/components/mobile/mobile-topup.tsx
Normal file
186
src/features/game/components/mobile/mobile-topup.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { createDeposit, type DepositTierItem } from '@/api'
|
||||||
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
|
import { useDepositTierList } from '@/hooks/use-deposit-tier-list'
|
||||||
|
import { notify } from '@/lib/notify'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const PANEL_CLASS =
|
||||||
|
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(88,225,238,0.08)]'
|
||||||
|
|
||||||
|
function formatNumber(value: number) {
|
||||||
|
return new Intl.NumberFormat('en-US').format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileTopup() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const tierListQuery = useDepositTierList()
|
||||||
|
const tiers = tierListQuery.data ?? []
|
||||||
|
const createDepositInFlightRef = useRef(false)
|
||||||
|
const pendingPayWindowRef = useRef<Window | null>(null)
|
||||||
|
const createDepositMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
channelCode,
|
||||||
|
tierId,
|
||||||
|
}: {
|
||||||
|
channelCode: string
|
||||||
|
tierId: string
|
||||||
|
}) =>
|
||||||
|
createDeposit({
|
||||||
|
channel_code: channelCode,
|
||||||
|
idempotency_key: String(Date.now()),
|
||||||
|
tier_id: tierId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateDeposit = async (tier: DepositTierItem) => {
|
||||||
|
if (createDepositInFlightRef.current || createDepositMutation.isPending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelCode = tier.payChannelCode ?? tier.channels[0]?.code ?? ''
|
||||||
|
|
||||||
|
if (!channelCode) {
|
||||||
|
notify.error(t('commonUi.toast.requestFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createDepositInFlightRef.current = true
|
||||||
|
const payWindow = window.open('', '_blank')
|
||||||
|
|
||||||
|
if (!payWindow) {
|
||||||
|
createDepositInFlightRef.current = false
|
||||||
|
notify.error(t('gameDesktop.topup.tier.openPayUrlFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payWindow.opener = null
|
||||||
|
pendingPayWindowRef.current = payWindow
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createDepositMutation.mutateAsync({
|
||||||
|
channelCode,
|
||||||
|
tierId: tier.id,
|
||||||
|
})
|
||||||
|
const payUrl = result.pay_url.trim()
|
||||||
|
|
||||||
|
if (!payUrl) {
|
||||||
|
payWindow.close()
|
||||||
|
notify.error(t('gameDesktop.topup.tier.missingPayUrl'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payWindow.location.replace(payUrl)
|
||||||
|
notify.success(t('gameDesktop.topup.tier.createSuccess'))
|
||||||
|
} catch (error) {
|
||||||
|
payWindow.close()
|
||||||
|
notify.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t('commonUi.toast.requestFailed'),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
createDepositInFlightRef.current = false
|
||||||
|
|
||||||
|
if (pendingPayWindowRef.current === payWindow) {
|
||||||
|
pendingPayWindowRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 w-full px-design-8 pb-design-8 text-[#D9FFFF]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
PANEL_CLASS,
|
||||||
|
'flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden px-design-10 py-design-10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mb-design-8 flex items-center border-b border-[rgba(89,209,223,0.2)] pb-design-8">
|
||||||
|
<div className="text-design-16 font-semibold text-[#9AF5FB]">
|
||||||
|
{t('gameDesktop.topup.tier.title')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tierListQuery.isLoading ? (
|
||||||
|
<DataLoadingIndicator
|
||||||
|
label={t('gameDesktop.topup.tier.loading')}
|
||||||
|
className="h-full min-h-0 rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)]"
|
||||||
|
/>
|
||||||
|
) : tierListQuery.isError ? (
|
||||||
|
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(185,63,68,0.28)] bg-[rgba(34,13,16,0.42)] px-design-12 text-center text-design-14 text-[#F4A9AE]">
|
||||||
|
{t('gameDesktop.topup.tier.failed')}
|
||||||
|
</div>
|
||||||
|
) : tiers.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-0 items-center justify-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(103,227,239,0.18)] bg-[rgba(6,24,35,0.52)] px-design-12 text-center text-design-14 text-[#8FDDE6]">
|
||||||
|
{t('gameDesktop.topup.tier.empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid min-h-0 min-w-0 grid-cols-2 gap-design-8 overflow-y-auto pr-design-1">
|
||||||
|
{tiers.map((tier) => (
|
||||||
|
<button
|
||||||
|
key={tier.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCreateDeposit(tier)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'relative min-h-design-126 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] px-design-8 py-design-8 text-left shadow-[0_0_calc(var(--design-unit)*8)_rgba(88,225,238,0.08)] transition-[border-color,box-shadow,filter] duration-150',
|
||||||
|
createDepositMutation.isPending
|
||||||
|
? 'cursor-wait opacity-80'
|
||||||
|
: 'cursor-pointer hover:border-[rgba(170,247,255,0.62)] hover:brightness-110 active:brightness-90',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute right-design-6 top-design-6 max-w-design-48 truncate rounded-full border border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] px-design-5 py-[2px] text-design-8 leading-none text-[#7CDDE7]">
|
||||||
|
{tier.currency ?? 'FIAT'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pr-design-46 text-design-10 uppercase leading-tight tracking-[0.04em] text-[#63AEB6]">
|
||||||
|
{tier.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-design-5 text-design-18 font-semibold leading-none text-[#FFE229]">
|
||||||
|
{formatNumber(tier.payAmount)}
|
||||||
|
</div>
|
||||||
|
<div className="pt-design-3 text-design-10 text-[#9FDCE3]">
|
||||||
|
{t('gameDesktop.topup.tier.coins')}:{' '}
|
||||||
|
{formatNumber(tier.totalAmount)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-design-7 rounded-[calc(var(--design-unit)*5)] border border-[rgba(89,209,223,0.18)] bg-[rgba(4,19,28,0.58)] px-design-6 py-design-5">
|
||||||
|
<div className="flex items-center justify-between gap-design-6 text-design-10">
|
||||||
|
<span className="text-[#7CE3E8]">
|
||||||
|
{t('gameDesktop.topup.tier.bonus')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#FFF1C9]">
|
||||||
|
{formatNumber(tier.bonusAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-design-3 flex items-center justify-between gap-design-6 text-design-10">
|
||||||
|
<span className="text-[#7CE3E8]">Channels</span>
|
||||||
|
<span className="line-clamp-1 text-right text-[#6DFF83]">
|
||||||
|
{tier.channels.length > 0
|
||||||
|
? tier.channels
|
||||||
|
.map((channel) => channel.name)
|
||||||
|
.join(', ')
|
||||||
|
: '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tier.desc ? (
|
||||||
|
<div className="mt-design-5 line-clamp-2 text-design-9 leading-[1.25] text-[#6DAAB0]">
|
||||||
|
{tier.desc}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileTopup
|
||||||
640
src/features/game/components/mobile/mobile-withdraw.tsx
Normal file
640
src/features/game/components/mobile/mobile-withdraw.tsx
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
import { Minus, Plus } from 'lucide-react'
|
||||||
|
import { type ReactNode, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
|
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select.tsx'
|
||||||
|
import { useWithdrawSubmit } from '@/hooks/use-withdraw-submit'
|
||||||
|
import { useWithdrawVm } from '@/hooks/use-withdraw-vm'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
const PANEL_CLASS =
|
||||||
|
'rounded-md border border-[rgba(110,229,243,0.24)] bg-[linear-gradient(180deg,rgba(7,30,43,0.9),rgba(3,15,26,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(88,225,238,0.08)]'
|
||||||
|
|
||||||
|
function formatNumber(value: number) {
|
||||||
|
return new Intl.NumberFormat('en-US').format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPaymentGlyph(code: string, name: string) {
|
||||||
|
if (code.toLowerCase().includes('alipay')) {
|
||||||
|
return '支'
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.trim().slice(0, 1).toUpperCase() || code.slice(0, 1).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function WithdrawField({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-design-3">
|
||||||
|
<div className="text-design-10 font-medium uppercase leading-none text-[#6FD4DA]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmountShell({
|
||||||
|
amount,
|
||||||
|
availableBalanceText,
|
||||||
|
onAmountChange,
|
||||||
|
onMinus,
|
||||||
|
onPlus,
|
||||||
|
}: {
|
||||||
|
amount: number
|
||||||
|
availableBalanceText: string
|
||||||
|
onAmountChange: (value: number) => void
|
||||||
|
onMinus: () => void
|
||||||
|
onPlus: () => void
|
||||||
|
}) {
|
||||||
|
function handleInputChange(value: string) {
|
||||||
|
const nextValue = Number(value.replace(/[^\d]/g, ''))
|
||||||
|
|
||||||
|
onAmountChange(Number.isFinite(nextValue) ? nextValue : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-design-2">
|
||||||
|
<div className="flex h-design-34 items-center gap-design-6 rounded-[calc(var(--design-unit)*5)] border border-[rgba(103,227,239,0.32)] bg-[linear-gradient(180deg,rgba(14,64,74,0.82),rgba(8,36,47,0.78))] px-design-6 shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(93,239,255,0.08)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMinus}
|
||||||
|
className="flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||||
|
>
|
||||||
|
<Minus className="h-design-12 w-design-12" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={amount === 0 ? '' : String(amount)}
|
||||||
|
onChange={(event) => handleInputChange(event.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
className="h-full min-w-0 flex-1 bg-transparent text-center text-design-16 font-medium text-[#A1EBF3] outline-none placeholder:text-[rgba(109,170,176,0.55)]"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPlus}
|
||||||
|
className="flex h-design-24 w-design-24 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*4)] border border-[rgba(109,232,244,0.44)] bg-[rgba(37,115,123,0.32)] text-[#E1FEFF] transition hover:border-[rgba(170,247,255,0.82)] hover:bg-[rgba(66,146,151,0.35)]"
|
||||||
|
>
|
||||||
|
<Plus className="h-design-12 w-design-12" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-design-2 text-design-10 text-[#6DAAB0]">
|
||||||
|
{availableBalanceText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickAmountCard({
|
||||||
|
amount,
|
||||||
|
preview,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
amount: number
|
||||||
|
preview: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex h-design-42 min-w-0 w-full cursor-pointer flex-col items-start justify-center overflow-hidden rounded-[calc(var(--design-unit)*6)] border px-design-6 text-left transition-[border-color,background-color,box-shadow,filter] duration-150',
|
||||||
|
active
|
||||||
|
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.2),inset_0_0_calc(var(--design-unit)*10)_rgba(255,217,120,0.08)]'
|
||||||
|
: 'border-[rgba(103,227,239,0.26)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:border-[rgba(170,247,255,0.62)] hover:brightness-110',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute right-design-6 top-design-6 h-design-5 w-design-5 rounded-full transition',
|
||||||
|
active
|
||||||
|
? 'bg-[#FFD15E] shadow-[0_0_8px_rgba(255,209,94,0.8)]'
|
||||||
|
: 'bg-[rgba(122,220,230,0.26)]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="max-w-full truncate text-design-13 font-semibold leading-none text-[#FFE229]">
|
||||||
|
{amount}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-full truncate pt-design-3 text-design-10 leading-none',
|
||||||
|
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentCard({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
glyph,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
label: string
|
||||||
|
glyph: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex h-design-42 min-w-0 cursor-pointer items-center gap-design-6 rounded-[calc(var(--design-unit)*6)] border px-design-6 text-left transition-[border-color,background-color,box-shadow,filter] duration-150',
|
||||||
|
active
|
||||||
|
? 'border-[#D18A43] bg-[linear-gradient(180deg,rgba(88,54,28,0.96),rgba(56,33,18,0.92))] shadow-[0_0_calc(var(--design-unit)*10)_rgba(209,138,67,0.18),inset_0_0_calc(var(--design-unit)*10)_rgba(255,217,120,0.08)]'
|
||||||
|
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(11,48,63,0.9),rgba(5,24,35,0.94))] hover:border-[rgba(170,247,255,0.62)] hover:brightness-110',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute right-design-6 top-design-6 h-design-5 w-design-5 rounded-full transition',
|
||||||
|
active
|
||||||
|
? 'bg-[#FFD15E] shadow-[0_0_8px_rgba(255,209,94,0.8)]'
|
||||||
|
: 'bg-[rgba(122,220,230,0.26)]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-design-26 w-design-26 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border text-design-12 font-semibold leading-none transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-[rgba(255,218,132,0.45)] bg-[rgba(255,211,113,0.16)] text-[#FFD97A]'
|
||||||
|
: 'border-[rgba(121,219,229,0.28)] bg-[rgba(10,39,52,0.7)] text-[#8DE4EA]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 pr-design-7">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'truncate !text-design-12 font-medium leading-none',
|
||||||
|
active ? 'text-[#FFF1C9]' : 'text-[#D7FBFF]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pt-design-2 !text-design-10 uppercase leading-none tracking-[0.03em]',
|
||||||
|
active ? 'text-[#FFDFA4]' : 'text-[#63AEB6]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Channel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputShell({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
error,
|
||||||
|
errorMessage,
|
||||||
|
uppercase = false,
|
||||||
|
type = 'text',
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder: string
|
||||||
|
error?: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
uppercase?: boolean
|
||||||
|
type?: 'text' | 'email' | 'tel'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-design-3">
|
||||||
|
<Input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(
|
||||||
|
'h-design-30 rounded-[calc(var(--design-unit)*5)] border px-design-8 text-design-12',
|
||||||
|
uppercase && 'uppercase',
|
||||||
|
error
|
||||||
|
? 'border-[#B93F44] bg-[rgba(34,13,16,0.78)] text-[#FCEEEE]'
|
||||||
|
: 'border-[rgba(103,227,239,0.24)] bg-[linear-gradient(180deg,rgba(10,47,57,0.84),rgba(5,23,32,0.92))] text-[#ACF1F6]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{error && errorMessage ? (
|
||||||
|
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight = false,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: ReactNode
|
||||||
|
highlight?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex border-b border-[rgba(89,209,223,0.2)] last:border-b-0">
|
||||||
|
<div className="flex w-[46%] shrink-0 items-center border-r border-[rgba(89,209,223,0.2)] px-design-6 py-design-7 text-design-10 font-medium uppercase leading-[1.15] text-[#7CE3E8]">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 items-center justify-end px-design-6 py-design-7 text-right text-design-10 text-[#E6FFFF]',
|
||||||
|
highlight && 'text-design-11 font-semibold text-[#6DFF83]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileWithdraw() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const vm = useWithdrawVm()
|
||||||
|
const withdrawSubmitMutation = useWithdrawSubmit()
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const [hasSubmitted, setHasSubmitted] = useState(false)
|
||||||
|
const [activeQuickAmountId, setActiveQuickAmountId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleAmountChange(nextAmount: number) {
|
||||||
|
vm.setAmount(Math.max(0, nextAmount))
|
||||||
|
setActiveQuickAmountId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuickAmountSelect(optionId: string, amount: number) {
|
||||||
|
vm.setAmount(Math.max(0, amount))
|
||||||
|
setActiveQuickAmountId(optionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWithdrawFormState() {
|
||||||
|
setHasSubmitted(false)
|
||||||
|
setActiveQuickAmountId(null)
|
||||||
|
vm.resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloseWithdraw() {
|
||||||
|
resetWithdrawFormState()
|
||||||
|
setModalOpen('desktopWithdrawTopup', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmWithdraw() {
|
||||||
|
if (withdrawSubmitMutation.isPending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasSubmitted(true)
|
||||||
|
|
||||||
|
if (
|
||||||
|
vm.amountRequiredError ||
|
||||||
|
vm.amountExceedsBalance ||
|
||||||
|
vm.holderNameError ||
|
||||||
|
vm.bankAccountError ||
|
||||||
|
vm.paymentChannelCodeError ||
|
||||||
|
vm.bankCodeError ||
|
||||||
|
vm.receiverEmailError ||
|
||||||
|
vm.receiverPhoneError
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawSubmitMutation.mutate(
|
||||||
|
{
|
||||||
|
bank_code: vm.bankCode,
|
||||||
|
channel_code: vm.paymentChannelCode,
|
||||||
|
idempotency_key: String(Date.now()),
|
||||||
|
receive_account: vm.bankAccount.trim(),
|
||||||
|
receiver_email: vm.receiverEmail.trim(),
|
||||||
|
receiver_mobile: vm.receiverPhone.trim(),
|
||||||
|
receiver_name: vm.holderName.trim(),
|
||||||
|
receive_type: 'bank',
|
||||||
|
withdraw_coin: vm.amount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
handleCloseWithdraw()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 w-full px-design-6 pb-design-6 text-[#D9FFFF]">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
PANEL_CLASS,
|
||||||
|
'flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-design-8 py-design-7">
|
||||||
|
<div className="flex flex-col !gap-design-10">
|
||||||
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.diamondAmount')}
|
||||||
|
>
|
||||||
|
<AmountShell
|
||||||
|
amount={vm.amount}
|
||||||
|
availableBalanceText={t(
|
||||||
|
'gameDesktop.withdraw.availableBalance',
|
||||||
|
{ amount: formatNumber(vm.availableBalance) },
|
||||||
|
)}
|
||||||
|
onAmountChange={handleAmountChange}
|
||||||
|
onMinus={() => handleAmountChange(vm.amount - 1)}
|
||||||
|
onPlus={() => handleAmountChange(vm.amount + 1)}
|
||||||
|
/>
|
||||||
|
{hasSubmitted && vm.amountRequiredError ? (
|
||||||
|
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||||
|
{t('gameDesktop.withdraw.errors.amountRequired')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasSubmitted && vm.amountExceedsBalance ? (
|
||||||
|
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||||
|
{t('gameDesktop.withdraw.errors.amountExceedsBalance')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 grid-cols-3 gap-design-5">
|
||||||
|
{vm.quickAmounts.map((option) => (
|
||||||
|
<QuickAmountCard
|
||||||
|
key={option.id}
|
||||||
|
amount={option.diamonds}
|
||||||
|
preview={option.preview}
|
||||||
|
active={option.id === activeQuickAmountId}
|
||||||
|
onClick={() =>
|
||||||
|
handleQuickAmountSelect(option.id, option.diamonds)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.currencyType')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={vm.currencyCode}
|
||||||
|
onValueChange={vm.setCurrencyCode}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-design-30 w-full rounded-[calc(var(--design-unit)*5)] border-[rgba(103,227,239,0.3)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-8 text-left !text-design-12 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.08)] data-[size=default]:h-design-30 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-14 [&_svg]:w-design-14 [&_svg]:text-[#79DFEA]"
|
||||||
|
aria-label={t('gameDesktop.withdraw.currencySelection')}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t('gameDesktop.withdraw.selectCurrency')}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vm.config.currencies.map((option) => (
|
||||||
|
<SelectItem key={option.code} value={option.code}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.paymentChannel')}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col gap-design-3">
|
||||||
|
{vm.sortedPayChannels.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-design-5">
|
||||||
|
{vm.sortedPayChannels.map((channel) => (
|
||||||
|
<PaymentCard
|
||||||
|
key={channel.code}
|
||||||
|
active={channel.code === vm.paymentChannelCode}
|
||||||
|
label={channel.name}
|
||||||
|
glyph={getPaymentGlyph(channel.code, channel.name)}
|
||||||
|
onClick={() => vm.setPaymentChannelCode(channel.code)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-design-38 items-center rounded-[calc(var(--design-unit)*6)] border border-[rgba(185,63,68,0.45)] bg-[rgba(34,13,16,0.6)] px-design-8 text-design-10 text-[#F4B1B1]">
|
||||||
|
{t('gameDesktop.withdraw.errors.paymentChannelUnavailable')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasSubmitted && vm.paymentChannelCodeError ? (
|
||||||
|
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||||
|
{t('gameDesktop.withdraw.errors.paymentChannelRequired')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField label={t('gameDesktop.withdraw.fields.bankCode')}>
|
||||||
|
<div className="flex w-full flex-col gap-design-3">
|
||||||
|
<Select
|
||||||
|
value={vm.bankCode}
|
||||||
|
onValueChange={vm.setBankCode}
|
||||||
|
disabled={vm.sortedBanks.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
'h-design-30 w-full rounded-[calc(var(--design-unit)*5)] bg-[linear-gradient(180deg,rgba(12,61,72,0.82),rgba(6,28,39,0.9))] px-design-8 text-left !text-design-12 text-[#A5EDF4] shadow-[inset_0_0_calc(var(--design-unit)*10)_rgba(94,237,255,0.08)] data-[size=default]:h-design-30 data-[placeholder]:text-[rgba(109,170,176,0.55)] [&_svg]:h-design-14 [&_svg]:w-design-14 [&_svg]:text-[#79DFEA]',
|
||||||
|
hasSubmitted && vm.bankCodeError
|
||||||
|
? 'border-[#B93F44]'
|
||||||
|
: 'border-[rgba(103,227,239,0.3)]',
|
||||||
|
)}
|
||||||
|
aria-label={t('gameDesktop.withdraw.fields.bankCode')}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.bankCode',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vm.sortedBanks.map((bank) => (
|
||||||
|
<SelectItem key={bank.code} value={bank.code}>
|
||||||
|
{bank.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{vm.sortedBanks.length === 0 ? (
|
||||||
|
<div className="pl-design-2 text-design-10 text-[#F4B1B1]">
|
||||||
|
{t('gameDesktop.withdraw.errors.bankCodeUnavailable')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hasSubmitted && vm.bankCodeError ? (
|
||||||
|
<div className="pl-design-2 text-design-10 text-[#F44F4F]">
|
||||||
|
{t('gameDesktop.withdraw.errors.bankCodeRequired')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.cardHolderName')}
|
||||||
|
>
|
||||||
|
<InputShell
|
||||||
|
value={vm.holderName}
|
||||||
|
onChange={vm.setHolderName}
|
||||||
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.cardHolderName',
|
||||||
|
)}
|
||||||
|
error={hasSubmitted && vm.holderNameError}
|
||||||
|
errorMessage={t(
|
||||||
|
'gameDesktop.withdraw.errors.cardHolderNameRequired',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.bankAccountNumber')}
|
||||||
|
>
|
||||||
|
<InputShell
|
||||||
|
value={vm.bankAccount}
|
||||||
|
onChange={vm.setBankAccount}
|
||||||
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.bankAccountNumber',
|
||||||
|
)}
|
||||||
|
error={hasSubmitted && vm.bankAccountError}
|
||||||
|
errorMessage={t(
|
||||||
|
'gameDesktop.withdraw.errors.bankAccountRequired',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.receiverEmail')}
|
||||||
|
>
|
||||||
|
<InputShell
|
||||||
|
value={vm.receiverEmail}
|
||||||
|
onChange={vm.setReceiverEmail}
|
||||||
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.receiverEmail',
|
||||||
|
)}
|
||||||
|
type="email"
|
||||||
|
error={hasSubmitted && vm.receiverEmailError}
|
||||||
|
errorMessage={t(
|
||||||
|
'gameDesktop.withdraw.errors.receiverEmailInvalid',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<WithdrawField
|
||||||
|
label={t('gameDesktop.withdraw.fields.receiverPhone')}
|
||||||
|
>
|
||||||
|
<InputShell
|
||||||
|
value={vm.receiverPhone}
|
||||||
|
onChange={vm.setReceiverPhone}
|
||||||
|
placeholder={t(
|
||||||
|
'gameDesktop.withdraw.placeholders.receiverPhone',
|
||||||
|
)}
|
||||||
|
type="tel"
|
||||||
|
error={hasSubmitted && vm.receiverPhoneError}
|
||||||
|
errorMessage={t(
|
||||||
|
'gameDesktop.withdraw.errors.receiverPhoneInvalid',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</WithdrawField>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-[calc(var(--design-unit)*4)] border border-[rgba(89,209,223,0.22)] bg-[rgba(4,19,28,0.58)]">
|
||||||
|
<PreviewRow
|
||||||
|
label={t('gameDesktop.withdraw.preview.diamondAmount')}
|
||||||
|
value={formatNumber(vm.amount)}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label={vm.selectedCurrencyPreview.exchangeRateLabel}
|
||||||
|
value={vm.selectedCurrencyPreview.exchangeRateValue}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label={vm.selectedCurrencyPreview.convertibleLabel}
|
||||||
|
value={vm.selectedCurrencyPreview.convertibleValue}
|
||||||
|
highlight={true}
|
||||||
|
/>
|
||||||
|
<PreviewRow
|
||||||
|
label={t(
|
||||||
|
'gameDesktop.withdraw.preview.fixedExchangeDiamondAmount',
|
||||||
|
)}
|
||||||
|
value="0-0-0 0:0:0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[calc(var(--design-unit)*4)] border border-[rgba(240,175,66,0.2)] bg-[rgba(110,77,26,0.24)] px-design-8 py-design-6 text-design-10 leading-[1.3] text-[#F0B44A]">
|
||||||
|
{vm.withdrawCopy.rateHint}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-design-3 px-design-1 text-design-10 uppercase leading-[1.3] text-[#7AD8E0]">
|
||||||
|
<div>
|
||||||
|
{vm.withdrawCopy.processingLabel}:{' '}
|
||||||
|
<span className="text-[#77FF76]">
|
||||||
|
{vm.withdrawCopy.processingValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{vm.withdrawCopy.noticeLabel}:{' '}
|
||||||
|
<span className="text-red-700">{vm.withdrawCopy.feeNote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-design-6 border-t border-[rgba(89,209,223,0.22)] bg-[rgba(3,15,24,0.86)] px-design-8 py-design-5">
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
src={lengthGreenBtn}
|
||||||
|
size="100% 100%"
|
||||||
|
onClick={handleCloseWithdraw}
|
||||||
|
className="flex h-design-38 flex-1 cursor-pointer items-center justify-center pb-design-2 text-center !text-design-12 font-bold uppercase text-[#F0FFFF] transition hover:brightness-110 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{t('gameDesktop.withdraw.cancel')}
|
||||||
|
</SmartBackground>
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
src={lengthBlueBtn}
|
||||||
|
size="100% 100%"
|
||||||
|
onClick={handleConfirmWithdraw}
|
||||||
|
disabled={withdrawSubmitMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
'flex h-design-38 flex-1 items-center justify-center whitespace-nowrap pb-design-2 text-center !text-design-12 font-bold uppercase leading-[1.05] text-[#F0FFFF] transition',
|
||||||
|
withdrawSubmitMutation.isPending
|
||||||
|
? 'cursor-not-allowed opacity-70'
|
||||||
|
: 'cursor-pointer hover:brightness-110 active:scale-[0.98]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{withdrawSubmitMutation.isPending
|
||||||
|
? t('commonUi.action.submitting')
|
||||||
|
: `${t('gameDesktop.withdraw.confirm')}`}
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileWithdraw
|
||||||
@@ -3,9 +3,11 @@ import dayjs from 'dayjs'
|
|||||||
import { RotateCw } from 'lucide-react'
|
import { RotateCw } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getNoticeList } from '@/api'
|
||||||
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
import lengthGreenBtn from '@/assets/system/length-green-btn.webp'
|
||||||
import checkIcon from '@/assets/system/right.webp'
|
import checkIcon from '@/assets/system/right.webp'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator.tsx'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator.tsx'
|
||||||
@@ -13,7 +15,6 @@ import {
|
|||||||
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
ENTRY_NOTICE_CONFIRM_INTERVAL_MS,
|
||||||
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
|
ENTRY_NOTICE_LAST_CONFIRMED_AT_KEY,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import { getNoticeList } from '@/features/game/api'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useModalStore } from '@/store/modal'
|
import { useModalStore } from '@/store/modal'
|
||||||
@@ -36,8 +37,15 @@ function setLastConfirmedAt(storageKey: string, timestamp: number) {
|
|||||||
localStorage.setItem(storageKey, String(timestamp))
|
localStorage.setItem(storageKey, String(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntryNoticeGateModal() {
|
interface EntryNoticeGateModalProps {
|
||||||
|
variant?: 'desktop' | 'mobile'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntryNoticeGateModal({
|
||||||
|
variant = 'desktop',
|
||||||
|
}: EntryNoticeGateModalProps = {}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const isMobile = variant === 'mobile'
|
||||||
const authStatus = useAuthStore((state) => state.status)
|
const authStatus = useAuthStore((state) => state.status)
|
||||||
const authIsHydrated = useAuthStore((state) => state.isHydrated)
|
const authIsHydrated = useAuthStore((state) => state.isHydrated)
|
||||||
const accessToken = useAuthStore((state) => state.accessToken)
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
@@ -118,29 +126,64 @@ export function EntryNoticeGateModal() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Modal = isMobile ? MobileCenterModal : CenterModal
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterModal
|
<Modal
|
||||||
open={shouldShowModal}
|
open={shouldShowModal}
|
||||||
isShowClose={false}
|
isShowClose={false}
|
||||||
isNormalBg={true}
|
isNormalBg={true}
|
||||||
title={
|
title={
|
||||||
<div className="modal-title-glow text-design-26">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'modal-title-glow',
|
||||||
|
isMobile ? 'text-design-16' : 'text-design-26',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{t('game.modals.entryNotice.title')}
|
{t('game.modals.entryNotice.title')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
titleAlign="left"
|
titleAlign="left"
|
||||||
className="h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]"
|
className={
|
||||||
|
isMobile
|
||||||
|
? 'h-design-400'
|
||||||
|
: 'h-design-700 w-design-1000 max-h-[92vh] max-w-[92vw]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col',
|
||||||
|
isMobile
|
||||||
|
? 'gap-design-10 px-design-8 pb-design-8 pt-design-4'
|
||||||
|
: 'gap-design-20 px-design-14 pb-design-30 pt-design-8',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-h-0 flex-1 overflow-y-auto rounded-md border border-[#2B8CA3]/45 bg-[#001B24]/70 shadow-[inset_0_0_calc(var(--design-unit)*18)_rgba(39,175,205,0.1)]',
|
||||||
|
isMobile ? 'p-design-8' : 'p-design-18',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<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 ? (
|
{noticeListQuery.isPending ? (
|
||||||
<DataLoadingIndicator
|
<DataLoadingIndicator
|
||||||
label={t('game.modals.entryNotice.loading')}
|
label={t('game.modals.entryNotice.loading')}
|
||||||
className="h-full min-h-[calc(var(--design-unit)*320)]"
|
className={cn(
|
||||||
|
'h-full',
|
||||||
|
isMobile
|
||||||
|
? 'min-h-[calc(var(--design-unit)*220)] text-design-12'
|
||||||
|
: 'min-h-[calc(var(--design-unit)*320)]',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : noticeListQuery.isError ? (
|
) : 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
|
||||||
<div className="text-design-22">
|
className={cn(
|
||||||
|
'flex h-full flex-col items-center justify-center text-center text-[#9CE8F2]',
|
||||||
|
isMobile
|
||||||
|
? 'min-h-[calc(var(--design-unit)*220)] gap-design-10'
|
||||||
|
: 'min-h-[calc(var(--design-unit)*320)] gap-design-18',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={isMobile ? 'text-design-13' : 'text-design-22'}>
|
||||||
{t('game.modals.entryNotice.loadFailed')}
|
{t('game.modals.entryNotice.loadFailed')}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -148,30 +191,75 @@ export function EntryNoticeGateModal() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
void noticeListQuery.refetch()
|
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]"
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-md border border-[#4AC6DE]/45 bg-[#0B4454] text-[#D7FFFF] transition hover:bg-[#0E576D]',
|
||||||
|
isMobile
|
||||||
|
? 'gap-design-5 px-design-10 py-design-6 text-design-12'
|
||||||
|
: 'gap-design-8 px-design-18 py-design-10 text-design-18',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<RotateCw className="h-design-18 w-design-18" />
|
<RotateCw
|
||||||
|
className={
|
||||||
|
isMobile
|
||||||
|
? 'h-design-13 w-design-13'
|
||||||
|
: 'h-design-18 w-design-18'
|
||||||
|
}
|
||||||
|
/>
|
||||||
{t('game.modals.entryNotice.retry')}
|
{t('game.modals.entryNotice.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-design-16">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col',
|
||||||
|
isMobile ? 'gap-design-8' : 'gap-design-16',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{popoutNotices.map((notice, index) => (
|
{popoutNotices.map((notice, index) => (
|
||||||
<article
|
<article
|
||||||
key={notice.notice_id}
|
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"
|
className={cn(
|
||||||
|
'rounded-md border border-[#2B8CA3]/45 bg-[linear-gradient(180deg,rgba(9,63,78,0.96)_0%,rgba(6,42,53,0.98)_100%)]',
|
||||||
|
isMobile ? 'p-design-9' : 'p-design-20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-wrap items-center justify-between',
|
||||||
|
isMobile
|
||||||
|
? 'mb-design-6 gap-design-6'
|
||||||
|
: 'mb-design-12 gap-design-12',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 font-semibold leading-tight text-white',
|
||||||
|
isMobile ? 'text-design-13' : 'text-design-24',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<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}
|
{index + 1}. {notice.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-12 py-design-5 text-design-15 text-[#9CE8F2]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 text-[#9CE8F2]',
|
||||||
|
isMobile
|
||||||
|
? 'px-design-7 py-design-3 text-design-9'
|
||||||
|
: 'px-design-12 py-design-5 text-design-15',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{dayjs(notice.publish_time * 1000).format(
|
{dayjs(notice.publish_time * 1000).format(
|
||||||
'YYYY-MM-DD HH:mm:ss',
|
'YYYY-MM-DD HH:mm:ss',
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="whitespace-pre-wrap text-design-18 leading-[1.8] text-[#C4F2F7]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'whitespace-pre-wrap text-[#C4F2F7]',
|
||||||
|
isMobile
|
||||||
|
? 'text-design-11 leading-[1.55]'
|
||||||
|
: 'text-design-18 leading-[1.8]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{notice.content ?? ''}
|
{notice.content ?? ''}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -180,8 +268,20 @@ export function EntryNoticeGateModal() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-center justify-center gap-design-20">
|
<div
|
||||||
<label className="inline-flex cursor-pointer items-center justify-center gap-design-12 text-design-20 text-[#C4F2F7]">
|
className={cn(
|
||||||
|
'flex shrink-0 flex-col items-center justify-center',
|
||||||
|
isMobile ? 'gap-design-8' : 'gap-design-20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
'inline-flex cursor-pointer items-center justify-center text-[#C4F2F7]',
|
||||||
|
isMobile
|
||||||
|
? 'gap-design-6 text-design-11'
|
||||||
|
: 'gap-design-12 text-design-20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={hasAgreed}
|
checked={hasAgreed}
|
||||||
@@ -192,7 +292,10 @@ export function EntryNoticeGateModal() {
|
|||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-design-32 w-design-32 shrink-0 items-center justify-center rounded-[calc(var(--design-unit)*5)] border transition',
|
'flex shrink-0 items-center justify-center border transition',
|
||||||
|
isMobile
|
||||||
|
? 'h-design-20 w-design-20 rounded-[calc(var(--design-unit)*4)]'
|
||||||
|
: 'h-design-32 w-design-32 rounded-[calc(var(--design-unit)*5)]',
|
||||||
hasAgreed
|
hasAgreed
|
||||||
? 'border-[#4AFF49]/80 bg-[#071F11]'
|
? 'border-[#4AFF49]/80 bg-[#071F11]'
|
||||||
: 'border-[#6CCDCF]/70 bg-[#031D25]',
|
: 'border-[#6CCDCF]/70 bg-[#031D25]',
|
||||||
@@ -204,7 +307,12 @@ export function EntryNoticeGateModal() {
|
|||||||
alt=""
|
alt=""
|
||||||
priority={true}
|
priority={true}
|
||||||
showSkeleton={false}
|
showSkeleton={false}
|
||||||
className="h-design-34 w-design-38 overflow-visible"
|
className={cn(
|
||||||
|
'overflow-visible',
|
||||||
|
isMobile
|
||||||
|
? 'h-design-22 w-design-24'
|
||||||
|
: 'h-design-34 w-design-38',
|
||||||
|
)}
|
||||||
imgClassName="object-contain"
|
imgClassName="object-contain"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -230,7 +338,10 @@ export function EntryNoticeGateModal() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-design-72 w-design-270 items-center justify-center rounded-md pb-design-5 text-design-22 font-bold transition',
|
'flex items-center justify-center rounded-md font-bold transition',
|
||||||
|
isMobile
|
||||||
|
? 'h-design-42 w-design-156 pb-design-3 text-design-14'
|
||||||
|
: 'h-design-72 w-design-270 pb-design-5 text-design-22',
|
||||||
canEnter
|
canEnter
|
||||||
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
|
? 'modal-title-glow cursor-pointer text-white hover:brightness-110 active:brightness-95'
|
||||||
: 'cursor-not-allowed text-white opacity-80 grayscale',
|
: 'cursor-not-allowed text-white opacity-80 grayscale',
|
||||||
@@ -248,6 +359,10 @@ export function EntryNoticeGateModal() {
|
|||||||
</SmartBackground>
|
</SmartBackground>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CenterModal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MobileEntryNoticeGateModal() {
|
||||||
|
return <EntryNoticeGateModal variant="mobile" />
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
import type { PeriodHistoryDisplayItem } from '@/features/game/hooks/use-period-history-vm'
|
import type { PeriodHistoryDisplayItem } from '@/hooks/use-period-history-vm'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface PeriodHistoryListLabels {
|
interface PeriodHistoryListLabels {
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './api'
|
|
||||||
export * from './shared'
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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'
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { FlowerImageAsset } from '@/type'
|
||||||
|
|
||||||
const animalModules = import.meta.glob('../../../assets/animal/*.webp', {
|
const animalModules = import.meta.glob('../../../assets/animal/*.webp', {
|
||||||
eager: true,
|
eager: true,
|
||||||
import: 'default',
|
import: 'default',
|
||||||
@@ -8,12 +10,6 @@ const rewardModules = import.meta.glob('../../../assets/reward/*.webp', {
|
|||||||
import: 'default',
|
import: 'default',
|
||||||
}) as Record<string, string>
|
}) as Record<string, string>
|
||||||
|
|
||||||
export interface FlowerImageAsset {
|
|
||||||
animalUrl: string
|
|
||||||
id: number
|
|
||||||
rewardUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FLOWER_IMAGE_LIST: FlowerImageAsset[] = Array.from(
|
export const FLOWER_IMAGE_LIST: FlowerImageAsset[] = Array.from(
|
||||||
{ length: 36 },
|
{ length: 36 },
|
||||||
(_, index) => {
|
(_, index) => {
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
export * from './constants'
|
|
||||||
export * from './flower-assets'
|
export * from './flower-assets'
|
||||||
export * from './initial-state'
|
export * from './initial-state'
|
||||||
export * from './selectors'
|
export * from './selectors'
|
||||||
export * from './types'
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { DEFAULT_CHIP_AMOUNTS } from '@/constants'
|
import {
|
||||||
import { DEFAULT_GAME_CHIP_COLORS, GAME_MAX_SELECTION_CELLS } from './constants'
|
DEFAULT_CHIP_AMOUNTS,
|
||||||
|
DEFAULT_GAME_CHIP_COLORS,
|
||||||
|
GAME_MAX_SELECTION_CELLS,
|
||||||
|
} from '@/constants'
|
||||||
import type {
|
import type {
|
||||||
AnnouncementState,
|
AnnouncementState,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -7,7 +10,7 @@ import type {
|
|||||||
DashboardState,
|
DashboardState,
|
||||||
GameBootstrapSnapshot,
|
GameBootstrapSnapshot,
|
||||||
RoundSnapshot,
|
RoundSnapshot,
|
||||||
} from './types'
|
} from '@/type'
|
||||||
|
|
||||||
function createEmptyRoundSnapshot(nowIso: string): RoundSnapshot {
|
function createEmptyRoundSnapshot(nowIso: string): RoundSnapshot {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from './constants'
|
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from '@/constants'
|
||||||
import type {
|
import type {
|
||||||
AnnouncementState,
|
AnnouncementState,
|
||||||
BetSelection,
|
BetSelection,
|
||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
RoundSnapshot,
|
RoundSnapshot,
|
||||||
TrendDirection,
|
TrendDirection,
|
||||||
TrendEntry,
|
TrendEntry,
|
||||||
} from './types'
|
} from '@/type'
|
||||||
|
|
||||||
export function getChipById(chips: Chip[], chipId: string) {
|
export function getChipById(chips: Chip[], chipId: string) {
|
||||||
return chips.find((chip) => chip.id === chipId) ?? null
|
return chips.find((chip) => chip.id === chipId) ?? null
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import type {
|
|
||||||
ANNOUNCEMENT_TONES,
|
|
||||||
BET_SOURCES,
|
|
||||||
CELL_STATUSES,
|
|
||||||
CONNECTION_STATUSES,
|
|
||||||
CONNECTION_TRANSPORTS,
|
|
||||||
ROUND_PHASES,
|
|
||||||
TREND_DIRECTIONS,
|
|
||||||
} from './constants'
|
|
||||||
|
|
||||||
export type RoundPhase = (typeof ROUND_PHASES)[number]
|
|
||||||
export type CellStatus = (typeof CELL_STATUSES)[number]
|
|
||||||
export type ConnectionStatus = (typeof CONNECTION_STATUSES)[number]
|
|
||||||
export type ConnectionTransport = (typeof CONNECTION_TRANSPORTS)[number]
|
|
||||||
export type AnnouncementTone = (typeof ANNOUNCEMENT_TONES)[number]
|
|
||||||
export type BetSource = (typeof BET_SOURCES)[number]
|
|
||||||
export type TrendDirection = (typeof TREND_DIRECTIONS)[number]
|
|
||||||
|
|
||||||
export interface GameCell {
|
|
||||||
column: number
|
|
||||||
id: number
|
|
||||||
label: string
|
|
||||||
odds: number
|
|
||||||
row: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Chip {
|
|
||||||
amount: number
|
|
||||||
color: string
|
|
||||||
id: string
|
|
||||||
isDefault?: boolean
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BetSelection {
|
|
||||||
amount: number
|
|
||||||
cellId: number
|
|
||||||
chipId: string
|
|
||||||
id: string
|
|
||||||
placedAt: string
|
|
||||||
source: BetSource
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoundSnapshot {
|
|
||||||
bettingClosesAt: string
|
|
||||||
id: string
|
|
||||||
phase: RoundPhase
|
|
||||||
revealingAt: string
|
|
||||||
settledAt: string | null
|
|
||||||
startedAt: string
|
|
||||||
winningCellId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
|
||||||
payoutMultiplier: number
|
|
||||||
roundId: string
|
|
||||||
settledAt: string
|
|
||||||
totalPoolAmount: number
|
|
||||||
winningCellId: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendEntry {
|
|
||||||
cellId: number
|
|
||||||
currentStreak: number
|
|
||||||
direction: TrendDirection
|
|
||||||
hitCount: number
|
|
||||||
lastHitRoundId: string | null
|
|
||||||
missCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnouncementItem {
|
|
||||||
createdAt: string
|
|
||||||
expiresAt: string | null
|
|
||||||
id: string
|
|
||||||
isPinned?: boolean
|
|
||||||
isRead?: boolean
|
|
||||||
message: string
|
|
||||||
title: string
|
|
||||||
tone: AnnouncementTone
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnnouncementState {
|
|
||||||
activeAnnouncementId: string | null
|
|
||||||
items: AnnouncementItem[]
|
|
||||||
lastUpdatedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardState {
|
|
||||||
countdownMs: number
|
|
||||||
featuredCellId: number | null
|
|
||||||
onlinePlayers: number
|
|
||||||
tableLimitMax: number
|
|
||||||
tableLimitMin: number
|
|
||||||
totalPoolAmount: number
|
|
||||||
updatedAt: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConnectionState {
|
|
||||||
connectedAt: string | null
|
|
||||||
lastError: string | null
|
|
||||||
lastMessageAt: string | null
|
|
||||||
latencyMs: number | null
|
|
||||||
reconnectAttempt: number
|
|
||||||
status: ConnectionStatus
|
|
||||||
transport: ConnectionTransport
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameBootstrapSnapshot {
|
|
||||||
announcements: AnnouncementState
|
|
||||||
cells: GameCell[]
|
|
||||||
chips: Chip[]
|
|
||||||
connection: ConnectionState
|
|
||||||
dashboard: DashboardState
|
|
||||||
history: HistoryEntry[]
|
|
||||||
maxSelectionCount: number
|
|
||||||
round: RoundSnapshot
|
|
||||||
selections: BetSelection[]
|
|
||||||
trends: TrendEntry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameCellViewModel extends GameCell {
|
|
||||||
currentStreak: number
|
|
||||||
hitCount: number
|
|
||||||
isSelected: boolean
|
|
||||||
isWinningCell: boolean
|
|
||||||
selectionAmount: number
|
|
||||||
selectionCount: number
|
|
||||||
status: CellStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectionSummary {
|
|
||||||
amount: number
|
|
||||||
cellId: number
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { AUTH_ERROR_KEY_PREFIX } from '@/constants'
|
import { AUTH_ERROR_KEY_PREFIX } from '@/constants'
|
||||||
import { ApiError } from '@/lib/api/api-error'
|
import { ApiError } from '@/lib/api/api-error'
|
||||||
|
import type { AuthSubmitContext } from '@/type'
|
||||||
type AuthSubmitContext = 'login' | 'register'
|
|
||||||
|
|
||||||
function isTranslationKey(value: unknown): value is string {
|
function isTranslationKey(value: unknown): value is string {
|
||||||
return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX)
|
return typeof value === 'string' && value.startsWith(AUTH_ERROR_KEY_PREFIX)
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useGameRoundStore,
|
useGameRoundStore,
|
||||||
useGameSessionStore,
|
useGameSessionStore,
|
||||||
} from '@/store/game'
|
} from '@/store/game'
|
||||||
|
import type { DesktopAnimalWarningType } from '@/type'
|
||||||
|
|
||||||
function parseBalance(value: string | number | null | undefined) {
|
function parseBalance(value: string | number | null | undefined) {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
@@ -22,8 +23,6 @@ function parseBalance(value: string | number | null | undefined) {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DesktopAnimalWarningType = 'balance' | 'betLimit' | 'limit'
|
|
||||||
|
|
||||||
function getNextMarqueeId(ids: number[], currentId: number | null) {
|
function getNextMarqueeId(ids: number[], currentId: number | null) {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { placeGameBet } from '@/features/game'
|
import { placeGameBet } from '@/api'
|
||||||
import type { BetSelection } from '@/features/game/shared'
|
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
useGameRoundStore,
|
useGameRoundStore,
|
||||||
useGameSessionStore,
|
useGameSessionStore,
|
||||||
} from '@/store/game'
|
} from '@/store/game'
|
||||||
|
import type { BetSelection } from '@/type'
|
||||||
|
|
||||||
function parseBalance(value: string | number | null | undefined) {
|
function parseBalance(value: string | number | null | undefined) {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getDepositTierList } from '@/api'
|
||||||
import {
|
import {
|
||||||
DEFAULT_APP_LANGUAGE,
|
DEFAULT_APP_LANGUAGE,
|
||||||
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import { getDepositTierList } from '@/features/game/api'
|
|
||||||
|
|
||||||
export function useDepositTierList() {
|
export function useDepositTierList() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getDepositWithdrawConfig } from '@/api'
|
||||||
import {
|
import {
|
||||||
DEFAULT_APP_LANGUAGE,
|
DEFAULT_APP_LANGUAGE,
|
||||||
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
FINANCE_CONFIG_QUERY_STALE_TIME_MS,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import { getDepositWithdrawConfig } from '@/features/game/api'
|
|
||||||
|
|
||||||
export function useDepositWithdrawConfig() {
|
export function useDepositWithdrawConfig() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getDepositOrderList, getWithdrawOrderList } from '@/api'
|
||||||
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
|
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
|
||||||
import { getDepositOrderList, getWithdrawOrderList } from '@/features/game/api'
|
import type { FinanceRecordType } from '@/type'
|
||||||
|
|
||||||
export type FinanceRecordType = 'deposit' | 'withdraw'
|
|
||||||
|
|
||||||
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
|
const FINANCE_RECORD_TYPE_OPTIONS: Array<{
|
||||||
key: FinanceRecordType
|
key: FinanceRecordType
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { placeGameBet } from '@/api'
|
||||||
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
|
import { CHIP_IMAGE_MAP, CHIP_IMAGE_OPTIONS } from '@/constants'
|
||||||
import { placeGameBet } from '@/features/game'
|
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { useAuthStore, useModalStore } from '@/store'
|
import { useAuthStore, useModalStore } from '@/store'
|
||||||
import {
|
import {
|
||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
useGameRoundStore,
|
useGameRoundStore,
|
||||||
useGameSessionStore,
|
useGameSessionStore,
|
||||||
} from '@/store/game'
|
} from '@/store/game'
|
||||||
|
import type { ConfirmState } from '@/type'
|
||||||
type ConfirmState = 'idle' | 'ready' | 'insufficient' | 'limit' | 'submitting'
|
|
||||||
|
|
||||||
function formatChipDisplayValue(amount: number) {
|
function formatChipDisplayValue(amount: number) {
|
||||||
if (Number.isInteger(amount)) {
|
if (Number.isInteger(amount)) {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getGameBetMyOrders } from '@/api'
|
||||||
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
|
import { GAME_HISTORY_PAGE_SIZE } from '@/constants'
|
||||||
import { getGameBetMyOrders } from '@/features/game/api/game-api'
|
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useGameRoundStore } from '@/store/game'
|
import { useGameRoundStore } from '@/store/game'
|
||||||
|
import type { HistoryResultState } from '@/type'
|
||||||
|
|
||||||
function formatCreatedTime(timestamp: number, locale: string) {
|
function formatCreatedTime(timestamp: number, locale: string) {
|
||||||
const date = new Date(timestamp * 1000)
|
const date = new Date(timestamp * 1000)
|
||||||
@@ -32,8 +32,6 @@ function formatNumbers(numbers: number[]) {
|
|||||||
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
|
return numbers.map((number) => String(number).padStart(2, '0')).join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
type HistoryResultState = 'lost' | 'pending' | 'win'
|
|
||||||
|
|
||||||
export function useGameHistoryVm() {
|
export function useGameHistoryVm() {
|
||||||
const { i18n, t } = useTranslation()
|
const { i18n, t } = useTranslation()
|
||||||
const accessToken = useAuthStore((state) => state.accessToken)
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { getGameLobbyInit, normalizePeriodTickRound } from '@/api'
|
||||||
import {
|
import {
|
||||||
FALLBACK_POLL_INTERVAL_MS,
|
FALLBACK_POLL_INTERVAL_MS,
|
||||||
GAME_SOCKET_TOPIC_VALUES,
|
GAME_SOCKET_TOPIC_VALUES,
|
||||||
@@ -18,29 +19,15 @@ import {
|
|||||||
useGameRoundStore,
|
useGameRoundStore,
|
||||||
useGameSessionStore,
|
useGameSessionStore,
|
||||||
} from '@/store/game'
|
} from '@/store/game'
|
||||||
import { getGameLobbyInit, normalizePeriodTickRound } from '../api/game-api'
|
|
||||||
import type {
|
import type {
|
||||||
BetWinEventDataDto,
|
BetWinEventDataDto,
|
||||||
GamePeriodTickDto,
|
GamePeriodTickDto,
|
||||||
JackpotHitEventDataDto,
|
JackpotHitEventDataDto,
|
||||||
JackpotHitItemDto,
|
JackpotHitItemDto,
|
||||||
} from '../api/types'
|
PeriodEventData,
|
||||||
|
UserStreakMessageData,
|
||||||
type UserStreakMessageData = {
|
WalletChangedData,
|
||||||
currentStreak: number
|
} from '@/type'
|
||||||
oddsFactor?: number
|
|
||||||
streakLevel?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type PeriodEventData = {
|
|
||||||
openTime: number | null
|
|
||||||
periodNo: string
|
|
||||||
resultNumber: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type WalletChangedData = {
|
|
||||||
coin: string
|
|
||||||
}
|
|
||||||
|
|
||||||
let sharedSocketClient: GameSocketClient | null = null
|
let sharedSocketClient: GameSocketClient | null = null
|
||||||
let sharedSocketKey: string | null = null
|
let sharedSocketKey: string | null = null
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
CONNECTION_LATENCY_GOOD_MS,
|
CONNECTION_LATENCY_GOOD_MS,
|
||||||
CONNECTION_LATENCY_POOR_MS,
|
CONNECTION_LATENCY_POOR_MS,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
|
import { useAppLanguage } from '@/hooks/use-app-language'
|
||||||
import {
|
import {
|
||||||
isDesktopFullscreen,
|
isDesktopFullscreen,
|
||||||
subscribeDesktopFullscreenChange,
|
subscribeDesktopFullscreenChange,
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { loginWithPassword } from '@/api'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
|
import { type LoginFormValues, loginFormSchema } from '@/schema/auth-schema'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { loginWithPassword } from '../api/auth-api'
|
import type { UseLoginFormOptions } from '@/type'
|
||||||
import { type LoginFormValues, loginFormSchema } from '../schema/auth-schema'
|
|
||||||
import { toAuthSubmitErrorKey } from './auth-error-key'
|
import { toAuthSubmitErrorKey } from './auth-error-key'
|
||||||
import { createZodResolver } from './zod-form-resolver'
|
import { createZodResolver } from './zod-form-resolver'
|
||||||
|
|
||||||
interface UseLoginFormOptions {
|
|
||||||
onSuccess?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLoginForm({ onSuccess }: UseLoginFormOptions = {}) {
|
export function useLoginForm({ onSuccess }: UseLoginFormOptions = {}) {
|
||||||
const startSession = useAuthStore((state) => state.startSession)
|
const startSession = useAuthStore((state) => state.startSession)
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
@@ -1,24 +1,12 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
import {
|
import { type GamePeriodHistoryItemDto, getGamePeriodHistory } from '@/api'
|
||||||
type GamePeriodHistoryItemDto,
|
|
||||||
getGamePeriodHistory,
|
|
||||||
} from '@/features/game/api/period-history-api'
|
|
||||||
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
|
import { FLOWER_IMAGE_BY_ID } from '@/features/game/shared'
|
||||||
|
import type { PeriodHistoryDisplayItem } from '@/type'
|
||||||
|
|
||||||
export const DEFAULT_PERIOD_HISTORY_LIMIT = 36
|
export const DEFAULT_PERIOD_HISTORY_LIMIT = 36
|
||||||
|
|
||||||
export interface PeriodHistoryDisplayItem {
|
|
||||||
displayPeriodNo: string
|
|
||||||
displayResultNumber: string
|
|
||||||
image: string
|
|
||||||
isOdd: boolean
|
|
||||||
openTime: number
|
|
||||||
periodNo: string
|
|
||||||
resultNumber: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPeriodNo(periodNo: string) {
|
function formatPeriodNo(periodNo: string) {
|
||||||
const [, timeSegment] = periodNo.split('-')
|
const [, timeSegment] = periodNo.split('-')
|
||||||
|
|
||||||
@@ -1,24 +1,21 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { registerWithPassword } from '@/api'
|
||||||
import {
|
import {
|
||||||
DEFAULT_REGISTER_INVITE_CODE,
|
DEFAULT_REGISTER_INVITE_CODE,
|
||||||
REGISTER_INVITE_CODE_QUERY_PARAM,
|
REGISTER_INVITE_CODE_QUERY_PARAM,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { useAuthStore } from '@/store/auth'
|
|
||||||
import { registerWithPassword } from '../api/auth-api'
|
|
||||||
import {
|
import {
|
||||||
type RegisterFormValues,
|
type RegisterFormValues,
|
||||||
registerFormSchema,
|
registerFormSchema,
|
||||||
} from '../schema/auth-schema'
|
} from '@/schema/auth-schema'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import type { UseRegisterFormOptions } from '@/type'
|
||||||
import { toAuthSubmitErrorKey } from './auth-error-key'
|
import { toAuthSubmitErrorKey } from './auth-error-key'
|
||||||
import { createZodResolver } from './zod-form-resolver'
|
import { createZodResolver } from './zod-form-resolver'
|
||||||
|
|
||||||
interface UseRegisterFormOptions {
|
|
||||||
onSuccess?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitialRegisterInviteCode() {
|
function getInitialRegisterInviteCode() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return DEFAULT_REGISTER_INVITE_CODE
|
return DEFAULT_REGISTER_INVITE_CODE
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { sendSmsCode } from '@/api'
|
||||||
import { SMS_CODE_COOLDOWN_FALLBACK_SECONDS } from '@/constants'
|
import { SMS_CODE_COOLDOWN_FALLBACK_SECONDS } from '@/constants'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { sendSmsCode } from '../api/auth-api'
|
|
||||||
import { toAuthSubmitErrorKey } from './auth-error-key'
|
import { toAuthSubmitErrorKey } from './auth-error-key'
|
||||||
|
|
||||||
export function useSendSmsCode() {
|
export function useSendSmsCode() {
|
||||||
@@ -2,9 +2,8 @@ import { useInfiniteQuery } from '@tanstack/react-query'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getWalletRecordList } from '@/api'
|
||||||
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
|
import { DEFAULT_LIST_PAGE_SIZE } from '@/constants'
|
||||||
import { getWalletRecordList } from '@/features/game/api'
|
|
||||||
|
|
||||||
const WALLET_RECORD_TYPE = 'payout'
|
const WALLET_RECORD_TYPE = 'payout'
|
||||||
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import {
|
import { createWithdraw, type WithdrawCreateRequestDto } from '@/api'
|
||||||
createWithdraw,
|
|
||||||
type WithdrawCreateRequestDto,
|
|
||||||
} from '@/features/game/api'
|
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
|
|
||||||
export function useWithdrawSubmit() {
|
export function useWithdrawSubmit() {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { DepositWithdrawConfig } from '@/api'
|
||||||
import {
|
import {
|
||||||
DEFAULT_CURRENCY_CODE,
|
DEFAULT_CURRENCY_CODE,
|
||||||
DEFAULT_WITHDRAW_CONFIG,
|
DEFAULT_WITHDRAW_CONFIG,
|
||||||
@@ -8,8 +8,7 @@ import {
|
|||||||
WITHDRAW_EMAIL_PATTERN,
|
WITHDRAW_EMAIL_PATTERN,
|
||||||
WITHDRAW_PHONE_PATTERN,
|
WITHDRAW_PHONE_PATTERN,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import type { DepositWithdrawConfig } from '@/features/game/api'
|
import { useDepositWithdrawConfig } from '@/hooks/use-deposit-withdraw-config'
|
||||||
import { useDepositWithdrawConfig } from '@/features/game/hooks/use-deposit-withdraw-config'
|
|
||||||
import { useAuthStore } from '@/store'
|
import { useAuthStore } from '@/store'
|
||||||
|
|
||||||
function formatNumber(locale: string, value: number) {
|
function formatNumber(locale: string, value: number) {
|
||||||
@@ -8,7 +8,7 @@ import msMY from '@/locales/ms-MY'
|
|||||||
import zhCN from '@/locales/zh-CN'
|
import zhCN from '@/locales/zh-CN'
|
||||||
import { getStoredAppLanguage, setStoredAppLanguage } from '@/store/auth'
|
import { getStoredAppLanguage, setStoredAppLanguage } from '@/store/auth'
|
||||||
|
|
||||||
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
export type { AppLanguage } from '@/type'
|
||||||
|
|
||||||
/** @description 判断给定语言是否在当前应用支持列表中。 */
|
/** @description 判断给定语言是否在当前应用支持列表中。 */
|
||||||
export function isSupportedLanguage(
|
export function isSupportedLanguage(
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
HTTP_STATUS,
|
HTTP_STATUS,
|
||||||
REQUEST_HEADERS,
|
REQUEST_HEADERS,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
import type { AuthTokenDto } from '@/features/auth/api/types'
|
|
||||||
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
|
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
|
||||||
import { ApiError } from '@/lib/api/api-error.ts'
|
import { ApiError } from '@/lib/api/api-error.ts'
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +29,7 @@ import {
|
|||||||
getStoredAppLanguage,
|
getStoredAppLanguage,
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
} from '@/store/auth'
|
} from '@/store/auth'
|
||||||
import type { ApiResponse } from '@/type'
|
import type { ApiResponse, AuthTokenDto } from '@/type'
|
||||||
|
|
||||||
type RequestOptions = Omit<Options, 'json'>
|
type RequestOptions = Omit<Options, 'json'>
|
||||||
type JsonRequestOptions<TBody> = RequestOptions & {
|
type JsonRequestOptions<TBody> = RequestOptions & {
|
||||||
|
|||||||
74
src/lib/auth/auth-normalizers.ts
Normal file
74
src/lib/auth/auth-normalizers.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type {
|
||||||
|
AuthSessionDto,
|
||||||
|
AuthSessionInput,
|
||||||
|
AuthUser,
|
||||||
|
AuthUserDto,
|
||||||
|
AuthUserProfileDto,
|
||||||
|
RefreshTokenDto,
|
||||||
|
} from '@/type'
|
||||||
|
|
||||||
|
export function normalizeAuthUser(dto: AuthUserDto): AuthUser {
|
||||||
|
return {
|
||||||
|
channelId: dto.channel_id,
|
||||||
|
coin: dto.coin,
|
||||||
|
id: dto.uuid,
|
||||||
|
name: dto.username,
|
||||||
|
phone: dto.phone,
|
||||||
|
riskFlags: dto.risk_flags,
|
||||||
|
username: dto.username,
|
||||||
|
uuid: dto.uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthUserProfile(dto: AuthUserProfileDto): AuthUser {
|
||||||
|
return {
|
||||||
|
channelId: dto.channel_id,
|
||||||
|
coin: dto.coin,
|
||||||
|
createTime: dto.create_time,
|
||||||
|
currentStreak: dto.current_streak,
|
||||||
|
email: dto.email,
|
||||||
|
headImage: dto.head_image,
|
||||||
|
id: dto.uuid,
|
||||||
|
lastBetPeriodNo: dto.last_bet_period_no,
|
||||||
|
name: dto.username,
|
||||||
|
phone: dto.phone,
|
||||||
|
registerInviteCode: dto.register_invite_code,
|
||||||
|
riskFlags: dto.risk_flags,
|
||||||
|
username: dto.username,
|
||||||
|
uuid: dto.uuid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAuthUsers(
|
||||||
|
baseUser: AuthUser | null | undefined,
|
||||||
|
profileUser: AuthUser | null | undefined,
|
||||||
|
): AuthUser | null {
|
||||||
|
if (!baseUser && !profileUser) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseUser,
|
||||||
|
...profileUser,
|
||||||
|
id: profileUser?.id ?? baseUser?.id ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthSession(dto: AuthSessionDto): AuthSessionInput {
|
||||||
|
return {
|
||||||
|
accessToken: dto['user-token'],
|
||||||
|
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
|
||||||
|
currentUser: normalizeAuthUser(dto.user),
|
||||||
|
refreshToken: dto.refresh_token ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRefreshAuthSession(
|
||||||
|
dto: RefreshTokenDto,
|
||||||
|
): AuthSessionInput {
|
||||||
|
return {
|
||||||
|
accessToken: dto['user-token'],
|
||||||
|
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
|
||||||
|
refreshToken: dto.refresh_token ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,16 @@ import { LOGIN_PROMPT_DEDUP_MS } from '@/constants'
|
|||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { queryClient } from '@/lib/query/query-client'
|
import { queryClient } from '@/lib/query/query-client'
|
||||||
import type { AuthSessionInput, AuthUser } from '@/store/auth'
|
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useModalStore } from '@/store/modal'
|
import { useModalStore } from '@/store/modal'
|
||||||
|
import type {
|
||||||
export type CurrentUserInitializer = () => Promise<AuthUser | null>
|
AuthSessionInput,
|
||||||
export type RefreshSessionHandler = (
|
AuthUser,
|
||||||
refreshToken: string,
|
ClearAuthenticatedSessionOptions,
|
||||||
) => Promise<AuthSessionInput | null>
|
CurrentUserInitializer,
|
||||||
|
RefreshSessionHandler,
|
||||||
|
UnauthorizedSessionOptions,
|
||||||
|
} from '@/type'
|
||||||
|
|
||||||
let currentUserInitializer: CurrentUserInitializer | null = null
|
let currentUserInitializer: CurrentUserInitializer | null = null
|
||||||
let refreshSessionHandler: RefreshSessionHandler | null = null
|
let refreshSessionHandler: RefreshSessionHandler | null = null
|
||||||
@@ -17,16 +19,6 @@ let authInitializationPromise: Promise<void> | null = null
|
|||||||
let refreshSessionPromise: Promise<boolean> | null = null
|
let refreshSessionPromise: Promise<boolean> | null = null
|
||||||
let lastLoginPromptAt = 0
|
let lastLoginPromptAt = 0
|
||||||
|
|
||||||
interface ClearAuthenticatedSessionOptions {
|
|
||||||
clearBrowserStorage?: boolean
|
|
||||||
clearQueryCache?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UnauthorizedSessionOptions extends ClearAuthenticatedSessionOptions {
|
|
||||||
openLoginModal?: boolean
|
|
||||||
showLoginRequiredToast?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearBrowserStorageData() {
|
function clearBrowserStorageData() {
|
||||||
if (typeof localStorage !== 'undefined') {
|
if (typeof localStorage !== 'undefined') {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { redirect } from '@tanstack/react-router'
|
import { redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
import type { AppLanguage } from '@/i18n'
|
|
||||||
import { getPreferredLanguage } from '@/i18n'
|
import { getPreferredLanguage } from '@/i18n'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import type { RequireAuthenticatedSessionOptions } from '@/type'
|
||||||
|
|
||||||
import { initializeAuthSession, isAuthenticated } from './auth-session'
|
import { initializeAuthSession, isAuthenticated } from './auth-session'
|
||||||
|
|
||||||
interface RequireAuthenticatedSessionOptions {
|
|
||||||
fallbackLanguage?: AppLanguage
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAuthenticatedSession(
|
export async function requireAuthenticatedSession(
|
||||||
options: RequireAuthenticatedSessionOptions = {},
|
options: RequireAuthenticatedSessionOptions = {},
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
import { APP_DEFAULT_DESCRIPTION, APP_NAME } from '@/constants'
|
import { APP_DEFAULT_DESCRIPTION, APP_NAME } from '@/constants'
|
||||||
|
import type { DocumentMetadata } from '@/type'
|
||||||
interface DocumentMetadata {
|
|
||||||
description?: string
|
|
||||||
robots?: string
|
|
||||||
title?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function upsertMetaTag(
|
function upsertMetaTag(
|
||||||
selector: string,
|
selector: string,
|
||||||
|
|||||||
@@ -3,13 +3,7 @@ import {
|
|||||||
DEFAULT_ALERT_DURATION_MS,
|
DEFAULT_ALERT_DURATION_MS,
|
||||||
NOTIFICATION_EXIT_DURATION_MS,
|
NOTIFICATION_EXIT_DURATION_MS,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
|
import type { NotificationType, NotifyOptions } from '@/type'
|
||||||
type NotificationType = 'success' | 'error' | 'warning' | 'info' | 'loading'
|
|
||||||
|
|
||||||
export interface NotifyOptions {
|
|
||||||
description?: string
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationDialog {
|
interface NotificationDialog {
|
||||||
description?: string
|
description?: string
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ export default {
|
|||||||
label: 'Language',
|
label: 'Language',
|
||||||
zhCN: '中文',
|
zhCN: '中文',
|
||||||
enUS: 'English',
|
enUS: 'English',
|
||||||
msMY: 'Bahasa Melayu',
|
msMY: 'Melayu',
|
||||||
idID: 'Bahasa Indonesia',
|
idID: 'Indonesia',
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
metaTitle: 'Game Lobby',
|
metaTitle: 'Game Lobby',
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ export default {
|
|||||||
label: 'Bahasa',
|
label: 'Bahasa',
|
||||||
zhCN: '中文',
|
zhCN: '中文',
|
||||||
enUS: 'English',
|
enUS: 'English',
|
||||||
msMY: 'Bahasa Melayu',
|
msMY: 'Melayu',
|
||||||
idID: 'Bahasa Indonesia',
|
idID: 'Indonesia',
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
metaTitle: 'Lobby Game',
|
metaTitle: 'Lobby Game',
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ export default {
|
|||||||
label: 'Bahasa',
|
label: 'Bahasa',
|
||||||
zhCN: '中文',
|
zhCN: '中文',
|
||||||
enUS: 'English',
|
enUS: 'English',
|
||||||
msMY: 'Bahasa Melayu',
|
msMY: 'Melayu',
|
||||||
idID: 'Bahasa Indonesia',
|
idID: 'Indonesia',
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
metaTitle: 'Lobi Permainan',
|
metaTitle: 'Lobi Permainan',
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ export default {
|
|||||||
label: '语言',
|
label: '语言',
|
||||||
zhCN: '中文',
|
zhCN: '中文',
|
||||||
enUS: 'English',
|
enUS: 'English',
|
||||||
msMY: 'Bahasa Melayu',
|
msMY: 'Melayu',
|
||||||
idID: 'Bahasa Indonesia',
|
idID: 'Indonesia',
|
||||||
},
|
},
|
||||||
game: {
|
game: {
|
||||||
metaTitle: '游戏大厅',
|
metaTitle: '游戏大厅',
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { getCurrentUserProfile, refreshAuthSession } from '@/api'
|
||||||
import { AppBootResourceGate } from '@/components/app-boot-resource-gate'
|
import { AppBootResourceGate } from '@/components/app-boot-resource-gate'
|
||||||
import { AppNotificationAlert } from '@/components/ui/notification-alert'
|
import { AppNotificationAlert } from '@/components/ui/notification-alert'
|
||||||
import { APP_ROOT_ELEMENT_ID } from '@/constants'
|
import { APP_ROOT_ELEMENT_ID } from '@/constants'
|
||||||
import {
|
|
||||||
getCurrentUserProfile,
|
|
||||||
refreshAuthSession,
|
|
||||||
} from '@/features/auth/api/auth-api'
|
|
||||||
import { GlobalAudioController } from '@/features/game/audio/global-audio-controller'
|
import { GlobalAudioController } from '@/features/game/audio/global-audio-controller'
|
||||||
import '@/i18n'
|
import '@/i18n'
|
||||||
import { prefetchAuthToken } from '@/lib/api/api-client'
|
import { prefetchAuthToken } from '@/lib/api/api-client'
|
||||||
|
|||||||
@@ -1,29 +1,42 @@
|
|||||||
import { startTransition, useEffect, useState } from 'react'
|
import { startTransition, useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getGameLobbyInit } from '@/api'
|
||||||
import { MOBILE_LAYOUT_BREAKPOINT_PX } from '@/constants'
|
import { MOBILE_LAYOUT_BREAKPOINT_PX } from '@/constants'
|
||||||
import { getGameLobbyInit } from '@/features/game'
|
import {
|
||||||
import { EntryNoticeGateModal } from '@/features/game/components'
|
EntryNoticeGateModal,
|
||||||
import { MobileEntry } from '@/features/game/entry/mobile-entry.tsx'
|
MobileEntryNoticeGateModal,
|
||||||
import { PcEntry } from '@/features/game/entry/pc-entry.tsx'
|
} from '@/features/game/components/shared/entry-notice-gate-modal'
|
||||||
import { useGameRealtimeSync } from '@/features/game/hooks/use-game-realtime-sync.ts'
|
import { useGameRealtimeSync } from '@/hooks/use-game-realtime-sync.ts'
|
||||||
import DesktopAutoSettingModal from '@/features/game/modal/desktop/desktop-auto-setting-modal.tsx'
|
import { useDocumentMetadata } from '@/lib/head/document-metadata.ts'
|
||||||
import DesktopLanguageModal from '@/features/game/modal/desktop/desktop-language-modal.tsx'
|
import { notify } from '@/lib/notify.ts'
|
||||||
import DesktopLoginModal from '@/features/game/modal/desktop/desktop-login-modal.tsx'
|
import { MobileEntry } from '@/main/mobile-entry.tsx'
|
||||||
import DesktopNoticeModal from '@/features/game/modal/desktop/desktop-notice-modal.tsx'
|
import { PcEntry } from '@/main/pc-entry.tsx'
|
||||||
import { DesktopPeriodHistoryDrawer } from '@/features/game/modal/desktop/desktop-period-history-drawer.tsx'
|
import DesktopAutoSettingModal from '@/modal/desktop/desktop-auto-setting-modal.tsx'
|
||||||
import DesktopProceduresModal from '@/features/game/modal/desktop/desktop-procedures-modal.tsx'
|
import DesktopLanguageModal from '@/modal/desktop/desktop-language-modal.tsx'
|
||||||
import DesktopRegisterModal from '@/features/game/modal/desktop/desktop-register-modal.tsx'
|
import DesktopLoginModal from '@/modal/desktop/desktop-login-modal.tsx'
|
||||||
import DesktopRulesModal from '@/features/game/modal/desktop/desktop-rules-modal.tsx'
|
import DesktopNoticeModal from '@/modal/desktop/desktop-notice-modal.tsx'
|
||||||
import DesktopSupportModal from '@/features/game/modal/desktop/desktop-support-modal.tsx'
|
import { DesktopPeriodHistoryDrawer } from '@/modal/desktop/desktop-period-history-drawer.tsx'
|
||||||
import DesktopUserInfoModal from '@/features/game/modal/desktop/desktop-userInfo-modal.tsx'
|
import DesktopProceduresModal from '@/modal/desktop/desktop-procedures-modal.tsx'
|
||||||
import DesktopWithdrawTopupModal from '@/features/game/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
import DesktopRegisterModal from '@/modal/desktop/desktop-register-modal.tsx'
|
||||||
import { useDocumentMetadata } from '@/lib/head/document-metadata'
|
import DesktopRulesModal from '@/modal/desktop/desktop-rules-modal.tsx'
|
||||||
import { notify } from '@/lib/notify'
|
import DesktopSupportModal from '@/modal/desktop/desktop-support-modal.tsx'
|
||||||
|
import DesktopUserInfoModal from '@/modal/desktop/desktop-userInfo-modal.tsx'
|
||||||
|
import DesktopWithdrawTopupModal from '@/modal/desktop/desktop-withdraw-topup-modal.tsx'
|
||||||
|
import MobileAutoSettingModal from '@/modal/mobile/mobile-auto-setting-modal.tsx'
|
||||||
|
import MobileLanguageModal from '@/modal/mobile/mobile-language-modal.tsx'
|
||||||
|
import MobileLoginModal from '@/modal/mobile/mobile-login-modal.tsx'
|
||||||
|
import MobileNoticeModal from '@/modal/mobile/mobile-notice-modal.tsx'
|
||||||
|
import { MobilePeriodHistoryDrawer } from '@/modal/mobile/mobile-period-history-drawer.tsx'
|
||||||
|
import MobileProceduresModal from '@/modal/mobile/mobile-procedures-modal.tsx'
|
||||||
|
import MobileRegisterModal from '@/modal/mobile/mobile-register-modal.tsx'
|
||||||
|
import MobileRulesModal from '@/modal/mobile/mobile-rules-modal.tsx'
|
||||||
|
import MobileSupportModal from '@/modal/mobile/mobile-support-modal.tsx'
|
||||||
|
import MobileUserInfoModal from '@/modal/mobile/mobile-userInfo-modal.tsx'
|
||||||
|
import MobileWithdrawTopupModal from '@/modal/mobile/mobile-withdraw-topup-modal.tsx'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
|
||||||
|
|
||||||
function EntryModalHost() {
|
function DesktopModalHost() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
|
{/* 桌面端登录弹窗:用于未登录用户进入登录流程 */}
|
||||||
@@ -54,7 +67,38 @@ function EntryModalHost() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntryPage() {
|
function MobileModalHost() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 移动端登录弹窗:用于未登录用户进入登录流程 */}
|
||||||
|
<MobileLoginModal />
|
||||||
|
{/* 移动端注册弹窗:用于新用户注册账号 */}
|
||||||
|
<MobileRegisterModal />
|
||||||
|
{/* 移动端语言切换弹窗:用于选择当前站点展示语言 */}
|
||||||
|
<MobileLanguageModal />
|
||||||
|
{/* 移动端规则弹窗:展示当前游戏玩法、下注与结算规则 */}
|
||||||
|
<MobileRulesModal />
|
||||||
|
{/* 移动端用户信息弹窗:展示个人资料与站内消息 */}
|
||||||
|
<MobileUserInfoModal />
|
||||||
|
{/* 移动端公告弹窗:展示活动公告或运营通知内容 */}
|
||||||
|
<MobileNoticeModal />
|
||||||
|
{/* 移动端自动托管弹窗:配置自动托管相关条件 */}
|
||||||
|
<MobileAutoSettingModal />
|
||||||
|
{/* 移动端充值/提现前置选择弹窗:先选择进入充值还是提现 */}
|
||||||
|
<MobileProceduresModal />
|
||||||
|
{/* 移动端充值/提现业务弹窗:承载具体的充值或提现内容 */}
|
||||||
|
<MobileWithdrawTopupModal />
|
||||||
|
{/* 移动端客服弹窗:承载在线客服 iframe */}
|
||||||
|
<MobileSupportModal />
|
||||||
|
{/* 强制弹窗 */}
|
||||||
|
<MobileEntryNoticeGateModal />
|
||||||
|
{/* 历史开奖信息弹窗 */}
|
||||||
|
<MobilePeriodHistoryDrawer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainEntryPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
useGameRealtimeSync()
|
useGameRealtimeSync()
|
||||||
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
|
||||||
@@ -213,7 +257,7 @@ export function EntryPage() {
|
|||||||
className="flex min-h-0 flex-1 flex-col"
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
>
|
>
|
||||||
{isMobile ? <MobileEntry /> : <PcEntry />}
|
{isMobile ? <MobileEntry /> : <PcEntry />}
|
||||||
<EntryModalHost />
|
{isMobile ? <MobileModalHost /> : <DesktopModalHost />}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
|
import { MobileHeader } from '@/features/game/components/mobile/mobile-header.tsx'
|
||||||
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
import { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
|
||||||
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
|
import { useAutoHostingRunner } from '@/hooks/use-auto-hosting-runner.ts'
|
||||||
|
|
||||||
export function MobileEntry() {
|
export function MobileEntry() {
|
||||||
useAutoHostingRunner()
|
useAutoHostingRunner()
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { DesktopHeader } from '@/features/game/components'
|
|
||||||
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
|
||||||
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
|
import { DesktopControl } from '@/features/game/components/desktop/desktop-control.tsx'
|
||||||
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
import { DesktopGameHistory } from '@/features/game/components/desktop/desktop-game-history.tsx'
|
||||||
|
import { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
|
||||||
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
import { DesktopStatusLine } from '@/features/game/components/desktop/desktop-status.tsx'
|
||||||
import { useAutoHostingRunner } from '@/features/game/hooks/use-auto-hosting-runner.ts'
|
import { useAutoHostingRunner } from '@/hooks/use-auto-hosting-runner.ts'
|
||||||
|
|
||||||
export function PcEntry() {
|
export function PcEntry() {
|
||||||
useAutoHostingRunner()
|
useAutoHostingRunner()
|
||||||
@@ -3,7 +3,7 @@ import { motion } from 'motion/react'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
import { useFinanceRecordsVm } from '@/features/game/hooks/use-finance-records-vm'
|
import { useFinanceRecordsVm } from '@/hooks/use-finance-records-vm'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
function DesktopFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { useAppLanguage } from '@/features/game/hooks/use-app-language'
|
import { useAppLanguage } from '@/hooks/use-app-language'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { DesktopLoginForm } from '@/features/auth/components/desktop-login-form'
|
import { DesktopLoginForm } from '@/features/auth/components/desktop/desktop-login-form'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
function DesktopLoginModal() {
|
function DesktopLoginModal() {
|
||||||
@@ -3,11 +3,11 @@ import dayjs from 'dayjs'
|
|||||||
import { ArrowLeft } from 'lucide-react'
|
import { ArrowLeft } from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getNoticeDetail, getNoticeList } from '@/api'
|
||||||
import blueBtnBg from '@/assets/system/blue-btn.webp'
|
import blueBtnBg from '@/assets/system/blue-btn.webp'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
import { getNoticeDetail, getNoticeList } from '@/features/game/api'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
DEFAULT_PERIOD_HISTORY_LIMIT,
|
DEFAULT_PERIOD_HISTORY_LIMIT,
|
||||||
type PeriodHistoryDisplayItem,
|
type PeriodHistoryDisplayItem,
|
||||||
usePeriodHistoryVm,
|
usePeriodHistoryVm,
|
||||||
} from '@/features/game/hooks/use-period-history-vm'
|
} from '@/hooks/use-period-history-vm'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
|
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { DesktopRegisterForm } from '@/features/auth/components/desktop-register-form'
|
import { DesktopRegisterForm } from '@/features/auth/components/desktop/desktop-register-form'
|
||||||
import { useModalStore } from '@/store'
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
function DesktopRegisterModal() {
|
function DesktopRegisterModal() {
|
||||||
@@ -10,18 +10,18 @@ import {
|
|||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { logoutWithPassword } from '@/api'
|
||||||
import avatar from '@/assets/system/avatar.webp'
|
import avatar from '@/assets/system/avatar.webp'
|
||||||
import userInfoBg from '@/assets/system/userInfo-bg.webp'
|
import userInfoBg from '@/assets/system/userInfo-bg.webp'
|
||||||
import { CenterModal } from '@/components/center-modal.tsx'
|
import { CenterModal } from '@/components/center-modal.tsx'
|
||||||
import { SmartBackground } from '@/components/smart-background.tsx'
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
import { SmartImage } from '@/components/smart-image.tsx'
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
|
import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
|
||||||
import { logoutWithPassword } from '@/features/auth/api/auth-api'
|
|
||||||
import DesktopFinanceRecordsTab from '@/features/game/modal/desktop/desktop-finance-records-tab'
|
|
||||||
import DesktopWalletRecordsTab from '@/features/game/modal/desktop/desktop-wallet-records-tab'
|
|
||||||
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
|
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
|
||||||
import { notify } from '@/lib/notify'
|
import { notify } from '@/lib/notify'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import DesktopFinanceRecordsTab from '@/modal/desktop/desktop-finance-records-tab'
|
||||||
|
import DesktopWalletRecordsTab from '@/modal/desktop/desktop-wallet-records-tab'
|
||||||
import { useAuthStore, useModalStore } from '@/store'
|
import { useAuthStore, useModalStore } from '@/store'
|
||||||
|
|
||||||
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
|
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
|
||||||
@@ -3,7 +3,7 @@ import { motion } from 'motion/react'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
import { useWalletRecordsVm } from '@/features/game/hooks/use-wallet-records-vm'
|
import { useWalletRecordsVm } from '@/hooks/use-wallet-records-vm'
|
||||||
|
|
||||||
function DesktopWalletRecordsTab({ enabled }: { enabled: boolean }) {
|
function DesktopWalletRecordsTab({ enabled }: { enabled: boolean }) {
|
||||||
const vm = useWalletRecordsVm({ enabled })
|
const vm = useWalletRecordsVm({ enabled })
|
||||||
229
src/modal/mobile/mobile-auto-setting-modal.tsx
Normal file
229
src/modal/mobile/mobile-auto-setting-modal.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { Input } from '@/components/ui/input.tsx'
|
||||||
|
import { Switch } from '@/components/ui/switch.tsx'
|
||||||
|
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
|
||||||
|
import { notify } from '@/lib/notify'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import {
|
||||||
|
type AutoHostingStopRules,
|
||||||
|
selectSelectionTotal,
|
||||||
|
useGameAutoHostingStore,
|
||||||
|
useGameRoundStore,
|
||||||
|
useGameSessionStore,
|
||||||
|
} from '@/store/game'
|
||||||
|
|
||||||
|
function parseAmount(value: string) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBalance(value: string | number | null | undefined) {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value)
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileAutoSettingModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopAutoSetting)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const currentUser = useAuthStore((state) => state.currentUser)
|
||||||
|
const round = useGameRoundStore((state) => state.round)
|
||||||
|
const selections = useGameRoundStore((state) => state.selections)
|
||||||
|
const totalBetAmount = useGameRoundStore(selectSelectionTotal)
|
||||||
|
const tableLimitMax = useGameSessionStore(
|
||||||
|
(state) => state.dashboard.tableLimitMax,
|
||||||
|
)
|
||||||
|
const startHosting = useGameAutoHostingStore((state) => state.startHosting)
|
||||||
|
const [balanceLimitEnabled, setBalanceLimitEnabled] = useState(false)
|
||||||
|
const [balanceLimitValue, setBalanceLimitValue] = useState('0')
|
||||||
|
const [singleWinLimitEnabled, setSingleWinLimitEnabled] = useState(false)
|
||||||
|
const [singleWinLimitValue, setSingleWinLimitValue] = useState(
|
||||||
|
String(AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD),
|
||||||
|
)
|
||||||
|
const [jackpotStopEnabled, setJackpotStopEnabled] = useState(false)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setModalOpen('desktopAutoSetting', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (round.phase !== 'betting' || !round.id) {
|
||||||
|
notify.warning(t('commonUi.toast.betUnavailable'))
|
||||||
|
handleClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selections.length === 0) {
|
||||||
|
notify.warning(t('commonUi.toast.selectNumbersBeforeAutoHosting'))
|
||||||
|
handleClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = parseBalance(currentUser?.coin)
|
||||||
|
|
||||||
|
if (tableLimitMax > 0 && totalBetAmount > tableLimitMax) {
|
||||||
|
notify.warning(t('commonUi.toast.betLimitExceeded'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalBetAmount > balance) {
|
||||||
|
notify.warning(t('commonUi.toast.insufficientBalance'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: AutoHostingStopRules = {
|
||||||
|
stopIfBalanceBelow: {
|
||||||
|
amount: parseAmount(balanceLimitValue),
|
||||||
|
enabled: balanceLimitEnabled,
|
||||||
|
},
|
||||||
|
stopIfSingleWinAbove: {
|
||||||
|
amount: parseAmount(singleWinLimitValue),
|
||||||
|
enabled: singleWinLimitEnabled,
|
||||||
|
},
|
||||||
|
stopOnJackpot: jackpotStopEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
startHosting({
|
||||||
|
balanceAfterBet: balance,
|
||||||
|
rules,
|
||||||
|
selections,
|
||||||
|
})
|
||||||
|
notify.success(t('commonUi.toast.autoHostingStarted'))
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={
|
||||||
|
<div className={'modal-title-glow text-design-26 uppercase'}>
|
||||||
|
{t('game.modals.autoSetting.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isNormalBg={true}
|
||||||
|
titleAlign="left"
|
||||||
|
className="!h-[min(calc(var(--design-unit)*500),calc(100dvh-var(--design-unit)*28))]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex h-full w-full flex-col justify-between px-design-18 pt-design-30 pb-design-60'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={'flex w-full flex-col gap-design-26'}>
|
||||||
|
<div className={'flex items-center justify-between gap-design-30'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('game.modals.autoSetting.rows.stopIfBalanceLowerThan')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={balanceLimitValue}
|
||||||
|
inputMode="decimal"
|
||||||
|
onChange={(event) => setBalanceLimitValue(event.target.value)}
|
||||||
|
className={
|
||||||
|
'game-setting-input h-full w-design-280 text-design-18'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
size={'sm'}
|
||||||
|
checked={balanceLimitEnabled}
|
||||||
|
onCheckedChange={setBalanceLimitEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex items-center justify-between gap-design-30'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('game.modals.autoSetting.rows.stopIfSingleWinExceeds')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'game-setting-input-shell flex h-design-58 w-design-410 items-center justify-between pl-design-18 pr-design-10'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={singleWinLimitValue}
|
||||||
|
inputMode="decimal"
|
||||||
|
onChange={(event) => setSingleWinLimitValue(event.target.value)}
|
||||||
|
className={
|
||||||
|
'game-setting-input h-full w-design-280 text-design-18'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
size={'sm'}
|
||||||
|
checked={singleWinLimitEnabled}
|
||||||
|
onCheckedChange={setSingleWinLimitEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex items-center justify-between gap-design-30'}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'w-design-300 shrink-0 text-design-22 leading-[1.1] text-[#9CF7FF]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('game.modals.autoSetting.rows.stopOnAnyJackpot')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex w-design-410 justify-end pr-design-2'}>
|
||||||
|
<Switch
|
||||||
|
size={'sm'}
|
||||||
|
checked={jackpotStopEnabled}
|
||||||
|
onCheckedChange={setJackpotStopEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={'flex w-full justify-center'}>
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
src={lengthBlueBtn}
|
||||||
|
size="100% 100%"
|
||||||
|
repeat="no-repeat"
|
||||||
|
position="center"
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className={
|
||||||
|
'w-design-300 h-design-72 pb-design-4 flex cursor-pointer items-center justify-center text-design-24 font-bold tracking-wide text-[#E7FBFF] transition-transform hover:-translate-y-[1px] active:translate-y-0'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('game.modals.autoSetting.startAutoSpin')}
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileAutoSettingModal
|
||||||
181
src/modal/mobile/mobile-finance-records-tab.tsx
Normal file
181
src/modal/mobile/mobile-finance-records-tab.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
|
import { useFinanceRecordsVm } from '@/hooks/use-finance-records-vm'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function maskOrderNo(value: string) {
|
||||||
|
const text = value.trim()
|
||||||
|
|
||||||
|
if (text.length <= 12) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text.slice(0, 6)}**${text.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileFinanceRecordsTab({ enabled }: { enabled: boolean }) {
|
||||||
|
const vm = useFinanceRecordsVm({ enabled })
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
|
||||||
|
estimateSize: () => 52,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
overscan: 6,
|
||||||
|
})
|
||||||
|
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lastItem = virtualItems.at(-1)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!lastItem ||
|
||||||
|
lastItem.index < vm.items.length - 1 ||
|
||||||
|
!vm.hasNextPage ||
|
||||||
|
vm.isFetchingNextPage
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void vm.fetchNextPage()
|
||||||
|
}, [
|
||||||
|
virtualItems,
|
||||||
|
vm.fetchNextPage,
|
||||||
|
vm.hasNextPage,
|
||||||
|
vm.isFetchingNextPage,
|
||||||
|
vm.items.length,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col p-design-4">
|
||||||
|
<div className="mb-design-8 flex shrink-0 items-center justify-between gap-design-8 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||||
|
<div className="relative grid min-w-0 grid-cols-2 overflow-hidden rounded-md border border-[#3EAFC7]/30 bg-[#031B24]/75 p-design-3">
|
||||||
|
{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)
|
||||||
|
rowVirtualizer.scrollToOffset(0)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'relative h-design-24 min-w-design-82 cursor-pointer rounded-md px-design-8 text-design-12 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 text-design-10">
|
||||||
|
{recordType.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 text-design-11 text-[#7ECAD1]">
|
||||||
|
{vm.pageLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-x-auto rounded-md">
|
||||||
|
<div className="min-w-design-330">
|
||||||
|
<div className="grid grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] gap-design-6 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-10 py-design-8 text-design-12 text-[#7ECAD1]">
|
||||||
|
<div>{vm.headers.orderNo}</div>
|
||||||
|
<div>{vm.headers.amount}</div>
|
||||||
|
<div>{vm.headers.bonusAmount}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
className="mt-design-7 max-h-[calc(var(--design-unit)*340)] min-h-0 overflow-y-auto pr-design-2"
|
||||||
|
>
|
||||||
|
{vm.isLoading ? (
|
||||||
|
<DataLoadingIndicator label={vm.loadingText} />
|
||||||
|
) : vm.isError ? (
|
||||||
|
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||||
|
{vm.loadFailedText}
|
||||||
|
</div>
|
||||||
|
) : vm.items.length === 0 ? (
|
||||||
|
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||||
|
{vm.emptyText}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
|
{virtualItems.map((virtualRow) => {
|
||||||
|
const item = vm.items[virtualRow.index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
className="absolute left-0 top-0 w-full pb-design-7"
|
||||||
|
style={{
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item ? (
|
||||||
|
<motion.div
|
||||||
|
className="grid h-design-45 grid-cols-[minmax(0,1.55fr)_minmax(0,0.9fr)_minmax(0,0.9fr)] items-center gap-design-6 rounded-md bg-[#0A4252] px-design-10 py-design-8 text-design-12 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={{
|
||||||
|
duration: 0.16,
|
||||||
|
ease: 'easeOut',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="truncate font-medium text-white"
|
||||||
|
title={item.orderNoLabel}
|
||||||
|
>
|
||||||
|
{maskOrderNo(item.orderNoLabel)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[#FEEEB0]">
|
||||||
|
{item.amountLabel}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[#7CFFCF]">
|
||||||
|
{item.bonusAmountLabel}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<DataLoadingIndicator
|
||||||
|
compact
|
||||||
|
label={vm.loadingText}
|
||||||
|
className="h-design-45 rounded-md bg-[#0A4252]/60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileFinanceRecordsTab
|
||||||
100
src/modal/mobile/mobile-language-modal.tsx
Normal file
100
src/modal/mobile/mobile-language-modal.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { useAppLanguage } from '@/hooks/use-app-language'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
function MobileLanguageModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopLanguage)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const { currentLanguage, languageOptions, selectLanguage } = useAppLanguage()
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setModalOpen('desktopLanguage', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectLanguage = async (
|
||||||
|
language: (typeof languageOptions)[number]['code'],
|
||||||
|
) => {
|
||||||
|
await selectLanguage(language)
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={
|
||||||
|
<div className="modal-title-glow text-design-16">
|
||||||
|
{t('language.label')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isNormalBg={true}
|
||||||
|
titleAlign="left"
|
||||||
|
className="!h-design-350"
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 flex-col px-design-14 pt-design-5">
|
||||||
|
<div className="w-full flex flex-wrap gap-design-10 overflow-y-auto">
|
||||||
|
{languageOptions.map((option: (typeof languageOptions)[number]) => {
|
||||||
|
const isActive = option.code === currentLanguage
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSelectLanguage(option.code)}
|
||||||
|
className={cn(
|
||||||
|
'group relative w-[calc(50%-var(--design-unit)*5))] flex h-design-130 min-w-0 flex-col justify-between overflow-hidden rounded-[calc(var(--design-unit)*12)] border px-design-12 py-design-12 text-left transition-all duration-200',
|
||||||
|
isActive
|
||||||
|
? 'border-[#8BF5FF] bg-[linear-gradient(180deg,rgba(22,64,80,0.94),rgba(7,21,31,0.96))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(128,223,231,0.52),0_0_calc(var(--design-unit)*18)_rgba(66,227,255,0.18)]'
|
||||||
|
: 'border-[#62BFC8]/45 bg-[linear-gradient(180deg,rgba(10,30,43,0.92),rgba(4,13,21,0.94))] shadow-[inset_0_0_calc(var(--design-unit)*12)_rgba(128,223,231,0.16)] hover:border-[#86EFFF]/80 hover:shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(128,223,231,0.28),0_0_calc(var(--design-unit)*14)_rgba(66,227,255,0.1)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-200',
|
||||||
|
isActive
|
||||||
|
? 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.22),transparent_42%)] opacity-100'
|
||||||
|
: 'bg-[radial-gradient(circle_at_top_right,rgba(131,246,255,0.14),transparent_42%)] group-hover:opacity-100',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex min-w-0 items-start justify-between">
|
||||||
|
<SmartImage
|
||||||
|
src={option.icon}
|
||||||
|
alt={t(option.labelKey)}
|
||||||
|
className="h-design-26 w-design-26 shrink-0 rounded-[calc(var(--design-unit)*8)] object-cover shadow-[0_calc(var(--design-unit)*5)_calc(var(--design-unit)*12)_rgba(0,0,0,0.28)]"
|
||||||
|
/>
|
||||||
|
{isActive ? (
|
||||||
|
<div className="rounded-full border border-[#8BF5FF]/55 bg-[#8BF5FF]/18 px-design-8 py-design-3 text-design-10 font-semibold uppercase text-[#C9FCFF] shadow-[0_0_calc(var(--design-unit)*10)_rgba(66,227,255,0.18)]">
|
||||||
|
{t('gameDesktop.control.selected')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative min-w-0 w-full">
|
||||||
|
<div className="w-full truncate text-design-17 font-semibold text-[#F3FFFF]">
|
||||||
|
{t(option.labelKey)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-px w-full bg-[linear-gradient(90deg,rgba(128,223,231,0),rgba(128,223,231,0.65),rgba(128,223,231,0))]" />
|
||||||
|
|
||||||
|
<div className="relative mt-design-8 flex min-w-0 items-center justify-between text-design-11 text-[#98D6DC]">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{t('language.label')}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[#D8FDFF]">{option.code}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileLanguageModal
|
||||||
33
src/modal/mobile/mobile-login-modal.tsx
Normal file
33
src/modal/mobile/mobile-login-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { MobileLoginForm } from '@/features/auth/components/mobile/mobile-login-form'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
function MobileLoginModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopLogin)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setModalOpen('desktopLogin', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setModalOpen('desktopLogin', false)}
|
||||||
|
title={
|
||||||
|
<div className="modal-title-glow text-design-16">
|
||||||
|
{t('game.modals.login.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
titleAlign="center"
|
||||||
|
className="!h-design-360"
|
||||||
|
backdropClassName="backdrop-blur-none"
|
||||||
|
>
|
||||||
|
<MobileLoginForm onSuccess={handleSubmit} />
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileLoginModal
|
||||||
190
src/modal/mobile/mobile-notice-modal.tsx
Normal file
190
src/modal/mobile/mobile-notice-modal.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getNoticeDetail, getNoticeList } from '@/api'
|
||||||
|
import blueBtnBg from '@/assets/system/blue-btn.webp'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
type NoticeViewState = 'detail' | 'list'
|
||||||
|
|
||||||
|
function MobileNoticeModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopNotice)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const [noticeView, setNoticeView] = useState<NoticeViewState>('list')
|
||||||
|
const [selectedNoticeId, setSelectedNoticeId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const noticeListQuery = useQuery({
|
||||||
|
queryKey: ['game', 'notice-list'],
|
||||||
|
queryFn: () => getNoticeList(),
|
||||||
|
enabled: open && noticeView === 'list',
|
||||||
|
})
|
||||||
|
|
||||||
|
const noticeDetailQuery = useQuery({
|
||||||
|
queryKey: ['game', 'notice-detail', selectedNoticeId],
|
||||||
|
queryFn: () => getNoticeDetail(selectedNoticeId ?? 0),
|
||||||
|
enabled: open && noticeView === 'detail' && selectedNoticeId !== null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const noticeItems = useMemo(
|
||||||
|
() => noticeListQuery.data?.list ?? [],
|
||||||
|
[noticeListQuery.data],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleReturnToList() {
|
||||||
|
setNoticeView('list')
|
||||||
|
setSelectedNoticeId(null)
|
||||||
|
await noticeListQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setNoticeView('list')
|
||||||
|
setSelectedNoticeId(null)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setModalOpen('desktopNotice', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={handleSubmit}
|
||||||
|
title={
|
||||||
|
<div className="modal-title-glow text-design-14">
|
||||||
|
{t('game.modals.userInfo.message.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isNormalBg={true}
|
||||||
|
titleAlign="left"
|
||||||
|
className="!h-design-330"
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col px-design-6 pb-design-8 pt-design-2">
|
||||||
|
{noticeView === 'detail' ? (
|
||||||
|
<div className="mb-design-8 flex shrink-0 items-center rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void handleReturnToList()
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-design-7 text-[#86DAE7] transition hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="flex h-design-28 w-design-28 items-center justify-center rounded-full border border-[#4AC6DE]/45 bg-[#0B4454]">
|
||||||
|
<ArrowLeft className="h-design-16 w-design-16" />
|
||||||
|
</span>
|
||||||
|
<span className="text-design-13 font-medium">
|
||||||
|
{t('game.modals.userInfo.message.back')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto rounded-md">
|
||||||
|
{noticeView === 'list' ? (
|
||||||
|
<div className="flex h-full w-full flex-col gap-design-8 p-design-4">
|
||||||
|
{noticeListQuery.isLoading ? (
|
||||||
|
<DataLoadingIndicator
|
||||||
|
label={t('game.modals.userInfo.message.loading')}
|
||||||
|
/>
|
||||||
|
) : noticeListQuery.isError ? (
|
||||||
|
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||||
|
{t('game.modals.userInfo.message.loadFailed')}
|
||||||
|
</div>
|
||||||
|
) : noticeItems.length === 0 ? (
|
||||||
|
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||||
|
{t('game.modals.userInfo.message.empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
noticeItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.notice_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedNoticeId(item.notice_id)
|
||||||
|
setNoticeView('detail')
|
||||||
|
}}
|
||||||
|
className="flex min-h-design-62 cursor-pointer items-center gap-design-9 rounded-md bg-[#0A4252] px-design-9 py-design-8 text-left transition hover:bg-[#0E576D]"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-design-6 text-design-11 text-[#BFEAEC]">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-[calc(var(--design-unit)*4)] border px-design-5 py-design-2 text-center text-design-9 font-semibold leading-none',
|
||||||
|
item.is_read
|
||||||
|
? 'border-[#2D7384] bg-[linear-gradient(180deg,#20596A,#153A47)] text-[#B4E9F0]'
|
||||||
|
: 'border-[#9B6427] bg-[linear-gradient(180deg,#8A5320,#5E3616)] text-[#FFF0A8]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.is_read
|
||||||
|
? t('game.modals.userInfo.message.read')
|
||||||
|
: t('game.modals.userInfo.message.unread')}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{dayjs(item.publish_time * 1000).format(
|
||||||
|
'YYYY-MM-DD HH:mm:ss',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-design-3 flex items-center gap-design-8">
|
||||||
|
<div className="truncate text-design-14 text-white">
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SmartBackground
|
||||||
|
src={blueBtnBg}
|
||||||
|
size="100% 100%"
|
||||||
|
className="flex h-design-34 w-design-78 shrink-0 items-center justify-center text-design-12 font-bold"
|
||||||
|
>
|
||||||
|
{t('game.modals.userInfo.message.check')}
|
||||||
|
</SmartBackground>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col gap-design-10 p-design-4">
|
||||||
|
{noticeDetailQuery.isLoading ? (
|
||||||
|
<DataLoadingIndicator
|
||||||
|
label={t('game.modals.userInfo.message.loading')}
|
||||||
|
/>
|
||||||
|
) : noticeDetailQuery.isError ? (
|
||||||
|
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||||
|
{t('game.modals.userInfo.message.loadFailed')}
|
||||||
|
</div>
|
||||||
|
) : noticeDetailQuery.data ? (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto 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-12 shadow-[0_0_calc(var(--design-unit)*18)_rgba(14,108,132,0.16)]">
|
||||||
|
<div className="mb-design-10 inline-flex rounded-full border border-[#51BCD1]/35 bg-[#0A4252]/80 px-design-10 py-design-4 text-design-11 text-[#9CE8F2]">
|
||||||
|
{dayjs(noticeDetailQuery.data.publish_time * 1000).format(
|
||||||
|
'YYYY-MM-DD HH:mm:ss',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-design-18 font-semibold leading-tight text-white">
|
||||||
|
{noticeDetailQuery.data.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-design-12 whitespace-pre-wrap text-design-14 leading-[1.62] text-[#C4F2F7]">
|
||||||
|
{noticeDetailQuery.data.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-design-24 text-center text-design-13 text-[#6CCDCF]">
|
||||||
|
{t('game.modals.userInfo.message.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileNoticeModal
|
||||||
181
src/modal/mobile/mobile-period-history-drawer.tsx
Normal file
181
src/modal/mobile/mobile-period-history-drawer.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { PeriodHistoryList } from '@/features/game/components/shared/period-history-list'
|
||||||
|
import {
|
||||||
|
DEFAULT_PERIOD_HISTORY_LIMIT,
|
||||||
|
type PeriodHistoryDisplayItem,
|
||||||
|
usePeriodHistoryVm,
|
||||||
|
} from '@/hooks/use-period-history-vm'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
const OVERLAY_EASE = [0.16, 1, 0.3, 1] as const
|
||||||
|
const DRAWER_TRANSITION = {
|
||||||
|
type: 'tween',
|
||||||
|
duration: 0.34,
|
||||||
|
ease: OVERLAY_EASE,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
interface PeriodHistoryDrawerLabels {
|
||||||
|
close: string
|
||||||
|
empty: string
|
||||||
|
failed: string
|
||||||
|
loading: string
|
||||||
|
retry: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MobilePeriodHistoryDrawerViewProps {
|
||||||
|
isError: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
items: PeriodHistoryDisplayItem[]
|
||||||
|
labels: PeriodHistoryDrawerLabels
|
||||||
|
onClose: () => void
|
||||||
|
onRetry: () => void
|
||||||
|
open: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePeriodHistoryDrawer() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopPeriodHistory)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const vm = usePeriodHistoryVm({
|
||||||
|
enabled: open,
|
||||||
|
limit: DEFAULT_PERIOD_HISTORY_LIMIT,
|
||||||
|
})
|
||||||
|
const handleClose = () => {
|
||||||
|
setModalOpen('desktopPeriodHistory', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobilePeriodHistoryDrawerView
|
||||||
|
open={open}
|
||||||
|
items={vm.items}
|
||||||
|
isLoading={vm.isLoading}
|
||||||
|
isError={vm.isError}
|
||||||
|
labels={{
|
||||||
|
close: t('gameDesktop.periodHistory.close'),
|
||||||
|
empty: t('gameDesktop.periodHistory.empty'),
|
||||||
|
failed: t('gameDesktop.periodHistory.failed'),
|
||||||
|
loading: t('gameDesktop.periodHistory.loading'),
|
||||||
|
retry: t('gameDesktop.periodHistory.retry'),
|
||||||
|
title: t('gameDesktop.periodHistory.title'),
|
||||||
|
}}
|
||||||
|
onClose={handleClose}
|
||||||
|
onRetry={() => void vm.refetch()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobilePeriodHistoryDrawerView({
|
||||||
|
isError,
|
||||||
|
isLoading,
|
||||||
|
items,
|
||||||
|
labels,
|
||||||
|
onClose,
|
||||||
|
onRetry,
|
||||||
|
open,
|
||||||
|
}: MobilePeriodHistoryDrawerViewProps) {
|
||||||
|
const prefersReducedMotion = useReducedMotion()
|
||||||
|
const [isDrawerAnimating, setIsDrawerAnimating] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
aria-label={labels.close}
|
||||||
|
className="fixed left-0 right-0 top-0 bottom-[calc(var(--design-unit)*150)] z-30 cursor-default bg-black/48"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: prefersReducedMotion ? 0.12 : 0.26,
|
||||||
|
ease: OVERLAY_EASE,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={labels.title}
|
||||||
|
className="fixed left-0 top-design-16 bottom-[calc(var(--design-unit)*150)] z-40 flex w-design-1120 max-w-[calc(100vw-var(--design-unit)*24)] origin-left flex-col overflow-hidden rounded-r-[calc(var(--design-unit)*10)] border border-[rgba(81,230,255,0.62)] bg-[linear-gradient(180deg,rgba(6,19,32,0.98),rgba(3,12,22,0.96))] text-[#D5FBFF] shadow-[0_0_calc(var(--design-unit)*18)_rgba(39,216,255,0.28),0_0_calc(var(--design-unit)*54)_rgba(39,216,255,0.16),inset_0_0_calc(var(--design-unit)*18)_rgba(74,224,255,0.16)]"
|
||||||
|
initial={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { x: '-100%', opacity: 0.98 }
|
||||||
|
}
|
||||||
|
animate={
|
||||||
|
prefersReducedMotion ? { opacity: 1 } : { x: 0, opacity: 1 }
|
||||||
|
}
|
||||||
|
exit={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { x: '-100%', opacity: 0.98 }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
prefersReducedMotion ? { duration: 0.12 } : DRAWER_TRANSITION
|
||||||
|
}
|
||||||
|
onAnimationStart={() => setIsDrawerAnimating(true)}
|
||||||
|
onAnimationComplete={() => setIsDrawerAnimating(false)}
|
||||||
|
style={
|
||||||
|
isDrawerAnimating
|
||||||
|
? { willChange: 'transform, opacity' }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-design-8 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(80,241,255,0.96),transparent)]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute bottom-0 left-0 h-design-28 w-design-28 border-b-2 border-l-2 border-[#28E6FF]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute bottom-0 right-0 h-design-28 w-design-28 border-b-2 border-r-2 border-[#28E6FF]"
|
||||||
|
/>
|
||||||
|
<div className="relative flex h-design-78 shrink-0 items-center justify-between border-b border-[rgba(80,224,255,0.38)] px-design-42">
|
||||||
|
<h2 className="text-design-28 font-bold leading-none text-white [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(156,244,255,0.42)]">
|
||||||
|
{labels.title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={labels.close}
|
||||||
|
className="flex h-design-42 w-design-42 cursor-pointer items-center justify-center text-[#C8F7FF] transition-colors duration-200 hover:text-white focus-visible:ring-2 focus-visible:ring-[#4FEAFF]"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={32} strokeWidth={2.1} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
className="history-scroll-hidden min-h-0 flex-1 overflow-y-auto px-design-34 py-design-26"
|
||||||
|
initial={
|
||||||
|
prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: 8 }
|
||||||
|
}
|
||||||
|
animate={
|
||||||
|
prefersReducedMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
prefersReducedMotion
|
||||||
|
? { duration: 0.12 }
|
||||||
|
: { duration: 0.22, delay: 0.08, ease: OVERLAY_EASE }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PeriodHistoryList
|
||||||
|
items={items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
labels={labels}
|
||||||
|
onRetry={onRetry}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
src/modal/mobile/mobile-procedures-modal.tsx
Normal file
101
src/modal/mobile/mobile-procedures-modal.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import diamond from '@/assets/system/diamond.webp'
|
||||||
|
import proceduresBg from '@/assets/system/procedures-bg.webp'
|
||||||
|
import topupBtnBg from '@/assets/system/topup.webp'
|
||||||
|
import withdrawBtnBg from '@/assets/system/withdraw.webp'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { useAuthStore, useModalStore } from '@/store'
|
||||||
|
|
||||||
|
function MobileProceduresModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopProcedures)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const setWithdrawTopupType = useModalStore(
|
||||||
|
(state) => state.setWithdrawTopupType,
|
||||||
|
)
|
||||||
|
const currentUser = useAuthStore((state) => state.currentUser)
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setModalOpen('desktopProcedures', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenWithdrawTopup(type: 'withdraw' | 'topup') {
|
||||||
|
setModalOpen('desktopProcedures', false)
|
||||||
|
setWithdrawTopupType(type)
|
||||||
|
setModalOpen('desktopWithdrawTopup', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={handleSubmit}
|
||||||
|
title={
|
||||||
|
<div className={'modal-title-glow text-design-16'}>
|
||||||
|
{t('game.modals.procedures.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isNormalBg={true}
|
||||||
|
titleAlign="left"
|
||||||
|
className="h-design-280"
|
||||||
|
>
|
||||||
|
<div className={'h-full flex flex-col px-design-5'}>
|
||||||
|
<SmartBackground
|
||||||
|
src={proceduresBg}
|
||||||
|
repeat="no-repeat"
|
||||||
|
size="cover"
|
||||||
|
className={
|
||||||
|
'flex-1 flex h-full min-h-0 w-full flex-col items-center justify-between overflow-hidden rounded-md px-design-14 py-design-18'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'flex items-center justify-center gap-design-14 pl-design-50 py-design-8'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SmartImage
|
||||||
|
className={'w-design-40 mt-design-15 ml-design-10'}
|
||||||
|
alt={'diamond'}
|
||||||
|
src={diamond}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'modal-title-gold-glow mt-design-15 text-design-22 font-bold tracking-[0.06em] text-[#F7DC7A]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{currentUser?.coin || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SmartBackground>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'h-design-74 flex w-full items-center justify-center gap-design-30'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SmartBackground
|
||||||
|
src={withdrawBtnBg}
|
||||||
|
onClick={() => handleOpenWithdrawTopup('withdraw')}
|
||||||
|
className={
|
||||||
|
'flex h-design-74 w-design-120 cursor-pointer items-center justify-center pb-design-6 text-design-14 font-bold transition-[transform,filter] duration-150 hover:brightness-110 active:translate-y-[calc(var(--design-unit)*1)] active:scale-[0.98] active:brightness-95'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('game.modals.procedures.withdraw')}
|
||||||
|
</SmartBackground>
|
||||||
|
<SmartBackground
|
||||||
|
src={topupBtnBg}
|
||||||
|
onClick={() => handleOpenWithdrawTopup('topup')}
|
||||||
|
className={
|
||||||
|
'flex h-design-74 w-design-120 cursor-pointer items-center justify-center pb-design-10 text-design-14 font-bold transition-[transform,filter] duration-150 hover:brightness-110 active:translate-y-[calc(var(--design-unit)*1)] active:scale-[0.98] active:brightness-95'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('game.modals.procedures.topup')}
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileProceduresModal
|
||||||
33
src/modal/mobile/mobile-register-modal.tsx
Normal file
33
src/modal/mobile/mobile-register-modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { MobileRegisterForm } from '@/features/auth/components/mobile/mobile-register-form'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
function MobileRegisterModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopRegister)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setModalOpen('desktopRegister', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => setModalOpen('desktopRegister', false)}
|
||||||
|
title={
|
||||||
|
<div className={'modal-title-glow'}>
|
||||||
|
{t('game.modals.register.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
titleAlign="center"
|
||||||
|
className="!h-[min(calc(var(--design-unit)*520),calc(100dvh-var(--design-unit)*28))]"
|
||||||
|
backdropClassName="backdrop-blur-none"
|
||||||
|
>
|
||||||
|
<MobileRegisterForm onSuccess={handleSubmit} />
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileRegisterModal
|
||||||
53
src/modal/mobile/mobile-rules-modal.tsx
Normal file
53
src/modal/mobile/mobile-rules-modal.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import lengthBlueBtn from '@/assets/system/length-blue-btn.webp'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
function MobileRulesModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopRules)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setModalOpen('desktopRules', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
isNormalBg={true}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={
|
||||||
|
<div className="modal-title-glow text-design-16">
|
||||||
|
{t('game.modals.rules.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
titleAlign="left"
|
||||||
|
className="!h-design-320"
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 flex-col gap-design-12 px-design-14 pb-design-16 pt-design-4">
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto rounded-[calc(var(--design-unit)*10)] bg-black/35 px-design-14 py-design-12 text-design-12 leading-[1.62] text-[#B9E7EA] whitespace-pre-line">
|
||||||
|
{t('game.modals.rules.content')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 justify-center">
|
||||||
|
<SmartBackground
|
||||||
|
as="button"
|
||||||
|
type="button"
|
||||||
|
src={lengthBlueBtn}
|
||||||
|
size="100% 100%"
|
||||||
|
repeat="no-repeat"
|
||||||
|
position="center"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="modal-title-glow flex h-design-42 w-design-120 cursor-pointer items-center justify-center mt-design-1 pb-design-3 pl-design-5 text-design-14 font-bold"
|
||||||
|
>
|
||||||
|
{t('game.modals.rules.confirm')}
|
||||||
|
</SmartBackground>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileRulesModal
|
||||||
78
src/modal/mobile/mobile-support-modal.tsx
Normal file
78
src/modal/mobile/mobile-support-modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
const SUPPORT_CHAT_URL =
|
||||||
|
'https://tawk.to/chat/6a1d23d9e29f411c2ce86772/1jq0t82lu'
|
||||||
|
const IFRAME_READY_DELAY_MS = 2_000
|
||||||
|
|
||||||
|
function MobileSupportModal() {
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const readyTimerRef = useRef<number | null>(null)
|
||||||
|
const open = useModalStore((state) => state.modals.desktopSupport)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
|
const clearReadyTimer = useCallback(() => {
|
||||||
|
if (readyTimerRef.current === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(readyTimerRef.current)
|
||||||
|
readyTimerRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setModalOpen('desktopSupport', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoaded = () => {
|
||||||
|
clearReadyTimer()
|
||||||
|
readyTimerRef.current = window.setTimeout(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
readyTimerRef.current = null
|
||||||
|
}, IFRAME_READY_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
clearReadyTimer()
|
||||||
|
setIsLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clearReadyTimer
|
||||||
|
}, [clearReadyTimer, open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
isNormalBg={true}
|
||||||
|
onClose={handleClose}
|
||||||
|
titleAlign="left"
|
||||||
|
title={<div className="modal-title-glow text-design-16">在线客服</div>}
|
||||||
|
className="h-design-500"
|
||||||
|
>
|
||||||
|
<div className="h-full min-h-0 px-design-8 pb-design-10 pt-design-4">
|
||||||
|
<div className="relative h-full min-h-0 overflow-hidden rounded-[calc(var(--design-unit)*7)] border border-[#2A6D73] bg-[linear-gradient(180deg,rgba(5,22,31,0.98),rgba(2,10,17,0.98))] shadow-[inset_0_0_calc(var(--design-unit)*14)_rgba(88,205,218,0.13),0_0_calc(var(--design-unit)*10)_rgba(31,156,174,0.14)]">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-[radial-gradient(circle_at_center,rgba(20,92,105,0.38),rgba(2,10,17,0.98)_58%)]">
|
||||||
|
<DataLoadingIndicator
|
||||||
|
label="客服连线中"
|
||||||
|
className="text-design-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<iframe
|
||||||
|
title="customer-service-chat"
|
||||||
|
src={SUPPORT_CHAT_URL}
|
||||||
|
onLoad={handleLoaded}
|
||||||
|
className="h-full w-full bg-[linear-gradient(180deg,#061923,#020A11)]"
|
||||||
|
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileSupportModal
|
||||||
328
src/modal/mobile/mobile-userInfo-modal.tsx
Normal file
328
src/modal/mobile/mobile-userInfo-modal.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import {
|
||||||
|
CircleUserRound,
|
||||||
|
ClipboardList,
|
||||||
|
LogOut,
|
||||||
|
ReceiptText,
|
||||||
|
WalletCards,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { logoutWithPassword } from '@/api'
|
||||||
|
import avatar from '@/assets/system/avatar.webp'
|
||||||
|
import userInfoBg from '@/assets/system/userInfo-bg.webp'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import { SmartBackground } from '@/components/smart-background.tsx'
|
||||||
|
import { SmartImage } from '@/components/smart-image.tsx'
|
||||||
|
import { REGISTER_INVITE_CODE_QUERY_PARAM } from '@/constants'
|
||||||
|
import { clearAuthenticatedSession } from '@/lib/auth/auth-session'
|
||||||
|
import { notify } from '@/lib/notify'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import MobileFinanceRecordsTab from '@/modal/mobile/mobile-finance-records-tab'
|
||||||
|
import MobileWalletRecordsTab from '@/modal/mobile/mobile-wallet-records-tab'
|
||||||
|
import { useAuthStore, useModalStore } from '@/store'
|
||||||
|
|
||||||
|
type UserInfoTabKey = 'financeRecords' | 'profile' | 'walletRecords'
|
||||||
|
|
||||||
|
const USER_INFO_TABS: Array<{
|
||||||
|
key: UserInfoTabKey
|
||||||
|
labelKey: string
|
||||||
|
icon: typeof CircleUserRound
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
labelKey: 'game.modals.userInfo.tabs.profile',
|
||||||
|
icon: CircleUserRound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'financeRecords',
|
||||||
|
labelKey: 'game.modals.userInfo.tabs.financeRecords',
|
||||||
|
icon: ReceiptText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'walletRecords',
|
||||||
|
labelKey: 'game.modals.userInfo.tabs.walletRecords',
|
||||||
|
icon: WalletCards,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function createRegisterInviteUrl(inviteCode: string) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
|
||||||
|
url.searchParams.set(REGISTER_INVITE_CODE_QUERY_PARAM, inviteCode)
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(text: string) {
|
||||||
|
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
|
||||||
|
textarea.value = text
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
textarea.style.top = '-9999px'
|
||||||
|
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copied = document.execCommand('copy')
|
||||||
|
|
||||||
|
if (!copied) {
|
||||||
|
throw new Error('Copy command failed')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileUserInfoModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopUserInfo)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
const [activeTab, setActiveTab] = useState<UserInfoTabKey>('profile')
|
||||||
|
const currentUser = useAuthStore((state) => state.currentUser)
|
||||||
|
const inviteCode = currentUser?.registerInviteCode?.trim() ?? ''
|
||||||
|
const logoutUsername =
|
||||||
|
currentUser?.username ?? currentUser?.phone ?? currentUser?.name ?? ''
|
||||||
|
const logoutMutation = useMutation({
|
||||||
|
mutationFn: logoutWithPassword,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setActiveTab('profile')
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setModalOpen('desktopUserInfo', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyInviteLink() {
|
||||||
|
if (!inviteCode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyTextToClipboard(createRegisterInviteUrl(inviteCode))
|
||||||
|
notify.success(t('commonUi.toast.inviteLinkCopied'))
|
||||||
|
} catch {
|
||||||
|
notify.error(t('commonUi.toast.inviteLinkCopyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
if (logoutMutation.isPending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logoutMutation.mutateAsync({
|
||||||
|
password: '',
|
||||||
|
username: logoutUsername,
|
||||||
|
})
|
||||||
|
notify.success(t('commonUi.toast.logoutSuccess'))
|
||||||
|
} catch {
|
||||||
|
notify.warning(t('commonUi.toast.logoutLocalOnly'))
|
||||||
|
} finally {
|
||||||
|
clearAuthenticatedSession({ clearBrowserStorage: true })
|
||||||
|
setModalOpen('desktopUserInfo', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={handleSubmit}
|
||||||
|
title={
|
||||||
|
<div className="modal-title-glow text-design-16">
|
||||||
|
{t('game.modals.userInfo.title')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isNormalBg={true}
|
||||||
|
titleAlign="left"
|
||||||
|
className="h-design-420"
|
||||||
|
>
|
||||||
|
<div className="relative flex h-full min-h-0 w-full flex-col px-design-8 pb-design-10 pt-design-2">
|
||||||
|
<div className="relative mb-design-10 grid h-design-40 shrink-0 grid-cols-3 overflow-hidden rounded-[calc(var(--design-unit)*10)] border border-[#2B8CA3]/35 bg-[#031B24]/75 p-design-3">
|
||||||
|
{USER_INFO_TABS.map((tab) => {
|
||||||
|
const Icon = tab.icon
|
||||||
|
const isActive = tab.key === activeTab
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex min-w-0 cursor-pointer items-center justify-center gap-design-5 overflow-hidden rounded-[calc(var(--design-unit)*8)] px-design-5 transition-colors duration-200',
|
||||||
|
isActive
|
||||||
|
? 'text-[#FEEEB0]'
|
||||||
|
: 'text-[#58ADAF] hover:text-[#BFEAEC]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span
|
||||||
|
layoutId="user-info-tab-active-bg"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 rounded-[calc(var(--design-unit)*8)] bg-[linear-gradient(180deg,rgba(254,238,176,0.34)_0%,rgba(254,238,176,0.15)_100%)]"
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 430,
|
||||||
|
damping: 36,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isActive ? (
|
||||||
|
<motion.span
|
||||||
|
layoutId="user-info-tab-active-indicator"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-design-10 bottom-0 h-[calc(var(--design-unit)*2)] rounded-full bg-[linear-gradient(90deg,rgba(255,248,214,0.2),rgba(254,238,176,0.95),rgba(255,248,214,0.2))] shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.36)]"
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 430,
|
||||||
|
damping: 36,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 transition',
|
||||||
|
isActive &&
|
||||||
|
'drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(254,238,176,0.5)]',
|
||||||
|
)}
|
||||||
|
animate={{
|
||||||
|
scale: isActive ? 1.06 : 1,
|
||||||
|
y: isActive ? -2 : 0,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<Icon className="h-design-17 w-design-17" />
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
'relative z-10 min-w-0 truncate text-center text-design-11 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)}
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{activeTab === 'profile' ? (
|
||||||
|
<SmartBackground
|
||||||
|
src={userInfoBg}
|
||||||
|
size="140% 100%"
|
||||||
|
className={
|
||||||
|
'relative flex h-design-250 min-h-0 w-full flex-col overflow-hidden bg-top bg-no-repeat px-design-14 py-design-14 text-[#6CCDCF]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||||
|
<div className="flex shrink-0 items-center gap-design-12">
|
||||||
|
<SmartImage
|
||||||
|
className="h-design-58 w-design-58 shrink-0"
|
||||||
|
src={currentUser?.headImage || avatar}
|
||||||
|
alt={'avatar'}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1 text-design-14 leading-[1.55] text-[#6CCDCF]">
|
||||||
|
<div className="truncate">
|
||||||
|
{t('game.modals.userInfo.profile.name')} :
|
||||||
|
{currentUser?.name ?? '--'}
|
||||||
|
</div>
|
||||||
|
<div className="truncate mt-design-5">
|
||||||
|
{t('game.modals.userInfo.profile.tel')} :{' '}
|
||||||
|
{currentUser?.phone ?? '--'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-design-24 min-h-0 w-full flex-1 overflow-hidden px-design-12 py-design-10 text-design-13 leading-[1.6]">
|
||||||
|
<div className="text-[#6CCDCF]">
|
||||||
|
{t('game.modals.userInfo.profile.registeredAt')}:
|
||||||
|
<span className="ml-design-8 text-design-12 text-[#599AA3]">
|
||||||
|
{currentUser?.createTime
|
||||||
|
? dayjs
|
||||||
|
.unix(currentUser.createTime)
|
||||||
|
.format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
: '--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-design-10 flex min-w-0 items-center text-[#6CCDCF]">
|
||||||
|
<span className="shrink-0">
|
||||||
|
{t('auth.register.fields.inviteCode.label')}
|
||||||
|
</span>
|
||||||
|
<span className="ml-design-8 min-w-0 flex-1 truncate text-design-12 text-[#599AA3]">
|
||||||
|
{inviteCode || '--'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void handleCopyInviteLink()
|
||||||
|
}}
|
||||||
|
disabled={!inviteCode}
|
||||||
|
aria-label={t(
|
||||||
|
'game.modals.userInfo.profile.copyInviteLink',
|
||||||
|
)}
|
||||||
|
title={t('game.modals.userInfo.profile.copyInviteLink')}
|
||||||
|
className="ml-design-8 flex h-design-26 w-design-26 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#356E76] bg-[#0B2F35]/70 text-[#6CCDCF] transition-colors duration-200 hover:border-[#6CCDCF] hover:text-[#D9FFFF] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#6CCDCF] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
>
|
||||||
|
<ClipboardList className="h-design-15 w-design-15" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-design-30 flex w-full shrink-0 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void handleLogout()
|
||||||
|
}}
|
||||||
|
disabled={logoutMutation.isPending}
|
||||||
|
className="inline-flex h-design-36 min-w-design-126 cursor-pointer items-center justify-center gap-design-7 rounded-md border border-[#8F4747] bg-[#3A1111]/80 px-design-12 text-design-13 text-[#FFD7D7] transition-colors duration-200 hover:border-[#FF8A8A] hover:bg-[#5A1818]/85 hover:text-white focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#FF8A8A] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<LogOut className="h-design-15 w-design-15" />
|
||||||
|
<span>
|
||||||
|
{logoutMutation.isPending
|
||||||
|
? t('game.modals.userInfo.profile.loggingOut')
|
||||||
|
: t('game.modals.userInfo.profile.logout')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SmartBackground>
|
||||||
|
) : activeTab === 'financeRecords' ? (
|
||||||
|
<MobileFinanceRecordsTab
|
||||||
|
enabled={open && activeTab === 'financeRecords'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MobileWalletRecordsTab
|
||||||
|
enabled={open && activeTab === 'walletRecords'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileUserInfoModal
|
||||||
140
src/modal/mobile/mobile-wallet-records-tab.tsx
Normal file
140
src/modal/mobile/mobile-wallet-records-tab.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import { DataLoadingIndicator } from '@/components/ui/data-loading-indicator'
|
||||||
|
import { useWalletRecordsVm } from '@/hooks/use-wallet-records-vm'
|
||||||
|
|
||||||
|
function MobileWalletRecordsTab({ enabled }: { enabled: boolean }) {
|
||||||
|
const vm = useWalletRecordsVm({ enabled })
|
||||||
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: vm.items.length + (vm.hasNextPage ? 1 : 0),
|
||||||
|
estimateSize: () => 52,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
overscan: 6,
|
||||||
|
})
|
||||||
|
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lastItem = virtualItems.at(-1)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!lastItem ||
|
||||||
|
lastItem.index < vm.items.length - 1 ||
|
||||||
|
!vm.hasNextPage ||
|
||||||
|
vm.isFetchingNextPage
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void vm.fetchNextPage()
|
||||||
|
}, [
|
||||||
|
virtualItems,
|
||||||
|
vm.fetchNextPage,
|
||||||
|
vm.hasNextPage,
|
||||||
|
vm.isFetchingNextPage,
|
||||||
|
vm.items.length,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 w-full flex-col p-design-4">
|
||||||
|
<div className="mb-design-8 flex shrink-0 items-center justify-between gap-design-8 rounded-md border border-[#3EAFC7]/40 bg-[#062E39]/80 px-design-8 py-design-7">
|
||||||
|
<div className="text-design-14 font-medium text-[#BFEAEC]">
|
||||||
|
{vm.headers.type}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-design-11 text-[#7ECAD1]">
|
||||||
|
{vm.pageLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-x-auto rounded-md">
|
||||||
|
<div className="min-w-design-520">
|
||||||
|
<div className="grid grid-cols-[minmax(0,0.72fr)_minmax(0,0.78fr)_minmax(0,0.78fr)_minmax(0,1.18fr)_minmax(0,0.88fr)] gap-design-5 rounded-md border border-[#2B8CA3]/35 bg-[#031B24]/75 px-design-9 py-design-8 text-design-11 text-[#7ECAD1]">
|
||||||
|
<div className="text-center">{vm.headers.amount}</div>
|
||||||
|
<div>{vm.headers.balanceBefore}</div>
|
||||||
|
<div>{vm.headers.balanceAfter}</div>
|
||||||
|
<div>{vm.headers.time}</div>
|
||||||
|
<div>{vm.headers.remark}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
className="mt-design-7 max-h-[calc(var(--design-unit)*340)] min-h-0 overflow-y-auto pr-design-2"
|
||||||
|
>
|
||||||
|
{vm.isLoading ? (
|
||||||
|
<DataLoadingIndicator label={vm.loadingText} />
|
||||||
|
) : vm.isError ? (
|
||||||
|
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||||
|
{vm.loadFailedText}
|
||||||
|
</div>
|
||||||
|
) : vm.items.length === 0 ? (
|
||||||
|
<div className="py-design-24 text-center text-design-12 text-[#6CCDCF]">
|
||||||
|
{vm.emptyText}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||||
|
>
|
||||||
|
{virtualItems.map((virtualRow) => {
|
||||||
|
const item = vm.items[virtualRow.index]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
className="absolute left-0 top-0 w-full pb-design-7"
|
||||||
|
style={{
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item ? (
|
||||||
|
<motion.div
|
||||||
|
className="grid h-design-45 grid-cols-[minmax(0,0.72fr)_minmax(0,0.78fr)_minmax(0,0.78fr)_minmax(0,1.18fr)_minmax(0,0.88fr)] items-center gap-design-5 rounded-md bg-[#0A4252] px-design-9 py-design-8 text-design-11 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={{
|
||||||
|
duration: 0.16,
|
||||||
|
ease: 'easeOut',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="truncate text-center font-medium text-[#FEEEB0]">
|
||||||
|
{item.amountLabel}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[#86DAE7]">
|
||||||
|
{item.balanceBeforeLabel}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[#7CFFCF]">
|
||||||
|
{item.balanceAfterLabel}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap text-[#BFEAEC]">
|
||||||
|
{item.timeLabel}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate text-white"
|
||||||
|
title={item.remarkLabel}
|
||||||
|
>
|
||||||
|
{item.remarkLabel}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<DataLoadingIndicator
|
||||||
|
compact
|
||||||
|
label={vm.loadingText}
|
||||||
|
className="h-design-45 rounded-md bg-[#0A4252]/60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileWalletRecordsTab
|
||||||
39
src/modal/mobile/mobile-withdraw-topup-modal.tsx
Normal file
39
src/modal/mobile/mobile-withdraw-topup-modal.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { MobileCenterModal } from '@/components/mobile-center-modal.tsx'
|
||||||
|
import MobileTopup from '@/features/game/components/mobile/mobile-topup.tsx'
|
||||||
|
import MobileWithdraw from '@/features/game/components/mobile/mobile-withdraw.tsx'
|
||||||
|
import { useModalStore } from '@/store'
|
||||||
|
|
||||||
|
function MobileWithdrawTopupModal() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useModalStore((state) => state.modals.desktopWithdrawTopup)
|
||||||
|
const type = useModalStore((state) => state.withdrawTopupType)
|
||||||
|
const setModalOpen = useModalStore((state) => state.setModalOpen)
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setModalOpen('desktopWithdrawTopup', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCenterModal
|
||||||
|
open={open}
|
||||||
|
onClose={handleSubmit}
|
||||||
|
title={
|
||||||
|
<div className={'modal-title-glow text-design-16 uppercase'}>
|
||||||
|
{type === 'withdraw'
|
||||||
|
? t('game.modals.withdrawTopup.applyWithdraw')
|
||||||
|
: t('game.modals.withdrawTopup.applyTopup')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isNormalBg={true}
|
||||||
|
titleAlign="left"
|
||||||
|
className="h-design-510"
|
||||||
|
>
|
||||||
|
<div className={'h-full min-h-0 w-full'}>
|
||||||
|
{type === 'withdraw' ? <MobileWithdraw /> : <MobileTopup />}
|
||||||
|
</div>
|
||||||
|
</MobileCenterModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileWithdrawTopupModal
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { EntryPage } from '@/features/game/entry/entry-page.tsx'
|
import { MainEntryPage } from '@/main/main-entry-page.tsx'
|
||||||
|
|
||||||
export const Route = createFileRoute('/$lang/')({
|
export const Route = createFileRoute('/$lang/')({
|
||||||
component: EntryPage,
|
component: MainEntryPage,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PASSWORD_MAX_LENGTH,
|
PASSWORD_MAX_LENGTH,
|
||||||
PASSWORD_MIN_LENGTH,
|
PASSWORD_MIN_LENGTH,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
|
import type { LoginFormValues, RegisterFormValues } from '@/type'
|
||||||
|
|
||||||
const usernameSchema = z
|
const usernameSchema = z
|
||||||
.string()
|
.string()
|
||||||
@@ -42,6 +43,3 @@ export const registerFormSchema = z
|
|||||||
message: 'auth.validation.confirmPassword.mismatch',
|
message: 'auth.validation.confirmPassword.mismatch',
|
||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
})
|
})
|
||||||
|
|
||||||
export type LoginFormValues = z.infer<typeof loginFormSchema>
|
|
||||||
export type RegisterFormValues = z.infer<typeof registerFormSchema>
|
|
||||||
@@ -2,14 +2,7 @@ import { create } from 'zustand'
|
|||||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||||
|
|
||||||
import { AUDIO_PREFERENCES_STORAGE_KEY } from '@/constants'
|
import { AUDIO_PREFERENCES_STORAGE_KEY } from '@/constants'
|
||||||
|
import type { AudioPreferenceState } from '@/type'
|
||||||
interface AudioPreferenceState {
|
|
||||||
hasUnlockedSoundPlayback: boolean
|
|
||||||
markSoundPlaybackUnlocked: () => void
|
|
||||||
isSoundEnabled: boolean
|
|
||||||
setSoundEnabled: (enabled: boolean) => void
|
|
||||||
toggleSoundEnabled: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAudioStore = create<AudioPreferenceState>()(
|
export const useAudioStore = create<AudioPreferenceState>()(
|
||||||
persist(
|
persist(
|
||||||
|
|||||||
@@ -2,37 +2,7 @@ import { create } from 'zustand'
|
|||||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||||
|
|
||||||
import { APP_PREFERENCES_STORAGE_KEY, AUTH_STORAGE_KEY } from '@/constants'
|
import { APP_PREFERENCES_STORAGE_KEY, AUTH_STORAGE_KEY } from '@/constants'
|
||||||
|
import type { AuthSessionInput, AuthStatus, AuthUser } from '@/type'
|
||||||
/**@description 未登录 | 已登录 | 正在从存储恢复数据 */
|
|
||||||
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
|
|
||||||
|
|
||||||
export interface AuthUser {
|
|
||||||
createTime?: number
|
|
||||||
channelId?: number
|
|
||||||
coin?: string
|
|
||||||
currentStreak?: number
|
|
||||||
email?: string
|
|
||||||
headImage?: string
|
|
||||||
id: string
|
|
||||||
isJackpot?: boolean
|
|
||||||
lastBetPeriodNo?: string
|
|
||||||
name?: string
|
|
||||||
oddsFactor?: number
|
|
||||||
phone?: string
|
|
||||||
registerInviteCode?: string
|
|
||||||
riskFlags?: number
|
|
||||||
roles?: string[]
|
|
||||||
streakLevel?: number
|
|
||||||
username?: string
|
|
||||||
uuid?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthSessionInput {
|
|
||||||
accessToken: string
|
|
||||||
accessTokenExpiresAt?: number | null
|
|
||||||
currentUser?: AuthUser | null
|
|
||||||
refreshToken?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PersistedAuthState {
|
interface PersistedAuthState {
|
||||||
accessToken: string | null
|
accessToken: string | null
|
||||||
|
|||||||
@@ -1,43 +1,12 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
|
import { AUTO_HOSTING_DEFAULT_SINGLE_WIN_THRESHOLD } from '@/constants'
|
||||||
import type { BetSelection } from '@/features/game/shared'
|
import type {
|
||||||
|
AutoHostingStopRules,
|
||||||
export interface AutoHostingStopRules {
|
BetSelection,
|
||||||
stopIfBalanceBelow: {
|
GameAutoHostingStoreState,
|
||||||
amount: number
|
StartAutoHostingInput,
|
||||||
enabled: boolean
|
} from '@/type'
|
||||||
}
|
|
||||||
stopIfSingleWinAbove: {
|
|
||||||
amount: number
|
|
||||||
enabled: boolean
|
|
||||||
}
|
|
||||||
stopOnJackpot: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StartAutoHostingInput {
|
|
||||||
balanceAfterBet: number | null
|
|
||||||
rules: AutoHostingStopRules
|
|
||||||
selections: BetSelection[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameAutoHostingStoreState {
|
|
||||||
balanceAfterBet: number | null
|
|
||||||
completedRounds: number
|
|
||||||
isHosting: boolean
|
|
||||||
lastIsJackpot: boolean | null
|
|
||||||
lastSingleWinAmount: number | null
|
|
||||||
lastSubmittedRoundId: string | null
|
|
||||||
rules: AutoHostingStopRules
|
|
||||||
selections: BetSelection[]
|
|
||||||
markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void
|
|
||||||
recordBetWin: (input: {
|
|
||||||
isJackpot: boolean
|
|
||||||
singleWinAmount: number | null
|
|
||||||
}) => void
|
|
||||||
startHosting: (input: StartAutoHostingInput) => void
|
|
||||||
stopHosting: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_AUTO_HOSTING_RULES: AutoHostingStopRules = {
|
const DEFAULT_AUTO_HOSTING_RULES: AutoHostingStopRules = {
|
||||||
stopIfBalanceBelow: {
|
stopIfBalanceBelow: {
|
||||||
|
|||||||
@@ -1,54 +1,31 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { DEFAULT_ACTIVE_CHIP_ID } from '@/constants'
|
||||||
import type {
|
|
||||||
BetSelection,
|
|
||||||
Chip,
|
|
||||||
GameBootstrapSnapshot,
|
|
||||||
GameCell,
|
|
||||||
HistoryEntry,
|
|
||||||
RoundPhase,
|
|
||||||
RoundSnapshot,
|
|
||||||
TrendEntry,
|
|
||||||
} from '@/features/game/shared'
|
|
||||||
import {
|
import {
|
||||||
buildGameCellViewModels,
|
buildGameCellViewModels,
|
||||||
createEmptyGameBootstrapSnapshot,
|
createEmptyGameBootstrapSnapshot,
|
||||||
DEFAULT_ACTIVE_CHIP_ID,
|
|
||||||
getChipById,
|
getChipById,
|
||||||
getRecentWinningCellIds,
|
getRecentWinningCellIds,
|
||||||
getSelectionTotal,
|
getSelectionTotal,
|
||||||
groupSelectionsByCell,
|
groupSelectionsByCell,
|
||||||
} from '@/features/game/shared'
|
} from '@/features/game/shared'
|
||||||
|
import type {
|
||||||
type GameRoundSlice = Pick<
|
BetSelection,
|
||||||
GameBootstrapSnapshot,
|
Chip,
|
||||||
| 'cells'
|
GameCell,
|
||||||
| 'chips'
|
GameRoundSlice,
|
||||||
| 'history'
|
GameRoundStoreData,
|
||||||
| 'maxSelectionCount'
|
GameRoundStoreState,
|
||||||
| 'round'
|
HistoryEntry,
|
||||||
| 'selections'
|
RevealAnimationPhase,
|
||||||
| 'trends'
|
RevealAnimationState,
|
||||||
>
|
RewardAnimationType,
|
||||||
|
RoundPhase,
|
||||||
|
RoundSnapshot,
|
||||||
|
TrendEntry,
|
||||||
|
} from '@/type'
|
||||||
|
|
||||||
const MIN_BET_QUANTITY = 1
|
const MIN_BET_QUANTITY = 1
|
||||||
|
|
||||||
export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
|
|
||||||
export type RewardAnimationType = 'none' | 'small' | 'big'
|
|
||||||
|
|
||||||
export interface RevealAnimationState {
|
|
||||||
pendingRewardAmount: string | null
|
|
||||||
pendingRewardKey: string | null
|
|
||||||
pendingRewardRoundId: string | null
|
|
||||||
pendingRewardType: RewardAnimationType
|
|
||||||
phase: RevealAnimationPhase
|
|
||||||
revealKey: string | null
|
|
||||||
rewardAmount: string | null
|
|
||||||
rewardType: RewardAnimationType
|
|
||||||
roundId: string | null
|
|
||||||
winningCellId: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function createIdleRevealAnimation(): RevealAnimationState {
|
function createIdleRevealAnimation(): RevealAnimationState {
|
||||||
return {
|
return {
|
||||||
pendingRewardAmount: null,
|
pendingRewardAmount: null,
|
||||||
@@ -143,39 +120,6 @@ function resolveSelectionQuantity(
|
|||||||
return normalizeBetQuantity(firstSelection.amount / chip.amount)
|
return normalizeBetQuantity(firstSelection.amount / chip.amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameRoundStoreState extends GameRoundSlice {
|
|
||||||
activeChipId: string
|
|
||||||
activeBetQuantity: number
|
|
||||||
adjustBetQuantity: (delta: number) => void
|
|
||||||
clearSelections: () => void
|
|
||||||
clearRewardAnimation: () => void
|
|
||||||
finishRevealAnimation: () => void
|
|
||||||
hydrateRound: (snapshot: GameRoundSlice) => void
|
|
||||||
placeBet: (cellId: number) => void
|
|
||||||
playPreparedRevealAnimation: (roundId?: string | null) => void
|
|
||||||
prepareRevealAnimation: (input: {
|
|
||||||
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
|
|
||||||
setPendingBetWinReward: (input: {
|
|
||||||
isJackpot: boolean
|
|
||||||
revealKey: string
|
|
||||||
roundId?: string | null
|
|
||||||
totalWin: string
|
|
||||||
winningCellId?: number | null
|
|
||||||
}) => void
|
|
||||||
syncRound: (round: Partial<RoundSnapshot>) => void
|
|
||||||
upsertSelections: (selections: BetSelection[]) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function createInitialRoundState(): GameRoundSlice & {
|
function createInitialRoundState(): GameRoundSlice & {
|
||||||
activeChipId: string
|
activeChipId: string
|
||||||
activeBetQuantity: number
|
activeBetQuantity: number
|
||||||
@@ -596,15 +540,5 @@ export const selectSelectionsByCell = (state: GameRoundStoreState) =>
|
|||||||
groupSelectionsByCell(state.selections)
|
groupSelectionsByCell(state.selections)
|
||||||
|
|
||||||
export type GameRoundStore = typeof useGameRoundStore
|
export type GameRoundStore = typeof useGameRoundStore
|
||||||
export type GameRoundStoreData = Pick<
|
|
||||||
GameRoundStoreState,
|
|
||||||
| 'cells'
|
|
||||||
| 'chips'
|
|
||||||
| 'history'
|
|
||||||
| 'maxSelectionCount'
|
|
||||||
| 'round'
|
|
||||||
| 'selections'
|
|
||||||
| 'trends'
|
|
||||||
>
|
|
||||||
|
|
||||||
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }
|
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }
|
||||||
|
|||||||
@@ -4,33 +4,25 @@ import {
|
|||||||
CONNECTION_LATENCY_FAIR_MS,
|
CONNECTION_LATENCY_FAIR_MS,
|
||||||
MAX_JACKPOT_BROADCAST_COUNT,
|
MAX_JACKPOT_BROADCAST_COUNT,
|
||||||
} from '@/constants'
|
} from '@/constants'
|
||||||
|
import {
|
||||||
|
createEmptyGameBootstrapSnapshot,
|
||||||
|
getUnreadAnnouncementCount,
|
||||||
|
getVisibleAnnouncements,
|
||||||
|
} from '@/features/game/shared'
|
||||||
import type {
|
import type {
|
||||||
AnnouncementState,
|
AnnouncementState,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
DashboardState,
|
DashboardState,
|
||||||
GameBootstrapSnapshot,
|
GameBootstrapSnapshot,
|
||||||
} from '@/features/game/shared'
|
JackpotBroadcastItem,
|
||||||
import {
|
} from '@/type'
|
||||||
createEmptyGameBootstrapSnapshot,
|
|
||||||
getUnreadAnnouncementCount,
|
|
||||||
getVisibleAnnouncements,
|
|
||||||
} from '@/features/game/shared'
|
|
||||||
|
|
||||||
type GameSessionSlice = Pick<
|
type GameSessionSlice = Pick<
|
||||||
GameBootstrapSnapshot,
|
GameBootstrapSnapshot,
|
||||||
'announcements' | 'connection' | 'dashboard'
|
'announcements' | 'connection' | 'dashboard'
|
||||||
>
|
>
|
||||||
|
|
||||||
export interface JackpotBroadcastItem {
|
|
||||||
id: string
|
|
||||||
message: string
|
|
||||||
nickname: string
|
|
||||||
periodNo: string
|
|
||||||
receivedAt: string
|
|
||||||
totalWin: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type JackpotBroadcastInput = Omit<JackpotBroadcastItem, 'receivedAt'>
|
type JackpotBroadcastInput = Omit<JackpotBroadcastItem, 'receivedAt'>
|
||||||
|
|
||||||
export interface GameSessionStoreState extends GameSessionSlice {
|
export interface GameSessionStoreState extends GameSessionSlice {
|
||||||
|
|||||||
@@ -1,22 +1,9 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { INITIAL_MODAL_VISIBILITY, MODAL_KEYS } from '@/constants'
|
import { INITIAL_MODAL_VISIBILITY, MODAL_KEYS } from '@/constants'
|
||||||
import type { WithdrawTopupType } from '@/type'
|
import type { ModalKey, ModalStoreState } from '@/type'
|
||||||
|
|
||||||
export { MODAL_KEYS }
|
export { MODAL_KEYS }
|
||||||
|
|
||||||
export type ModalKey = (typeof MODAL_KEYS)[number]
|
|
||||||
|
|
||||||
type ModalVisibilityMap = Record<ModalKey, boolean>
|
|
||||||
|
|
||||||
export interface ModalStoreState {
|
|
||||||
modals: ModalVisibilityMap
|
|
||||||
withdrawTopupType: WithdrawTopupType
|
|
||||||
closeAllModals: () => void
|
|
||||||
openExclusiveModal: (key: ModalKey) => void
|
|
||||||
setModalOpen: (key: ModalKey, open: boolean) => void
|
|
||||||
setWithdrawTopupType: (type: WithdrawTopupType) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useModalStore = create<ModalStoreState>()((set) => ({
|
export const useModalStore = create<ModalStoreState>()((set) => ({
|
||||||
modals: INITIAL_MODAL_VISIBILITY,
|
modals: INITIAL_MODAL_VISIBILITY,
|
||||||
withdrawTopupType: 'withdraw',
|
withdrawTopupType: 'withdraw',
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number
|
||||||
|
msg?: string
|
||||||
|
data: T
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorOptions {
|
||||||
|
message: string
|
||||||
|
status?: number
|
||||||
|
data?: unknown
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface FinanceCurrencyConfigDto {
|
export interface FinanceCurrencyConfigDto {
|
||||||
code: string
|
code: string
|
||||||
deposit_coins_per_fiat: string
|
deposit_coins_per_fiat: string
|
||||||
@@ -49,11 +63,7 @@ export interface DepositTierItemDto {
|
|||||||
amount?: number | string
|
amount?: number | string
|
||||||
bonus_amount?: number | string
|
bonus_amount?: number | string
|
||||||
bonus_coins?: number | string
|
bonus_coins?: number | string
|
||||||
channels?: Array<{
|
channels?: Array<{ code?: string; name?: string; sort?: number | string }>
|
||||||
code?: string
|
|
||||||
name?: string
|
|
||||||
sort?: number | string
|
|
||||||
}>
|
|
||||||
coins?: number | string
|
coins?: number | string
|
||||||
currency?: string
|
currency?: string
|
||||||
desc?: string
|
desc?: string
|
||||||
@@ -120,11 +130,7 @@ export interface DepositWithdrawConfig {
|
|||||||
export interface DepositTierItem {
|
export interface DepositTierItem {
|
||||||
amount: number
|
amount: number
|
||||||
bonusAmount: number
|
bonusAmount: number
|
||||||
channels: Array<{
|
channels: Array<{ code: string; name: string; sort: number }>
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
sort: number
|
|
||||||
}>
|
|
||||||
coins: number
|
coins: number
|
||||||
currency: string | null
|
currency: string | null
|
||||||
desc: string
|
desc: string
|
||||||
@@ -162,18 +168,18 @@ export interface DepositCreateResponseDto {
|
|||||||
total_amount: number
|
total_amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinanceOrderItemDto {
|
|
||||||
amount: number | string
|
|
||||||
bonus_amount: number | string
|
|
||||||
order_no: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FinanceOrderPaginationDto {
|
export interface FinanceOrderPaginationDto {
|
||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FinanceOrderItemDto {
|
||||||
|
amount: number | string
|
||||||
|
bonus_amount: number | string
|
||||||
|
order_no: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface FinanceOrderListDto {
|
export interface FinanceOrderListDto {
|
||||||
list: FinanceOrderItemDto[]
|
list: FinanceOrderItemDto[]
|
||||||
pagination: FinanceOrderPaginationDto
|
pagination: FinanceOrderPaginationDto
|
||||||
@@ -1,5 +1,36 @@
|
|||||||
import type { SMS_SEND_EVENT_REGISTER } from '@/constants'
|
import type { SMS_SEND_EVENT_REGISTER } from '@/constants'
|
||||||
import type { AuthSessionInput, AuthUser } from '@/store/auth'
|
|
||||||
|
export type AuthStatus = 'anonymous' | 'authenticated' | 'restoring'
|
||||||
|
|
||||||
|
export type AuthSubmitContext = 'login' | 'register'
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
createTime?: number
|
||||||
|
channelId?: number
|
||||||
|
coin?: string
|
||||||
|
currentStreak?: number
|
||||||
|
email?: string
|
||||||
|
headImage?: string
|
||||||
|
id: string
|
||||||
|
isJackpot?: boolean
|
||||||
|
lastBetPeriodNo?: string
|
||||||
|
name?: string
|
||||||
|
oddsFactor?: number
|
||||||
|
phone?: string
|
||||||
|
registerInviteCode?: string
|
||||||
|
riskFlags?: number
|
||||||
|
roles?: string[]
|
||||||
|
streakLevel?: number
|
||||||
|
username?: string
|
||||||
|
uuid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSessionInput {
|
||||||
|
accessToken: string
|
||||||
|
accessTokenExpiresAt?: number | null
|
||||||
|
currentUser?: AuthUser | null
|
||||||
|
refreshToken?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthApiEnvelope<T> {
|
export interface AuthApiEnvelope<T> {
|
||||||
code: number
|
code: number
|
||||||
@@ -107,68 +138,37 @@ export interface SendSmsCodeResult {
|
|||||||
messageId: string
|
messageId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAuthUser(dto: AuthUserDto): AuthUser {
|
export type LoginFormValues = { password: string; username: string }
|
||||||
return {
|
|
||||||
channelId: dto.channel_id,
|
export type RegisterFormValues = {
|
||||||
coin: dto.coin,
|
captcha: string
|
||||||
id: dto.uuid,
|
confirmPassword: string
|
||||||
name: dto.username,
|
inviteCode: string
|
||||||
phone: dto.phone,
|
mobile: string
|
||||||
riskFlags: dto.risk_flags,
|
password: string
|
||||||
username: dto.username,
|
|
||||||
uuid: dto.uuid,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAuthUserProfile(dto: AuthUserProfileDto): AuthUser {
|
export interface UseLoginFormOptions {
|
||||||
return {
|
onSuccess?: () => void
|
||||||
channelId: dto.channel_id,
|
|
||||||
coin: dto.coin,
|
|
||||||
createTime: dto.create_time,
|
|
||||||
currentStreak: dto.current_streak,
|
|
||||||
email: dto.email,
|
|
||||||
headImage: dto.head_image,
|
|
||||||
id: dto.uuid,
|
|
||||||
lastBetPeriodNo: dto.last_bet_period_no,
|
|
||||||
name: dto.username,
|
|
||||||
phone: dto.phone,
|
|
||||||
registerInviteCode: dto.register_invite_code,
|
|
||||||
riskFlags: dto.risk_flags,
|
|
||||||
username: dto.username,
|
|
||||||
uuid: dto.uuid,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeAuthUsers(
|
export interface UseRegisterFormOptions {
|
||||||
baseUser: AuthUser | null | undefined,
|
onSuccess?: () => void
|
||||||
profileUser: AuthUser | null | undefined,
|
|
||||||
): AuthUser | null {
|
|
||||||
if (!baseUser && !profileUser) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseUser,
|
|
||||||
...profileUser,
|
|
||||||
id: profileUser?.id ?? baseUser?.id ?? '',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAuthSession(dto: AuthSessionDto): AuthSessionInput {
|
export interface ClearAuthenticatedSessionOptions {
|
||||||
return {
|
clearBrowserStorage?: boolean
|
||||||
accessToken: dto['user-token'],
|
clearQueryCache?: boolean
|
||||||
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
|
|
||||||
currentUser: normalizeAuthUser(dto.user),
|
|
||||||
refreshToken: dto.refresh_token ?? null,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeRefreshAuthSession(
|
export interface UnauthorizedSessionOptions
|
||||||
dto: RefreshTokenDto,
|
extends ClearAuthenticatedSessionOptions {
|
||||||
): AuthSessionInput {
|
openLoginModal?: boolean
|
||||||
return {
|
showLoginRequiredToast?: boolean
|
||||||
accessToken: dto['user-token'],
|
|
||||||
accessTokenExpiresAt: Date.now() + dto.expires_in * 1000,
|
|
||||||
refreshToken: dto.refresh_token ?? null,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CurrentUserInitializer = () => Promise<AuthUser | null>
|
||||||
|
|
||||||
|
export type RefreshSessionHandler = (
|
||||||
|
refreshToken: string,
|
||||||
|
) => Promise<AuthSessionInput | null>
|
||||||
638
src/type/game.type.ts
Normal file
638
src/type/game.type.ts
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
import type {
|
||||||
|
ANNOUNCEMENT_TONES,
|
||||||
|
BET_SOURCES,
|
||||||
|
CELL_STATUSES,
|
||||||
|
CONNECTION_STATUSES,
|
||||||
|
CONNECTION_TRANSPORTS,
|
||||||
|
ROUND_PHASES,
|
||||||
|
TREND_DIRECTIONS,
|
||||||
|
} from '@/constants'
|
||||||
|
|
||||||
|
// ─── Enum Union Types ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RoundPhase = (typeof ROUND_PHASES)[number]
|
||||||
|
export type CellStatus = (typeof CELL_STATUSES)[number]
|
||||||
|
export type ConnectionStatus = (typeof CONNECTION_STATUSES)[number]
|
||||||
|
export type ConnectionTransport = (typeof CONNECTION_TRANSPORTS)[number]
|
||||||
|
export type AnnouncementTone = (typeof ANNOUNCEMENT_TONES)[number]
|
||||||
|
export type BetSource = (typeof BET_SOURCES)[number]
|
||||||
|
export type TrendDirection = (typeof TREND_DIRECTIONS)[number]
|
||||||
|
export type GamePeriodStatus =
|
||||||
|
| 'betting'
|
||||||
|
| 'locked'
|
||||||
|
| 'settling'
|
||||||
|
| 'payouting'
|
||||||
|
| 'finished'
|
||||||
|
| 'void'
|
||||||
|
| (string & {})
|
||||||
|
export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
|
||||||
|
export type RewardAnimationType = 'none' | 'small' | 'big'
|
||||||
|
export type FinanceRecordType = 'deposit' | 'withdraw'
|
||||||
|
export type ConfirmState =
|
||||||
|
| 'idle'
|
||||||
|
| 'ready'
|
||||||
|
| 'insufficient'
|
||||||
|
| 'limit'
|
||||||
|
| 'submitting'
|
||||||
|
export type HistoryResultState = 'lost' | 'pending' | 'win'
|
||||||
|
export type DesktopAnimalWarningType = 'balance' | 'betLimit' | 'limit'
|
||||||
|
|
||||||
|
// ─── Game Domain Models ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GameCell {
|
||||||
|
column: number
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
odds: number
|
||||||
|
row: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chip {
|
||||||
|
amount: number
|
||||||
|
color: string
|
||||||
|
id: string
|
||||||
|
isDefault?: boolean
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetSelection {
|
||||||
|
amount: number
|
||||||
|
cellId: number
|
||||||
|
chipId: string
|
||||||
|
id: string
|
||||||
|
placedAt: string
|
||||||
|
source: BetSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundSnapshot {
|
||||||
|
bettingClosesAt: string
|
||||||
|
id: string
|
||||||
|
phase: RoundPhase
|
||||||
|
revealingAt: string
|
||||||
|
settledAt: string | null
|
||||||
|
startedAt: string
|
||||||
|
winningCellId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
payoutMultiplier: number
|
||||||
|
roundId: string
|
||||||
|
settledAt: string
|
||||||
|
totalPoolAmount: number
|
||||||
|
winningCellId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendEntry {
|
||||||
|
cellId: number
|
||||||
|
currentStreak: number
|
||||||
|
direction: TrendDirection
|
||||||
|
hitCount: number
|
||||||
|
lastHitRoundId: string | null
|
||||||
|
missCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementItem {
|
||||||
|
createdAt: string
|
||||||
|
expiresAt: string | null
|
||||||
|
id: string
|
||||||
|
isPinned?: boolean
|
||||||
|
isRead?: boolean
|
||||||
|
message: string
|
||||||
|
title: string
|
||||||
|
tone: AnnouncementTone
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementState {
|
||||||
|
activeAnnouncementId: string | null
|
||||||
|
items: AnnouncementItem[]
|
||||||
|
lastUpdatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardState {
|
||||||
|
countdownMs: number
|
||||||
|
featuredCellId: number | null
|
||||||
|
onlinePlayers: number
|
||||||
|
tableLimitMax: number
|
||||||
|
tableLimitMin: number
|
||||||
|
totalPoolAmount: number
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionState {
|
||||||
|
connectedAt: string | null
|
||||||
|
lastError: string | null
|
||||||
|
lastMessageAt: string | null
|
||||||
|
latencyMs: number | null
|
||||||
|
reconnectAttempt: number
|
||||||
|
status: ConnectionStatus
|
||||||
|
transport: ConnectionTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameBootstrapSnapshot {
|
||||||
|
announcements: AnnouncementState
|
||||||
|
cells: GameCell[]
|
||||||
|
chips: Chip[]
|
||||||
|
connection: ConnectionState
|
||||||
|
dashboard: DashboardState
|
||||||
|
history: HistoryEntry[]
|
||||||
|
maxSelectionCount: number
|
||||||
|
round: RoundSnapshot
|
||||||
|
selections: BetSelection[]
|
||||||
|
trends: TrendEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameCellViewModel extends GameCell {
|
||||||
|
currentStreak: number
|
||||||
|
hitCount: number
|
||||||
|
isSelected: boolean
|
||||||
|
isWinningCell: boolean
|
||||||
|
selectionAmount: number
|
||||||
|
selectionCount: number
|
||||||
|
status: CellStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionSummary {
|
||||||
|
amount: number
|
||||||
|
cellId: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodHistoryDisplayItem {
|
||||||
|
displayPeriodNo: string
|
||||||
|
displayResultNumber: string
|
||||||
|
image: string
|
||||||
|
isOdd: boolean
|
||||||
|
openTime: number
|
||||||
|
periodNo: string
|
||||||
|
resultNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowerImageAsset {
|
||||||
|
animalUrl: string
|
||||||
|
id: number
|
||||||
|
rewardUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevealAnimationState {
|
||||||
|
pendingRewardAmount: string | null
|
||||||
|
pendingRewardKey: string | null
|
||||||
|
pendingRewardRoundId: string | null
|
||||||
|
pendingRewardType: RewardAnimationType
|
||||||
|
phase: RevealAnimationPhase
|
||||||
|
revealKey: string | null
|
||||||
|
rewardAmount: string | null
|
||||||
|
rewardType: RewardAnimationType
|
||||||
|
roundId: string | null
|
||||||
|
winningCellId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoHostingStopRules {
|
||||||
|
stopIfBalanceBelow: { amount: number; enabled: boolean }
|
||||||
|
stopIfSingleWinAbove: { amount: number; enabled: boolean }
|
||||||
|
stopOnJackpot: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JackpotBroadcastItem {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
nickname: string
|
||||||
|
periodNo: string
|
||||||
|
receivedAt: string
|
||||||
|
totalWin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStreakMessageData {
|
||||||
|
currentStreak: number
|
||||||
|
oddsFactor?: number
|
||||||
|
streakLevel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodEventData {
|
||||||
|
openTime: number | null
|
||||||
|
periodNo: string
|
||||||
|
resultNumber: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletChangedData {
|
||||||
|
coin: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AudioAssetId = 'hall-bgm'
|
||||||
|
|
||||||
|
export interface AudioAssetDefinition {
|
||||||
|
id: AudioAssetId
|
||||||
|
loop?: boolean
|
||||||
|
src: string
|
||||||
|
volume?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Game DTOs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GameCellDto {
|
||||||
|
column: number
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
odds: number
|
||||||
|
row: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChipDto {
|
||||||
|
amount: number
|
||||||
|
color: string
|
||||||
|
id: string
|
||||||
|
is_default?: boolean
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetSelectionDto {
|
||||||
|
amount: number
|
||||||
|
cell_id: number
|
||||||
|
chip_id: string
|
||||||
|
id: string
|
||||||
|
placed_at: string
|
||||||
|
source: BetSelection['source']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundSnapshotDto {
|
||||||
|
betting_closes_at: string
|
||||||
|
id: string
|
||||||
|
phase: RoundSnapshot['phase']
|
||||||
|
revealing_at: string
|
||||||
|
settled_at: string | null
|
||||||
|
started_at: string
|
||||||
|
winning_cell_id: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntryDto {
|
||||||
|
payout_multiplier: number
|
||||||
|
round_id: string
|
||||||
|
settled_at: string
|
||||||
|
total_pool_amount: number
|
||||||
|
winning_cell_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendEntryDto {
|
||||||
|
cell_id: number
|
||||||
|
current_streak: number
|
||||||
|
direction: TrendEntry['direction']
|
||||||
|
hit_count: number
|
||||||
|
last_hit_round_id: string | null
|
||||||
|
miss_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementItemDto {
|
||||||
|
created_at: string
|
||||||
|
expires_at: string | null
|
||||||
|
id: string
|
||||||
|
is_pinned?: boolean
|
||||||
|
is_read?: boolean
|
||||||
|
message: string
|
||||||
|
title: string
|
||||||
|
tone: 'info' | 'success' | 'warning' | 'critical'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementStateDto {
|
||||||
|
active_announcement_id: string | null
|
||||||
|
items: AnnouncementItemDto[]
|
||||||
|
last_updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStateDto {
|
||||||
|
countdown_ms: number
|
||||||
|
featured_cell_id: number | null
|
||||||
|
online_players: number
|
||||||
|
table_limit_max: number
|
||||||
|
table_limit_min: number
|
||||||
|
total_pool_amount: number
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionStateDto {
|
||||||
|
connected_at: string | null
|
||||||
|
last_error: string | null
|
||||||
|
last_message_at: string | null
|
||||||
|
latency_ms: number | null
|
||||||
|
reconnect_attempt: number
|
||||||
|
status: ConnectionState['status']
|
||||||
|
transport: ConnectionState['transport']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameBootstrapDto {
|
||||||
|
announcements: AnnouncementStateDto
|
||||||
|
cells: GameCellDto[]
|
||||||
|
chips: ChipDto[]
|
||||||
|
connection: ConnectionStateDto
|
||||||
|
dashboard: DashboardStateDto
|
||||||
|
history: HistoryEntryDto[]
|
||||||
|
max_selection_count?: number
|
||||||
|
round: RoundSnapshotDto
|
||||||
|
selections: BetSelectionDto[]
|
||||||
|
trends: TrendEntryDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameRoundFeedDto {
|
||||||
|
history: HistoryEntryDto[]
|
||||||
|
round: RoundSnapshotDto
|
||||||
|
selections: BetSelectionDto[]
|
||||||
|
trends: TrendEntryDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameAnnouncementsDto {
|
||||||
|
announcements: AnnouncementStateDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeListItemDto {
|
||||||
|
content?: string
|
||||||
|
is_read: boolean
|
||||||
|
must_confirm?: boolean
|
||||||
|
notice_id: number
|
||||||
|
notice_type: 'silent' | 'popout' | (string & {})
|
||||||
|
publish_time: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeListDto {
|
||||||
|
list: NoticeListItemDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeDetailDto {
|
||||||
|
content: string
|
||||||
|
must_confirm: boolean
|
||||||
|
notice_id: number
|
||||||
|
notice_type: 'silent' | 'popout' | (string & {})
|
||||||
|
publish_time: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeConfirmDto {
|
||||||
|
confirm_time: number
|
||||||
|
confirmed: boolean
|
||||||
|
notice_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyPeriodDto {
|
||||||
|
countdown: number
|
||||||
|
lock_at: number
|
||||||
|
open_at: number
|
||||||
|
period_no: string
|
||||||
|
status: GamePeriodStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyBetConfigDto {
|
||||||
|
chips: Record<string, string>
|
||||||
|
default_bet_chip_id: number
|
||||||
|
max_bet_per_number: string
|
||||||
|
min_bet_per_number: string
|
||||||
|
pick_max_number_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyDictionaryItemDto {
|
||||||
|
category: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
number: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyUserSnapshotDto {
|
||||||
|
coin: string
|
||||||
|
current_streak: number
|
||||||
|
is_jackpot?: boolean
|
||||||
|
odds_factor?: number
|
||||||
|
streak_level?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyInitDto {
|
||||||
|
bet_config: GameLobbyBetConfigDto
|
||||||
|
dictionary: GameLobbyDictionaryItemDto[]
|
||||||
|
period?: GameLobbyPeriodDto | null
|
||||||
|
runtime_enabled: boolean
|
||||||
|
server_time: number
|
||||||
|
user_snapshot: GameLobbyUserSnapshotDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameLobbyInitResult {
|
||||||
|
runtimeEnabled: boolean
|
||||||
|
serverTime: number
|
||||||
|
snapshot: GameBootstrapSnapshot
|
||||||
|
userSnapshot: GameLobbyInitDto['user_snapshot']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePeriodTickDto {
|
||||||
|
bet_close_in: number
|
||||||
|
countdown: number
|
||||||
|
period_id: number | null
|
||||||
|
period_no: string
|
||||||
|
result_number: number | null
|
||||||
|
runtime_enabled: boolean
|
||||||
|
server_time: number
|
||||||
|
status: GamePeriodStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JackpotHitItemDto {
|
||||||
|
nickname: string
|
||||||
|
period_no: string
|
||||||
|
result_number: number
|
||||||
|
total_win: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JackpotHitEventDataDto {
|
||||||
|
hits: JackpotHitItemDto[]
|
||||||
|
period_id: number | null
|
||||||
|
period_no: string
|
||||||
|
result_number: number | null
|
||||||
|
server_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JackpotHitEventDto {
|
||||||
|
data: JackpotHitEventDataDto
|
||||||
|
event: 'jackpot.hit'
|
||||||
|
server_time: number
|
||||||
|
topic?: 'jackpot.hit'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetWinItemDto {
|
||||||
|
bet_id: number
|
||||||
|
win_amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetWinEventDataDto {
|
||||||
|
balance_after?: string
|
||||||
|
bets: BetWinItemDto[]
|
||||||
|
current_streak?: number
|
||||||
|
is_jackpot: boolean
|
||||||
|
is_win: boolean
|
||||||
|
odds_factor?: number
|
||||||
|
payout_pending_review: boolean
|
||||||
|
period_id?: number
|
||||||
|
period_no: string
|
||||||
|
result_number: number | null
|
||||||
|
server_time?: number
|
||||||
|
streak_level?: number
|
||||||
|
total_win: string
|
||||||
|
user_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BetWinEventDto {
|
||||||
|
data: BetWinEventDataDto
|
||||||
|
event: 'bet.win'
|
||||||
|
server_time: number
|
||||||
|
topic?: 'bet.win'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameBetOrderDto {
|
||||||
|
bet_amount: string
|
||||||
|
create_time: number
|
||||||
|
numbers: number[]
|
||||||
|
order_no: string
|
||||||
|
period_no: string
|
||||||
|
result_number: number | null
|
||||||
|
status: string
|
||||||
|
total_amount: string
|
||||||
|
win_amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameBetOrdersPaginationDto {
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameBetOrdersDto {
|
||||||
|
list: GameBetOrderDto[]
|
||||||
|
pagination: GameBetOrdersPaginationDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePlaceBetRequestDto {
|
||||||
|
bet_amount?: string
|
||||||
|
bet_id: number
|
||||||
|
idempotency_key: string
|
||||||
|
numbers: string
|
||||||
|
period_no: string
|
||||||
|
single_bet_amount?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePlaceBetDto {
|
||||||
|
balance_after: string
|
||||||
|
current_streak: number
|
||||||
|
locked_balance?: string
|
||||||
|
numbers_count: number
|
||||||
|
order_no: string
|
||||||
|
period_no: string
|
||||||
|
status: 'accepted' | 'rejected' | (string & {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePeriodHistoryItemDto {
|
||||||
|
open_time: number
|
||||||
|
period_no: string
|
||||||
|
result_number: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store State Types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type GameRoundSlice = Pick<
|
||||||
|
GameBootstrapSnapshot,
|
||||||
|
| 'cells'
|
||||||
|
| 'chips'
|
||||||
|
| 'history'
|
||||||
|
| 'maxSelectionCount'
|
||||||
|
| 'round'
|
||||||
|
| 'selections'
|
||||||
|
| 'trends'
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface GameRoundStoreState extends GameRoundSlice {
|
||||||
|
activeChipId: string
|
||||||
|
activeBetQuantity: number
|
||||||
|
adjustBetQuantity: (delta: number) => void
|
||||||
|
clearSelections: () => void
|
||||||
|
clearRewardAnimation: () => void
|
||||||
|
finishRevealAnimation: () => void
|
||||||
|
hydrateRound: (snapshot: GameRoundSlice) => void
|
||||||
|
placeBet: (cellId: number) => void
|
||||||
|
playPreparedRevealAnimation: (roundId?: string | null) => void
|
||||||
|
prepareRevealAnimation: (input: {
|
||||||
|
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
|
||||||
|
setPendingBetWinReward: (input: {
|
||||||
|
isJackpot: boolean
|
||||||
|
revealKey: string
|
||||||
|
roundId?: string | null
|
||||||
|
totalWin: string
|
||||||
|
winningCellId?: number | null
|
||||||
|
}) => void
|
||||||
|
syncRound: (round: Partial<RoundSnapshot>) => void
|
||||||
|
upsertSelections: (selections: BetSelection[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameRoundStoreData = Pick<
|
||||||
|
GameRoundStoreState,
|
||||||
|
| 'cells'
|
||||||
|
| 'chips'
|
||||||
|
| 'history'
|
||||||
|
| 'maxSelectionCount'
|
||||||
|
| 'round'
|
||||||
|
| 'selections'
|
||||||
|
| 'trends'
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface GameSessionStoreState {
|
||||||
|
announcements: AnnouncementState
|
||||||
|
connection: ConnectionState
|
||||||
|
dashboard: DashboardState
|
||||||
|
dismissAnnouncement: (announcementId: string) => void
|
||||||
|
hydrateSession: (snapshot: {
|
||||||
|
announcements: AnnouncementState
|
||||||
|
connection: ConnectionState
|
||||||
|
dashboard: DashboardState
|
||||||
|
}) => void
|
||||||
|
jackpotBroadcasts: JackpotBroadcastItem[]
|
||||||
|
markAnnouncementRead: (announcementId: string) => void
|
||||||
|
pushJackpotBroadcasts: (
|
||||||
|
broadcasts: Omit<JackpotBroadcastItem, 'receivedAt'>[],
|
||||||
|
) => void
|
||||||
|
requestRealtimeConnection: () => void
|
||||||
|
resetRealtimeConnectionRequest: () => void
|
||||||
|
shouldConnectRealtime: boolean
|
||||||
|
setConnectionLatency: (latencyMs: number | null) => void
|
||||||
|
setConnectionStatus: (status: ConnectionStatus) => void
|
||||||
|
syncConnection: (patch: Partial<ConnectionState>) => void
|
||||||
|
syncDashboard: (patch: Partial<DashboardState>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameSessionStoreData = Pick<
|
||||||
|
GameSessionStoreState,
|
||||||
|
'announcements' | 'connection' | 'dashboard' | 'jackpotBroadcasts'
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface StartAutoHostingInput {
|
||||||
|
balanceAfterBet: number | null
|
||||||
|
rules: AutoHostingStopRules
|
||||||
|
selections: BetSelection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameAutoHostingStoreState {
|
||||||
|
balanceAfterBet: number | null
|
||||||
|
completedRounds: number
|
||||||
|
isHosting: boolean
|
||||||
|
lastIsJackpot: boolean | null
|
||||||
|
lastSingleWinAmount: number | null
|
||||||
|
lastSubmittedRoundId: string | null
|
||||||
|
rules: AutoHostingStopRules
|
||||||
|
selections: BetSelection[]
|
||||||
|
markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void
|
||||||
|
recordBetWin: (input: {
|
||||||
|
isJackpot: boolean
|
||||||
|
singleWinAmount: number | null
|
||||||
|
}) => void
|
||||||
|
startHosting: (input: StartAutoHostingInput) => void
|
||||||
|
stopHosting: () => void
|
||||||
|
}
|
||||||
@@ -1,17 +1,4 @@
|
|||||||
export type WithdrawTopupType = 'withdraw' | 'topup'
|
export * from './api.type'
|
||||||
|
export * from './auth.type'
|
||||||
/** @description 后端统一响应体结构。 */
|
export * from './game.type'
|
||||||
export interface ApiResponse<T> {
|
export * from './system.type'
|
||||||
code: number
|
|
||||||
msg?: string
|
|
||||||
data: T
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @description 后端统一错误响应体结构。 */
|
|
||||||
export interface ApiErrorOptions {
|
|
||||||
message: string
|
|
||||||
status?: number
|
|
||||||
data?: unknown
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
|
|||||||
54
src/type/system.type.ts
Normal file
54
src/type/system.type.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { MODAL_KEYS, SUPPORTED_LANGUAGES } from '@/constants'
|
||||||
|
|
||||||
|
export type AppLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
||||||
|
|
||||||
|
export type ModalKey = (typeof MODAL_KEYS)[number]
|
||||||
|
|
||||||
|
export type WithdrawTopupType = 'withdraw' | 'topup'
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| 'success'
|
||||||
|
| 'error'
|
||||||
|
| 'warning'
|
||||||
|
| 'info'
|
||||||
|
| 'loading'
|
||||||
|
|
||||||
|
export interface AudioPreferenceState {
|
||||||
|
hasUnlockedSoundPlayback: boolean
|
||||||
|
markSoundPlaybackUnlocked: () => void
|
||||||
|
isSoundEnabled: boolean
|
||||||
|
setSoundEnabled: (enabled: boolean) => void
|
||||||
|
toggleSoundEnabled: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyOptions {
|
||||||
|
description?: string
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentMetadata {
|
||||||
|
description?: string
|
||||||
|
robots?: string
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_ENV: 'development' | 'production' | 'test'
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
readonly VITE_WEBSOCKET_URL?: string
|
||||||
|
readonly VITE_ENABLE_QUERY_DEVTOOLS: 'true' | 'false'
|
||||||
|
readonly VITE_ENABLE_REQUEST_LOG: 'true' | 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalStoreState {
|
||||||
|
modals: Record<ModalKey, boolean>
|
||||||
|
withdrawTopupType: WithdrawTopupType
|
||||||
|
closeAllModals: () => void
|
||||||
|
openExclusiveModal: (key: ModalKey) => void
|
||||||
|
setModalOpen: (key: ModalKey, open: boolean) => void
|
||||||
|
setWithdrawTopupType: (type: WithdrawTopupType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequireAuthenticatedSessionOptions {
|
||||||
|
fallbackLanguage?: AppLanguage
|
||||||
|
}
|
||||||
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@@ -1,13 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
|
||||||
readonly VITE_APP_ENV: 'development' | 'production' | 'test'
|
|
||||||
readonly VITE_API_BASE_URL: string
|
|
||||||
readonly VITE_WEBSOCKET_URL?: string
|
|
||||||
readonly VITE_ENABLE_QUERY_DEVTOOLS: 'true' | 'false'
|
|
||||||
readonly VITE_ENABLE_REQUEST_LOG: 'true' | 'false'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user