diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md new file mode 100644 index 0000000..90ce1d7 --- /dev/null +++ b/docs/36字花-移动端接口设计草案.md @@ -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 获取接口鉴权 Token(auth-token) +- **GET** `/api/v1/authToken` +- 用途:获取 `auth-token`(所有接口请求头必带) + +请求示例: +`/api/v1/authToken?secret=564d14asdasd113e46542asd6das1a2a×tamp=1776331077&device_id=1&signature=AD84C49880896DBC16C59C7B122D1FF7` + +请求参数: +- `secret`:string(含义:客户端密钥;服务端从环境变量 `AUTH_TOKEN_SECRET` 校验) +- `timestamp`:int(含义:请求时间戳;服务端允许与服务器时间误差 ±300 秒) +- `device_id`:string(含义:设备码) +- `signature`:string(含义:签名值) + +签名算法: +- 取参与签名的参数(不含 `signature`):`device_id`、`secret`、`timestamp` +- 按参数名 **a-z** 排序后拼接为字符串:`key=value&key=value...` +- 计算:`signature = strtoupper(md5(拼接字符串))` + +返回参数: +- `auth_token`:string(含义:接口鉴权 token;放到请求头 `auth-token`) +- `expires_in`:int(含义:有效期秒数) +- `server_time`:int(含义:服务器时间戳,用于校时) + +可能错误码: +- `1001` 参数缺失 +- `1002` 参数格式错误 +- `1103` 密钥无效/签名错误 +- `3001` 时间戳无效 + +### 1.5 服务端性能与 Redis 热点缓存(实现说明) + +> **对客户端无契约变更**:请求路径、参数、响应 JSON 形状与错误码均不因缓存而改变;本节仅说明服务端如何降延迟、读路径与一致性注意点。 + +**与「框架文件缓存」的区别** + +| 配置 | 作用域 | +|------|--------| +| `CACHE_DRIVER`(`config/cache.php`,如 `file`) | Think-ORM / `get_sys_config()` 等**系统参数表 `config`** 的模型缓存,落盘在 `runtime/cache`,**不参与**本游戏业务热点路径。 | +| `GAME_HOT_CACHE_*`(`config/game_hot_cache.php`) | 游戏侧 **`user` / `game_config` / `game_record`** 行级 JSON 缓存,走 **`support\Redis`**(`config/redis.php` 连接),键前缀 `dfw:v1:`。 | + +**服务端缓存覆盖(与移动端直接相关的读路径)** + +- **用户**:会员鉴权优先读 Redis 中的 `user` 行快照,未命中再查库并回填。**余额、连胜、打码量等变更**落库后,统一经 **`GameHotDataCoordinator::afterUserCommitted($userId)`**:先 **`GameHotDataRedis::userReplaceCacheFromDb`** 与 DB 对齐,再向 Redis 写队列投递幂等刷新任务(见 `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`),用于削峰而非替代同步回源。 +- **游戏配置**:`game_config` 按 `config_key` 缓存。后台直连 `Db` 更新时须 **`GameHotDataCoordinator::afterGameConfigKeyCommitted($key)`**(模型 `GameConfig` 事件与独立表单控制器已接入);独立保存接口在写入前对同一 `config_key` 使用 **`GameHotDataLock`(`TYPE_GAME_CONFIG`)** 互斥。勿仅删除缓存键而不回源,否则最长不一致窗口为 TTL。 +- **对局**:当前活跃局、按 `id` 的局、最新一条 `game_record` 等;写库后经 **`GameHotDataCoordinator::afterGameRecordCommitted`** 同步刷新相关 Redis 键并入队。开奖/封盘等路径另可按记录 id 使用 **`GameHotDataLock`(`TYPE_GAME_RECORD`)** 串行化。 + +**环境变量(示例见仓库根目录 `.env-example`)** + +- `GAME_HOT_CACHE_ENABLED`:是否启用上述 Redis 热点缓存(`false` 时全程回退数据库)。 +- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`:各类缓存 TTL(秒);**以写后同步回源为主**,TTL 仅作兜底。 +- `GAME_HOT_CACHE_ENABLE_WRITE_QUEUE` 及队列长度、消费进程间隔等:控制写库后的**幂等刷新任务**是否入队及背压策略(见 `config/game_hot_cache.php`)。 + +**一致性提示(联调/测试)** + +- 任何绕过协调入口、只改 DB 不调用 **`GameHotDataCoordinator`** 的手工脚本,都可能与 Redis 短期不一致;生产环境应避免。 +- **`POST /api/game/betPlace`** 扣款路径使用与后台钱包加减点相同的 **用户维度 Redis 锁**(`GameHotDataRedis::userAdminMutationLockTry`)及 **`WHERE coin = ?` 条件更新**,与并发派彩/后台调账互斥;失败时返回 **§4.2** 所列中文说明。 +- 客户端仍可按 **§3.2 `dictionaryList` 的 `version`** 做本地缓存;服务端字典另有 Redis 加速,二者可同时存在。 + +--- + +## 2. 认证与账户模块(user) + +### 2.1 注册 +- **POST** `/api/user/register` +- 用途:仅手机号注册并绑定邀请归属(admin/channel) + +请求参数: +- `username`:string,手机号(含义:注册账号,仅支持大陆手机号) +- `password`:string,明文经 HTTPS 传输(含义:登录密码,服务端需加盐哈希存储) +- `invite_code`:string,必填(含义:子代理邀请码,用于绑定渠道 `channel_id` 与归属) +- `device_id`:string,可选(含义:设备标识,用于风控与登录记录) + +返回参数: +- `user-token`:string(含义:后续接口登录态令牌;用于需要登录的接口请求头) +- `refresh_token`:string,可选(含义:用于刷新访问令牌) +- `expires_in`:int(秒,含义:令牌有效期) +- `user`:object(仅返回非私密信息,不返回 `id`) + - `uuid`:string(含义:用户对外唯一标识,10 位) + - `username`:string(含义:用户昵称/展示名) + - `coin`:string(含义:当前余额) + - `channel_id`:int(含义:归属渠道 ID) + - `risk_flags`:int(含义:风控状态位) + +### 2.2 登录 +- **POST** `/api/user/login` + +请求参数: +- `username`:string(含义:登录账号,当前支持手机号) +- `password`:string(含义:登录密码) +- `device_id`:string,可选(含义:设备标识,辅助风控) + +返回参数: +- `user-token`:string(含义:访问令牌;用于需要登录的接口请求头) +- `refresh_token`:string,可选(含义:用于刷新访问令牌) +- `expires_in`:int(含义:访问令牌剩余有效秒数) +- `user`:object(仅返回非私密信息,不返回 `id`) + - `uuid`:string(含义:用户对外唯一标识,10 位) + - `username`:string(含义:用户昵称/展示名) + - `coin`:string(含义:当前余额) + - `channel_id`:int(含义:归属渠道 ID) + - `risk_flags`:int(含义:风控状态位) + +### 2.3 获取当前用户信息 +- **POST** `/api/user/profile` + +返回参数(金额类字段统一 2 位小数字符串,与 `/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,合法范围 1–36) + - `chips`:array[string](如 `["1.00","5.00"]`,含义:快捷筹码面额) + - `single_number_max_bet`:string(含义:单号码最大下注额) +- `dictionary`:array + - `number`:int(1-36,含义:字花编号) + - `name`:string(含义:字花名称) + - `category`:string(含义:字花分类) + - `icon`:string(含义:图标资源地址) +- `user_snapshot`:object(`coin`、`current_streak`,含义:用户状态快照) + +### 3.2 获取36字花字典(可缓存) +- **POST** `/api/game/dictionaryList` + +返回参数: +- `version`:string(含义:字典版本号,前端可用于缓存比对) +- `items`:同 `dictionary`(含义:36字花字典明细) + +### 3.3 获取最近开奖记录(近30期) +- **POST** `/api/game/periodHistory` + +请求参数: +- `limit`:int(可选,默认 30,含义:返回最近几期) + +返回参数: +- `list`:array + - `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`;每个号码为 1–36 的整数,数量不超过 `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 + - `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 + - `record_id`:int(含义:钱包流水 ID) + - `biz_type`:string(含义:业务类型) + - `direction`:int(1入2出,含义:资金方向) + - `amount`:string(含义:本次变动金额) + - `balance_before`:string(含义:变动前余额) + - `balance_after`:string(含义:变动后余额) + - `ref_type`:string(含义:关联业务单类型) + - `ref_id`:string(含义:关联业务单标识) + - `create_time`:int(含义:流水时间) + +补充约定: +- 金额字段(`amount`、`balance_before`、`balance_after` 等)客户端显示统一两位小数。 +- 后台管理员加减点会生成 `biz_type=admin_credit/admin_deduct` 的流水记录,备注默认模板:`后台管理员(操作管理员)加点/扣点100(值)`(示例)。 + +### 5.3 充值档位列表 +- **POST** `/api/finance/depositTierList` + +说明: +- 由后台「配置管理 → 充值档位」维护,存放在 `game_config.deposit_tier`(JSON 数组)。 +- 后台表单中的「支付货币」下拉来源于 `game_config.finance_cashier.currencies`(不再前端硬编码)。 +- 初始化/重建档位时按当前 `finance_cashier` 货币集合生成:**每种货币 6 条档位**(运营可再编辑)。 +- 仅返回启用状态(`status=1`)的档位,按 `sort` 升序;玩家仅能从中选择。 +- 档位仅描述"充值规格",不再包含收款账户;具体收款由第三方支付网关返回的 `pay_url` 引导。 +- **多语言**:后台保存 `title`(中文名)、`title_en`(英文名)、`desc`(中文描述)、`desc_en`(英文描述)。接口返回的 `title` / `desc` 会根据请求头 `lang` 自动适配: + - `lang=zh`(默认):返回 `title` / `desc`,若为空则回退到英文 + - `lang=en`:返回 `title_en` / `desc_en`,若为空则回退到中文 + - 移动端客户端仅看到单一 `title` / `desc`,无需自行判断语言 + +请求参数:无(无需 body 与 query) + +返回参数: +- `list`:list,档位列表;每一项结构: + - `id`:string(含义:档位稳定 ID,创建订单时作为 `tier_id` 原样回传;与 `tier_key` 同值) + - `tier_key`:string(含义:与 `id` 相同,兼容旧字段名) + - `title`:string(含义:档位名称,已按 `lang` 头切换;例如 `lang=en` 下返回 `"Starter Pack"`,`lang=zh` 下返回 `"新手首充礼包"`) + - `currency`:string(含义:标价币种,如 `CNY`) + - `pay_amount`:string(2 位小数,含义:对外标价金额,与业务配置一致;展示用) + - `amount`:string(2 位小数,含义:玩家本次需支付的充值金额) + - `bonus_amount`:string(2 位小数,含义:该档位赠送金额,无赠送为 `0.00`) + - `total_amount`:string(2 位小数,含义:到账总额 = amount + bonus_amount,方便前端直接展示"到账 120") + - `desc`:string(含义:档位描述/活动文案,已按 `lang` 头切换;可空) + - `channels`:array(含义:可用支付渠道列表,用于 `depositCreate` 的 `channel_code`;渠道与档位不再做单独绑定,所有启用渠道自动兼容全部档位) + - 每项:`code`(string,渠道代码,小写,与创建订单时传入的 `channel_code` 一致)、`name`(展示名)、`sort`(排序) + +### 5.3A 获取充值/提现配置 +- **POST** `/api/finance/depositWithdrawConfig` +- 兼容旧接口:`POST /api/finance/cashierConfig`(返回结构一致,建议客户端统一切到 `depositWithdrawConfig`) + +用途: +- 一次性返回充值与提现页面所需配置:货币列表、汇率、可用充值渠道、提现银行、提现限额与文案配置。 + +返回参数: +- `platform_coin_label`:string(平台币名称,按 `lang` 适配) +- `currencies`:array + - `code`:string(货币代码) + - `label`:string(货币展示名,按 `lang` 适配) + - `deposit_coins_per_fiat`:string(充值汇率) + - `withdraw_coins_per_fiat`:string(提现汇率) +- `rates`:array(兼容字段) + - `currency`:string + - `diamonds_per_fiat_unit`:string +- `pay_channels`:array(充值渠道) + - `code`:string(渠道代码) + - `name`:string(展示名) + - `sort`:int(排序) + - `status`:int(启用状态,1=启用) + - `tier_ids`:array(兼容字段;当前固定空数组,表示自动兼容全部充值档位) +- `withdraw`:object + - `banks`:array(提现银行) + - `min_ewallet`:string(电子钱包最低提现) + - `min_bank`:string(银行卡最低提现) + - `rate_hint`:string(汇率提示文案) + - `processing_note`:string(到账提示文案) + - `fee_note`:string(手续费提示文案) + - `rate_mode`:string(`fixed` / `live`) + - `fields`:object(提现表单必填项开关) + +### 5.4 创建充值订单 +- **POST** `/api/finance/depositCreate` +- `Content-Type: application/json`(推荐)、`application/x-www-form-urlencoded` 或 **`multipart/form-data`**(如 Apifox 的 form-data);字段名与下表一致即可,服务端通过统一参数名读取,**不限制**为某一种 body 类型。 + +说明(与真实「创建订单 → 调起三方 → 异步回调」一致): +- **创建单**:`depositCreate` 仅写入 **待支付** 订单(`status=pending`),**不在此请求内入账**。返回体中 `paid=false`,`pay_url` 为 **模拟第三方收银台** 完整 URL(HMAC 防篡改,见下 §5.4.1)。 +- **客户端**:在 WebView/系统浏览器中打开 `pay_url`;用户完成支付后(模拟页为「确认支付」按钮),由服务端 `depositMockNotify` 验签后调用 `DepositSettlement::settle` 入账,并推送 `wallet.changed`;客户端可轮询 `depositDetail` 或依赖推送更新余额。 +- **未来接入真实第三方支付**:将 `pay_url` 与回调 URL 替换为真网关,入账仍**仅**在回调/验签成功路径中调用 `DepositSettlement::settle`(与当前模拟回调一致)。 +- 档位与渠道取自 `depositTierList`:创建订单时须选择返回 `channels` 中某一渠道的 `code` 作为 `channel_code` 传入;服务端会校验档位存在、启用且渠道已启用。 +- **HMAC 密钥**:模拟链路与签名校验使用环境变量 **`DEPOSIT_MOCK_HMAC_KEY`**(或 `config('app.deposit_mock_hmac_key')`);生产环境务必配置,与代码中默认值区分。 +- **并发上限**:同一用户最多同时存在 **3 笔待支付充值单**(`status=0`);超过后创建接口返回 `code=2005`。 +- **超时失效**:充值单创建后 **60 秒内未支付**将自动置为失败(`status=failed`),并在订单备注记录失败原因(`[timeout] unpaid over 60s`)。 +- **定时任务兜底**:服务端进程 `depositOrderExpireTicker` 每 **10 秒**主动扫描超时待支付单,保证即使用户不访问任何充值接口也会准时失效。 + +请求参数(**三者缺一不可**,任一为空或空白即 `code=1001` 参数缺失): +- `tier_id`:string,必填(含义:玩家选择的充值档位 ID,取自 `depositTierList` 的 `id`;也可用同义字段名 `tier_key`) +- `channel_code`:string,必填(含义:支付渠道代码,**小写**;须与所选档位在 `depositTierList` 返回的 `channels[].code` 之一一致,例如默认内置渠道常为 `directpay`) +- `idempotency_key`:string,必填,≤64(含义:客户端生成的唯一键,短时间内同 `idempotency_key` 不会重复下单;建议 UUID。**调试工具中若使用变量,请确保解析后非空**) + +> **常见 1001 原因**:只传了 `tier_id` + `idempotency_key`,**漏传 `channel_code`**。请先调 `depositTierList`,用对应档位下 `channels` 中某项的 `code` 作为 `channel_code`。 + +返回参数: +- `order_no`:string(含义:充值订单号) +- `amount`:string(2 位小数,含义:玩家本次支付的充值金额,与所选档位 `amount` 一致) +- `bonus_amount`:string(2 位小数,含义:本次赠送金额,与所选档位 `bonus_amount` 一致,无赠送为 `0.00`) +- `total_amount`:string(2 位小数,含义:实际入账总额 = amount + bonus_amount) +- `pay_channel`:string(含义:支付通道标识,与请求中选择的 `channel_code` 一致,落库在订单上) +- `paid`:bool(含义:当前单据是否已到账;`true` 表示钱包已入账、`status=paid`;`false` 表示待玩家在第三方支付页面完成支付) +- `pay_url`:string(含义:第三方支付收银台地址;**`paid=false`(待支付)** 时返回**完整 URL**(如 `https://你的域名/api/finance/depositMockPayPage?order_no=...&sign=...`);`paid=true` 时为空串) +- `status`:string(`pending`/`paid`/`failed`,含义:本接口创建成功时为 `pending`,入账完成后为 `paid`) +- `create_time`:int(含义:订单创建时间,秒级时间戳) +- `pay_time`:int(含义:订单到账时间,未到账为 0) + +#### 5.4.1 模拟第三方:收银台页与「异步通知」回调(开发/无真网关时使用) + +- **GET** `/api/finance/depositMockPayPage` + - **Query**:`order_no`(与 `depositCreate` 返回一致)、`sign`(HMAC,与 `pay_url` 中一致;**不要自行拼接,须完整使用 `depositCreate` 返回的 `pay_url` 或同接口再次查询到的地址**) + - 无需 `auth-token` / `user-token`(外跳浏览器使用)。 + - 返回:HTML 页面,用户点击 **「确认支付(模拟成功)」** 即提交到下方 `depositMockNotify`。 + +- **POST** `/api/finance/depositMockNotify` + - **Body**(`application/x-www-form-urlencoded` 或 JSON 均可,字段名一致即可):`order_no`、`sign`(与上页/支付链接一致) + - 无需 `user-token`;`auth-token` 可选(当前实现不校验)。 + - 验签成功后:对 `status=0` 的订单执行入账(`DepositSettlement::settle`,`source=third_party` 语义),并推送 `wallet.changed`。已入账订单**幂等**再调返回当前订单信息。 + - 成功响应:与 `depositCreate` 成功体相同结构(`code=1` + `data` 为统一充值订单结构)。 + +错误码约定: +- `1001`:缺少必填参数(`tier_id`(或 `tier_key`)、`channel_code`、`idempotency_key` 任一未传或为空字符串) +- `1002`:`idempotency_key` 过长,或与其他玩家的订单冲突 +- `1003`:模拟回调/链接参数非法(如 `sign` 与 `order_no` 不匹配)——`depositMockNotify` 与无效支付链接 +- `2000`:订单落库或入账失败(事务回滚后返回原始错误描述) +- `2003`:所选 `tier_id` 不存在、已停用或不在启用列表中 +- `2004`:`channel_code` 未配置或已禁用 +- `2005`:待支付充值单超过上限(`data.max_pending`、`data.pending_count`、`data.expire_seconds`) + +### 5.5 查看充值订单详情 +- **POST** `/api/finance/depositDetail` + +请求参数: +- `order_no`:string,必填(含义:充值订单号) + +返回参数(与 `depositCreate` 统一结构): +- `order_no`:string(含义:充值订单号) +- `amount`:string(2 位小数,含义:本单充值金额) +- `bonus_amount`:string(2 位小数,含义:本单赠送金额,无赠送为 `0.00`) +- `total_amount`:string(2 位小数,含义:入账总额) +- `pay_channel`:string(含义:支付通道标识) +- `paid`:bool(含义:是否已到账) +- `pay_url`:string(含义:第三方支付页面地址,已到账为空串) +- `status`:string(`pending`/`paid`/`failed`) +- `create_time`:int(含义:订单创建时间) +- `pay_time`:int(含义:订单到账时间,未到账为 0) + +### 5.6 查询充值订单列表 +- **POST** `/api/finance/depositList` + +用于我的充值记录页的分页列表;列表含订单状态,到账时间/支付通道等完整字段请再调用 `/api/finance/depositDetail` 获取。 + +请求参数: +- `page`:int,选填,默认 `1`(含义:页码,从 1 开始) +- `page_size`:int,选填,默认 `20`,最大 `100`(含义:每页数量,超出范围回退为 `20`) + +返回参数: +- `list`:array(含义:充值订单列表,按 `id desc` 排序) + - `order_no`:string(含义:充值订单号) + - `amount`:string(2 位小数,含义:本单充值金额) + - `bonus_amount`:string(2 位小数,含义:本单赠送金额,无赠送为 `0.00`) + - `status`:string(含义:订单状态,与 `depositDetail` 一致:`pending`/`paid`/`failed`) +- `pagination`:object(含义:分页信息) + - `page`:int(含义:当前页码) + - `page_size`:int(含义:每页数量) + - `total`:int(含义:总记录数) + +### 5.7 提现申请 +- **POST** `/api/finance/withdrawCreate` + +请求参数: +- `withdraw_coin`:string(含义:申请提现金额,必须 > 0) +- `receive_account`:string(含义:收款账号) +- `receive_type`:string(`bank`/`ewallet`/`crypto`,含义:收款类型) +- `idempotency_key`:string(含义:防重复提交提现) + +返回参数: +- `order_no`:string(含义:提现订单号) +- `status`:string(`pending_review`/`processing`,含义:提现状态) +- `fee_coin`:string(含义:手续费) +- `actual_arrival_coin`:string(含义:实到账金额) +- `risk_review_required`:bool(含义:是否命中人工审核) + +校验顺序(任一失败即返回对应错误码,不再创建订单): +1. 参数完整性与金额合法性(`code=1001`;金额必须为数值且 > 0) +2. **待审核订单数限制**:同一用户 `status=0`(待审核)的 `withdraw_order` 不得超过 3 笔,否则 `code=2004 Too many pending withdraw orders`,`data` 中回传: + - `max_pending`:上限值(当前为 `3`) + - `pending_count`:当前待审核订单数 +3. `coin_balance >= withdraw_coin`,否则 `code=2001 Insufficient balance` +4. **单笔上限校验**:`withdraw_coin <= max_withdrawable`,否则 `code=2002 Withdraw exceeds available bet flow`,`data` 中回传: + - `max_withdrawable`:**当前允许的单笔最大提现金额**(= `min(coin_balance, max_withdraw_by_flow)`,前端据此提示"最大可提现金额为 XXX") + - `coin_balance`、`bet_flow_coin`、`total_withdraw_coin`、`ratio` + - `max_withdraw_by_flow`:仅按打码量折算的上限(= `max(0, bet_flow_coin / ratio - total_withdraw_coin)`);`ratio=0` 时为 `null` +5. 以上全通过后在同一事务内: + - `withdraw_order` 写入:`amount` / `fee`(默认 0.5%) / `actual_amount = amount - fee` / `status=0`(待审核) / `channel_id` 取自用户归属渠道快照。 + - `user` 表原子更新:`coin -= withdraw_coin` 且 `total_withdraw_coin += withdraw_coin`(WHERE `coin >= withdraw_coin` 防止并发超额扣减)。 + - `user_wallet_record` 写入 `biz_type=withdraw`、`direction=2`、`amount=withdraw_coin`、`ref_type=withdraw_order`、`idempotency_key=wd_apply_{order_no}`,代表"冻结"动作。 + +说明(打码量即提现配额模型): +- 单笔最大可提现 `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`;每笔提现按 `withdraw_coin × ratio` 消耗打码配额,已消耗部分累积在 `total_withdraw_coin`。 +- `ratio = 0` 时视为"不限打码",单笔上限仅受 `coin_balance` 约束。 +- 采用"申请即冻结"语义:提现在移动端提交后立即从 `user.coin` 中扣减并写出金流水;后台审核 **拒绝** 时由管理端在同一事务中回冲余额、`total_withdraw_coin` 与流水,不出现"等待审核期间用户还能把这笔钱再下注"的漏洞。 +- 后台审核 **通过** 时不再额外触碰余额;若管理员调整了 `amount` 或 `fee`,按新旧差额再生成一条 `withdraw` / `withdraw_refund` 流水以保持账务平衡,并同步修正 `total_withdraw_coin`。 +- `withdraw_bet_flow_ratio` 由后台「游戏配置」维护,默认 `1.00`,修改后对新请求立即生效。 + +### 5.8 查看提现订单详情 +- **POST** `/api/finance/withdrawDetail` + +请求参数: +- `order_no`:string,必填(含义:提现订单号) + +返回参数: +- `order_no`:string(含义:提现订单号) +- `status`:string(`pending_review`/`approved`/`rejected`,含义:审核状态;`status=3 已打款` 暂未对外暴露,合并到 `approved`) +- `withdraw_coin`:string(含义:申请提现金额,与后台 `withdraw_order.amount` 对齐) +- `fee_coin`:string(含义:手续费,与后台 `withdraw_order.fee` 对齐) +- `actual_arrival_coin`:string(含义:实际到账金额 = 申请金额 - 手续费;后台审核调整后会同步刷新) +- `reject_reason`:string/null(含义:拒绝原因,`status=rejected` 时取自 `withdraw_order.remark`,否则为 `null`) +- `create_time`:int(含义:申请时间) +- `review_time`:int/null(含义:审核时间戳,未审核为 `null`) + +### 5.9 查询提现订单列表 +- **POST** `/api/finance/withdrawList` + +用于我的提现记录页的分页列表;列表含审核/打款状态摘要,手续费、实到账、拒绝原因等请再调用 `/api/finance/withdrawDetail` 获取。 + +请求参数: +- `page`:int,选填,默认 `1`(含义:页码,从 1 开始) +- `page_size`:int,选填,默认 `20`,最大 `100`(含义:每页数量,超出范围回退为 `20`) + +返回参数: +- `list`:array(含义:提现订单列表,按 `id desc` 排序) + - `order_no`:string(含义:提现订单号) + - `amount`:string(2 位小数,含义:申请提现金额,与后台 `withdraw_order.amount` 对齐) + - `status`:string(含义:订单状态,与 `withdrawDetail` 一致:`pending_review`/`approved`/`rejected`;后台已打款 `status=3` 合并为 `approved`) +- `pagination`:object(含义:分页信息) + - `page`:int(含义:当前页码) + - `page_size`:int(含义:每页数量) + - `total`:int(含义:总记录数) + +--- + +## 6. 公告与消息模块(operation/notice) + +### 6.1 拉取公告列表 +- **GET** `/api/notice/noticeList` + +请求参数(query string): +- `page`:int(可选,默认 1) +- `page_size`:int(可选,默认 20) + +返回参数: +- `list`:array + - `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 与私有频道 + +Apipost(v7+)支持 **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` + - Body(x-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×tamp=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(负责人所属角色组层级路径列表) +- `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 + 路由。 diff --git a/docs/本次代码变更说明.md b/docs/本次代码变更说明.md new file mode 100644 index 0000000..e45e53d --- /dev/null +++ b/docs/本次代码变更说明.md @@ -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` 可正常打开 diff --git a/index.html b/index.html index bb56a27..3ea8589 100644 --- a/index.html +++ b/index.html @@ -6,23 +6,23 @@ - - + + - + - React SPA Template + 36字花
diff --git a/package.json b/package.json index 4ee3e0c..080e5b8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@tanstack/react-router": "^1.168.22", "i18next": "^26.0.5", "ky": "^2.0.1", + "lucide-react": "^1.9.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^17.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d4d468..f42502e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: ky: specifier: ^2.0.1 version: 2.0.1 + lucide-react: + specifier: ^1.9.0 + version: 1.9.0(react@19.2.5) react: specifier: ^19.2.4 version: 19.2.5 @@ -1529,6 +1532,11 @@ packages: lru-cache@5.1.1: 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: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3311,6 +3319,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.9.0(react@19.2.5): + dependencies: + react: 19.2.5 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src/assets/animal/1.webp b/src/assets/animal/1.webp new file mode 100644 index 0000000..df1dbb9 Binary files /dev/null and b/src/assets/animal/1.webp differ diff --git a/src/assets/animal/10.webp b/src/assets/animal/10.webp new file mode 100644 index 0000000..d9cb0bb Binary files /dev/null and b/src/assets/animal/10.webp differ diff --git a/src/assets/animal/11.webp b/src/assets/animal/11.webp new file mode 100644 index 0000000..55d319f Binary files /dev/null and b/src/assets/animal/11.webp differ diff --git a/src/assets/animal/12.webp b/src/assets/animal/12.webp new file mode 100644 index 0000000..a3eeb5e Binary files /dev/null and b/src/assets/animal/12.webp differ diff --git a/src/assets/animal/13.webp b/src/assets/animal/13.webp new file mode 100644 index 0000000..10679ad Binary files /dev/null and b/src/assets/animal/13.webp differ diff --git a/src/assets/animal/14.webp b/src/assets/animal/14.webp new file mode 100644 index 0000000..95e63e4 Binary files /dev/null and b/src/assets/animal/14.webp differ diff --git a/src/assets/animal/15.webp b/src/assets/animal/15.webp new file mode 100644 index 0000000..d7c71f6 Binary files /dev/null and b/src/assets/animal/15.webp differ diff --git a/src/assets/animal/16.webp b/src/assets/animal/16.webp new file mode 100644 index 0000000..546dc0b Binary files /dev/null and b/src/assets/animal/16.webp differ diff --git a/src/assets/animal/17.webp b/src/assets/animal/17.webp new file mode 100644 index 0000000..137d958 Binary files /dev/null and b/src/assets/animal/17.webp differ diff --git a/src/assets/animal/18.webp b/src/assets/animal/18.webp new file mode 100644 index 0000000..421c064 Binary files /dev/null and b/src/assets/animal/18.webp differ diff --git a/src/assets/animal/19.webp b/src/assets/animal/19.webp new file mode 100644 index 0000000..cd69941 Binary files /dev/null and b/src/assets/animal/19.webp differ diff --git a/src/assets/animal/2.webp b/src/assets/animal/2.webp new file mode 100644 index 0000000..21d448d Binary files /dev/null and b/src/assets/animal/2.webp differ diff --git a/src/assets/animal/20.webp b/src/assets/animal/20.webp new file mode 100644 index 0000000..a4c7221 Binary files /dev/null and b/src/assets/animal/20.webp differ diff --git a/src/assets/animal/21.webp b/src/assets/animal/21.webp new file mode 100644 index 0000000..560261e Binary files /dev/null and b/src/assets/animal/21.webp differ diff --git a/src/assets/animal/22.webp b/src/assets/animal/22.webp new file mode 100644 index 0000000..6a2a2e5 Binary files /dev/null and b/src/assets/animal/22.webp differ diff --git a/src/assets/animal/23.webp b/src/assets/animal/23.webp new file mode 100644 index 0000000..087ff3e Binary files /dev/null and b/src/assets/animal/23.webp differ diff --git a/src/assets/animal/24.webp b/src/assets/animal/24.webp new file mode 100644 index 0000000..6b713c8 Binary files /dev/null and b/src/assets/animal/24.webp differ diff --git a/src/assets/animal/25.webp b/src/assets/animal/25.webp new file mode 100644 index 0000000..e9927b1 Binary files /dev/null and b/src/assets/animal/25.webp differ diff --git a/src/assets/animal/26.webp b/src/assets/animal/26.webp new file mode 100644 index 0000000..883c8de Binary files /dev/null and b/src/assets/animal/26.webp differ diff --git a/src/assets/animal/27.webp b/src/assets/animal/27.webp new file mode 100644 index 0000000..6c4b6da Binary files /dev/null and b/src/assets/animal/27.webp differ diff --git a/src/assets/animal/28.webp b/src/assets/animal/28.webp new file mode 100644 index 0000000..65ccb32 Binary files /dev/null and b/src/assets/animal/28.webp differ diff --git a/src/assets/animal/29.webp b/src/assets/animal/29.webp new file mode 100644 index 0000000..d545bd0 Binary files /dev/null and b/src/assets/animal/29.webp differ diff --git a/src/assets/animal/3.webp b/src/assets/animal/3.webp new file mode 100644 index 0000000..dd3f4c2 Binary files /dev/null and b/src/assets/animal/3.webp differ diff --git a/src/assets/animal/30.webp b/src/assets/animal/30.webp new file mode 100644 index 0000000..fca1bcc Binary files /dev/null and b/src/assets/animal/30.webp differ diff --git a/src/assets/animal/31.webp b/src/assets/animal/31.webp new file mode 100644 index 0000000..e848520 Binary files /dev/null and b/src/assets/animal/31.webp differ diff --git a/src/assets/animal/32.webp b/src/assets/animal/32.webp new file mode 100644 index 0000000..43ccd39 Binary files /dev/null and b/src/assets/animal/32.webp differ diff --git a/src/assets/animal/33.webp b/src/assets/animal/33.webp new file mode 100644 index 0000000..c4822ed Binary files /dev/null and b/src/assets/animal/33.webp differ diff --git a/src/assets/animal/34.webp b/src/assets/animal/34.webp new file mode 100644 index 0000000..754e55c Binary files /dev/null and b/src/assets/animal/34.webp differ diff --git a/src/assets/animal/35.webp b/src/assets/animal/35.webp new file mode 100644 index 0000000..2a0b1f7 Binary files /dev/null and b/src/assets/animal/35.webp differ diff --git a/src/assets/animal/36.webp b/src/assets/animal/36.webp new file mode 100644 index 0000000..ced01e0 Binary files /dev/null and b/src/assets/animal/36.webp differ diff --git a/src/assets/animal/4.webp b/src/assets/animal/4.webp new file mode 100644 index 0000000..16624b5 Binary files /dev/null and b/src/assets/animal/4.webp differ diff --git a/src/assets/animal/5.webp b/src/assets/animal/5.webp new file mode 100644 index 0000000..c53193b Binary files /dev/null and b/src/assets/animal/5.webp differ diff --git a/src/assets/animal/6.webp b/src/assets/animal/6.webp new file mode 100644 index 0000000..60c49ae Binary files /dev/null and b/src/assets/animal/6.webp differ diff --git a/src/assets/animal/7.webp b/src/assets/animal/7.webp new file mode 100644 index 0000000..d17b21e Binary files /dev/null and b/src/assets/animal/7.webp differ diff --git a/src/assets/animal/8.webp b/src/assets/animal/8.webp new file mode 100644 index 0000000..583c1ed Binary files /dev/null and b/src/assets/animal/8.webp differ diff --git a/src/assets/animal/9.webp b/src/assets/animal/9.webp new file mode 100644 index 0000000..ae1fc71 Binary files /dev/null and b/src/assets/animal/9.webp differ diff --git a/src/assets/slices.zip b/src/assets/slices.zip new file mode 100644 index 0000000..0b7e767 Binary files /dev/null and b/src/assets/slices.zip differ diff --git a/src/assets/slices/10@2x.webp b/src/assets/slices/10@2x.webp new file mode 100644 index 0000000..d9cb0bb Binary files /dev/null and b/src/assets/slices/10@2x.webp differ diff --git a/src/assets/slices/11@2x.webp b/src/assets/slices/11@2x.webp new file mode 100644 index 0000000..55d319f Binary files /dev/null and b/src/assets/slices/11@2x.webp differ diff --git a/src/assets/slices/12@2x.webp b/src/assets/slices/12@2x.webp new file mode 100644 index 0000000..a3eeb5e Binary files /dev/null and b/src/assets/slices/12@2x.webp differ diff --git a/src/assets/slices/13@2x.webp b/src/assets/slices/13@2x.webp new file mode 100644 index 0000000..10679ad Binary files /dev/null and b/src/assets/slices/13@2x.webp differ diff --git a/src/assets/slices/14@2x.webp b/src/assets/slices/14@2x.webp new file mode 100644 index 0000000..95e63e4 Binary files /dev/null and b/src/assets/slices/14@2x.webp differ diff --git a/src/assets/slices/15@2x.webp b/src/assets/slices/15@2x.webp new file mode 100644 index 0000000..d7c71f6 Binary files /dev/null and b/src/assets/slices/15@2x.webp differ diff --git a/src/assets/slices/16@2x.webp b/src/assets/slices/16@2x.webp new file mode 100644 index 0000000..546dc0b Binary files /dev/null and b/src/assets/slices/16@2x.webp differ diff --git a/src/assets/slices/17@2x.webp b/src/assets/slices/17@2x.webp new file mode 100644 index 0000000..137d958 Binary files /dev/null and b/src/assets/slices/17@2x.webp differ diff --git a/src/assets/slices/18@2x.webp b/src/assets/slices/18@2x.webp new file mode 100644 index 0000000..421c064 Binary files /dev/null and b/src/assets/slices/18@2x.webp differ diff --git a/src/assets/slices/19@2x.webp b/src/assets/slices/19@2x.webp new file mode 100644 index 0000000..cd69941 Binary files /dev/null and b/src/assets/slices/19@2x.webp differ diff --git a/src/assets/slices/1@2x.webp b/src/assets/slices/1@2x.webp new file mode 100644 index 0000000..df1dbb9 Binary files /dev/null and b/src/assets/slices/1@2x.webp differ diff --git a/src/assets/slices/20@2x.webp b/src/assets/slices/20@2x.webp new file mode 100644 index 0000000..a4c7221 Binary files /dev/null and b/src/assets/slices/20@2x.webp differ diff --git a/src/assets/slices/21@2x.webp b/src/assets/slices/21@2x.webp new file mode 100644 index 0000000..560261e Binary files /dev/null and b/src/assets/slices/21@2x.webp differ diff --git a/src/assets/slices/22@2x.webp b/src/assets/slices/22@2x.webp new file mode 100644 index 0000000..6a2a2e5 Binary files /dev/null and b/src/assets/slices/22@2x.webp differ diff --git a/src/assets/slices/23@2x.webp b/src/assets/slices/23@2x.webp new file mode 100644 index 0000000..087ff3e Binary files /dev/null and b/src/assets/slices/23@2x.webp differ diff --git a/src/assets/slices/24@2x.webp b/src/assets/slices/24@2x.webp new file mode 100644 index 0000000..6b713c8 Binary files /dev/null and b/src/assets/slices/24@2x.webp differ diff --git a/src/assets/slices/25@2x.webp b/src/assets/slices/25@2x.webp new file mode 100644 index 0000000..e9927b1 Binary files /dev/null and b/src/assets/slices/25@2x.webp differ diff --git a/src/assets/slices/26@2x.webp b/src/assets/slices/26@2x.webp new file mode 100644 index 0000000..883c8de Binary files /dev/null and b/src/assets/slices/26@2x.webp differ diff --git a/src/assets/slices/27@2x.webp b/src/assets/slices/27@2x.webp new file mode 100644 index 0000000..6c4b6da Binary files /dev/null and b/src/assets/slices/27@2x.webp differ diff --git a/src/assets/slices/28@2x.webp b/src/assets/slices/28@2x.webp new file mode 100644 index 0000000..65ccb32 Binary files /dev/null and b/src/assets/slices/28@2x.webp differ diff --git a/src/assets/slices/29@2x.webp b/src/assets/slices/29@2x.webp new file mode 100644 index 0000000..d545bd0 Binary files /dev/null and b/src/assets/slices/29@2x.webp differ diff --git a/src/assets/slices/2@2x.webp b/src/assets/slices/2@2x.webp new file mode 100644 index 0000000..21d448d Binary files /dev/null and b/src/assets/slices/2@2x.webp differ diff --git a/src/assets/slices/30@2x.webp b/src/assets/slices/30@2x.webp new file mode 100644 index 0000000..fca1bcc Binary files /dev/null and b/src/assets/slices/30@2x.webp differ diff --git a/src/assets/slices/31@2x.webp b/src/assets/slices/31@2x.webp new file mode 100644 index 0000000..e848520 Binary files /dev/null and b/src/assets/slices/31@2x.webp differ diff --git a/src/assets/slices/32@2x.webp b/src/assets/slices/32@2x.webp new file mode 100644 index 0000000..43ccd39 Binary files /dev/null and b/src/assets/slices/32@2x.webp differ diff --git a/src/assets/slices/33@2x.webp b/src/assets/slices/33@2x.webp new file mode 100644 index 0000000..c4822ed Binary files /dev/null and b/src/assets/slices/33@2x.webp differ diff --git a/src/assets/slices/34@2x.webp b/src/assets/slices/34@2x.webp new file mode 100644 index 0000000..754e55c Binary files /dev/null and b/src/assets/slices/34@2x.webp differ diff --git a/src/assets/slices/35@2x.webp b/src/assets/slices/35@2x.webp new file mode 100644 index 0000000..2a0b1f7 Binary files /dev/null and b/src/assets/slices/35@2x.webp differ diff --git a/src/assets/slices/36@2x.webp b/src/assets/slices/36@2x.webp new file mode 100644 index 0000000..ced01e0 Binary files /dev/null and b/src/assets/slices/36@2x.webp differ diff --git a/src/assets/slices/3@2x.webp b/src/assets/slices/3@2x.webp new file mode 100644 index 0000000..dd3f4c2 Binary files /dev/null and b/src/assets/slices/3@2x.webp differ diff --git a/src/assets/slices/4@2x.webp b/src/assets/slices/4@2x.webp new file mode 100644 index 0000000..16624b5 Binary files /dev/null and b/src/assets/slices/4@2x.webp differ diff --git a/src/assets/slices/5@2x.webp b/src/assets/slices/5@2x.webp new file mode 100644 index 0000000..c53193b Binary files /dev/null and b/src/assets/slices/5@2x.webp differ diff --git a/src/assets/slices/6@2x.webp b/src/assets/slices/6@2x.webp new file mode 100644 index 0000000..60c49ae Binary files /dev/null and b/src/assets/slices/6@2x.webp differ diff --git a/src/assets/slices/7@2x.webp b/src/assets/slices/7@2x.webp new file mode 100644 index 0000000..d17b21e Binary files /dev/null and b/src/assets/slices/7@2x.webp differ diff --git a/src/assets/slices/8@2x.webp b/src/assets/slices/8@2x.webp new file mode 100644 index 0000000..583c1ed Binary files /dev/null and b/src/assets/slices/8@2x.webp differ diff --git a/src/assets/slices/9@2x.webp b/src/assets/slices/9@2x.webp new file mode 100644 index 0000000..ae1fc71 Binary files /dev/null and b/src/assets/slices/9@2x.webp differ diff --git a/src/assets/slices/Auto-Spin亮@2x.webp b/src/assets/slices/Auto-Spin亮@2x.webp new file mode 100644 index 0000000..061b542 Binary files /dev/null and b/src/assets/slices/Auto-Spin亮@2x.webp differ diff --git a/src/assets/slices/Auto-Spin暗@2x.webp b/src/assets/slices/Auto-Spin暗@2x.webp new file mode 100644 index 0000000..e0c7cc5 Binary files /dev/null and b/src/assets/slices/Auto-Spin暗@2x.webp differ diff --git a/src/assets/slices/LOGO@2x.webp b/src/assets/slices/LOGO@2x.webp new file mode 100644 index 0000000..89e7c34 Binary files /dev/null and b/src/assets/slices/LOGO@2x.webp differ diff --git a/src/assets/slices/clear亮@2x.webp b/src/assets/slices/clear亮@2x.webp new file mode 100644 index 0000000..255c6ac Binary files /dev/null and b/src/assets/slices/clear亮@2x.webp differ diff --git a/src/assets/slices/clear暗@2x.webp b/src/assets/slices/clear暗@2x.webp new file mode 100644 index 0000000..d461bb1 Binary files /dev/null and b/src/assets/slices/clear暗@2x.webp differ diff --git a/src/assets/slices/confirm按钮@2x.webp b/src/assets/slices/confirm按钮@2x.webp new file mode 100644 index 0000000..6bb9652 Binary files /dev/null and b/src/assets/slices/confirm按钮@2x.webp differ diff --git a/src/assets/slices/repeat亮@2x.webp b/src/assets/slices/repeat亮@2x.webp new file mode 100644 index 0000000..a37b3e7 Binary files /dev/null and b/src/assets/slices/repeat亮@2x.webp differ diff --git a/src/assets/slices/repeat暗@2x.webp b/src/assets/slices/repeat暗@2x.webp new file mode 100644 index 0000000..4f6327f Binary files /dev/null and b/src/assets/slices/repeat暗@2x.webp differ diff --git a/src/assets/slices/trebd map按钮@2x.webp b/src/assets/slices/trebd map按钮@2x.webp new file mode 100644 index 0000000..6df1f2a Binary files /dev/null and b/src/assets/slices/trebd map按钮@2x.webp differ diff --git a/src/assets/slices/上栏@2x.webp b/src/assets/slices/上栏@2x.webp new file mode 100644 index 0000000..b2030b6 Binary files /dev/null and b/src/assets/slices/上栏@2x.webp differ diff --git a/src/assets/slices/下拉@2x.webp b/src/assets/slices/下拉@2x.webp new file mode 100644 index 0000000..f188a96 Binary files /dev/null and b/src/assets/slices/下拉@2x.webp differ diff --git a/src/assets/slices/中奖变亮遮罩@2x.webp b/src/assets/slices/中奖变亮遮罩@2x.webp new file mode 100644 index 0000000..7902c71 Binary files /dev/null and b/src/assets/slices/中奖变亮遮罩@2x.webp differ diff --git a/src/assets/slices/中奖记录框@2x.webp b/src/assets/slices/中奖记录框@2x.webp new file mode 100644 index 0000000..5d13334 Binary files /dev/null and b/src/assets/slices/中奖记录框@2x.webp differ diff --git a/src/assets/slices/信息 (1)@2x.webp b/src/assets/slices/信息 (1)@2x.webp new file mode 100644 index 0000000..7147941 Binary files /dev/null and b/src/assets/slices/信息 (1)@2x.webp differ diff --git a/src/assets/slices/倒计时下部框@2x.webp b/src/assets/slices/倒计时下部框@2x.webp new file mode 100644 index 0000000..418df7a Binary files /dev/null and b/src/assets/slices/倒计时下部框@2x.webp differ diff --git a/src/assets/slices/倒计时框@2x.webp b/src/assets/slices/倒计时框@2x.webp new file mode 100644 index 0000000..41c10ec Binary files /dev/null and b/src/assets/slices/倒计时框@2x.webp differ diff --git a/src/assets/slices/充值按钮@2x.webp b/src/assets/slices/充值按钮@2x.webp new file mode 100644 index 0000000..1c1aba8 Binary files /dev/null and b/src/assets/slices/充值按钮@2x.webp differ diff --git a/src/assets/slices/光效@2x.webp b/src/assets/slices/光效@2x.webp new file mode 100644 index 0000000..ff296c1 Binary files /dev/null and b/src/assets/slices/光效@2x.webp differ diff --git a/src/assets/slices/公告@2x.webp b/src/assets/slices/公告@2x.webp new file mode 100644 index 0000000..996f498 Binary files /dev/null and b/src/assets/slices/公告@2x.webp differ diff --git a/src/assets/slices/公告栏@2x.webp b/src/assets/slices/公告栏@2x.webp new file mode 100644 index 0000000..5d2878e Binary files /dev/null and b/src/assets/slices/公告栏@2x.webp differ diff --git a/src/assets/slices/分割线1@2x.webp b/src/assets/slices/分割线1@2x.webp new file mode 100644 index 0000000..15f8f5a Binary files /dev/null and b/src/assets/slices/分割线1@2x.webp differ diff --git a/src/assets/slices/删除@2x.webp b/src/assets/slices/删除@2x.webp new file mode 100644 index 0000000..be852aa Binary files /dev/null and b/src/assets/slices/删除@2x.webp differ diff --git a/src/assets/slices/卡片边框@2x.webp b/src/assets/slices/卡片边框@2x.webp new file mode 100644 index 0000000..e3a8ea9 Binary files /dev/null and b/src/assets/slices/卡片边框@2x.webp differ diff --git a/src/assets/slices/卡片边框素材@2x.webp b/src/assets/slices/卡片边框素材@2x.webp new file mode 100644 index 0000000..f54408c Binary files /dev/null and b/src/assets/slices/卡片边框素材@2x.webp differ diff --git a/src/assets/slices/国旗@2x.webp b/src/assets/slices/国旗@2x.webp new file mode 100644 index 0000000..209535f Binary files /dev/null and b/src/assets/slices/国旗@2x.webp differ diff --git a/src/assets/slices/声音关@2x.webp b/src/assets/slices/声音关@2x.webp new file mode 100644 index 0000000..fcb0fd6 Binary files /dev/null and b/src/assets/slices/声音关@2x.webp differ diff --git a/src/assets/slices/声音开@2x.webp b/src/assets/slices/声音开@2x.webp new file mode 100644 index 0000000..b7f3945 Binary files /dev/null and b/src/assets/slices/声音开@2x.webp differ diff --git a/src/assets/slices/头像@2x.webp b/src/assets/slices/头像@2x.webp new file mode 100644 index 0000000..27a2653 Binary files /dev/null and b/src/assets/slices/头像@2x.webp differ diff --git a/src/assets/slices/头像框@2x.webp b/src/assets/slices/头像框@2x.webp new file mode 100644 index 0000000..a72355d Binary files /dev/null and b/src/assets/slices/头像框@2x.webp differ diff --git a/src/assets/slices/底中框@2x.webp b/src/assets/slices/底中框@2x.webp new file mode 100644 index 0000000..4af8a88 Binary files /dev/null and b/src/assets/slices/底中框@2x.webp differ diff --git a/src/assets/slices/循环 (1)@2x.webp b/src/assets/slices/循环 (1)@2x.webp new file mode 100644 index 0000000..de2f09e Binary files /dev/null and b/src/assets/slices/循环 (1)@2x.webp differ diff --git a/src/assets/slices/按钮底框@2x.webp b/src/assets/slices/按钮底框@2x.webp new file mode 100644 index 0000000..9678677 Binary files /dev/null and b/src/assets/slices/按钮底框@2x.webp differ diff --git a/src/assets/slices/框内可滑动@2x.webp b/src/assets/slices/框内可滑动@2x.webp new file mode 100644 index 0000000..0d25cbe Binary files /dev/null and b/src/assets/slices/框内可滑动@2x.webp differ diff --git a/src/assets/slices/椭圆 3@2x.webp b/src/assets/slices/椭圆 3@2x.webp new file mode 100644 index 0000000..f5a8bc7 Binary files /dev/null and b/src/assets/slices/椭圆 3@2x.webp differ diff --git a/src/assets/slices/火@2x.webp b/src/assets/slices/火@2x.webp new file mode 100644 index 0000000..a1f2957 Binary files /dev/null and b/src/assets/slices/火@2x.webp differ diff --git a/src/assets/slices/矩形 3 拷贝 2@2x(1).webp b/src/assets/slices/矩形 3 拷贝 2@2x(1).webp new file mode 100644 index 0000000..f8f8079 Binary files /dev/null and b/src/assets/slices/矩形 3 拷贝 2@2x(1).webp differ diff --git a/src/assets/slices/矩形 3 拷贝 2@2x(2).webp b/src/assets/slices/矩形 3 拷贝 2@2x(2).webp new file mode 100644 index 0000000..6696d04 Binary files /dev/null and b/src/assets/slices/矩形 3 拷贝 2@2x(2).webp differ diff --git a/src/assets/slices/矩形 3 拷贝 2@2x.webp b/src/assets/slices/矩形 3 拷贝 2@2x.webp new file mode 100644 index 0000000..7c64ff2 Binary files /dev/null and b/src/assets/slices/矩形 3 拷贝 2@2x.webp differ diff --git a/src/assets/slices/矩形 3 拷贝 3@2x.webp b/src/assets/slices/矩形 3 拷贝 3@2x.webp new file mode 100644 index 0000000..838396b Binary files /dev/null and b/src/assets/slices/矩形 3 拷贝 3@2x.webp differ diff --git a/src/assets/slices/矩形 4 拷贝@2x.webp b/src/assets/slices/矩形 4 拷贝@2x.webp new file mode 100644 index 0000000..62d1874 Binary files /dev/null and b/src/assets/slices/矩形 4 拷贝@2x.webp differ diff --git a/src/assets/slices/矩形 510@2x.webp b/src/assets/slices/矩形 510@2x.webp new file mode 100644 index 0000000..df3ddaa Binary files /dev/null and b/src/assets/slices/矩形 510@2x.webp differ diff --git a/src/assets/slices/筹码100@2x.webp b/src/assets/slices/筹码100@2x.webp new file mode 100644 index 0000000..48a1983 Binary files /dev/null and b/src/assets/slices/筹码100@2x.webp differ diff --git a/src/assets/slices/筹码10@2x.webp b/src/assets/slices/筹码10@2x.webp new file mode 100644 index 0000000..55b3c12 Binary files /dev/null and b/src/assets/slices/筹码10@2x.webp differ diff --git a/src/assets/slices/筹码1@2x.webp b/src/assets/slices/筹码1@2x.webp new file mode 100644 index 0000000..b82007b Binary files /dev/null and b/src/assets/slices/筹码1@2x.webp differ diff --git a/src/assets/slices/筹码25@2x.webp b/src/assets/slices/筹码25@2x.webp new file mode 100644 index 0000000..1ef3b2b Binary files /dev/null and b/src/assets/slices/筹码25@2x.webp differ diff --git a/src/assets/slices/筹码50@2x.webp b/src/assets/slices/筹码50@2x.webp new file mode 100644 index 0000000..393dba1 Binary files /dev/null and b/src/assets/slices/筹码50@2x.webp differ diff --git a/src/assets/slices/筹码5@2x.webp b/src/assets/slices/筹码5@2x.webp new file mode 100644 index 0000000..5fcea44 Binary files /dev/null and b/src/assets/slices/筹码5@2x.webp differ diff --git a/src/assets/slices/筹码底@2x.webp b/src/assets/slices/筹码底@2x.webp new file mode 100644 index 0000000..95b8563 Binary files /dev/null and b/src/assets/slices/筹码底@2x.webp differ diff --git a/src/assets/slices/筹码选中特效@2x.webp b/src/assets/slices/筹码选中特效@2x.webp new file mode 100644 index 0000000..7b51f0d Binary files /dev/null and b/src/assets/slices/筹码选中特效@2x.webp differ diff --git a/src/assets/slices/网络信号@2x.webp b/src/assets/slices/网络信号@2x.webp new file mode 100644 index 0000000..ac17d33 Binary files /dev/null and b/src/assets/slices/网络信号@2x.webp differ diff --git a/src/assets/slices/自选筹码+@2x.webp b/src/assets/slices/自选筹码+@2x.webp new file mode 100644 index 0000000..22b8858 Binary files /dev/null and b/src/assets/slices/自选筹码+@2x.webp differ diff --git a/src/assets/slices/自选筹码-@2x.webp b/src/assets/slices/自选筹码-@2x.webp new file mode 100644 index 0000000..550c504 Binary files /dev/null and b/src/assets/slices/自选筹码-@2x.webp differ diff --git a/src/assets/slices/自选筹码底@2x.webp b/src/assets/slices/自选筹码底@2x.webp new file mode 100644 index 0000000..5e66301 Binary files /dev/null and b/src/assets/slices/自选筹码底@2x.webp differ diff --git a/src/assets/slices/规则@2x.webp b/src/assets/slices/规则@2x.webp new file mode 100644 index 0000000..7f7062e Binary files /dev/null and b/src/assets/slices/规则@2x.webp differ diff --git a/src/assets/slices/设置@2x.webp b/src/assets/slices/设置@2x.webp new file mode 100644 index 0000000..a50b5d2 Binary files /dev/null and b/src/assets/slices/设置@2x.webp differ diff --git a/src/assets/slices/邮件@2x.webp b/src/assets/slices/邮件@2x.webp new file mode 100644 index 0000000..380d95f Binary files /dev/null and b/src/assets/slices/邮件@2x.webp differ diff --git a/src/assets/slices/金边@2x.webp b/src/assets/slices/金边@2x.webp new file mode 100644 index 0000000..01be9e4 Binary files /dev/null and b/src/assets/slices/金边@2x.webp differ diff --git a/src/assets/slices/钻石@2x(1).webp b/src/assets/slices/钻石@2x(1).webp new file mode 100644 index 0000000..39e2e63 Binary files /dev/null and b/src/assets/slices/钻石@2x(1).webp differ diff --git a/src/assets/slices/钻石@2x(2).webp b/src/assets/slices/钻石@2x(2).webp new file mode 100644 index 0000000..624f05b Binary files /dev/null and b/src/assets/slices/钻石@2x(2).webp differ diff --git a/src/assets/slices/钻石@2x.webp b/src/assets/slices/钻石@2x.webp new file mode 100644 index 0000000..47df018 Binary files /dev/null and b/src/assets/slices/钻石@2x.webp differ diff --git a/src/assets/slices/锁@2x.webp b/src/assets/slices/锁@2x.webp new file mode 100644 index 0000000..918a134 Binary files /dev/null and b/src/assets/slices/锁@2x.webp differ diff --git a/src/assets/system/a.webp b/src/assets/system/a.webp new file mode 100644 index 0000000..418df7a Binary files /dev/null and b/src/assets/system/a.webp differ diff --git a/src/assets/system/avatar.webp b/src/assets/system/avatar.webp new file mode 100644 index 0000000..27a2653 Binary files /dev/null and b/src/assets/system/avatar.webp differ diff --git a/src/assets/system/diamond.webp b/src/assets/system/diamond.webp new file mode 100644 index 0000000..47df018 Binary files /dev/null and b/src/assets/system/diamond.webp differ diff --git a/src/assets/system/logo.webp b/src/assets/system/logo.webp new file mode 100644 index 0000000..89e7c34 Binary files /dev/null and b/src/assets/system/logo.webp differ diff --git a/src/assets/system/wifi.webp b/src/assets/system/wifi.webp new file mode 100644 index 0000000..ac17d33 Binary files /dev/null and b/src/assets/system/wifi.webp differ diff --git a/src/components/language-link.tsx b/src/components/language-link.tsx new file mode 100644 index 0000000..2a3bf18 --- /dev/null +++ b/src/components/language-link.tsx @@ -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 ( + + {label} + + ) +} diff --git a/src/components/smart-image.tsx b/src/components/smart-image.tsx new file mode 100644 index 0000000..88ff17d --- /dev/null +++ b/src/components/smart-image.tsx @@ -0,0 +1,197 @@ +import { + type CSSProperties, + forwardRef, + type ImgHTMLAttributes, + type ReactNode, + useEffect, + useMemo, + useState, +} from 'react' + +const cx = (...classes: Array) => + classes.filter(Boolean).join(' ') + +export interface SmartImageProps + extends Omit< + ImgHTMLAttributes, + '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( + 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 ( +
+ {placeholderSrc ? ( + + ) : null} + + {showSkeleton && shouldShowVisualPlaceholder ? ( + + ) + }, +) + +/** + * + * + * 如果是首屏主视觉图: + * + * + * */ diff --git a/src/constants/index.ts b/src/constants/index.ts index 03dc5aa..0c13546 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -2,11 +2,11 @@ export const APP_ROOT_ELEMENT_ID = 'root' /** @description 应用名称,用于文档标题和分享元信息。 */ -export const APP_NAME = 'React SPA Template' +export const APP_NAME = '36字花' /** @description 应用默认的页面描述,用于 SEO 和分享卡片。 */ 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 认证状态持久化到浏览器时使用的存储键。 */ export const AUTH_STORAGE_KEY = 'auth-session' @@ -39,3 +39,6 @@ export const QUERY_RETRYABLE_STATUS_CODES = [ /** @description 国际化语言设置持久化到浏览器时使用的存储键。 */ export const I18N_LANGUAGE_STORAGE_KEY = 'app-language' + +/** @description 桌面端布局切换起始断点。 */ +export const DESKTOP_LAYOUT_MIN_WIDTH_PX = 1024 diff --git a/src/data/animal.ts b/src/data/animal.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/game/api/game-api.ts b/src/features/game/api/game-api.ts new file mode 100644 index 0000000..8cf4e6f --- /dev/null +++ b/src/features/game/api/game-api.ts @@ -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(GAME_API_ENDPOINTS.bootstrap) + + return normalizeGameBootstrap(response.data) +} + +export async function getGameRoundFeed() { + const response = await api.get(GAME_API_ENDPOINTS.roundFeed) + + return normalizeGameRoundFeed(response.data) +} + +export async function getGameAnnouncements() { + const response = await api.get( + GAME_API_ENDPOINTS.announcements, + ) + + return normalizeAnnouncementState(response.data.announcements) +} + +export async function getMockGameBootstrap(latencyMs = 120) { + await new Promise((resolve) => { + setTimeout(resolve, latencyMs) + }) + + return createMockGameBootstrapSnapshot() +} diff --git a/src/features/game/api/index.ts b/src/features/game/api/index.ts new file mode 100644 index 0000000..07dbae4 --- /dev/null +++ b/src/features/game/api/index.ts @@ -0,0 +1,2 @@ +export * from './game-api' +export * from './types' diff --git a/src/features/game/api/types.ts b/src/features/game/api/types.ts new file mode 100644 index 0000000..270299f --- /dev/null +++ b/src/features/game/api/types.ts @@ -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, +} diff --git a/src/features/game/components/desktop/desktop-animal.tsx b/src/features/game/components/desktop/desktop-animal.tsx new file mode 100644 index 0000000..8836f43 --- /dev/null +++ b/src/features/game/components/desktop/desktop-animal.tsx @@ -0,0 +1,66 @@ +import { SmartImage } from '@/components/smart-image' + +const cx = (...classes: Array) => + classes.filter(Boolean).join(' ') + +const animalModules = import.meta.glob('../../../../assets/animal/*.webp', { + eager: true, + import: 'default', +}) as Record + +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 ( +
+ {animalImageList.map((item) => { + const isActive = item.id === activeId + + return ( + + ) + })} +
+ ) +} diff --git a/src/features/game/components/desktop/desktop-header.tsx b/src/features/game/components/desktop/desktop-header.tsx new file mode 100644 index 0000000..3dbde05 --- /dev/null +++ b/src/features/game/components/desktop/desktop-header.tsx @@ -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 ( +
+
+
+ +
+ +
+ +
+ 24 ms +
+
+ +
+
System Time
+
20:05:12 GMT+08
+
+ +
+
+ +
Rules & Ddds
+
+ +
+ +
Pesan
+
+ +
+ +
BGM
+
+ +
+ +
ID
+
+
+
+
+ +
+ Biomond Balance +
+
+
+ +
+
5994469974
+
+ +
+
+
+
+
+
+ ) +} diff --git a/src/features/game/components/desktop/desktop-title.tsx b/src/features/game/components/desktop/desktop-title.tsx new file mode 100644 index 0000000..4f55ee9 --- /dev/null +++ b/src/features/game/components/desktop/desktop-title.tsx @@ -0,0 +1,12 @@ +import { Megaphone } from 'lucide-react' +export function DesktopTitle() { + return ( +
+ +
+ Selamat kepada pemain Wu Yanzu yang telah memenangkan hadiah utama + sebesar 5.000 yuan sebanyak lima kali berturut-turut!🎉🎉🎉 +
+
+ ) +} diff --git a/src/features/game/components/index.ts b/src/features/game/components/index.ts new file mode 100644 index 0000000..0194d5f --- /dev/null +++ b/src/features/game/components/index.ts @@ -0,0 +1,2 @@ +export { DesktopHeader } from '@/features/game/components/desktop/desktop-header' +export { GameAnnouncementModal } from '@/features/game/components/shared/game-announcement-modal' diff --git a/src/features/game/components/shared/game-announcement-modal.tsx b/src/features/game/components/shared/game-announcement-modal.tsx new file mode 100644 index 0000000..c4fc50a --- /dev/null +++ b/src/features/game/components/shared/game-announcement-modal.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from 'react' + +const cx = (...classes: Array) => + 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 = { + 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 = { + 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 ( + + ) +} + +export function GameAnnouncementModal({ + open, + title, + description, + eyebrow = '', + tone = 'brand', + primaryAction, + secondaryAction, + children, +}: GameAnnouncementModalProps) { + if (!open) { + return null + } + + return ( +
+
+
+
+ + {eyebrow} + +

+ {title} +

+ {description ? ( +
+ {description} +
+ ) : null} +
+ {children ? ( +
+ {children} +
+ ) : null} + {secondaryAction || primaryAction ? ( +
+ {secondaryAction ? : null} + {primaryAction ? : null} +
+ ) : null} +
+
+
+ ) +} diff --git a/src/features/game/entry/game-route-page.tsx b/src/features/game/entry/game-route-page.tsx new file mode 100644 index 0000000..e9ffd61 --- /dev/null +++ b/src/features/game/entry/game-route-page.tsx @@ -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 ( +
+ { + 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" + > +
+

{t('game.modal.line1')}

+

{t('game.modal.line2')}

+
+
+
+ ) +} diff --git a/src/features/game/index.ts b/src/features/game/index.ts new file mode 100644 index 0000000..128378a --- /dev/null +++ b/src/features/game/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './shared' diff --git a/src/features/game/shared/constants.ts b/src/features/game/shared/constants.ts new file mode 100644 index 0000000..fd6eeff --- /dev/null +++ b/src/features/game/shared/constants.ts @@ -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 diff --git a/src/features/game/shared/index.ts b/src/features/game/shared/index.ts new file mode 100644 index 0000000..5f7709f --- /dev/null +++ b/src/features/game/shared/index.ts @@ -0,0 +1,4 @@ +export * from './constants' +export * from './mock-data' +export * from './selectors' +export * from './types' diff --git a/src/features/game/shared/mock-data.ts b/src/features/game/shared/mock-data.ts new file mode 100644 index 0000000..dd1f09b --- /dev/null +++ b/src/features/game/shared/mock-data.ts @@ -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 +} diff --git a/src/features/game/shared/selectors.ts b/src/features/game/shared/selectors.ts new file mode 100644 index 0000000..f8c84cf --- /dev/null +++ b/src/features/game/shared/selectors.ts @@ -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>( + (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 + }) +} diff --git a/src/features/game/shared/types.ts b/src/features/game/shared/types.ts new file mode 100644 index 0000000..7a1f815 --- /dev/null +++ b/src/features/game/shared/types.ts @@ -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 +} diff --git a/src/lib/api/api-client.ts b/src/lib/api/api-client.ts index 6c7373f..13366c6 100644 --- a/src/lib/api/api-client.ts +++ b/src/lib/api/api-client.ts @@ -8,7 +8,7 @@ import { handleUnauthorizedSession, tryRefreshAuthSession, } from '@/lib/auth/auth-session' -import { useAuthStore } from '@/store/auth-store' +import { useAuthStore } from '@/store/auth' import { ApiError } from './api-error' import type { ApiResponse } from './types' diff --git a/src/lib/auth/auth-session.ts b/src/lib/auth/auth-session.ts index 9a46c0c..735c722 100644 --- a/src/lib/auth/auth-session.ts +++ b/src/lib/auth/auth-session.ts @@ -1,5 +1,5 @@ -import type { AuthSessionInput, AuthUser } from '@/store/auth-store' -import { useAuthStore } from '@/store/auth-store' +import type { AuthSessionInput, AuthUser } from '@/store/auth' +import { useAuthStore } from '@/store/auth' export type CurrentUserInitializer = () => Promise export type RefreshSessionHandler = ( diff --git a/src/lib/auth/require-auth.ts b/src/lib/auth/require-auth.ts index 20d29f8..4ba29b5 100644 --- a/src/lib/auth/require-auth.ts +++ b/src/lib/auth/require-auth.ts @@ -2,7 +2,7 @@ import { redirect } from '@tanstack/react-router' import type { AppLanguage } from '@/i18n' import { getPreferredLanguage } from '@/i18n' -import { useAuthStore } from '@/store/auth-store' +import { useAuthStore } from '@/store/auth' import { initializeAuthSession, isAuthenticated } from './auth-session' diff --git a/src/locales/en-US/common.ts b/src/locales/en-US/common.ts index 91ab4d9..22e7a94 100644 --- a/src/locales/en-US/common.ts +++ b/src/locales/en-US/common.ts @@ -1,10 +1,11 @@ export default { nav: { home: 'Home', + game: 'Game', }, shell: { - eyebrow: 'React SPA scaffold', - subtitle: 'TanStack Router + Query + Auth + i18n', + eyebrow: '36 Character Flower', + subtitle: 'Real-time draw game frontend for mobile and desktop', }, notFound: { eyebrow: '404', @@ -13,30 +14,118 @@ export default { home: 'Back home', }, home: { - eyebrow: 'Blank scaffold ready', - title: 'Start building your frontend project from here.', + eyebrow: 'Game shell in progress', + title: + 'The 36-character-flower dual-device game framework is now being built.', 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: { - routingMode: 'Routing mode', - dataLayer: 'Data layer', - transport: 'Transport', - auth: 'Auth layer', - metadata: 'Page metadata', + routingMode: 'Routing', + dataLayer: 'State model', + transport: 'Realtime', + auth: 'Product', + metadata: 'Current focus', }, values: { - routingMode: 'SPA + file routes', - dataLayer: 'TanStack Query', - transport: 'ky', - auth: 'Zustand session scaffold', - metadata: 'Dynamic title / meta', + routingMode: 'Shared URL + split device views', + dataLayer: 'Round / Bet / User / UI / Connection', + transport: 'HTTP + WebSocket', + auth: '36-grid live draw gameplay', + metadata: 'Scaffold the structure before state machine polish', }, 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: { label: 'Language', zhCN: '中文', 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 diff --git a/src/locales/zh-CN/common.ts b/src/locales/zh-CN/common.ts index e2914bb..657dacc 100644 --- a/src/locales/zh-CN/common.ts +++ b/src/locales/zh-CN/common.ts @@ -1,10 +1,11 @@ export default { nav: { home: '首页', + game: '游戏大厅', }, shell: { - eyebrow: 'React SPA 脚手架', - subtitle: 'TanStack Router + Query + Auth + i18n', + eyebrow: '36字花', + subtitle: '实时开奖 · 双端适配 · 多语言游戏前端', }, notFound: { eyebrow: '404', @@ -13,30 +14,114 @@ export default { home: '返回首页', }, home: { - eyebrow: '空白脚手架已就绪', - title: '从这里开始搭建你的前端项目。', + eyebrow: '游戏前台骨架已启动', + title: '36 字花双端游戏框架正在落地。', description: - '模板已经接好路由、请求层、会话状态、head metadata 和多语言。删除示例后,这一页就是你的干净起点。', + '当前版本已经切入真实业务方向:统一路由、共享状态、移动端与桌面端分视图,后续会在这个基础上继续接入实时状态机、下注流程和开奖动画。', cards: { - routingMode: '路由模式', - dataLayer: '数据层', - transport: '请求层', - auth: '认证层', - metadata: '页面元信息', + routingMode: '路由策略', + dataLayer: '状态规划', + transport: '联机能力', + auth: '业务目标', + metadata: '当前重点', }, values: { - routingMode: 'SPA + 文件路由', - dataLayer: 'TanStack Query', - transport: 'ky', - auth: 'Zustand 会话基座', - metadata: '动态 title / meta', + routingMode: '统一 URL + 双端分视图', + dataLayer: 'Round / Bet / User / UI / Connection', + transport: 'HTTP + WebSocket', + auth: '36 宫格实时开奖玩法', + metadata: '先落结构,再接状态机与动效', }, footnote: - '建议先从 src/routes/$lang/index.tsx、src/lib/api/api-client.ts 和 src/store/auth-store.ts 开始替换成你的项目结构。', + '下一步会优先完成游戏主路由、共享业务模型和移动端 / 桌面端页面骨架。', + primaryAction: '进入游戏大厅', + secondaryAction: '查看项目结构', }, language: { label: '语言', zhCN: '中文', 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 diff --git a/src/main.tsx b/src/main.tsx index a8ff6bd..3e6a43c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,7 +8,7 @@ import '@/i18n' import { initializeAuthSession } from '@/lib/auth/auth-session' import { queryClient } from '@/lib/query/query-client' import { router } from '@/router' -import './styles.css' +import './style/index.css' const rootElement = document.getElementById(APP_ROOT_ELEMENT_ID) const shouldShowQueryDevtools = diff --git a/src/routes/$lang/index.tsx b/src/routes/$lang/index.tsx index 9317e36..7a03a42 100644 --- a/src/routes/$lang/index.tsx +++ b/src/routes/$lang/index.tsx @@ -1,19 +1,7 @@ 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/')({ - component: HomePage, + component: GameRoutePage, }) - -function HomePage() { - const { t } = useTranslation() - - useDocumentMetadata({ - title: t('home.title'), - description: t('home.description'), - }) - - return
111
-} diff --git a/src/routes/$lang/route.tsx b/src/routes/$lang/route.tsx index 9f1b327..a899841 100644 --- a/src/routes/$lang/route.tsx +++ b/src/routes/$lang/route.tsx @@ -1,7 +1,9 @@ import { createFileRoute, Navigate, Outlet } from '@tanstack/react-router' import { useEffect } from 'react' 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' export const Route = createFileRoute('/$lang')({ @@ -23,24 +25,18 @@ function LanguageLayout() { if (!isValidLanguage) { return } - - // 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 ( - <> - - +
+ +
+ +
+
+ +
+
+ +
+
) } diff --git a/src/store/auth-store.ts b/src/store/auth/auth-store.ts similarity index 100% rename from src/store/auth-store.ts rename to src/store/auth/auth-store.ts diff --git a/src/store/auth/index.ts b/src/store/auth/index.ts new file mode 100644 index 0000000..e5835d0 --- /dev/null +++ b/src/store/auth/index.ts @@ -0,0 +1 @@ +export * from './auth-store' diff --git a/src/store/game/game-round-store.ts b/src/store/game/game-round-store.ts new file mode 100644 index 0000000..214341e --- /dev/null +++ b/src/store/game/game-round-store.ts @@ -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) => 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()((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 } diff --git a/src/store/game/game-session-store.ts b/src/store/game/game-session-store.ts new file mode 100644 index 0000000..1b27495 --- /dev/null +++ b/src/store/game/game-session-store.ts @@ -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) => void + syncDashboard: (patch: Partial) => void +} + +function createInitialSessionState(): GameSessionSlice { + const snapshot = createMockGameBootstrapSnapshot() + + return { + announcements: snapshot.announcements, + connection: snapshot.connection, + dashboard: snapshot.dashboard, + } +} + +export const useGameSessionStore = create()((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 } diff --git a/src/store/game/index.ts b/src/store/game/index.ts new file mode 100644 index 0000000..e2aa53d --- /dev/null +++ b/src/store/game/index.ts @@ -0,0 +1,2 @@ +export * from './game-round-store' +export * from './game-session-store' diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..6f9fed7 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,2 @@ +export * from './auth' +export * from './game' diff --git a/src/style/index.css b/src/style/index.css new file mode 100644 index 0000000..347c161 --- /dev/null +++ b/src/style/index.css @@ -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); + } +} diff --git a/src/styles.css b/src/styles.css deleted file mode 100644 index 986eaed..0000000 --- a/src/styles.css +++ /dev/null @@ -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; - } -}