feat: 新增首页和图片资源

This commit is contained in:
JiaJun
2026-04-24 18:02:42 +08:00
parent bd92f10b83
commit 9127a06d4a
179 changed files with 3424 additions and 101 deletions

View File

@@ -0,0 +1,880 @@
# 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 获取接口鉴权 Tokenauth-token
- **GET** `/api/v1/authToken`
- 用途:获取 `auth-token`(所有接口请求头必带)
请求示例:
`/api/v1/authToken?secret=564d14asdasd113e46542asd6das1a2a&timestamp=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 位小数字符串,与 `/api/wallet/balanceSummary` 对齐):
**基础档案**
- `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含义当前余额两字段同值便于与 `/api/wallet/balanceSummary` 平滑切换)
- `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含义打码量 / 提现配额诊断快照,结构与 `/api/wallet/balanceSummary.withdraw_flow` 一致,此处额外附 `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合法范围 136
- `chips`array[string](如 `["1.00","5.00"]`,含义:快捷筹码面额)
- `single_number_max_bet`string含义单号码最大下注额
- `dictionary`array<object>
- `number`int1-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字花字典明细
### 3.3 获取最近开奖记录近30期
- **POST** `/api/game/periodHistory`
请求参数:
- `limit`int可选默认 30含义返回最近几期
返回参数:
- `list`array<object>
- `period_no`string含义历史期号
- `result_number`int含义该期开奖结果
- `open_time`int含义开奖时间
---
## 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/betPlace`
- 用途:单期手动下注;玩家只需选择**压注号码**与**本笔压注总金额**。开奖只出一个号码,若该号码 ∈ 所选号码集合即视为**中奖**,派彩按整笔 `bet_amount`(落库为 `total_amount`× 赔率计算(赔率与连胜倍率见服务端实现)。
请求参数:
- `period_no`string含义下注目标期号
- `numbers`string含义本次压注号码集合**英文逗号分隔**,如 `1,8,16`;每个号码为 136 的整数,数量不超过 `pick_max_number_count`(同 `lobbyInit.bet_config`),重复号码会去重)
- `bet_amount`string含义**本笔整笔压注金额**> 0服务端按此金额从余额扣款并写入注单 `total_amount`**不再**按「单号金额 × 号码个数」计算)
- `idempotency_key`string必填含义防止重复下单
返回参数:
- `order_no`string含义下注订单号
- `period_no`string含义实际落单期号
- `status`string`accepted`/`rejected`,含义:受理结果)
- `locked_balance`string可选含义冻结金额
- `balance_after`string含义下单后余额
- `current_streak`int含义下单后连胜快照
**可能错误码(补充)**(其余见文档头部错误码分段;扣款与缓存一致性强相关):
- `3001`:游戏已暂停(`runtime_enabled=false`,后台「游戏实时对局」关闭运行开关或作废本局后未重新开启;与非法流程类错误同段)
- `5000`:系统繁忙;或 **用户 Redis 互斥锁**未获取(与后台钱包/并发写同一用户串行,文案与后台一致:「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」);或 **`coin` 条件更新**未命中(并发下注/派彩/后台已改余额:「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」)。
> 说明:一键重复上一注、自动托管开启/停止均由前端控制,客户端在相应时机调用 `/api/game/betPlace` 即可完成,不再提供独立接口。
### 4.3 查询我的下注记录最近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 获取钱包摘要
- **POST** `/api/wallet/balanceSummary`
返回参数:
- `coin_balance`string含义可用余额等同于 `user.coin`
- `frozen_balance`string含义冻结余额当前系统无冻结场景固定返回 `0.00`
- `withdrawable_balance`string含义可提现余额等同于 `coin_balance`**单笔实际上限以 `max_withdrawable` 为准**
- `max_withdrawable`string含义**当前允许发起的单笔最大提现金额** = min(`coin_balance`, 打码配额余量);客户端直接用于"最大可提 XXX"提示与金额输入上限校验)
- `total_deposit_coin`string含义累计充值币额
- `total_withdraw_coin`string含义累计提现币额提现受理时累加
- `bet_flow_coin`string含义打码量/流水;开奖结算后按每注 `total_amount` 1:1 累加)
- `withdraw_flow`object含义打码量 / 提现配额诊断快照,供前端展示说明与上限推导)
- `ratio`string含义打码量倍数来自 `game_config.withdraw_bet_flow_ratio`,默认 `1.00``0` 表示"不限打码"
- `net_deposit`string含义净充值 = max(0, 累计充值 - 累计提现)
- `required_bet_flow`string含义按门槛口径所需的打码量 = 净充值 × 倍数,纯展示)
- `remaining_bet_flow`string含义按门槛口径还差多少打码量纯展示已达标为 `0.00`
- `eligible`bool含义是否满足整体门槛纯展示实际放行以 `max_withdrawable` 为准)
- `max_withdraw_by_flow`string/null含义仅按打码量折算的单笔可提上限 = max(0, `bet_flow_coin` / `ratio` - `total_withdraw_coin`)`ratio=0` 不限打码时返回 `null`
- `flow_unlimited`bool含义是否处于"不限打码"状态,`ratio=0` 时为 `true`
说明(打码量即提现配额模型):
- 每笔提现按 `withdraw_coin × ratio` 消耗打码配额;`total_withdraw_coin` 已累积历史消耗。
- **单笔最大可提现 `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`**`ratio=0` 时退化为仅受余额约束。
- 倍数 `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`int1入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`string2 位小数,含义:对外标价金额,与业务配置一致;展示用)
- `amount`string2 位小数,含义:玩家本次需支付的充值金额)
- `bonus_amount`string2 位小数,含义:该档位赠送金额,无赠送为 `0.00`
- `total_amount`string2 位小数,含义:到账总额 = 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`**模拟第三方收银台** 完整 URLHMAC 防篡改,见下 §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`string2 位小数,含义:玩家本次支付的充值金额,与所选档位 `amount` 一致)
- `bonus_amount`string2 位小数,含义:本次赠送金额,与所选档位 `bonus_amount` 一致,无赠送为 `0.00`
- `total_amount`string2 位小数,含义:实际入账总额 = 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`string2 位小数,含义:本单充值金额)
- `bonus_amount`string2 位小数,含义:本单赠送金额,无赠送为 `0.00`
- `total_amount`string2 位小数,含义:入账总额)
- `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`string2 位小数,含义:本单充值金额)
- `bonus_amount`string2 位小数,含义:本单赠送金额,无赠送为 `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`string2 位小数,含义:申请提现金额,与后台 `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. 推送模块webman/push
> 用于移动端实时监听对局状态、开奖结果、余额变更与强公告事件。
> 协议与客户端行为对齐 [Pusher Channels](https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/)webman/push 内置兼容客户端 `push.js`)。
> 参考:[webman/push 官方文档](https://www.workerman.net/doc/webman/plugin/push.html)
### 7.1 频道命名与职责(优化版)
| 频道名 | 类型 | 订阅方 | 典型事件 |
|--------|------|--------|----------|
| `private-user-{user.uuid}` | 私有(`private-` 前缀) | 当前登录用户;`{user.uuid}` 与登录态/档案中的 **10 位 `uuid`** 一致 | `bet.accepted``wallet.changed``withdraw.review_required`、定向 `notice.popout` 等 |
| `public-game-period` | 公共 | 所有在线客户端 | `period.tick``period.locked``period.opened` |
| `public-operation-notice` | 公共 | 所有在线客户端 | 全站/渠道级 `notice.popout`(与私有公告二选一或并存,由实现约定) |
约定说明:
- **用户私有频道一律使用对外标识 `uuid`,不使用数据库主键 `user_id`**,避免与后台、日志、多端展示口径不一致,并降低枚举内网 ID 的风险。
- 名称以 `private-` 开头的频道必须通过 **私有频道鉴权**(见 7.2)成功后才能收到服务端推送。
- `public-*` 可直接订阅,无需鉴权 HTTP 步骤。
### 7.2 连接地址与鉴权流程
**WebSocket 连接 URL与官方 `push.js` 一致)**
- 形如:`{websocket_base}/app/{app_key}`
- 示例(本地默认配置见 `config/plugin/webman/push/app.php``ws://127.0.0.1:3131/app/{app_key}`
- 生产环境请改为 `wss://` 与对外域名,并与网关/证书一致。
**连接建立后的协议步骤(简述)**
1. 客户端建立 WebSocket服务端下发 `pusher:connection_established`payload 内含 **`socket_id`**(后续鉴权必填)。
2. 订阅 **公共** 频道:发送 `pusher:subscribe``data` 仅含 `channel` 名即可。
3. 订阅 **私有** 频道:
- 客户端向 **鉴权接口** 发起 `POST``Content-Type: application/x-www-form-urlencoded`),表单字段:`channel_name``socket_id`
- 默认鉴权路径为 **`/plugin/webman/push/auth`**(与 `config/plugin/webman/push/app.php``auth` 一致,可随部署调整)。
- 服务端校验「当前登录用户是否允许订阅该 `channel_name`」——对 `private-user-{uuid}` 应校验 **`uuid` 与当前用户一致**,否则返回 `403`
- 鉴权成功返回的 JSON 由 `push.js` 原样作为 `pusher:subscribe``data` 发送(含 `auth` 等字段)。
**与移动端登录态的关系**
- 客户端在调用鉴权接口时,除 `channel_name` / `socket_id` 外,需携带与 REST API 一致的 **`user-token`(及业务所需的 `auth-token`**,由服务端解析用户身份后再比对 `private-user-{uuid}`
- **不建议**依赖浏览器 Cookie Session 作为唯一依据H5 外还有 App 内嵌、小程序等);若仅沿用框架示例中的 Session需在落地实现中改为 **无状态 token 校验**
### 7.3 事件定义(初设)
| 事件名 | 建议频道 | 说明 |
|--------|----------|------|
| `period.tick` | `public-game-period` | 倒计时广播 |
| `period.locked` | `public-game-period` | 封盘 |
| `period.opened` | `public-game-period` | 开奖完成(中奖号码) |
| `bet.accepted` | `private-user-{uuid}` | 下注成功回执 |
| `bet.settled` | `private-user-{uuid}` | **每期每用户一条**:该局开奖对该用户全部注单的汇总(`total_win_amount``order_count``hit_order_count``result_number``balance_after`;不再按单笔注单重复推送) |
| `wallet.changed` | `private-user-{uuid}` | 余额变化(中奖派彩入账等;`reason=payout` 等) |
| `notice.popout` | `public-operation-notice``private-user-{uuid}` | 强公告(按业务选择广播或定向) |
| `withdraw.review_required` | `private-user-{uuid}` | 提现进入审核 |
### 7.4 消息形态(客户端解析)
连接上收到的单帧一般为 JSON常见两类
- 协议类:`event``pusher:connection_established``pusher_internal:subscription_succeeded` 等。
- 业务类:`event` 为业务事件名,`channel` 为频道名,`data` 为负载(可能为字符串化的 JSON客户端需 `JSON.parse` 一次)。
业务负载示例(与初设一致,字段以实际实现为准):
```json
{
"event": "period.opened",
"channel": "public-game-period",
"data": {
"period_no": "20260416001",
"result_number": 18,
"open_time": 1776326400
}
}
```
### 7.5 降级与一致性
- 推送仅作 **体验增强**:断线、弱网时客户端仍应以 **HTTP 轮询/用户主动刷新**(如 `/api/game/periodCurrent``/api/wallet/balanceSummary`)为准。
- 同一业务状态以 **服务端落库与接口查询** 为最终一致;推送到达顺序不保证与业务因果严格一致,需客户端幂等与去重(可带 `period_no` / `order_no` / 时间戳)。
### 7.6 使用 Apipost 调试 WebSocket 与私有频道
Apipostv7+)支持 **WebSocket**:新建请求 → 选择 **WebSocket** → 类型选 **Raw**。私有频道遵循「先拿 `socket_id` → 再 HTTP 鉴权 → 再发 `pusher:subscribe`」,与 `vendor/webman/push/src/push.js` 行为一致。
**A. 仅调试公共频道(如 `public-game-period`**
1. 启动 webman 与 push 进程,确认 `config/plugin/webman/push/app.php``websocket``app_key`
2. 在 Apipost 中 WebSocket URL 填:`ws://127.0.0.1:3131/app/{app_key}`(将 `{app_key}` 换成配置中的真实值)。
3. 点击连接,在消息面板应收到一帧 `pusher:connection_established`,从中取出 `socket_id`(公共订阅可不依赖后续步骤,但便于对照协议)。
4. 在发送框填入一行 JSON勿带代码块标记并发送
`{"event":"pusher:subscribe","data":{"channel":"public-game-period"}}`
5. 成功时随后会收到 `pusher_internal:subscription_succeeded`;之后服务端向该频道 `trigger` 的事件会出现在消息列表中。
**B. 调试用户私有频道 `private-user-{uuid}`**
1. 同上先连接,从首帧解析出 **`socket_id`**。
2. 新建 **HTTP** 请求:`POST http://{你的HTTP入口}/plugin/webman/push/auth`
- Header`Content-Type: application/x-www-form-urlencoded`
- Bodyx-www-form-urlencoded`channel_name=private-user-{替换为真实uuid}&socket_id={上一步的socket_id}`
- 若鉴权已接入 `user-token`,请在 Header 中一并带上与移动端一致的 **`user-token`**(及 `auth-token` 等),否则会得到 `403` 或无效签名。
3. 将接口返回的 **JSON 正文**(整段)作为 `pusher:subscribe``data`:在 Apipost WebSocket 发送
`{"event":"pusher:subscribe","data": <上一步响应 JSON 对象>}`
注意:`push.js` 会把鉴权返回与 `channel` 字段合并后再发送;若手搓 JSON需保证与官方协议一致`auth` 字段)。
4. 订阅成功后即可在消息面板等待该私有频道上的业务事件。
**说明**:若仅做协议连通性验证,可暂时使用服务端对鉴权接口的占位实现;**上线前**必须落实「`channel_name` 与当前用户 `uuid` 匹配」校验,避免越权订阅。
---
## 8. 移动端完整调用流程
## 8.1 首次进入游戏
1. `GET /api/v1/authToken?secret=xxx&timestamp=xxx&device_id=xxx&signature=xxx` 获取 `auth-token`
2. `POST /api/user/login` 登录(请求头带 `auth-token`
3. `POST /api/game/lobbyInit` 拉首页初始化(请求头带 `auth-token`
3. 建立 webman/push 连接并订阅:
- `public-game-period`
- `private-user-{user.uuid}``uuid` 取自登录/档案接口,与 7.1 一致)
4. 收到 `period.tick` 实时刷新倒计时
5. 用户下注调用 `POST /api/game/betPlace`
6. 监听 `bet.accepted` + `wallet.changed` 更新下注结果和余额
7. 监听 `period.opened` 渲染开奖动画并刷新开奖记录
## 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/betPlace`
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. 游戏时序流程图(接口 + 推送)
```mermaid
flowchart TD
A[用户登录 /api/user/login] --> B[拉初始化 /api/game/lobbyInit]
B --> C[连接webman/push并订阅频道]
C --> D[收到 period.tick 倒计时]
D --> E{0-20秒下注期?}
E -- 是 --> F[提交下注 /api/game/betPlace]
F --> G[推送 bet.accepted + wallet.changed]
E -- 否 --> H[进入封盘状态 period.locked]
H --> I[服务端算票与开奖]
I --> J[推送 period.opened]
J --> K[客户端开奖动画与结果展示]
K --> L[客户端刷新开奖记录 /api/game/periodHistory]
L --> D
```
---
## 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. **push事件最小集**:是否先只上 `period.tick``period.opened``wallet.changed` 三类。
5. **错误码规范**:是否已有公司统一错误码表;若有需对齐替换本草案码段。
确认后可进入下一步:按该文档落地 controller + validate + service + 路由。

View File

@@ -0,0 +1,373 @@
# 本次代码变更说明
本文档总结当前这一次工作中,对仓库代码所做的主要改动,重点说明:
- 新增了哪些文件
- 修改了哪些已有文件
- 每个模块新增了什么能力
- 当前已经验证过什么
说明:
- 下文基于当前 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/index.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/index.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/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/auth/index.ts)
说明:
- 统一导出认证模块 store
#### [src/store/game/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/game/index.ts)
说明:
- 统一导出游戏模块 store
#### [src/store/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/store/index.ts)
新增内容:
- 统一导出 `src/store` 下各模块
- 当前包括 `auth``game`
#### [src/features/game/model/index.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/index.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/index.ts](/Users/jiaunun/Desktop/36-character-flower/src/features/game/components/index.ts)
新增内容:
- 对组件模块统一导出
### 3.8 游戏入口与总导出
#### [src/features/game/entry/game-route-page.tsx](/Users/jiaunun/Desktop/36-character-flower/src/features/game/entry/game-route-page.tsx)
新增内容:
- 游戏路由页适配层
- 负责:
- 读取 Zustand 状态
- 拉 mock bootstrap
- 组装状态卡片、历史、走势、面板内容
- 根据设备类型切换移动端 / 桌面端壳层
- 接公告弹窗、自动托管遮罩
- 使用 i18n 文案
#### [src/features/game/index.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/index.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/game-route-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/game-route-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` 可正常打开

View File

@@ -6,23 +6,23 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" name="description"
content="A frontend scaffold with Vite, React, TanStack Router, TanStack Query, Zustand, and Biome." content="36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand."
/> />
<meta name="robots" content="index,follow" /> <meta name="robots" content="index,follow" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="React SPA Template" /> <meta property="og:site_name" content="36字花" />
<meta property="og:title" content="React SPA Template" /> <meta property="og:title" content="36字花" />
<meta <meta
property="og:description" property="og:description"
content="A frontend scaffold with Vite, React, TanStack Router, TanStack Query, Zustand, and Biome." content="36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand."
/> />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="React SPA Template" /> <meta name="twitter:title" content="36字花" />
<meta <meta
name="twitter:description" name="twitter:description"
content="A frontend scaffold with Vite, React, TanStack Router, TanStack Query, Zustand, and Biome." content="36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand."
/> />
<title>React SPA Template</title> <title>36字花</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -30,6 +30,7 @@
"@tanstack/react-router": "^1.168.22", "@tanstack/react-router": "^1.168.22",
"i18next": "^26.0.5", "i18next": "^26.0.5",
"ky": "^2.0.1", "ky": "^2.0.1",
"lucide-react": "^1.9.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^17.0.3", "react-i18next": "^17.0.3",

12
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
ky: ky:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1 version: 2.0.1
lucide-react:
specifier: ^1.9.0
version: 1.9.0(react@19.2.5)
react: react:
specifier: ^19.2.4 specifier: ^19.2.4
version: 19.2.5 version: 19.2.5
@@ -1529,6 +1532,11 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.9.0:
resolution: {integrity: sha512-6qVAmbgCjcJz7sAGSPSSJ++RAwjlK2XCbRrZKv63Ciko1KT8jX0//CXxgI3jg2HlJu8tADqdYlNDebmYjeoruA==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3311,6 +3319,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.9.0(react@19.2.5):
dependencies:
react: 19.2.5
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5

BIN
src/assets/animal/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
src/assets/animal/10.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
src/assets/animal/11.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
src/assets/animal/12.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
src/assets/animal/13.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
src/assets/animal/14.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
src/assets/animal/15.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src/assets/animal/16.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
src/assets/animal/17.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
src/assets/animal/18.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
src/assets/animal/19.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
src/assets/animal/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/animal/20.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
src/assets/animal/21.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
src/assets/animal/22.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
src/assets/animal/23.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
src/assets/animal/24.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
src/assets/animal/25.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/animal/26.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/assets/animal/27.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
src/assets/animal/28.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
src/assets/animal/29.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/assets/animal/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
src/assets/animal/30.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
src/assets/animal/31.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/animal/32.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
src/assets/animal/33.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
src/assets/animal/34.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/animal/35.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
src/assets/animal/36.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/animal/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
src/assets/animal/5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/animal/6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
src/assets/animal/7.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
src/assets/animal/8.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
src/assets/animal/9.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
src/assets/slices.zip Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
src/assets/slices/1@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
src/assets/slices/2@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
src/assets/slices/3@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
src/assets/slices/4@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
src/assets/slices/5@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
src/assets/slices/6@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
src/assets/slices/7@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
src/assets/slices/8@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
src/assets/slices/9@2x.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
src/assets/system/a.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
src/assets/system/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
src/assets/system/wifi.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,35 @@
import type { AppLanguage } from '@/i18n'
interface LanguageLinkProps {
currentPathname: string
isActive: boolean
label: string
language: AppLanguage
}
export function LanguageLink({
currentPathname,
isActive,
label,
language,
}: LanguageLinkProps) {
const nextPathname = currentPathname.replace(
/^\/(zh-CN|en-US)(?=\/|$)/,
`/${language}`,
)
return (
<a
href={nextPathname}
className={`inline-flex min-h-10 items-center rounded-full border px-3 text-xs font-medium transition ${
isActive
? 'border-amber-300/30 bg-amber-300/14 text-amber-100'
: 'border-white/10 bg-white/5 text-slate-300 hover:border-cyan-300/20 hover:text-white'
}`}
hrefLang={language}
lang={language}
>
{label}
</a>
)
}

View File

@@ -0,0 +1,197 @@
import {
type CSSProperties,
forwardRef,
type ImgHTMLAttributes,
type ReactNode,
useEffect,
useMemo,
useState,
} from 'react'
const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
export interface SmartImageProps
extends Omit<
ImgHTMLAttributes<HTMLImageElement>,
'alt' | 'children' | 'className' | 'loading' | 'src'
> {
src?: string | null
alt: string
className?: string
imgClassName?: string
skeletonClassName?: string
fallbackClassName?: string
fallback?: ReactNode
fallbackSrc?: string
placeholderSrc?: string
loading?: 'eager' | 'lazy'
priority?: boolean
showSkeleton?: boolean
aspectRatio?: CSSProperties['aspectRatio']
}
export const SmartImage = forwardRef<HTMLImageElement, SmartImageProps>(
function SmartImage(
{
src,
alt,
className,
imgClassName,
skeletonClassName,
fallbackClassName,
fallback,
fallbackSrc,
placeholderSrc,
loading,
priority = false,
showSkeleton = true,
aspectRatio,
draggable = false,
decoding,
fetchPriority,
onError,
onLoad,
style,
...imgProps
},
ref,
) {
const normalizedSrc = src ?? ''
const initialState = useMemo(
() => ({
currentSrc: normalizedSrc,
hasError: normalizedSrc.length === 0,
isLoaded: false,
retriedWithFallback: false,
}),
[normalizedSrc],
)
const [currentSrc, setCurrentSrc] = useState(initialState.currentSrc)
const [isLoaded, setIsLoaded] = useState(initialState.isLoaded)
const [hasError, setHasError] = useState(initialState.hasError)
const [retriedWithFallback, setRetriedWithFallback] = useState(
initialState.retriedWithFallback,
)
useEffect(() => {
setCurrentSrc(initialState.currentSrc)
setIsLoaded(initialState.isLoaded)
setHasError(initialState.hasError)
setRetriedWithFallback(initialState.retriedWithFallback)
}, [initialState])
const resolvedLoading = priority ? 'eager' : (loading ?? 'lazy')
const resolvedFetchPriority = priority ? 'high' : (fetchPriority ?? 'auto')
const shouldShowVisualPlaceholder = !isLoaded && !hasError
const showFallback = hasError && !currentSrc
return (
<div
className={cx('relative overflow-hidden', className)}
style={aspectRatio ? { aspectRatio } : undefined}
>
{placeholderSrc ? (
<img
src={placeholderSrc}
alt=""
aria-hidden="true"
className={cx(
'pointer-events-none absolute inset-0 h-full w-full object-cover transition duration-300',
shouldShowVisualPlaceholder
? 'scale-105 opacity-100 blur-xl'
: 'opacity-0',
)}
/>
) : null}
{showSkeleton && shouldShowVisualPlaceholder ? (
<div
aria-hidden="true"
className={cx(
'absolute inset-0 animate-pulse bg-gradient-to-br from-slate-800 via-slate-700/70 to-slate-800',
skeletonClassName,
)}
/>
) : null}
{currentSrc ? (
<img
ref={ref}
src={currentSrc}
alt={alt}
loading={resolvedLoading}
decoding={decoding ?? 'async'}
draggable={draggable}
fetchPriority={resolvedFetchPriority}
className={cx(
'relative z-[1] h-full w-full object-cover transition duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
imgClassName,
)}
style={style}
onLoad={(event) => {
setIsLoaded(true)
setHasError(false)
onLoad?.(event)
}}
onError={(event) => {
onError?.(event)
if (
fallbackSrc &&
!retriedWithFallback &&
currentSrc !== fallbackSrc
) {
setCurrentSrc(fallbackSrc)
setIsLoaded(false)
setHasError(false)
setRetriedWithFallback(true)
return
}
setCurrentSrc('')
setIsLoaded(false)
setHasError(true)
}}
{...imgProps}
/>
) : null}
{showFallback
? (fallback ?? (
<div
className={cx(
'absolute inset-0 flex items-center justify-center bg-slate-950/80 px-4 text-center text-xs font-medium tracking-[0.12em] text-slate-300 uppercase',
fallbackClassName,
)}
>
Image unavailable
</div>
))
: null}
</div>
)
},
)
/**
* <SmartImage
* src={bannerUrl}
* alt="banner"
* className="h-48 w-full rounded-3xl"
* imgClassName="object-cover"
* placeholderSrc={bannerBlurUrl}
* fallbackSrc="/images/banner-fallback.jpg"
* />
*
* 如果是首屏主视觉图:
*
* <SmartImage
* src={heroUrl}
* alt="hero"
* priority
* className="aspect-[16/9] w-full rounded-[32px]"
* />
* */

View File

@@ -2,11 +2,11 @@
export const APP_ROOT_ELEMENT_ID = 'root' export const APP_ROOT_ELEMENT_ID = 'root'
/** @description 应用名称,用于文档标题和分享元信息。 */ /** @description 应用名称,用于文档标题和分享元信息。 */
export const APP_NAME = 'React SPA Template' export const APP_NAME = '36字花'
/** @description 应用默认的页面描述,用于 SEO 和分享卡片。 */ /** @description 应用默认的页面描述,用于 SEO 和分享卡片。 */
export const APP_DEFAULT_DESCRIPTION = export const APP_DEFAULT_DESCRIPTION =
'A frontend scaffold with Vite, React, TanStack Router, TanStack Query, Zustand, and Biome.' '36-character-flower real-time game portal built with React, TanStack Router, TanStack Query, and Zustand.'
/** @description 认证状态持久化到浏览器时使用的存储键。 */ /** @description 认证状态持久化到浏览器时使用的存储键。 */
export const AUTH_STORAGE_KEY = 'auth-session' export const AUTH_STORAGE_KEY = 'auth-session'
@@ -39,3 +39,6 @@ export const QUERY_RETRYABLE_STATUS_CODES = [
/** @description 国际化语言设置持久化到浏览器时使用的存储键。 */ /** @description 国际化语言设置持久化到浏览器时使用的存储键。 */
export const I18N_LANGUAGE_STORAGE_KEY = 'app-language' export const I18N_LANGUAGE_STORAGE_KEY = 'app-language'
/** @description 桌面端布局切换起始断点。 */
export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024

0
src/data/animal.ts Normal file
View File

View File

@@ -0,0 +1,191 @@
import { api } from '@/lib/api/api-client'
import type {
AnnouncementItem,
AnnouncementState,
BetSelection,
ConnectionState,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundSnapshot,
TrendEntry,
} from '../shared'
import { createMockGameBootstrapSnapshot } from '../shared'
import type {
AnnouncementStateDto,
BetSelectionDto,
ChipDto,
ConnectionStateDto,
DashboardStateDto,
GameAnnouncementsDto,
GameBootstrapDto,
GameCellDto,
GameRoundFeedDto,
HistoryEntryDto,
RoundSnapshotDto,
TrendEntryDto,
} from './types'
export const GAME_API_ENDPOINTS = {
announcements: 'game/announcements',
bootstrap: 'game/bootstrap',
roundFeed: 'game/round-feed',
} as const
function normalizeGameCell(dto: GameCellDto) {
return dto satisfies GameCell
}
function normalizeChip(dto: ChipDto) {
return {
amount: dto.amount,
color: dto.color,
id: dto.id,
isDefault: dto.is_default,
label: dto.label,
}
}
function normalizeBetSelection(dto: BetSelectionDto) {
return {
amount: dto.amount,
cellId: dto.cell_id,
chipId: dto.chip_id,
id: dto.id,
placedAt: dto.placed_at,
source: dto.source,
} satisfies BetSelection
}
function normalizeRoundSnapshot(dto: RoundSnapshotDto) {
return {
bettingClosesAt: dto.betting_closes_at,
id: dto.id,
phase: dto.phase,
revealingAt: dto.revealing_at,
settledAt: dto.settled_at,
startedAt: dto.started_at,
winningCellId: dto.winning_cell_id,
} satisfies RoundSnapshot
}
function normalizeHistoryEntry(dto: HistoryEntryDto) {
return {
payoutMultiplier: dto.payout_multiplier,
roundId: dto.round_id,
settledAt: dto.settled_at,
totalPoolAmount: dto.total_pool_amount,
winningCellId: dto.winning_cell_id,
} satisfies HistoryEntry
}
function normalizeTrendEntry(dto: TrendEntryDto) {
return {
cellId: dto.cell_id,
currentStreak: dto.current_streak,
direction: dto.direction,
hitCount: dto.hit_count,
lastHitRoundId: dto.last_hit_round_id,
missCount: dto.miss_count,
} satisfies TrendEntry
}
function normalizeAnnouncementState(dto: AnnouncementStateDto) {
return {
activeAnnouncementId: dto.active_announcement_id,
items: dto.items.map(
(item) =>
({
createdAt: item.created_at,
expiresAt: item.expires_at,
id: item.id,
isPinned: item.is_pinned,
isRead: item.is_read,
message: item.message,
title: item.title,
tone: item.tone,
}) satisfies AnnouncementItem,
),
lastUpdatedAt: dto.last_updated_at,
} satisfies AnnouncementState
}
function normalizeDashboardState(dto: DashboardStateDto) {
return {
countdownMs: dto.countdown_ms,
featuredCellId: dto.featured_cell_id,
onlinePlayers: dto.online_players,
tableLimitMax: dto.table_limit_max,
tableLimitMin: dto.table_limit_min,
totalPoolAmount: dto.total_pool_amount,
updatedAt: dto.updated_at,
} satisfies DashboardState
}
function normalizeConnectionState(dto: ConnectionStateDto) {
return {
connectedAt: dto.connected_at,
lastError: dto.last_error,
lastMessageAt: dto.last_message_at,
latencyMs: dto.latency_ms,
reconnectAttempt: dto.reconnect_attempt,
status: dto.status,
transport: dto.transport,
} satisfies ConnectionState
}
export function normalizeGameBootstrap(dto: GameBootstrapDto) {
return {
announcements: normalizeAnnouncementState(dto.announcements),
cells: dto.cells.map(normalizeGameCell),
chips: dto.chips.map(normalizeChip),
connection: normalizeConnectionState(dto.connection),
dashboard: normalizeDashboardState(dto.dashboard),
history: dto.history.map(normalizeHistoryEntry),
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
} satisfies GameBootstrapSnapshot
}
export function normalizeGameRoundFeed(dto: GameRoundFeedDto) {
return {
history: dto.history.map(normalizeHistoryEntry),
round: normalizeRoundSnapshot(dto.round),
selections: dto.selections.map(normalizeBetSelection),
trends: dto.trends.map(normalizeTrendEntry),
} satisfies Pick<
GameBootstrapSnapshot,
'history' | 'round' | 'selections' | 'trends'
>
}
export async function getGameBootstrap() {
const response = await api.get<GameBootstrapDto>(GAME_API_ENDPOINTS.bootstrap)
return normalizeGameBootstrap(response.data)
}
export async function getGameRoundFeed() {
const response = await api.get<GameRoundFeedDto>(GAME_API_ENDPOINTS.roundFeed)
return normalizeGameRoundFeed(response.data)
}
export async function getGameAnnouncements() {
const response = await api.get<GameAnnouncementsDto>(
GAME_API_ENDPOINTS.announcements,
)
return normalizeAnnouncementState(response.data.announcements)
}
export async function getMockGameBootstrap(latencyMs = 120) {
await new Promise((resolve) => {
setTimeout(resolve, latencyMs)
})
return createMockGameBootstrapSnapshot()
}

View File

@@ -0,0 +1,2 @@
export * from './game-api'
export * from './types'

View File

@@ -0,0 +1,133 @@
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[]
round: RoundSnapshotDto
selections: BetSelectionDto[]
trends: TrendEntryDto[]
}
export interface GameRoundFeedDto {
history: HistoryEntryDto[]
round: RoundSnapshotDto
selections: BetSelectionDto[]
trends: TrendEntryDto[]
}
export interface GameAnnouncementsDto {
announcements: AnnouncementStateDto
}
export type {
AnnouncementState,
Chip,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
}

View File

@@ -0,0 +1,66 @@
import { SmartImage } from '@/components/smart-image'
const cx = (...classes: Array<string | false | null | undefined>) =>
classes.filter(Boolean).join(' ')
const animalModules = import.meta.glob('../../../../assets/animal/*.webp', {
eager: true,
import: 'default',
}) as Record<string, string>
const animalImageList = Object.entries(animalModules)
.map(([path, url]) => {
const match = path.match(/\/(\d+)\.webp$/)
return {
id: Number(match?.[1] ?? 0),
url,
}
})
.filter((item) => item.id > 0)
.sort((left, right) => left.id - right.id)
interface DesktopAnimalProps {
activeId?: number | null
className?: string
itemClassName?: string
imageClassName?: string
onSelect?: (animalId: number) => void
}
export function DesktopAnimal({
activeId,
className,
itemClassName,
imageClassName,
onSelect,
}: DesktopAnimalProps) {
return (
<section className={cx('grid grid-cols-6', className)}>
{animalImageList.map((item) => {
const isActive = item.id === activeId
return (
<button
key={item.id}
type="button"
onClick={() => onSelect?.(item.id)}
className={cx(
'flex flex-col items-center transition',
'cursor-pointer',
isActive &&
'border-[rgba(255,151,15,0.95)] shadow-[inset_0_0_16px_rgba(255,151,15,0.55)]',
itemClassName,
)}
>
<SmartImage
src={item.url}
alt={`animal-${item.id}`}
className={cx('h-[110px] w-[220px]', imageClassName)}
/>
</button>
)
})}
</section>
)
}

View File

@@ -0,0 +1,97 @@
import { CircleAlert, Mail, Plus, Volume2 } from 'lucide-react'
import avatar from '@/assets/system/avatar.webp'
import diamond from '@/assets/system/diamond.webp'
import logo from '@/assets/system/logo.webp'
import wifi from '@/assets/system/wifi.webp'
import { SmartImage } from '@/components/smart-image.tsx'
export function DesktopHeader() {
return (
<header className="sticky top-0 z-30 border-b border-white/8 bg-slate-950/70 backdrop-blur-xl">
<div className="h-[70px] w-full flex items-center px-[12px]">
<div className="w-[400px] flex justify-center items-center h-full border-r border-[rgba(128,223,231,0.65)]">
<SmartImage
src={logo}
alt="logo"
priority
className="w-[320px] h-[40px]"
/>
</div>
<div className="w-[148px] flex justify-center items-center gap-[10px] h-full px-[20px] border-r border-[rgba(128,223,231,0.65)]">
<SmartImage
src={wifi}
alt="wifi"
priority
className="w-[28px] h-[20px]"
/>
<div className={'text-[#74FF69] text-[20px]'}>
24 <span className={'text-[16px]'}>ms</span>
</div>
</div>
<div className="w-[175px] flex flex-col justify-center items-center gap-[5px] h-full px-[20px] border-r border-[rgba(128,223,231,0.65)]">
<div>System Time</div>
<div>20:05:12 GMT+08</div>
</div>
<div className="flex-1 flex items-center justify-around gap-[10px] h-full px-[20px] text-[#D5FBFF] border-r border-[rgba(128,223,231,0.65)]">
<div className={'flex gap-[10px] common-neon-inset'}>
<CircleAlert color={'#57B8BF'} />
<div>Rules & Ddds</div>
</div>
<div className={'flex gap-[10px] common-neon-inset'}>
<Mail color={'#57B8BF'} />
<div>Pesan</div>
</div>
<div className={'flex gap-[10px] common-neon-inset'}>
<Volume2 color={'#57B8BF'} />
<div>BGM</div>
</div>
<div className={'flex gap-[10px] common-neon-inset'}>
<CircleAlert color={'#57B8BF'} />
<div>ID</div>
</div>
</div>
<div className="flex-1 flex items-center h-full text-[#D5FBFF]">
<div className={'relative flex items-center justify-center'}>
<SmartImage
src={avatar}
alt="avatar"
priority
className="absolute left-[20px] top-0 z-20 w-[50px] h-[50px]"
/>
<div
className={
'common-neon-inset !py-[20px] flex items-center justify-center box-border w-[175px] h-[36px]'
}
>
Biomond Balance
</div>
</div>
<div className={'relative flex items-center justify-center h-full'}>
<SmartImage
src={diamond}
alt="diamond"
priority
className="absolute left-[30px] top-0 z-20 w-[50px] h-[50px]"
/>
<div
className={
'common-neon-inset !py-[20px] flex items-center justify-end gap-[10px] w-[175px] h-[36px]'
}
>
<div>5994469974</div>
<div className={'bg-[#2D4559] rounded-xs cursor-pointer p-[5px]'}>
<Plus size={16} />
</div>
</div>
</div>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,12 @@
import { Megaphone } from 'lucide-react'
export function DesktopTitle() {
return (
<section className="common-neon-inset flex items-center text-[#FF970F] flex gap-[10px] !px-[20px] h-[50px]">
<Megaphone color={'#57B8BF'} />
<div>
Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama
sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉
</div>
</section>
)
}

View File

@@ -0,0 +1,2 @@
export { DesktopHeader } from '@/features/game/components/desktop/desktop-header'
export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal'

View File

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

View File

@@ -0,0 +1,115 @@
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getMockGameBootstrap, getVisibleAnnouncements } from '@/features/game'
import { GameAnnouncementModal } from '@/features/game/components'
import { useDocumentMetadata } from '@/lib/head/document-metadata'
import { useGameRoundStore, useGameSessionStore } from '@/store/game'
const ENABLE_ANNOUNCEMENT_MODAL = false
export function GameRoutePage() {
const { t } = useTranslation()
const announcements = useGameSessionStore((state) => state.announcements)
const dismissAnnouncement = useGameSessionStore(
(state) => state.dismissAnnouncement,
)
const hydrateRound = useGameRoundStore((state) => state.hydrateRound)
const hydrateSession = useGameSessionStore((state) => state.hydrateSession)
const markAnnouncementRead = useGameSessionStore(
(state) => state.markAnnouncementRead,
)
const [isHydrating, setIsHydrating] = useState(true)
const activeAnnouncement = useMemo(
() =>
announcements.items.find(
(item) => item.id === announcements.activeAnnouncementId,
) ??
getVisibleAnnouncements(announcements)[0] ??
null,
[announcements],
)
useDocumentMetadata({
title: t('game.metaTitle'),
description: t('game.metaDescription'),
})
useEffect(() => {
let cancelled = false
void getMockGameBootstrap().then((snapshot) => {
if (cancelled) {
return
}
startTransition(() => {
hydrateRound({
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
})
hydrateSession({
announcements: snapshot.announcements,
connection: snapshot.connection,
dashboard: snapshot.dashboard,
})
setIsHydrating(false)
})
})
return () => {
cancelled = true
}
}, [hydrateRound, hydrateSession])
return (
<section
aria-busy={isHydrating}
aria-label={t('game.lobbyTitle')}
className="flex min-h-0 flex-1"
>
<GameAnnouncementModal
open={ENABLE_ANNOUNCEMENT_MODAL && Boolean(activeAnnouncement)}
eyebrow={t('game.modal.eyebrow')}
title={activeAnnouncement?.title ?? ''}
description={activeAnnouncement?.message}
primaryAction={{
label: t('game.modal.acknowledge'),
onClick: () => {
if (!activeAnnouncement) {
return
}
markAnnouncementRead(activeAnnouncement.id)
dismissAnnouncement(activeAnnouncement.id)
},
tone: 'brand',
}}
secondaryAction={{
label: t('game.modal.later'),
onClick: () => {
if (!activeAnnouncement) {
return
}
dismissAnnouncement(activeAnnouncement.id)
},
tone: 'neutral',
}}
tone="warning"
>
<div className="space-y-2 text-sm text-slate-300">
<p>{t('game.modal.line1')}</p>
<p>{t('game.modal.line2')}</p>
</div>
</GameAnnouncementModal>
</section>
)
}

View File

@@ -0,0 +1,2 @@
export * from './api'
export * from './shared'

View File

@@ -0,0 +1,61 @@
export const GAME_GRID_ROWS = 6
export const GAME_GRID_COLUMNS = 6
export const GAME_TOTAL_CELLS = GAME_GRID_ROWS * GAME_GRID_COLUMNS
export const ROUND_PHASES = [
'waiting',
'betting',
'locked',
'revealing',
'settled',
] as const
export const CELL_STATUSES = [
'idle',
'betting',
'selected',
'locked',
'won',
'lost',
] as const
export const CONNECTION_STATUSES = [
'idle',
'connecting',
'connected',
'reconnecting',
'disconnected',
] as const
export const CONNECTION_TRANSPORTS = [
'websocket',
'polling',
'offline',
] as const
export const ANNOUNCEMENT_TONES = [
'info',
'success',
'warning',
'critical',
] as const
export const BET_SOURCES = ['local', 'server'] as const
export const TREND_DIRECTIONS = ['rising', 'steady', 'falling'] as const
export const DEFAULT_GAME_CHIP_AMOUNTS = [10, 25, 50, 100, 200, 500] as const
export const DEFAULT_GAME_CHIP_COLORS = [
'#1D4ED8',
'#0F766E',
'#B45309',
'#B91C1C',
'#7C3AED',
'#111827',
] as const
export const DEFAULT_ACTIVE_CHIP_ID = 'chip-50'
export const DEFAULT_ANNOUNCEMENT_TTL_MS = 90_000
export const GAME_RECENT_HISTORY_LIMIT = 12
export const GAME_BOARD_COLUMNS = GAME_GRID_COLUMNS

View File

@@ -0,0 +1,4 @@
export * from './constants'
export * from './mock-data'
export * from './selectors'
export * from './types'

View File

@@ -0,0 +1,184 @@
import {
DEFAULT_ACTIVE_CHIP_ID,
DEFAULT_ANNOUNCEMENT_TTL_MS,
DEFAULT_GAME_CHIP_AMOUNTS,
DEFAULT_GAME_CHIP_COLORS,
GAME_GRID_COLUMNS,
GAME_TOTAL_CELLS,
} from './constants'
import { deriveTrendEntries, getRoundCountdownMs } from './selectors'
import type {
AnnouncementState,
BetSelection,
Chip,
ConnectionState,
DashboardState,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundSnapshot,
} from './types'
const MOCK_GAME_BASE_TIME = '2026-04-23T12:00:00.000Z'
const MOCK_HISTORY_RESULTS = [8, 12, 12, 4, 31, 9, 17, 22, 17, 5, 28, 13]
function offsetIso(baseIso: string, offsetMs: number) {
return new Date(Date.parse(baseIso) + offsetMs).toISOString()
}
export function createGameCells() {
return Array.from({ length: GAME_TOTAL_CELLS }, (_, index) => {
const id = index + 1
return {
column: (index % GAME_GRID_COLUMNS) + 1,
id,
label: String(id).padStart(2, '0'),
odds: 36,
row: Math.floor(index / GAME_GRID_COLUMNS) + 1,
} satisfies GameCell
})
}
export function createDefaultChips() {
return DEFAULT_GAME_CHIP_AMOUNTS.map((amount, index) => ({
amount,
color: DEFAULT_GAME_CHIP_COLORS[index],
id: `chip-${amount}`,
isDefault: `chip-${amount}` === DEFAULT_ACTIVE_CHIP_ID,
label: amount >= 100 ? `${amount / 100}x` : String(amount),
})) satisfies Chip[]
}
export function createMockHistoryEntries(baseIso = MOCK_GAME_BASE_TIME) {
return MOCK_HISTORY_RESULTS.map((winningCellId, index) => {
const settledAt = offsetIso(baseIso, -(index + 1) * 30_000)
return {
payoutMultiplier: 36,
roundId: `round-${6200 - index}`,
settledAt,
totalPoolAmount: 12_000 + index * 850,
winningCellId,
} satisfies HistoryEntry
})
}
export function createMockRoundSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
return {
bettingClosesAt: offsetIso(baseIso, 18_000),
id: 'round-6201',
phase: 'betting',
revealingAt: offsetIso(baseIso, 24_000),
settledAt: offsetIso(baseIso, 30_000),
startedAt: baseIso,
winningCellId: null,
} satisfies RoundSnapshot
}
export function createMockBetSelections(chips = createDefaultChips()) {
const defaultChip =
chips.find((chip) => chip.id === DEFAULT_ACTIVE_CHIP_ID) ?? chips[0]
return [
{
amount: defaultChip.amount,
cellId: 8,
chipId: defaultChip.id,
id: 'bet-local-1',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 4_000),
source: 'local',
},
{
amount: chips[1]?.amount ?? defaultChip.amount,
cellId: 12,
chipId: chips[1]?.id ?? defaultChip.id,
id: 'bet-server-2',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 7_000),
source: 'server',
},
{
amount: chips[3]?.amount ?? defaultChip.amount,
cellId: 17,
chipId: chips[3]?.id ?? defaultChip.id,
id: 'bet-local-3',
placedAt: offsetIso(MOCK_GAME_BASE_TIME, 10_000),
source: 'local',
},
] satisfies BetSelection[]
}
export function createMockAnnouncementState(baseIso = MOCK_GAME_BASE_TIME) {
return {
activeAnnouncementId: 'announcement-maintenance',
items: [
{
createdAt: offsetIso(baseIso, -20_000),
expiresAt: offsetIso(baseIso, DEFAULT_ANNOUNCEMENT_TTL_MS),
id: 'announcement-maintenance',
isPinned: true,
isRead: false,
message: 'Realtime sync upgrades finish after the current cycle.',
title: 'Table maintenance',
tone: 'warning',
},
{
createdAt: offsetIso(baseIso, -55_000),
expiresAt: null,
id: 'announcement-promo',
isRead: true,
message: 'Warm-up round rebates are credited every 5 settled rounds.',
title: 'Reward window live',
tone: 'success',
},
],
lastUpdatedAt: offsetIso(baseIso, -10_000),
} satisfies AnnouncementState
}
export function createMockDashboardState(
baseIso = MOCK_GAME_BASE_TIME,
round = createMockRoundSnapshot(baseIso),
history = createMockHistoryEntries(baseIso),
) {
return {
countdownMs: getRoundCountdownMs(round, baseIso),
featuredCellId: history[0]?.winningCellId ?? null,
onlinePlayers: 1_284,
tableLimitMax: 5_000,
tableLimitMin: 10,
totalPoolAmount: 84_300,
updatedAt: baseIso,
} satisfies DashboardState
}
export function createMockConnectionState(baseIso = MOCK_GAME_BASE_TIME) {
return {
connectedAt: offsetIso(baseIso, -180_000),
lastError: null,
lastMessageAt: offsetIso(baseIso, -500),
latencyMs: 48,
reconnectAttempt: 0,
status: 'connected',
transport: 'websocket',
} satisfies ConnectionState
}
export function createMockGameBootstrapSnapshot(baseIso = MOCK_GAME_BASE_TIME) {
const cells = createGameCells()
const chips = createDefaultChips()
const history = createMockHistoryEntries(baseIso)
const round = createMockRoundSnapshot(baseIso)
return {
announcements: createMockAnnouncementState(baseIso),
cells,
chips,
connection: createMockConnectionState(baseIso),
dashboard: createMockDashboardState(baseIso, round, history),
history,
round,
selections: createMockBetSelections(chips),
trends: deriveTrendEntries(history),
} satisfies GameBootstrapSnapshot
}

View File

@@ -0,0 +1,184 @@
import { GAME_RECENT_HISTORY_LIMIT, GAME_TOTAL_CELLS } from './constants'
import type {
AnnouncementState,
BetSelection,
Chip,
GameCell,
GameCellViewModel,
HistoryEntry,
RoundSnapshot,
TrendDirection,
TrendEntry,
} from './types'
export function getChipById(chips: Chip[], chipId: string) {
return chips.find((chip) => chip.id === chipId) ?? null
}
export function getSelectionTotal(selections: BetSelection[]) {
return selections.reduce((total, selection) => total + selection.amount, 0)
}
export function groupSelectionsByCell(selections: BetSelection[]) {
return selections.reduce<Record<number, { amount: number; count: number }>>(
(accumulator, selection) => {
const current = accumulator[selection.cellId] ?? {
amount: 0,
count: 0,
}
accumulator[selection.cellId] = {
amount: current.amount + selection.amount,
count: current.count + 1,
}
return accumulator
},
{},
)
}
export function getRecentWinningCellIds(
history: HistoryEntry[],
limit = GAME_RECENT_HISTORY_LIMIT,
) {
return history.slice(0, limit).map((entry) => entry.winningCellId)
}
export function getUnreadAnnouncementCount(announcements: AnnouncementState) {
return announcements.items.filter((item) => !item.isRead).length
}
export function getVisibleAnnouncements(
announcements: AnnouncementState,
nowIso = new Date().toISOString(),
) {
const now = Date.parse(nowIso)
return announcements.items.filter((item) => {
if (item.expiresAt === null) {
return true
}
return Date.parse(item.expiresAt) > now
})
}
export function getRoundCountdownMs(
round: RoundSnapshot,
nowIso = new Date().toISOString(),
) {
const now = Date.parse(nowIso)
if (round.phase === 'waiting' || round.phase === 'betting') {
return Math.max(0, Date.parse(round.bettingClosesAt) - now)
}
if (round.phase === 'locked' || round.phase === 'revealing') {
return Math.max(0, Date.parse(round.revealingAt) - now)
}
if (round.settledAt) {
return Math.max(0, Date.parse(round.settledAt) - now)
}
return 0
}
export function deriveTrendEntries(
history: HistoryEntry[],
cellCount = GAME_TOTAL_CELLS,
) {
const entries = Array.from({ length: cellCount }, (_, index) => {
const cellId = index + 1
const hitRounds = history.filter((entry) => entry.winningCellId === cellId)
const recentSample = history.slice(0, 6)
const previousSample = history.slice(6, 12)
const recentHits = recentSample.filter(
(entry) => entry.winningCellId === cellId,
).length
const previousHits = previousSample.filter(
(entry) => entry.winningCellId === cellId,
).length
let direction: TrendDirection = 'steady'
if (recentHits > previousHits) {
direction = 'rising'
} else if (recentHits < previousHits) {
direction = 'falling'
}
let currentStreak = 0
for (const entry of history) {
if (entry.winningCellId !== cellId) {
break
}
currentStreak += 1
}
return {
cellId,
currentStreak,
direction,
hitCount: hitRounds.length,
lastHitRoundId: hitRounds[0]?.roundId ?? null,
missCount: history.length - hitRounds.length,
} satisfies TrendEntry
})
return entries.sort((left, right) => {
if (right.hitCount !== left.hitCount) {
return right.hitCount - left.hitCount
}
return left.cellId - right.cellId
})
}
export function buildGameCellViewModels(input: {
cells: GameCell[]
round: RoundSnapshot
selections: BetSelection[]
trends: TrendEntry[]
}) {
const groupedSelections = groupSelectionsByCell(input.selections)
const trendByCell = new Map(
input.trends.map((entry) => [entry.cellId, entry]),
)
return input.cells.map((cell) => {
const groupedSelection = groupedSelections[cell.id]
const trend = trendByCell.get(cell.id)
const isSelected = Boolean(groupedSelection)
const isWinningCell = input.round.winningCellId === cell.id
let status: GameCellViewModel['status'] = 'idle'
if (input.round.phase === 'betting') {
status = isSelected ? 'selected' : 'betting'
} else if (
input.round.phase === 'locked' ||
input.round.phase === 'revealing'
) {
status = isSelected ? 'locked' : 'idle'
} else if (input.round.phase === 'settled' && isWinningCell) {
status = 'won'
} else if (input.round.phase === 'settled' && isSelected) {
status = 'lost'
}
return {
...cell,
currentStreak: trend?.currentStreak ?? 0,
hitCount: trend?.hitCount ?? 0,
isSelected,
isWinningCell,
selectionAmount: groupedSelection?.amount ?? 0,
selectionCount: groupedSelection?.count ?? 0,
status,
} satisfies GameCellViewModel
})
}

View File

@@ -0,0 +1,134 @@
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[]
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
}

View File

@@ -8,7 +8,7 @@ import {
handleUnauthorizedSession, handleUnauthorizedSession,
tryRefreshAuthSession, tryRefreshAuthSession,
} from '@/lib/auth/auth-session' } from '@/lib/auth/auth-session'
import { useAuthStore } from '@/store/auth-store' import { useAuthStore } from '@/store/auth'
import { ApiError } from './api-error' import { ApiError } from './api-error'
import type { ApiResponse } from './types' import type { ApiResponse } from './types'

View File

@@ -1,5 +1,5 @@
import type { AuthSessionInput, AuthUser } from '@/store/auth-store' import type { AuthSessionInput, AuthUser } from '@/store/auth'
import { useAuthStore } from '@/store/auth-store' import { useAuthStore } from '@/store/auth'
export type CurrentUserInitializer = () => Promise<AuthUser | null> export type CurrentUserInitializer = () => Promise<AuthUser | null>
export type RefreshSessionHandler = ( export type RefreshSessionHandler = (

View File

@@ -2,7 +2,7 @@ import { redirect } from '@tanstack/react-router'
import type { AppLanguage } from '@/i18n' import type { AppLanguage } from '@/i18n'
import { getPreferredLanguage } from '@/i18n' import { getPreferredLanguage } from '@/i18n'
import { useAuthStore } from '@/store/auth-store' import { useAuthStore } from '@/store/auth'
import { initializeAuthSession, isAuthenticated } from './auth-session' import { initializeAuthSession, isAuthenticated } from './auth-session'

View File

@@ -1,10 +1,11 @@
export default { export default {
nav: { nav: {
home: 'Home', home: 'Home',
game: 'Game',
}, },
shell: { shell: {
eyebrow: 'React SPA scaffold', eyebrow: '36 Character Flower',
subtitle: 'TanStack Router + Query + Auth + i18n', subtitle: 'Real-time draw game frontend for mobile and desktop',
}, },
notFound: { notFound: {
eyebrow: '404', eyebrow: '404',
@@ -13,30 +14,118 @@ export default {
home: 'Back home', home: 'Back home',
}, },
home: { home: {
eyebrow: 'Blank scaffold ready', eyebrow: 'Game shell in progress',
title: 'Start building your frontend project from here.', title:
'The 36-character-flower dual-device game framework is now being built.',
description: description:
'The template already includes routing, data fetching, session state, head metadata, and i18n. After removing the examples, this page becomes your clean starting point.', 'The project has moved past the generic scaffold stage. It is now being structured around a shared game route, shared state, and split mobile and desktop views for the real-time betting experience.',
cards: { cards: {
routingMode: 'Routing mode', routingMode: 'Routing',
dataLayer: 'Data layer', dataLayer: 'State model',
transport: 'Transport', transport: 'Realtime',
auth: 'Auth layer', auth: 'Product',
metadata: 'Page metadata', metadata: 'Current focus',
}, },
values: { values: {
routingMode: 'SPA + file routes', routingMode: 'Shared URL + split device views',
dataLayer: 'TanStack Query', dataLayer: 'Round / Bet / User / UI / Connection',
transport: 'ky', transport: 'HTTP + WebSocket',
auth: 'Zustand session scaffold', auth: '36-grid live draw gameplay',
metadata: 'Dynamic title / meta', metadata: 'Scaffold the structure before state machine polish',
}, },
footnote: footnote:
'A practical place to start is replacing src/routes/$lang/index.tsx, src/lib/api/api-client.ts, and src/store/auth-store.ts with your project-specific structure.', 'Next up: the main game route, shared business model, and dedicated mobile and desktop page shells.',
primaryAction: 'Enter game lobby',
secondaryAction: 'View project structure',
}, },
language: { language: {
label: 'Language', label: 'Language',
zhCN: '中文', zhCN: '中文',
enUS: 'English', enUS: 'English',
}, },
game: {
metaTitle: 'Game Lobby',
metaDescription: '36-character-flower live game lobby.',
lobbyTitle: '36 Character Flower Lobby',
lobbySubtitle:
'Under one shared business route, mobile and desktop mount different views on top of the same game data and state.',
status: {
roundState: 'Round state',
currentRound: 'Current round {{id}}',
tablePool: 'Table pool',
onlineCount: '{{count}} online',
activeChip: 'Active chip',
announcementsRead: '{{read}}/{{total}} announcements read',
connection: 'Connection',
connectionHealthy: 'Live sync stable',
connectionRecovering: 'Waiting to recover',
synced: 'Synced',
degraded: 'Degraded',
},
board: {
historyTitle: 'Round history',
historySubtitle: 'Recent draw and payout trace',
trendTitle: 'Trend radar',
trendSubtitle: 'Momentum and miss streak snapshot',
stageTitle: 'Draw stage',
stageSubtitle:
'This stage holds the main board and control structure before the full state machine and animation pass.',
currentPhase: 'Current phase',
selectedBet: 'Bet {{amount}}',
hitCount: '{{count}} hits',
hitBadge: '{{count}}x',
badgeWin: 'Win',
badgeBet: 'Bet',
cellLabel: 'Cell {{id}}',
winningCell: 'Winning cell {{id}}',
missedRounds: 'Missed {{count}} rounds',
rising: 'Rising',
falling: 'Falling',
steady: 'Steady',
hitTotal: '{{count}} hits',
},
phases: {
betting: 'Betting',
locked: 'Locked',
revealing: 'Revealing',
settled: 'Settled',
},
actions: {
unifiedBetHint: 'Unified bet',
totalBet: 'Total bet',
canBet: 'Can bet',
yes: 'Yes',
no: 'No',
quickBet: 'Quick bet 08',
clearPending: 'Clear pending',
autoModeDemo: 'Auto mode demo',
stopAuto: 'Stop auto',
},
modal: {
eyebrow: 'Announcement',
acknowledge: 'Acknowledge',
later: 'Later',
line1:
'This will later connect to the real announcement body, confirmation checkbox, and persistence flow.',
line2: 'For now it validates the shared modal structure.',
},
autoSpin: {
eyebrow: 'Auto spin',
title: 'Auto spin running',
description:
'Auto mode will cover the board while preserving target cell focus and progress.',
trailingLabel: 'Manual input locked',
},
footer: {
implementationTitle: 'Current implementation',
implementationSubtitle:
'This iteration prioritizes the dual-device shell, shared model, and business wiring.',
implementationBody:
'Next steps are the real API, WebSocket, full UI store, and round lifecycle state machine.',
limitsTitle: 'Table limits',
limitsSubtitle: 'Derived from dashboard mock data',
minBet: 'Min bet',
maxBet: 'Max bet',
},
},
} as const } as const

View File

@@ -1,10 +1,11 @@
export default { export default {
nav: { nav: {
home: '首页', home: '首页',
game: '游戏大厅',
}, },
shell: { shell: {
eyebrow: 'React SPA 脚手架', eyebrow: '36字花',
subtitle: 'TanStack Router + Query + Auth + i18n', subtitle: '实时开奖 · 双端适配 · 多语言游戏前端',
}, },
notFound: { notFound: {
eyebrow: '404', eyebrow: '404',
@@ -13,30 +14,114 @@ export default {
home: '返回首页', home: '返回首页',
}, },
home: { home: {
eyebrow: '空白脚手架已就绪', eyebrow: '游戏前台骨架已启动',
title: '从这里开始搭建你的前端项目。', title: '36 字花双端游戏框架正在落地。',
description: description:
'模板已经接好路由、请求层、会话状态、head metadata 和多语言。删除示例后,这一页就是你的干净起点。', '当前版本已经切入真实业务方向:统一路由、共享状态、移动端与桌面端分视图,后续会在这个基础上继续接入实时状态机、下注流程和开奖动画。',
cards: { cards: {
routingMode: '路由模式', routingMode: '路由策略',
dataLayer: '数据层', dataLayer: '状态规划',
transport: '请求层', transport: '联机能力',
auth: '认证层', auth: '业务目标',
metadata: '页面元信息', metadata: '当前重点',
}, },
values: { values: {
routingMode: 'SPA + 文件路由', routingMode: '统一 URL + 双端分视图',
dataLayer: 'TanStack Query', dataLayer: 'Round / Bet / User / UI / Connection',
transport: 'ky', transport: 'HTTP + WebSocket',
auth: 'Zustand 会话基座', auth: '36 宫格实时开奖玩法',
metadata: '动态 title / meta', metadata: '先落结构,再接状态机与动效',
}, },
footnote: footnote:
'建议先从 src/routes/$lang/index.tsx、src/lib/api/api-client.ts 和 src/store/auth-store.ts 开始替换成你的项目结构。', '下一步会优先完成游戏主路由、共享业务模型和移动端 / 桌面端页面骨架。',
primaryAction: '进入游戏大厅',
secondaryAction: '查看项目结构',
}, },
language: { language: {
label: '语言', label: '语言',
zhCN: '中文', zhCN: '中文',
enUS: 'English', enUS: 'English',
}, },
game: {
metaTitle: '游戏大厅',
metaDescription: '36字花实时开奖游戏大厅。',
lobbyTitle: '36字花游戏大厅',
lobbySubtitle:
'统一业务路由下,移动端与桌面端分别挂载不同视图,但共用一套游戏数据和状态。',
status: {
roundState: '回合状态',
currentRound: '当前期号 {{id}}',
tablePool: '桌面资金池',
onlineCount: '{{count}} 在线',
activeChip: '当前筹码',
announcementsRead: '已读公告 {{read}}/{{total}}',
connection: '连接质量',
connectionHealthy: '连接稳定',
connectionRecovering: '等待重连',
synced: '已同步',
degraded: '异常',
},
board: {
historyTitle: '开奖历史',
historySubtitle: '最近开奖与派奖记录',
trendTitle: '冷热走势',
trendSubtitle: '热度与遗漏参考',
stageTitle: '开奖舞台',
stageSubtitle: '这一层先挂载主盘面与中控结构,后续再接真实状态机和动画。',
currentPhase: '当前阶段',
selectedBet: '下注 {{amount}}',
hitCount: '命中 {{count}} 次',
hitBadge: '{{count}}x',
badgeWin: '中奖',
badgeBet: '下注',
cellLabel: '字花 {{id}}',
winningCell: '中奖格 {{id}}',
missedRounds: '遗漏 {{count}} 局',
rising: '热度上升',
falling: '热度回落',
steady: '稳定',
hitTotal: '{{count}} 命中',
},
phases: {
betting: '下注中',
locked: '已封盘',
revealing: '开奖中',
settled: '已结算',
},
actions: {
unifiedBetHint: '统一下注额',
totalBet: '总下注',
canBet: '可下注',
yes: '是',
no: '否',
quickBet: '快速选中 08',
clearPending: '清空未确认',
autoModeDemo: '自动托管演示',
stopAuto: '停止托管',
},
modal: {
eyebrow: '强制公告',
acknowledge: '已读并进入',
later: '稍后查看',
line1: '这里后续会接真实公告图文、勾选确认和已读状态。',
line2: '当前先用共享弹窗骨架验证结构。',
},
autoSpin: {
eyebrow: '自动托管',
title: '自动托管运行中',
description: '托管态会覆盖主盘面,但目标格子和进度信息仍然保留可见。',
trailingLabel: '手动输入已锁定',
},
footer: {
implementationTitle: '当前实现说明',
implementationSubtitle:
'这一版优先把双端壳层、共享模型和业务接线落到代码里。',
implementationBody:
'下一步会继续接入真实 API、WebSocket、完整 UI Store 和回合状态机。',
limitsTitle: '桌限信息',
limitsSubtitle: '来自 dashboard mock 数据',
minBet: '最低下注',
maxBet: '最高下注',
},
},
} as const } as const

View File

@@ -8,7 +8,7 @@ import '@/i18n'
import { initializeAuthSession } from '@/lib/auth/auth-session' import { initializeAuthSession } from '@/lib/auth/auth-session'
import { queryClient } from '@/lib/query/query-client' import { queryClient } from '@/lib/query/query-client'
import { router } from '@/router' import { router } from '@/router'
import './styles.css' import './style/index.css'
const rootElement = document.getElementById(APP_ROOT_ELEMENT_ID) const rootElement = document.getElementById(APP_ROOT_ELEMENT_ID)
const shouldShowQueryDevtools = const shouldShowQueryDevtools =

View File

@@ -1,19 +1,7 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useDocumentMetadata } from '@/lib/head/document-metadata' import { GameRoutePage } from '@/features/game/entry/game-route-page'
export const Route = createFileRoute('/$lang/')({ export const Route = createFileRoute('/$lang/')({
component: HomePage, component: GameRoutePage,
}) })
function HomePage() {
const { t } = useTranslation()
useDocumentMetadata({
title: t('home.title'),
description: t('home.description'),
})
return <div>111</div>
}

View File

@@ -1,7 +1,9 @@
import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router' import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DesktopAnimal } from '@/features/game/components/desktop/desktop-animal.tsx'
import { DesktopHeader } from '@/features/game/components/desktop/desktop-header.tsx'
import { DesktopTitle } from '@/features/game/components/desktop/desktop-title.tsx'
import { getPreferredLanguage, isSupportedLanguage } from '@/i18n' import { getPreferredLanguage, isSupportedLanguage } from '@/i18n'
export const Route = createFileRoute('/$lang')({ export const Route = createFileRoute('/$lang')({
@@ -23,24 +25,18 @@ function LanguageLayout() {
if (!isValidLanguage) { if (!isValidLanguage) {
return <Navigate to="/$lang" params={{ lang: preferredLanguage }} replace /> return <Navigate to="/$lang" params={{ lang: preferredLanguage }} replace />
} }
// function changeLanguage(nextLanguage: AppLanguage) {
// const nextPathname = location.pathname.replace(
// /^\/(zh-CN|en-US)(?=\/|$)/,
// `/${nextLanguage}`,
// )
// void i18n.changeLanguage(nextLanguage)
// void navigate({
// to: nextPathname,
// search: location.search,
// hash: location.hash,
// replace: true,
// })
// }
return ( return (
<> <div className="flex min-h-full flex-col">
<Outlet /> <DesktopHeader />
</> <div className={'mx-auto w-[95%] my-[10px]'}>
<DesktopTitle />
</div>
<div className={''}>
<DesktopAnimal />
</div>
<main className="flex min-h-0 flex-1 flex-col">
<Outlet />
</main>
</div>
) )
} }

1
src/store/auth/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './auth-store'

View File

@@ -0,0 +1,171 @@
import { create } from 'zustand'
import type {
BetSelection,
Chip,
GameBootstrapSnapshot,
GameCell,
HistoryEntry,
RoundPhase,
RoundSnapshot,
TrendEntry,
} from '@/features/game/shared'
import {
buildGameCellViewModels,
createMockGameBootstrapSnapshot,
DEFAULT_ACTIVE_CHIP_ID,
getChipById,
getRecentWinningCellIds,
getSelectionTotal,
groupSelectionsByCell,
} from '@/features/game/shared'
type GameRoundSlice = Pick<
GameBootstrapSnapshot,
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
>
export interface GameRoundStoreState extends GameRoundSlice {
activeChipId: string
clearSelections: () => void
hydrateRound: (snapshot: GameRoundSlice) => void
placeBet: (cellId: number) => void
removeSelectionsForCell: (cellId: number) => void
selectChip: (chipId: string) => void
setPhase: (phase: RoundPhase) => void
syncRound: (round: Partial<RoundSnapshot>) => void
upsertSelections: (selections: BetSelection[]) => void
}
function createInitialRoundState(): GameRoundSlice & { activeChipId: string } {
const snapshot = createMockGameBootstrapSnapshot()
return {
activeChipId:
snapshot.chips.find((chip) => chip.isDefault)?.id ??
DEFAULT_ACTIVE_CHIP_ID,
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
}
}
export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
...createInitialRoundState(),
clearSelections: () => {
set({ selections: [] })
},
hydrateRound: (snapshot) => {
set((state) => ({
activeChipId: getChipById(snapshot.chips, state.activeChipId)
? state.activeChipId
: (snapshot.chips.find((chip) => chip.isDefault)?.id ??
snapshot.chips[0]?.id ??
DEFAULT_ACTIVE_CHIP_ID),
cells: snapshot.cells,
chips: snapshot.chips,
history: snapshot.history,
round: snapshot.round,
selections: snapshot.selections,
trends: snapshot.trends,
}))
},
placeBet: (cellId) => {
set((state) => {
const activeChip =
getChipById(state.chips, state.activeChipId) ??
state.chips.find((chip) => chip.isDefault) ??
state.chips[0]
if (!activeChip || state.round.phase !== 'betting') {
return state
}
return {
selections: [
...state.selections,
{
amount: activeChip.amount,
cellId,
chipId: activeChip.id,
id: `bet-${cellId}-${state.selections.length + 1}-${Date.now()}`,
placedAt: new Date().toISOString(),
source: 'local',
},
],
}
})
},
removeSelectionsForCell: (cellId) => {
set((state) => ({
selections: state.selections.filter(
(selection) => selection.cellId !== cellId,
),
}))
},
selectChip: (chipId) => {
set((state) => {
if (!getChipById(state.chips, chipId)) {
return state
}
return { activeChipId: chipId }
})
},
setPhase: (phase) => {
set((state) => ({
round: {
...state.round,
phase,
},
}))
},
syncRound: (round) => {
set((state) => ({
round: {
...state.round,
...round,
},
}))
},
upsertSelections: (selections) => {
set({ selections })
},
}))
export const selectActiveChip = (state: GameRoundStoreState): Chip | null =>
getChipById(state.chips, state.activeChipId) ??
state.chips.find((chip) => chip.isDefault) ??
state.chips[0] ??
null
export const selectBoardCells = (state: GameRoundStoreState) =>
buildGameCellViewModels({
cells: state.cells,
round: state.round,
selections: state.selections,
trends: state.trends,
})
export const selectCanPlaceBets = (state: GameRoundStoreState) =>
state.round.phase === 'betting'
export const selectRecentResults = (state: GameRoundStoreState) =>
getRecentWinningCellIds(state.history)
export const selectSelectionTotal = (state: GameRoundStoreState) =>
getSelectionTotal(state.selections)
export const selectSelectionsByCell = (state: GameRoundStoreState) =>
groupSelectionsByCell(state.selections)
export type GameRoundStore = typeof useGameRoundStore
export type GameRoundStoreData = Pick<
GameRoundStoreState,
'cells' | 'chips' | 'history' | 'round' | 'selections' | 'trends'
>
export type { BetSelection, GameCell, HistoryEntry, RoundSnapshot, TrendEntry }

View File

@@ -0,0 +1,131 @@
import { create } from 'zustand'
import type {
AnnouncementState,
ConnectionState,
ConnectionStatus,
DashboardState,
GameBootstrapSnapshot,
} from '@/features/game/shared'
import {
createMockGameBootstrapSnapshot,
getUnreadAnnouncementCount,
getVisibleAnnouncements,
} from '@/features/game/shared'
type GameSessionSlice = Pick<
GameBootstrapSnapshot,
'announcements' | 'connection' | 'dashboard'
>
export interface GameSessionStoreState extends GameSessionSlice {
dismissAnnouncement: (announcementId: string) => void
hydrateSession: (snapshot: GameSessionSlice) => void
markAnnouncementRead: (announcementId: string) => void
setConnectionLatency: (latencyMs: number | null) => void
setConnectionStatus: (status: ConnectionStatus) => void
syncConnection: (patch: Partial<ConnectionState>) => void
syncDashboard: (patch: Partial<DashboardState>) => void
}
function createInitialSessionState(): GameSessionSlice {
const snapshot = createMockGameBootstrapSnapshot()
return {
announcements: snapshot.announcements,
connection: snapshot.connection,
dashboard: snapshot.dashboard,
}
}
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
...createInitialSessionState(),
dismissAnnouncement: (announcementId) => {
set((state) => ({
announcements: {
...state.announcements,
activeAnnouncementId:
state.announcements.activeAnnouncementId === announcementId
? (state.announcements.items.find(
(item) => item.id !== announcementId,
)?.id ?? null)
: state.announcements.activeAnnouncementId,
items: state.announcements.items.filter(
(item) => item.id !== announcementId,
),
},
}))
},
hydrateSession: (snapshot) => {
set(snapshot)
},
markAnnouncementRead: (announcementId) => {
set((state) => ({
announcements: {
...state.announcements,
items: state.announcements.items.map((item) =>
item.id === announcementId ? { ...item, isRead: true } : item,
),
},
}))
},
setConnectionLatency: (latencyMs) => {
set((state) => ({
connection: {
...state.connection,
latencyMs,
},
}))
},
setConnectionStatus: (status) => {
set((state) => ({
connection: {
...state.connection,
connectedAt:
status === 'connected'
? (state.connection.connectedAt ?? new Date().toISOString())
: state.connection.connectedAt,
status,
},
}))
},
syncConnection: (patch) => {
set((state) => ({
connection: {
...state.connection,
...patch,
},
}))
},
syncDashboard: (patch) => {
set((state) => ({
dashboard: {
...state.dashboard,
...patch,
},
}))
},
}))
export const selectActiveAnnouncement = (state: GameSessionStoreState) =>
state.announcements.items.find(
(item) => item.id === state.announcements.activeAnnouncementId,
) ?? null
export const selectIsConnectionHealthy = (state: GameSessionStoreState) =>
state.connection.status === 'connected' &&
(state.connection.latencyMs === null || state.connection.latencyMs < 150)
export const selectUnreadAnnouncementCount = (state: GameSessionStoreState) =>
getUnreadAnnouncementCount(state.announcements)
export const selectVisibleAnnouncements = (state: GameSessionStoreState) =>
getVisibleAnnouncements(state.announcements)
export type GameSessionStore = typeof useGameSessionStore
export type GameSessionStoreData = Pick<
GameSessionStoreState,
'announcements' | 'connection' | 'dashboard'
>
export type { AnnouncementState, ConnectionState, DashboardState }

2
src/store/game/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './game-round-store'
export * from './game-session-store'

2
src/store/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './auth'
export * from './game'

77
src/style/index.css Normal file
View File

@@ -0,0 +1,77 @@
@import "tailwindcss";
@theme {
--font-sans:
"Inter", "SF Pro Display", "Segoe UI", "Helvetica Neue", sans-serif;
--font-mono:
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
--color-game-bg: #07111f;
--color-game-surface: #0d1b2d;
--color-game-surface-strong: #12253d;
--color-game-accent: #31d0ff;
--color-game-accent-soft: #7ce8ff;
--color-game-gold: #f5c86b;
--color-game-gold-strong: #ffd66e;
--color-game-danger: #ff5e7a;
--color-game-success: #4fdc9b;
--shadow-game-panel:
0 20px 60px rgba(2, 6, 23, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
@layer base {
html {
@apply h-full w-full;
}
body {
@apply m-0 h-full w-full;
}
#root {
@apply h-full w-full;
}
body {
background:
radial-gradient(circle at top, rgba(49, 208, 255, 0.12), transparent 28%),
radial-gradient(
circle at bottom right,
rgba(245, 200, 107, 0.1),
transparent 24%
),
linear-gradient(180deg, #07111f 0%, #040812 100%);
color: #f8fafc;
}
}
@layer utilities {
.common-neon-inset {
border: 1px solid rgba(128, 223, 231, 0.65);
border-radius: 5px;
padding: 8px 10px;
box-shadow: inset 0 0 8px rgba(128, 223, 231, 0.65);
}
.game-panel {
border: 1px solid rgba(124, 232, 255, 0.12);
background: linear-gradient(
180deg,
rgba(18, 37, 61, 0.88),
rgba(7, 17, 31, 0.78)
);
box-shadow: var(--shadow-game-panel);
backdrop-filter: blur(18px);
}
.game-chip-glow {
box-shadow:
0 0 0 1px rgba(49, 208, 255, 0.16),
0 16px 36px rgba(49, 208, 255, 0.16);
}
.game-gold-glow {
box-shadow:
0 0 0 1px rgba(245, 200, 107, 0.2),
0 18px 40px rgba(245, 200, 107, 0.14);
}
}

View File

@@ -1,22 +0,0 @@
@import "tailwindcss";
@theme {
--font-sans:
"Inter", "SF Pro Display", "Segoe UI", "Helvetica Neue", sans-serif;
--font-mono:
"JetBrains Mono", "SFMono-Regular", "SF Mono", Consolas, monospace;
}
@layer base {
html {
@apply h-full w-full;
}
body {
@apply m-0 h-full w-full;
}
#root {
@apply h-full w-full;
}
}