Compare commits
14
Commits
e61399ecd6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3248ee4b83 | ||
|
|
34c7fa9d72 | ||
|
|
266dad03e4 | ||
|
|
ab91b0ca82 | ||
|
|
dbbe48d7a3 | ||
|
|
5848c5daac | ||
|
|
43286f10ef | ||
|
|
4be9ce4e85 | ||
|
|
23fe1f3995 | ||
|
|
73c21ca026 | ||
|
|
687a1f0f98 | ||
|
|
1e256b94e8 | ||
|
|
69e1a9531f | ||
|
|
470d1471e5 |
+14
-10
@@ -19,7 +19,7 @@
|
||||
| 缓存 | Redis(Spring Data Redis + Lettuce) |
|
||||
| 消息 | RabbitMQ(业务侧配置虚拟主机等,详见各 profile) |
|
||||
| 实时 | WebSocket(如 `/ws/**` 等路径,与安全放行配置保持一致) |
|
||||
| API 文档 | **Springfox 3.x**(主用,Swagger / OAS3 分组);另引入 **springdoc-openapi** Starter,可与 Springfox 并行存在 |
|
||||
| API 文档 | 已移除(不再依赖 Springfox / springdoc,仓库内无 Swagger UI 与 OpenAPI 端点) |
|
||||
|
||||
---
|
||||
|
||||
@@ -81,7 +81,7 @@ chengdu-backend/
|
||||
|
||||
### 对外匿名接口(简述)
|
||||
|
||||
与安全配置对齐的常见分类包括:`/login`、`/register`、`/captchaImage`,以及业务侧的 `/api/**`、`/admin/**`、WebSocket、部分 Swagger 与 Druid 监控路径等。具体请以 `ruoyi-framework` 中 **SecurityFilterChain** 与白名单配置为准。
|
||||
与安全配置对齐的常见分类包括:`/login`、`/register`、`/captchaImage`,以及业务侧的 `/api/**`、`/admin/**`、WebSocket、Druid 监控路径等。具体请以 `ruoyi-framework` 中 **SecurityFilterChain** 与白名单配置为准。
|
||||
|
||||
---
|
||||
|
||||
@@ -130,7 +130,7 @@ mvn -pl ruoyi-admin -am spring-boot:run -DskipTests
|
||||
- 技术栈:Vue CLI、Element UI(`package.json` 声明 Node `>=8.9`,实际开发建议 Node **16 LTS** 或 **18 LTS**,并按需开启 OpenSSL 兼容)。
|
||||
- 脚本见 `ruoyi-ui/package.json`(Windows 脚本含 `set NODE_OPTIONS=--openssl-legacy-provider`;在 macOS/Linux 上需 `export NODE_OPTIONS=...` 或使用等价脚本)。
|
||||
- 开发与打包:`npm run dev`、`npm run build:prod` 等。
|
||||
- 系统工具内「Swagger」页当前 iframe 指向 **`/doc.html`**;若后端未部署对应聚合页,请改为与下文 **API 文档** 中实际地址一致(例如 `/swagger-ui.html`)。
|
||||
- 原「系统工具 → 系统接口」的 Swagger 页已随接口文档一并删除(`views/tool/swagger/index.vue`),若侧边栏仍有该菜单,执行 `DELETE FROM sys_menu WHERE component = 'tool/swagger/index';` 后重新登录。
|
||||
|
||||
### promo-ui2(Vue 3)
|
||||
|
||||
@@ -158,16 +158,14 @@ mvn -pl ruoyi-admin -am spring-boot:run -DskipTests
|
||||
|
||||
## API 文档
|
||||
|
||||
- **Springfox(当前主要使用)**:启动后访问(端口与 `server.port` 一致,仓库默认 **28081**)
|
||||
`http://localhost:28081/swagger-ui.html`
|
||||
在页面右上角切换分组(如 `user`、`thirdParty`、`playingMethod`、`task`、`promo` 等;与 `SwaggerConfig` 中 Docket 一致)。OpenAPI JSON 路径形如 `/<group>/api-docs`(Spring Security 已对 `/*/api-docs` 等放行,以实际配置为准)。
|
||||
- **springdoc-openapi**:`ruoyi-admin` 已引入依赖;若单独启用 SpringDoc UI,需确保 **Security** 对 `/swagger-ui/**`、`/v3/api-docs` 等路径放行,避免 401。
|
||||
- 项目已**移除全部接口文档能力**:删除 Springfox(`springfox-boot-starter`)、springdoc-openapi 依赖、`SwaggerConfig` 与源码中的 `@Api` / `@ApiOperation` / `@ApiModelProperty` 等注解,`SecurityConfig` 与 `ResourcesConfig` 里对应的放行与静态映射也已去掉。
|
||||
- 因此 `/swagger-ui.html`、`/v3/api-docs`、`/*/api-docs`、`/doc.html` 均不再对外提供服务(未认证访问会直接落到 401)。
|
||||
|
||||
---
|
||||
|
||||
## 版本信息
|
||||
|
||||
- 父 POM:`com.ruoyi:ruoyi:4.8.2`(`spring-boot.version` **4.0.3**,`springdoc.version` **3.0.2**,`swagger` / Springfox **3.0.0**)
|
||||
- 父 POM:`com.ruoyi:ruoyi:4.8.2`(`spring-boot.version` **4.0.3**)
|
||||
- 若依官网参考:<http://www.ruoyi.vip>
|
||||
|
||||
---
|
||||
@@ -175,7 +173,13 @@ mvn -pl ruoyi-admin -am spring-boot:run -DskipTests
|
||||
## 安全建议
|
||||
|
||||
1. 轮换并私有化所有第三方(支付宝、聚合支付、短信等)密钥;生产环境勿使用仓库内示例明文。
|
||||
2. 修改 JWT `token.secret` 与 Druid 监控口令等非默认值。
|
||||
3. 按需收紧 Druid、`/actuator`(若启用)及 Swagger 相关路径的访问范围。
|
||||
2. 修改 JWT `token.secret` 等非默认值。
|
||||
3. Druid 监控台:生产已关闭(`application-pro.yml` 的 `statViewServlet.enabled=false`,`/druid/**` 是匿名放行的,开着等于把全部 SQL 与连接信息暴露到公网);
|
||||
开发环境已限制 `allow: 127.0.0.1` 并改用非默认口令。临时排障如需开启,务必保持白名单 + 改口令,用完立即关回。
|
||||
4. **三端 token 互不通用**(游戏管理后台 / 推广后台 / 玩家端):三个端共用同一套 JWT 与会话存储,
|
||||
靠 `LoginUser.loginScope`(`admin` / `promo` / `player`)区分,判定在 `ruoyi-framework` 的 `SecurityConfig`:
|
||||
`/admin|/system|/monitor|/tool|/test` 只认后台账号、`/promo/**` 只认推广端签发的 token、`/api/**` 只认玩家端签发的 token。
|
||||
标记由 `ApiUserDetailsServiceImpl` 按登录账号前缀(`api_` / `promo_` / `promo_user_` / 无前缀)写入,随会话缓存在 Redis。
|
||||
**注意**:升级到该版本后,不带端标记的旧 token 会被拒绝,推广后台与玩家端需要重新登录一次(后台账号不受影响)。
|
||||
|
||||
如有与本说明不一致之处,以当前仓库代码及团队部署文档为准。
|
||||
|
||||
+282
-108
@@ -1,130 +1,304 @@
|
||||
# 数据库变更台账
|
||||
|
||||
> **数据库改动只写这里**:中文标题 + 完整可执行 SQL。不再单独建 `docs/*.sql`(全量基线除外)。
|
||||
> 每条都要能回得去 —— **回滚方案与验证步骤必写**。
|
||||
> 只记**变更 SQL**;**不写回滚 SQL** —— 已执行的条目即为既成事实,要撤销请从本文件的 git 历史取旧版本。
|
||||
> 若某条变更依赖**尚未执行**的前置对象(建表、种子数据等),必须在条目标注清楚,别让后来者踩空。
|
||||
|
||||
---
|
||||
|
||||
## 删除「奥林匹斯 / 荒野争锋 / 极速糖果」三个玩法的后台菜单
|
||||
## 头像库「是否默认」唯一化:`tt_user_avatar` 存量数据清洗
|
||||
|
||||
**背景**:这三个玩法不再需要,要求从后台左侧菜单移除。
|
||||
**背景**:`tt_user_avatar.is_default` 的语义是「新用户 / 白号注册时分配的初始头像」,
|
||||
**全表必须最多只有一条 `is_default='1'`**。但后台「设置 → 头像管理」的开关写成默认时,
|
||||
后端只做了 `updateById`,没有把其他记录的默认标记清掉 ⇒ 库中一度累积出 **7 条默认**(id 63~69),
|
||||
`id=62` 反而是「否」。后果:后台看到一排「是」容易误解(用户 2026-09-24 反馈),
|
||||
且 C 端注册处(`ApiRegisterServiceImpl` / `PromoLoginServiceImpl`)用的是
|
||||
`list(is_default='1')` + `RandomUtil.choice(...)` **随机挑一条**,导致默认头像实际不唯一、每次注册拿到的都不同。
|
||||
|
||||
**为什么先动数据库**:RuoYi 的左侧菜单是**数据库驱动**的(`sys_menu` → `/getRouters` 接口 → 前端 `sidebarRouters`),
|
||||
代码里没有菜单清单。所以顺序是:**先删数据,再删前端页面文件**。
|
||||
**代码侧修复**(非 SQL,见 `docs/修改记录.md` 同日条目):`TtUserAvatarServiceImpl`
|
||||
在 `updateUserAvatarById` / `saveAvatar` 中增加互斥逻辑 —— 任何把某条置为 `is_default='1'` 的写入,
|
||||
都先把其余记录清成 `'0'`,两步同一事务。
|
||||
|
||||
**影响面**(实查本机库 `henan`,非估算):
|
||||
**本次数据库变更 1 处**(2026-09-24 已在库 `henan` 执行):把多余的默认标记收敛为**只保留 id 最小的一条**(id=63)。
|
||||
|
||||
| 表 | 行数 | 说明 |
|
||||
|---|---|---|
|
||||
| `sys_menu` | **22** | 3 个顶级菜单(`menu_type='M'`)+ 19 个子页面(`'C'`);**无** `'F'` 按钮权限行 |
|
||||
| `sys_role_menu` | **22** | 全部属于 `role_id = 103` 这一个角色 |
|
||||
|
||||
| 顶级 menu_id | 菜单 | path | 子菜单 | 子菜单 menu_id | 前端 component |
|
||||
|---|---|---|---|---|---|
|
||||
| 3130 | 奥林匹斯 | `olympus` | 7 个 | 3131~3137 | `game/olympus/*` |
|
||||
| 2105 | 荒野争锋 | `bullet` | 6 个 | 2106~2110、2112 | `game/bullet/*` |
|
||||
| 2125 | 极速糖果 | `sweet` | 6 个 | 2126~2131 | `game/sweet/*` |
|
||||
|
||||
⚠️ **`2111`(宝箱管理→临时倍率)不在删除范围内** —— 它虽然编号紧邻 `2110`,但父级是 `2018`(宝箱管理),
|
||||
且其 component 是 `game/box/mock/index`(被 `3141` 经典开箱的「临时爆率」也在用),**不要动**。
|
||||
|
||||
**变更 SQL**(2026-09-22 已在库 `henan` 执行):
|
||||
**变更 SQL**:
|
||||
|
||||
```sql
|
||||
-- 1) 先清角色-菜单授权,避免留下孤儿行
|
||||
DELETE FROM sys_role_menu
|
||||
WHERE menu_id IN (3130,3131,3132,3133,3134,3135,3136,3137,2105,2106,2107,2108,2109,2110,2112,2125,2126,2127,2128,2129,2130,2131);
|
||||
|
||||
-- 2) 再删菜单本身
|
||||
DELETE FROM sys_menu
|
||||
WHERE menu_id IN (3130,3131,3132,3133,3134,3135,3136,3137,2105,2106,2107,2108,2109,2110,2112,2125,2126,2127,2128,2129,2130,2131);
|
||||
UPDATE `tt_user_avatar`
|
||||
SET `is_default` = '0'
|
||||
WHERE `is_default` = '1'
|
||||
AND `id` <> (SELECT `min_id` FROM (
|
||||
SELECT MIN(`id`) AS `min_id` FROM `tt_user_avatar` WHERE `is_default` = '1'
|
||||
) AS `t`);
|
||||
```
|
||||
|
||||
**验证**(执行后实测):
|
||||
> ⚠️ MySQL 不允许在 `UPDATE` 的子查询里直接查同一张表,故此处包了一层派生表 `t` 绕过;
|
||||
> 该写法的坑已记在 `.workbuddy/memory/` 当日日志里,后续同类 SQL 照此办理。
|
||||
|
||||
**验证口径**:执行后 `SELECT is_default, COUNT(*) FROM tt_user_avatar GROUP BY is_default;`
|
||||
应当只有两行 —— `0 → N`(N = 总数-1)、`1 → 1`。
|
||||
|
||||
---
|
||||
|
||||
## 幸运升级·水位策略简化为「单一目标水位」:`sys_config` 配置结构变更
|
||||
|
||||
**背景**:上一版把水位策略做成了 **4 档波段表**(每档 = 水位下界/上界 + 随机数下界/上界),
|
||||
后台要维护 16 个数、还要保证档间单调且留有重叠,运营难维护也容易配错。本次简化为**只填一个「目标水位(%)」**:
|
||||
随机数的**中心**由目标水位自动反推,**取值范围**仍由每个饰品自己的「随机数区间」限定
|
||||
(水位高于目标 → 落点靠近区间下限)。**口径推导、换算表、仿真数据与算例见 `docs/修改记录.md` 的 2026-09-24 条。**
|
||||
|
||||
**本次数据库变更 1 处**(2026-09-24 已在库 `henan` 执行):`sys_config.config_key = 'luckyUpgradeWatermark'` 的
|
||||
`config_value` 由「波段表」改为「目标水位 + 灵敏度」,`bands` 字段废弃(后端已不再读取,留着不报错)。
|
||||
|
||||
> ⚠️ **前置依赖**:本条的 `UPDATE` 依赖下一节「幸运升级·水位驱动改造」插入的那行 `sys_config`
|
||||
> —— 全新库必须先执行那一条,再执行本条。
|
||||
>
|
||||
> ⚠️ **同时恢复**(代码侧,非 SQL):饰品列表页的「随机数区间」编辑入口**重新启用**,
|
||||
> `tt_upgrade_ornaments.luck_section` / `anchor_luck_section` / `blank_luck_section` 现在**必须配置**,
|
||||
> 且要**包含按目标水位推算出的中心值**,否则中心被夹在边界上、水位停不到目标值。
|
||||
> ⇒ 下一节里「上线前清空三个区间列」的说法**已作废**,不要再执行那两条清空 SQL。
|
||||
|
||||
**变更 SQL**:
|
||||
|
||||
```sql
|
||||
-- 两个都必须是 0
|
||||
SELECT COUNT(*) FROM sys_menu
|
||||
WHERE menu_id IN (3130,3131,3132,3133,3134,3135,3136,3137,2105,2106,2107,2108,2109,2110,2112,2125,2126,2127,2128,2129,2130,2131)
|
||||
OR parent_id IN (3130, 2105, 2125); -- 实测 0
|
||||
|
||||
SELECT COUNT(*) FROM sys_role_menu
|
||||
WHERE menu_id IN (3130,3131,3132,3133,3134,3135,3136,3137,2105,2106,2107,2108,2109,2110,2112,2125,2126,2127,2128,2129,2130,2131); -- 实测 0
|
||||
UPDATE `sys_config`
|
||||
SET `config_value` = '{"enabled":true,"targetWatermark":30,"gain":2,"luckyValueMode":"raw"}',
|
||||
`remark` = '幸运升级水位策略:目标水位%(平台希望长期维持的净收益比例)、灵敏度 gain、幸运值展示口径 raw/eff。随机数中心 = 100/((1-目标水位/100)*实测标定系数),再由每个饰品的随机数区间限定取值范围;中奖判定恒用 min(概率,75)。enabled=false 逐字节回退固定区间',
|
||||
`update_time` = NOW()
|
||||
WHERE `config_key` = 'luckyUpgradeWatermark';
|
||||
```
|
||||
|
||||
一级菜单复查(`SELECT menu_id, menu_name, path FROM sys_menu WHERE parent_id = 0 ORDER BY order_num, menu_id`)
|
||||
实测剩余 20 项,已无「奥林匹斯 / 荒野争锋 / 极速糖果」。
|
||||
**字段口径**:
|
||||
|
||||
**回滚 SQL**(22 条 `sys_menu` + 22 条 `sys_role_menu`,由 `mysqldump --no-create-info --complete-insert` 导出):
|
||||
|
||||
```sql
|
||||
-- 事务包裹更安全;menu_id 是显式指定,不需要自增
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2105,'荒野争锋',0,7,'bullet',NULL,NULL,1,0,'M','0','0','','excel','admin','2026-05-14 14:36:15','admin','2026-08-01 13:37:13','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2106,'饰品列表',2105,1,'ornament','game/bullet/ornament/index',NULL,1,0,'C','0','0','game:bullet:ornament:list','button','admin','2026-05-14 14:49:48','admin','2026-05-14 14:55:16','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2107,'箱子管理',2105,2,'box','game/bullet/box/index',NULL,1,0,'C','0','0','game:bullet:box:list','bug','admin','2026-05-14 14:56:11','',NULL,'');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2108,'回合管理',2105,3,'round','game/bullet/round/index',NULL,1,0,'C','0','0','game:bullet:round:list','build','admin','2026-05-14 14:56:49','',NULL,'');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2109,'玩家记录',2105,4,'record','game/bullet/record/index',NULL,1,0,'C','0','0','game:bullet:record:list','button','admin','2026-05-14 14:57:29','',NULL,'');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2110,'每日统计',2105,5,'dailywin','game/bullet/dailywin/index',NULL,1,0,'C','0','0','game:bullet:dailywin:list','chart','admin','2026-05-14 14:58:07','',NULL,'');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2112,'奖品管理',2105,6,'prize','game/bullet/prize/index',NULL,1,0,'C','0','0','game:bullet:prize:list','component','admin','2026-05-26 04:47:02','admin','2026-05-26 05:34:40','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2125,'极速糖果',0,7,'sweet',NULL,NULL,1,0,'M','0','0','','star','admin','2026-06-18 10:15:36','admin','2026-08-01 13:29:08','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2126,'箱子管理',2125,2,' box','game/sweet/box/index',NULL,1,0,'C','0','0','game:sweet:box:list','404','admin','2026-06-18 10:17:18','admin','2026-06-18 14:34:47','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2127,'饰品列表',2125,1,'ornament','game/sweet/ornament/index',NULL,1,0,'C','0','0','game:sweet:ornament:list','bug','admin','2026-06-18 10:18:01','admin','2026-06-18 14:34:29','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2128,'奖品管理',2125,6,'prize','game/sweet/prize/index',NULL,1,0,'C','0','0','game:sweet:prize:list','build','admin','2026-06-18 10:18:41','admin','2026-06-18 14:35:55','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2129,'每日统计',2125,5,'dailywin','game/sweet/dailywin/index',NULL,1,0,'C','0','0','game:sweet:dailywin:list','button','admin','2026-06-18 10:19:39','admin','2026-06-18 14:35:39','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2130,'回合管理',2125,3,'round','game/sweet/round/index',NULL,1,0,'C','0','0','game:sweet:round:list','cascader','admin','2026-06-18 10:20:13','admin','2026-06-18 14:35:03','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (2131,'玩家记录',2125,4,'record','game/sweet/record/index',NULL,1,0,'C','0','0','game:sweet:record:list','cascader','admin','2026-06-18 10:21:20','admin','2026-06-18 14:35:17','');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3130,'奥林匹斯',0,5,'olympus','',NULL,1,0,'M','0','0','','star','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯电子游戏管理');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3131,'箱子配置',3130,1,'box','game/olympus/box/index',NULL,1,0,'C','0','0','game:olympus:box','shopping','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯箱子配置与身份桶分布');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3132,'饰品列表',3130,2,'ornament','game/olympus/ornament/index',NULL,1,0,'C','0','0','game:olympus:ornament','star','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯 17 档饰品桶定义');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3133,'回合管理',3130,3,'round','game/olympus/round/index',NULL,1,0,'C','0','0','game:olympus:round','documentation','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯预生成回合查询');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3134,'奖池水位',3130,4,'rewardPool','game/olympus/rewardPool/index',NULL,1,0,'C','0','0','game:olympus:pool','money','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯按箱奖池水位(注水/抽水/批量/重置)');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3135,'玩家记录',3130,5,'record','game/olympus/record/index',NULL,1,0,'C','0','0','game:olympus:record','list','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯游戏记录');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3136,'每日统计',3130,6,'dailywin','game/olympus/dailywin/index',NULL,1,0,'C','0','0','game:olympus:win','chart','admin','2026-08-01 13:41:53','',NULL,'奥林匹斯每日盈亏统计');
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`) VALUES (3137,'奖品管理',3130,7,'prize','game/olympus/prize/index',NULL,1,0,'C','0','0','game:olympus:prize','guide','admin','2026-08-01 13:41:53','admin','2026-08-04 13:46:18','奥林匹斯返奖饰品与区间配置');
|
||||
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2105);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2106);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2107);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2108);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2109);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2110);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2112);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2125);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2126);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2127);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2128);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2129);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2130);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,2131);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3130);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3131);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3132);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3133);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3134);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3135);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3136);
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (103,3137);
|
||||
```
|
||||
|
||||
**配套的前端文件删除**(38 个文件,全部在 git 里,`git checkout` 可恢复):
|
||||
|
||||
| 目录 | 文件数 |
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `ruoyi-ui/src/views/game/olympus/` | 7 |
|
||||
| `ruoyi-ui/src/views/game/bullet/` | 6 |
|
||||
| `ruoyi-ui/src/views/game/sweet/` | 6 |
|
||||
| `ruoyi-ui/src/api/game/olympus/` | 7 |
|
||||
| `ruoyi-ui/src/api/game/bullet/` | 6 |
|
||||
| `ruoyi-ui/src/api/game/sweet/` | 6 |
|
||||
| `enabled` | `true` = 按水位动态出随机数;`false` = 逐字节回退「每个饰品位固定 `luck_section`」 |
|
||||
| `targetWatermark` | **目标水位(%)**:平台希望长期维持的净收益比例。30 ⇒ 长期平均 RTP ≈ 70% |
|
||||
| `gain` | 响应灵敏度:水位每偏离目标 1 个点,随机数中心反向调整 `gain`%。默认 2,后台不展示(调参用) |
|
||||
| `luckyValueMode` | 幸运值**展示**口径:`raw`(默认,累加前端原始概率)/ `eff`(累加 `min(概率,75)`);**不影响判定** |
|
||||
|
||||
**刻意没动**:
|
||||
---
|
||||
|
||||
- **后端 Java 代码**。`olympus / bullet / sweet / sugar` 在后端出现在**跨模块共享**的位置 ——
|
||||
`TtAccountRecordSource`(流水来源码:60=奥林匹斯…)、`TtboxRecordSource`、`RedisConstants`、
|
||||
`MessageCategoryEnum`、`PromoTurnover*`、`ApiUserPackSackServiceImpl`、`WebsiteSetupServiceImpl` 等。
|
||||
删这些会波及**其他玩法与推广/发货链路**,不在「删页面」范围内。
|
||||
- **数据库业务表**(`game_olympus_*`、`game_sweet_*` 等)原样保留,未 TRUNCATE、未 DROP。
|
||||
- `ruoyi-ui/src/views/game/sugar/`(5 个 api + 5 个页面)—— **没有任何菜单引用它**:
|
||||
菜单里的「极速糖果」指向的是 `game/sweet/*`,而 `game_sugar_*` 表在 `docs/cleanup_records.sql` 里被标注为「极速糖果」。
|
||||
即 `sugar` 与 `sweet` 是两套并存的东西,`sugar` 未被任何菜单挂载(后端却存在 `GameSugarWin/User/StepInfo/Spin` 实体)。
|
||||
**疑似遗留或未接入的开发分支,未确认前不删。**
|
||||
## 幸运升级·水位驱动改造:水位日志表、拉取记录快照列、水位策略配置与「奖池水位」菜单
|
||||
|
||||
**背景**:幸运升级原来每个饰品位配一套固定幸运区间,随机数永远落在区间内(如 `[100,160)`),
|
||||
玩家能看出「每次都要投到 1 倍原价以上才出」。本次改为**按全局水位动态决定本轮的随机数区间**:
|
||||
`水位 = (Σ下注 − Σ返奖 + 人工调整) / Σ下注`(排除白号),水位越高 → 可出的随机数越低。
|
||||
同时新增后台人工注水/抽水。**方案、换算表、口径推导与算例全部汇总在 `docs/修改记录.md` 的 2026-09-23 条**(不另建单独文档)。
|
||||
|
||||
**本次数据库变更 4 处**(2026-09-23 已在库 `henan` 执行):
|
||||
|
||||
| 对象 | 变更 | 说明 |
|
||||
|---|---|---|
|
||||
| `tt_upgrade_reward_log` | **新建表** | 注水/抽水审计日志,`delta` 为带符号影响,水位调整额 = `SUM(delta)`(DB 为真相,Redis 只是短缓存) |
|
||||
| `tt_upgrade_record` | 新增 **2 列** | `watermark`(本次拉取时的水位%)、`round_target`(本轮抽到的随机数快照)|
|
||||
| `sys_config` | 新增 **1 行** | `config_id=155`、`config_key='luckyUpgradeWatermark'`,水位波段表 + 幸运值展示口径;`enabled=false` 可逐字节回退旧行为 |
|
||||
| `sys_menu` / `sys_role_menu` | 各新增 **1 / 2 行** | `menu_id=3208`「幸运升级 → 奖池水位」,授权给 `role_id` 101(三恒)、103(Fdily)|
|
||||
|
||||
> **为什么 `menu_id` 用 `3208`**:`3200~3207` 是麻将胡了的号段,按顺序往后取,不复用已下架玩法的旧号段。
|
||||
|
||||
**变更 SQL**:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `tt_upgrade_reward_log` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
`type` tinyint NOT NULL COMMENT '1注水 2抽水 3重置',
|
||||
`amount` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值,展示用)',
|
||||
`delta` decimal(14,2) NOT NULL DEFAULT '0.00' COMMENT '对水位的带符号影响(注水正/抽水负/重置为抵消值)',
|
||||
`watermark_before` decimal(8,2) DEFAULT NULL COMMENT '调整前水位%',
|
||||
`watermark_after` decimal(8,2) DEFAULT NULL COMMENT '调整后水位%',
|
||||
`operator` varchar(64) DEFAULT NULL COMMENT '操作人',
|
||||
`remark` varchar(200) DEFAULT NULL COMMENT '调整原因(必填,前端保证)',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_create` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幸运升级奖池水位调整日志';
|
||||
|
||||
ALTER TABLE `tt_upgrade_record`
|
||||
ADD COLUMN `watermark` decimal(8,2) NULL COMMENT '本次拉取时的水位%(1-RTP,排除白号,含人工调整)',
|
||||
ADD COLUMN `round_target` int NULL COMMENT '本轮抽到的随机数 fullV 快照';
|
||||
|
||||
INSERT INTO `sys_config`
|
||||
(`config_id`, `config_name`, `config_key`, `config_value`, `config_type`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
VALUES
|
||||
(155, '幸运升级水位策略', 'luckyUpgradeWatermark',
|
||||
'{"enabled":true,"luckyValueMode":"raw","bands":[{"from":-999,"to":10,"vMin":95,"vMax":130},{"from":10,"to":26,"vMin":115,"vMax":150},{"from":26,"to":36,"vMin":75,"vMax":175},{"from":36,"to":999,"vMin":85,"vMax":128}]}',
|
||||
'Y', 'admin', NOW(), '', NULL, '幸运升级水位策略:波段表(水位%→随机数区间) + 幸运值展示口径 raw(原始概率)/eff(与判定同口径);中奖判定恒用 min(概率,75)。enabled=false 逐字节回退固定区间');
|
||||
|
||||
INSERT INTO `sys_menu`
|
||||
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
VALUES
|
||||
(3208, '奖池水位', 2075, 3, 'rewardPool', 'skins/luckyUpgrade/rewardPool/index', NULL, 1, 0, 'C', '0', '0', 'skins:upgrade:pool', 'money', 'admin', NOW(), '', NULL, '幸运升级·奖池水位(全局水位/人工注水抽水/调整历史)');
|
||||
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) VALUES (101, 3208), (103, 3208);
|
||||
```
|
||||
|
||||
**验证**(2026-09-23 实测结果):
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='henan' AND table_name='tt_upgrade_reward_log'; -- 期望 1,实测 1
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema='henan' AND table_name='tt_upgrade_record'
|
||||
AND column_name IN ('watermark','round_target'); -- 期望 2,实测 2
|
||||
SELECT COUNT(*) FROM sys_config WHERE config_key='luckyUpgradeWatermark'; -- 期望 1,实测 1
|
||||
SELECT COUNT(*) FROM sys_menu WHERE menu_id=3208; -- 期望 1,实测 1
|
||||
SELECT COUNT(*) FROM sys_role_menu WHERE menu_id=3208; -- 期望 2,实测 2
|
||||
```
|
||||
|
||||
后台左侧「幸运升级」目录下出现「奖池水位」页(`/luckyUpgrade/rewardPool`)。
|
||||
|
||||
**注意:水位策略上线后的运营动作**
|
||||
|
||||
> ⚠️ **本节第 1 条已被本文件顶部「水位策略简化为单一目标水位」那条取代**:
|
||||
> `luck_section` / `anchor_luck_section` / `blank_luck_section` **重新成为必填项**
|
||||
> (语义 = 随机数的允许取值范围),**不要清空**;而且要配成**包含「按目标水位推算出的中心值」**的区间。
|
||||
> 清空后后端会用兜底区间 `[80,160]`,能用但不受控。
|
||||
|
||||
1. ~~两个已存在的饰品位(`tt_upgrade_ornaments.id` 18 / 25)的 `luck_section` 要清空,才能完全跟随全局波段~~
|
||||
—— **已作废**,见上方提示。
|
||||
2. 水位从 0 起步。`enabled=true` 时前几件货会落在收紧档(水位低 → 随机数高),属预期行为;
|
||||
需要立刻放水用后台「奖池水位 → 注水」。
|
||||
|
||||
## 管理后台按钮级权限补全:`sys_menu` 补 24 行 `F`(按钮)记录
|
||||
|
||||
**背景**:后台按钮的显隐走前端 `v-hasPermi`(`ruoyi-ui/src/directive/permission/hasPermi.js`),
|
||||
接口放行走后端 `@PreAuthorize("@ss.hasPermi('...')")`,**两边比对的是同一个 `sys_menu.perms`**
|
||||
(超管 `user_id=1` 拿 `*:*:*`,其他人拿「已授权菜单行的 `perms` 去重集合」,见
|
||||
`ruoyi-framework/.../SysPermissionService.java:55`)。
|
||||
|
||||
历史遗留:**从 `menu_id` 31xx 段开始,新增页面只建了 `C` 行、没建 `F` 行**
|
||||
(`F` 行分布:1000 段 61 行、2000 段 18 行、3100 段仅 9 行、3200+ 段 0 行)。
|
||||
后果是这些页面的按钮在角色授权树里**根本勾不到** ⇒ 除超管外谁都看不见;
|
||||
更严重的是部分 **列表/查询接口也会 403**,页面进去是空的。
|
||||
|
||||
盘点结果:代码在用、但 `sys_menu` 未登记的权限串共 **43 个**。其中 **23 个属于 4 个无菜单入口的孤儿页面**
|
||||
(`ttOrnaments/boxOpenChance`、`ttBonus/firstRecharge`、`ttWelfare/monthlyRecharges`、`ttReplacementRecord/index`
|
||||
—— 已核实无菜单、无组件引用、无路由、无接口复用方,**不可达,本期不动**);
|
||||
**其余 20 个 + 复用组补 4 行 = 24 行**,本次补齐。
|
||||
|
||||
> 为什么 `menu_id` 用 `3209~3232`:`MAX(menu_id)=3208`(麻将胡了 3200~3207、幸运升级 3208),按顺序往后取。
|
||||
|
||||
**本次数据库变更 3 处**(2026-09-27 已在库 `henan` 执行):
|
||||
|
||||
| 对象 | 变更 | 说明 |
|
||||
|---|---|---|
|
||||
| `sys_menu` | 新增 **24 行** | `menu_id=3209~3232`,`menu_type='F'`,挂到 6 个 C 行下(2035/2038/2086/2092/2123/3118)|
|
||||
| `sys_menu` | 修正 **3 行** | `menu_id=3160~3162` 的 `parent_id`:`3156`(目录 `M`「流水抽奖」)→ `3157`(页面 `C`「抽奖活动」)。**同属 F 行挂载错误**,不改的话勾「抽奖活动」联动带不到这 3 个按钮 |
|
||||
| `sys_role_menu` | 新增 **24 行** | 把上述 F 行补授权给「**已授权其父 C 行**」的角色(动态 SQL,执行时按实际授权现状生成)。执行时这些父 C 行**仅 `role_id=104`(测试人员)**已授权 ⇒ 24 行全落给 104(`sys_role_menu` 总行数 222 → 246)|
|
||||
|
||||
> 关于 `3160~3162`:这三个串(`system:turnoverLottery:add/edit/remove`)**当前代码里无人使用**
|
||||
> (`turnoverLottery/list.vue` 的新增/修改/删除按钮没写 `v-hasPermi`,后端也没有对应 `@PreAuthorize`),
|
||||
> 所以本次修正只是把菜单树层级摆正、消除「F 行挂在目录下」的隐患,不影响线上行为。
|
||||
> 若将来给该页面补按钮权限,直接复用这三个串即可。
|
||||
|
||||
**变更 SQL**:
|
||||
|
||||
```sql
|
||||
/* ---- 1. 24 行按钮权限(F)---- */
|
||||
INSERT INTO `sys_menu`
|
||||
(`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query`, `is_frame`, `is_cache`,
|
||||
`menu_type`, `visible`, `status`, `perms`, `icon`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
VALUES
|
||||
/* 奖池详情(ttRoll/prizedetail)—— 该页「删除」按钮历史上绑的就是 admin:prize:edit */
|
||||
(3209, '修改奖池', 2038, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:prize:edit', '#', 'admin', NOW(), '', NULL, '奖池详情·修改'),
|
||||
/* CDK管理(ttRedPack/index)—— 整套新增,此前 0 行 F */
|
||||
(3210, '新增红包', 2086, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:add', '#', 'admin', NOW(), '', NULL, 'CDK管理·新增'),
|
||||
(3211, '删除红包', 2086, 2, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:remove', '#', 'admin', NOW(), '', NULL, 'CDK管理·删除'),
|
||||
(3212, '修改红包', 2086, 3, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:edit', '#', 'admin', NOW(), '', NULL, 'CDK管理·修改'),
|
||||
(3213, '导出红包', 2086, 4, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:export', '#', 'admin', NOW(), '', NULL, 'CDK管理·导出'),
|
||||
(3214, '红包查询', 2086, 5, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:query', '#', 'admin', NOW(), '', NULL, 'CDK管理·详情'),
|
||||
(3215, '红包列表', 2086, 6, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:list', '#', 'admin', NOW(), '', NULL, 'CDK管理·列表接口'),
|
||||
(3216, '红包全部列表', 2086, 7, '', NULL, NULL, 1, 0, 'F', '0', '0', 'skinsback:ttRedPack:listAll', '#', 'admin', NOW(), '', NULL, 'CDK管理·不分页列表接口'),
|
||||
/* 对战排行榜奖励(ttFight/fightRankingReward)—— 此前 0 行 F */
|
||||
(3217, '新增奖励', 2092, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:add', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·生成'),
|
||||
(3218, '删除奖励', 2092, 2, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:remove', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·清空'),
|
||||
(3219, '修改奖励', 2092, 3, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:edit', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·修改'),
|
||||
(3220, '奖励查询', 2092, 4, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:query', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·详情'),
|
||||
(3221, '奖励列表', 2092, 5, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:reward:list', '#', 'admin', NOW(), '', NULL, '对战排行榜奖励·列表接口'),
|
||||
/* 机器人对战配置(ttFight/robotFightConfig)—— 该 C 行下原有 4 行(3119~3122),价格配置这套是漏的,故 order 从 5 起 */
|
||||
(3222, '新增价格配置', 3118, 5, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:add', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置新增'),
|
||||
(3223, '删除价格配置', 3118, 6, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:remove', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置删除'),
|
||||
(3224, '修改价格配置', 3118, 7, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:edit', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置修改'),
|
||||
(3225, '价格配置查询', 3118, 8, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:query', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置详情'),
|
||||
(3226, '价格配置列表', 3118, 9, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:robotFightPriceConfig:list', '#', 'admin', NOW(), '', NULL, '机器人对战配置·价格配置列表接口'),
|
||||
/* 货币列表(ttRechargeList/index)—— 该页只有详情接口校验;列表接口本身无 @PreAuthorize */
|
||||
(3227, '货币查询', 2035, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:rechargeList:query', '#', 'admin', NOW(), '', NULL, '货币列表·详情'),
|
||||
/* 机器人对战分组宝箱(ttFight/robotFightGroupBox)
|
||||
⚠️ 复用组:该页前端按钮用 admin:box:{add,edit,remove}、后端接口用 admin:box:{list,query},
|
||||
而这 4 个串原先只挂在 2019「宝箱列表」下 —— 父子联动带不过来,
|
||||
所以必须在 2123 下再挂一遍,才能做到「勾这一个页面 = 该页完整可用」。*/
|
||||
(3228, '新增分组宝箱', 2123, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:add', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·新增(与 2020 同 perms,刻意重复)'),
|
||||
(3229, '删除分组宝箱', 2123, 2, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:remove', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·删除(与 2021 同 perms,刻意重复)'),
|
||||
(3230, '修改分组宝箱', 2123, 3, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:edit', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·修改(与 2022 同 perms,刻意重复)'),
|
||||
(3231, '分组宝箱查询', 2123, 4, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:query', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·详情接口'),
|
||||
(3232, '分组宝箱列表', 2123, 5, '', NULL, NULL, 1, 0, 'F', '0', '0', 'admin:box:list', '#', 'admin', NOW(), '', NULL, '机器人对战分组宝箱·列表接口(与 2019/3139 同 perms,刻意重复)');
|
||||
|
||||
/* ---- 2. 授权补齐:把新 F 行补给「已授权其父 C 行」的角色 ----
|
||||
动态写法的好处:不依赖执行时刻的角色授权现状,也不用在角色界面逐个重存。
|
||||
注意:只补 F 行;C 行(页面本身)是否可见仍由业务在「角色管理」里决定。*/
|
||||
INSERT INTO `sys_role_menu` (`role_id`, `menu_id`)
|
||||
SELECT DISTINCT `rm`.`role_id`, `f`.`menu_id`
|
||||
FROM `sys_menu` `f`
|
||||
JOIN `sys_role_menu` `rm` ON `rm`.`menu_id` = `f`.`parent_id`
|
||||
WHERE `f`.`menu_id` BETWEEN 3209 AND 3232
|
||||
AND NOT EXISTS (SELECT 1 FROM `sys_role_menu` `x`
|
||||
WHERE `x`.`role_id` = `rm`.`role_id` AND `x`.`menu_id` = `f`.`menu_id`);
|
||||
|
||||
/* ---- 3. 修正 3 行挂错父节点的 F(3160~3162:目录 3156 → 页面 3157)---- */
|
||||
UPDATE `sys_menu`
|
||||
SET `parent_id` = 3157
|
||||
WHERE `menu_id` BETWEEN 3160 AND 3162
|
||||
AND `menu_type` = 'F'
|
||||
AND `parent_id` = 3156;
|
||||
```
|
||||
|
||||
**验证**:
|
||||
|
||||
```sql
|
||||
-- ① 24 行 F 都进去了、且 perms 无重复录入
|
||||
SELECT COUNT(*) FROM sys_menu WHERE menu_id BETWEEN 3209 AND 3232 AND menu_type='F'; -- 期望 24
|
||||
SELECT perms, COUNT(*) FROM sys_menu WHERE menu_id BETWEEN 3209 AND 3232
|
||||
GROUP BY perms HAVING COUNT(*) > 1; -- 期望 0 行(2123 组与 2019 组不同 id,不算重复)
|
||||
|
||||
-- ② 全库 F 行的 parent 必须是指向 C 行的 menu_id(防挂错;本次修掉了 3160~3162 这组)
|
||||
SELECT f.menu_id, f.menu_name, f.perms, f.parent_id, p.menu_type
|
||||
FROM sys_menu f LEFT JOIN sys_menu p ON p.menu_id = f.parent_id
|
||||
WHERE f.menu_type = 'F' AND (p.menu_id IS NULL OR p.menu_type <> 'C'); -- 期望 0 行
|
||||
|
||||
-- ③ 授权补齐后:每个新 F 行,其父 C 行的每个授权角色都应能查到
|
||||
SELECT f.menu_id, f.perms, rm.role_id
|
||||
FROM sys_menu f
|
||||
JOIN sys_role_menu rm ON rm.menu_id = f.parent_id
|
||||
WHERE f.menu_id BETWEEN 3209 AND 3232
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_role_menu x
|
||||
WHERE x.role_id = rm.role_id AND x.menu_id = f.menu_id); -- 期望 0 行
|
||||
|
||||
-- ④ 端到端:模拟某角色的 perms 集合,确认缺串已消失
|
||||
SELECT DISTINCT m.perms FROM sys_menu m
|
||||
JOIN sys_role_menu rm ON m.menu_id = rm.menu_id
|
||||
WHERE rm.role_id = <角色ID> AND m.perms IS NOT NULL AND m.perms <> '';
|
||||
```
|
||||
|
||||
**代码侧配套(非 SQL)**:新增 `.workbuddy/verify-perms.cjs`,做「代码在用的权限串 ↔ `sys_menu.perms`」双向差集,
|
||||
防止后续新增页面再次只建 C 行、漏建 F 行。跑法见脚本头部注释。
|
||||
|
||||
**注意:权限生效需要用户重新登录**(`permissions` 是登录时从 `/getInfo` 拉取的,存在前端 store 里,不会热更新)。
|
||||
|
||||
---
|
||||
|
||||
-----------------------对战接口优化--------------------------
|
||||
|
||||
SHOW INDEX FROM tt_fight;
|
||||
|
||||
ALTER TABLE tt_fight ADD INDEX idx_ttf_status_create (status, create_time);
|
||||
|
||||
ALTER TABLE tt_fight
|
||||
ADD COLUMN total_ornaments_price DECIMAL(18,2) NULL COMMENT '本场开箱饰品总价值(百佳榜排序用)',
|
||||
ADD INDEX idx_ttf_status_total (status, total_ornaments_price);
|
||||
|
||||
ALTER TABLE tt_fight_user ADD INDEX idx_tfu_fight_user (fight_id, user_id);
|
||||
|
||||
ALTER TABLE tt_fight ADD INDEX idx_ttf_create_time (create_time);
|
||||
|
||||
-----------------------------------------------------------
|
||||
|
||||
+964
-4
@@ -2,11 +2,821 @@
|
||||
|
||||
> **只记大改动**:一个需求 / 一个模块算一条;同一件事的多轮微调合并成一条,不逐轮记。
|
||||
> **排序:最新在最上面。**
|
||||
> 展开细节看 `docs/updata_sql.md`(数据库改动,含完整可执行 SQL 与回滚)与 `.workbuddy/memory/YYYY-MM-DD.md`(过程与踩坑)。
|
||||
> 展开细节看 `docs/updata_sql.md`(数据库改动,含完整可执行 SQL)与 `.workbuddy/memory/YYYY-MM-DD.md`(过程与踩坑)。
|
||||
> **维护**:完成一个大改动后,在顶部按日期加一条;同一天追加在该日期块末尾。
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-27
|
||||
|
||||
- **管理后台按钮级权限补全:`sys_menu` 补 24 行 `F` + 防漂移校验脚本**
|
||||
|
||||
**起因**:用户问「管理后台会不会有只有超管能看到、非超管看不见的页面内按钮」。核查结论:**会,而且比按钮更严重**。
|
||||
|
||||
**机制**:按钮显隐走前端 `v-hasPermi`、接口放行走后端 `@PreAuthorize("@ss.hasPermi(...)")`,
|
||||
两边比对的是**同一份 `sys_menu.perms`**;超管(`user_id==1`)拿 `*:*:*` 所以永远看得见。
|
||||
⇒ **某权限串只要在 `sys_menu` 里没有对应行,就永远授权不到任何非超管角色**:按钮看不见、部分列表/详情接口直接 403。
|
||||
|
||||
**盘出的问题**(代码在用、库里未登记,并集 43 串):
|
||||
- 4 个孤儿页面(开箱概率 / 首充奖励 / 月度充值 / 换装记录)占 23 串 —— 已核实无菜单、无组件引用、无路由、无接口复用方,
|
||||
**不可达,本期不动**(手输 URL 也 404,因 RuoYi 路由由 `/getRouters` 动态注册)。
|
||||
- 其余 20 串 + `admin:box:*` 复用组补 4 行 = **24 行**,本次补齐。
|
||||
- 结构性漂移:**从 `menu_id` 31xx 段起,新增页面只建 `C` 行、不建 `F` 行**(F 行分布:1000 段 61、2000 段 18、3100 段 9、3200+ 段 0)。
|
||||
|
||||
**改动清单**
|
||||
1. `sys_menu` 新增 24 行 `F`(`menu_id 3209~3232`,挂 2035/2038/2086/2092/2123/3118 六个 C 行下)。
|
||||
2. `sys_role_menu` 动态补齐:把新 F 行补给「已授权其父 C 行」的角色(本次实写 24 行,全给 `role_id=104`)。
|
||||
3. `sys_menu` 修正 3 行(`3160~3162`)`parent_id`:目录 `3156` → 页面 `3157`(F 行挂错层级,父子联动带不到)。
|
||||
4. **代码侧配套**:新增 `.workbuddy/verify-perms.cjs` —— 扫前端 `v-hasPermi` + 后端 `@PreAuthorize`,
|
||||
与 `sys_menu.perms` 做双向差集 + 校验全库 F 行 parent 必须指向 C 行。非 0 退出码,建议挂 `publish.sh` 前防复发。
|
||||
|
||||
**验证**:库层 5 项全对(24 行 / 0 重复 / 0 挂错父节点 / 0 未授权 / `role_menu` 222→246);
|
||||
`node .workbuddy/verify-perms.cjs` ⇒ **ALL_OK 6 passed / 0 failed**。
|
||||
|
||||
**注意**:① 权限生效需用户**重新登录**(`permissions` 登录时从 `/getInfo` 拉取,不热更新)。
|
||||
② 执行时库内授权已被重排 —— 只有 `role_id=104`(测试人员)持有这些页面,`三恒` 等角色**0 菜单**,
|
||||
且多个启用用户(三恒/国荣/s1/zzyzzy123/Fdily)**未分配任何角色**,这是业务配置问题,本次未动。
|
||||
|
||||
细节 SQL 见 `docs/updata_sql.md`「管理后台按钮级权限补全」,过程与踩坑见 `.workbuddy/memory/2026-09-27.md`。
|
||||
|
||||
- **推广后台「佣金明细记录」等 3 张报表右侧留白 → 全列 `width` 改 `min-width`**
|
||||
|
||||
**起因**:用户截图 `promo-ui2/src/views/CommissionReport/index.vue` 的「佣金明细记录」表,说「这里表格填充不满」。
|
||||
|
||||
**根因**(读 Element Plus 源码定论,不是猜):`element-plus/es/components/table/src/table-layout.mjs:100-171`
|
||||
的 `updateColumnsWidth()` 只在 **存在弹性列** 时才把表格撑满容器
|
||||
(`flexColumns = flattenColumns.filter(c => !isNumber(c.width))`);
|
||||
全列都是数字 `width` 时走 else 分支 ⇒ `bodyWidth = Σ realWidth` ⇒ **表格宽恒等于列宽之和,容器再宽右侧也留白**。
|
||||
⚠️ 关键陷阱:`width="170"` 这种**静态字符串**也会被 `table-column/render-helper.mjs:39` 的
|
||||
`parseWidth()`(= `Number.parseInt`)转成数字 170 ⇒ 那是**固定列**;
|
||||
反过来只写 `min-width` 时 `setColumnWidth()` 会执行 `column.width = void 0` ⇒ 那才是**弹性列**。
|
||||
|
||||
**改动清单**(`promo-ui2/src/views/CommissionReport/index.vue`,3 张表共 16 列;另有 6 张表见本节末):
|
||||
1. 「佣金明细记录」6 列(本地时间170 / 手机号130 / 该次消费120 / 佣金比例90 / 佣金金额100 / 来源120)
|
||||
2. 「佣金日报」5 列(手机号130 / 今日消费120 / 来源120 / 佣金金额120 / 日期130)
|
||||
3. 「佣金汇总」5 列(手机号130 / 来源120 / 消费金额120 / 本次佣金120 / 参加游戏次数140)
|
||||
—— **一律 `width` → `min-width`,数值不变**。选「全列都变弹性」而不是「只挑一列变弹性」:
|
||||
弹性的那一列会独吞全部余量(1280 视口下 170px 的时间列会涨到 ≈590px),全列按 min-width 等比摊才均匀。
|
||||
模板顶部加了注释说明为什么不能改回 `width`。脚本/接口/分页/过滤逻辑一行未动。
|
||||
|
||||
**验证**(3 件,全部 ALL_OK):
|
||||
- SFC 编译:`node .workbuddy/verify-promo-vue-compile.cjs src/views/CommissionReport/index.vue` ⇒ **ALL_OK 4/0**
|
||||
(连带跑 Home ⇒ 11/0,未受影响)。
|
||||
- 算法级多视口回归:`node .workbuddy/verify-ep-table-stretch.cjs src/views/CommissionReport/index.vue`
|
||||
⇒ **ALL_OK 15/0**。1280/1440/1600/1920/2560 五个视口下 3 张表**留白全为 0px、`scrollX` 全 false**。
|
||||
- 真产物:`node .workbuddy/check-promo-modules.cjs src/views/CommissionReport/index.vue --has=min-width --prop=min-width:16`
|
||||
⇒ **ALL_OK**(用户常驻的 `:4000` dev server 上主模块 200、`"min-width"` prop 恰好 **16** 次、0 处编译错误串)。
|
||||
- 全站审计:`node .workbuddy/audit-table-fill-ep.cjs` ⇒ CommissionReport 3 张表从「✗ 不能撑满」转为有弹性列。
|
||||
|
||||
**新增脚本(3 个,都是 promo-ui2 / Element Plus 侧原先缺的)**:
|
||||
`.workbuddy/audit-table-fill-ep.cjs`(全站扫「全固定列 ⇒ 留白」)、
|
||||
`.workbuddy/verify-ep-table-stretch.cjs`(复现 `updateColumnsWidth` 做多视口定量回归)、
|
||||
`.workbuddy/check-promo-modules.cjs`(从 `:4000` 拉真实编译产物做断言)。
|
||||
原有的 `sim-table-width.cjs` / `verify-table-fill.cjs` 只适用 ruoyi-ui(Element UI 2.x),**不要混用**。
|
||||
|
||||
**注意 / 一并修的另外 6 张**:审计出全站共 **9 张**同样会留白的表,用户拍板「6 张全改」,已一并完成:
|
||||
`BusinessManage/index.vue` 3 张(招商 Σ1128 / 主播 Σ828 / 普通用户 Σ920,后两张在展开行内)、
|
||||
`CardMerchant/index.vue:44`(Σ1210)、`subFlow/index.vue:20`(Σ1570)、`SubordinateRecharge/index.vue:23`(Σ760)。
|
||||
- 改法一致:**数据列 `width` → `min-width`**;**「操作」列保持 `width`**(CardMerchant 100、subFlow 120)——
|
||||
操作列固定宽是设计意图,不该被拉宽。BusinessManage 的 `type="expand"` 列也不动(`cellStarts` 强制 48px 固定)。
|
||||
- **嵌套子表(BusinessManage 二/三级)**:容器宽度由父级展开行决定,**不能用「视口 − 288」推算**。
|
||||
`verify-ep-table-stretch.cjs` 已加 `depth` 判定,嵌套表只做「有没有弹性列」的定性断言,不套用视口断言。
|
||||
- **两张宽表仍会横向滚动**(改 min-width 治不了,属列宽总量问题):`CardMerchant` Σ1210 ⇒ 视口 ≤1440 横滚(差 58px);
|
||||
`subFlow` Σ1570 ⇒ 视口 ≤1600 横滚。**这两个视口下改前改后都是横滚**,未新增;≥1600 / ≥1920 起改为撑满(改前是留白)。
|
||||
若要 1440 也不横滚,得再压列宽(`使用时间/创建时间` 180→165、`卡密` 200→190 之类),本次未做,等确认。
|
||||
- 复验:全站审计 **不能撑满 0 / 34**;SFC 编译 **19/0 ALL_OK**;列宽回归 **22/0 ALL_OK**(6 张表 × 视口);
|
||||
真产物(`:4000`)4 个文件主模块全 200、`"min-width"` prop 计数 **22 / 8 / 6 / 13** 逐一吻合、0 错误串。
|
||||
|
||||
⚠️ **未经浏览器实测**(本机 Chrome 起不来,见 `.workbuddy/memory/theme-skin.md`)⇒ 效果请自己刷 `:4000` 确认。
|
||||
|
||||
- **修复:幸运升级「水位驱动」因 mapper 里 `SUM(x, 0)` 语法错而整条不可用**
|
||||
|
||||
**现象**:启动日志 `TtUpgradeRewardLogMapper.xml` → `sumRecordTotals` 报
|
||||
`java.sql.SQLSyntaxErrorException: ... right syntax to use near '' at line 4`,
|
||||
外层 WARN「幸运升级水位驱动初始化失败(不阻断启动,首次拉取时会自动重试)」。
|
||||
|
||||
**根因**:`TtUpgradeRewardLogMapper.xml` 的 `blankBet` / `blankWin` 两行把外层 `COALESCE(..., 0)` 的写法抄进了 `SUM()` ——
|
||||
`SUM(CASE WHEN is_blank THEN r.amount_consumed END, 0)`。**`SUM()` 只接受一个参数**,第二个 `, 0` 直接语法错。
|
||||
(同文件 `totalBet` / `totalWin` 两行写的是 `CASE WHEN is_blank THEN 0 ELSE ... END`,是对的,可作对照。)
|
||||
|
||||
**改动**:删掉 `SUM(...)` 里多余的 `, 0`(外层 `COALESCE(SUM(...), 0)` 保留,仍是 null-safe)。全仓库仅此一处。
|
||||
|
||||
**影响面(不是"只是个 WARN")**:
|
||||
- `ensureInit(false)`(`@PostConstruct` 预热)异常 → Redis 累计量 `KEY_BET/KEY_WIN/KEY_BLANK_*` **从未写入**,
|
||||
`KEY_INIT_AT` 也没落 ⇒ 每次调用都重新全表扫并再次失败,**水位恒为 0**,水位驱动实际等于停摆。
|
||||
- 后台「奖池水位 → 按历史重算」`/recalc` 调 `ensureInit(true)` 且**没有 try/catch** ⇒ 该按钮**必 500**。
|
||||
- `calibrationFactor()` 有 try/catch → 失败返回 0 → 回落兜底系数 1.19(这条不受影响)。
|
||||
|
||||
**验证**:库里实测 `SELECT SUM(CASE WHEN 1 THEN 2 END, 0)` 复现同一 `ERROR 1064`;
|
||||
改成 `SUM(CASE WHEN ... END)` 后整条 `sumRecordTotals` 跑通出数。XML 重新解析通过。
|
||||
|
||||
**上线动作**:`./ry.sh stop` → `mvn clean install`(见 `USER.md` 的「必须 clean install,否则嵌套旧 jar」)→ `./ry.sh start`。
|
||||
|
||||
- **推广后台登录页新增「开发环境账号密码登录」**
|
||||
|
||||
**起因**:推广后台唯一登录入口是 `/promo/smsLogin`(手机号 + 4 位短信验证码 + 滑块),本地联调要连短信通道、拖滑块;
|
||||
后端此前已明确下线账号密码登录(`PromoLoginServiceImpl` 注释:「账号密码登录已下线……避免用密码绕过短信校验被撞库」)。
|
||||
|
||||
**改动清单**(前后端配合,**默认关闭**):
|
||||
|
||||
1. **后端新增 `POST /promo/devLogin`**(`@Anonymous`):账号解析直接复用 `createUserToken` 的 `promo_user_` 入口
|
||||
⇒ 推广账号(`tt_user` 的主播/招商/卡商,登录名=手机号)与超管(`promo_admin` 的 `username` 或 `phone_number`)
|
||||
**都能登录**,区别只是「短信验证码」换成了 `SecurityUtils.matchesPassword` 的 BCrypt 密码校验。
|
||||
账号不存在 / 停用 / 密码错统一返回「账号或密码错误」(防账号枚举,真实原因走 `log.warn`)。
|
||||
2. **开关走配置文件而非数据库**:新增 `promo.dev-login.enabled` —— `application.yml` 默认 `false`,`application-dev.yml` 置 `true`,
|
||||
生产 profile 不覆盖 ⇒ 接口恒返回「账号密码登录未开启」,且后台管理员无法误开。
|
||||
3. `PromoWebConfig` 放行 `/promo/devLogin`(维护拦截器不拦登录类接口,与 smsLogin/register 同口径)。
|
||||
4. **前端** `LoginForm.vue` 只在 `import.meta.env.DEV`(即 `pnpm run dev`)下渲染「短信验证码 / 账号密码」切换与密码表单;
|
||||
登录成功后复用原有的 `completeLogin` + `enterSystem` 落地流程。`api/login` 增加 `devLoginApi` 与 `DevLoginType`。
|
||||
|
||||
**验证**:`vue-tsc --noEmit` 改动文件 0 报错(仓库其余 30+ 条为既存错误);`mvn -B -DskipTests compile` ⇒ `BUILD SUCCESS`。
|
||||
|
||||
**无需数据库变更**:开关是配置文件项,不是 `sys_config`(相对 `promoMaintenance` 的取舍见 `.workbuddy/memory/2026-09-27.md`)。
|
||||
|
||||
**使用注意**:密码按 BCrypt 校验,测试账号若不知明文,查 `tt_user.remark`(格式 `明文密码:xxx`)或 `promo_admin` 表。
|
||||
|
||||
- **管理后台首页改排版:11 张等宽统计卡 → 「左窄轨 + 右主区」两栏**
|
||||
|
||||
**起因**:用户反馈首页「排版还是和原来一样,只是改了 UI 的风格」——换肤把配色圆角阴影改了,但骨架仍是
|
||||
「页头 → 卡片网格 → 区块 → 区块」的垂直堆叠模板,要求**排版差异化、风格照旧**。
|
||||
|
||||
**改动清单**(`ruoyi-ui/src/views/index.vue`,单文件)
|
||||
1. **骨架拆两栏**:`grid-template-columns: 300px minmax(0,1fr)` + `align-items:start`。
|
||||
- **左窄轨**(`position:sticky; top:16px`,跟随滚动):3 个核心大数字(在线用户 / 总充值 / 总流水,字号 28px)
|
||||
+ 待办区(待处理提货申请 / 提货余额)。待处理 >0 时雏菊金描边 + 数值变色。
|
||||
- **右侧主区**:实时 RTP → 「经营数据」紧凑指标带(6 项 `mini-stat`,小图标 + 标签 + 数值)→ 流水统计图
|
||||
(顺序按用户看完后的要求定:图表 400px 最高,放最底部滚到底正好)。
|
||||
2. **指标一项不丢**:原 `statCards` 11 项拆成 `railMetrics`(3) + `miniStats`(6) + 待办区硬编码 2 项 = 11。
|
||||
原「图标 + 大数值 + 标签」的等权卡片形态取消,改为「大数字 / 紧凑条」两级,主次拉开。
|
||||
3. **RTP 网格** `repeat(5,1fr)` → `repeat(auto-fit, minmax(160px,1fr))`:右区宽度随屏幕浮动(左轨占了 300px),
|
||||
写死 5 列会在 1440 以下挤出 4+1。
|
||||
4. 旧骨架 CSS(`.stat-grid` / `.stat-card` / `.stat-icon` / `.stat-info` / `.stat-arrow`)与 `statCards` computed **一并删除**。
|
||||
|
||||
**风格**:色板 / 圆角 / 阴影 / 图标浅底规则全部沿用,**未新增任何色值** —— cottagecore 配色纪律不动。
|
||||
|
||||
**验证**:`node .workbuddy/verify-home-layout.cjs`(新增,cwd=ruoyi-ui)⇒ **ALL_OK 28 条 0 失败**
|
||||
(含 vue-template-compiler 编译模板、dart-sass 编译样式、新旧骨架核对、区块顺序、指标总数 11、交互绑定未丢);
|
||||
`node .workbuddy/audit-palette.cjs` ⇒ **离版文件 0/185、离版色值 0 次**;
|
||||
dev server(`:80`)`/static/js/app.js` ⇒ 200 / 7.1MB / 0 处 `Module build failed`。
|
||||
⚠️ 本机助手拉不起 Chrome(安全策略)⇒ **渲染后实际观感未经实测**,需用户刷新页面肉眼确认。
|
||||
|
||||
**后续补丁(用户看完截图提的)**:左轨「待处理提货申请 / **提货余额**」那格里,提货余额写的是 `$9259.64`
|
||||
(**美元符号**),而页面其余金额(总充值 `¥1800`、总流水 `¥0.00`、RTP 投注/奖励 `¥0.00`、经营数据 6 项 `¥…`)
|
||||
一律 `¥` ⇒ 统一为 `¥{{ balance }}`(`ruoyi-ui/src/views/index.vue:55`,全站唯一一处 `$` 货币写法,已 grep 确认)。
|
||||
`verify-home-layout.cjs` 补 2 条断言(模板内不得出现 `$` 紧跟插值;提货余额那行前缀必须是 `¥`)⇒ 现 **30 条 ALL_OK**。
|
||||
真产物复核(新增 `.workbuddy/check-ruoyi-bundle.cjs`,从 `:80` 拉 `app.js`):编译后为
|
||||
`_vm._v("¥" + _vm._s(_vm.balance))`,`$` 版本 0 处。
|
||||
|
||||
- **推广后台首页改排版:「基本信息 / 5 张返利卡 / 汇总表」三段堆叠 → 「推广工具置顶」**
|
||||
|
||||
**起因**:与上一轮管理端首页同类问题(换肤只改配色,骨架没动)。用户在 3 轮高保真稿里选定 **版本 3 · 推广工具置顶**。
|
||||
|
||||
**改动清单**(`promo-ui2/src/views/Home/index.vue`,单文件)
|
||||
1. **① 我的推广(置顶通栏)**:`hero` 行放**推广链接**(15px 加粗、超长省略、整条可复制)+ 主色「复制链接」按钮(无链接时 `disabled`);
|
||||
下面一条虚线分隔的三格:**我的邀请码**(复制 / 修改 / 已修改)、**手机号码**(绑 `phoneNumber`)、**上级邀请码**。
|
||||
2. **② 返利 KPI 横带**(4 格,19px 数字):**累计返利 / 今日返利 / 本周返利 / 累计下级** ——
|
||||
全部从已有的 `getGameCommissionData()` 的 `periods` 里取(`总`/`今日`/`本周` 行),**不新增任何接口**。
|
||||
3. **③ 底部两栏** `260px minmax(0,1fr)`:左 = **返利比例**(5 行「玩法名 ⋯⋯ 百分比」点线清单,替掉原来 5 张等宽卡);
|
||||
右 = **周期返利汇总**表格(列一项没删,仍是 10 列)。`@media(max-width:1200px)` 收成一栏让表格拿满宽度。
|
||||
4. **④ 账号信息降级到最底部**:用户账号 / 用户类型 / 金币余额 / 积分余额 / 注册时间 / Steam交易链接(6 项)。
|
||||
样式上**做成非卡片**(`background/border` 透明、去阴影),避免和上面的数据抢注意力。
|
||||
5. 旧骨架(`.section` / `.section-title` / `.game-rate-cards` / `.rate-card*`)与那两条**从未命中过**的
|
||||
`.el-table__row tr:nth-child(odd)` 伪条纹规则一并删除(Element 的 `stripe` 已负责隔行)。
|
||||
6. 颜色从残留的旧 Element 灰(`#f5f7fa` / `#e4e7ed` / `#303133` / `#67c23a` / `#606266` / `#909399` / `#fafafa` / `#e6a23c`)
|
||||
收敛到田园核白名单 —— 该页 scoped 样式是换肤时漏掉的一份。
|
||||
|
||||
**一字未动的**:`username: userInfo.userName`(改邀请码的**入参**,后端拿它做「只能改自己」校验,与展示用的 `phoneNumber` 语义不同);
|
||||
返利比例出处仍是 `getInfo()` 的三个 `*CommissionRate`;表格 10 列 / 弹窗 / 复制 / `onMounted` 双并发加载全部保留。
|
||||
|
||||
**验证**(新增 2 个脚本)
|
||||
- `node .workbuddy/verify-promo-home.cjs` ⇒ **ALL_OK 54 条 0 失败**:新骨架 12 项、旧骨架 4 项须绝迹、区块顺序、
|
||||
表格 10 列 / 账号 6 项 / KPI 4 项 / 玩法 5 项齐全、交互绑定 7 项、旧 Element 色 0 处、色值全在白名单、8 组对比度 ≥4.5:1。
|
||||
- `node .workbuddy/verify-promo-vue-compile.cjs`(新增,用 promo 自带的 `vue/compiler-sfc` 走
|
||||
parse → compileScript → compileTemplate → compileStyle)⇒ **ALL_OK 7 条 0 失败**。
|
||||
- `node .workbuddy/verify-home-page.cjs` ⇒ **ALL_OK 5 条 0 失败**(该脚本①的结构性正则随「手机号码」移位已改写为与结构无关的定位)。
|
||||
- **真机 dev 产物**(用户的 vite dev server 正在 `:4000` 跑,热更新):`/src/views/Home/index.vue` 的
|
||||
style / template / script-setup / main 四个模块全 200,0 处 `Transform failed` / `Internal server error`;
|
||||
编译后 15/15 个新骨架选择器都带上了 `[data-v-a763a6ae]` 前缀,旧类名 0 残留。
|
||||
|
||||
⚠️ 两点未做实测:**渲染后观感**(本机 Chrome 拉不起来);**窄屏横向滚动** —— 底部两栏时表格可用宽度 ≈ 屏宽-200(侧栏)-80,
|
||||
10 列 `min-width` 合计 1090px,**视口 < 约 1600px 时右侧表格会出现横向滚动**(≥1600 正常),已在 `<1200px` 断点收成一栏规避。
|
||||
**数据全是 0 是账号本身无下级/无消费所致**,不是渲染问题(`periodData` 的 4 行都在,只是值都为 0.00)。
|
||||
|
||||
过程与踩坑见 `.workbuddy/memory/2026-09-27.md`。
|
||||
|
||||
- **管理后台站点名「河南项目」→「凯科佰娱」(ruoyi-ui)**
|
||||
|
||||
**起因**:用户截图(`localhost/#/index`)圈出左下角 Logo 旁标题与浏览器 tab 标题,要求改成「凯科佰娱」。
|
||||
|
||||
**定位**:截图端口 80 + hash 路由 ⇒ 是 **`ruoyi-ui`(Vue2 管理后台,`vue.config.js` 默认 port 80)**,不是 promo-ui2(:4000)。
|
||||
站点名只有一个源:`VUE_APP_TITLE`(`.env.*`)。消费方两处 ——
|
||||
`src/layout/components/Sidebar/Logo.vue:23`(侧栏标题)、`src/App.vue:20-29` 的 `metaInfo.titleTemplate`
|
||||
(`${路由名} - ${VUE_APP_TITLE}`);`public/index.html` 的 `<title>` 由 `vue.config.js:10` 的 `name` 注入。
|
||||
⇒ **改一处 env 即可同时改掉侧栏 + tab**。另有 login/register 两页是硬编码同名,一并改。
|
||||
|
||||
**改动清单(6 处,全是字符串替换)**
|
||||
1. `.env.development` / `.env.production` / `.env.staging`:`VUE_APP_TITLE = 河南项目` → `凯科佰娱`。
|
||||
2. `vue.config.js:10` 兜底值 `|| '河南项目'` → `|| '凯科佰娱'`。
|
||||
3. `src/views/login.vue:5`、`src/views/register.vue:4` 的 `<h3 class="title">`。
|
||||
|
||||
**注意**:① `.env` 在**进程启动时**注入 `process.env`,**改完必须重启 dev server**(webpack 不会热更 env)。
|
||||
② `ruoyi-ui/dist/` 是构建产物,仍含旧串,需 `npm run build:prod` 重新生成才会更新(未手动改 dist)。
|
||||
③ 后端 `application-*.yml` 里的「河南」是注释(控制台账号备注),与站点名无关,未动;
|
||||
`docs/修改记录.md:910` 等处的「河南项目」是历史叙述,保留。
|
||||
|
||||
过程见 `.workbuddy/memory/2026-09-27.md`。
|
||||
|
||||
- **推广后台顶栏「分享链接」删除(与首页「我的推广链接」重复)**
|
||||
|
||||
**起因**:用户截图 `promo-ui2` 首页(`localhost:4000/#/home/index`),圈出**顶栏**的 `分享链接:https://…?code=…`
|
||||
与**页面里**的「我的推广链接」hero 卡是同一份内容,要求「把上面的去掉」。
|
||||
|
||||
**定位**:两处不同来源 ——
|
||||
① **顶栏**:`src/layout/components/ToolHeader.vue:45-47` 的 `<span>分享链接:{userStore.getUserInfo?.shareLink}</span>`
|
||||
(**布局层组件,所有页面都有**,`role !== '99' && role !== '05'` 才显示);
|
||||
② **页面**:`src/views/Home/index.vue:4-21` 的 hero 卡 —— 本次改版(版本 3「推广工具置顶」)**新加的**。
|
||||
⇒ hero 一上线,顶栏那份就成了重复。按用户口径删**顶栏**(上面的)。
|
||||
|
||||
**改动清单**
|
||||
1. `ToolHeader.vue`:删掉该 `<span>`(原地留注释说明为何删)。
|
||||
2. 同文件连带清理:`useUserStore` 的 import 与 `const userStore = useUserStore()` —— 全文件只有这一处在用,
|
||||
不删会变成未使用变量(`vue-tsc` / eslint 会报)。
|
||||
3. `Home/index.vue`:删掉「改邀请码成功后同步顶部 header 分享链接」的 `userStore.setUserInfo({...})` 块 + 其 import/实例——
|
||||
该块**存在的唯一目的**就是喂被删掉的顶栏,属同一改动的死代码,不是重构。
|
||||
|
||||
**验证**:给 `.workbuddy/check-promo-modules.cjs` **新增 `--absent=<子串>`**(原先只有 `--has` 用于"必须有",
|
||||
删元素没法验),从用户常驻的 `:4000` 拉 **vite 真产物**断言:
|
||||
- `layout/components/ToolHeader.vue` → 主模块 200、style 子模块 200、`useUserStore` / `shareLink` / `getUserInfo` **全部已消失**,**ALL_OK 6/0**。
|
||||
- `views/Home/index.vue` → 主模块 200、`useUserStore` / `setUserInfo` **已消失**,**ALL_OK 4/0**。
|
||||
⚠️ `--absent` 的坑:dev 构建**保留模板注释** ⇒ 注释里**别写被删的那个词**,否则 `_createCommentVNode("…")` 会命中假 FAIL。
|
||||
|
||||
**影响面**:改的是**布局层** ⇒ 所有页面顶栏都不再显示分享链接(不只首页)。
|
||||
`userStore.userInfo.shareLink` 仍由 `Login/completeLogin.ts` 与 `views/User/index.vue` 写入,只是**不再有展示方**(无其他读取处,已 grep 确认);
|
||||
改邀请码后 store 里那份不再自动刷新,因无消费方,无影响。
|
||||
|
||||
过程见 `.workbuddy/memory/2026-09-27.md`。
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-24
|
||||
|
||||
- **头像管理:「是否默认」可同时多条为「是」的缺陷修复(唯一性约束 + 存量数据清洗 + 开关交互)**
|
||||
|
||||
**起因**:用户在「网站设置 → 头像管理」截图反馈,「是否默认」那一列有 7 条都显示绿色「是」,
|
||||
而按语义**全站默认头像只应该有一条**。
|
||||
|
||||
**排查结论(逐层验证,不是猜的)**:
|
||||
|
||||
| 层 | 实测 | 结论 |
|
||||
|---|---|---|
|
||||
| 数据库 | `SELECT is_default, COUNT(*) ... GROUP BY is_default` → `0→1`、`1→7`(id 63~69) | **数据本身就错**,界面是忠实反映 |
|
||||
| 前端 | `v-model="scope.row.isDefault"` 直读接口字段 | 无显示层问题,不需要改渲染 |
|
||||
| 后端 | `TtUserAvatarServiceImpl.updateUserAvatarById` 只做 `updateById` | **根因**:设为默认时不互斥清空其他记录 |
|
||||
|
||||
⇒ 定位为**后端写接口缺约束 + 存量脏数据**,不是 UI bug。
|
||||
|
||||
**二、连带发现(比表面问题更严重)**:C 端有 4 处消费「默认头像」,其中
|
||||
`ApiRegisterServiceImpl`、`PromoLoginServiceImpl` 用的是
|
||||
`list(is_default='1')` + `RandomUtil.choice(...)` **随机挑一条** —— 这是在「可能有多条默认」的
|
||||
既成事实上打的补丁。后果是**每次注册拿到的初始头像都不一样**,默认头像形同虚设。
|
||||
(`TtUserServiceImpl` 白号那边已有 `defaultAvatarList.isEmpty()` 的降级,逻辑本身没问题。)
|
||||
|
||||
**三、改动清单**
|
||||
|
||||
1. **后端互斥(核心)** `skins-service/service-admin`:
|
||||
- `TtUserAvatarService` 新增 `saveAvatar(TtUserAvatar)`、`getDefaultAvatar()`;
|
||||
- `TtUserAvatarServiceImpl` 新增私有方法 `clearOtherDefaultsIfNeeded(...)`:
|
||||
凡是把某条写成 `is_default='1'` 的写入,**先 `UPDATE ... SET is_default='0' WHERE is_default='1' AND id<>当前id`**,
|
||||
再落当前这条,两步同一事务(`@Transactional`)。只在确实要置默认时才执行 UPDATE,普通编辑不白扫全表。
|
||||
- `updateUserAvatarById` / `saveAvatar` 均接入;顺手删掉原方法里无意义的 `setAvatar(getAvatar())`。
|
||||
2. **控制器** `TtUserAvatarController.add` 由 `userAvatarService.save(...)` 改走 `saveAvatar(...)`,
|
||||
使**新增**也受同一约束(原来新增一条默认就会叠加出第二个)。
|
||||
3. **存量数据清洗**:`UPDATE tt_user_avatar SET is_default='0' WHERE is_default='1' AND id <> (最小 id 的默认)`,
|
||||
实测影响 6 行,清洗后 `0→7 / 1→1`(保留 id=63)。SQL 全文见 `docs/updata_sql.md`。
|
||||
4. **前端交互** `ruoyi-ui/src/views/skins/ttSetting/avatar.vue`:「是否默认」由 `updateUserStatus`
|
||||
改走新的 `updateDefault(...)` —— 开启时先 `$confirm` 告知「原默认头像「xx」将自动取消默认」,
|
||||
取消则回滚开关视觉并重新拉列表。关闭时直接保存。
|
||||
|
||||
**四、验证**:后端 `mvn -q -pl skins-service/service-admin -am install -DskipTests` 通过,
|
||||
字节搜 `clearOtherDefaultsIfNeeded`/`saveAvatar`/`getDefaultAvatar` 均在 `.class` 里;
|
||||
前端 `vue-cli-service build` 通过,新文案与新方法名均落在 `dist/static/js/chunk-cf2f9538.*.js`。
|
||||
|
||||
**五、踩坑**:MySQL **不允许 `UPDATE` 的子查询直接查同一张表**(报 1093),
|
||||
解法是包一层派生表 `(SELECT MIN(id) ... ) AS t` 绕过 —— 后续同类 SQL 照此办理。
|
||||
|
||||
⚠️ **待办**:后端 jar 未重打包、服务未重启,互斥逻辑**尚未在运行中的服务里生效**;
|
||||
前端 `dist` 已就绪但线上资源需按部署流程发布。存量数据已清洗,界面当下已正确。
|
||||
|
||||
- **幸运升级:水位配置从「4 档波段表」简化为「一个目标水位百分比」+ 饰品随机数区间恢复可编辑**
|
||||
|
||||
**起因**:上一版后台要维护一张 4 档波段表(水位下界/上界 × 随机数下界/上界 = 16 个数),
|
||||
还要保证档间单调且留有重叠 —— 运营看不懂,也容易配出互相矛盾的表。
|
||||
用户要的模型其实简单得多:**「每个饰品设一个区间,水位高的时候随机数靠近区间下限,
|
||||
但水位要维持在我设定的那个值」** ⇒ 后台只该填**一个水位百分比**。
|
||||
|
||||
⚠️ **本条取代 `2026-09-23` 条里的「三、波段表」与「十、上线前清空旧区间」两处**,
|
||||
其余口径(三身份判定隔离、幸运值共享展示、标定系数、注水抽水、水位统计口径)**全部仍然有效**。
|
||||
|
||||
**一、新口径(一句话)**
|
||||
|
||||
目标水位 → 自动反推随机数中心 → 在该饰品自己的区间内围绕中心抖动。
|
||||
后台不配随机数,随机数由目标水位算出来:
|
||||
|
||||
```
|
||||
V* = 100 / ((1 − 目标水位/100) × k) k = 实测标定系数(见第四节,样本不足按 1.19)
|
||||
中心 = V* × (1 − (当前水位 − 目标水位)/100 × gain) gain 默认 2
|
||||
t = (中心 − 下限) / (上限 − 下限) 中心在区间内的相对位置,夹到 [0.10, 0.90]
|
||||
V = 下限 + (上限 − 下限) × u^p, p = 1/t − 1 u ~ U(0,1)
|
||||
```
|
||||
|
||||
- **偏斜随机**:支撑集始终是**整个区间**(任何值都可能出),水位只改变**概率密度**。
|
||||
水位 = 目标 ⇒ `t = 0.5`、`p = 1` ⇒ **均匀分布**(区间内等概率);
|
||||
水位高于目标(放水)⇒ 中心下移、`p > 1` ⇒ 概率向**下限**堆积;低于目标则向**上限**堆积。
|
||||
- `E[u^p] = 1/(p+1) = t` ⇒ `E[V] = 中心` ⇒ **水位精确收敛回目标值**(这就是「保证维持在我设定的值」)。
|
||||
- ⚠️ 曾先写过「中心平移 + 固定宽度抖动,再夹取到区间边界」,**已废弃**:中心贴边时落点会被压成贴着边界的一条窄带,
|
||||
且**水位偏离越大窄带越窄**,玩家一眼看出是"卡边界"。改成偏斜随机后,任何水位下支撑集都是整个区间。
|
||||
- ⚠️ `t` 夹到 `[0.10, 0.90]` 决定了**中心的可表达范围 = 区间下界 + [10%, 90%] × 区间宽度**。
|
||||
中心超出这个范围(水位远离目标,或区间配得太窄)⇒ 水位只能停在附近。
|
||||
例:区间 `[100,160]` 的中心可表达范围是 `[106, 154]`,对应水位约 **`[20.7%, 45.4%]`**;
|
||||
想把水位开到 60%,区间上限得提到 **210 以上**。
|
||||
|
||||
**二、目标水位 ↔ 随机数中心换算表**(`k = 1.19`,即实测系数兜底值)
|
||||
|
||||
| 目标水位 | 目标 RTP | 随机数中心 `V*` |
|
||||
|---:|---:|---:|
|
||||
| 0% | 100% | 84 |
|
||||
| 10% | 90% | 93 |
|
||||
| 20% | 80% | 105 |
|
||||
| **30%** | **70%** | **120** |
|
||||
| 40% | 60% | 140 |
|
||||
| 45% | 55% | 153 |
|
||||
| 50% | 50% | 168 |
|
||||
| 60% | 40% | 210 |
|
||||
|
||||
⚠️ **`V*` 必须落在饰品的随机数区间内**(且离边界 > 10),否则中心被夹住、水位停在别处。
|
||||
例:目标 60% 需要中心 210 ⇒ 饰品区间上界必须 ≥ 210。
|
||||
|
||||
**三、饰品随机数区间的作用(恢复了编辑入口)**
|
||||
|
||||
`tt_upgrade_ornaments` 的三个区间列(`luck_section` / `anchor_luck_section` / `blank_luck_section`)
|
||||
重新成为**必填项**,语义从「固定随机数区间」变成「**随机数的取值范围**」:
|
||||
**整个区间始终都可能出**,中心由水位决定、只控制概率往哪一端堆。区间越宽,单轮波动越大、越难被玩家归纳出规律。
|
||||
⇒ **`2026-09-23` 条里「上线前清空三个区间列」的做法已作废**,不要再执行。
|
||||
⇒ 区间还**限定了这台机器能维持的水位范围**(见第一节末条):区间太窄或下限太高,高水位目标就实现不了。
|
||||
|
||||
**四、实测标定系数 k:改为 5 分钟内存缓存**
|
||||
|
||||
k = 「改造后累计下注(排除白号) / Σ(`round_target/100` × 目标饰品价)」,含义与算法不变。
|
||||
但它是一次**全表聚合 + `tt_user` JOIN**,而抽随机数的热路径现在也要用它
|
||||
⇒ 加 **5 分钟内存缓存**(启动预热 + 后台「按历史重算」主动刷新),不能每次拉取都查一次 DB。
|
||||
|
||||
**五、仿真验证**(脚本 `.workbuddy/upgrade-target-watermark-sim.mjs`,每种配置 60 万次拉取,可复跑)
|
||||
|
||||
| 配置 | 目标水位 | 实测稳态水位 | 偏差 | 实测 RTP | p10~p90 随机数 | 随机数实际范围 | 低于 100 占比 |
|
||||
|---|---:|---:|---:|---:|---:|---|---:|
|
||||
| A 区间 `[100,160]` | 30% | **29.97%** | −0.03 | 70.03% | 100~148 | **`[100,160]` 全覆盖** | 0.0% |
|
||||
| B 区间 `[75,175]`(宽) | 30% | **29.98%** | −0.02 | 69.99% | 81~163 | **`[75,175]` 全覆盖** | **32.8%** |
|
||||
| C 区间 `[100,160]` | 45% | 43.80% | −1.20 | 56.21% | 146~159 | `[111,160]` | 0.0% |
|
||||
| D 区间 `[100,160]` | 60% | 43.79% | **−16.21** | 56.21% | 146~159 | `[117,160]` | 0.0% |
|
||||
| E 区间 `[80,90]`(窄) | 30% | 9.77% | **−20.23** | 90.25% | 88~90 | `[83,90]` | 100.0% |
|
||||
|
||||
- **A/B**:区间包含 `V*` ⇒ 稳态水位与目标值差 < 0.05 个点,**「维持在我设定的值」成立**;
|
||||
且随机数的**实际出现范围就是整个区间**(不是贴着中心的一条窄带)。
|
||||
- **C**:`V* = 157.4` 距区间上限 160 只差 2.6,小于 10% 区间宽度(6)⇒ 被 `t` 上限夹住,偏 1.2 个点。
|
||||
- **D/E**:区间不包含 `V*` ⇒ 中心被夹死在边界,水位**到不了**目标值(偏 16~20 个点)—— 这是唯一的坑。
|
||||
- **B 的 32.8%** 说明:想把「每次都要 100 以上才出」的观感彻底打掉,区间下限要低于 100(宽区间 `[75,175]`)。
|
||||
|
||||
**六、改动清单**
|
||||
|
||||
1. `UpgradeWatermarkSettingBody`:删 `bands` / `Band` 类,改为 `targetWatermark` + `gain`(后台只填目标水位)。
|
||||
2. `UpgradeWatermarkService`:删波段表查表逻辑,新增 `targetCenter()` / `centerAt()` / `drawTarget(lo, hi)`;
|
||||
`drawTarget` 用**偏斜随机**(`V = 下限 + 宽度 × u^p`,`p = 1/t − 1`,`t` 由中心在区间内的相对位置给出);
|
||||
标定系数加 5 分钟缓存;`info()` 返回 `targetWatermark` / `targetGap` / `targetCenter` / `centerNow` /
|
||||
`effectiveCalibration`(原 `bands` / `bandRows` / `bandMin` / `bandMax` 全部移除)。
|
||||
3. `ApiLuckyUpgradeServiceImpl`:新增 `resolveRange()`(读该饰品位 × 本身份的区间,**直接读实体字段**,
|
||||
不再走 Redis 区间缓存 ⇒ 后台改完下一轮立即生效,不用清缓存);`drawRoundTarget` 把区间交给水位服务。
|
||||
未配区间时兜底 `[80,160]`。回退分支 `legacyFixedTarget()` 保持旧的 Redis 缓存写法不动。
|
||||
4. 前端 `views/skins/luckyUpgrade/rewardPool/index.vue`:**删掉整张波段表**,改为一个「目标水位」输入框
|
||||
+ 策略开关,**改完立即生效(页面没有保存按钮)**;实时条显示「当前水位 / 目标水位 / 离目标多少 /
|
||||
下一轮随机数 / 人工调整 / 实测标定系数 / 白号 / 策略开关」。
|
||||
策略卡里另有一个**「随机数区间 → 可维持水位范围」换算器**:填两个数即时反推
|
||||
(`中心可表达范围 = 下限 + [10%, 90%] × 宽度`,再代回 `水位 = 1 − 100/(k × 中心)`),
|
||||
目标水位不在算出的范围内会标红提示;右侧 `?` 悬浮说明「区间是什么 / 为什么它限定了水位范围 / 怎么用」。
|
||||
⇒ 正文只讲**区间硬约束**,不展示「随机数中心」这类内部推算量。
|
||||
5. 前端 `views/skins/ttUpgrade/index.vue`:**恢复**普通/主播/白号三列「随机数区间」编辑(列名由「幸运区间」改为
|
||||
「随机数区间」);顶部水位条改为显示「目标水位 / 推算随机数中心」(原「本轮随机数区间」两栏随波段表一起删)。
|
||||
|
||||
**七、验证**
|
||||
|
||||
- `mvn -B clean install -DskipTests` 通过;`ruoyi-ui` 生产构建通过。
|
||||
- 仿真 E 组反证有效:区间 `[80,90]` + 目标 30% ⇒ 稳态 9.84%、RTP 90.2%,说明夹取逻辑确实按预期生效(不是"看起来对")。
|
||||
- 全仓扫 `bands` / `bandRows` / `bandMin` / `bandMax` 无残留引用。
|
||||
|
||||
**八、上线前必做**
|
||||
|
||||
1. **给每个在售饰品配随机数区间**,且区间要包含「按目标水位推算的中心值」,**中心离两端各留约 10% 区间宽度**
|
||||
(后台水位页会显示该中心)。区间缺失时后端兜底 `[80,160]`,能用但不受控。
|
||||
区间同时**限定了水位能调到的范围**(例:`[100,160]` 只能维持约 `[20.7%, 45.4%]` 的水位)。
|
||||
2. 目标水位**默认 30%**(≈ 长期 RTP 70%);上线初期水位从 0 起步,会有一段收紧期,想立刻拉到目标值就在水位页注水。
|
||||
3. 后端需**重启**才生效(策略与抽数逻辑都在 Java 侧)。
|
||||
|
||||
**九、虚拟水位:一批饰品同时开轮时,不会都按同一个水位放水**(2026-09-24 补充)
|
||||
|
||||
**问题**:真实水位只反映**已经结算**的下注/返奖。一批饰品同时开新轮时(同时上架、或几个玩家同时首次拉取),
|
||||
它们会全部用同一个真实水位去算中心 —— 水位高时连着出好几个低随机数,而这批货真出完后水位其实已经变了。
|
||||
|
||||
**做法**:每抽一个随机数就把这一轮的**预估交易**预留进 `upgrade_pool:pending:bet` / `:pending:win`:
|
||||
`预估下注 = 饰品价 × k × 随机数 / 100`(乘 k 是因为判定溢出)、`预估返奖 = 饰品价`。
|
||||
抽下一个时用的是**虚拟水位**:
|
||||
|
||||
```
|
||||
虚拟水位 = (真实bet + pending_bet − 真实win − pending_win + 调整) / (真实bet + pending_bet)
|
||||
```
|
||||
|
||||
这一轮出货过程中产生的实际流水会在 `recordPull` 里**逐步把预留扣回去** —— 两边口径一致
|
||||
(`k` 就是「实际 / 名义」的实测系数),所以真正出货时预留基本归零、不会长期挂账。
|
||||
白号轮次不进水位口径,不参与预留;`enabled=false` 或没抽过数时 pending = 0,退化为真实水位。
|
||||
后台「按历史重算」会把 pending 一起归零,兜住「某个轮次永远没出货、pending 长期挂账」的漂移。
|
||||
|
||||
**效果**(同一仿真脚本的「预留机制」段;8 轮连开后的 `E[V]`):
|
||||
|
||||
| 累计下注体量 | 不用预留 | 用预留(第 8 轮) | 收紧 |
|
||||
|---:|---:|---:|---:|
|
||||
| 2 万 | 88.0 | **108.6** | **+23.4%** |
|
||||
| 10 万 | 88.0 | 90.8 | +3.2% |
|
||||
| 100 万 | 88.0 | 88.0 | 0% |
|
||||
|
||||
⚠️ **效果与水位体量成反比,这是数学必然、不是实现问题** —— 水位是「累计比例」,
|
||||
一件货(玩家成本约 1.2 倍饰品价)只占累计下注的极小一份。刚上线体量小时很灵敏,体量大后几乎可忽略。
|
||||
⇒ 想让大水位下收敛更灵敏,要调的是 **`gain`**(水位每偏离 1 个点、中心移动的幅度,默认 2),不是靠预留。
|
||||
⇒ 另外:中心一旦撞到区间边界(`t` 被夹到 `[0.10, 0.90]`),`E[V]` 就不再随水位变化 ——
|
||||
上面 100 万那组水位明明降了 0.32 个点,`E[V]` 仍是 88,就是这个原因。
|
||||
|
||||
**★ 生效边界:能稳定处理的量级**(判据 `|Δ虚拟水位| ≥ 1 个点` ⇒ `E[V]` 变化 ≥ 2.4 点、约 2.7%,线上看得出来):
|
||||
|
||||
| 累计下注 \ 连开个数 | 3 个 | 5 个 | 10 个 | 20 个 | 50 个 |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
| 1 万 | −10.0 \* | −10.8 \* | −16.1 \* | −12.6 \* | −14.0 \* |
|
||||
| 3 万 | −4.1 \* | −6.6 \* | −6.4 \* | −13.1 \* | −13.2 \* |
|
||||
| 10 万 | −1.4 \* | −2.2 \* | −4.0 \* | −6.3 \* | −10.6 \* |
|
||||
| 30 万 | −0.5 | −0.7 | −1.4 \* | −2.7 \* | −6.0 \* |
|
||||
| 100 万 | −0.1 | −0.2 | −0.3 | −0.9 | −2.0 \* |
|
||||
| 500 万 | −0.03 | −0.04 | −0.1 | −0.2 | −0.4 |
|
||||
|
||||
(\* = 有效;饰品价按 1000、水位 45%、目标 30% 算。因撞区间边界后随机数饱和,1 万那行不再单调。)
|
||||
|
||||
**门槛公式**:`Δ水位 ≈ x·[q(1−r) − 1] / (1 + q·x)`,其中 `x = N·p/B`、`q = k·V/100 ≈ 1.2~1.43`、`r` = 当前水位。
|
||||
⇒ **`r = 目标水位` 时分子为 0 —— 稳态下预留不起作用(本该如此)**,水位偏离目标越多才越明显。
|
||||
⇒ 判据的本质是「**连开饰品的总面值 `Np` 占累计下注 `B` 的比例**」:**`Np/B ≳ 3%~5%` 才看得出来**。
|
||||
|
||||
**为什么和累计下注相关(而不是只看水位)** —— 水位是**比值**、流水是**绝对额**:
|
||||
`水位 = 利润 / 累计下注`,一件货(饰品价 1000、随机数 120)带来「下注 +1200、返奖 +1000、净赚 +200」,
|
||||
但这 200 元对水位的推动完全取决于分母:
|
||||
|
||||
| 累计下注 B | 加一件货后 | 水位变化 |
|
||||
|---:|---|---:|
|
||||
| 10 万 | 45.00% → 44.80% | **−0.20 点** |
|
||||
| 100 万 | 45.00% → 44.98% | **−0.02 点** |
|
||||
|
||||
⇒ 同一笔钱落进 10 倍大的池子,效果就是 1/10。
|
||||
⚠️ **预留不会凭空制造推动** —— 它预估的就是这批货真实产生的交易,所以效果上限 = **这批货真出完后本来就会推动的量**,
|
||||
机制只是把这份推动**提前**给第 2、第 3 个随机数看到。⇒ 「连开 N 个才有效」的门槛,本质不是预留不灵,
|
||||
而是**这 N 件货本来也推不动水位**。
|
||||
⇒ 所以「按水位判断」和「体量影响效果」是两件事:**水位→随机数**(判断,只看水位与 `gain`)与
|
||||
**流水→水位**(记账,比例反馈天然有惯性,强依赖体量)。
|
||||
|
||||
| 累计下注(p = 1000) | 需要连开多少个才有效 |
|
||||
|---:|---|
|
||||
| 1 万 ~ 3 万 | 2 ~ 3 个 |
|
||||
| 10 万 | 3 个(面值占 3%) |
|
||||
| 30 万 | 10 个 |
|
||||
| 100 万 | 50 个(现实中不存在) |
|
||||
| 500 万以上 | 不现实 |
|
||||
|
||||
⇒ **一句话结论:刚上线、累计下注在十万量级以内能稳定处理;滚到百万以上基本失效。**
|
||||
这是「水位是累计比例」的必然结果 —— 想在大水位下也能快速纠偏,要调 **`gain`**(水位每偏离 1 个点、
|
||||
中心移动的幅度;现在默认 2,暂未在后台暴露),或者直接人工注水/抽水。
|
||||
|
||||
- **后台开关(el-switch)统一为「文字 = 当前值」口径,修掉反向文字露底色的误导**
|
||||
|
||||
**起因**:用户反馈「头像管理」的**状态**与**是否默认**两列看得费解。查下来是两个叠加问题:
|
||||
|
||||
1. **两边文字显示规则相反**。element-ui 的 `el-switch` 里 `active-text` 渲染在**右**侧、
|
||||
且只在**开启时**显示;`inactive-text` 渲染在**左**侧、只在**关闭时**显示。
|
||||
本页「状态」用的是 `inactive-text="启用"`(把当前值放在左侧),
|
||||
「是否默认」用的是 `active-text="是"`(把当前值放在右侧)—— 同一张表里两套相反的读法。
|
||||
2. **更糟的是反向文字会露出底色**。「状态」列开启时左侧露出绿色底 + 白字「启用」,
|
||||
点一下滑块滑到左边、绿底被盖住,可读文字变成「停用」。两列的滑块方向可以完全相反,
|
||||
同一行的绿白视觉还不一样(`is-checked` 与背景色是两套独立配色)。
|
||||
|
||||
⇒ **定的新口径(全后台统一):`inactive-text` = 关闭时的值,`active-text` = 开启时的值,
|
||||
也就是「文字显示的就是当前值」,滑块停在哪边、哪边的文字就是现在的结果。**
|
||||
颜色同步收敛为与 `active-value`/`inactive-value` 同侧:**绿 = 该项生效、红 = 该项未生效**。
|
||||
|
||||
**改动清单(7 个文件,15 处开关)**
|
||||
|
||||
| 文件 | 列 / 字段 | 调整 |
|
||||
|---|---|---|
|
||||
| `skins/ttSetting/avatar.vue` | 状态 `status` | 文字翻正(启用/停用),配色与 `active-value` 同侧 |
|
||||
| `skins/ttSetting/avatar.vue` | 是否默认 `isDefault` | `active/inactive-value` 翻正为 `1/0`(= 与 `initData`、弹窗单选按钮一致),文字归右侧 |
|
||||
| `skins/ttuser/index.vue` | 帐号状态 `status` | `active/inactive-value` 翻正为 `0/1`(原为 `1/0`,是全局唯一的反向异类) |
|
||||
| `skins/ttuser/index.vue` | 提货状态 `deliveryStatus` | 同上翻正(`1` = 已禁用提货) |
|
||||
| `skins/ttuser/index.vue` | 赠送状态 `giftStatus` | 同上翻正(`1` = 已禁用赠送) |
|
||||
| `skins/ttBonus/index.vue` | 启用状态 `status` | 文字从「是/否」改为「启用/禁用」并翻正 |
|
||||
| `skins/ttBox/boxType.vue` | 是否对战类型 `isFightType`、是否显示 `isShow` | label 归位;`isShow` 的 `active/inactive-value` 翻正为 `1/0` |
|
||||
| `skins/ttredPacket/index.vue` | 是否启用 `useStatus` | `active/inactive-value` 翻正为 `1/0`,配色同侧 |
|
||||
| `skins/ttUpgrade/index.vue` | 状态 `status` | `active/inactive-value` 翻正为 `0/1`(原为 `1/0`)+ 文字翻正 |
|
||||
| `skins/ttBox/index.vue` | 弹窗内 是否对战宝箱 `isFight`、宝箱状态 `status`、是否首页推荐 `isHome` | 文字归位、`isFight`/`status` 的 `active-value` 翻正 |
|
||||
|
||||
**关键约束:以上全部只是「值 ↔ 显示」的映射调整,`active-value` 与 `inactive-value` 成对同翻,
|
||||
提交给后端的字段值逐字节不变,数据库语义与接口契约零改动**(各页 `@change` 都是把整个 row/form
|
||||
原样 `PUT` 出去,不做值转换)。
|
||||
|
||||
**语义校对依据**(改前逐个确认过,避免把含义改反):
|
||||
- `tt_user_avatar.status` 与 `tt_user.status`:`UserStatus` 枚举 = `OK("0","正常")` / `DISABLE("1","停用")`,
|
||||
`ApiUserDetailsServiceImpl` 对 `DISABLE` 直接拒登并踢线 ⇒ **`0` = 启用**。
|
||||
- `tt_user_avatar.is_default`:`TtUserAvatar` 的 `@Excel(readConverterExp = "0=否,1=是")`,
|
||||
且注册 / 白号 / 登录三处均查 `is_default = '1'` 取默认头像 ⇒ **`1` = 是**。
|
||||
- `tt_user.delivery_status` / `gift_status`:实体注释 + 业务代码均为 **`1` = 已禁用**(`"1".equals(...)` 返回"已被禁用"提示)。
|
||||
- 可对照的既有正确写法:`ttSetting/banner.vue`、`ttRechargeList/index.vue`、`ttuser/userPack.vue`。
|
||||
|
||||
⚠️ **踩坑**:用户截图里「是否默认」列显示为**红底白字「否」**容易被误读成"开着"。
|
||||
那是 `active-color`/`inactive-color` 与 `active-text`/`inactive-text` 配成反的典型症状 ——
|
||||
改完须确认**颜色所在的半边和文字所在的半边是同一半**。
|
||||
|
||||
⚠️ **未改(故意留着)**:`ttRoll/officialRollConfig.vue` 的状态开关用的是
|
||||
`:value="scope.row.status === '0'"` + 无文字标注的正向布尔(绿=开),本身自洽;
|
||||
只建议后续补一个文字标注。
|
||||
|
||||
- **管理后台:3 个页面表格右侧留白(「表格没有平铺完整」)**
|
||||
|
||||
**起因**:用户反馈「管理后台有些页面的表格没有平铺完整」。追问确认现象是
|
||||
**表格右侧留一条空白**(不是列错位、也不是横向滚动条)。
|
||||
|
||||
**根因**(读 Element 源码定论):`element-ui/packages/table/src/table-layout.js:132-195`
|
||||
的 `updateColumnsWidth()` ——
|
||||
|
||||
```
|
||||
flexColumns = 所有「width 不是数字」的列(含显式 min-width 列、以及两者都没写的列)
|
||||
if (flexColumns.length > 0 && fit) → 自动分配,表格撑满 bodyWidth (第 145 行)
|
||||
else → bodyWidth = Σ(width || minWidth || 80)(第 182-194 行)
|
||||
```
|
||||
|
||||
⇒ **只要有一列写了 `min-width`(或宽度两者都不写),表格就会自动撑满容器;
|
||||
但当所有列都是固定 `width` 时,表格宽度恒等于列宽之和 —— 容器再宽也不拉伸,右侧就空一条。**
|
||||
|
||||
同一份源码里还有个易漏的点:`type="selection" / "expand" / "index"` 在未写 width 时,
|
||||
由 `config.js:3-26` 的 `cellStarts` 给死 **48px 固定宽**,**不参与弹性分配** ——
|
||||
所以「展开列 + 其余全固定宽」的表格同样会留白。
|
||||
|
||||
**扫描结论**(115 个顶层表格,1920 视口下容器可用 1584px):
|
||||
|
||||
- **仅 6 个**是全固定列(会留白);**109 个**含弹性列、会自行撑满。
|
||||
- **0 个**会出横向滚动条(判据:`Σwidth + Σmin-width > 容器`)。
|
||||
- 留白量**随屏幕变宽而增大**(2560 视口下最大 1854px)—— 这正是"没平铺完整"的典型特征。
|
||||
|
||||
**改动清单(3 个页面主表格,各把「内容最长的固定列」改为 `min-width`)**
|
||||
|
||||
| 文件 | 列 | 调整 | 表格宽 → 结果 |
|
||||
|---|---|---|---|
|
||||
| `skins/blankCardKey/index.vue` | 卡密编码 `cardCode` | `width="200"` → `min-width="200"` | 1000 → 撑满(原留白 584px) |
|
||||
| `system/role/index.vue` | 权限字符 `roleKey` | `width="150"` → `min-width="150"` | 1075 → 撑满(原留白 509px) |
|
||||
| `game/wheel/records/index.vue` | 投注符号 `betSymbols` | `width="140"` → `min-width="140"` | 1368 → 撑满(原留白 216px) |
|
||||
|
||||
选列理由:卡密编码是 32 位等宽字符 + 复制按钮、权限字符内容最长且**已带 `show-overflow-tooltip`**
|
||||
(加宽无副作用)、投注符号是拼接字符串、长度波动最大 —— 都是"多给宽度只有好处"的列。
|
||||
三处均加注释说明「为什么必须是 min-width」,防止后来人按"列宽统一用 width"的惯例改回去。
|
||||
|
||||
**未改(用户明确不动)**:3 个展开行内嵌明细表(`wheel/records` 下注明细、
|
||||
`delivery/transferApply` 与 `transferApplyProcessed` 的饰品明细)与 1 个弹窗内倍率表
|
||||
(`wheel/config`,固定 `width:640px`)—— 它们宽度受容器约束是设计意图。
|
||||
其中 `transferApply` / `transferApplyProcessed` 声明了 `style="width: auto"` 却只有 2 列固定宽
|
||||
(300+120=420),**在展开行里只占 420px**;若后续要铺满,改其中一列为 `min-width` 即可。
|
||||
|
||||
**验证**:
|
||||
- 编译产物断言:`vue-template-compiler` 编译 3 个页面,✓ 全部通过,
|
||||
`min-width` 各 1 个(200 / 150 / 140),其余列仍为固定 width。
|
||||
- 算法级回归:复现 `updateColumnsWidth` 后,
|
||||
**1280 / 1440 / 1600 / 1920 / 2560 五个视口下 3 个主表格留白全部归零**。
|
||||
- dev server 起来后 `curl` 返回 200,确认改动未破坏编译。
|
||||
- ⚠️ **本机无法真机截图核对**:助手拉起 Chrome 被安全策略拦(连带报 `wsl.exe` 在程序黑名单),
|
||||
故本次验证口径是「读源码 + 复现算法 + 编译产物断言」,**未经浏览器实测**。
|
||||
|
||||
**纯前端改动,不涉及数据库与接口**;`dist` 未发布。
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-23
|
||||
|
||||
- **幸运升级:随机数改由「全局水位」动态决定 + 判定层三身份隔离 / 展示层共享幸运值 + 后台注水抽水页**
|
||||
|
||||
> ⚠️ **后续修订(2026-09-24)**:本条的「三、水位 → 随机数的映射(波段表)」与「十、上线前必做」第 1 条
|
||||
> (清空三个区间列)**已被 2026-09-24 条取代** —— 水位从「4 档波段表」简化为「单一目标水位」,
|
||||
> 饰品区间从"建议清空"变为"必须配置且要包含推算中心"。其余内容(水位口径、三身份判定隔离、
|
||||
> 幸运值共享展示、标定系数、注水抽水、换算关系)**全部仍然有效**。
|
||||
|
||||
|
||||
**起因**:原来每个饰品位配一套**固定**幸运区间(如 `[100,160)`),每轮从区间里均匀抽一个随机数当作出货阈值,
|
||||
于是随机数永远 ≥ 区间下限 —— 玩家能看出「每次都要投到 1 倍原价以上才出」这条硬底。
|
||||
同时三个身份(普通/主播/白号)各有各的区间与进度,前端展示的「幸运值」各不相同,
|
||||
看主播或白号表演的观众会发现数值对不上账。
|
||||
|
||||
|
||||
|
||||
**一、换算关系(改这个玩法的参数前必看)**
|
||||
|
||||
幸运区间的单位是**「饰品价的百分比」**:
|
||||
|
||||
| 量 | 关系 |
|
||||
|---|---|
|
||||
| 每拉一次消耗 | `饰品价 × probability / 100` |
|
||||
| 每拉一次推进 | `eff = min(probability, 75)` 点进度 |
|
||||
| 每 1 点进度的成本 | `饰品价 / 100`(`probability ≤ 75` 时严格成立) |
|
||||
| 出一件货需要 | 约 `fullV` 点进度 |
|
||||
| **理论 RTP** | `100 / fullV` |
|
||||
| **水位** | `1 − RTP = 1 − 100/fullV` |
|
||||
|
||||
| 目标水位 | 目标 RTP | 名义中心随机数 |
|
||||
|---:|---:|---:|
|
||||
| −41% | 141% | 71 |
|
||||
| −20% | 120% | 83 |
|
||||
| 0% | 100% | 100 |
|
||||
| 10% | 90% | 111 |
|
||||
| 20% | 80% | 125 |
|
||||
| **30%** | **70%** | **143** |
|
||||
| 40% | 60% | 167 |
|
||||
| 47% | 53% | 189 |
|
||||
|
||||
⚠️ **水位能到多少由随机数区间的上下界决定**:想让水位能长期停在 40% 以上,某一档的随机数上界必须 > 160。
|
||||
|
||||
**二、最终口径(三条,都是拍过板的)**
|
||||
|
||||
1. **随机数 + 判定进度:按身份完全隔离**。每个身份有自己的一份「本轮进度 + 本轮随机数」,
|
||||
白号中奖**只刷新白号自己的随机数**,普通玩家的进度与随机数原样不动。
|
||||
2. **水位:全局**(整个幸运升级一个水位,不按饰品位拆),且**排除白号**。
|
||||
3. **幸运值:判定层隔离、展示层共享** —— 见下面第 4 条改动。
|
||||
|
||||
**三、水位 → 随机数的映射(波段表)**
|
||||
|
||||
`sys_config: luckyUpgradeWatermark` 存一张波段表,命中档后在该档区间内**均匀随机**(有 10s 内存缓存):
|
||||
|
||||
| 水位区间 | 可出的随机数区间 |
|
||||
|---|---|
|
||||
| < 10% | `[95, 130]` |
|
||||
| 10% ~ 26% | `[115, 150]` |
|
||||
| **26% ~ 36%** | **`[75, 175]`** ← 稳态档 |
|
||||
| ≥ 36% | `[85, 128]` |
|
||||
|
||||
保存时后端强校验:水位档升序不重叠、`0 < vMin < vMax`、**水位越高随机数中心值必须越小**(配反直接拒绝)。
|
||||
|
||||
**四、名义 ≠ 实测:标定系数 k**
|
||||
|
||||
判定是「跨过阈值即出货、最后一拉整段 eff 计入并丢弃溢出」,所以**实测等效需求是名义 `fullV` 的 118%~120%**,
|
||||
波段表的随机数值**不能按 `100/fullV` 公式拍**。后台自动算标定系数:
|
||||
|
||||
```
|
||||
k = 改造后累计下注(排除白号) / Σ(round_target/100 × 目标饰品价,仅中奖记录)
|
||||
预计落点水位 = (1 − 100/(k × 中心随机数)) × 100
|
||||
```
|
||||
|
||||
数据不足时 `k = null`,后台显示「样本不足」而不是用公式瞎估;「预计落点水位」落在该档自己的水位区间内 ⇒ 该档是**稳态档**(后台标绿)。
|
||||
|
||||
**五、改动清单**
|
||||
|
||||
(SQL 见 `docs/updata_sql.md`,接口口径见 `docs/幸运升级接口文档.md`)
|
||||
|
||||
1. **水位**:`水位% = (Σ下注 − Σ返奖 + 人工调整) / Σ下注 × 100`,统计范围**整个幸运升级**且**排除白号**。
|
||||
累计量走 Redis 增量计数(`upgrade_pool:*`),DB 只用于启动初始化与后台「按历史重算」——
|
||||
不在拉取热路径上对 `tt_upgrade_record` 做全表聚合。
|
||||
2. **随机数**:按当前水位查波段表,命中档后在档内均匀随机;水位越高随机数越低(放水)。
|
||||
`enabled=false` ⇒ 逐字节回退到「每个饰品位固定 `luck_section`」,不发版可回滚。
|
||||
新增 `UpgradeWatermarkService`(水位/波段表/标定/注水抽水)+ `TtUpgradeWatermarkController`。
|
||||
3. **判定层三身份完全隔离**:进度与随机数各存 Redis `upgrade_range_fixed:{饰品位}:{身份}`
|
||||
(结构与改造前一致,老缓存不用迁移),白号中奖不影响普通玩家的进度与随机数。
|
||||
4. **幸运值(展示)**:累加口径**保持不变** —— 按「玩家在前端拉的那个值」累加原始 `probability`;
|
||||
**中奖判定恒用 `min(概率,75)`**,两者刻意分开(75% 封顶是风控手段,不改展示数字)。
|
||||
**展示值是三个身份共享的一个累计器:任一身份(含白号)中奖即整体归零,且中奖那一拉的增量仍计入**
|
||||
(出货那一刻显示的是含本次的值,下一拉才从 0 重新累计);表里那三列只用来**分别记录各身份的贡献**(后台排查),展示时相加。
|
||||
`getOrnamentsList.luckyValue` 返回这个共享值(所以所有人看到的数一致),另给 `myLuckyValue`。
|
||||
另留 `luckyValueMode` 开关(默认 `raw`,可切 `eff`),**只影响展示值与落库快照**。
|
||||
5. **后台新增「幸运升级 → 奖池水位」页**(`menu_id=3208`):水位/实测RTP 统计卡、波段表编辑器(含名义RTP 与
|
||||
**按实测标定的「预计落点水位」**)、注水抽水(原因必填)、调整历史、「按历史重算」。
|
||||
6. 拉取记录新增 `watermark` / `round_target` 快照列,便于事后复盘「这批货是在什么水位下出的」。
|
||||
|
||||
**六、仿真结论(脚本 `.workbuddy/upgrade-watermark-sim.mjs`,40 万次拉取,可复跑)**
|
||||
|
||||
| 方案 | 名义 target | 实测 RTP | 实测水位 | 等效需求/名义 | 随机数<100 占比 |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| **A** 现状:固定 `[100,160)` | 129 | 65.1% | 34.9% | 119% | **0.0%** |
|
||||
| **C** 曾设想的「共享池 + 只清中奖方」(**已否决**) | 108 | **131.8%** | **−31.9%** | 71% | 19.2% |
|
||||
| **F** 水位波段表(已实现) | 125 | **66.9%** | **33.1%** | 120% | **24.6%** |
|
||||
|
||||
三个关键结论:
|
||||
|
||||
- ⚠️ **「三身份共享一个池、中奖只清中奖方」会亏钱**:残留进度会垫薄下一轮阈值,非主力身份中奖时垫得最狠还会连锁
|
||||
⇒ 仿真实测 **RTP 从 62% 飙到 131.8%、水位掉到 −32%**。所以**判定层必须按身份隔离**,
|
||||
「一个奖池」只体现在**幸运值的展示**上。
|
||||
- ⚠️ **名义 ≠ 实测**(见第四节):波段表的随机数必须按实测标定,按 `100/fullV` 公式拍一定偏。
|
||||
- **稳态随机数的均值由目标水位唯一决定**(水位 30% ⟺ 平均随机数 ≈ 121),这是数学、改不掉;
|
||||
能做的只是把档内区间**做宽**(推荐跨度 ≥ 80)。改造后随机数覆盖 75~175、**24.6% 的轮次低于 100**,
|
||||
而 RTP 基本不变(65.1% → 66.9%)—— 玩家不再看到「必须 100 以上才出」的硬底。
|
||||
|
||||
**七、一个完整算例(对着数字走一遍)**
|
||||
|
||||
**前提**:饰品价 1000 元;波段表如上(当前落在 26~36% 档 ⇒ 随机数取自 `[75,175]`);
|
||||
起始累计(排除白号)下注 100,000 / 返奖 65,000 / 调整 0 ⇒ **水位 = 35.00%**。
|
||||
|
||||
| 步 | 谁 | 拉取 | 扣款 | 该池随机数 | 判定(进度 + min(概率,75)) | 结果 | 该池进度 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | 普通 A | 10% | 100 | 120(本池新抽) | 0 + 10 = 10 < 120 | 失败 | 10 |
|
||||
| 2 | 普通 B | 50% | 500 | 120(沿用) | 10 + 50 = 60 < 120 | 失败 | 60 |
|
||||
| 3 | 主播 C | 20% | 200 | **95**(主播池自己抽) | 0 + 20 = 20 < 95 | 失败 | 20 |
|
||||
| 4 | 白号 D | 90% | 900 | **88**(白号池自己抽) | 0 + **75** = 75 < 88 | 失败 | 75 |
|
||||
| 5 | 白号 D | 20% | 200 | 88(沿用) | 75 + 20 = 95 ≥ 88 | **中奖** | 0 → 重抽 130 |
|
||||
| 6 | 普通 A | 75% | 750 | 120(沿用) | 60 + 75 = 135 ≥ 120 | **中奖** | 0 → 重抽 105 |
|
||||
|
||||
**第 5 步后三个池的状态**(判定层隔离:白号中奖,普通/主播一点没动)
|
||||
|
||||
| 池 | 本轮随机数 | 进度 |
|
||||
|---|---|---|
|
||||
| 普通 | 120 | 60 |
|
||||
| 主播 | 95 | 20 |
|
||||
| 白号 | 130(重抽) | 0 |
|
||||
|
||||
**第 4 步为什么要用 75 而不是 90**:白号拉 90% 时扣款按 90% 收(900 元)、判定只按 `min(90,75)=75` 加进度,
|
||||
而界面幸运值按**原始概率**加(白号那份 +90)。⇒ 那一刻白号显示「幸运值 90」、本池随机数 88,看着早该中了,
|
||||
但判定进度只有 75(还差 13)—— **实际没中**;下一步再拉 20%(75+20=95 ≥ 88)才中。这就是那个分叉的实际后果。
|
||||
|
||||
**幸运值展示 = 三个身份共享的一个累计值**(所有人看到同一个数;任一身份中奖即整体归零)
|
||||
|
||||
| 时点 | 普通 | 主播 | 白号 | 展示 luckyValue | 说明 |
|
||||
|---|---|---|---|---|---|
|
||||
| 起始 | 0 | 0 | 0 | **0** | |
|
||||
| 步 1 后 | 10 | 0 | 0 | **10** | 普通 +10 |
|
||||
| 步 2 后 | 60 | 0 | 0 | **60** | 普通 +50 |
|
||||
| 步 3 后 | 60 | 20 | 0 | **80** | 主播**也加进同一个数** |
|
||||
| 步 4 后 | 60 | 20 | 90 | **170** | 白号 +90 |
|
||||
| 步 5 后 | 0 | 0 | 0 | **190** | 白号 +20 → **中奖 → 整体归零**;显示的是中奖那一刻的 190 |
|
||||
| 步 6 后 | 0 | 0 | 0 | **75** | 0 + 75 → **中奖 → 整体归零** |
|
||||
|
||||
**水位的变化(白号的量不算)**
|
||||
|
||||
| 步 | 累计下注 | 累计返奖 | 水位 |
|
||||
|---|---|---|---|
|
||||
| 起始 | 100,000 | 65,000 | 35.00% |
|
||||
| 1(普通 +100) | 100,100 | 65,000 | 35.06% |
|
||||
| 2(普通 +500) | 100,600 | 65,000 | 35.39% |
|
||||
| 3(主播 +200) | 100,800 | 65,000 | 35.52% |
|
||||
| 4(白号 +900) | 100,800 | 65,000 | 35.52%(**不变**) |
|
||||
| 5(白号 +200 且中奖 +1000) | 100,800 | 65,000 | 35.52%(**不变**) |
|
||||
| 6(普通 +750 且中奖 +1000) | 101,550 | 66,000 | 35.01% |
|
||||
|
||||
白号的下注/返奖进另一组计数(后台「白号」那一栏),只展示、不影响水位。
|
||||
|
||||
**水位什么时候生效**:只在「新的一轮开始」读一次(该饰品位该身份首次拉取 / 刚中奖)。
|
||||
本轮中途水位变化**不改手里的随机数**;若水位涨到 ≥ 36%,下一次**新开的一轮**才会从 `[85,128]` 里抽。
|
||||
|
||||
**落库记录(`tt_upgrade_record`,节选)**
|
||||
|
||||
| 步 | user_type | amount_consumed | amount_required | round_target | watermark | is_victory | lucky_value |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | 02 | 100 | 1200 | 120 | 35.00 | 0 | 10 |
|
||||
| 4 | 06 | 900 | 880 | 88 | 35.52 | 0 | 170 |
|
||||
| 5 | 06 | 200 | 880 | 88 | 35.52 | 1 | **190** |
|
||||
| 6 | 02 | 750 | 1200 | 120 | 35.52 | 1 | **75** |
|
||||
|
||||
- `amount_required = 饰品价 × round_target / 100`。
|
||||
- **中奖记录的 `lucky_value` 记的是「中奖那一刻的累计值」(含本次),不是 0** —— 与玩家当下看到的一致。
|
||||
- **`watermark` 记的是「本次拉取之前」的水位** —— 代码先读水位、判定出货成功后才做增量记账,顺序不能反。
|
||||
|
||||
**八、改动文件**
|
||||
|
||||
后端 `ApiLuckyUpgradeServiceImpl`、`UpgradeWatermarkService`(新)、`TtUpgradeWatermarkController`(新)、
|
||||
`TtUpgradeRewardLogMapper`(新) + XML、`RedisConstants`、`ApiLuckyUpgradeMapper.xml`、`TtUpgradeOrnamentsMapper.xml`、
|
||||
实体 `TtUpgradeRecord` / `TtUpgradeRewardLog`(新) / `UpgradeWatermarkSettingBody`(新) / 两个 VO;
|
||||
前端 `views/skins/luckyUpgrade/rewardPool/index.vue`(新) + api、`views/skins/ttUpgrade/index.vue`。
|
||||
|
||||
**九、验证**
|
||||
|
||||
`mvn -B clean install -DskipTests` 通过,且 **javap 产物确认 `doUpgrade` 只剩新签名、无旧 `(Integer[], UpgradeBodyA)` 残留**;
|
||||
`ruoyi-ui` 构建 `DONE Build complete`;`audit-palette` 离版文件/色值 **0 / 0**;`verify-dist` **ALL_OK(34 项)**;
|
||||
`audit-ops-fit` 需加宽 **0 条**。新页面确认已进 `dist` chunk。
|
||||
算例里的 6 个幸运值(10/60/80/170/190/75)用脚本复刻改后逻辑跑过,全部一致。
|
||||
|
||||
**踩坑**:① 本项目是 **Spring Boot 4(jakarta)**,`@PostConstruct` 写成 `javax.` 会编译失败;
|
||||
② **并行对同一文件发多个 Edit 会静默丢改动**(回执 success 但没落盘,仿真脚本主循环那次白排查一轮)⇒ 改完必须 grep 复验;
|
||||
③ 15 万元素数组用 `Math.min(...arr)` 直接 `RangeError: Maximum call stack size exceeded`,大数组统计先 sort 再取分位;
|
||||
④ **口径类需求别用文字对齐**:「幸运值」到底是「三身份各自累计、各清各的」还是「一个共享累计器、谁中奖都整体归零」,
|
||||
文字上两句话都解释得通 —— 最后靠「第 5 步应该显示 190、第 6 步应该显示 75」这组数字才把模型逼出来
|
||||
(若是各清各的,第 5 步应为 80;若不清零,第 6 步应为 265)。
|
||||
|
||||
**十、上线前必做**
|
||||
|
||||
1. 清空两个已存在饰品位的旧固定区间
|
||||
(`UPDATE tt_upgrade_ornaments SET luck_section = NULL, anchor_luck_section = NULL, blank_luck_section = NULL;`)
|
||||
—— 不清的话它们仍会被旧区间夹住,仍看得见「100 以上才出」的硬底;
|
||||
2. 水位从 0 起步,`enabled=true` 时前几件货会落在收紧档,属预期,需要立刻放水就在新页面注水。
|
||||
|
||||
## 2026-09-22
|
||||
|
||||
- **docker 配置命名统一:`changzhou` → `henan`(全套 `henan_` 前缀)**
|
||||
@@ -37,14 +847,14 @@
|
||||
|
||||
**关键前提**:RuoYi 的左侧菜单是**数据库驱动**的(`sys_menu` → `/getRouters` → 前端 `sidebarRouters`),**代码里没有菜单清单**,所以必须"先删数据、再删页面文件",只删前端文件的话菜单依然在。
|
||||
|
||||
**改动清单**(完整 SQL 与回滚语句见 `docs/updata_sql.md`):
|
||||
**改动清单**(完整 SQL 见 `docs/updata_sql.md`):
|
||||
|
||||
1. **数据库**(库 `henan`,已执行):`sys_menu` 删 **22 行**(3 个顶级 M + 19 个子页面 C);`sys_role_menu` 删 **22 行**授权(全属 `role_id=103`)。**无 `'F'` 按钮权限行**。
|
||||
- 3130 奥林匹斯 `olympus` → 子菜单 3131~3137
|
||||
- 2105 荒野争锋 `bullet` → 子菜单 2106~2110、2112
|
||||
- 2125 极速糖果 `sweet` → 子菜单 2126~2131
|
||||
2. **前端文件**(38 个,删前已确认全部被 git 跟踪):`ruoyi-ui/src/views/game/{olympus,bullet,sweet}/`(7+6+6)与 `ruoyi-ui/src/api/game/{olympus,bullet,sweet}/`(7+6+6)。
|
||||
3. **文档**:本条 + `docs/updata_sql.md` 新条目(含 44 条回滚 INSERT)。
|
||||
3. **文档**:本条 + `docs/updata_sql.md` 新条目。
|
||||
|
||||
**验证**:
|
||||
|
||||
@@ -58,4 +868,154 @@
|
||||
2. **数据库业务表**:`game_olympus_*` / `game_sweet_*` 等原样保留,未 TRUNCATE、未 DROP。数据还在,随时可回。
|
||||
3. **`ruoyi-ui/src/views/game/sugar/`**(5 api + 5 页面)—— **没有任何菜单引用**。菜单「极速糖果」指向的是 `game/sweet/*`,而 `docs/cleanup_records.sql` 又把 `game_sugar_*` 标注为「极速糖果」,两套并存;后端还存在 `GameSugarWin/User/StepInfo/Spin` 实体。**疑似遗留或未接入的开发分支,未确认前不删。**
|
||||
|
||||
**遗留待定**:① 仪表盘「实时 RTP」卡片与「流水统计」折线里仍有 **玫瑰女郎(=olympus)** 一条 —— 那是后端 `/getRtpData` 返回的玩法列表,**与菜单无关**,删菜单不会让它消失;若要一并去掉得动后端。② `game/sugar/*` 是否清理待定。③ 是否需要把 `game_olympus_*` / `game_sweet_*` 业务表也清掉(现在只是入口没了,数据仍在库里占空间)。
|
||||
**遗留待定**:① 仪表盘「实时 RTP」卡片与「流水统计」折线里仍有 **玫瑰女郎(=olympus)** 一条 —— **已于下一轮处理**;② `game/sugar/*` 是否清理待定。③ 是否需要把 `game_olympus_*` / `game_sweet_*` 业务表也清掉(现在只是入口没了,数据仍在库里占空间)——实查确认**不存在 `%olympus%` 业务表**,无需处理。
|
||||
|
||||
- **彻底清理「玫瑰女郎」(olympus)的后端:枚举、返利统计链路与前端列**
|
||||
|
||||
**起因**:上一轮只下架了菜单与页面,后端仍保留整套 olympus 常量、统计列与返利计算。本轮要求「和后端相关的都删掉,包括返利的统计」。
|
||||
|
||||
**顺手修掉一个构建阻塞**:`skins-service/pom.xml` 仍声明 `<module>game-olympus-service</module>`、`ruoyi-admin/pom.xml` 仍依赖该 artifact,而**该模块目录早已不存在** ⇒ 这是**当时 Maven 构建必挂**的隐藏故障(本次删掉引用后构建才通过)。全仓已无任何文件名含 olympus。
|
||||
|
||||
**改动面:2 个 pom + 21 个后端文件 + 13 个前端文件**
|
||||
|
||||
| 层 | 文件 | 改动 |
|
||||
|---|---|---|
|
||||
| pom | `skins-service/pom.xml`、`ruoyi-admin/pom.xml` | 删 `game-olympus-service` 的 module / dependency |
|
||||
| 常量 | `TtAccountRecordSource.java` | 删 `OLYMPUS_SPIN(60)` / `OLYMPUS_REWARD(61)` 枚举项,并从 **6 处**集合与判断中移除(推广福利流水、返利游戏类型、今日投注、今日赢奖、流水抽奖条件);删 `isSpecialGameConsume` / `isNewGameConsumeForCommission`,`isGameConsumeForCommission` 简化为只判经典 |
|
||||
| 常量 | `TtboxRecordSource.java` | 删 `OLYMPUS_BOX(13)` |
|
||||
| 常量 | `MessageCategoryEnum.java` | 删 `GAME_OLYMPUS` |
|
||||
| 常量 | `RedisConstants.java` | 删 3 个 `OLYMPUS_*` 缓存键 |
|
||||
| 配置 | `ParameterSettingBody.java`、`WebsiteSetupServiceImpl.java` | 删 `olympusMaintenance` 字段与读写(数据库侧见 `updata_sql.md`) |
|
||||
| 仪表盘 | `TtRechargeRecordController.java` | 折线 `chartData` 整条 `roseladyList` 链删除;实时 RTP 的 `gameConfig` / `gameNames` 去掉第 8 项(**保留索引 5 = Roll 房**,`if (i == 5)` 依赖它) |
|
||||
| 用户报表 | `TtUserBlendErcashMapper.xml`、`TtUserSourceReportMapper.xml`、`TtUserAmountRecordsController.java` | 删 `olympusGame` 列 ×2 + 排序分支;总流水 source 列表去 60 |
|
||||
| 开箱统计 | `TtBoxRecordsMapper.xml` | 返奖总额 `source IN (...)` 去 13 |
|
||||
| 返利 | `PromoTurnoverMapper.xml` | 删 `todayOlympus` / `yesterdayOlympus` / `weekOlympus` / `totalOlympus` 四列、`todayNewGame` / `yesterdayNewGame`、`newGameConsume` ×2,source 列表去 60 |
|
||||
| 返利 | `PromoTurnoverMapper.java` | 删 `getSubNewGameConsumeByDate` / `getSpecialGameConsumeByUserId` 两个专属查询 |
|
||||
| 返利 | `PromoTurnoverServiceImpl.java` | 看板 `buildPeriod` 去掉 olympus 周期项与 4 个调用点参数;下级佣金去掉新游戏分项 |
|
||||
| 返利 | `PromoTurnoverController.java` | 三处 `olympusConsume` 统计链删除;`businessGameRate` 统一取经典费率 |
|
||||
| 返利 | `PromoAdminManageController.java` | 删 `specialGameTurnover` 输出与 `sumNewGameConsume` |
|
||||
| 返利 | `CommissionSettlementTask.java` | **周结佣金**去掉新游戏分项 |
|
||||
| 其他 | `TtUserServiceImpl.java` | 实时返佣分支去掉「新游戏」,只判经典 |
|
||||
| 其他 | `ApiUserPackSackServiceImpl.java`、`ApiProfileRecordsServiceImpl.java`、`TaskUpdate.java`、`TtBoxServiceImpl.java` | 映射 / 筛选集合 / 发奖公式 / 注释 |
|
||||
| 前端 ruoyi-ui | `utils/accountSource.js`、`ttSetting/index.vue`、`ttuser/{userTurnover,userPack,creditsRecords,amountRecords,blendErcashArchive}.vue` | 删流水来源码 60/61 与 13 的字典与筛选项、删「玫瑰女郎维护」开关、删用户流水表的 `olympusGame` 列与排序标签 |
|
||||
| 前端 promo-ui2 | `Home`、`FlowRecord`、`BusinessManage`、`AnchorManage`、`subFlow/transaction`、`CommissionReport` | 删费率卡 / 周期列 / 玫瑰女郎列 ×3 / 特殊游戏流水列 ×2 / 日特殊游戏消费列 / 来源码字典 |
|
||||
|
||||
**验证**(产物级断言,不是看代码):
|
||||
|
||||
- 后端 `mvn -B clean install -DskipTests` → **`BUILD SUCCESS`**,`Total time: 04:13 min`,无 `BUILD FAILURE` / `COMPILATION ERROR`;`ruoyi-admin.jar` 正常产出(6 个模块全链打通)。
|
||||
- `target/classes` 里 5 个关键 class(`TtAccountRecordSource` / `TtboxRecordSource` / `MessageCategoryEnum` / `RedisConstants` / `PromoTurnoverServiceImpl`)搜 `OLYMPUS` **命中 = 0**。
|
||||
- `target` 中 3 个 mapper XML 搜 `olympus` / `newGame` / `source 60` **命中全 0**。
|
||||
- 数据库:`sys_config` 里 `%olympus%` 残留 **= 0**;一级菜单 20 个(与上轮一致)。
|
||||
- `ruoyi-ui` 生产构建通过;`promo-ui2` 因 `node_modules` 缺 `esbuild` 无法构建(环境问题,非本次改动),改做静态校验。
|
||||
|
||||
**刻意保留**:
|
||||
|
||||
1. **历史数据**(`tt_user_blend_ercash` 47 行、`tt_box_records` 293 行、`tt_user_source_report` 14 行)—— 属账目,未删。副作用:流水来源码字典删了,这些历史流水在后台显示不出中文名。
|
||||
2. `ruoyi-ui/src/views/index.vue` 的 `hidden` 数组仍留「玫瑰女郎」、`.rtp-theme-4` 样式保留 —— 后端已不再返回,留作**二层防护**。
|
||||
3. **`newGameCommissionRate`(新游戏返佣比例)字段保留** —— 它挂在 `tt_user` 表上,属**用户配置字段**而非统计,删除涉及 DDL 与用户管理页,且语义上不点名玫瑰女郎。现状:无任何消费来源指向它,相关分项恒为 0。
|
||||
|
||||
- **新增「麻将胡了」玩法:以「新游戏」类型接入,复用原玫瑰女郎的整套统计口径**
|
||||
|
||||
**起因**:玫瑰女郎下架后,用户引入新游戏「麻将胡了」(后端 `mahjong` 玩法),要求「以新游戏的类型把相应的统计都加上」。
|
||||
本轮是**上一轮删除动作的完整镜像** —— 把上一轮删掉的 olympus 分支,按 mahjong 重新接回全部统计链路。
|
||||
|
||||
**口径映射**(沿用原语义,未改结构):
|
||||
|
||||
| 概念 | olympus 旧值 | mahjong 新值 |
|
||||
|---|---|---|
|
||||
| 消费来源码 | `OLYMPUS_SPIN = 60` | `MAHJONG_SPIN = 68` |
|
||||
| 奖励来源码 | `OLYMPUS_REWARD = 61` | `MAHJONG_REWARD = 69` |
|
||||
| 开箱产出码 | `TtboxRecordSource` 13 | `MAHJONG_BOX = 15` |
|
||||
| 新游戏返佣费率 | `tt_user.newGameCommissionRate` | **同名沿用**(未动 DDL) |
|
||||
| 返佣流水来源 | `GAME_CONSUME_COMMISSION(47)`「特殊游戏返利」 | 同 |
|
||||
|
||||
**改动清单**(后端 21 个文件 + 前端 7 个 + DB 3 张表):
|
||||
|
||||
| 层 | 文件 | 改了什么 |
|
||||
|---|---|---|
|
||||
| 枚举 | `TtAccountRecordSource` / `TtboxRecordSource` / `MessageCategoryEnum` / `RedisConstants` | 新增 68 / 69 / 15 及消息分类、缓存键 |
|
||||
| 仪表盘 | `TtRechargeRecordController` | `chartData` 加 `mahjongList`;`realTimeRtp` 加第 8 项「麻将胡了」(**追加在末尾,不动 `i == 5` 的 Roll 房下标**) |
|
||||
| 开箱统计 | `TtBoxRecordsMapper.xml` | 3 处返奖口径 `source IN` 加 `15` |
|
||||
| 用户报表 | `TtUserBlendErcashMapper.xml` / `TtUserSourceReportMapper.xml` / `TtUserAmountRecordsController` | 加 `mahjongGame` 列 ×2 + 排序分支 + `AGG_SORT_FIELDS` |
|
||||
| 返利 | `PromoTurnoverMapper.xml` | `getBatchedCommissionData` / `getGameCommissionData` / `getWeeklyCommissionDataWithSnapshot` / `getWeeklyDailyCommissionData` / `businessGameConsumeRows` 全部加 68 与 newGame/mahjong 列 |
|
||||
| 返利 | `PromoTurnoverServiceImpl` / `PromoTurnoverController` / `PromoAdminManageController` / `CommissionSettlementTask` | 看板费率卡与周期项、三处 `*Consume` 统计链、`businessGameRate` 新游戏分支、`specialGameTurnover`、`sumNewGameConsume`、周结分项 |
|
||||
| 其他 | `TtUserServiceImpl`(实时返佣新游戏分支)、`ApiProfileRecordsServiceImpl`(69 / 68)、`ApiUserPackSackServiceImpl`(15 → 69 映射)、`TaskUpdate`(特殊游戏消费 + 0.01 系数)、`TtBoxServiceImpl`(注释) | |
|
||||
| 配置 | `ParameterSettingBody` + `WebsiteSetupServiceImpl` | `mahjongMaintenance` 读写(键 `mahjongMaintenance`,兜底 `"0"`) |
|
||||
| 前端 ruoyi-ui | `views/index.vue`(折线加「麻将胡了流水」)、`utils/accountSource.js`(68 / 69)、`skins/ttSetting/index.vue`(麻将胡了维护)、`skins/ttuser/{amountRecords,creditsRecords,blendErcashArchive,userPack,userTurnover}` | 来源码字典与筛选 68 / 69、开箱码 15、`mahjongGame` 列与排序标签 |
|
||||
| 前端 promo-ui2 | `Home`(费率卡 + 周期列)、`FlowRecord`(麻将胡了列 ×2)、`BusinessManage` / `AnchorManage`(特殊游戏流水列)、`subFlow/transaction`(日特殊游戏消费)、`CommissionReport`(68 → 麻将胡了) | |
|
||||
|
||||
**数据库**:`sys_menu` +8(麻将胡了目录 `3200` + 7 子页)、`sys_role_menu` +8(role 103)、
|
||||
`sys_config` +1(`mahjongMaintenance`)。完整 SQL 见 `docs/updata_sql.md`。
|
||||
|
||||
**验证**(产物级断言):
|
||||
|
||||
- 后端 `mvn -B clean install -DskipTests` → **`BUILD SUCCESS`**,19 个模块全过,`Total time: 03:55 min`。
|
||||
- `ruoyi-ui` 生产构建 → `DONE Build complete`。
|
||||
- `promo-ui2` 生产构建 → **本轮把环境修好了**,构建通过(修法见「踩坑」)。
|
||||
- 数据库实测:`sys_menu` 3200~3207 = 8 行、`sys_role_menu` = 8 行、`sys_config.mahjongMaintenance` = 1 行。
|
||||
|
||||
**踩坑(记一次,下次别重踩)**:`promo-ui2/node_modules` 是**残缺的扁平布局** —— 绝大多数包只在
|
||||
`node_modules/.pnpm/node_modules/` 里有,顶层没有软链 ⇒ vite 一启动就 `ERR_MODULE_NOT_FOUND`
|
||||
(先 `esbuild`、再 `@rollup/rollup-win32-x64-msvc`、再 `@babel/core`…… 逐个补是打地鼠)。
|
||||
**一次性修法**:把 `.pnpm/node_modules` 下**所有**包(含 `@scope` 子目录)在顶层批量建 junction
|
||||
(本次补了 800+ 个链接)后构建即通过。根因是当初 `pnpm install` 没跑完/被裁过,属**环境问题、非代码问题**。
|
||||
|
||||
**刻意未做**:麻将胡了的 **11 张 `game_mahjong_*` 游戏表尚未落库**(DDL + 种子在 `mahjong-py/schema.sql`)。
|
||||
这 7 个新菜单页面依赖它们,未建表前打开会报「表不存在」。属「游戏本体」前置依赖、且 generator 还没写完
|
||||
(见 `mahjong-py/README.md`),**待用户确认后再执行**。
|
||||
|
||||
- **后台管理端(`ruoyi-ui`)整站换肤:Cottagecore 田园核风 + 逐页 UI 细节收敛**
|
||||
|
||||
**起因**:管理后台长期沿用它默认的企业蓝(Element 默认色板 + vue-element-admin / AdminLTE 遗留色),与河南项目的
|
||||
田园调性不符;用户要求按 Cottagecore 规范整站重做,并**逐页检查小问题、把 UI 细节调到位**。
|
||||
|
||||
**做法:不引 Tailwind,走「Element 变量 + 皮肤覆盖层」**
|
||||
(`ruoyi-ui` 是 Vue2 + Element UI 2.15 + vue-cli4/webpack4/postcss7,Tailwind 的 preflight 会打乱 Element 基础样式)。
|
||||
新建 **`src/assets/styles/cottagecore.scss` 作为全站唯一色源**,其余文件一律引用变量、不写死十六进制;
|
||||
再通过重编译 `element-ui` 的 theme-chalk 源码(`element-variables.scss`)换掉 Element 自己的底色。
|
||||
|
||||
| 文件 | 角色 |
|
||||
|---|---|
|
||||
| `assets/styles/cottagecore.scss` | **唯一色源**:品牌色 + 组件槽位色 + 图标色相 + 对比度实测注释 |
|
||||
| `assets/styles/element-variables.scss` | 覆盖 `$--color-*` / 圆角 / 阴影 / `$--color-white` → 暖白 `#fffdfa`,再 `@import` theme-chalk 源码整包编译 |
|
||||
| `assets/styles/skin.scss` | 皮肤覆盖层(按钮 / 表单 / 表格 / 分页 / 弹层 / Element 写死的冷色) |
|
||||
| `assets/styles/index.scss` | 总入口,**最后一行 `@import './skin.scss'`**(整体回滚 = 注释这一行) |
|
||||
| `assets/styles/sidebar.scss` / `variables.scss` | 侧栏(4 色图标轮换)/ 菜单深浅两套色 + `:export` 给 js |
|
||||
|
||||
**六轮递进,要点**:
|
||||
|
||||
1. **建色源 + 换 Element 底色**:primary 定陶土 `#9a5c35`(**不用草绿**,否则「新增 / 修改」同色系分不清;
|
||||
想切回只改色源一处),success 草绿 `#457450`,warning 深雏菊金 `#8f6418`,danger 莓红 `#a4485a`,info 暖褐 `#6b6152`。
|
||||
正文 `#5f4f3d`(**`#8b7355` 只有 4.17:1,禁止做正文**);草绿 `#5a8f5a` 白字仅 3.81:1 ⇒ **只做装饰与大色块**,
|
||||
绿色文字一律 `#3f6b45`。
|
||||
2. **摘掉隐藏的 ThemePicker**:`App.vue` 曾无条件挂 `<theme-picker>`(只用 CSS `display:none` 藏,组件照样 mount),
|
||||
它在 `created()` 里会去 `unpkg.com` 拉整包 Element 默认主题并注入 `<style>` **把配色改回企业蓝**。已移除,别再挂回去。
|
||||
3. **全局色板机械清扫**(45 个文件):把散在业务页里的旧 Element 色(`#409eff` / `#67c23a` / `#e6a23c` / `#f56c6c` / `#909399` …)
|
||||
按语义映射到色源;同时改掉 `Logo.vue` / `Navbar.vue` 的白色毛玻璃 + 蓝阴影、全局滚动条的冷蓝。
|
||||
4. **顶栏与标签页合并成一条**,去掉重复显示的面包屑。
|
||||
5. **Element 内部写死的冷色**(hex 搜索抓不到、只能用更高权重规则盖):白遮罩、蓝色 info toast、
|
||||
冷绿上传成功勾 `#13ce66`(**它的选择器是带父级的三段式 0,2,0,单类选择器会被压住**)、冷灰滚动条 / 标签 / 日期面板。
|
||||
根因是 `$--color-white` → 把**暖白 `#fffdfa`** 提为「白」,一次性统一所有白色面。
|
||||
6. **操作列按钮折行**(用户截图里那类问题)——根因不是列宽不够,而是 `element-ui.scss` 里一条全局规则
|
||||
`.fixed-width .el-button--mini { width: 60px }` 把按钮钉死 60px,`图标 + 2~4 个中文` 直接被截断换行。
|
||||
删掉该规则 + 给 19 个操作列补显式宽度(≥3 个按钮的收进「更多」下拉)+ 补下拉与前钮间距。
|
||||
|
||||
**验证(三层,脚本留在 `.workbuddy/`,可重跑)**:
|
||||
|
||||
- `node .workbuddy/audit-palette.cjs` —— 源码级色板审计(`.vue` + `.scss` + `.css`,**先剥注释再扫**):
|
||||
当前 **0 / 184 文件离版**。色板白名单里另有一类**刻意保留的域内语义色**:
|
||||
CS2 稀有度、麻将/饰品品质色谱(金/红/紫)、仪表盘折线色 —— **不是主题色,不许改**。
|
||||
- `node .workbuddy/verify-dist.cjs` —— 构建产物断言,**34 项 ALL_OK**:① 本项目源码的冷色必须为 0;
|
||||
② 新增规则必须存在;③ 中性色(`rgba(0,0,0,*)`)设「冻结基线」上限防复发。
|
||||
**扫描范围是 app.css + 全部 `chunk-*.css`**(views/* 是懒加载,只看 app.css 会整片漏掉)。
|
||||
- `node .workbuddy/verify-computed.cjs` —— **真机 Chrome 计算样式断言,20 项 ALL_OK**。
|
||||
专治 element-ui 内部字面量色:这些色值**在样式表文本里去不掉**,`grep = 0` 不成立,必须看计算值。
|
||||
- 构建:`DONE Build complete`,零 Sass 错误;仅剩既有的 `delLevel` 缺失导出 + 体积警告,与本改动无关。
|
||||
|
||||
**踩坑**:并行对**同一文件**发多个 Edit 会撞 `EBUSY`;更阴的是**偶发不报错、但两次写入只剩一次**(本次 `sidebar.scss` 丢过两个替换)
|
||||
⇒ **机械替换一律串行,改完用 grep 逐条复验**,别信 Edit 的 success 回执。
|
||||
|
||||
**刻意未做**:① `variables.scss` 里 `sideTheme: theme-dark` 的深色侧栏回退色(冷蓝灰)保留 ——
|
||||
设置抽屉里的「深色」按钮现在点下去视觉上是空操作(皮肤层已把侧栏壳设成透明 + 文字 `!important`),
|
||||
**没有删按钮也没改回退色**,要不要彻底下掉这个开关待定;② `btn.scss` 的 `.blue-btn` 等 7 个类全项目 0 引用,
|
||||
但其颜色已随 `variables.scss` 指向新色板,**不构成视觉问题,未清理**。
|
||||
|
||||
+156
-31
@@ -1,6 +1,12 @@
|
||||
# 幸运升级接口文档(Cocos 客户端对接)
|
||||
|
||||
> 适用版本:当前 master。所有接口路径前缀 `/api/luckyUpgrade`(另有 1 个关联接口在 `/api/userPackSack`)。
|
||||
> 适用版本:当前 master。
|
||||
> **2026-09-23 起随机数改由「全局水位」动态决定**(不再是每个饰品位的固定幸运区间);三身份的进度与随机数**完全隔离**;
|
||||
> 幸运值展示值改为三身份合计(累加口径仍按玩家在前端拉的值,**中奖判定恒用 `min(概率,75)`**)。
|
||||
> **2026-09-24 起水位配置简化为「只填一个目标水位(%)」** —— 随机数中心由它自动推算,
|
||||
> 取值范围仍由每个饰品自己的区间限定(见 §1.2 / §1.3 / §6.4)。
|
||||
> 方案、换算表、口径推导与算例见 `docs/修改记录.md` 的 2026-09-24 条(前情见 2026-09-23 条);数据库变更见 `docs/updata_sql.md`。
|
||||
> 所有接口路径前缀 `/api/luckyUpgrade`(另有 1 个关联接口在 `/api/userPackSack`)。
|
||||
> 代码位置:`skins-service/service-playingmethod/.../ApiLuckyUpgradeController.java`、`ApiLuckyUpgradeServiceImpl.java`、`ApiUpgradeRecordServiceImpl.java`。
|
||||
|
||||
---
|
||||
@@ -15,8 +21,8 @@
|
||||
↓ 校验(身份 / 封禁 / 删除 / 未充值 703 / 余额 702)
|
||||
↓ 加锁(饰品位 + 奖池类型)→ 扣款(source=40 幸运升级消费)
|
||||
↓ 判定(见 1.2)
|
||||
├─ 成功:获得该饰品位对应的整件饰品,进度与幸运值清 0
|
||||
└─ 失败:获得后台配置的「失败奖励」饰品(安慰奖,可多件),进度与幸运值累加
|
||||
├─ 成功:获得该饰品位对应的整件饰品,**该身份**的进度与随机数清 0 / 重抽(其他身份不受影响)
|
||||
└─ 失败:获得后台配置的「失败奖励」饰品(安慰奖,可多件),**该身份**的进度累加
|
||||
↓ 写 tt_upgrade_record 记录(含各项快照)+ 背包 tt_box_records(source=4 幸运升级)+ 站内消息
|
||||
↓ 返回升级记录(含 gainOrnamentList,可用于结果页立即分解)
|
||||
```
|
||||
@@ -27,23 +33,72 @@
|
||||
|---|---|
|
||||
| 单次消耗 | `price = 饰品实时价(tt_ornament.use_price) × probability / 100`,保留 2 位小数(**前端不传价格**,由后端算,防 0 元 100% 中奖) |
|
||||
| 判定概率 | `eff = min(probability, 75)`(**封顶 75**:选 90% 也只按 75% 判定,但钱按 90% 收) |
|
||||
| 本轮空间 | `fullV`:从该饰品位对应奖池的幸运区间 `[min, max)` 里随机取 `nextInt(max-min) + min` |
|
||||
| 判定 | 每拉一次 `进度 += eff`;`进度 + eff >= fullV` 即中奖,中奖后 `fullV` 重新随机、进度清 0;否则失败继续累加 |
|
||||
| 推论 | **玩家为一件饰品付出的总成本 ≈ 饰品价 × fullV / 100**。所以幸运区间 = 该池的"售价百分比区间":`[50,250]` 表示平均要付 50%~250% 原价才出;压到个位数(如 `[9,10]`)等于必中、平台必亏 |
|
||||
| 本轮空间 | `fullV`:由**当前全局水位**自动推算 —— 先按目标水位反推中心,再在该饰品自己的区间 `[min, max]` 内做**偏斜随机**(支撑集是整个区间,水位高时下限附近的数更频繁)。水位见 1.3 |
|
||||
| 判定 | 每拉一次 `进度 += eff`;`进度 + eff >= fullV` 即中奖,中奖后**只刷新当前身份**的 `fullV`、该身份进度清 0;**其他身份的进度与随机数一律不受影响**;否则失败继续累加 |
|
||||
| 推论 | **玩家为一件饰品付出的总成本 ≈ 饰品价 × fullV / 100**,即 `理论 RTP = 100 / fullV`、`水位 = 1 − 100/fullV`。⇒ 想让水位能长期停在某个值,饰品的随机数区间必须**包含**该水位对应的中心随机数 |
|
||||
|
||||
### 1.3 幸运值与幸运区间:三套独立奖池
|
||||
> ⚠️ **名义 ≠ 实测**:判定是「跨过阈值即出货、最后一拉整段 eff 计入并丢弃溢出」,实测等效需求是名义值的
|
||||
> **118%~120%**。所以**不要按 `100/fullV` 公式拍参数**,按后台「奖池水位」页的实测 RTP 与标定系数来调。
|
||||
|
||||
`幸运值` = 该池"累计原始概率",失败累加 `probability`(原始值,不是封顶后的 75)、中奖清 0;界面与记录里展示的就是它。
|
||||
### 1.3 幸运值、水位与三个身份池
|
||||
|
||||
| 奖池 | 判定/显示用 userType | 幸运区间字段 | 幸运值字段 | 本轮已投金额字段 |
|
||||
|---|---|---|---|---|
|
||||
| 普通用户 | `02` | `tt_upgrade_ornaments.luck_section` | `probability_invested` | `amount_invested` / `effective_amount_invested` |
|
||||
| 主播 | `01` | `anchor_luck_section` | `anchor_probability_invested` | `anchor_amount_invested` / `anchor_effective_amount_invested` |
|
||||
| 白号 | `06`(`is_blank_account=1` 优先判定) | `blank_luck_section`(未配置回退 `luck_section`) | `blank_probability_invested` | `blank_amount_invested` / `blank_effective_amount_invested` |
|
||||
**水位(全局,2026-09-23 起)**:
|
||||
|
||||
- 三池**完全隔离**:进度缓存在 Redis `upgrade_range_fixed:{饰品位}:{池类型}` / `upgrade_range:{饰品位}:{池类型}`,互不影响、互不阻塞(锁也是按「饰品位 + 池类型」)。
|
||||
- 同一池内是**共享进度**:同一个池子的玩家一起把进度推到 `fullV`,谁那一拉跨过阈值谁中奖(和普通/主播现有行为一致,白号同理,白号之间共享 06 池)。
|
||||
- 白号记录里的幸运值/已投金额读的都是白号自己的池,不再是真实玩家池的读数。
|
||||
```
|
||||
水位% = (Σ下注 − Σ返奖 + 人工调整) / Σ下注 × 100 # 统计范围 = 整个幸运升级,排除白号
|
||||
```
|
||||
|
||||
水位越高说明平台已经净赚越多 → 本轮可出的随机数越低(放水);水位不足 → 随机数越高(收紧)。
|
||||
后台只配一个**目标水位 T(%)**,随机数中心由它自动反推:
|
||||
|
||||
```
|
||||
V* = 100 / ((1 − T/100) × k) k = 实测标定系数(1.18~1.20,样本不足按 1.19)
|
||||
中心 = V* × (1 − (当前水位 − T)/100 × gain) gain 默认 2
|
||||
t = (中心 − min) / (max − min) 中心在区间内的相对位置,夹到 [0.10, 0.90]
|
||||
V = min + (max − min) × u^p, p = 1/t − 1 u ~ U(0,1)
|
||||
```
|
||||
|
||||
**整个区间始终都可能出**,水位只改变概率重心:水位 = 目标时 `p = 1`(区间内均匀分布);
|
||||
水位高于目标 ⇒ `p > 1` ⇒ 下限附近更频繁;低于目标则相反。
|
||||
`E[V] = 中心` ⇒ 水位会**长期自动收敛回目标值**(仿真实测偏差 < 0.05 个点)。
|
||||
⚠️ `t` 的夹取范围意味着**中心的可表达范围 = min + [10%, 90%] × 区间宽度**:
|
||||
区间太窄或下限太高时,高水位目标就实现不了(例:`[100,160]` 只能维持约 `[20.7%, 45.4%]` 的水位)。
|
||||
|
||||
**虚拟水位(抽随机数实际用的是它,不是上面那个真实水位)**:真实水位只反映**已经结算**的下注/返奖,
|
||||
一批饰品同时开新轮时若都用同一个真实水位,会连着出好几个同侧偏斜的随机数。所以每抽一个随机数,
|
||||
就把这一轮的预估交易预留进 pending:
|
||||
|
||||
```
|
||||
虚拟水位 = (真实bet + pending_bet − 真实win − pending_win + 调整) / (真实bet + pending_bet)
|
||||
pending_bet += 饰品价 × k × 随机数 / 100 pending_win += 饰品价
|
||||
```
|
||||
|
||||
出货过程中产生的实际流水会**逐步把预留扣回去**(两边口径一致 ⇒ 真正出货时归零,不会长期挂账)。
|
||||
白号轮次不进水位口径,不参与预留;未抽过数时 pending = 0,退化为真实水位。
|
||||
|
||||
水位从 0 起步,可用后台「幸运升级 → 奖池水位」人工注水/抽水。策略与人工调整见 §6.4。
|
||||
|
||||
**三个身份池**:
|
||||
|
||||
| 池 | userType | 幸运值字段(累计进度) | 本轮已投金额字段 |
|
||||
|---|---|---|---|
|
||||
| 普通用户 | `02` | `probability_invested` | `amount_invested` / `effective_amount_invested` |
|
||||
| 主播 | `01` | `anchor_probability_invested` | `anchor_amount_invested` / `anchor_effective_amount_invested` |
|
||||
| 白号 | `06`(`is_blank_account=1` 优先判定) | `blank_probability_invested` | `blank_amount_invested` / `blank_effective_amount_invested` |
|
||||
|
||||
- 三池**完全隔离**:进度与随机数缓存在 Redis `upgrade_range_fixed:{饰品位}:{池类型}`,锁也是按「饰品位 + 池类型」。
|
||||
**白号中奖只刷新白号自己的随机数**;普通玩家的进度和随机数原样保留 —— 一个奖池但不会互相污染。
|
||||
- **随机数的中心由全局水位决定,取值范围由每个饰品自己的区间限定**:`luck_section` /
|
||||
`anchor_luck_section` / `blank_luck_section` 三列仍按身份分别生效,但语义从改造前的「固定随机数区间」
|
||||
变为「**允许的取值范围**」—— 区间要**包含水位推算出的中心值**,否则中心被夹在边界上、水位会停在别处。
|
||||
策略关闭(`enabled=false`)时这三列恢复成改造前的「固定随机数区间」用法。
|
||||
- **幸运值的累加口径**:默认按「玩家在前端拉的那个值」累加(原始 `probability`,选 90% 就 `+90`),与历史记录口径一致;
|
||||
**中奖判定固定用 `min(probability, 75)`** —— 两者刻意分开(75% 封顶是风控手段,不改展示数字)。
|
||||
后台开关 `luckyValueMode` 可切成 `eff`(按 `min(概率,75)` 累加),**只影响展示值与落库快照**。
|
||||
- **幸运值 = 三个身份共享的一个累计器**:`getOrnamentsList.luckyValue` 就是这个值,所以所有人看到的数完全一致;
|
||||
**任一身份(含白号)中奖都把它整体归零**,且**中奖那一拉的增量仍计入**(出货那一刻看到的是含本次的数,下一拉才从 0 重新累计)。
|
||||
⚠️ 这与「判定进度」不是一回事:**判定进度与随机数按身份完全独立**,白号中奖不会动普通玩家的进度。
|
||||
另有 `myLuckyValue` = 查看者所属身份的贡献(自上次任一身份中奖以来),排查用。
|
||||
|
||||
### 1.4 获得与失败奖励
|
||||
|
||||
@@ -132,7 +187,8 @@
|
||||
| usePrice | BigDecimal | 实时价格(= 计算消耗、展示目标金额的基准) |
|
||||
| imageUrl | String | 饰品图片 |
|
||||
| shortName / typeName / rarityName / exteriorName / levelImg | String | 简称 / 类型 / 稀有度 / 磨损度 / 品质图 |
|
||||
| luckyValue | Integer | **当前幸运值**(按登录身份取对应池:普通/主播/白号) |
|
||||
| luckyValue | Integer | **当前幸运值**(三个身份共享的一个累计值,所有人看到的数一致);口径见 1.3。**任一身份中奖即整体归零** |
|
||||
| myLuckyValue | Integer | 查看者所属身份对共享值的贡献(自上次任一身份中奖以来);排查用,一般不用 |
|
||||
|
||||
### 4.2 `POST /api/luckyUpgrade/upgrade` — 拉取一次
|
||||
|
||||
@@ -210,7 +266,7 @@
|
||||
|---|---|---|
|
||||
| userId / avatar / nickName / vipLevel | - | 玩家信息(昵称是升级时快照) |
|
||||
| probability | Integer | 成功率(原始概率,如 89 表示 89%) |
|
||||
| luckyValue | Integer | 幸运值快照(按该玩家所属池;白号是白号池的值) |
|
||||
| luckyValue | Integer | 幸运值快照(**中奖记录记的是「中奖那一刻」的值,含本次**),与 `getOrnamentsList.luckyValue` 同口径 |
|
||||
| amountConsumed | BigDecimal | 消耗 |
|
||||
| gainOrnamentsPrice | BigDecimal | 获得饰品总价值 |
|
||||
| isVictory | Boolean | 成败:true=升级成功(前端胜负样式用) |
|
||||
@@ -240,7 +296,7 @@
|
||||
|---|---|---|
|
||||
| avatar / nickName / vipLevel | - | 拉取人信息 |
|
||||
| probability | Integer | 本次拉取**选择的成功率**(不是中奖率) |
|
||||
| luckyValue | Integer | 本次拉取时的**幸运值快照**(累计原始概率;成功为 0),与 `recordPage` 同口径 |
|
||||
| luckyValue | Integer | 本次拉取时的**幸运值快照**(含本次的增量;中奖那一拉记的就是出货那一刻的值),与 `recordPage` 同口径 |
|
||||
| isVictory | Boolean | 成败:true=升级成功 |
|
||||
| name / imageUrl / usePrice / exteriorName / levelImg | - | **目标饰品**("谁在拉这个饰品",不是获得物) |
|
||||
| openTime | String | 拉取时间 |
|
||||
@@ -291,8 +347,8 @@
|
||||
1. **upgradeId 语义**:所有接口里的 `upgradeId` / `recentPulls.upgradeRecordId` 都是**可升级饰品ID**(`tt_upgrade_ornaments.id`),来自 `getOrnamentsList` 的 `id`,不是 `tt_ornament.id`,也不是升级记录ID。
|
||||
2. **扣款以服务端为准**:不要传价格;展示价格用 `usePrice × probability / 100`(与后端同公式,后端保留 2 位小数)。
|
||||
3. **概率封顶 75**:UI 上若提供 >75% 的档位,要提示"更高概率只加钱、不提升判定"。
|
||||
4. **幸运值怎么用**:主界面实时幸运值取 `getOrnamentsList.luckyValue`;拉取成功后用 `upgrade` 响应里的 `luckyValue` 立即覆盖,不必等列表刷新。记录里的幸运值是历史快照。
|
||||
5. **分池**:幸运值/幸运区间按 普通(02) / 主播(01) / 白号(06) 三套独立,白号看到的是白号池的值;同一池内进度是共享的,别人垫的进度也算在你头上。
|
||||
4. **幸运值怎么用**:主界面实时幸运值取 `getOrnamentsList.luckyValue`(**三个身份合计的全服累计值,所有人一致**);拉取成功后用 `upgrade` 响应里的 `luckyValue` 立即覆盖,不必等列表刷新。记录里的幸运值是历史快照。
|
||||
5. **分池与水位**:进度与随机数按 普通(02) / 主播(01) / 白号(06) 三套**完全独立** —— 别人(含白号)中奖**不会**动你的进度和随机数。随机数的**中心**由全局水位统一决定(水位越高越容易出货),**取值范围**仍由该饰品自己的随机数区间限定。
|
||||
6. **分页**:`recordPage`/`myHistoryRecord` 的 `size` 上限 20;切换 tab(我的/全服、当前饰品/全部饰品)时 `page` 重置为 1。
|
||||
7. **失败记录展示**:以 `isVictory` 判断胜负,`name/imageUrl/...` 是"实际获得物"(失败=安慰奖);无安慰奖时金额为 0。
|
||||
8. **结果页分解**:`gainOrnamentList` 里的 `id` 就是分解入参;不调用 = 饰品留在背包。
|
||||
@@ -306,16 +362,20 @@
|
||||
|
||||
| 后台列 | 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| 普通用户 / 幸运区间 | `luck_section` | 普通用户池的 `[min, max)`,即"售价百分比区间" |
|
||||
| 普通用户 / 随机数区间 | `luck_section` | **随机数的允许取值范围**(`[min,max]` JSON),水位推算出的中心必须落在里面 |
|
||||
| 普通用户 / 本轮已投金额 | `amount_invested` | 普通池当前累计投入(运行时值,后台不可编辑回写) |
|
||||
| 主播用户 / 幸运区间 | `anchor_luck_section` | 主播池区间 |
|
||||
| 白号用户 / 幸运区间 | `blank_luck_section` | 白号池区间;为空则回退 `luck_section` |
|
||||
| 白号用户 / 本轮已投金额 | `blank_amount_invested` | 白号池当前累计投入 |
|
||||
| 主播用户 / 随机数区间 | `anchor_luck_section` | 同上,主播身份独立配置 |
|
||||
| 白号用户 / 随机数区间 | `blank_luck_section` | 同上,白号身份独立配置;未配则回退 `luck_section` |
|
||||
| 状态 | `status` | 0=启用(=可升级、会出现在客户端列表) 1=禁用 |
|
||||
|
||||
- 调难度就调"幸运区间":区间 = 玩家为该饰品付出的**价格百分比**。想更容易出就整体下调(如 `[80,280]`→`[60,200]`);**不要压到个位数**(如 `[9,10]` 等于必中,等于按 9% 价格卖货),也不要配成 `min == max`(会被后端防护强制改成 `[0,160]`)。
|
||||
- **改完区间要清缓存才立即生效**:`DEL upgrade_range:{饰品位}:{池}` + `DEL upgrade_range_fixed:{饰品位}:{池}`(池=01/02/06)。不清的话当前这一轮仍按旧空间跑,等本轮中奖后自动按新配置重建。Redis 实测在 db0,密码见 `application-*.yml`。
|
||||
- 白号是独立奖池,可与普通/主播配不同的区间;「批量填货」新增饰品位时白号区间默认取普通用户的默认值。
|
||||
- **随机数的中心不在这里配**:中心由「目标水位」自动推算(页面顶部水位条显示 当前水位 / 目标水位 /
|
||||
实测 RTP / 推算随机数中心),这里只配**取值范围**。调难度请改目标水位(见 6.4)。
|
||||
- ⚠️ **区间必须包含推算出的中心值**,且**中心离两端各留约 10% 区间宽度**,否则中心被夹住、水位会停在附近
|
||||
而不是目标值(仿真实测可偏 16~20 个点)。区间还**限定了能维持的水位范围**:`[100,160]` 只能维持约 `[20.7%, 45.4%]`。
|
||||
- **整个区间始终都可能出**(偏斜随机),水位只改变概率重心;水位 = 目标时是均匀分布。
|
||||
区间越宽,单轮随机数波动越大、越难被玩家归纳出规律。
|
||||
- 区间改完**下一轮立即生效**,不用清 Redis(水位驱动下直接读实体字段,不走区间缓存)。
|
||||
- ⚠️ 旧版本文档里「上线前清空三个区间列」的做法**已作废** —— 现在这三列是必填项。
|
||||
|
||||
### 6.2 「幸运升级 → 失败奖励」(`tt_upgrade_fail_ornaments`)
|
||||
|
||||
@@ -328,13 +388,78 @@
|
||||
| `websiteMaintenance` | `1` | 全站维护:`upgrade` 与分解接口都拦截 |
|
||||
| `luckyUpgradeMaintenance` | `1` | 只拦幸运升级:`upgrade` 返回「幸运升级维护中......」 |
|
||||
|
||||
### 6.4 数据落库
|
||||
### 6.4 「幸运升级 → 奖池水位」(目标水位 / 注水抽水)—— 后台接口
|
||||
|
||||
Base = `/admin/upgradeWatermark`,均需后台登录。
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /info` | 当前水位快照 + 目标水位策略 + 按目标水位推算的随机数中心 |
|
||||
| `POST /setting` | 保存策略:`targetWatermark`(必填)+ `gain`(可选,默认 2)+ `enabled` + `luckyValueMode` |
|
||||
| `POST /adjust` | 注水 / 抽水(`type` 1注水 2抽水;调整原因必填) |
|
||||
| `POST /adjustReset` | 重置人工调整(插入抵消日志,历史保留) |
|
||||
| `GET /adjustLog` | 调整历史分页 |
|
||||
| `POST /recalc` | 按历史流水全量重算累计量 + 刷新标定系数(纠正 Redis 计数漂移) |
|
||||
|
||||
`/info` 返回(节选):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `enabled` | 策略开关;`false` = 回退到每个饰品位的固定随机数区间 |
|
||||
| `targetWatermark` | **目标水位(%)** —— 后台唯一要配的运营参数 |
|
||||
| `gain` | 响应灵敏度(水位每偏离目标 1 个点,随机数中心反向调整 gain%),默认 2 |
|
||||
| `watermark` / `rtp` | 当前(真实)水位% / 实测 RTP%(均排除白号) |
|
||||
| `watermarkWithPending` | **虚拟水位** —— 抽随机数实际用的那个;与 `watermark` 的差 = 已抽走未出货轮次的影响 |
|
||||
| `pendingBet` / `pendingWin` | 已抽走随机数、还没出货的那些轮次的剩余预估下注 / 返奖 |
|
||||
| `targetGap` | 当前水位 − 目标水位(正 = 赚得比目标多 ⇒ 下一轮放水) |
|
||||
| `targetCenter` | 按目标水位推算的随机数中心 `V*`(**饰品区间要包含它**) |
|
||||
| `centerNow` | 下一轮新开会用到的随机数中心(按**虚拟水位**算,与实际抽数口径一致) |
|
||||
| `calibration` | 实测标定系数 k;样本不足时为 `null` |
|
||||
| `effectiveCalibration` | 实际参与推算的系数(样本不足时 = 1.19 兜底) |
|
||||
| `totalBet` / `totalWin` / `blankBet` / `blankWin` / `adjust` | 水位口径的累计量(白号不进水位,仅并列展示) |
|
||||
|
||||
**策略配置(`sys_config: luckyUpgradeWatermark`)**:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"targetWatermark": 30,
|
||||
"gain": 2,
|
||||
"luckyValueMode": "raw"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `enabled` | `false` = 逐字节回退改造前的固定幸运区间行为,不发版即可回滚 |
|
||||
| `targetWatermark` | **目标水位(%)**:平台希望长期维持的净收益比例。30 ⇒ 长期平均 RTP ≈ 70%。合法范围 −50 ~ 95 |
|
||||
| `gain` | 响应灵敏度,默认 2:水位每高出目标 1 个点,随机数中心降 2%(放水);低于目标则反之 |
|
||||
| `luckyValueMode` | 幸运值**展示**口径:`raw`(默认,累加原始 `probability`)/ `eff`(累加 `min(概率,75)`)。**不影响中奖判定** |
|
||||
|
||||
**目标水位 → 随机数中心换算表**(按 `k = 1.19` 兜底系数算):
|
||||
|
||||
| 目标水位 | 0% | 10% | 20% | **30%** | 40% | 45% | 50% | 60% |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 中心 `V*` | 84 | 93 | 105 | **120** | 140 | 153 | 168 | 210 |
|
||||
|
||||
保存时强校验:`targetWatermark` 必填且在 −50% ~ 95% 之间;`gain` 若填写需在 0.5 ~ 10 之间。
|
||||
|
||||
⚠️ **标定**:判定溢出让「实测等效需求」是名义值的 118%~120%,所以中心公式必须带上**实测标定系数**
|
||||
(`V* = 100 / ((1 − 目标水位/100) × k)`),不能按 `100/(1 − 目标水位/100)` 直接算。
|
||||
`k` 由系统按真实流水滚动计算(5 分钟内存缓存),样本不足时按 1.19 兜底。
|
||||
⚠️ **`V*` 必须落在饰品的随机数区间内**,且**离两端各留约 10% 区间宽度**,否则中心被夹住、水位停不到目标值。
|
||||
⚠️ **区间同时限定了能维持的水位范围**(中心可表达范围 = 区间下界 + `[10%, 90%]` 宽度):
|
||||
例 `[100,160]` 对应水位约 `[20.7%, 45.4%]`;想把水位开到 60%,区间上限得提到 210 以上。
|
||||
完整推导、仿真数据与算例见 `docs/修改记录.md` 的 2026-09-24 条。
|
||||
|
||||
### 6.5 数据落库
|
||||
|
||||
| 表 | 说明 |
|
||||
|---|---|
|
||||
| `tt_upgrade_record` | 每条拉取记录(成败、消耗、获得总额、各项快照、`gain_ornament_list` JSON、`lucky_value`) |
|
||||
| `tt_upgrade_record` | 每条拉取记录(成败、消耗、获得总额、各项快照、`gain_ornament_list` JSON、`lucky_value`、`watermark` 水位快照、`round_target` 本轮随机数快照) |
|
||||
| `tt_box_records` | 获得物入背包(`source=4` 幸运升级),`id` 即结果页分解入参 |
|
||||
| `tt_user_blend_ercash` | 余额流水:消费 40、游戏内分解 41、背包分解 10 |
|
||||
| `tt_upgrade_ornaments` | 饰品位配置 + 三个池的运行进度(各池幸运值/已投金额) |
|
||||
| `tt_upgrade_ornaments` | 饰品位配置 + 三个身份的运行进度(各身份累计幸运值/已投金额) |
|
||||
| `tt_upgrade_reward_log` | 水位人工调整日志(注水/抽水/重置;`delta` 为带符号影响,水位调整额 = `SUM(delta)`) |
|
||||
|
||||
字段变更历史见 `docs/updata_sql.md`(含白号三列的 ALTER)。
|
||||
|
||||
+217
-148
@@ -2,7 +2,11 @@
|
||||
|
||||
> 目标:把「麻将胡了」(5 轴 × 4 行、1024 路、级联消除、金色符号、乘数阶梯、免费旋转)做成**玫瑰女郎(Olympus)同一套设计模式**的兄弟游戏。
|
||||
> 参考实现:`olympus-py/`(回合池生成器)+ `skins-service/game-olympus-service/`(Java 服务)+ `ruoyi-ui/src/views/game/olympus/`(后台)+ `docs/玫瑰女郎接口文档.md`(接口契约)。
|
||||
> 状态:**方案待评审**,未动任何代码。末尾第 13 节列出必须由你拍板的 10 个问题。
|
||||
> **文档来源**:本方案是随同源项目从 **`changzhou_backend`** 搬到本仓的(本仓 `docs/修改记录.md` 已把它归为"旧项目文档"),**通篇是 changzhou 语境**。所以文内的 `olympus-py/`、`skins-service/game-olympus-service/`、`docs/玫瑰女郎接口文档.md`、`docs/updata_sql.md`、`D:\changzhou-cocos\...` 都是**那个仓库**的资产:
|
||||
> - 在 `D:\changzhou_app\changzhou_backend` 里它们**完整存在**(含上面两个 docs 模板);
|
||||
> - 在本仓(henan-backend)里**没有**——`olympus-py/` 与 `game-olympus-service/` 未随拷贝带过来,`docs/updata_sql.md` 已清空、`docs/玫瑰女郎接口文档.md` 已删(均为有意为之)。
|
||||
> - **落地仓库已定:本仓 henan-backend**。因此:① 路径类引用一律按**本仓现状**读(§3.8 / §6.5 / §8 / 附录 B 已就地标注);② 开工前先把母本从 `D:\changzhou_app\changzhou_backend` 拷过来当参照(`olympus-py/` → 本方案 §5 的照抄对象;`skins-service/game-olympus-service/` → §6 的照抄对象);③ 注意本仓 `skins-service/pom.xml:22` 仍声明 `game-olympus-service` 而该目录不存在,**Maven 从根构建会以 "Child module does not exist" 失败**(`ruoyi-admin/pom.xml:100` 也依赖它),要么先拷母本、要么先摘掉这行。数学与规则部分(§2 全篇)与仓库无关,两边通用。
|
||||
> 状态:**方案待评审**,未动任何代码。第 13 节是全部定案与决策链(原列的 10 个待拍板项已全部澄清)。
|
||||
|
||||
---
|
||||
|
||||
@@ -11,12 +15,12 @@
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 复用什么 | 离线预生成回合池 + 倍率桶配额控 RTP + 服务端挑回合 + 客户端逐帧续播 + 按箱×身份奖池水位 + 开局即结算 |
|
||||
| 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **12 个箱子**(只按投注档分,档位见 §2.8.1)、无模式倍率、无 `need_fs` 过滤 |
|
||||
| 玩法范围 | **只有一个「普通旋转」模式**(用户明确不做底注、不做购买免费旋转)→ **11 个箱子**(只按投注档分,档位见 §2.8.2)、无模式倍率、无 `need_fs` 过滤 |
|
||||
| 必须重写什么 | ① 中奖判定(1024 路**前缀链**:必须从第 1 轴起 1→2→3→4→5 连续,中间断链即整笔作废,替代 8+ 聚簇)② 盘面几何(5×4=20 格替代 5×6=30 格)③ 乘数介质(阶梯倍率替代炸弹球)④ **级联的消除规则**(金色中奖格**不清空**,就地留一个百搭占位) |
|
||||
| 发奖形态 | **与玫瑰女郎完全一致**:游戏内**显示现金**(「共赢得 X.XX」),但**最终赢额转成一个饰品发到背包**(`settleAtStart` 按赢额落区间查饰品 → 写 `tt_box_records`),玩家在末帧选择「放入背包 / 立即分解」。→ 所以**不做**"开户即加余额"那条近路,`prize_ornament` / `prize_range` 两张表都要 |
|
||||
| 新增资产 | `mahjong-py/`(生成器)+ `game-mahjong-service`(Java 模块)+ **11 张** `game_mahjong_*` 表 + 7 个后台页(含奖品管理)+ `docs/麻将胡了接口文档.md` + Cocos 面板 |
|
||||
| 工作量 | 后端(py + Java + 后台 + 文档)≈ **20~26** 人日;客户端 ≈ 15~20 人日;联调/RTP 验收 ≈ 5 人日 |
|
||||
| 最大风险 | ① 1024 路天然高方差 → 靠"桶配额 + 收敛阶段 + 封顶"压;② **没有"买 FS"兜底后,100x 以上的高倍桶只能靠 normal 池自己构造**;③ py/Java 两份 `calcRounds` 历史性不同步;④ 赔付计量单位(=总额÷20)若在规则页没说清,玩家会按"倍总额"理解而产生对账投诉 |
|
||||
| 最大风险 | ① 1024 路天然高方差 → 靠"桶配额 + 收敛阶段 + 封顶"压;② **没有"买 FS"兜底后,100x 以上的高倍桶只能靠 normal 池自己构造**;③ py/Java 两份 `calcRounds` 历史性不同步;④ **`prize_range` 只管"发哪件饰品"、不是 RTP 旋钮**(12 件/12 段已导入且连续无缝、上界 99,999,999,见 §3.8;饰品价格 = 本局赢额,空转兜底只有 ¥0.01)—— RTP 全部在桶配额里,别再往饰品价格上找旋钮 |
|
||||
|
||||
---
|
||||
|
||||
@@ -28,12 +32,12 @@
|
||||
|---|---|---|---|
|
||||
| 1 | **服务端权威**:客户端不能决定任何结果 | 全部结果由后端挑出的回合决定 | 一致 |
|
||||
| 2 | **离线预生成回合池**:Python 造 `round`+`step` 落库,Java 只挑不算 | `olympus-py/generate_rounds.py` 写 11.7 万回合 / 106 万步 | `mahjong-py/generate_rounds.py`,同结构 |
|
||||
| 3 | **桶(倍率档)定义赔付区间**,`expect_mul` = 区间中点,区间连续无缝 | `game_olympus_ornament` 17 档 0x~1000x | `game_mahjong_bucket` 10 档(见 §3.1) |
|
||||
| 4 | **桶配额 = RTP 旋钮**(不是概率硬编码) | `game_olympus_box_ornament.count`,4 身份各一套 | `game_mahjong_box_bucket`,同构 |
|
||||
| 3 | **桶(倍率档)定义赔付区间**,`expect_mul` = 区间中点,区间连续无缝 | `game_olympus_ornament` 17 档 0x~1000x | `game_mahjong_bucket` 16 档(见 §3.1) |
|
||||
| 4 | **桶配额 = RTP 旋钮**(不是概率硬编码) | `game_olympus_box_ornament.count`,4 身份各一套 | `game_mahjong_box_bucket`,同构(**3 身份**,见 §3.6) |
|
||||
| 5 | **两阶段抽取**:固定阶段按 `remaining` 加权消耗库存;收敛阶段按 `desiredNextMul` 逼近目标且不消耗 | `OlympusRoundPicker.pick():44-100` | 同逻辑,`bucket` 替 `ornament` |
|
||||
| 6 | **按箱 × 身份奖池水位**:`payoutCap = max(0, 水位+本局成本) × rtp`;白号 `payoutCap=null` | `OlympusRewardPoolCacheRepository:80-84` | 沿用(水位 = Σ注册成本 − Σ派彩现金 + adjust) |
|
||||
| 7 | **身份分桶** common/anchor/robot/white,白号独立库存不进真实经济 | `OlympusAudience` | 直接复用(建议上移到共享模块) |
|
||||
| 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致,但结算动作从"写背包"改为"加余额" |
|
||||
| 7 | **身份分桶** common/anchor/white(**3 个**;原 robot 维度已按用户要求去掉,`user_type='03'` 归 common),白号独立库存不进真实经济 | `OlympusAudience`(它那份是 4 个) | 本游戏自带 3 个身份的判定,见 §3.6 |
|
||||
| 8 | **开局即结算 + 幂等守卫**:`settled 0→1` 的 UPDATE 守卫;`settle_source=start→finish` | `OlympusGameService.settleAtStart():279-306` | 一致(结算动作就是"写背包":赢额落 `prize_range` 区间匹配饰品,见 §6.3) |
|
||||
| 9 | **客户端只续播**:`dospin` 带 `boxId` = 开新局;不带 = 下一步;`state=FINISH` 才展示结算 | `OlympusGameService.continueGame():230-254` | 一致 |
|
||||
| 10 | **Redis 单局缓存 30 分钟 + `hasActive` 重连**,重连要能接回累计量 | `ruoyi:olympus:{userId}` TTLL 30min | `ruoyi:mahjong:{userId}`;接回量 = 本旋波次/本局累计 |
|
||||
| 11 | **一轮 cycle**:`N = 固定池/(1−收敛比)`,抽满 `resetCycle + refillFromConfig`(模板) | `OlympusCycleRepository` + `box_template` | 一致 |
|
||||
@@ -41,7 +45,7 @@
|
||||
|
||||
**结论:12 条全部照抄,一条都不改。**(用户 2026-09-21 定案:麻将胡了的赢额也**要变成饰品发到背包**,所以"赢额 → 落区间 → 查饰品 → 写背包 → 末帧选择放入/分解"这条链路与玫瑰女郎一模一样,连 `settleAtStart` 的 `max(totalWin, 0.01)` 兜底都能照抄。)
|
||||
|
||||
**唯一的表述差异**:规则页写着「所有赢奖以现金显示」——这是**展示口径**(游戏内「共赢得 X.XX」显示的是钱),**发放口径**仍是饰品。玫瑰女郎也是这么做的(它的 `settledWin` 就是"本局价值 = 饰品价格",前端同样显示金额)。这句话不是"直付余额"的意思。
|
||||
**唯一的表述差异**:规则页写着「所有赢奖以现金显示」——这是**展示口径**(游戏内「共赢得 X.XX」显示的是钱),**发放口径**仍是饰品。玫瑰女郎也是这么做的(它的 `settledWin` 就是"本局价值",而背包记录里的价格字段 `ornaments_price` 存的就是这个赢额——前端同样显示金额)。这句话不是"直付余额"的意思。
|
||||
|
||||
---
|
||||
|
||||
@@ -255,7 +259,7 @@
|
||||
| ⑥ 能和谁一起消 | 百搭**和普通符号完全同权**:它参与的那条前缀链里可以是普通符号,**也可以是金色符号**;金色符号本来就是普通符号镀金,所以**不需要任何特殊逻辑** |
|
||||
| ⑦ **不跨旋** | 下一旋**整盘重发**,百搭与金色标记都不带走;**每个旋开局时盘面上 0 个百搭** |
|
||||
|
||||
> 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且全盘限量 1 张、名额会在金色格被消除时补上。
|
||||
> 换句话说:**百搭就是一张和普通符号完全同权的牌**,只不过它"什么都能当",而且**不限张数**——每个金色格被消除时各就地留一个(见 §2.2 铁律)。
|
||||
> 实现上**不要给"百搭 + 金色"加任何特例**——金色符号在链判定里就是它自己的自然符号,和"發 + 百搭"没有区别。
|
||||
- **胡(夺宝/SCATTER)**:不参与 1024 路(**既不计入链,也不被百搭替代**);出现在任意轴任意位置;**自身不赔现金**(截图规则只给了免费旋转,没有 scatter 现金赔付),因此 `scatterTrigger.scatterWin` 恒为 0。
|
||||
- 3 个及以上胡 → 触发免费旋转:**12 旋 + 每多 1 个额外 +2 旋**(3→12 / 4→14 / 5→16 / 6→18)。免费旋转内再 3+ 胡 → 重触发,同样 12+2n。
|
||||
@@ -280,8 +284,8 @@
|
||||
→ ★ 这些格子**全部"被消除"**(客户端全部播爆裂动画,含金色格)。
|
||||
"消除"不等于"清空":消除之后该格才分成两类——
|
||||
2. 非金色的中奖格 → 置为**空洞**,参与重力下落 + 顶部补新符号
|
||||
3. 金色的中奖格 → 该符号被消耗;**若盘面上当前没有百搭,取其中 1 个就地写入 W**
|
||||
(这个格子**不是空洞**、不补新符号;同波其余金色格按第 2 类处理,照常清空)
|
||||
3. 金色的中奖格 → 该符号被消耗;**每个金色格各自就地写入 W**(无数量上限)
|
||||
(这些格子**不是空洞**、不补新符号;同波每个金色格都按这一步处理)
|
||||
4. 重力下落 + 顶部补位(这一步里 W 已经是普通盘面符号,见下方"锚定问题")
|
||||
5. 生成第 n 波的帧:grid_state 已经是转换后的盘面,并带 wild_converted=[pos,...] 供客户端播"金色→百搭"动画
|
||||
第 n+1 波:
|
||||
@@ -303,8 +307,8 @@
|
||||
- 百搭**上方**的格被清空 → 百搭留在原地不动,上方的牌落到它头上。
|
||||
- 如果把它做成"钉在空中",**它下方被清空的格就落不进任何符号**(上方的牌被它挡住),会出现悬空空洞、客户端与服务端对不上账 —— 所以**不能钉**。
|
||||
- 客户端要注意:**百搭会移动位置**,下落动画必须用帧里的 `dropData`(`from → to`)驱动,**不能假设它原地不动**;"金色→百搭"的变形动画(`wildConverted`)播完之后,紧接着就可能是它的下落动画。
|
||||
- ⚠ **"涉及中奖"的判定**:金色格被计入某笔中奖的 `ways`(无论它是作为该符号的自然符号,还是被当作其它符号的百搭替代计入)都算"涉及"。**金色格只做自己那一种符号的替代**——它显示为金色「中」时,对「中」是自然符号、对其它符号是百搭替代。
|
||||
> 注意:由于最多只有 1 个百搭,且金色格本身是自然符号,**"金色格被别人当百搭替代"的情况只可能由盘面上那个唯一的百搭产生**,不会成群出现。
|
||||
- ⚠ **"涉及中奖"的判定**:金色格只要被计入某笔中奖的 `ways` 就算"涉及中奖"(于是该格被消除、就地转成 `W`)。⚠️ **金色格只当它自己的自然符号用,不做别的符号的替代**——金色「中」只算「中」;"顶替别人"这件事**只有元宝 `W` 会做**(`W` 既能顶替金色格的符号,也能顶替任何普通符号)。这是 §2.2 铁律 + §15 第 1 波算例共同确定的口径:**若金色格同时还能当百搭,§15 第 1 波白板链的 `ways` 就会从 2 变成 4,全盘数学都会变。**
|
||||
> 注意:盘面上可以有**多个**百搭(每个金色格各留一个),且**同一个 `W` 可以被多个符号同时借用**(每条链各算各的,见 §15 第 4 波:一个 `W` 同时支撑筒与索红绿两条 5 连)——判定时逐个百搭独立参与每条链,既不限量、也不去重。
|
||||
|
||||
**其余约定**:
|
||||
|
||||
@@ -350,7 +354,7 @@
|
||||
| × 本波阶梯倍率 | 基础旋 `1/2/3/5`;FS 旋 `2/4/6/10` |
|
||||
| = 本波赢额 | `本笔基础倍率 × 投注总额 × rotationMul` |
|
||||
|
||||
> 由此可推出**本局最小中奖**(基础旋第 1 波、最低档符号、1 路):`0.1 × 1 × 投注额 × 1 = 投注额 × 0.1` —— 这条直接约束了下注档下限(见 §2.8.2)。
|
||||
> 由此可推出**本局最小中奖**(基础旋第 1 波、最低档符号、1 路):`0.1 × 1 × 投注额 × 1 = 投注额 × 0.1` —— 这条直接约束了下注档下限(见 §2.8.3)。
|
||||
|
||||
> 实现提示(来自 olympus 经验):Olympus 的"炸弹累计器"(`accumulator_before/after`,FS 内跨旋传递)机制**原封不动复用**——麻将胡了虽然乘数不跨旋累计,但"本波阶梯倍率 / 本波序号"需要每帧下发,字段名可直接沿用 `accumulator`(语义改为"本波阶梯倍率")以免客户端大改。**建议改名为 `rotationMul`**,避免误解。
|
||||
|
||||
@@ -427,27 +431,27 @@
|
||||
|
||||
原版:投注大小 0.02~50.00 × 投注倍数 1~10,另有现金钱包与自动旋转。
|
||||
|
||||
**方案(推荐)**:`箱子 = 投注档`,**不设投注倍数**(见 §2.8.1):
|
||||
**方案(推荐)**:`箱子 = 投注档`,**不设投注倍数**(见 §2.8.2):
|
||||
- `price(实际扣费) = box.bet`(本游戏 costMultiplier ≡ 1)
|
||||
- **回合池里存的全是倍率**(`actual_mul`),与投注额无关 → **改档位不需要重生成池子**(这正是玫瑰女郎能做到"改配比不动池子"的根因)。
|
||||
- 奖池水位按 `(boxId, audience)` 聚合,投注额只影响单局金额,不影响池结构。
|
||||
|
||||
**曾经的备选(已否)**:给"档位 × 倍数"每个组合建一个箱子 → 池子碎片化、水位口径混乱。
|
||||
|
||||
#### 2.8.1 设计多少个下注额、哪几个、能多大?
|
||||
#### 2.8.1 该设计多少个下注额、哪几个、能多大?(先把成本分清)
|
||||
|
||||
**先分清两件事,因为成本差一个数量级:**
|
||||
|
||||
| 加什么 | 池子要不要动 | 单局理论最大赔付 | 成本 |
|
||||
|---|---|---|---|
|
||||
| **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 线性上涨(`500x × 新档位`) | 贵(生成时间 + 40 条配比 + 一套水位) |
|
||||
| **加档位**(多一个箱子) | **要**:每档一个独立回合池 ~4500 回合 / 8 万帧 | 线性上涨(`500x × 新档位`) | 贵(生成时间 + 48 条配比〔16 桶 × 3 身份〕+ 一套水位) |
|
||||
| **加 betMul 上限** | **不用**:池子只存倍率(`actual_mul`),与下注额无关 | **同箱内直接放大 10 倍** | 几乎为零 |
|
||||
|
||||
> ⚠️ **关键认识**:`betMul` 不是"免费做大",它**把同一箱内的金额跨度拉到 10 倍,等价于偷偷多开了 9 个档位**,却只用一个池子——风控口径和水位观察口径都会变粗。
|
||||
> 而且在 500x 封顶下,`betMul` 会把**理论单局最大赔付**直接乘以 10。
|
||||
> 所以本次**不再推荐"档位 × 倍数"双控件**,改为**只保留投注档**(与玫瑰女郎完全一致)。
|
||||
|
||||
#### 2.8.1 推荐方案(11 档,只保留投注档)
|
||||
#### 2.8.2 推荐方案(11 档,只保留投注档)
|
||||
|
||||
**结论:`1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`,共 11 档;不设投注倍数。**
|
||||
|
||||
@@ -468,7 +472,7 @@
|
||||
| **上限(风控)** | 单局理论最大赔付 = **500x × 500 = ¥250,000**;玫瑰女郎现状 = 1000x × 300 = **¥300,000** | ✅ **压在玫瑰女郎之下** |
|
||||
| **档位取整** | 档位都是整数 → 任何中奖金额都是整分 | ✅ 不会出现除不尽的数 |
|
||||
| **前 10 档** | 与玫瑰女郎**一字不差** | ✅ 玩家熟悉、报表口径统一 |
|
||||
| **规模** | 11 箱 × ~4500 = **5 万回合 / ~90 万帧** / 生成 20~30 分钟 / 704 条配比行 | ✅ 与玫瑰女郎同量级(它 30 箱 / 106 万帧) |
|
||||
| **规模** | 11 箱 × ~4500 = **5 万回合 / ~90 万帧** / 生成 15~25 分钟 / 528 条配比行 | ✅ 与玫瑰女郎同量级(它 30 箱 / 106 万帧) |
|
||||
|
||||
**为什么不设投注倍数**(与玫瑰女郎保持一致的做法)
|
||||
|
||||
@@ -485,28 +489,28 @@
|
||||
| 到 ¥1000 **且守住风控** | 加 ¥1000 档的同时把封顶降到 **300x** | 300x × 1000 = **¥300,000** ✅ 正好对齐玫瑰女郎 |
|
||||
| 到 ¥2000 | 加 ¥2000 档 + 封顶 150x | ¥300,000 ✅(但 150x 封顶会让桶表整体缩一档,要重配池子) |
|
||||
|
||||
> 每加一档的成本:+4500 回合 / +8 万帧 / +52 条配比行 / 多一套水位要运营盯。**建议先上 11 档,看实际流水再决定要不要开高档**。
|
||||
> 每加一档的成本:+4500 回合 / +8 万帧 / +48 条配比行(16 桶 × 3 身份)/ 多一套水位要运营盯。**建议先上 11 档,看实际流水再决定要不要开高档**。
|
||||
|
||||
**已否掉的方案**
|
||||
|
||||
- ❌ 原版双控件(档位 1~300 × 倍数 1~10):顶档 ¥3000 一注、单局理论最大赔付 **¥1,500,000**(玫瑰女郎的 5 倍),且同箱金额跨度 10 倍。
|
||||
- ❌ 12 档到 ¥1000 + 倍数 1~10(最高 ¥10,000 一注):单局理论最大赔付 **¥5,000,000**,是玫瑰女郎的 16 倍,风控口径直接破。
|
||||
- ❌ 直接抄原版 0.02~50.00:那是社娱场面额,本平台是金币场;而且原版 0.02 是"每条基础投注"的金额(见 §2.8.2),当成总额抄过来会让最低中奖显示成 +0.00。
|
||||
2. **冷门高档位会水位饥饿**:¥500 / ¥1000 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放 10 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。
|
||||
- ❌ 直接抄原版 0.02~50.00:那是社娱场面额,本平台是金币场;而且原版 0.02 是"每条基础投注"的金额(见 §2.8.3),当成总额抄过来会让最低中奖显示成 +0.00。
|
||||
- **冷门高档位会水位饥饿**:¥300 / ¥500 这两档玩家天然少,水位长期接近 0 → `payoutCap` 把它们压到出不了奖。**上线时必须给这两档单独注一笔初始水位**,或干脆先只放前 9 档、等有需求再开高档(**加档不需要改客户端,只需加箱 + 生成池子**)。
|
||||
|
||||
**为什么不是更多档**
|
||||
|
||||
1. **每多一档 = 多一整套成本**(这才是决定档位数量的真正约束):
|
||||
- 多一个**回合池**:约 4500 回合 / 8 万帧,池子必须按箱独立生成;
|
||||
- 多 64 条**配比行**(16 桶 × 4 身份)要配、要重算、要维护模板;
|
||||
- 多 48 条**配比行**(16 桶 × 3 身份)要配、要重算、要维护模板;
|
||||
- 多一套**奖池水位**(4 个身份各一条)要运营盯。
|
||||
10 档 → 4.5 万回合 / 80 万帧 / 生成 15~25 分钟 / 400 条配比行,正好是玫瑰女郎已经在跑的量级。
|
||||
11 档 → 5 万回合 / 90 万帧 / 生成 15~25 分钟 / 528 条配比行,正好是玫瑰女郎已经在跑的量级。
|
||||
2. **档位越多,冷门档越容易"饿死"**:奖池水位是**按箱独立**的,没人玩的档水位长期为 0 → `payoutCap = max(0, 水位+成本) × rtp` 被压到接近 0 → **那个档几乎出不了奖**,只能靠运营注水救。所以档位不是越多越好。
|
||||
3. **玩家侧足够**:1~500 共 11 档,比原版 0.02~50 的覆盖面更宽;相邻档位间距 2~3 倍,选注手感是行业常规。
|
||||
4. **对齐玫瑰女郎**:前 10 档与玫瑰女郎一字不差,玩家已熟悉,运营报表/返利阈值/消费排行榜口径统一。
|
||||
|
||||
**不推荐的做法**
|
||||
- ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场、玫瑰女郎最低档是 ¥1;**而且原版的 0.02 是"每条基础投注"的金额**(见 §2.8.2 的说明),当成总额抄过来会让最低中奖显示成 +0.00。
|
||||
- ❌ 直接抄原版的 0.02~50.00:那是社娱场(social casino)面额,本平台是金币场、玫瑰女郎最低档是 ¥1;**而且原版的 0.02 是"每条基础投注"的金额**(见 §2.8.3 的说明),当成总额抄过来会让最低中奖显示成 +0.00。
|
||||
- ❌ 为了"档位够密"铺 20~30 档:池子生成与后台维护成本线性上涨,且冷门档水位饥饿问题成倍放大。
|
||||
- ❌ 一次把最高档拉到 ¥5000 / ¥10000:单局最大赔付会到千万级,且这两档几乎没人玩、水位必饿死。
|
||||
|
||||
@@ -516,7 +520,7 @@
|
||||
|
||||
> ⚠️ **上线前必须做的一件事**:新游戏的奖池水位从 0 开始,而 `payoutCap = max(0, 水位 + 本局成本) × rtp` —— 意味着**开局头几局玩家最多只能赢 0.8 倍下注**,大奖要等水位被玩起来才出得来。所以**上线前要按箱 × 身份人工注水**(后台奖池水位页,原因必填),否则前几天的"爆奖概率"会低到不像这个游戏。玫瑰女郎也是这么运营的。
|
||||
|
||||
#### 2.8.2 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住
|
||||
#### 2.8.3 下注档不是随便定的:它被**赔付表**和**饰品区间表**两头夹住
|
||||
|
||||
用户问"是不是要根据赔付表来设计下注额"——**是,而且还有一个来自饰品表的约束**。这两条是硬约束,不是审美偏好。
|
||||
|
||||
@@ -536,36 +540,34 @@
|
||||
→ **`box.bet ≥ 0.10` 是硬下限**(我们的模型里 `box.bet` = **投注总额**)。
|
||||
|
||||
> ⚠️ **关于原版"投注大小 0.02~50.00"的理解**:规则页有一条「一条中奖路所得的奖励等同于赔付表上的赔付值**乘以投注大小和投注倍数**」。
|
||||
> 这条要成立,只有一种读法:**原版的「投注大小」= 每条基础投注的金额**(即 `投注总额 = 投注大小 × 20 × 投注倍数`)。
|
||||
> 按这个读法,原版最低档 0.02 的**最小中奖 = 2 × 0.02 = ¥0.04**,**不会丢分**。
|
||||
> 而我们的模型里 `box.bet` 是**投注总额**,所以下限是 `0.10`;单纯把原版的 `0.02` 抄过来当**总额**才会丢分(0.002 显示成 +0.00)。
|
||||
> **待确认(一个 30 秒的真机测试)**:把原版「投注大小」设成 **1.00**、「投注倍数」设成 **1**,看「投注总额」显示 **20.00** 还是 **1.00**——
|
||||
> 显示 **20.00** ⇒ 投注大小 = 每条基础投注(上面那种读法,规则页第 1 条成立);显示 **1.00** ⇒ 投注大小 = 总额(那第 1 条就该改写成"乘以投注总额后除以 20")。
|
||||
> **已确认(用户口径)**:原版的「投注大小」**就是投注总额**——设成 1.00 就显示 1.00,不 ×20。所以那条规则里的"点数"必须按**"游戏规定了 20 条基础投注"折算**,实际算式是 `点数 × ways × (投注总额 ÷ 20) × 倍数`。
|
||||
> 由此得到正确结论:原版最低档 `0.02` 的**最小中奖 = 2 × (0.02 ÷ 20) = ¥0.002** —— 那正是"社娱场面额"下会显示成 `+0.00` 的原因,也再次说明**原版的档位不能直接抄成本平台的总额**。
|
||||
> 我们的口径不变:`box.bet` 是**投注总额**、赔付表已把 `÷20` 内化成倍率、11 档从 ¥1 起(最小中奖 ¥0.10)。
|
||||
|
||||
**附带的一个好性质**:**赔付表里的倍率都是 0.05 的整数倍**(0.1 / 0.2 / 0.25 / 0.3 / 0.4 / 0.5 / 0.75 / 1 / 2 / 3 / 4 / 5),而投注档又都是整数金额,所以**任何一笔中奖金额都是整分**,不会出现 0.0333 这种除不尽的数。建议投注档表**只放整数**,把这条性质保住。
|
||||
|
||||
**约束二:来自饰品区间表 —— 最低档饰品的价格决定"每局保底返奖"**
|
||||
**约束二:来自饰品区间表 —— 它只决定"发哪件饰品",不决定赔多少钱**
|
||||
|
||||
"必得饰品"意味着**空转局也会发一个最低档饰品**(`win = max(totalWin, 0.01)` 兜底),而这个饰品的价格就是那一局的返奖。空转率按基础旋 30%~40% 估:
|
||||
⚠️ **勘误(2026-09-22,按母本代码核实)**:本节原来写"最低档饰品的价格决定每局保底返奖",**是错前提**。真实模型:
|
||||
|
||||
```
|
||||
每局保底返奖 ≈ 空转率 × 最低档饰品价格
|
||||
低档位下注的 RTP 下限 ≈ 空转率 × 最低档饰品价格 ÷ 投注总额
|
||||
```
|
||||
- olympus 写背包用的是 `.ornamentsPrice(win)` —— **饰品的"价格"记的就是本局赢额**;
|
||||
- 平台分解入账是 `Σ TtBoxRecords.ornamentsPrice`(`service-user/.../ApiUserPackSackServiceImpl.settleDecomposeBySource`)——**玩家最终拿到的钱就是这个数**;
|
||||
- 玫瑰女郎线上 dump 里 1 号饰品(安慰奖)的注释也写着「兜底饰品 1是(**仅展示不覆盖金额**)」。
|
||||
|
||||
| 最低档饰品价 | 空转率 | 投注 ¥1 时的保底 RTP |
|
||||
⇒ **每局实付 = `record.settled_win = win`**(空转局 = `max(totalWin, 0.01)` = **¥0.01**),**饰品只是载体**。所以"必得饰品"的真实保底成本:
|
||||
|
||||
| 兜底金额 | 空转率 | 投注 ¥1 时的保底 RTP |
|
||||
|---|---|---|
|
||||
| ¥0.01 | 40% | 0.4% ✅ |
|
||||
| ¥0.05 | 40% | 2% ✅ |
|
||||
| ¥0.50 | 40% | **20%** ⚠️ 光保底就吃掉 1/4 的目标 RTP |
|
||||
| **¥0.01(当前实际值)** | 65% | **0.65%** ✅ 可忽略 |
|
||||
| (假设哪天把兜底抬到 ¥0.50) | 65% | 32.5% ⚠️ 那才需要担心 |
|
||||
|
||||
→ **最低档饰品的返奖区间下限要压在 ¥0.01~0.05 量级**;饰品区间表(`prize_range`)在导入/配置时要和最低下注档一起检查,别直接搬一套为"高档位"设计的区间表过来。
|
||||
→ 对账口径也要注意:水位里的"派彩"应当用 **`record.settled_win`(饰品价)**,而不是 `total_win`(游戏内赢额)——否则这两者对不上时,水位会漂。
|
||||
→ **`prize_range` 不是 RTP 旋钮**:区间有空洞或没覆盖,只会让那一档走 `findPrizeWithFallback` **换一件饰品发**,**金额不变**(`settled_win` 照旧)。
|
||||
→ 对账口径:水位里的"派彩"用 **`record.settled_win`**(= 本局 win = 背包记录里的 `ornaments_price`)。
|
||||
|
||||
**结论**:下注档表要同时满足
|
||||
**结论**:下注档表要满足
|
||||
1. `bet ≥ 0.10`(赔付表约束,实际取 ¥1 起)
|
||||
2. 档位取整数(保证 unit 是整分)
|
||||
3. 最低档饰品价 ≤ ¥0.05 量级(饰品表约束)
|
||||
3. ~~最低档饰品价 ≤ ¥0.05 量级~~ **(作废:饰品价格 = 赢额,不是成本项)** —— 真正要盯的是**空转兜底金额** `max(totalWin, 0.01)` 这个常量,目前 ¥0.01、可忽略
|
||||
|
||||
模式:**只有一个「普通旋转」**(用户 2026-09-21 明确:底注、购买免费旋转都不做)。
|
||||
|
||||
@@ -576,7 +578,7 @@
|
||||
> 砍掉 `ante` / `buy_fs` 带来的连锁简化(都要落地,别留半套):
|
||||
> - `MahjongModeType` 只留 `normal` 一个枚举值,`costMultiplier` 恒为 1,`price = box.bet`;
|
||||
> - **没有 `need_fs` 过滤**了(那是 `buy_fs` 专用),挑回合只按桶;
|
||||
> - 箱数从 30 降到 **10**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
|
||||
> - 箱数从 30 降到 **11**(只按投注档分箱)→ 池子规模、生成时间、后台配置量全部降到 1/3;
|
||||
> - 高倍桶(100x 以上)**只能靠自然触发 FS 的回合供给**,没有"买 FS"那批高倍回合兜底 → 生成端要用 bias 主动**构造**高倍局,不能靠碰运气(见 §5.4 风险)。
|
||||
|
||||
---
|
||||
@@ -642,11 +644,12 @@
|
||||
| 1 | 白板 3 连 | 0.4 | 2 | 0.80 | x1 | ↑ 并入上行 | ↑ | ↑ |
|
||||
| 2 | 八萬 4 连 | 0.75 | 4 | 3.00 | x2 | 3.00 | 6.00 | ¥600 |
|
||||
| 3 | 花筒 5 连 | 1 | 1 | 1.00 | x3 | 1.00 | 3.00 | ¥300 |
|
||||
| 4 | 筒 5 连 | 0.5 | 2 | 1.00 | x5 | **2.00** | 10.00 | ¥1000 |
|
||||
| 4 | 筒 5 连 | 0.5 | 2 | 1.00 | x5 | **2.40** | 12.00 | ¥1200 |
|
||||
| 4 | 索红绿 5 连 | 1 | 1 | 1.00 | x5 | ↑ 并入上行 | ↑ | ↑ |
|
||||
| 4 | 白板 3 连 | 0.4 | 1 | 0.40 | x5 | ↑ 并入上行 | ↑ | ↑ |
|
||||
|
||||
- **本局总倍率 = 2.80 + 6.00 + 3.00 + 10.00 = 21.80 倍**(未触 500x 封顶)
|
||||
- **本局赢额 = 21.80 × ¥100 = ¥2180** ✓(与 §15 总账一致)
|
||||
- **本局总倍率 = 2.80 + 6.00 + 3.00 + 12.00 = 23.80 倍**(未触 500x 封顶)
|
||||
- **本局赢额 = 23.80 × ¥100 = ¥2380** ✓(与 §15 总账一致)
|
||||
- 全程只有 **1 个胡** → 若满 3 个胡就会先走 FS(FS 内阶梯换成 `2/4/6/10`,其余公式不变)
|
||||
|
||||
---
|
||||
@@ -763,38 +766,42 @@
|
||||
**直接沿用玫瑰女郎那张 17 档桶表,只去掉最高那档**(它到 1000x,我们封顶 500x,1000x 那档永远不会被命中)。
|
||||
这样后台页面、配比算法、收敛逻辑、运营经验**全部零改动**;与玫瑰女郎的差异只有顶桶一行。
|
||||
|
||||
物理表名 `game_mahjong_bucket`;**后台页显示名「饰品桶」**(对应玫瑰女郎的 `game_olympus_ornament`,见附录 A 映射)。
|
||||
物理表名 `game_mahjong_bucket`。**后台命名一律沿用现网玫瑰女郎,不自造新词**:
|
||||
- 菜单名 = **「饰品列表」**(与 rose 的 `game/olympus/ornament/index` 同款;别叫"倍率桶")
|
||||
- 箱子页里的动作按钮 = **「饰品配置」**(别叫"桶配比")、**「模板管理」**
|
||||
- 桶的 `name` 值也照母本:`0x / 0.5x / 1.5x / 2.5x / 4x / 6.5x / 10x / 16x / 25x / 40x / 60x / 100x / 150x / 200x / 300x / 500x`
|
||||
- 箱子 `name` = **下注额本身**(`1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`):本游戏**只有一个模式**,所以**不加** `{模式名}-bet` 前缀(母本有前缀是因为它有 normal/ante/buy_fs 三种模式)——2026-09-22 用户定;别叫"¥1 档"
|
||||
|
||||
| 桶ID | 名称 | 期望倍率 | 落桶下限 | 落桶上限 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 0倍饰品 | 0 | 0 | 0 |
|
||||
| 2 | 0.5倍饰品 | 0.5 | 0.01 | 1 |
|
||||
| 3 | 1.5倍饰品 | 1.5 | 1 | 2 |
|
||||
| 4 | 2.5倍饰品 | 2.5 | 2 | 3 |
|
||||
| 5 | 4倍饰品 | 4 | 3 | 5 |
|
||||
| 6 | 6.5倍饰品 | 6.5 | 5 | 8 |
|
||||
| 7 | 10倍饰品 | 10 | 8 | 12 |
|
||||
| 8 | 16倍饰品 | 16 | 12 | 20 |
|
||||
| 9 | 25倍饰品 | 25 | 20 | 30 |
|
||||
| 10 | 40倍饰品 | 40 | 30 | 50 |
|
||||
| 11 | 60倍饰品 | 60 | 50 | 70 |
|
||||
| 12 | 100倍饰品 | 100 | 70 | 130 |
|
||||
| 13 | 150倍饰品 | 150 | 130 | 170 |
|
||||
| 14 | 200倍饰品 | 200 | 170 | 230 |
|
||||
| 15 | 300倍饰品 | 300 | 230 | 370 |
|
||||
| **16** | **500倍饰品(封顶)** | **500** | 370 | **700** |
|
||||
| 1 | 0x | 0 | 0 | 0 |
|
||||
| 2 | 0.5x | 0.5 | 0.01 | 1 |
|
||||
| 3 | 1.5x | 1.5 | 1 | 2 |
|
||||
| 4 | 2.5x | 2.5 | 2 | 3 |
|
||||
| 5 | 4x | 4 | 3 | 5 |
|
||||
| 6 | 6.5x | 6.5 | 5 | 8 |
|
||||
| 7 | 10x | 10 | 8 | 12 |
|
||||
| 8 | 16x | 16 | 12 | 20 |
|
||||
| 9 | 25x | 25 | 20 | 30 |
|
||||
| 10 | 40x | 40 | 30 | 50 |
|
||||
| 11 | 60x | 60 | 50 | 70 |
|
||||
| 12 | 100x | 100 | 70 | 130 |
|
||||
| 13 | 150x | 150 | 130 | 170 |
|
||||
| 14 | 200x | 200 | 170 | 230 |
|
||||
| 15 | 300x | 300 | 230 | 370 |
|
||||
| **16** | **500x(封顶)** | **500** | 370 | **700** |
|
||||
|
||||
约定(与玫瑰女郎一致):
|
||||
- **期望倍率 = (落桶下限 + 落桶上限) / 2**;区间**连续无缝、覆盖 0~封顶**。
|
||||
- **桶 1 = 空转**(本局没有任何 1024 路中奖)。注意:**空转局照样发最低档饰品**("必得饰品",见 §6.3),所以桶 1 也是要配库存的。
|
||||
- **桶 16 = 封顶档**:区间上界**故意开到 700(1.4× 封顶)**,这样"本来该赔 600x 的局"也落进这一档;**实际发放一律截断到 500x**(对齐玫瑰女郎"匹配区间 ≠ 发放上限"的写法)。
|
||||
- **桶 16 = 封顶档**:生成端**先按 500x 截断得 `actual_mul`、再落桶**(顺序不能反——先落桶再截断会让 500x 以上的局无桶可落);区间上界仍留 **700** 作缓冲,**实际发放一律 = 500x**(对齐玫瑰女郎"匹配区间 ≠ 发放上限"的写法)。
|
||||
- 因为 500x 封顶,桶 16 的池内实测均值会明显低于名义 500(被截断的局都堆在这里),配比计算按池内 `AVG(actual_mul)` 走,必要时用 `PAYOUT_EXPECT_OVERRIDE`(玫瑰女郎顶桶就是名义 1000、实用 835)。
|
||||
|
||||
**这张表**只管两件事:① 一局落在哪个倍率档(配比与库存的单位);② RTP 配平。
|
||||
**实际发哪一件饰品**是另一回事——由 `prize_range` **按金额(¥)匹配**,见 §3.8。
|
||||
|
||||
**配置量与规模**
|
||||
- `16 桶 × 11 箱 × 4 身份 = **704 条**` 配比行(玫瑰女郎是 `17 × 30 × 4 = 2040` 条)。
|
||||
- `16 桶 × 11 箱 × 3 身份 = **528 条**` 配比行(玫瑰女郎是 `17 × 30 × 4 = 2040` 条)。
|
||||
- 注册轴范围 / 生成器/后台页面清单里对应的桶数见 §4、§5、§8。
|
||||
|
||||
**可选优化(先不做,看实测再决定)**
|
||||
@@ -807,7 +814,7 @@
|
||||
| 2a | 0.2倍饰品 | 0.2 | 0.01 | 0.4 |
|
||||
| 2b | 0.7倍饰品 | 0.7 | 0.4 | 1 |
|
||||
|
||||
> 另外记一个数学性质:可达到的倍率是**离散**的,全是 **0.05 的整数倍**(0.10 / 0.20 / 0.25 / 0.30 / 0.40 / 0.50 / 0.75 / 1.00…)——因为 `赔付值 × ways × ladder ÷ 20` 里赔付值都是整数。所以任何桶边界都会自然对齐,不需要额外处理。
|
||||
> 另外记一个数学性质:可达到的倍率是**离散**的,全是 **0.05 的整数倍**(0.10 / 0.20 / 0.25 / 0.30 / 0.40 / 0.50 / 0.75 / 1.00…)——因为赔付表本身就是 0.05 的整数倍(0.1 / 0.25 / 0.5 / 0.75 …),`赔付表 × ways × ladder` 的结果自然落在 0.05 的网格上。所以任何桶边界都会自然对齐,不需要额外处理。
|
||||
|
||||
### 3.2 RTP 恒等式
|
||||
```
|
||||
@@ -846,11 +853,12 @@ fixedRounds= round(N × (1 - cr))
|
||||
### 3.5 奖池水位(与玫瑰女郎同口径)
|
||||
|
||||
```
|
||||
水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.total_win) + adjust
|
||||
水位(box, audience) = Σ(已结算 record.cost) − Σ(已结算 record.settled_win) + adjust
|
||||
payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = null
|
||||
```
|
||||
|
||||
- bet/win **以 DB 为真相**(`game_mahjong_record` 按身份聚合),Redis 只做 5 秒短缓存;`adjust` 是纯 Redis 的人工注水/抽水值。
|
||||
- **`record.audience` 是"开局时的身份快照",要落库**(`MahjongGameService` 在建 record 时写入):水位聚合用它、也可以像玫瑰女郎那样 join `tt_user` 现推 —— 身份没变时两者等价,**身份被改过(例如白号开关被关)时以快照为准**,所以快照必写。**派彩一律取 `record.settled_win`(= `max(total_win, 0.01)`,也是背包记录 `tt_box_records.ornaments_price` 存的那个数)**,而不是只认 `total_win` —— 玫瑰女郎 `OlympusRecordMapper.sumPool` 用的就是 `SUM(r.settled_win)`,取错会让水位单向漂移(见 §3.8)。
|
||||
- 收敛/固定两阶段都用 `payoutCap` 过滤"期望赔付超上限"的桶,捞不到就退最小桶。
|
||||
- 白号(`is_blank_account=1`)`payoutCap=null`:纯概率、不受水位约束、消耗自己的 white 库存,不进真实经济。
|
||||
|
||||
@@ -859,21 +867,28 @@ payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = nu
|
||||
各身份的 RTP **不在代码里写死**,和玫瑰女郎一样由后台配置,两层:
|
||||
|
||||
1. **箱子级**:`game_mahjong_box.rtp`(目标返奖率)+ `rtp_converge_ratio`(收敛比例)—— 在后台「箱子配置」页改;
|
||||
2. **身份级**:`game_mahjong_box_bucket` 里**每个身份一套桶配比**(`count`),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 桶配比」弹窗改,或用「一键重算」按目标 RTP 反算。
|
||||
2. **身份级**:`game_mahjong_box_bucket` 里**每个身份一套桶配比**(`count`),配比变了,该身份的实际 RTP 就变了 —— 在后台「箱子配置 → 饰品配置」弹窗改,或用「一键重算」按目标 RTP 反算。
|
||||
|
||||
所以"各身份 RTP 定多少"**不是一个需要在方案阶段拍板的数**,而是运营参数:先全部按 **0.80** 建库,上线后按身份在后台调(玫瑰女郎就是这么跑的)。
|
||||
|
||||
| audience | 判定 | 建库默认 RTP | 说明 |
|
||||
|---|---|---|---|
|
||||
| common | 其余全部 | 0.80 | |
|
||||
| anchor | `user_type='01'` | 0.80 | 四身份互斥并集 = 全部 |
|
||||
| robot | `user_type='03'` | 0.80 | |
|
||||
| common | 其余全部(**含 `user_type='03'` 机器人**) | 0.80 | |
|
||||
| anchor | `user_type='01'` | 0.80 | **三身份互斥并集 = 全部** |
|
||||
| white | `is_blank_account=1`(最高优先) | 0.80 | 白号不进奖池:`payoutCap=null`、不注水不抽水,**配比即爆率**,给主播用 |
|
||||
|
||||
> ⚠️ **2026-09-22 用户定:身份维度从 4 个降为 3 个 —— 去掉 robot。** 机器人不再单独成池,
|
||||
> `user_type='03'` 现在归入 `common`(会消耗 common 库存、计入 common 水位)。这条同时改了:
|
||||
> 配比行 `16 桶 × 11 箱 × 3 身份 = 528 条`(原 704)、模板 `11 × 3 = 33 条`(原 44)、
|
||||
> 后台奖池水位页 3 个 Tab(原 4 个)。Java 侧的 `MahjongAudience`(已删 `ROBOT`)、
|
||||
> `MahjongAdminController.AUDIENCES`、`audienceUserSubQuery`、`MahjongRecordMapper.sumPool`
|
||||
> 与前端 3 个页面的身份数组都已同步。
|
||||
> **连带影响(要知道)**:机器人的下注/派彩现在会进入 common 池,不再是"隔离池"。
|
||||
|
||||
### 3.7 免费旋转出现率与"悬念盘"(生成端必须显式约束)
|
||||
|
||||
- 唯一模式 `normal`:目标**自然触发率 ≈0.5%**(每 200 局 1 次),由 3+ 胡的落点概率控制。
|
||||
- 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 6~8 号桶。**生成器要保证 6~8 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。
|
||||
- 触发局整局倍率自然落在 30x~150x 区间 → 天然归入 10~13 号桶。**生成器要保证 10~13 号桶里存在足够比例的"含 FS 回合",且这些回合在箱内占比 ≈0.5%**,否则"自然触发免费旋转"会变成空话。
|
||||
- **悬念盘(near-miss)**:用户描述的"出现 2 个胡时最后一列一直有放大效果、很刺激"——这是**为了钓出第 3 个胡的期待感**。虽然表现是客户端动画(见 §9),但**盘面得先存在**:
|
||||
- 生成端要保证一定比例的**基础旋**恰好落 **2 个胡且第 5 轴无胡**(`NEARMISS_2SCATTER_RATE`,建议 **1%~2%** 的基础旋);
|
||||
- 玫瑰女郎有现成的同类配置可参照(`olympus-py/olympus/config.py:181` 的 `BASE_NEARMISS_SCATTER`);
|
||||
@@ -900,7 +915,9 @@ payoutCap = max(0, 水位 + 本局成本) × box.rtp # 白号 = nu
|
||||
|
||||
**② 饰品区间表怎么来(不要重新设计)**
|
||||
|
||||
照玫瑰女郎的做法(`docs/updata_sql.md:1187-1200`)**整套导入**:
|
||||
**已落地:直接沿用玫瑰女郎线上那套(用户 2026-09-22 提供的 `game_olympus_prize_ornament` dump)**,12 件饰品 + 12 段区间,`id` 原样保留(因为 `prize_range.prize_id` 引用它)、`game_type` 改成 `MAHJONG`,SQL 见 `mahjong-py/schema.sql` 末尾两段(写法是 `INSERT ... ON DUPLICATE KEY UPDATE`,幂等,**没有**用 dump 里的 `DROP TABLE`)。
|
||||
|
||||
若以后要换源(比如改成糖果那套),照下面这段整套导:
|
||||
|
||||
```sql
|
||||
DELETE FROM game_mahjong_prize_range;
|
||||
@@ -911,21 +928,70 @@ INSERT INTO game_mahjong_prize_range (prize_id, win_low, win_high)
|
||||
SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
|
||||
```
|
||||
|
||||
**导入后必须核的 3 条(都直接决定 RTP,不能跳过)**
|
||||
**导入后核的 3 条(**已按玫瑰女郎线上 dump 导入并逐条核过:12 件饰品 / 12 段区间**)**
|
||||
|
||||
| # | 检查项 | 为什么 | 判据 |
|
||||
|---|---|---|---|
|
||||
| 1 | **最低档饰品的价格** | "必得饰品"意味着**空转局也发最低档饰品**,它的价格就是每局的**保底返奖**:`保底 RTP ≈ 空转率 × 最低饰品价 ÷ 投注额`。空转率按 **65%** 估,¥1 档下若最低饰品价 **¥0.50** → **保底 RTP 32.5%**,直接把目标 80% 吃掉一半 | **最低饰品价 ≤ ¥0.05**(→ 保底 ≈3%) |
|
||||
| 2 | **区间上界要覆盖到 500x × 最高档 = ¥250,000** | 覆盖不到的话,高倍局全都走 `findPrizeWithFallback` 落到兜底饰品,出现"赢了 20 万给个 50 块的皮肤" | 最高区间下限 ≥ ¥250,000(玫瑰女郎是 1000x × 300 = ¥300,000,量级相同,大概率够) |
|
||||
| 3 | **区间连续无缝、无空洞** | 有空洞就会走 fallback(落到 `is_fallback=1` 或最低/最高区间),发奖与赢额不匹配 | 按 `win_low` 排序后逐条核 `high_i + 0.01 == low_{i+1}` |
|
||||
> ⚠️ **先纠一个前提**:这三条**都不影响 RTP**(饰品价格 = 本局赢额,见 §2.8.3)。它们只决定**"赢了这笔钱发哪件饰品"**(展示层)—— 没覆盖或有空洞时走 `findPrizeWithFallback` 换一件,**金额不变**。
|
||||
|
||||
> 本地 MySQL 没启动,我没法直连查现成的区间数据;**上线前请在测试库跑一次**:
|
||||
> ```sql
|
||||
> SELECT MIN(win_low) AS 最低区间下限, MAX(win_high) AS 最高区间上限,
|
||||
> COUNT(*) AS 条数 FROM game_olympus_prize_range; -- 玫瑰女郎现成的
|
||||
> SELECT o.name, o.grade, r.win_low, r.win_high FROM game_olympus_prize_range r
|
||||
> JOIN game_olympus_prize_ornament o ON o.id = r.prize_id ORDER BY r.win_low LIMIT 5;
|
||||
> ```
|
||||
| # | 检查项 | 为什么 | 判据 | 实测结果 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **最低区间要覆盖空转兜底 ¥0.01** | 空转局的 `win = max(totalWin, 0.01) = 0.01` 得能落进区间 | 存在一段覆盖 0.01 | ✅ 第 1 段 `0.01~0.14` → 安慰奖 |
|
||||
| 2 | **区间上界要覆盖 500x × 最高档 = ¥250,000** | 覆盖不到的话,最大的那几档全走 fallback | 最高区间上限 ≥ ¥250,000 | ✅ 上界 99,999,999;¥250,000 落第 9 段 → 猎杀者匕首 \| 渐变之色 |
|
||||
| 3 | **区间连续无缝、无空洞** | 有空洞就走 fallback,发奖与赢额不匹配 | 按 `win_low` 排序逐条核 `high_i == low_{i+1}` | ✅ 断点 0(12 段首尾相接) |
|
||||
|
||||
> **实测(导入后按 mapper 同口径 SQL 验证)**:¥0.10 / ¥0.14 → 安慰奖;¥10 → SG 553 浪花喷漆;¥50 → 双持贝瑞塔 黑榄仁木;¥20,000 → AK-47 燃料喷射器;**¥250,000 → 猎杀者匕首 渐变之色**;¥2,500,000 → AWP 巨龙传说。
|
||||
> **两个实现细节(来自 `MahjongPrizeRangeMapper`,别按"双闭区间"想当然)**:
|
||||
> ① mapper 用**半开区间** `win_low < win <= win_high` + `ORDER BY win_low DESC LIMIT 1` ⇒ 段与段共享的那个端点值归**上一段**(¥10.00 发 SG 553,不发双持贝瑞塔),也**不会** `TooManyResults`;
|
||||
> ② `win = 0.01` 按半开语义其实**匹配不到任何区间**(`0.01 > 0.01` 为假)→ 走 `findFallbackPrize()`;而 1 号「安慰奖」正好就是 `is_fallback=1`,所以**结果一样**。以后在后台改兜底饰品时要记得:**空转局发的是它**。
|
||||
|
||||
---
|
||||
|
||||
### 3.9 空转率(桶 1 占比)定多少:**65%**(已用仿真校准)
|
||||
|
||||
**它是什么**:空转率 = **桶 1 的配额占比**(本局一圈 1024 路都没中),由 `calcRounds` 算出的 `count` 分布决定,不是硬编码概率 —— 所以"空转率定多少"就是"桶 1 的 count 占多少",后台「一键重算」可调。
|
||||
|
||||
**为什么它是个(小)指标**:空转局照样发兜底饰品,这笔钱无法用桶配比压掉:
|
||||
|
||||
```
|
||||
保底 RTP = 空转率 × 兜底金额 ÷ 投注总额 (兜底金额 = max(totalWin, 0.01) = ¥0.01)
|
||||
```
|
||||
|
||||
**¥0.01 的兜底很便宜**:空转 65% → 保底 **0.65%**;空转 80% → 0.80% —— 都在 ±1pt 容差内,不构成压力。
|
||||
|
||||
> ⚠️ **勘误(2026-09-22,按母本代码核实)**:本节原来写"最低饰品价 v 才是大闸门:v=¥0.05→3.25%、v=¥0.50→32.5%、v=¥2→130%(必亏)"——**那是把饰品的市场价当成了成本**。实际是 `ornamentsPrice = win`(olympus 写背包 `.ornamentsPrice(win)`,分解按 `Σ TtBoxRecords.ornamentsPrice` 入账),**饰品只是载体、每局实付 = `settled_win = win`**,所以兜底成本就是 ¥0.01。两个连带结论随之更正:
|
||||
> ① **11 个箱可以共用同一套桶配比期望**:保底只占 ≤0.65pt(¥1 档 0.65% / ¥10 档 0.065% / ¥500 档 0.0013%),落在容差内;要较真只给 ¥1、¥3 两档微调,不必逐箱重配。
|
||||
> ② **水位没有"虚高"问题**:空转局 `settled_win = 0.01` 就是实际派彩(饰品价格 = 赢额),`水位 = Σcost − Σsettled_win` 口径正确,**不需要**按饰品市场价记账。
|
||||
|
||||
**目标 RTP 0.80 的预算反解**(取 FS 触发率 0.5%、FS 局整局平均 45x、兜底金额 ¥0.01):
|
||||
|
||||
| 空转率 q | 命中率 | 保底 RTP | 其余中奖局需要的平均倍率 | 需要"抹掉"的自然中奖占比 |
|
||||
|---|---|---|---|---|
|
||||
| 50% | 50% | 0.50% | 1.15x | 2.5%(几乎不动自然形态) |
|
||||
| 60% | 40% | 0.60% | 1.44x | 22% |
|
||||
| **65%(建议)** | **35%** | **0.65%** | **1.65x** | **32%** |
|
||||
| 70% | 30% | 0.70% | 1.93x | 42% |
|
||||
| 80% | 20% | 0.80% | 2.91x | **61%** ⚠️ |
|
||||
|
||||
**结论:空转率取 65%(命中率 35%),可接受区间 60~70%。** 三条理由:
|
||||
|
||||
1. **≤50% 不建议**:中奖局平均只有 ~1.15x,而自然中奖的中位数只有 0.2x —— 池子里会塞满"中 0.1~0.5 倍"的小奖,观感是"一直在中小钱",运营侧每局都要走一次饰品区间匹配,账也更碎;
|
||||
2. **≥80% 不建议**:要抹掉 61% 的自然中奖、并把 RTP 压在 14/15/16 号高倍桶上,而这三档**没有"买 FS"兜底**(§5.4 风险),最容易饿死;
|
||||
3. **65% 只抹掉 32% 的自然中奖**(自然命中 51.3% → 35%),构造量适中,中奖局平均 1.65x "每次中奖都有感"。
|
||||
|
||||
**配套(同样要定死)**:**FS 局的整局倍率中心值取 45x、区间 25x~60x**,不要沿用"30x~150x"。因为按 0.5% 触发率算,FS 局平均 150x 一项就要吃掉 `0.5% × 150 = 75%` 的 RTP 预算,剩下的局几乎没有钱可赔;45x 时它占 **22.5%**,其余局还有 54% 的预算。
|
||||
|
||||
**自然形态实测(`mahjong-py` 20 万局、完全不加偏置)** —— 这些就是生成端要"买回来"的差距:
|
||||
|
||||
| 指标 | 自然值 | 设计目标 |
|
||||
|---|---|---|
|
||||
| 自然 RTP(平均 `actual_mul`) | **5.25**(525%) | 0.80 |
|
||||
| 基础旋命中率 / 空转率 | 51.3% / 48.7% | 35% / **65%** |
|
||||
| FS 触发率(3+ 胡落定) | **1.33%** | 0.5% |
|
||||
| 悬念盘(2 胡且第 5 轴无胡) | **4.89%** | 1~2% |
|
||||
| 触 500x 封顶 | 0.015% | — |
|
||||
| 波数分布(0 / 1 / 2 / ≥3 波) | 48.7% / 26.0% / 13.3% / 12.0% | — |
|
||||
|
||||
> 两条硬结论:① **自然 RTP 5.25 说明"RTP 完全靠池子构造买回来"**(与玫瑰女郎同构:规则本身不收敛,配额才收敛)⇒ 生成端**不能"自然抽样 + 重新分桶"**,必须按桶目标主动摆盘面(`_force_win` / bias 反解);
|
||||
> ② **胡要人为压到约 1/2.7**(1.33% → 0.5%)、**悬念盘压到约 1/3**(4.89% → 1.5%)—— 做法是**按配额投放胡**(生成端决定本旋落几个胡),而不是"改权重再撞运气"。
|
||||
|
||||
---
|
||||
|
||||
@@ -937,7 +1003,7 @@ SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
|
||||
|---|---|---|---|---|
|
||||
| 1 | `game_mahjong_box` | `game_olympus_box` | box_id, box_type, bet, price, total_rounds, rtp, rtp_converge_ratio, status | `total_ornaments`→`total_rounds` |
|
||||
| 2 | `game_mahjong_bucket` | `game_olympus_ornament` | id(1-16), expect_mul, name, range_low, range_high | **16 档饰品桶** |
|
||||
| 3 | `game_mahjong_box_bucket` | `game_olympus_box_ornament` | box_id, bucket_id, audience, count, used | 同构,唯一键 `(box_id,bucket_id,audience)`;**16 桶 × 11 箱 × 4 身份 = 704 条** |
|
||||
| 3 | `game_mahjong_box_bucket` | `game_olympus_box_ornament` | box_id, bucket_id, audience, count, used | 同构,唯一键 `(box_id,bucket_id,audience)`;**16 桶 × 11 箱 × 3 身份 = 528 条** |
|
||||
| 4 | `game_mahjong_round` | `game_olympus_round` | round_id, box_id, bucket_id, actual_mul, free_spin_count, total_rotations, has_fs, near_miss | 去 `total_cascades` 语义改为 `total_rotations`(波数);`need_fs` 不需要了(没有买 FS 模式);加 `near_miss` 标记 2 胡悬念盘 |
|
||||
| 5 | `game_mahjong_step` | `game_olympus_step` | 见 §4.1 | 字段大改(ways/golden/阶梯) |
|
||||
| 6 | `game_mahjong_record` | `game_olympus_record` | user_id, box_id, round_id, cost, bet_mul, total_win, settled, settle_source, audience, **prize_id / prize_name / prize_image_url / prize_level_img / settled_win / reward_type** | **保留全部 prize_* 列**(发饰品要走区间匹配),只多一个 `bet_mul` |
|
||||
@@ -961,8 +1027,8 @@ SELECT ornament_id, win_low, win_high FROM game_sweet_prize_range;
|
||||
"wild_converted": [{"pos":7,"fromSymbol":"B"}],// 本波"金色→百搭"就地转化的格(客户端播变形动画)
|
||||
"ways_detail": [{"symbol":"A","reelCount":3,"counts":[1,2,1,0,0],
|
||||
"loc":[1,6,11,7], // 本笔中奖的全部格位(1起),含金色格 —— 客户端按它播爆裂动画
|
||||
"ways":2,"payBase":15,"rotationMul":2,"win":3.00}],
|
||||
// win = 15 × 2 × (bet÷K) × 2,K 见 §2.1 计量单位
|
||||
"ways":2,"payBase":0.75,"rotationMul":2,"win":3.00}],
|
||||
// payBase 已是倍投注总额(發 3 连 = 0.75,无 ÷20);win = 0.75 × 2 × bet × 2(本例 bet=1)
|
||||
"drop_data": [{"from":12,"to":17,"symbol":"H"}],
|
||||
"fill_data": [{"pos":2,"symbol":"G"}],
|
||||
"scatter_trigger": {"scatterCount":3,"freeSpins":12,"extraSpins":0}, // 或 null
|
||||
@@ -996,7 +1062,7 @@ mahjong-py/
|
||||
├── generate_rounds.py # 批量落库(箱编号/池子分配/id 段切分/并行)
|
||||
├── simulate_cycle.py # 整轮 RTP 仿真验收(几乎原样拷贝)
|
||||
├── _recalc_boxes.py # 按目标 RTP 重算配比(calc_rounds 一行不改)
|
||||
├── _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板
|
||||
├── _make_templates.py # 11 箱 × 3 身份 = 33 条生效模板
|
||||
├── _regen.py # check/snapshot/clear/restore/verify
|
||||
├── _reset_boxes.py
|
||||
├── README.md # 结构与 runbook(照抄并改写游戏规则段)
|
||||
@@ -1056,8 +1122,8 @@ mahjong-py/
|
||||
cd mahjong-py
|
||||
python mahjong_db.py # 建 11 张表 + 16 档桶种子(幂等)
|
||||
python generate_rounds.py --per-bucket 20 # 干跑,不写库
|
||||
python generate_rounds.py --all --alloc config --write --workers 4 # 12 箱写库(只有 normal)
|
||||
python _make_templates.py # 12 箱 × 4 身份 = 48 条生效模板
|
||||
python generate_rounds.py --all --alloc config --write --workers 4 # 11 箱写库(只有 normal)
|
||||
python _make_templates.py # 11 箱 × 3 身份 = 33 条生效模板
|
||||
python _recalc_boxes.py --mode normal # 重算配比 + 回写 total_rounds
|
||||
python simulate_cycle.py --box 1 --cycles 5 --audience common --seed 2026 # RTP 验收
|
||||
python _regen.py check|snapshot|clear|restore|verify # 体检/快照/清池
|
||||
@@ -1074,9 +1140,9 @@ python -m pytest tests -q # 回归
|
||||
| `ALLOC_FLOOR` | 60 | 每桶保底条数 |
|
||||
| `BOX_STEP_BAND` | 200000 | 每箱 step id 预留段(只有 normal,比 olympus 的三模式好估) |
|
||||
| 每箱池子 | 4000~5000 条回合 | 因为没有"买 FS"那批高倍回合兜底,**高倍桶要靠 normal 池自己凑**,所以单箱池子要比玫瑰女郎更大 |
|
||||
| 总规模 | **12 箱** × ~4500 = **5.4 万回合**,每回合 8~40 帧 → **~96 万 step**(与 olympus 的 106 万同一量级) |
|
||||
| 总规模 | **11 箱** × ~4500 = **5 万回合**,每回合 8~40 帧 → **~90 万 step**(与 olympus 的 106 万同一量级) |
|
||||
| 生成耗时 | 4 进程约 **15~25 分钟**(本机 i3-10105F 只 4 物理核,**workers 必须 ≤4**,再高反而更慢) |
|
||||
| 风险点 | **高倍桶(8/9/10 号桶)没有买 FS 兜底**:1000x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 `_force_win` 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 `_calibrate_bias` 饥饿重校准可复用) |
|
||||
| 风险点 | **高倍桶(14/15/16 号桶)没有买 FS 兜底**:500x 的局只能靠"基础旋自然触发 FS + 长级联 + 阶梯叠满"构造出来,靠近乎碰运气是凑不齐的 → 必须用 `_force_win` 主动构造,并接受"构造局的高倍桶可能长时间饿死"(玫瑰女郎已有 `_calibrate_bias` 饥饿重校准可复用) |
|
||||
|
||||
> 若 80 万 step 的写入时间不可接受,回落方案:把"未中奖的回合"(桶 1、桶 2)合并成少数几条模板回合,靠 Java 端 `copyFromSameBucket` 复制(olympus 已有此兜底,`:179-231`)。
|
||||
|
||||
@@ -1163,7 +1229,7 @@ startNewGame(userId, boxId):
|
||||
- `scope=0` 全服口径排除机器人(`user_type='03'`)、封禁(`status='1'`)、已删除(`del_flag!='0'`),**白号保留上榜**。
|
||||
- 响应体用 `@JsonInclude(NON_NULL)`:未出现的 key 就是 null,客户端无需判空。
|
||||
|
||||
### 6.5 平台共享文件的改动(9 处,漏一处就少一块统计)
|
||||
### 6.5 平台共享文件的改动(10 处,漏一处就少一块统计)
|
||||
|
||||
| # | 文件 | 改动 |
|
||||
|---|---|---|
|
||||
@@ -1175,14 +1241,14 @@ startNewGame(userId, boxId):
|
||||
| 6 | `skins-model/.../MessageCategoryEnum.java` | 加 `GAME_MAHJONG("GAME_MAHJONG","麻将胡了")`(前缀 `GAME_` 才进"游戏"Tab)—— **本游戏要发结算站内信,必加** |
|
||||
| 7 | `sys_config` | `mahjongMaintenance` 维护开关(0 正常 1 维护) |
|
||||
| 8 | `sys_menu` | 7 个后台菜单(见 §8) |
|
||||
| 9 | `docs/updata_sql.md` | 新增一节「麻将胡了模块建表 / 服务接入」,格式照 1144-1225 行;**返奖饰品种子**照 1187-1200 行从糖果整套导入(`DELETE` + `INSERT ... SELECT`,保留原 id 以维持区间映射) |
|
||||
| 9 | `docs/updata_sql.md` | **该台账已按需求清空,不再当建表台账/模板用**(建表只以 `mahjong-py/schema.sql` 为权威、直接对库执行);**返奖饰品种子**仍照 §3.8 那段从糖果整套导入(`DELETE` + `INSERT ... SELECT`,保留原 id 以维持区间映射) |
|
||||
| 10 | 亏损返利 / 消费排行榜 / VIP 流水返利 | 这些模块按 source 码聚合,登记进第 1 项后自动生效;**必须实测一次**别只看代码 |
|
||||
|
||||
> **总览:10 处改动里,第 1/2/3/6 项是"漏了必出事故"的**——漏 1 少流水统计、漏 2 写不进背包、漏 3 分解口径错、漏 6 站内信发不出去。
|
||||
|
||||
### 6.6 封顶与"匹配/发放分离"
|
||||
|
||||
- 回合池里桶 10 的 `actual_mul` 一律 **= 500**(发放值),但配比计算时期望用池内实测均值(可配 `PAYOUT_EXPECT_OVERRIDE`),否则 RTP 会系统性偏低。
|
||||
- 回合池里桶 16 的 `actual_mul` 一律 **= 500**(发放值),但配比计算时期望用池内实测均值(可配 `PAYOUT_EXPECT_OVERRIDE`),否则 RTP 会系统性偏低。
|
||||
- 运行期 `payoutCap` 过滤 + 收敛阶段 `capMul = payoutCap / bet` 双保险。
|
||||
- FINISH 时 `totalMul ≥ 500` → `maxWinReached=true`、`spinsRemaining=0`。
|
||||
|
||||
@@ -1197,7 +1263,7 @@ startNewGame(userId, boxId):
|
||||
| 单笔/单波金额 | `base_score × bet`(base_score 由生成端算好) | `倍率 × ways × 投注额 × rotationMul` | 同结构(都是"乘数 × bet") |
|
||||
| **本局赢额** | `totalWin = actual_mul × bet` | `totalWin = actual_mul × bet` | ✅ **完全一致** |
|
||||
| 本局倍率 | `totalMul = actual_mul` | `totalMul = actual_mul` | ✅ 一致 |
|
||||
| 发奖 | 赢额 → 落 `prize_range` 区间 → 饰品 → 写背包 → `settled_win` = 饰品价 | 同 | ✅ 一致 |
|
||||
| 发奖 | 赢额 → 落 `prize_range` 区间 → 选定饰品 → 写背包(`ornaments_price` = 该赢额)→ `settled_win` = 该赢额 | 同 | ✅ 一致 |
|
||||
| 分解 | `OLYMPUS_BOX(13) → OLYMPUS_REWARD(61)` | `MAHJONG_BOX(15) → MAHJONG_REWARD(69)` | 同结构 |
|
||||
| 空转局 | `win = max(totalWin, 0.01)` → 最低档饰品(必得饰品) | 同 | ✅ 一致 |
|
||||
| 奖池水位记账 | `水位 = Σcost − Σsettled_win + adjust` | 同 | ✅ 一致 |
|
||||
@@ -1250,7 +1316,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
| `gridState` | 每帧 | **4 行 × 5 列**短码矩阵,20 格全满(权威渲染数据) |
|
||||
| `goldenDetail` | 每帧 | `[{"pos":1起,"symbol":"B"}]` 本帧仍为"金色普通符号"的格(它按 `symbol` 的自然符号成奖) |
|
||||
| `wildConverted` | 每帧 | `[{"pos":1起,"fromSymbol":"B"}]` 本波**就地转成百搭**的格:该格 `gridState` 已是 `W`、不清空不补新符,客户端播"金色→百搭"变形动画 |
|
||||
| `waysDetail` | 每帧 | `[{"symbol","reelCount","counts":[..5],"loc":[格位…],"ways","payBase","rotationMul","win"}]`:`loc` = **本笔中奖的全部格位(1 起,含金色格)**,客户端按它播爆裂动画;`win` = `payBase × ways × (bet÷K) × rotationMul`(**K 见 §2.1 计量单位**) |
|
||||
| `waysDetail` | 每帧 | `[{"symbol","reelCount","counts":[..5],"loc":[格位…],"ways","payBase","rotationMul","win"}]`:`loc` = **本笔中奖的全部格位(1 起,含金色格)**,客户端按它播爆裂动画;`win` = `payBase × ways × bet × rotationMul`(`payBase` 已是倍投注总额,**无 ÷20**,见 §2.1) |
|
||||
| `dropData` / `fillData` | 每帧 | 与玫瑰女郎同构(`from/to/symbol`、`pos/symbol`) |
|
||||
| `rotationMul` | 每帧 | **本波阶梯倍率**(基础旋 `1/2/3/5`、FS `2/4/6/10`);重连时用它接回档位 |
|
||||
| `winAmount` | 每帧 | **本旋累计到本帧**(末帧 = 本旋总额),不是增量 |
|
||||
@@ -1282,19 +1348,19 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
|---|---|---|---|
|
||||
| 麻将胡了(目录) | `mahjong` | — | |
|
||||
| 箱子配置 | `box` | `game/mahjong/box/index` | `game:mahjong:box` |
|
||||
| 倍率桶 | `bucket` | `game/mahjong/bucket/index` | `game:mahjong:bucket` |
|
||||
| 饰品列表 | `bucket` | `game/mahjong/bucket/index` | `game:mahjong:bucket` |
|
||||
| 回合管理 | `round` | `game/mahjong/round/index` | `game:mahjong:round` |
|
||||
| 奖池水位 | `rewardPool` | `game/mahjong/rewardPool/index` | `game:mahjong:pool` |
|
||||
| 玩家记录 | `record` | `game/mahjong/record/index` | `game:mahjong:record` |
|
||||
| 每日统计 | `dailywin` | `game/mahjong/dailywin/index` | `game:mahjong:win` |
|
||||
| **奖品管理** | `prize` | `game/mahjong/prize/index` | `game:mahjong:prize` | **本游戏要有**(赢额→饰品、返奖区间编辑、品质图) |
|
||||
|
||||
菜单 SQL 照抄 `docs/updata_sql.md:1165-1186` 的写法(`SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1)` 动态挂到"游戏管理"下,再 `LAST_INSERT_ID()` 取目录 id 插 7 个子菜单)。**注意两个仓库现状**:
|
||||
- `sql/` 目录是空的,全量 DDL 历史只在 `docs/updata_sql.md`(追加式)和生成器的 `schema.sql` 里,别去 `sql/` 找;
|
||||
菜单 SQL 的写法是 `SET @game_root := (SELECT parent_id FROM sys_menu WHERE component='game/sweet/box/index' LIMIT 1)` 动态挂到"游戏管理"下,再 `LAST_INSERT_ID()` 取目录 id 插 7 个子菜单;**仓内已无模板可抄**(`docs/updata_sql.md` 已清空、`sql/` 目录是空的),改成对现库 `sys_menu` 直接操作(现成的 olympus 目录行可当样板)。**注意仓库现状**:
|
||||
- 全量 DDL 只剩生成器的 `schema.sql` 这一处权威,别去 `sql/` 或 `docs/updata_sql.md` 找;
|
||||
- 后台页面**没有任何 `v-hasPermi` 指令**,路由也**不在 `router/index.js` 里静态注册**——能否访问完全由 `sys_menu` 行决定(`component='game/mahjong/<module>/index'`)。菜单 SQL 漏一条,页面就是白屏而不是报错。
|
||||
|
||||
**箱子配置页**:箱列表(bet/目标 RTP/固定池/收敛比例)+ 桶配比弹窗(改 `count`、看 `remaining`/`convergeCount`/计算 RTP)+ 模板管理 + 一键重算/批量重算。
|
||||
**奖池水位页**:四身份 Tab + 说明面板 + 逐箱水位与实时 RTP + 批量注水/抽水/重置(比例 + **调整原因必填**,空或纯空格不提交——这是用户已裁决的口径)+ 调整历史分页。
|
||||
**箱子配置页**:箱列表(bet/目标 RTP/固定池/收敛比例)+ 饰品配置弹窗(改 `count`、看 `remaining`/`convergeCount`/计算 RTP)+ 模板管理 + 一键重算/批量重算。
|
||||
**奖池水位页**:三身份 Tab + 说明面板 + 逐箱水位与实时 RTP + 批量注水/抽水/重置(比例 + **调整原因必填**,空或纯空格不提交——这是用户已裁决的口径)+ 调整历史分页。
|
||||
**奖品管理页**:返奖饰品列表(名称/品质/图片/品质图/磨损/兜底/返奖区间)+ 编辑(`prize/save` 同步写 `game_mahjong_prize_range`)——照抄 `olympus/prize/index.vue`(121 行)。
|
||||
**玩家记录页**:列里带**饰品名+图**与「播放状态 start/finish」标签(和玫瑰女郎一致)。
|
||||
**必做校验**:`calcRounds` 回写时同时更新 `total_rounds`,否则 `cycleN` 与实际池子对不上。
|
||||
@@ -1307,7 +1373,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
|---|---|---|
|
||||
| `assets/Scripts/Pages/MahjongPanel/MahjongPanel.ts` | 主面板(5×4 盘面、级联动画、阶梯条、FS 模式、自动旋转) | 结构照 `CandySloterPanel.ts`(186KB),**盘面改为 4 行 5 列、去掉炸弹、加金色转化动画** |
|
||||
| `MahjongBigWin.ts` | 爆奖三档 | 拷贝 |
|
||||
| `MahjongHistory.ts` / `MahjongRules.ts` / `MahjongSettings.ts` | 战绩 / 规则 / 设置(含投注大小与倍数) | 拷贝改文案 |
|
||||
| `MahjongHistory.ts` / `MahjongRules.ts` / `MahjongSettings.ts` | 战绩 / 规则 / 设置(含投注档选择,**无投注倍数**) | 拷贝改文案 |
|
||||
| `MahjongSfx.ts` / `MahjongSpineUtil.ts` | 音效 / 骨骼工具 | 拷贝 |
|
||||
|
||||
**主界面 HUD 布局(按用户给的真实运行截图逐块对齐)**:
|
||||
@@ -1336,8 +1402,8 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
|
||||
| 阶段 | 交付物 | 验收标准 | 人日 |
|
||||
|---|---|---|---|
|
||||
| **P0 数学与仿真** | `mahjong-py` 的 `config.py` / `engine.py` / `mahjong_simulator.py` / `tests/` | ① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 20~30%、**FS 触发率 ≈0.5%**、悬念盘占比 1~2%、总 RTP 落在设计区间 | 6~8 |
|
||||
| **P1 回合池** | `schema.sql` / `mahjong_db.py` / `generate_rounds.py` / `_recalc_boxes.py` / `_make_templates.py` / `simulate_cycle.py` / README | ① **12 箱**池子生成成功,无饥饿桶(尤其 8/9/10 号高倍桶);② `simulate_cycle` 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) | 4~5(比三模式省 1~1.5 天) |
|
||||
| **P0 数学与仿真** | `mahjong-py` 的 `config.py` / `engine.py` / `mahjong_simulator.py` / `tests/` | ① 前缀链判定逐条单测通过(§2.1 七个正反例);② 金色就地转百搭、阶梯乘数、级联几何三项有专门单测;③ 自然 1000 万旋仿真:基础旋命中率 **35%(空转率 65%,口径见 §3.9)**、**FS 触发率 ≈0.5%**、悬念盘占比 1~2%、总 RTP 落在设计区间 | 6~8 |
|
||||
| **P1 回合池** | `schema.sql` / `mahjong_db.py` / `generate_rounds.py` / `_recalc_boxes.py` / `_make_templates.py` / `simulate_cycle.py` / README | ① **11 箱**池子生成成功,无饥饿桶(尤其 14/15/16 号高倍桶);② `simulate_cycle` 整轮 RTP 偏差 ±3pt 内(目标 ±1pt);③ 池体检通过(每桶有条目、封顶局存在、含 FS 回合占比 ≈0.5%) | 4~5(比三模式省 1~1.5 天) |
|
||||
| **P2 Java 服务** | `game-mahjong-service` 全套 + 10 处共享文件改动 | ① `mvn -pl skins-service/game-mahjong-service -am compile` 通过;② 开局/续播/重连/封顶/空转局五条链路离线单测;③ 扣费与发奖同一事务、`settled` 幂等可重放、饰品开局即入背包、分解流水口径正确 | 6~8(含饰品/背包/站内信链路) |
|
||||
| **P3 后台 + SQL 文档** | 7 个 Vue 页面(含奖品管理)+ `docs/updata_sql.md` 新节 + `docs/麻将胡了接口文档.md` | ① 菜单挂载正确、权限生效;② 一键重算与注水/抽水(原因必填)可用;③ 返奖饰品与区间可编辑;④ 接口文档与真实响应逐字段对齐 | 4~5 |
|
||||
| **P4 客户端** | Mahjong 面板全套 | ① 逐帧复刻服务端盘面(含金色转化、阶梯高亮、**2 胡悬念放大**);② 断线重连回到正确波次;③ 末帧「放入背包/立即分解」+ 自动分解开关 | 15~20 |
|
||||
@@ -1352,10 +1418,10 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
**Python**
|
||||
- `test_engine.py`:
|
||||
- **前缀链判定逐条覆盖 §2.1 的 7 个正反例**(`2,3,1,0,0` ✅ / `2,3,1,2,0` ✅ 4 连 / `2,0,3,4,4` ❌ / `0,4,4,4,4` ❌ / `1,1,0,4,4` ❌ / `1,2,3,4,4` ✅ ways=96 / `1,1,1,1,1` ✅ ways=1)——**这是全游戏最容易写错的一处,必须单测钉死**;
|
||||
- 百搭补链(`發,百搭,發` 成立)、**原生盘面不落百搭**(`_fill`/新旋/补位断言不产出 `W`)、**每个金色格各留一个百搭(无上限)且只出现在第 2/3/4 轴**、**百搭不能替代胡**(`發,胡,發` 不成链)、**断言 `count_wild(grid) ≤ 1`**;
|
||||
- 百搭补链(`發,百搭,發` 成立)、**原生盘面不落百搭**(`_fill`/新旋/补位断言不产出 `W`)、**每个金色格各留一个百搭(无上限)且只出现在第 2/3/4 轴**、**百搭不能替代胡**(`發,胡,發` 不成链)、**断言 `W` 只可能出现在第 2/3/4 轴、且盘上百搭数 = 本波中奖金色格数(无上限)**;
|
||||
- **金色符号就地转百搭**:① 金色「中」参与中奖后,该格 `gridState` 变 `W` 且 `wildConverted` 有它;② 该格**没有**被清空/没有新符号落进去;③ W 在下一波能成功搭桥(例如让 `發,發,_,...` 变成 3 连);④ 该 W 在更后面的波参与成奖时**被正常消除**;⑤ 未被中奖涉及的金色格**不转化**、保持原样;
|
||||
- 赔付表 8×3 全档、**阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)**、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、**百搭不能替代胡**(`發,胡,發` 不成链)、**悬念盘**(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。
|
||||
- `test_simulator.py`:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且**金色格以 W 留在盘上**,玫瑰女郎有 `TestTumbleGeometry` 可照抄)、池内每桶 `actual_mul` 落在区间内、无"饥饿桶"(**重点查 8/9/10 号高倍桶**——没有买 FS 兜底,最容易饿死)。
|
||||
- 赔付表 8×3 全档、**阶梯逐波取值(1次=x1 / 2次=x2 / 3次=x3 / 4次+=x5)**、封顶截断、FS 触发/重触发旋数(3→12 / 4→14 / 5→16)、**悬念盘**(恰好 2 胡且第 5 轴无胡的盘面占比落在设计值)。
|
||||
- `test_simulator.py`:级联几何复刻(上一波盘面 − 本波中奖格 + drop/fill = 本波盘面,且**金色格以 W 留在盘上**,玫瑰女郎有 `TestTumbleGeometry` 可照抄)、池内每桶 `actual_mul` 落在区间内、无"饥饿桶"(**重点查 14/15/16 号高倍桶**——没有买 FS 兜底,最容易饿死)。
|
||||
- `simulate_cycle.py`:整轮 RTP 与设计的偏差;白号池独立。
|
||||
|
||||
**Java**
|
||||
@@ -1368,7 +1434,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
- 并发:同一用户并发 `dospin` 只有一个能开局(`COMMON_LOCK+userId`)。
|
||||
|
||||
**对账 / 回归**
|
||||
- `Σ record.settled_win`(已结算局) == `Σ` 对应饰品价 × 条数 == 背包里 `tt_box_records.source=15` 的条数口径。
|
||||
- `Σ record.settled_win`(已结算局) == `Σ tt_box_records.ornaments_price`(source=15)—— 两边都是"本局赢额",逐条对得上(饰品由 `prize_range` 按这个金额选定)。
|
||||
- 分解后:`MAHJONG_REWARD(69)` 流水之和 == 已分解饰品价之和。
|
||||
- 今日总投注/总赢奖、消费排行榜、VIP 流水返利、流水抽奖四项把新 source 计入后数值正确(改 `TtAccountRecordSource` 后必须实测,不能只看代码)。
|
||||
- 断线重连:播到第 3 旋第 2 波刷新,重连后阶梯档位与累计赢额与刷新前一致;**饰品此时已在背包**(无损模型)。
|
||||
@@ -1381,14 +1447,14 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
|---|---|---|
|
||||
| 1 | **1024 路天然高方差**(免费旋转 + 10x 阶梯叠加,单局理论可爆几千倍) | 桶配额 + 收敛阶段 + **500x 封顶**(把尾部直接切掉);单箱池子 ≥3000 条;上线前用 `simulate_cycle` 跑 5 轮以上看尾部 |
|
||||
| 2 | **金色符号"就地转百搭"改变了级联密度**(中奖格不再全清 → 盘面更容易连续中奖 → RTP 与旋内波数都会上升) | 这是规则本身的效果,不是 bug;但必须在 P0 用仿真量化(对比"金色格照常清除"的口径),再据此调桶配比;Q2 的"锚定/不锚定"用同一套仿真验证 |
|
||||
| 3 | **step 表体量**(~96 万帧,12 箱) | 无中奖桶(1/2)合并成模板回合 + Java `copyFromSameBucket` 复制;必要时分表 |
|
||||
| 3 | **step 表体量**(~90 万帧,11 箱) | 无中奖桶(1/2)合并成模板回合 + Java `copyFromSameBucket` 复制;必要时分表 |
|
||||
| 4 | **py / Java 两份 `calcRounds` 不同步**(玫瑰女郎已因此返工) | 定死"先改 py,再对齐 Java";P2 加一条对照单测(同输入同一配比) |
|
||||
| 5 | **免费旋转"演不动"**(FS 是白送的 12 旋,预算铺不开时动画会空转) | 沿用 FS 保底配额机制(每 N 旋必补一次中奖),并把预算按"保底预算 + 一旋大奖"拆分 |
|
||||
| 6 | ~~现金直付与平台既有"游戏必给饰品"体验差异~~ | **已定案:走饰品**(用户 2026-09-21)。代价是 P2 多 1~2 天(区间匹配 + 背包 + 站内信 + 分解口径),客户端末帧多一步"放入背包/分解" —— 已计入工期 |
|
||||
| 7 | **客户端工作量大**(玫瑰女郎面板是 186KB 单文件) | 直接复制改造,**不做公共 slot 基类抽象**(两个游戏的盘面/动画差异大于共性) |
|
||||
| 8 | **生成耗时**(本机 i3-10105F,4 物理核) | `--workers` 上限 4;分箱分批跑;把重建写进 runbook 而不是临时起意 |
|
||||
| 9 | **母本代码有文档漂移**(`olympus-py/README.md:459` 声称 RoundPicker 有 `ruoyi:olympus:seen:*` 不重发去重,**实际代码里不存在**) | 新游戏的 README 只写"代码里真的有的东西";照抄前先 grep 确认 |
|
||||
| 10 | **高倍桶没有"买 FS"兜底**(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) | 生成端用 `_force_win` **主动构造**而不是等运气;接受高倍桶 `count` 低、并保留 `_calibrate_bias` 饥饿重校准;上线前专门查 8/9/10 号桶有没有条数 |
|
||||
| 10 | **高倍桶没有"买 FS"兜底**(只有 normal 模式,100x+ 的局全靠自然触发 FS + 长级联构造) | 生成端用 `_force_win` **主动构造**而不是等运气;接受高倍桶 `count` 低、并保留 `_calibrate_bias` 饥饿重校准;上线前专门查 14/15/16 号桶有没有条数 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1402,10 +1468,10 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
| ~~Q1~~ | ~~纯百搭链(第1/2/3轴全百搭)算什么?~~ | ✅ **由"金色只在第 2/3/4 轴 + 百搭只能由金色转化"自动消解**——百搭永远不在第 1 轴,链的第 1 轴必然是自然符号 ⇒ 每笔中奖天然含自然符号,**纯百搭链数学上不可能出现**,不需要单独规则、也不需要 `WILD_PAYS` 开关(见 §2.2) |
|
||||
| ~~Q2~~ | ~~金色→百搭的那个百搭是否"钉住不动"?~~ | ✅ **不钉住**("符号是可以下落的,下面符号消除之后,上面符号会往下掉落") |
|
||||
| ~~Q3~~ | ~~免费旋转总旋数上限~~ | ✅ **36 旋**(用户 2026-09-21 定案),并配"**发不满就不发**"硬规则:`已发放 + 本次应赠 > 36` 时后续旋不再落 3+ 胡,杜绝"落胡只补几旋" |
|
||||
| ~~Q4~~ | ~~单局封顶倍率~~ | ✅ **500x** → `MAX_WIN_CAP_MUL = 500`;配 ¥1000 档 ×10 倍,单局最大赔付 ¥5,000,000 |
|
||||
| ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 12 箱、无 `need_fs`、`costMultiplier≡1` |
|
||||
| ~~Q4~~ | ~~单局封顶倍率~~ | ✅ **500x** → `MAX_WIN_CAP_MUL = 500`;配最高档 ¥500(**无投注倍数**),单局理论最大赔付 **¥250,000**(见 Q7) |
|
||||
| ~~Q5~~ | ~~是否要做 `ante` / `buy_fs`~~ | ✅ **都不做,只保留普通旋转** → 11 箱、无 `need_fs`、`costMultiplier≡1` |
|
||||
| ~~Q6~~ | ~~各身份目标 RTP~~ | ✅ **不是拍板项**:后台配置(箱子级 `rtp` + 身份级桶配比),建库统一 0.80,上线后按身份调,见 §3.6 |
|
||||
| Q7 | 投注档数量与档位表 | 🔧 **技术默认:11 档、不设投注倍数** —— `1/3/5/10/20/30/50/100/200/300/500`,单局理论最大赔付 ¥250,000(压在玫瑰女郎 ¥300,000 之下)。理由与升级路径见 §2.8.1 |
|
||||
| Q7 | 投注档数量与档位表 | 🔧 **技术默认:11 档、不设投注倍数** —— `1/3/5/10/20/30/50/100/200/300/500`,单局理论最大赔付 ¥250,000(压在玫瑰女郎 ¥300,000 之下)。理由与升级路径见 §2.8.2 |
|
||||
| Q8 | 每日统计要不要独立日表 | 🔧 **技术默认:不要**,后台按 `record` 聚合 |
|
||||
| ~~Q9~~ | ~~奖励形态:现金直付 vs 饰品入背包~~ | ✅ **赢额变成一个饰品发到背包** → 区间匹配 → `tt_box_records`(source=15) → 末帧放入/分解 → 分解时写 `MAHJONG_REWARD(69)` |
|
||||
| Q10 | 后台"回合管理"要不要暴露金色/阶梯明细 | 🔧 **技术默认**:只读展示 `actual_mul / 旋数 / 波数 / 是否含 FS / 悬念盘`,不展示内部帧 |
|
||||
@@ -1428,7 +1494,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 投注构成 | 选一个**投注档**(11 档,见下),即为**投注总额**,开局即扣(**不设投注倍数**) |
|
||||
| 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500 / 1000` |
|
||||
| 投注大小档位 | `1 / 3 / 5 / 10 / 20 / 30 / 50 / 100 / 200 / 300 / 500`(共 **11 档**,**无投注倍数**) |
|
||||
| 游戏单位 | 金额以 ¥ 为单位 |
|
||||
| 现金钱包 | 显示当下玩家可用的现金余额 |
|
||||
| 自动旋转 | 可按玩家选择的回合数量自动进行游戏 |
|
||||
@@ -1471,7 +1537,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
### 14.5 百搭符号(元宝)
|
||||
|
||||
- **百搭符号可替代除「夺宝符号(胡)」以外的所有符号**。
|
||||
- **百搭不会从卷轴上出现**:它**只能由金色普通符号被消除后转化而来**(见 14.6),而且**一个盘面上最多只会出现 1 个**。
|
||||
- **百搭不会从卷轴上出现**:它**只能由金色普通符号被消除后转化而来**(见 14.6),而且**每个金色格各留一个、无数量上限**(一个盘面可同时有多个)。
|
||||
- 百搭能**补链中间的断点**(`發, 百搭, 發` 成立),但它**不能替代胡**——`發, 胡, 發` 不成链。
|
||||
- **百搭被消除也要满足消除链**:它和普通符号完全同权——**只有它参与了某条成立的中奖链,才会随那一波一起被消除**;不满足链就留在盘面上继续用,不会凭空消失。
|
||||
- 百搭被消除之后,才可能再由金色符号转化出新的百搭;**下一旋整盘重发,百搭不带走**。
|
||||
@@ -1525,7 +1591,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
### 14.9 免费旋转模式
|
||||
|
||||
- **当 3 个夺宝符号(胡)出现在卷轴的任意位置时,免费旋转模式将被触发,并获得 12 次免费旋转**。
|
||||
- **每个额外的夺宝符号都将触发多 2 次的免费旋转**:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次,6 个 → 18 次。
|
||||
- **每个额外的夺宝符号都将触发多 2 次的免费旋转**:3 个 → 12 次,4 个 → 14 次,5 个 → 16 次(**一屏最多只出现 5 个胡**,不存在 6 个及以上 → 单次最多 16 旋)。
|
||||
- **免费旋转模式期间,卷轴上方的乘数将分别增加至 x2、x4、x6 和 x10**。
|
||||
- **免费旋转可以被重复触发**(FS 内再落 3 / 4 / 5 个胡 → **再送 12 / 14 / 16 旋**,与首触发同一套公式,见 §2.6)。
|
||||
- **免费旋转的总旋数上限为 36 旋**:`已发放 + 本次应赠 > 36` 时,后续旋**不再出现 3+ 个胡**(避免"落了胡却只补几旋")。因为单次最少赠 12 旋,所以**已发放 ≥ 25 旋之后一律不再出胡**,整局永远 ≤ 36 旋。
|
||||
@@ -1757,7 +1823,7 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
|
||||
> `W` 停在原处(轴3 那列没被清空),其余列各自下移一格并补新牌。
|
||||
|
||||
### 第 4 波(阶梯 x5,第 4 波及以后都是 x5)——**一个百搭被两个符号同时借用,本旋结束**
|
||||
### 第 4 波(阶梯 x5,第 4 波及以后都是 x5)——**一个百搭被三个符号同时借用,本旋结束**
|
||||
|
||||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -1770,11 +1836,12 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
|---|---|---|---|---|---|---|
|
||||
| **筒 G** | 1(行1) | 1(行4) | **1(百搭当筒)** | 1(行1、行3 → 2) | 1(行4) | **5 连,ways = 1×1×1×2×1 = 2** |
|
||||
| **索红绿 F** | 1(行4) | 1(行3) | **1(百搭当索红绿)** | 1(行4) | 1(行3) | **5 连,ways = 1×1×1×1×1 = 1** |
|
||||
| 白板 C | 1(行3) | 1(行2) | 0(W/A/B/H) | — | — | 轴3 断 |
|
||||
| **白板 C** | 1(行3) | 1(行2) | **1(百搭当白板)** | 0(G/A/G/F) | — | **3 连,ways = 1×1×1 = 1** |
|
||||
| 索绿 H | 1(行2) | 0(A/C/F/G) | — | — | — | 轴2 断 |
|
||||
| 發 A | 轴1 没有 | — | — | — | — | 不成奖 |
|
||||
|
||||
> 同一个百搭这波**同时被筒和索红绿两个符号借用**,两条链都成立 → **两笔并赔**。
|
||||
> 同一个百搭这波**同时被筒、索红绿、白板三个符号借用**,三条链都成立 → **三笔并赔**。
|
||||
> ⚠️ **勘误**:本节原来只算了筒与索红绿两笔,漏了"同一个 `W` 也能当白板"→ 白板 3 连(`loc` 含那个 `W`)。按 `mahjong-py` 引擎判定修正后,本波是三笔并赔,本旋总账从 ¥2180 变为 **¥2380**。
|
||||
|
||||
**赔付**:
|
||||
|
||||
@@ -1782,33 +1849,34 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
|---|---|---|---|---|---|
|
||||
| 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | **¥500** |
|
||||
| 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | **¥500** |
|
||||
| | | | | 小计 | **¥1000** |
|
||||
| 白板 3 连 | 0.4 | 1 | **0.40 倍** | x5 | **¥200** |
|
||||
| | | | | 小计 | **¥1200** |
|
||||
|
||||
**本波共 9 个符号被消除**(筒链 5 格 ∪ 索红绿链 5 格,重叠在 `W` 与部分格):
|
||||
(行1,轴1)G、(行4,轴1)F、(行4,轴2)G、(行3,轴2)F、**(行1,轴3) W**、(行1,轴4)G、(行3,轴4)G、(行4,轴4)F、(行3,轴5)F、(行4,轴5)G——
|
||||
`W` 参与了两条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。
|
||||
**本波共 12 个符号被消除**(筒链 6 格 ∪ 索红绿链 5 格 ∪ 白板链 3 格,其中 `W` 被三条链共用 → 6 + 5 + 3 − 2 = 12):
|
||||
轴1:G(行1)、C(行3)、F(行4) 轴2:C(行2)、F(行3)、G(行4) 轴3:**(行1) W** 轴4:G(行1)、G(行3)、F(行4) 轴5:F(行3)、G(行4)
|
||||
`W` 参与了三条链 → **被消除**;本波中奖格无金色 → **不产生新百搭**。
|
||||
|
||||
#### 盘面演变(第 4 波 → 本旋结束)
|
||||
|
||||
**① 消除瞬间**(10 格全爆:筒链 6 格 ∪ 索红绿链 5 格,重叠在 `W`):
|
||||
**① 消除瞬间**(12 格全爆:筒链 6 格 ∪ 索红绿链 5 格 ∪ 白板链 3 格,`W` 被三条链共用):
|
||||
|
||||
| 轴 → | 1 | 2 | 3 | 4 | 5 |
|
||||
|---|---|---|---|---|---|
|
||||
| 行1 | ▢ | A | ▢ | ▢ | C |
|
||||
| 行2 | H | C | A | A | S |
|
||||
| 行3 | C | ▢ | B | ▢ | ▢ |
|
||||
| 行2 | H | ▢ | A | A | S |
|
||||
| 行3 | ▢ | ▢ | B | ▢ | ▢ |
|
||||
| 行4 | ▢ | ▢ | H | ▢ | ▢ |
|
||||
|
||||
**② 符号落定(存活符号下移 + 新牌从列顶落下)→ 本旋结束时的盘面**:
|
||||
**② 符号落定(存活符号下移 + 新牌从列顶落下)→ 本旋结束时的盘面**(下表是引擎的一次真实结算,补位是新抽的牌):
|
||||
|
||||
| 轴 → | 1 | 2 | 3 | 4 | 5 | 这一列发生了什么 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 行1 | **E** | **G** | **E** | **C** | **A** | 轴1:H/C 下移两格 → 落进 2 张 |
|
||||
| 行2 | **F** | **D** | A | **D** | **B** | 轴2:A/C 下移两格 → 落进 2 张 |
|
||||
| 行3 | H | A | B | **E** | C | 轴3:A/B/H 下移一格 → 落进 1 张 |
|
||||
| 行4 | C | C | H | A | S | 轴4:A 落到底 + 落进 3 张;轴5:C/S 下移 + 落进 2 张 |
|
||||
| 行1 | **H** | **D** | **F** | **F** | **S** | 轴1:3 格空洞(G/C/F 被消)→ 只剩 H 存活、下移三格到行4;顶部补 H、G、E |
|
||||
| 行2 | **G** | **E** | A | **G** | **H** | 轴2:3 格空洞(C/F/G 被消)→ A 落到行4;顶部补 D、E、D |
|
||||
| 行3 | **E** | **D** | B | **D** | C | 轴3:只有一个空洞(`W` 被消)→ A/B/H 各上一格;顶部补 F |
|
||||
| 行4 | H | A | H | A | S | 轴4:3 格空洞 → A 落到底 + 补 F、G、D;轴5:2 格空洞 → C/S 下移两格 + 补 S、H |
|
||||
|
||||
→ 复核这个盘面:轴1 有 E/F/H/C,其中只有 C 能在轴2 对上,而**轴3 没有 C** → **无任何中奖组合,本旋结束(整局共 4 波)**。
|
||||
→ 复核这个盘面:轴1 里有 H/G/E,其中 E 能在轴2 对上(行2),但**轴3 没有 E** → 轴3 断链;H、G 在轴2 就已经断了 ⇒ **无任何中奖组合,本旋结束(整局共 4 波)**。
|
||||
|
||||
### 本旋总账
|
||||
|
||||
@@ -1820,12 +1888,13 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
| 3 | 花筒 5 连 | 1 | 1 | **1.00 倍** | x3 | ¥300 |
|
||||
| 4 | 筒 5 连 | 0.5 | 2 | **1.00 倍** | x5 | ¥500 |
|
||||
| 4 | 索红绿 5 连 | 1 | 1 | **1.00 倍** | x5 | ¥500 |
|
||||
| | | | | | 合计 | **¥2180** |
|
||||
| 4 | 白板 3 连 | 0.4 | 1 | **0.40 倍** | x5 | ¥200 |
|
||||
| | | | | | 合计 | **¥2380** |
|
||||
|
||||
> 计算方法:**本笔基础倍率 = 符号倍率 × ways**;**本波赢额 = Σ本笔基础倍率 × 投注总额 × 本波阶梯倍率**(投注总额 ¥100)。
|
||||
> 例:第 2 波 `0.75 × 4 = 3.00 倍` → `3.00 × 100 × 2 = ¥600` ✓
|
||||
|
||||
- 投注总额 ¥100 → **本局总倍率 = 2180 ÷ 100 = 21.80x**(未触及 500x 封顶)
|
||||
- 投注总额 ¥100 → **本局总倍率 = 2380 ÷ 100 = 23.80x**(未触及 500x 封顶)
|
||||
- 本旋只有 **1 个胡**(一直挂在轴5)→ **不触发免费旋转**(需 3+)
|
||||
|
||||
### 百搭轨迹(这张表是重点:**几个金色格中奖,就留几个百搭**)
|
||||
@@ -1836,21 +1905,21 @@ Base:`{host}/api/mahjong`,全部 POST + JSON + token 鉴权。
|
||||
| 第 1 波结束 | 0 → **2** | 同波有**两张**金色「中」中奖 → **各留一个百搭**(轴2、轴3 各一个)。两个 `W` 随后**都随重力从行2 掉到行3** |
|
||||
| 第 2 波结束 | 2 → **0** | **两个 `W` 都参与了八萬链**(满足消除条件)→ 一起被消除;本波无金色中奖,不补 |
|
||||
| 第 3 波结束 | 0 → **1** | 本波只有 **1 张**金色「花筒」中奖 → 留 **1 个** `W`;该列没有空洞,所以它**原地不动** |
|
||||
| 第 4 波结束 | 1 → **0** | `W` 同时参与筒链与索红绿链 → 被消除;本波无金色中奖,不补 |
|
||||
| 第 4 波结束 | 1 → **0** | `W` 同时参与筒链、索红绿链、白板链 → 被消除;本波无金色中奖,不补 |
|
||||
| 旋结束 | 0 | 无后续中奖;下一旋整盘重发 |
|
||||
|
||||
> **百搭数量没有上限**(第 1 波就同时有 2 个),但**只可能出现在第 2/3/4 轴**——因为金色格只在那三轴,而百搭的唯一来源是金色转化。
|
||||
> 这条推论很关键:**第 1 轴永远不会有百搭**,所以前缀链的第 1 轴必然是自然符号 ⇒ **任何一笔中奖都至少含 1 个自然符号** ⇒ **"纯百搭链"在数学上不可能出现**(Q1 因此永久关闭,不需要额外规则)。
|
||||
|
||||
> 全程每一波盘面上的百搭数都 **≤ 1** ✅,且**永远只由金色转化产生** ✅。
|
||||
> 每一波盘面上的百搭数 **= 本波中奖的金色格数**(0 / 1 / 2 …,无上限),且**永远只由金色转化产生** ✅。
|
||||
> 本示例**注意**:第 1 波两张金色格各留一个百搭 → **盘面同时有 2 个百搭**,第 2 波它们被一起消除。这与"每个金色格各留一个、无上限"的口径一致。
|
||||
|
||||
---
|
||||
|
||||
## 16. 免费旋转完整示例(触发 → 逐旋 → 重触发 → 触顶 30 → FINISH)
|
||||
## 16. 免费旋转完整示例(触发 → 逐旋 → 重触发 → 触顶 36 → FINISH)
|
||||
|
||||
> 场景:**普通旋转**,投注档 **¥100**。
|
||||
> 覆盖:3 胡触发 12 旋、**FS 内阶梯起点是 x2**、金色→百搭、百搭在 FS 内被用掉、FS 内重触发、**总旋数封顶 30**、FINISH 帧。
|
||||
> 覆盖:3 胡触发 12 旋、**FS 内阶梯起点是 x2**、金色→百搭、百搭在 FS 内被用掉、FS 内重触发、**总旋数封顶 36**、FINISH 帧。
|
||||
> 图例同 §15:`A`發 `B`中 `C`白板 `D`八萬 `E`花筒 `F`索红绿 `G`筒 `H`索绿 `W`百搭 `S`胡 **`*`=金色**。
|
||||
|
||||
### 16.1 基础旋(`spinNum = 0`,阶梯 x1)——触发盘
|
||||
@@ -2020,13 +2089,13 @@ boxRecordId = 背包记录 id // 末帧「放入背包 / 立即
|
||||
| 生成端补桶/饥饿重校准 | `olympus-py/olympus_simulator.py:740-805` |
|
||||
| 级联几何复刻测试 | `olympus-py/tests/test_simulator.py::TestTumbleGeometry` |
|
||||
| 平台流水 source 聚合 | `skins-model/.../TtAccountRecordSource.java:193-279` |
|
||||
| 建表 + 菜单 SQL 写法 | `docs/updata_sql.md:1144-1225` |
|
||||
| 接口契约模板 | `docs/玫瑰女郎接口文档.md`(706 行) |
|
||||
| 建表 + 菜单 SQL 写法 | ~~`docs/updata_sql.md:1144-1225`~~(该台账已按需求清空,**不再可用**)→ 建表以 `mahjong-py/schema.sql` 为权威;菜单直接对现库 `sys_menu` 操作 |
|
||||
| 接口契约模板 | ~~`docs/玫瑰女郎接口文档.md`~~(已按需求删除)→ 沿用本仓现存 `docs/疯狂转盘接口文档.md` / `docs/流水抽奖接口文档.md` 的写法 |
|
||||
|
||||
## 附录 C:待新增的运维 runbook(对齐 `olympus-py/README.md`)
|
||||
|
||||
1. **首次上线**:`mahjong_db.py` → `generate_rounds.py --all --alloc config --write` → `_make_templates.py` → `_recalc_boxes.py --mode normal` → **给 12 箱 × 4 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥500/¥1000 这两档必须单独注**)。
|
||||
2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode X` → 清 `ruoyi:mahjong:cycle:*` → `_make_templates.py`。
|
||||
1. **首次上线**:`mahjong_db.py` → `generate_rounds.py --all --alloc config --write` → `_make_templates.py` → `_recalc_boxes.py --mode normal` → **给 11 箱 × 3 身份注水**(水位从 0 起,不注水则前几局 payoutCap ≈ 0.8×下注,大奖出不来;**¥300/¥500 这两档必须单独注**)。
|
||||
2. **改配比(不重建池子)**:后台"一键重算"或 `_recalc_boxes.py --mode normal` → 清 `ruoyi:mahjong:cycle:*` → `_make_templates.py`。
|
||||
3. **重建池子**:`_regen.py snapshot` → `generate_rounds.py --clear --write` → 重算 → 清 Redis → 刷模板 → `_regen.py verify`。
|
||||
4. **调水位**:后台奖池水位页按身份逐箱注水/抽水(**原因必填**)或批量按"一轮花费基数 × 比例%"。
|
||||
5. **日常体检**:`_regen.py check`(每桶有条目 / 无封顶缺失 / 池内 RTP)+ `simulate_cycle.py` 抽样。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# mahjong-py · 麻将胡了(回合池生成器)
|
||||
|
||||
规则唯一事实来源是 `docs/麻将胡了设计方案.md`;本目录是它的 Python 实现(母本风格照 `D:\changzhou_app\changzhou_backend\olympus-py`,但**引擎是重写的**:麻将从盘面自己判定,母本只记账)。
|
||||
|
||||
## 现在有什么 / 还没有什么
|
||||
|
||||
| 状态 | 内容 |
|
||||
|---|---|
|
||||
| ✅ 已实现 | `mahjong/config.py`(规则参数唯一来源)、`models.py`、`engine.py`(前缀链 / 金色就地转百搭 / 列内重力+补位 / 阶梯 / FS / 500x 封顶)、`tests/test_engine.py`(11 项)、`mahjong_simulator.py`(自然盘面仿真) |
|
||||
| ⛔ 还没有 | `generate_rounds.py`(按桶配额主动摆盘面 + bias 反解 + 落库)、`_recalc_boxes.py`(配比)、`schema.sql`、`mahjong_db.py`、`_make_templates.py`、`simulate_cycle.py`、`_regen.py` |
|
||||
|
||||
## 跑起来
|
||||
|
||||
```bash
|
||||
cd mahjong-py
|
||||
set PYTHONIOENCODING=utf-8 # Windows:中文输出
|
||||
python tests/test_engine.py # 11 项规则单测(无需 pytest)
|
||||
python mahjong_simulator.py --rounds 200000 --seed 2026 # 自然盘面仿真(不含偏置构造)
|
||||
```
|
||||
|
||||
## 引擎的 6 条铁律(写代码时别绕开)
|
||||
|
||||
1. **前缀链**:从第 1 轴起 1→2→3→4→5 连续,断链整笔作废;`ways` 只乘链内轴。
|
||||
2. **金色格只当它自己的自然符号**(不做别的符号的替代);"顶替别人"只有 `W` 会做。
|
||||
3. **金色中奖格就地变 `W`**:不清空、不补牌;几个金格中奖就留几个 `W`(无上限)。金色只镀在第 2/3/4 轴的普通符号上 ⇒ **`W` 永远不落在第 1/5 轴**。
|
||||
4. **`W` 要满足消除链才被消除**;没参与成奖就留到下一波。同一个 `W` 可以同时被多个符号借用。
|
||||
5. **金色标记跟着符号下落**(不是跟着格号)——否则会"传染"给新补的牌。
|
||||
6. **阶梯每旋重置**:基础 `1/2/3/5`、FS `2/4/6/10`;**一屏最多 5 个胡**;FS 上限 36 旋且"发不满就不发"。
|
||||
|
||||
## 与设计文档的已知偏差(文档已同步勘误)
|
||||
|
||||
- §15 第 4 波原本只算两笔奖,漏了"同一个 `W` 也能当白板"→ 白板 3 连。修正后本波 ¥1200、本旋总账 **¥2380**(原 ¥2180)。
|
||||
- §3.9 是新加的空转率结论(65%),依据就是本目录 20 万局的自然仿真数据。
|
||||
|
||||
## ponytail 备注
|
||||
|
||||
- 生成端(配额/偏置)**故意还没写**:它的形状取决于 §3.9 的空转率口径与桶配比,先把引擎钉死再往上堆,避免拿未验证的数学去凑池子。
|
||||
- 仿真只做自然盘面,没有引入任何"目标倍率反解";`play_spin` 已经支持外部传盘面,构造器接上去即可。
|
||||
@@ -0,0 +1,405 @@
|
||||
"""麻将胡了 · 回合池生成器:把引擎跑出的回合写进 `game_mahjong_round` / `game_mahjong_step`。
|
||||
|
||||
两种模式:
|
||||
--mode natural (默认)自然形态:不干预,直接采样落库。**RTP≈5.25,只能测链路,不能当验收**。
|
||||
--mode quota 配额构造:先标定每桶池内实测均值 → 二分解出 16 桶配额(空转率锁 65%、目标 RTP 0.80)
|
||||
→ 按配额定量填充。**这才是能上线/能做 RTP 验收的池子**。
|
||||
|
||||
用法(在 mahjong-py 目录下):
|
||||
python generate_rounds.py --mode quota --per-box 1000 --dry-run
|
||||
python generate_rounds.py --mode quota --per-box 1000 --write
|
||||
python generate_rounds.py --mode natural --per-box 100 --dry-run
|
||||
python generate_rounds.py --boxes 1,2 --per-box 100 --write --mode quota
|
||||
|
||||
写库内容(两种模式一样):
|
||||
1. `game_mahjong_round`:每局一行(bucket_id / actual_mul / 旋数 / 波数 / has_fs / near_miss)
|
||||
2. `game_mahjong_step`:每帧一行(逐波;含 grid_state / ways_detail / 下落补位 / 阶梯 / 触发信息)
|
||||
3. `game_mahjong_box_bucket`:**配额就是 count**(3 个身份各一套,used=0)
|
||||
4. `game_mahjong_box_template`:每箱每身份一条生效模板(= 配额快照,供抽干补桶)
|
||||
5. `game_mahjong_box.total_rounds`:回写成本次生成的回合数
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from mahjong import config as C # noqa: E402
|
||||
from mahjong.engine import GRID_COLS, GRID_ROWS, play_round # noqa: E402
|
||||
|
||||
DB = dict(host="127.0.0.1", port=23306, user="henan", password="iTSJSPPZM3LSGAPC", database="henan")
|
||||
EMPTY_SHARE = 0.65 # 空转率(桶 1 配额占比,见设计文档 §3.9);未按箱分档时的统一值
|
||||
|
||||
# 按档调波动(用户 2026-09-22 定):**所有箱子 RTP 都锁 0.80**,但波动不同 ——
|
||||
# 低档(1~20):空转低 → 剩余配额必须更"平" → 小奖更多、手感热;
|
||||
# 高档(200~500):空转高 → 剩余配额更"陡" → 中大奖更多、博性更强。
|
||||
# 机制:`solve_quotas` 会把"剩余部分"的加权均值凑到 `0.80/(1-空转率)`,所以
|
||||
# **只改空转率就能同时改形状**,不用再单独调 power。
|
||||
VOLATILITY_LADDER = (
|
||||
(20, 0.62, "低档:空转 62%,小奖更多(热)"),
|
||||
(100, 0.65, "中档:空转 65%(设计基准)"),
|
||||
(10 ** 9, 0.68, "高档:空转 68%,中大奖更多(陡)"),
|
||||
)
|
||||
|
||||
|
||||
def empty_share_for(box_id: int) -> float:
|
||||
bet = C.bet_of(box_id)
|
||||
for upper, share, _ in VOLATILITY_LADDER:
|
||||
if bet <= upper:
|
||||
return share
|
||||
return EMPTY_SHARE
|
||||
TARGET_RTP = 0.80
|
||||
CALIB_ROUNDS = 6000 # 每箱标定样本(用来量每个桶的**池内实测**均值)
|
||||
MAX_TRIES = 400_000 # 单个桶的采样上限(防低概率桶把时间吃光)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 行构造
|
||||
def grid_rows(flat) -> list:
|
||||
"""扁平 20 格 → 4 行 × 5 列(接口用的 gridState 形状)。"""
|
||||
return [list(flat[r * GRID_COLS:(r + 1) * GRID_COLS]) for r in range(GRID_ROWS)]
|
||||
|
||||
|
||||
def build_steps(round_, round_id: int):
|
||||
"""把一局摊成逐帧行。"""
|
||||
rows, step_seq = [], 0
|
||||
spin_win = 0.0
|
||||
for spin in round_.spins:
|
||||
spin_win_before = spin_win
|
||||
acc_sym = Counter()
|
||||
winners = []
|
||||
rotations = spin.rotations
|
||||
frames = rotations or (None,) # 空旋也要发一帧(落定盘面),否则客户端无从渲染
|
||||
for i, rot in enumerate(frames, start=1):
|
||||
step_seq += 1
|
||||
if rot is None:
|
||||
grid_flat = spin.landing
|
||||
ways, removed, drops, fills, conv, gold = [], [], [], [], [], []
|
||||
base_mul, rot_mul = 0.0, C.ladder_mul(1, spin.is_fs)
|
||||
else:
|
||||
grid_flat = rot.grid_before
|
||||
gold = [{"pos": p, "symbol": rot.grid_before[p - 1]} for p in rot.golden_before]
|
||||
conv = [{"pos": p, "fromSymbol": s} for p, s in rot.converted]
|
||||
ways = [{"symbol": w.symbol, "reelCount": w.run, "counts": list(w.counts),
|
||||
"loc": list(w.loc), "ways": w.ways, "payBase": w.pay_base,
|
||||
"rotationMul": rot.rotation_mul,
|
||||
"win": round(w.pay_base * rot.rotation_mul, 4)}
|
||||
for w in rot.wins]
|
||||
seen = set()
|
||||
removed = [p for w in rot.wins for p in w.loc if not (p in seen or seen.add(p))]
|
||||
drops = [{"from": a, "to": b, "symbol": s} for a, b, s in rot.drops]
|
||||
fills = [{"pos": p, "symbol": s} for p, s in rot.fills]
|
||||
base_mul, rot_mul = rot.base_mul, rot.rotation_mul
|
||||
for w in rot.wins:
|
||||
acc_sym[w.symbol] += len(w.loc)
|
||||
winners.append({"symbol": w.symbol, "count": w.run,
|
||||
"win": round(w.pay_base * rot.rotation_mul, 4)})
|
||||
spin_win = spin_win_before + sum(r.win_mul for r in rotations[:i])
|
||||
is_last = (i == len(frames))
|
||||
scat = ({"scatterCount": spin.scatters, "freeSpins": spin.fs_awarded, "extraSpins": 0}
|
||||
if spin.index == 0 and i == 1 and spin.fs_awarded else None)
|
||||
retri = ({"scatterCount": spin.scatters, "extraSpins": C.fs_award(spin.scatters)}
|
||||
if spin.index > 0 and i == 1 and spin.retrigger else None)
|
||||
rows.append(dict(
|
||||
id=round_id * 100 + step_seq,
|
||||
round_id=round_id,
|
||||
reel_spin_id=spin.index,
|
||||
rotation_seq=i,
|
||||
grid_state=json.dumps(grid_rows(grid_flat), ensure_ascii=False) if grid_flat else None,
|
||||
golden_detail=json.dumps(gold, ensure_ascii=False) if gold else None,
|
||||
wild_converted=json.dumps(conv, ensure_ascii=False) if conv else None,
|
||||
ways_detail=json.dumps(ways, ensure_ascii=False) if ways else None,
|
||||
accumulated_symbols=json.dumps(dict(acc_sym), ensure_ascii=False),
|
||||
spin_winners=json.dumps(winners, ensure_ascii=False),
|
||||
removed_positions=json.dumps(removed),
|
||||
drop_data=json.dumps(drops, ensure_ascii=False) if drops else None,
|
||||
fill_data=json.dumps(fills, ensure_ascii=False) if fills else None,
|
||||
scatter_trigger=json.dumps(scat, ensure_ascii=False) if scat else None,
|
||||
retrigger_info=json.dumps(retri, ensure_ascii=False) if retri else None,
|
||||
base_score=round(base_mul, 4),
|
||||
win_amount=round(sum(r.win_mul for r in rotations[:i]), 4),
|
||||
rotation_mul=rot_mul,
|
||||
spin_win=round(spin_win, 4),
|
||||
win_capped=1 if (round_.capped and is_last and spin is round_.spins[-1]) else 0,
|
||||
bigwin_level=None,
|
||||
is_final=1 if is_last else 0,
|
||||
))
|
||||
spin_win = spin_win_before + sum(r.win_mul for r in rotations)
|
||||
return rows
|
||||
|
||||
|
||||
def make_round(box_id: int, seq: int, rng: random.Random):
|
||||
r = play_round(rng, box_id=box_id)
|
||||
round_id = box_id * 1_000_000 + seq
|
||||
steps = build_steps(r, round_id=round_id)
|
||||
row = dict(round_id=round_id, box_id=box_id, bucket_id=C.bucket_of(r.actual_mul),
|
||||
actual_mul=round(r.actual_mul, 4), free_spin_count=r.free_spins,
|
||||
total_rotations=sum(len(s.rotations) for s in r.spins),
|
||||
has_fs=1 if r.has_fs else 0, near_miss=1 if r.near_miss else 0)
|
||||
return row, steps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 配额构造
|
||||
def calibrate(box_id: int, n: int, seed: int):
|
||||
"""小样本标定:每个桶的**池内实测均值**(自然均值与名义 expect_mul 差很多,必须实测)。"""
|
||||
rng = random.Random(seed)
|
||||
tot, cnt = Counter(), Counter()
|
||||
for _ in range(n):
|
||||
r = play_round(rng, box_id=box_id)
|
||||
b = C.bucket_of(r.actual_mul)
|
||||
tot[b] += r.actual_mul
|
||||
cnt[b] += 1
|
||||
means = {}
|
||||
for b in C.BUCKETS:
|
||||
means[b.id] = (tot[b.id] / cnt[b.id]) if cnt[b.id] else b.expect_mul
|
||||
return means, cnt
|
||||
|
||||
|
||||
def _quota_from_power(means, power: float, empty_share: float):
|
||||
"""w_b = 30×0.6^((b-1)×power)(b≥2),桶 1 锁 empty_share;返回 16 个配额(下标 1..16)。"""
|
||||
ws = {b.id: 30.0 * (0.6 ** ((b.id - 1) * power)) for b in C.BUCKETS if b.id >= 2}
|
||||
s = sum(ws.values())
|
||||
q = {b.id: 0.0 for b in C.BUCKETS}
|
||||
q[1] = empty_share
|
||||
for b_id, w in ws.items():
|
||||
q[b_id] = (1.0 - empty_share) * w / s
|
||||
return q
|
||||
|
||||
|
||||
def solve_quotas(means, target_rtp=TARGET_RTP, empty_share=EMPTY_SHARE):
|
||||
"""二分 power,使 Σ q_b × mean_b = target_rtp。power 越大越偏向低倍桶(期望越低)。"""
|
||||
def expected(power):
|
||||
q = _quota_from_power(means, power, empty_share)
|
||||
return sum(q[b.id] * means[b.id] for b in C.BUCKETS)
|
||||
lo, hi = 0.05, 8.0
|
||||
# expected(power) 随 power **递减**:power 越大越偏向低倍桶
|
||||
for _ in range(80):
|
||||
mid = (lo + hi) / 2
|
||||
if expected(mid) > target_rtp:
|
||||
lo = mid # 期望偏高 → 还要更大的 power
|
||||
else:
|
||||
hi = mid
|
||||
q = _quota_from_power(means, (lo + hi) / 2, empty_share)
|
||||
return q, expected((lo + hi) / 2)
|
||||
|
||||
|
||||
def _fill(box_id: int, targets: dict, seed: int, max_tries=MAX_TRIES):
|
||||
"""按桶目标条数填充:落进目标桶才收。返回 (pool, bucket_cnt, short)。"""
|
||||
rng = random.Random(seed)
|
||||
pool, tries, short = [], Counter(), []
|
||||
for b in C.BUCKETS:
|
||||
got = 0
|
||||
while got < targets[b.id] and tries[b.id] < max_tries:
|
||||
tries[b.id] += 1
|
||||
row, steps = make_round(box_id, len(pool) + 1, rng)
|
||||
if row["bucket_id"] == b.id:
|
||||
pool.append((row, steps))
|
||||
got += 1
|
||||
if got < targets[b.id]:
|
||||
short.append(f"桶{b.id} {got}/{targets[b.id]}")
|
||||
for i, (row, steps) in enumerate(pool, start=1):
|
||||
rid = box_id * 1_000_000 + i
|
||||
row["round_id"] = rid
|
||||
for j, st in enumerate(steps, start=1):
|
||||
st["round_id"] = rid
|
||||
st["id"] = rid * 100 + j
|
||||
cnt = Counter(row["bucket_id"] for row, _ in pool)
|
||||
return pool, cnt, short
|
||||
|
||||
|
||||
def quota_targets(per_box: int, quotas: dict):
|
||||
targets = {b.id: int(round(per_box * quotas[b.id])) for b in C.BUCKETS}
|
||||
diff = per_box - sum(targets.values())
|
||||
if diff:
|
||||
targets[1] += diff
|
||||
return targets
|
||||
|
||||
|
||||
def _sample_bucket(box_id: int, bucket_id: int, rng: random.Random, max_tries=MAX_TRIES):
|
||||
"""采一条落进指定桶的回合(低概率桶会多试几次)。"""
|
||||
for _ in range(max_tries):
|
||||
row, steps = make_round(box_id, 0, rng)
|
||||
if row["bucket_id"] == bucket_id:
|
||||
return row, steps
|
||||
return None
|
||||
|
||||
|
||||
def _tune(box_id: int, pool: list, target_rtp: float, per_box: int, seed: int, verbose=True):
|
||||
"""整数可执行的定向微调:配额算出来是小数,四舍五入会把「0.4 条」的尾部桶抹成 0 条,
|
||||
于是它本该贡献的期望就丢了(实测池内均值会系统性偏低 5~8pt)。
|
||||
|
||||
做法:拿空转局(桶 1,倍率 0)去换中高桶的回合,每换一条按**真实倍率**结账,
|
||||
直到池内均值落进目标 ±0.005 以内。空转率只会动几条(<0.5pt)。
|
||||
"""
|
||||
rng = random.Random(seed + 31337)
|
||||
cur = sum(r["actual_mul"] for r, _ in pool)
|
||||
need = target_rtp * per_box - cur # 还差多少(单位:倍率总和)
|
||||
empties = [i for i, (r, _) in enumerate(pool) if r["bucket_id"] == 1]
|
||||
ordered = sorted((x for x in C.BUCKETS if x.id >= 2), key=lambda x: -x.expect_mul)
|
||||
swaps = 0
|
||||
while need > 0.6 and empties and ordered:
|
||||
cand = next((b for b in ordered if b.expect_mul <= need), None)
|
||||
if cand is None:
|
||||
break
|
||||
got = None
|
||||
for _ in range(300): # 只收"不超过缺口"的回合,保证不会冲过头
|
||||
s = _sample_bucket(box_id, cand.id, rng)
|
||||
if s and s[0]["actual_mul"] <= need:
|
||||
got = s
|
||||
break
|
||||
if got is None: # 这一档试不出来,退一档
|
||||
ordered = [b for b in ordered if b.id != cand.id]
|
||||
continue
|
||||
row, steps = got
|
||||
pool[empties.pop()] = (row, steps)
|
||||
need -= row["actual_mul"]
|
||||
swaps += 1
|
||||
rng = random.Random(rng.random()) # 让后续采样换流
|
||||
if verbose and swaps:
|
||||
print(f" 微调:换入 {swaps} 条中高桶回合(空转率随之动 {swaps / per_box:.2%})")
|
||||
cnt = Counter(r["bucket_id"] for r, _ in pool)
|
||||
rtp = sum(r["actual_mul"] for r, _ in pool) / max(1, len(pool))
|
||||
if verbose:
|
||||
print(f" 微调后池内实测 RTP {rtp:.4f}(与目标差 {abs(rtp - target_rtp):.4f})")
|
||||
return pool, cnt
|
||||
|
||||
|
||||
def generate_quota(box_id: int, per_box: int, seed: int, empty_share: float, target_rtp: float,
|
||||
verbose=True):
|
||||
"""解配额 → 定条数 → 填充 → 整数微调 → 重排 id。
|
||||
|
||||
`empty_share`:桶 1 的配额占比。CLI 不传时按 `empty_share_for(box_id)` 取**按档波动**的值
|
||||
(低档 62% / 中档 65% / 高档 68%),这样各箱形状不同、但 RTP 都锁在 `target_rtp`。
|
||||
|
||||
为什么填完还要微调:运行时按库存消耗(一个 cycle 每条回合正好播一次)⇒ `cycle RTP = 池内均值`,
|
||||
而配额是连续解出来的小数,四舍五入会抹掉尾部桶(见 `_tune` 的注释)。
|
||||
"""
|
||||
means, _ = calibrate(box_id, CALIB_ROUNDS, seed)
|
||||
quotas, exp_rtp = solve_quotas(means, target_rtp, empty_share)
|
||||
targets = quota_targets(per_box, quotas)
|
||||
if verbose:
|
||||
print(f" 标定:配额理论期望 {exp_rtp:.4f}(目标 {target_rtp});桶1 配额 {quotas[1]:.2%};"
|
||||
f"桶条数(1/2/3/12/16) = {targets[1]}/{targets[2]}/{targets[3]}/{targets[12]}/{targets[16]}")
|
||||
pool, cnt, short = _fill(box_id, targets, seed + 999 + box_id * 7919)
|
||||
pool, cnt = _tune(box_id, pool, target_rtp, per_box, seed + box_id, verbose)
|
||||
for i, (row, steps) in enumerate(pool, start=1): # 重排 id(采样与微调都打乱了顺序)
|
||||
rid = box_id * 1_000_000 + i
|
||||
row["round_id"] = rid
|
||||
for j, st in enumerate(steps, start=1):
|
||||
st["round_id"] = rid
|
||||
st["id"] = rid * 100 + j
|
||||
return pool, quotas, means, exp_rtp, short
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 写库
|
||||
def write(conn, pool_by_box, per_box, audiences=C.AUDIENCES):
|
||||
cur = conn.cursor()
|
||||
total_r = total_s = 0
|
||||
for box, (pool, quotas, bucket_cnt) in pool_by_box.items():
|
||||
rounds = [row for row, _ in pool]
|
||||
steps = [st for _, sts in pool for st in sts]
|
||||
lo, hi = box * 1_000_000 + 1, box * 1_000_000 + per_box
|
||||
cur.execute(f"DELETE FROM game_mahjong_step WHERE round_id BETWEEN {lo} AND {hi}")
|
||||
cur.execute(f"DELETE FROM game_mahjong_round WHERE box_id={box}")
|
||||
cur.executemany(
|
||||
"INSERT INTO game_mahjong_round (round_id,box_id,bucket_id,actual_mul,free_spin_count,"
|
||||
"total_rotations,has_fs,near_miss) VALUES (%(round_id)s,%(box_id)s,%(bucket_id)s,"
|
||||
"%(actual_mul)s,%(free_spin_count)s,%(total_rotations)s,%(has_fs)s,%(near_miss)s)", rounds)
|
||||
cols = ("id", "round_id", "reel_spin_id", "rotation_seq", "grid_state", "golden_detail",
|
||||
"wild_converted", "ways_detail", "accumulated_symbols", "spin_winners",
|
||||
"removed_positions", "drop_data", "fill_data", "scatter_trigger", "retrigger_info",
|
||||
"base_score", "win_amount", "rotation_mul", "spin_win", "win_capped",
|
||||
"bigwin_level", "is_final")
|
||||
cur.executemany(
|
||||
f"INSERT INTO game_mahjong_step ({','.join(cols)}) "
|
||||
f"VALUES ({','.join('%(' + c + ')s' for c in cols)})", steps)
|
||||
for aud in audiences:
|
||||
cur.execute("DELETE FROM game_mahjong_box_bucket WHERE box_id=%s AND audience=%s", (box, aud))
|
||||
cur.executemany(
|
||||
"INSERT INTO game_mahjong_box_bucket (box_id,bucket_id,audience,count,used) "
|
||||
"VALUES (%s,%s,%s,%s,0)",
|
||||
[(box, b.id, aud, bucket_cnt.get(b.id, 0)) for b in C.BUCKETS])
|
||||
items = json.dumps([{"bucketId": b.id, "count": bucket_cnt.get(b.id, 0)} for b in C.BUCKETS],
|
||||
ensure_ascii=False)
|
||||
cur.execute("DELETE FROM game_mahjong_box_template WHERE box_id=%s AND audience=%s", (box, aud))
|
||||
cur.execute(
|
||||
"INSERT INTO game_mahjong_box_template (box_id,audience,name,total_rounds,items_json,is_active) "
|
||||
"VALUES (%s,%s,%s,%s,%s,1)",
|
||||
(box, aud, f"配额池-{per_box}局", per_box, items))
|
||||
cur.execute("UPDATE game_mahjong_box SET total_rounds=%s WHERE box_id=%s", (per_box, box))
|
||||
conn.commit()
|
||||
total_r += len(rounds)
|
||||
total_s += len(steps)
|
||||
rtp = sum(r["actual_mul"] for r in rounds) / max(1, len(rounds))
|
||||
print(f" 箱 {box:2d}(赌注 {C.bet_of(box):>3}):回合 {len(rounds):>5} 帧 {len(steps):>6} "
|
||||
f"**池内实测 RTP {rtp:.4f}** 空转 {bucket_cnt.get(1,0)/max(1,len(rounds)):.1%} "
|
||||
f"FS 局 {sum(r['has_fs'] for r in rounds)}")
|
||||
print(f"合计:回合 {total_r},帧 {total_s}")
|
||||
return total_r, total_s
|
||||
|
||||
|
||||
def build_natural(box_id, per_box, seed):
|
||||
rng = random.Random(seed + box_id)
|
||||
pool, cnt = [], Counter()
|
||||
for i in range(1, per_box + 1):
|
||||
row, steps = make_round(box_id, i, rng)
|
||||
pool.append((row, steps))
|
||||
cnt[row["bucket_id"]] += 1
|
||||
return pool, cnt
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--mode", choices=("natural", "quota"), default="natural")
|
||||
ap.add_argument("--per-box", type=int, default=1000)
|
||||
ap.add_argument("--boxes", default="")
|
||||
ap.add_argument("--seed", type=int, default=20260922)
|
||||
ap.add_argument("--empty-share", type=float, default=None,
|
||||
help="空转率(桶 1 配额占比);不传=按档取(低档 0.62 / 中档 0.65 / 高档 0.68)")
|
||||
ap.add_argument("--target-rtp", type=float, default=TARGET_RTP)
|
||||
ap.add_argument("--write", action="store_true")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--host", default=DB["host"]); ap.add_argument("--port", type=int, default=DB["port"])
|
||||
ap.add_argument("--user", default=DB["user"]); ap.add_argument("--password", default=DB["password"])
|
||||
ap.add_argument("--database", default=DB["database"])
|
||||
args = ap.parse_args()
|
||||
|
||||
boxes = [int(x) for x in args.boxes.split(",") if x.strip()] or list(C.BOX_IDS)
|
||||
print(f"==== {args.mode} 模式:{len(boxes)} 箱 × {args.per_box} 局"
|
||||
f"{'' if args.write and not args.dry_run else '(干跑,不写库)'} ====")
|
||||
pool_by_box = {}
|
||||
for box in boxes:
|
||||
if args.mode == "natural":
|
||||
pool, cnt = build_natural(box, args.per_box, args.seed)
|
||||
quotas = None
|
||||
pool_by_box[box] = (pool, quotas, cnt)
|
||||
else:
|
||||
share = args.empty_share if args.empty_share is not None else empty_share_for(box)
|
||||
pool, quotas, means, exp_rtp, short = generate_quota(
|
||||
box, args.per_box, args.seed, share, args.target_rtp)
|
||||
cnt = Counter(row["bucket_id"] for row, _ in pool)
|
||||
if short:
|
||||
print(f" 未填满:{';'.join(short)}")
|
||||
pool_by_box[box] = (pool, quotas, cnt)
|
||||
if not (args.write and not args.dry_run):
|
||||
rows = [r for r, _ in pool_by_box[box][0]]
|
||||
rtp = sum(r["actual_mul"] for r in rows) / max(1, len(rows))
|
||||
print(f" 箱 {box:2d}(赌注 {C.bet_of(box):>3}):回合 {len(rows):>5} "
|
||||
f"**池内实测 RTP {rtp:.4f}** 空转 {cnt.get(1,0)/max(1,len(rows)):.1%} "
|
||||
f"FS 局 {sum(r['has_fs'] for r in rows)}")
|
||||
if not (args.write and not args.dry_run):
|
||||
return
|
||||
import pymysql
|
||||
conn = pymysql.connect(host=args.host, port=args.port, user=args.user,
|
||||
password=args.password, database=args.database, charset="utf8mb4")
|
||||
write(conn, pool_by_box, args.per_box)
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""麻将胡了 · 生成器包(规则参数 / 模型 / 引擎)。"""
|
||||
from .config import * # noqa: F401,F403
|
||||
from .engine import ( # noqa: F401
|
||||
assert_no_native_wild, assert_wild_only_middle, count_scatters, new_grid,
|
||||
play_round, play_spin, pos, settle_rotation, ways_wins,
|
||||
)
|
||||
from .models import Round, Rotation, Spin, Win # noqa: F401
|
||||
@@ -0,0 +1,166 @@
|
||||
"""麻将胡了 · 唯一事实来源(规则参数)。
|
||||
|
||||
数值全部取自 docs/麻将胡了设计方案.md(§2 规则 / §3 桶与 RTP / §2.8 投注档)。
|
||||
改规则改这里;生成器、引擎、单测、仿真都从这里读。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Mapping, Tuple
|
||||
|
||||
# ============================================================
|
||||
# 盘面:5 轴 × 4 行 = 20 格,ways = 4^5 = 1024
|
||||
# ============================================================
|
||||
GRID_COLS = 5
|
||||
GRID_ROWS = 4
|
||||
GRID_SIZE = GRID_ROWS * GRID_COLS
|
||||
WAYS = GRID_ROWS ** GRID_COLS
|
||||
|
||||
WILD = "W" # 百搭(元宝):只能由金色符号转化产生,卷轴永不落
|
||||
SCATTER = "S" # 胡(夺宝):只触发免费旋转,不赔现金
|
||||
|
||||
# 格位约定:接口/落库用 1 基、行优先(1~20);引擎内部用 0 基。
|
||||
# 引擎内 index = row * GRID_COLS + col;pos() 负责换算(见 engine.py)。
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SymbolRule:
|
||||
code: str
|
||||
name: str
|
||||
payouts: Mapping[int, float] # 连轴数(3/4/5) → 倍投注总额
|
||||
|
||||
|
||||
# 赔付表:原版"每条基础投注点数"已 ÷20 内化,表中数值**直接是倍率**
|
||||
SYMBOL_RULES: Dict[str, SymbolRule] = {
|
||||
"A": SymbolRule("A", "發", {3: 0.75, 4: 3.0, 5: 5.0}),
|
||||
"B": SymbolRule("B", "中", {3: 0.5, 4: 2.0, 5: 4.0}),
|
||||
"C": SymbolRule("C", "白板", {3: 0.4, 4: 1.0, 5: 3.0}),
|
||||
"D": SymbolRule("D", "八萬", {3: 0.3, 4: 0.75, 5: 2.0}),
|
||||
"E": SymbolRule("E", "花筒", {3: 0.2, 4: 0.5, 5: 1.0}),
|
||||
"F": SymbolRule("F", "索红绿", {3: 0.2, 4: 0.5, 5: 1.0}),
|
||||
"G": SymbolRule("G", "筒", {3: 0.1, 4: 0.25, 5: 0.5}),
|
||||
"H": SymbolRule("H", "索绿", {3: 0.1, 4: 0.25, 5: 0.5}),
|
||||
}
|
||||
PAYING: Tuple[str, ...] = ("A", "B", "C", "D", "E", "F", "G", "H")
|
||||
PAYTABLE: Dict[str, Mapping[int, float]] = {code: r.payouts for code, r in SYMBOL_RULES.items()}
|
||||
MIN_CHAIN = 3 # 前缀链至少 3 连才成奖(只有 3/4/5 三档)
|
||||
|
||||
# 卷轴出现权重:只有 8 个付费符号 + 胡(**没有 W**,原生盘面永不落百搭)
|
||||
SYMBOL_WEIGHTS: Dict[str, int] = {
|
||||
"A": 5, "B": 6, "C": 8, "D": 9, "E": 11, "F": 11, "G": 13, "H": 13, "S": 2,
|
||||
}
|
||||
|
||||
# 金色麻将符号:只可能出现在第 2/3/4 轴(0 基列 1..3)
|
||||
GOLDEN_COLS: Tuple[int, ...] = (1, 2, 3)
|
||||
GOLDEN_CELL_PROB = 0.10 # 逐格独立;新补入的符号也可以带金
|
||||
|
||||
# ============================================================
|
||||
# 乘数阶梯(本波阶梯倍率):每旋从第一档重新开始,不跨旋累计
|
||||
# ============================================================
|
||||
LADDER_BASE: Tuple[int, ...] = (1, 2, 3, 5)
|
||||
LADDER_FS: Tuple[int, ...] = (2, 4, 6, 10)
|
||||
|
||||
|
||||
def ladder_mul(rotation_seq: int, is_fs: bool) -> int:
|
||||
"""第 n 波(1 基)的阶梯倍率;第 4 波及以后取最后一档。"""
|
||||
if rotation_seq < 1:
|
||||
raise ValueError("波序从 1 开始")
|
||||
ladder = LADDER_FS if is_fs else LADDER_BASE
|
||||
return ladder[min(rotation_seq, len(ladder)) - 1]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 免费旋转:3+ 胡 → 12 旋 + 每多 1 个 +2;一屏最多 5 个胡;上限 36 旋
|
||||
# "发不满就不发":已发放 + 本次应赠 > 36 时,本旋绝不落 3+ 胡
|
||||
# ============================================================
|
||||
SCATTER_TRIGGER = 3
|
||||
FS_BASE_SPINS = 12
|
||||
FS_EXTRA_PER_SCATTER = 2
|
||||
MAX_SCATTER_PER_SCREEN = 5
|
||||
MAX_FREE_SPINS = 36
|
||||
|
||||
|
||||
def fs_award(scatter_count: int) -> int:
|
||||
return FS_BASE_SPINS + FS_EXTRA_PER_SCATTER * max(0, scatter_count - SCATTER_TRIGGER)
|
||||
|
||||
|
||||
# 单局封顶:总倍率到达即截断,剩余旋作废
|
||||
MAX_WIN_CAP_MUL = 500
|
||||
|
||||
# 悬念盘:基础旋恰好 2 个胡且第 5 轴无胡(生成端配额,客户端播第 5 列放大)
|
||||
NEARMISS_SCATTER_COUNT = 2
|
||||
NEARMISS_2SCATTER_RATE = 0.015
|
||||
|
||||
# ============================================================
|
||||
# 投注档:11 档纯投注档,无投注倍数(betMul 仅预留、服务端只接受 1)
|
||||
# price = cost = box.bet;boxId = 序号(1~11)
|
||||
# ============================================================
|
||||
BET_LADDER: Tuple[int, ...] = (1, 3, 5, 10, 20, 30, 50, 100, 200, 300, 500)
|
||||
BOX_IDS: Tuple[int, ...] = tuple(range(1, len(BET_LADDER) + 1))
|
||||
|
||||
|
||||
def bet_of(box_id: int) -> int:
|
||||
return BET_LADDER[box_id - 1]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 饰品桶(16 档,0x~500x):沿用玫瑰女郎 17 档结构、删掉 1000x 那档
|
||||
# 期望倍率 = (区间下限 + 上限) / 2;区间连续无缝
|
||||
# 桶 16 区间上界故意开到 700 作缓冲,但生成端"先按 500 截断再落桶"
|
||||
# ============================================================
|
||||
@dataclass(frozen=True)
|
||||
class Bucket:
|
||||
id: int
|
||||
name: str
|
||||
expect_mul: float
|
||||
range_low: float
|
||||
range_high: float
|
||||
|
||||
|
||||
BUCKETS: Tuple[Bucket, ...] = (
|
||||
Bucket(1, "0倍饰品", 0.0, 0.0, 0.0),
|
||||
Bucket(2, "0.5倍饰品", 0.5, 0.01, 1.0),
|
||||
Bucket(3, "1.5倍饰品", 1.5, 1.0, 2.0),
|
||||
Bucket(4, "2.5倍饰品", 2.5, 2.0, 3.0),
|
||||
Bucket(5, "4倍饰品", 4.0, 3.0, 5.0),
|
||||
Bucket(6, "6.5倍饰品", 6.5, 5.0, 8.0),
|
||||
Bucket(7, "10倍饰品", 10.0, 8.0, 12.0),
|
||||
Bucket(8, "16倍饰品", 16.0, 12.0, 20.0),
|
||||
Bucket(9, "25倍饰品", 25.0, 20.0, 30.0),
|
||||
Bucket(10, "40倍饰品", 40.0, 30.0, 50.0),
|
||||
Bucket(11, "60倍饰品", 60.0, 50.0, 70.0),
|
||||
Bucket(12, "100倍饰品", 100.0, 70.0, 130.0),
|
||||
Bucket(13, "150倍饰品", 150.0, 130.0, 170.0),
|
||||
Bucket(14, "200倍饰品", 200.0, 170.0, 230.0),
|
||||
Bucket(15, "300倍饰品", 300.0, 230.0, 370.0),
|
||||
Bucket(16, "500倍饰品(封顶)", 500.0, 370.0, 700.0),
|
||||
)
|
||||
FIXED_CAP_BUCKET_ID = 16 # 封顶桶
|
||||
|
||||
|
||||
def bucket_of(mul: float) -> int:
|
||||
"""按**已截断**的倍率落桶(顺序:先 min(mul, 500) 再落桶)。<0.01 视为空转。"""
|
||||
mul = min(float(mul), MAX_WIN_CAP_MUL)
|
||||
if mul < 0.01:
|
||||
return 1
|
||||
for b in BUCKETS:
|
||||
if b.range_low <= mul <= b.range_high:
|
||||
return b.id
|
||||
return FIXED_CAP_BUCKET_ID
|
||||
|
||||
|
||||
# ============================================================
|
||||
# RTP 与身份
|
||||
# ============================================================
|
||||
RTP = 0.80 # 目标返奖率(后台可按箱改)
|
||||
CONVERGE_RATIO = 0.15 # 收敛阶段占比(一轮 N = fixedSum / (1 - cr))
|
||||
AUDIENCE_COMMON = "common" # 其余全部(**含 user_type='03' 机器人**)
|
||||
AUDIENCE_ANCHOR = "anchor" # user_type = '01'
|
||||
AUDIENCE_WHITE = "white" # is_blank_account = 1(最高优先)
|
||||
# 只有 3 个身份维度:robot 维度已按用户要求去掉(2026-09-22),机器人归 common
|
||||
AUDIENCES: Tuple[str, ...] = (AUDIENCE_COMMON, AUDIENCE_ANCHOR, AUDIENCE_WHITE)
|
||||
|
||||
# 生成器/池子规模(先小规模验证,再放大到线上)
|
||||
ALLOC_K = 1.5 # 池子 = 配比 × 1.5
|
||||
ALLOC_FLOOR = 60 # 每桶保底条数
|
||||
SMOKE_ROUNDS_PER_BUCKET = 20 # `--per-bucket 20` 那种冒烟规模
|
||||
@@ -0,0 +1,279 @@
|
||||
"""麻将胡了 · 规则引擎(服务端权威判定的唯一实现)。
|
||||
|
||||
与母本 olympus/engine.py 的差别(母本只记账、消除由 simulator 决定):
|
||||
本引擎**从盘面自己判定**——1024 路前缀链、金色就地转百搭、列内重力+补位、阶梯、FS、500x 封顶。
|
||||
生成端(mahjong_simulator.py)只负责"把盘面摆成想要的形状",摆完之后一切由这里算。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import replace
|
||||
from typing import Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
from .config import (
|
||||
GOLDEN_CELL_PROB, GOLDEN_COLS, GRID_COLS, GRID_ROWS, MAX_SCATTER_PER_SCREEN,
|
||||
MAX_WIN_CAP_MUL, MIN_CHAIN, NEARMISS_SCATTER_COUNT, PAYING, PAYTABLE, SCATTER,
|
||||
SCATTER_TRIGGER, SYMBOL_WEIGHTS, WILD, fs_award, ladder_mul,
|
||||
)
|
||||
from .models import Round, Rotation, Spin, Win
|
||||
|
||||
Cell = Optional[str] # None = 空洞(结算中间态,必被补位填满)
|
||||
Grid = List[Cell]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 格位换算
|
||||
def pos(row: int, col: int) -> int:
|
||||
"""0 基 (行,列) → 接口/落库用的 1 基格位 1~20(行优先)。"""
|
||||
return row * GRID_COLS + col + 1
|
||||
|
||||
|
||||
def index_of(p: int) -> int:
|
||||
"""1 基格位 → 0 基内部下标。"""
|
||||
return p - 1
|
||||
|
||||
|
||||
def _rc(i: int) -> Tuple[int, int]:
|
||||
return divmod(i, GRID_COLS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 盘面构造
|
||||
def _pick(rng: random.Random) -> str:
|
||||
"""卷轴落一个符号:只从 8 付费符号 + 胡里出(永不落 W)。"""
|
||||
codes = tuple(SYMBOL_WEIGHTS)
|
||||
return rng.choices(codes, weights=[SYMBOL_WEIGHTS[c] for c in codes])[0]
|
||||
|
||||
|
||||
def _mark_golden(rng: random.Random, col: int, sym: str) -> bool:
|
||||
"""金色只在第 2/3/4 轴,且只打在普通符号上(百搭/胡不变金)。"""
|
||||
return (col in GOLDEN_COLS and sym != SCATTER and sym != WILD
|
||||
and rng.random() < GOLDEN_CELL_PROB)
|
||||
|
||||
|
||||
def new_grid(rng: random.Random) -> Tuple[Grid, Set[int]]:
|
||||
"""开一旋:整盘新发 + 第 2/3/4 轴的普通符号按概率打金色。返回 (盘面, 金色格下标集)。"""
|
||||
grid: Grid = []
|
||||
golden: Set[int] = set()
|
||||
for i in range(GRID_ROWS * GRID_COLS):
|
||||
row, col = _rc(i)
|
||||
sym = _pick(rng)
|
||||
grid.append(sym)
|
||||
if _mark_golden(rng, col, sym):
|
||||
golden.add(i)
|
||||
return grid, golden
|
||||
|
||||
|
||||
def assert_no_native_wild(grid: Sequence[Cell]) -> None:
|
||||
"""铁律:原生盘面(未结算)+ 补位都不产出 W,百搭只能由金色转化产生。"""
|
||||
for i, v in enumerate(grid):
|
||||
if v == WILD:
|
||||
raise AssertionError(f"格位 {pos(*_rc(i))} 出现了非转化来源的百搭")
|
||||
|
||||
|
||||
def assert_wild_only_middle(grid: Sequence[Cell]) -> None:
|
||||
"""铁律:金色只在第 2/3/4 轴 ⇒ 百搭只可能在第 2/3/4 轴(第 1 轴永远没有)。"""
|
||||
for i, v in enumerate(grid):
|
||||
if v == WILD and _rc(i)[1] not in GOLDEN_COLS:
|
||||
raise AssertionError(f"格位 {pos(*_rc(i))} 的百搭落在第 1 或第 5 轴,不可能")
|
||||
|
||||
|
||||
def count_scatters(grid: Sequence[Cell]) -> int:
|
||||
return sum(1 for v in grid if v == SCATTER)
|
||||
|
||||
|
||||
def scatters_in_col(grid: Sequence[Cell], col: int) -> int:
|
||||
return sum(1 for r in range(GRID_ROWS) if grid[r * GRID_COLS + col] == SCATTER)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 1024 路判定
|
||||
def _count_in_col(grid: Sequence[Cell], col: int, sym: str) -> int:
|
||||
"""该轴「符号 X 或百搭」的格数(金色格按自然符号计入)。"""
|
||||
n = 0
|
||||
for r in range(GRID_ROWS):
|
||||
v = grid[r * GRID_COLS + col]
|
||||
if v == sym or v == WILD:
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def chain_cells(grid: Sequence[Cell], sym: str, run: int) -> Tuple[int, ...]:
|
||||
cells = []
|
||||
for col in range(run):
|
||||
for r in range(GRID_ROWS):
|
||||
if grid[r * GRID_COLS + col] in (sym, WILD):
|
||||
cells.append(pos(r, col))
|
||||
return tuple(cells)
|
||||
|
||||
|
||||
def ways_wins(grid: Sequence[Cell]) -> Tuple[Win, ...]:
|
||||
"""前缀链:第 1 轴起 1→2→3→4→5 连续,一旦断链整笔作废;ways 只乘链内轴。"""
|
||||
wins: List[Win] = []
|
||||
for sym in PAYING:
|
||||
run, ways, counts = 0, 1, []
|
||||
for col in range(GRID_COLS):
|
||||
n = _count_in_col(grid, col, sym)
|
||||
if n == 0:
|
||||
break
|
||||
run += 1
|
||||
ways *= n
|
||||
counts.append(n)
|
||||
if run >= MIN_CHAIN:
|
||||
wins.append(Win(
|
||||
symbol=sym, run=run, counts=tuple(counts), ways=ways,
|
||||
loc=chain_cells(grid, sym, run), pay_base=PAYTABLE[sym][run] * ways,
|
||||
))
|
||||
return tuple(wins)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 重力 + 补位
|
||||
def collapse_fill(grid: Grid, golden: Set[int], rng: random.Random
|
||||
) -> Tuple[Tuple[Tuple[int, int], ...], Tuple[Tuple[int, str], ...]]:
|
||||
"""列内重力:幸存符号(含刚转化的百搭)保持顺序压到底,顶部补新符号(可带金色)。
|
||||
|
||||
金色是**镀在符号上**的:幸存符号下落时,金色标记要跟着它一起走,
|
||||
否则会留在原格"传染"给新补进来的牌(也就不再是"那个金色符号"了)。
|
||||
"""
|
||||
drops: List[Tuple[int, int]] = []
|
||||
fills: List[Tuple[int, str]] = []
|
||||
for col in range(GRID_COLS):
|
||||
survivors = [(r, grid[r * GRID_COLS + col], (r * GRID_COLS + col) in golden)
|
||||
for r in range(GRID_ROWS) if grid[r * GRID_COLS + col] is not None]
|
||||
for r in range(GRID_ROWS):
|
||||
golden.discard(r * GRID_COLS + col) # 清空本列标记,按落点重打
|
||||
new_col: List[Cell] = [None] * GRID_ROWS
|
||||
offset = GRID_ROWS - len(survivors)
|
||||
for k, (r, v, was_golden) in enumerate(survivors):
|
||||
dst_row = offset + k
|
||||
new_col[dst_row] = v
|
||||
if was_golden:
|
||||
golden.add(dst_row * GRID_COLS + col)
|
||||
if dst_row != r:
|
||||
drops.append((pos(r, col), pos(dst_row, col)))
|
||||
for r in range(offset):
|
||||
sym = _pick(rng)
|
||||
new_col[r] = sym
|
||||
fills.append((pos(r, col), sym))
|
||||
if _mark_golden(rng, col, sym):
|
||||
golden.add(r * GRID_COLS + col)
|
||||
for r in range(GRID_ROWS):
|
||||
grid[r * GRID_COLS + col] = new_col[r]
|
||||
return tuple(drops), tuple(fills)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 一波结算
|
||||
def settle_rotation(grid: Grid, golden: Set[int], seq: int, is_fs: bool,
|
||||
rng: random.Random) -> Optional[Rotation]:
|
||||
"""结算一波;无成奖返回 None。会就地改写 grid / golden。"""
|
||||
wins = ways_wins(grid)
|
||||
if not wins:
|
||||
return None
|
||||
|
||||
involved: Set[int] = {index_of(p) for w in wins for p in w.loc}
|
||||
rotation_mul = ladder_mul(seq, is_fs)
|
||||
base_mul = sum(w.pay_base for w in wins)
|
||||
|
||||
# ① 全部中奖格"被消除",但金色格是就地改写:不空洞、不补牌
|
||||
converted: List[Tuple[int, str]] = []
|
||||
for i in sorted(involved):
|
||||
if i in golden:
|
||||
golden.discard(i)
|
||||
converted.append((i, grid[i] or ""))
|
||||
grid[i] = WILD
|
||||
converted_idx = {i for i, _ in converted}
|
||||
for i in involved:
|
||||
if i not in converted_idx:
|
||||
grid[i] = None
|
||||
|
||||
# ② 重力下落 + 顶部补位(刚转化的 W 也是普通盘面符号,一起落)
|
||||
drops, fills = collapse_fill(grid, golden, rng)
|
||||
assert_wild_only_middle(grid)
|
||||
|
||||
return Rotation(
|
||||
seq=seq, rotation_mul=rotation_mul, wins=wins, base_mul=base_mul,
|
||||
win_mul=base_mul * rotation_mul,
|
||||
converted=tuple((pos(*_rc(i)), s) for i, s in converted),
|
||||
drops=drops, fills=fills, grid=tuple(grid),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 一旋 / 一局
|
||||
def _strip_scatters(grid: Grid, golden: Set[int], rng: random.Random,
|
||||
keep: int = SCATTER_TRIGGER - 1) -> None:
|
||||
"""把落定的胡数压到 keep(用于"一屏最多 5 个胡"与"发不满就不发")。"""
|
||||
hits = [i for i, v in enumerate(grid) if v == SCATTER]
|
||||
for i in hits[keep:]:
|
||||
row, col = _rc(i)
|
||||
grid[i] = _pick(rng)
|
||||
golden.discard(i)
|
||||
if _mark_golden(rng, col, grid[i] or ""):
|
||||
golden.add(i)
|
||||
|
||||
|
||||
def play_spin(rng: random.Random, index: int, is_fs: bool,
|
||||
grid: Optional[Grid] = None, golden: Optional[Set[int]] = None) -> Spin:
|
||||
"""播一旋:落定(grid)→ 逐波结算 → 直到本波无成奖。"""
|
||||
if grid is None or golden is None:
|
||||
grid, golden = new_grid(rng)
|
||||
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN)
|
||||
scatters = count_scatters(grid) # 触发只看**落定盘面**(补位落下的胡不算)
|
||||
|
||||
rotations: List[Rotation] = []
|
||||
seq = 1
|
||||
while True:
|
||||
rot = settle_rotation(grid, golden, seq, is_fs, rng)
|
||||
if rot is None:
|
||||
break
|
||||
rotations.append(rot)
|
||||
seq += 1
|
||||
return Spin(index=index, is_fs=is_fs, rotations=tuple(rotations),
|
||||
mul=sum(r.win_mul for r in rotations), scatters=scatters)
|
||||
|
||||
|
||||
def _is_near_miss(grid: Sequence[Cell]) -> bool:
|
||||
"""恰好 2 个胡、且第 5 轴没有胡(客户端在第 5 列播放大)。"""
|
||||
return (count_scatters(grid) == NEARMISS_SCATTER_COUNT
|
||||
and scatters_in_col(grid, GRID_COLS - 1) == 0)
|
||||
|
||||
|
||||
def play_round(rng: random.Random, box_id: int = 1) -> Round:
|
||||
"""完整一局:基础旋 →(3+ 胡)免费旋转 → 重触发 → 封顶截断。"""
|
||||
round_ = Round(box_id=box_id)
|
||||
total = 0.0
|
||||
|
||||
grid, golden = new_grid(rng)
|
||||
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN) # 一屏最多 5 个胡
|
||||
round_.near_miss = _is_near_miss(grid)
|
||||
|
||||
base = play_spin(rng, index=0, is_fs=False, grid=grid, golden=golden)
|
||||
if base.scatters >= SCATTER_TRIGGER:
|
||||
award = fs_award(base.scatters)
|
||||
base = replace(base, fs_awarded=award)
|
||||
round_.free_spins = award
|
||||
round_.spins.append(base)
|
||||
total += base.mul
|
||||
|
||||
played = 0
|
||||
while played < round_.free_spins:
|
||||
played += 1
|
||||
grid, golden = new_grid(rng)
|
||||
_strip_scatters(grid, golden, rng, keep=MAX_SCATTER_PER_SCREEN)
|
||||
sc = count_scatters(grid)
|
||||
retrigger = False
|
||||
if sc >= SCATTER_TRIGGER:
|
||||
extra = fs_award(sc)
|
||||
if round_.free_spins + extra <= 36: # "发不满就不发"
|
||||
round_.free_spins += extra
|
||||
retrigger = True
|
||||
else:
|
||||
_strip_scatters(grid, golden, rng) # 本旋就是不落 3+ 胡
|
||||
spin = play_spin(rng, index=played, is_fs=True, grid=grid, golden=golden)
|
||||
if retrigger:
|
||||
spin = replace(spin, retrigger=True)
|
||||
round_.spins.append(spin)
|
||||
total += spin.mul
|
||||
if total >= MAX_WIN_CAP_MUL: # 触顶:剩余旋作废
|
||||
round_.capped = True
|
||||
break
|
||||
|
||||
round_.raw_mul = total
|
||||
round_.actual_mul = min(total, float(MAX_WIN_CAP_MUL))
|
||||
return round_
|
||||
@@ -0,0 +1,58 @@
|
||||
"""麻将胡了 · 数据模型(frozen dataclass,母本 olympus/models.py 同风格)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Win:
|
||||
"""一笔奖 = 某符号的一条前缀链。"""
|
||||
symbol: str
|
||||
run: int # 链长 3/4/5(从第 1 轴起连续的轴数)
|
||||
counts: Tuple[int, ...] # 链内各轴的格数(百搭计入)
|
||||
ways: int # counts 的乘积
|
||||
loc: Tuple[int, ...] # 本笔涉及的全部格位(1 基,含金色格与百搭)
|
||||
pay_base: float # 赔付表[符号][链长] × ways,单位 = 倍投注总额
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rotation:
|
||||
"""一波 = 一次消除结算。"""
|
||||
seq: int # 本旋内第几波(= 阶梯序号)
|
||||
rotation_mul: int # 本波阶梯倍率
|
||||
wins: Tuple[Win, ...]
|
||||
base_mul: float # Σ pay_base
|
||||
win_mul: float # base_mul × rotation_mul
|
||||
converted: Tuple[Tuple[int, str], ...] # (格位1基, 原自然符号):金色就地转百搭
|
||||
drops: Tuple[Tuple[int, int], ...] # (from1基, to1基)
|
||||
fills: Tuple[Tuple[int, str], ...] # (格位1基, 新符号)
|
||||
grid: Tuple[Optional[str], ...] # 本波结算+补位后的盘面(20 格短码)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Spin:
|
||||
"""一旋。"""
|
||||
index: int # 0 = 基础旋(触发盘),>0 = 第 N 次免费旋转
|
||||
is_fs: bool
|
||||
rotations: Tuple[Rotation, ...]
|
||||
mul: float # 本旋倍率
|
||||
scatters: int # 本旋**落定盘面**上的胡数(补位产生的不算触发)
|
||||
fs_awarded: int = 0 # 本旋触发的赠送旋数(0 = 没触发)
|
||||
retrigger: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Round:
|
||||
"""一局 = 基础旋 + 免费旋转(可重触发),受 500x 封顶。"""
|
||||
box_id: int
|
||||
spins: List[Spin] = field(default_factory=list)
|
||||
raw_mul: float = 0.0 # 封顶前的总倍率
|
||||
actual_mul: float = 0.0 # min(raw_mul, 500) —— 落桶/发奖用这个
|
||||
capped: bool = False
|
||||
near_miss: bool = False # 基础旋恰好 2 个胡且第 5 轴无胡
|
||||
free_spins: int = 0 # 本局发放的免费旋数
|
||||
|
||||
@property
|
||||
def has_fs(self) -> bool:
|
||||
return self.free_spins > 0
|
||||
@@ -0,0 +1,99 @@
|
||||
"""麻将胡了 · 自然盘面仿真(回答"空转率/命中率到底是多少、离目标差多远")。
|
||||
|
||||
用法:
|
||||
python mahjong_simulator.py --rounds 200000 --seed 2026
|
||||
|
||||
它只跑**自然落牌 + 无偏构造**的局面 —— 也就是"完全不干预"时游戏长什么样:
|
||||
自然 RTP(= 平均 actual_mul)、基础旋命中率、级联波数分布、FS 自然触发率、悬念盘占比。
|
||||
生成端要做的事(把配比凑到目标 RTP、把高倍桶构造出来)都在这些数字之上做偏置。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from mahjong import config as C # noqa: E402
|
||||
from mahjong.engine import play_round # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--rounds", type=int, default=100000)
|
||||
ap.add_argument("--seed", type=int, default=2026)
|
||||
ap.add_argument("--box", type=int, default=5, help="投注档 boxId,只影响金额不影响倍率")
|
||||
args = ap.parse_args()
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
n = args.rounds
|
||||
base_hit = 0
|
||||
empty_rounds = 0
|
||||
fs_rounds = 0
|
||||
retrigger_rounds = 0
|
||||
near_miss = 0
|
||||
capped = 0
|
||||
rotations = Counter() # 本旋波数(基础旋)
|
||||
fs_award_dist = Counter()
|
||||
bucket_dist = Counter()
|
||||
scatter_dist = Counter()
|
||||
muls = []
|
||||
total = 0.0
|
||||
|
||||
for _ in range(n):
|
||||
r = play_round(rng, box_id=args.box)
|
||||
muls.append(r.actual_mul)
|
||||
total += r.actual_mul
|
||||
base = r.spins[0]
|
||||
if base.mul > 0:
|
||||
base_hit += 1
|
||||
else:
|
||||
empty_rounds += 1
|
||||
rotations[len(base.rotations)] += 1
|
||||
scatter_dist[base.scatters] += 1
|
||||
if r.has_fs:
|
||||
fs_rounds += 1
|
||||
fs_award_dist[r.free_spins] += 1
|
||||
if any(s.retrigger for s in r.spins):
|
||||
retrigger_rounds += 1
|
||||
if r.near_miss:
|
||||
near_miss += 1
|
||||
if r.capped:
|
||||
capped += 1
|
||||
bucket_dist[C.bucket_of(r.actual_mul)] += 1
|
||||
|
||||
muls.sort()
|
||||
|
||||
def pct(p: float) -> float:
|
||||
return muls[min(n - 1, int(n * p))]
|
||||
|
||||
print(f"=== 自然仿真:{n} 局 / seed={args.seed} / 无偏构造 ===")
|
||||
print(f"自然 RTP(平均 actual_mul) : {total / n:.4f}")
|
||||
print(f"基础旋命中率(至少一波成奖) : {base_hit / n:.4%} → 空转率 {empty_rounds / n:.2%}")
|
||||
print(f"基础旋空转局(= 桶 1 占比) : {empty_rounds / n:.4%}")
|
||||
print(f"FS 自然触发率(3+ 胡) : {fs_rounds / n:.4%} (设计目标 ≈0.5%,生成端要压低)")
|
||||
print(f"其中重触发过的局 : {retrigger_rounds / n:.4%}")
|
||||
print(f"悬念盘(2 胡且第 5 轴无胡) : {near_miss / n:.4%} (设计目标 1~2%)")
|
||||
print(f"触 500x 封顶 : {capped / n:.4%}")
|
||||
print(f"actual_mul 中位 / p90 / p99 / max: {pct(0.5):.2f} / {pct(0.9):.2f} / {pct(0.99):.2f} / {muls[-1]:.2f}")
|
||||
print("\n基础旋波数分布(0 = 一波都不成):")
|
||||
for k in sorted(rotations):
|
||||
print(f" {k} 波: {rotations[k] / n:7.3%}")
|
||||
print("\n落定盘面的胡数分布:")
|
||||
for k in sorted(scatter_dist):
|
||||
print(f" {k} 个: {scatter_dist[k] / n:7.3%}")
|
||||
print("\nFS 赠送旋数分布(触发局内):")
|
||||
for k in sorted(fs_award_dist):
|
||||
print(f" {k} 旋: {fs_award_dist[k] / max(1, fs_rounds):7.3%}")
|
||||
print("\n落桶分布(桶: 占比 / 名义期望倍率):")
|
||||
for b in C.BUCKETS:
|
||||
share = bucket_dist[b.id] / n
|
||||
if share:
|
||||
print(f" {b.id:2d} {b.name:<16} {share:7.4%} expect={b.expect_mul}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
-- 步骤 1:插入「麻将胡了」顶级目录(照 olympus menu_id=3130 的结构)
|
||||
INSERT INTO sys_menu
|
||||
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type,
|
||||
visible, status, perms, icon, create_by, create_time, remark)
|
||||
VALUES
|
||||
('麻将胡了', 0, 6, 'mahjong', '', NULL, 1, 0, 'M',
|
||||
'0', '0', '', 'star', 'admin', NOW(), '麻将胡了游戏后台管理目录');
|
||||
|
||||
-- 步骤 2:取刚插入的目录 id(LAST_INSERT_ID() 是**会话级**的,
|
||||
-- 所以目录 INSERT 和下面子菜单 INSERT 必须在同一个会话里连着执行,不能拆开跑)
|
||||
SET @mj_dir := LAST_INSERT_ID();
|
||||
|
||||
-- 步骤 3:插入 7 个子菜单(order_num 1..7,与 olympus 同序)
|
||||
INSERT INTO sys_menu
|
||||
(menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type,
|
||||
visible, status, perms, icon, create_by, create_time, remark)
|
||||
VALUES
|
||||
('箱子配置', @mj_dir, 1, 'box', 'game/mahjong/box/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:box', 'shopping', 'admin', NOW(), '麻将胡了·箱子配置(11 档投注,price ≡ bet)'),
|
||||
('饰品列表', @mj_dir, 2, 'bucket', 'game/mahjong/bucket/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:bucket', 'star', 'admin', NOW(), '麻将胡了·16 档饰品桶定义(0x~500x,只读)'),
|
||||
('回合管理', @mj_dir, 3, 'round', 'game/mahjong/round/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:round', 'documentation', 'admin', NOW(), '麻将胡了·预生成回合池(只读)'),
|
||||
('奖池水位', @mj_dir, 4, 'rewardPool', 'game/mahjong/rewardPool/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:pool', 'money', 'admin', NOW(), '麻将胡了·奖池水位(3 身份:普通/主播/白号)'),
|
||||
('玩家记录', @mj_dir, 5, 'record', 'game/mahjong/record/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:record', 'list', 'admin', NOW(), '麻将胡了·玩家游玩记录'),
|
||||
('每日统计', @mj_dir, 6, 'dailywin', 'game/mahjong/dailywin/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:win', 'chart', 'admin', NOW(), '麻将胡了·每日投注/赢奖/RTP 统计'),
|
||||
('奖品管理', @mj_dir, 7, 'prize', 'game/mahjong/prize/index', NULL, 1, 0, 'C', '0', '0', 'game:mahjong:prize', 'guide', 'admin', NOW(), '麻将胡了·返奖饰品与返奖区间');
|
||||
@@ -0,0 +1,298 @@
|
||||
-- ============================================================
|
||||
-- 麻将胡了(Mahjong)模块 全量建表 DDL
|
||||
-- 表前缀: game_mahjong_ 游戏类型: MAHJONG
|
||||
-- 字符集/排序规则: **utf8mb4 / utf8mb4_0900_ai_ci**(显式写出,不依赖服务器默认;与本库 henan 的默认一致,
|
||||
-- 也与 game_wheel_* 一致。注意本库另有 111 张表是 utf8mb4_general_ci / utf8mb4_unicode_ci —— 只要**不要**拿
|
||||
-- 字符串列去 join 它们(我们的 join 全在 user_id/box_id/round_id 等数值键上),就不会撞 Illegal mix of collations)
|
||||
-- 权威来源: docs/麻将胡了设计方案.md §4(表清单)/ 附录 A(与玫瑰女郎的映射)
|
||||
-- 与玫瑰女郎(game_olympus_*)的差异:
|
||||
-- 1. 饰品桶 16 档(0x~500x,删掉它的 1000x 那档);单局封顶 500 倍
|
||||
-- 2. 无模式倍率:只有 normal,cost = bet(去 mode_type 的 CM 语义)
|
||||
-- 3. 无炸弹:step 的 multiplier_detail / accumulator_* → rotation_mul(本波阶梯,不跨旋)
|
||||
-- 4. 盘面 4 行 × 5 列 = 20 格;step 自带 golden_detail / wild_converted / ways_detail
|
||||
-- 5. record 自带 audience(按箱×身份记水位)与 bet_mul(预留、恒 1)
|
||||
-- 6. box.total_ornaments → total_rounds
|
||||
-- 执行:mysql --default-character-set=utf8mb4 -u<user> -p <db> < schema.sql (可重复执行)
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 箱子配置(= 投注档,11 档;price = bet)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_box (
|
||||
box_id BIGINT NOT NULL COMMENT 'PK 1-11',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
|
||||
box_type VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '只有 normal',
|
||||
bet DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '投注档 = 投注总额',
|
||||
name VARCHAR(50) DEFAULT NULL,
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '实际扣费 = bet(costMultiplier ≡ 1)',
|
||||
total_rounds INT NOT NULL DEFAULT '0' COMMENT '总回合数(配比重算时回写)',
|
||||
rtp DECIMAL(5,4) NOT NULL DEFAULT '0.8000' COMMENT '目标返奖率',
|
||||
rtp_converge_ratio DECIMAL(4,3) NULL COMMENT '一轮收敛回合占比,空=默认0.15',
|
||||
sort INT DEFAULT '0',
|
||||
status VARCHAR(1) DEFAULT '1' COMMENT '1启用 0禁用',
|
||||
del_flag VARCHAR(1) DEFAULT '0',
|
||||
remark VARCHAR(200) DEFAULT NULL,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (box_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子配置(=投注档)';
|
||||
|
||||
-- 2. 饰品桶定义(0x ~ 500x,共 16 档)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_bucket (
|
||||
id BIGINT NOT NULL COMMENT 'PK 1-16',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
|
||||
expect_mul DECIMAL(8,2) NOT NULL COMMENT '期望倍率(最高 500)',
|
||||
name VARCHAR(50) DEFAULT NULL,
|
||||
range_low DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间下限(含)',
|
||||
range_high DECIMAL(8,2) DEFAULT NULL COMMENT '落桶区间上限(含)',
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了饰品桶定义';
|
||||
|
||||
-- 3. 箱子-饰品桶分布(库存配额,剩余 = count - used)
|
||||
-- audience 多身份爆率:每种身份一套独立分布;白号(white)独立库存、不进真实经济
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_box_bucket (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL,
|
||||
bucket_id INT NOT NULL,
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT 'common普通/anchor主播/white白号(无 robot 维度)',
|
||||
count INT NOT NULL DEFAULT '0' COMMENT '分配回合数',
|
||||
used INT NOT NULL DEFAULT '0' COMMENT '已消耗回合数,剩余=count-used',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_box_bucket_aud (box_id, bucket_id, audience)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子饰品桶分布(多身份)';
|
||||
|
||||
-- 4. 预生成回合(池里只存倍率,与投注额无关)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_round (
|
||||
round_id BIGINT NOT NULL COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL,
|
||||
bucket_id INT NOT NULL COMMENT '落入的饰品桶 1-16',
|
||||
actual_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '实际总倍率(先按 500 截断再落桶)',
|
||||
free_spin_count INT NOT NULL DEFAULT '0' COMMENT '免费旋转总次数(12/14/16 + 重触发)',
|
||||
total_rotations INT NOT NULL DEFAULT '0' COMMENT '整局总波数',
|
||||
has_fs TINYINT NOT NULL DEFAULT '0' COMMENT '是否含免费旋转 0否1是',
|
||||
near_miss TINYINT NOT NULL DEFAULT '0' COMMENT '悬念盘:基础旋恰好2个胡且第5轴无胡',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (round_id),
|
||||
INDEX idx_box_bucket (box_id, bucket_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了预生成回合';
|
||||
|
||||
-- 5. 回合步骤(每帧一行,客户端逐帧续播)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_step (
|
||||
id BIGINT NOT NULL COMMENT 'PK',
|
||||
round_id BIGINT NOT NULL,
|
||||
reel_spin_id INT NOT NULL DEFAULT '0' COMMENT '0=基础旋/触发盘, >0=第N次免费旋转',
|
||||
rotation_seq INT NOT NULL DEFAULT '0' COMMENT '本旋内第几波(= 阶梯序号)',
|
||||
grid_state JSON DEFAULT NULL COMMENT '4行×5列=20格短码矩阵',
|
||||
golden_detail JSON DEFAULT NULL COMMENT '本帧仍为"金色普通符号"的格 [{"pos","symbol"}]',
|
||||
wild_converted JSON DEFAULT NULL COMMENT '本波"金色→百搭"就地转化的格 [{"pos","fromSymbol"}]',
|
||||
ways_detail JSON DEFAULT NULL COMMENT '中奖明细 [{"symbol","reelCount","counts","loc","ways","payBase","rotationMul","win"}]',
|
||||
accumulated_symbols JSON DEFAULT NULL COMMENT '本旋累计消除符号 {"A":9,...}',
|
||||
spin_winners JSON DEFAULT NULL COMMENT '获胜者面板:本旋逐波追加,新旋清空',
|
||||
removed_positions JSON DEFAULT NULL COMMENT '消除位置 [pos,...](含金色格)',
|
||||
drop_data JSON DEFAULT NULL COMMENT '下落数据 [{"from","to","symbol"}]',
|
||||
fill_data JSON DEFAULT NULL COMMENT '补位数据 [{"pos","symbol"}]',
|
||||
scatter_trigger JSON DEFAULT NULL COMMENT '触发免费旋转 {"scatterCount":3,"freeSpins":12,"extraSpins":0}',
|
||||
retrigger_info JSON DEFAULT NULL COMMENT '免费旋转内重触发',
|
||||
base_score DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本波基础倍率(Σ符号倍率×ways,未乘阶梯)',
|
||||
win_amount DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本旋累计到本步(末帧=本旋总额),不是增量',
|
||||
rotation_mul DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '本波阶梯倍率(基础 1/2/3/5,FS 2/4/6/10;每旋重置)',
|
||||
spin_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '整局累计赢额(倍率)到本步',
|
||||
win_capped TINYINT NOT NULL DEFAULT '0' COMMENT '本步被 500x 封顶截断 0否1是',
|
||||
bigwin_level VARCHAR(16) DEFAULT NULL COMMENT '爆奖档位 small/middle/big(只在 FS 旋末帧,按单旋赢额)',
|
||||
is_final TINYINT DEFAULT '0' COMMENT '本旋末帧',
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_round (round_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了回合步骤';
|
||||
|
||||
-- 6. 游戏记录(含结算/幂等/发奖字段,建表即带全)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_record (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
user_id BIGINT NOT NULL,
|
||||
box_id BIGINT NOT NULL,
|
||||
round_id BIGINT NOT NULL,
|
||||
cost DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '扣费 = bet',
|
||||
bet_mul DECIMAL(10,2) NOT NULL DEFAULT '1.00' COMMENT '投注倍数(预留字段,恒 1)',
|
||||
total_win DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '总赢额(倍数×投注额,≤bet×500)',
|
||||
settled TINYINT NOT NULL DEFAULT '0' COMMENT '是否已结算 0未1已(幂等守卫)',
|
||||
mode_type VARCHAR(20) DEFAULT 'normal' COMMENT '只有 normal',
|
||||
reward_type VARCHAR(10) DEFAULT NULL COMMENT '发放类型 gold/ornament',
|
||||
settled_win DECIMAL(10,2) DEFAULT 0.00 COMMENT '本局价值(=命中饰品价格;空转局按饰品实际价值记账)',
|
||||
prize_id BIGINT DEFAULT NULL COMMENT 'game_mahjong_prize_ornament.id',
|
||||
prize_name VARCHAR(100) DEFAULT NULL COMMENT '饰品名称',
|
||||
prize_image_url VARCHAR(500) DEFAULT NULL COMMENT '饰品图片URL',
|
||||
prize_level_img VARCHAR(500) DEFAULT NULL COMMENT '品质图片URL',
|
||||
settle_source VARCHAR(20) DEFAULT NULL COMMENT 'start=开局已结算(动画还在播) / finish=末帧播完(进战绩)',
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT '身份:水位按 箱×身份 聚合',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_user_time (user_id, create_time),
|
||||
INDEX idx_box_time (box_id, create_time),
|
||||
INDEX idx_settled_time (settled, create_time),
|
||||
INDEX idx_box_aud (box_id, audience)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了游戏记录';
|
||||
|
||||
-- 7. 用户统计
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_user (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
user_id BIGINT NOT NULL,
|
||||
total_spins BIGINT NOT NULL DEFAULT '0',
|
||||
total_cost DECIMAL(12,2) NOT NULL DEFAULT '0.00',
|
||||
total_win DECIMAL(12,2) NOT NULL DEFAULT '0.00',
|
||||
max_win_mul DECIMAL(10,4) NOT NULL DEFAULT '0.0000' COMMENT '历史最高倍率(≤500)',
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_user (user_id)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了用户统计';
|
||||
|
||||
-- 8. 箱子桶分布模板(抽干补桶依据,按身份区分)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_box_template (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT NOT NULL,
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common',
|
||||
name VARCHAR(100) NOT NULL COMMENT '模板名称(同箱+身份内唯一)',
|
||||
total_rounds INT NOT NULL DEFAULT 0 COMMENT '= items 各 count 之和',
|
||||
items_json TEXT NOT NULL COMMENT '分布JSON: [{"bucketId":1,"count":100},...]',
|
||||
is_active TINYINT NOT NULL DEFAULT 0 COMMENT '是否生效模板 1是 0否',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_box_aud_name (box_id, audience, name),
|
||||
KEY idx_box_aud_active (box_id, audience, is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了箱子桶分布模板(多身份)';
|
||||
|
||||
-- 9. 奖池水位调整日志(注水/抽水/重置审计,原因必填由前端保证)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_reward_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
box_id BIGINT DEFAULT NULL COMMENT '箱子ID(批量/重置为汇总,可空)',
|
||||
box_name VARCHAR(100) DEFAULT NULL,
|
||||
audience VARCHAR(16) NOT NULL DEFAULT 'common' COMMENT 'common/anchor/white',
|
||||
type TINYINT NOT NULL COMMENT '1注水 2抽水 3重置',
|
||||
amount DECIMAL(14,2) NOT NULL DEFAULT '0.00' COMMENT '调整金额(绝对值)',
|
||||
balance_before DECIMAL(14,2) DEFAULT NULL COMMENT '调整前水位',
|
||||
balance_after DECIMAL(14,2) DEFAULT NULL COMMENT '调整后水位',
|
||||
pool_balance DECIMAL(14,2) DEFAULT NULL COMMENT '操作后总池余额',
|
||||
operator VARCHAR(64) DEFAULT NULL,
|
||||
remark VARCHAR(200) DEFAULT NULL,
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_create (create_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了奖池水位调整日志';
|
||||
|
||||
-- 10. 返奖饰品定义(按赢额金额区间匹配,与游戏无关)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_prize_ornament (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
game_type VARCHAR(16) NOT NULL DEFAULT 'MAHJONG',
|
||||
name VARCHAR(100) DEFAULT NULL,
|
||||
image_url VARCHAR(500) DEFAULT NULL,
|
||||
grade INT DEFAULT NULL COMMENT '品质等级 1-5',
|
||||
exterior_name VARCHAR(100) DEFAULT NULL COMMENT '品质名称 崭新出厂/略有磨损/久经沙场/破损不堪/战痕累累',
|
||||
sort INT DEFAULT '0',
|
||||
status VARCHAR(1) DEFAULT '1',
|
||||
is_fallback TINYINT DEFAULT '0' COMMENT '兜底饰品 1是 0否(区间空洞时落它)',
|
||||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了返奖饰品';
|
||||
|
||||
-- 11. 返奖饰品区间(与 prize_ornament 一一对应,区间连续无缝覆盖 0~250000)
|
||||
CREATE TABLE IF NOT EXISTS game_mahjong_prize_range (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
prize_id BIGINT NOT NULL COMMENT 'game_mahjong_prize_ornament.id',
|
||||
win_low DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间下限(含)',
|
||||
win_high DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '返奖区间上限(含)',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_prize (prize_id),
|
||||
KEY idx_win_low (win_low)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='麻将胡了返奖区间';
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:饰品桶 1-16(0x ~ 500x)
|
||||
-- ============================================================
|
||||
INSERT INTO game_mahjong_bucket (id, game_type, expect_mul, name, range_low, range_high) VALUES
|
||||
(1, 'MAHJONG', 0.00, '0x', 0.00, 0.00),
|
||||
(2, 'MAHJONG', 0.50, '0.5x', 0.01, 1.00),
|
||||
(3, 'MAHJONG', 1.50, '1.5x', 1.00, 2.00),
|
||||
(4, 'MAHJONG', 2.50, '2.5x', 2.00, 3.00),
|
||||
(5, 'MAHJONG', 4.00, '4x', 3.00, 5.00),
|
||||
(6, 'MAHJONG', 6.50, '6.5x', 5.00, 8.00),
|
||||
(7, 'MAHJONG', 10.00, '10x', 8.00, 12.00),
|
||||
(8, 'MAHJONG', 16.00, '16x', 12.00, 20.00),
|
||||
(9, 'MAHJONG', 25.00, '25x', 20.00, 30.00),
|
||||
(10, 'MAHJONG', 40.00, '40x', 30.00, 50.00),
|
||||
(11, 'MAHJONG', 60.00, '60x', 50.00, 70.00),
|
||||
(12, 'MAHJONG', 100.00, '100x', 70.00, 130.00),
|
||||
(13, 'MAHJONG', 150.00, '150x', 130.00, 170.00),
|
||||
(14, 'MAHJONG', 200.00, '200x', 170.00, 230.00),
|
||||
(15, 'MAHJONG', 300.00, '300x', 230.00, 370.00),
|
||||
(16, 'MAHJONG', 500.00, '500x', 370.00, 700.00)
|
||||
ON DUPLICATE KEY UPDATE expect_mul=VALUES(expect_mul), name=VALUES(name),
|
||||
range_low=VALUES(range_low), range_high=VALUES(range_high);
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:11 个箱子(=11 个投注档,price = bet,无投注倍数)
|
||||
-- ============================================================
|
||||
INSERT INTO game_mahjong_box (box_id, game_type, box_type, bet, name, price, rtp, sort, status, remark) VALUES
|
||||
(1, 'MAHJONG', 'normal', 1.00, '1', 1.00, 0.8000, 1, '1', NULL),
|
||||
(2, 'MAHJONG', 'normal', 3.00, '3', 3.00, 0.8000, 2, '1', NULL),
|
||||
(3, 'MAHJONG', 'normal', 5.00, '5', 5.00, 0.8000, 3, '1', NULL),
|
||||
(4, 'MAHJONG', 'normal', 10.00, '10', 10.00, 0.8000, 4, '1', NULL),
|
||||
(5, 'MAHJONG', 'normal', 20.00, '20', 20.00, 0.8000, 5, '1', NULL),
|
||||
(6, 'MAHJONG', 'normal', 30.00, '30', 30.00, 0.8000, 6, '1', NULL),
|
||||
(7, 'MAHJONG', 'normal', 50.00, '50', 50.00, 0.8000, 7, '1', NULL),
|
||||
(8, 'MAHJONG', 'normal', 100.00, '100', 100.00, 0.8000, 8, '1', NULL),
|
||||
(9, 'MAHJONG', 'normal', 200.00, '200', 200.00, 0.8000, 9, '1', NULL),
|
||||
(10, 'MAHJONG', 'normal', 300.00, '300', 300.00, 0.8000, 10, '1', NULL),
|
||||
(11, 'MAHJONG', 'normal', 500.00, '500', 500.00, 0.8000, 11, '1', NULL)
|
||||
ON DUPLICATE KEY UPDATE bet=VALUES(bet), price=VALUES(price), name=VALUES(name),
|
||||
rtp=VALUES(rtp), sort=VALUES(sort), status=VALUES(status);
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:返奖饰品 12 件(取自玫瑰女郎线上 dump,id 原样保留以维持区间映射)
|
||||
-- 说明:zone 是按**赢额金额(¥)**匹配的展示层,与饰品桶(倍率)无关;
|
||||
-- 保留原 id 是因为 prize_range.prize_id 直接引用它。
|
||||
-- ============================================================
|
||||
INSERT INTO game_mahjong_prize_ornament
|
||||
(id, game_type, name, image_url, grade, exterior_name, sort, status, is_fallback, create_time, update_time) VALUES
|
||||
(1, 'MAHJONG', '安慰奖', '/profile/upload/2026/09/15/VU1QLTQ1IHwgU2NvcmNoZWQgKEZhY3RvcnkgTmV3KQ==_20260915144142A001.png', 4, '战痕累累', 1, '1', 1, '2026-08-01 14:05:58', '2026-09-15 14:41:45'),
|
||||
(2, 'MAHJONG', 'SG 553 | 浪花喷漆', '/profile/upload/2026/07/02/U0cgNTUzIHwgV2F2ZSBTcHJheSAoRmFjdG9yeSBOZXcp_20260702034148A002.png', 2, '略有磨损', 2, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(3, 'MAHJONG', '双持贝瑞塔 | 黑榄仁木', '/profile/upload/2026/07/02/RHVhbCBCZXJldHRhcyB8IEJsYWNrIExpbWJhIChGYWN0b3J5IE5ldyk=_20260702034157A003.png', 3, '久经沙场', 3, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(4, 'MAHJONG', 'M4A4 | 皇家圣骑士', '/profile/upload/2026/07/02/TTRBNCB8IFJveWFsIFBhbGFkaW4gKEZhY3RvcnkgTmV3KQ==_20260702034203A004.png', 4, '破损不堪', 4, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(5, 'MAHJONG', 'AK-47 | 深海复仇', '/profile/upload/2026/07/02/QUstNDcgfCBBcXVhbWFyaW5lIFJldmVuZ2UgKEZhY3RvcnkgTmV3KQ==_20260702034212A005.png', 4, '破损不堪', 5, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(6, 'MAHJONG', 'AK-47 | 火蛇', '/profile/upload/2026/07/02/QUstNDcgfCBGaXJlIFNlcnBlbnQgKEZhY3RvcnkgTmV3KQ==_20260702034220A006.png', 3, '久经沙场', 6, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(7, 'MAHJONG', 'AK-47 | 燃料喷射器', '/profile/upload/2026/07/02/QUstNDcgfCBGdWVsIEluamVjdG9yIChGYWN0b3J5IE5ldyk=_20260702034228A007.png', 3, '久经沙场', 7, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(8, 'MAHJONG', '加利尔 AR | 水蓝阶地', '/profile/upload/2026/07/02/R2FsaWwgQVIgfCBBcXVhIFRlcnJhY2UgKEZhY3RvcnkgTmV3KQ==_20260702034236A008.png', 2, '略有磨损', 8, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(9, 'MAHJONG', '猎杀者匕首 | 渐变之色', '/profile/upload/2026/07/02/4piFIEh1bnRzbWFuIEtuaWZlIHwgRmFkZSAoRmFjdG9yeSBOZXcp_20260702034302A012.png', 2, '略有磨损', 9, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(10, 'MAHJONG', '爪子刀 | 都市伪装', '/profile/upload/2026/07/02/4piFIEthcmFtYml0IHwgVXJiYW4gTWFza2VkIChGYWN0b3J5IE5ldyk=_20260702034248A010.png', 1, '崭新出厂', 10, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(11, 'MAHJONG', '熊刀 | 多普勒', '/profile/upload/2026/07/02/4piFIFVyc3VzIEtuaWZlIHwgRG9wcGxlciAoTWluaW1hbCBXZWFyKQ==_20260702034256A011.png', 1, '崭新出厂', 11, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58'),
|
||||
(12, 'MAHJONG', 'AWP | 巨龙传说', '/profile/upload/2026/07/02/QVdQIHwgRHJhZ29uIExvcmUgKEZhY3RvcnkgTmV3KQ==_20260702034308A013.png', 1, '崭新出厂', 12, '1', 0, '2026-08-01 14:05:58', '2026-08-01 14:05:58')
|
||||
ON DUPLICATE KEY UPDATE game_type=VALUES(game_type), name=VALUES(name), image_url=VALUES(image_url),
|
||||
grade=VALUES(grade), exterior_name=VALUES(exterior_name), sort=VALUES(sort),
|
||||
status=VALUES(status), is_fallback=VALUES(is_fallback), update_time=NOW();
|
||||
|
||||
-- ============================================================
|
||||
-- 种子数据:返奖区间 12 段(连续无缝覆盖 0.01 ~ 99,999,999)
|
||||
-- 已核:① 每段 win_high = 下一段 win_low(无空洞、无重叠)
|
||||
-- ② 最大档上界 99,999,999 ≥ 麻将单局理论最大赔付 ¥250,000(500x × ¥500)
|
||||
-- —— ¥250,000 落在第 9 段(100,005.82~499,988),发 9 号饰品
|
||||
-- ③ 空转局兜底 win=0.01 落在第 1 段(0.01~0.14),发「安慰奖」(is_fallback=1)
|
||||
-- ⚠️ 这 12 段只决定"发哪件饰品"(展示层);实际入账金额是 record.settled_win
|
||||
-- = 本局 win,与区间无关(见 service-user 分解入账:Σ TtBoxRecords.ornamentsPrice)
|
||||
-- ============================================================
|
||||
INSERT INTO game_mahjong_prize_range (id, prize_id, win_low, win_high) VALUES
|
||||
(1, 1, 0.01, 0.14),
|
||||
(2, 2, 0.14, 10.00),
|
||||
(3, 3, 10.00, 50.00),
|
||||
(4, 4, 50.00, 200.00),
|
||||
(5, 5, 200.00, 1000.00),
|
||||
(6, 6, 1000.00, 5000.00),
|
||||
(7, 7, 5000.00, 20000.00),
|
||||
(8, 8, 20000.00, 100005.82),
|
||||
(9, 9, 100005.82, 499988.00),
|
||||
(10, 10, 499988.00, 1000013.00),
|
||||
(11, 11, 1000013.00, 2000012.00),
|
||||
(12, 12, 2000012.00, 99999999.00)
|
||||
ON DUPLICATE KEY UPDATE prize_id=VALUES(prize_id), win_low=VALUES(win_low), win_high=VALUES(win_high);
|
||||
|
||||
-- ============================================================
|
||||
-- 还没做(不在本文件内,见设计文档 §6.5):
|
||||
-- 1. 528 条 game_mahjong_box_bucket 配比行(16 桶 × 11 箱 × 3 身份):由 `_recalc_boxes.py` / 后台「一键重算」生成
|
||||
-- 2. sys_config mahjongMaintenance、sys_menu 7 个菜单:平台共享改动,单独执行
|
||||
-- 3. 每箱×身份的初始水位:后台奖池水位页注水(¥300/¥500 两档必须单独注)
|
||||
-- ============================================================
|
||||
@@ -0,0 +1,203 @@
|
||||
"""麻将胡了 · 引擎单测(不依赖 pytest:`python tests/test_engine.py` 直接跑)。
|
||||
|
||||
重点覆盖:§2.1 七个正反例、百搭补链/不能替代胡、金色就地转百搭、
|
||||
一个百搭被两条链同时借用、阶梯取档、FS 旋数上限与"发不满就不发"、500x 封顶。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from mahjong import config as C # noqa: E402
|
||||
from mahjong.engine import ( # noqa: E402
|
||||
assert_wild_only_middle, count_scatters, new_grid, play_round,
|
||||
settle_rotation, ways_wins,
|
||||
)
|
||||
|
||||
|
||||
def make_grid(rows, golden=()):
|
||||
"""rows: 4 个长度 5 的短码字符串(上→下);golden: 0 基 (行,列) 集合。"""
|
||||
grid = [c for row in rows for c in row]
|
||||
assert len(grid) == C.GRID_SIZE, f"盘面必须是 20 格,收到 {len(grid)}"
|
||||
return grid, {r * C.GRID_COLS + c for r, c in golden}
|
||||
|
||||
|
||||
def cols_of(counts, fill="S", sym="B"):
|
||||
"""按"每轴 sym 的个数"造盘面(其余填 fill,保证不形成别的链)。"""
|
||||
rows = []
|
||||
for r in range(C.GRID_ROWS):
|
||||
row = []
|
||||
for c in range(C.GRID_COLS):
|
||||
# 从底部往上摆 sym
|
||||
row.append(sym if r >= C.GRID_ROWS - counts[c] else fill)
|
||||
rows.append("".join(row))
|
||||
return make_grid(rows)
|
||||
|
||||
|
||||
def mul(x):
|
||||
return round(x, 4)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §2.1 前缀链
|
||||
CASES = [
|
||||
([2, 3, 1, 0, 0], 3, 6), # ✅ 3 连
|
||||
([2, 3, 1, 2, 0], 4, 12), # ✅ 4 连(第 5 轴才断)
|
||||
([2, 0, 3, 4, 4], None, None), # ❌ 第 2 轴断
|
||||
([0, 4, 4, 4, 4], None, None), # ❌ 第 1 轴必需
|
||||
([1, 1, 0, 4, 4], None, None), # ❌ 不能跳轴
|
||||
([1, 2, 3, 4, 4], 5, 96), # ✅ 满链
|
||||
([1, 1, 1, 1, 1], 5, 1), # ✅ 最低 ways
|
||||
]
|
||||
|
||||
|
||||
def test_prefix_chain():
|
||||
for counts, run, ways in CASES:
|
||||
grid, _ = cols_of(counts)
|
||||
wins = [w for w in ways_wins(grid) if w.symbol == "B"]
|
||||
if run is None:
|
||||
assert not wins, f"{counts} 不该成奖,却得到 {wins}"
|
||||
else:
|
||||
assert len(wins) == 1, f"{counts} 应恰好一笔,得到 {wins}"
|
||||
assert wins[0].run == run and wins[0].ways == ways, f"{counts} → {wins[0]}"
|
||||
print("[ok] §2.1 前缀链 7 个正反例")
|
||||
|
||||
|
||||
def test_scatter_breaks_chain_and_no_substitute():
|
||||
grid, _ = make_grid(["ASASS", "SSSSS", "SSSSS", "SSSSS"])
|
||||
assert ways_wins(grid) == (), "發,胡,發 不能成链(胡不进链、也挡链)"
|
||||
grid, _ = make_grid(["AWASS", "SSSSS", "SSSSS", "SSSSS"])
|
||||
wins = [w for w in ways_wins(grid) if w.symbol == "A"]
|
||||
assert len(wins) == 1 and wins[0].run == 3 and wins[0].ways == 1, "百搭要能补链中间的断点"
|
||||
print("[ok] 胡不成链 / 百搭补链")
|
||||
|
||||
|
||||
def test_paytable_grid():
|
||||
for code, rule in C.SYMBOL_RULES.items():
|
||||
for run, pay in rule.payouts.items():
|
||||
assert abs(pay / 0.05 - round(pay / 0.05)) < 1e-9, f"{code} {run} 连 {pay} 不是 0.05 的整数倍"
|
||||
print("[ok] 赔付表全部落在 0.05 网格上")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 金色 → 百搭
|
||||
def test_golden_converts_in_place():
|
||||
"""§15 第 1 波:两张金色「中」各留一个百搭,且是就地改写的。"""
|
||||
grid, golden = make_grid(
|
||||
["BCBES", "BBBDF", "DCCGE", "CEHFG"], golden=[(1, 1), (1, 2)])
|
||||
rng = random.Random(7)
|
||||
rot = settle_rotation(grid, golden, 1, False, rng)
|
||||
assert rot is not None
|
||||
paid = {w.symbol: mul(w.pay_base) for w in rot.wins}
|
||||
assert paid == {"B": 2.0, "C": 0.8}, f"本波两笔奖应为 2.00 / 0.80,得到 {paid}"
|
||||
assert mul(rot.win_mul) == 2.8, "x1 阶梯下本波 2.80 倍"
|
||||
assert len(rot.converted) == 2, f"两个金色格各留一个百搭,得到 {rot.converted}"
|
||||
assert {s for _, s in rot.converted} == {"B"}, "转化前的自然符号应记进 wild_converted"
|
||||
assert all(grid[i] != C.WILD for i in golden), "百搭永不回填金色标记(每格只转化一次)"
|
||||
assert sum(1 for v in grid if v == C.WILD) == 2, "两个百搭仍在盘面上(跟着重力落)"
|
||||
assert_wild_only_middle(grid)
|
||||
print("[ok] 金色格就地转百搭(不空洞/不补牌,2 个金色格 → 2 个百搭)")
|
||||
|
||||
|
||||
def test_one_wild_borrowed_by_three_chains():
|
||||
"""§15 第 4 波:同一个百搭同时撑起「筒」5 连、「索红绿」5 连、「白板」3 连。
|
||||
|
||||
注:设计文档原来只算了前两笔(漏了白板链),本测试按规则把它补上;
|
||||
文档已同步勘误(本波 ¥1200、本旋总账 ¥2380)。
|
||||
"""
|
||||
grid, golden = make_grid(["GAWGC", "HCAAS", "CFBGF", "FGHFG"])
|
||||
rng = random.Random(11)
|
||||
rot = settle_rotation(grid, golden, 4, False, rng)
|
||||
assert rot is not None
|
||||
got = {w.symbol: (w.run, w.ways, mul(w.pay_base)) for w in rot.wins}
|
||||
assert got == {"G": (5, 2, 1.0), "F": (5, 1, 1.0), "C": (3, 1, 0.4)}, f"三笔并赔,得到 {got}"
|
||||
shared = set(rot.wins[0].loc)
|
||||
for w in rot.wins[1:]:
|
||||
shared &= set(w.loc)
|
||||
assert shared == {3}, f"三条链必须共用第 3 格那个百搭,实际 {shared}"
|
||||
assert mul(rot.base_mul) == 2.4 and mul(rot.win_mul) == 12.0, "本波 2.40 倍 × x5 = 12.00 倍"
|
||||
assert sum(1 for v in grid if v == C.WILD) == 0, "参与成链的百搭要随本波被消除"
|
||||
print("[ok] 一个百搭被三条链同时借用,并随波被消除")
|
||||
|
||||
|
||||
def test_involved_golden_only():
|
||||
"""只有"参与成奖"的金色格才转化;没被涉及的金色格原地不动。"""
|
||||
# B 链在轴1~轴3;轴4 的 F 带金色但没参与任何链 → 不转化
|
||||
grid, golden = make_grid(["BCBES", "BBBDF", "DCCGE", "CEHFG"], golden=[(3, 3)])
|
||||
rng = random.Random(9)
|
||||
rot = settle_rotation(grid, golden, 1, False, rng)
|
||||
assert rot is not None and rot.converted == (), f"未被涉及的金色格不该转化:{rot.converted}"
|
||||
assert 3 * C.GRID_COLS + 3 in golden, "未涉及的金色标记要保留"
|
||||
print("[ok] 只有参与成奖的金色格才就地转百搭")
|
||||
|
||||
|
||||
def test_golden_travels_with_symbol():
|
||||
"""金色是镀在符号上的:金色格没被消除但它下方的牌被消 → 它下落,金标记跟着走。"""
|
||||
grid, golden = make_grid(["BDBSS", "BBBSS", "BBBSS", "SSSSS"], golden=[(0, 1)])
|
||||
rot = settle_rotation(grid, golden, 1, False, random.Random(1))
|
||||
assert rot is not None, "B 应为 3 连(col0=3 / col1=2 / col2=3)"
|
||||
# D 从行1 落到行3(col1 有 2 格空洞),金标记必须跟着
|
||||
assert grid[2 * C.GRID_COLS + 1] == "D", "金色 D 应该落到行3"
|
||||
assert 2 * C.GRID_COLS + 1 in golden, "金标记要跟着符号落到新位置"
|
||||
assert 0 * C.GRID_COLS + 1 not in golden, "不能留在原格(否则会传染给新补的牌)"
|
||||
print("[ok] 金色标记跟着符号下落")
|
||||
|
||||
|
||||
def test_no_wild_without_conversion():
|
||||
"""原生盘面(卷轴+补位)永不落百搭;金色只在第 2/3/4 轴。"""
|
||||
for seed in range(300):
|
||||
g, gold = new_grid(random.Random(seed))
|
||||
assert C.WILD not in g, "原生盘面永远不落百搭"
|
||||
assert all(c % C.GRID_COLS in C.GOLDEN_COLS for c in gold), "金色只在第 2/3/4 轴"
|
||||
print("[ok] 原生不落百搭 / 金色只在第 2/3/4 轴")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 阶梯 / 封顶 / FS
|
||||
def test_ladder():
|
||||
base = [C.ladder_mul(n, False) for n in range(1, 7)]
|
||||
fs = [C.ladder_mul(n, True) for n in range(1, 7)]
|
||||
assert base == [1, 2, 3, 5, 5, 5], base
|
||||
assert fs == [2, 4, 6, 10, 10, 10], fs
|
||||
print("[ok] 阶梯:基础 1/2/3/5,FS 2/4/6/10,第 4 波起封顶")
|
||||
|
||||
|
||||
def test_example_2_9_1_wave1():
|
||||
"""§2.9.1 第 1 波:中 3 连 + 白板 3 连 = 0.90 倍(x1)。"""
|
||||
grid, golden = make_grid(["BCBEH", "DBAGF", "EDCAG", "CEHFD"])
|
||||
rot = settle_rotation(grid, golden, 1, False, random.Random(5))
|
||||
assert rot is not None and mul(rot.base_mul) == 0.9 and mul(rot.win_mul) == 0.9
|
||||
print("[ok] §2.9.1 第 1 波 = 0.90 倍")
|
||||
|
||||
|
||||
def test_fs_award_and_cap():
|
||||
assert [C.fs_award(n) for n in (3, 4, 5)] == [12, 14, 16]
|
||||
rng = random.Random(2026)
|
||||
reachable = set()
|
||||
for _ in range(40000):
|
||||
r = play_round(rng, box_id=5)
|
||||
assert r.actual_mul <= C.MAX_WIN_CAP_MUL, "单局封顶 500x"
|
||||
assert mul(r.actual_mul) == mul(min(r.raw_mul, C.MAX_WIN_CAP_MUL))
|
||||
if r.free_spins:
|
||||
assert r.free_spins <= C.MAX_FREE_SPINS, f"FS 上限 36,得到 {r.free_spins}"
|
||||
assert r.free_spins in (12, 14, 16, 24, 26, 28, 30, 32, 36), r.free_spins
|
||||
reachable.add(r.free_spins)
|
||||
if r.capped:
|
||||
assert r.actual_mul == C.MAX_WIN_CAP_MUL
|
||||
assert reachable, "4 万局都没触发过一次 FS,权重/参数明显不对"
|
||||
print(f"[ok] 封顶与 FS 上限(4 万局内出现的赠送旋数:{sorted(reachable)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_prefix_chain()
|
||||
test_scatter_breaks_chain_and_no_substitute()
|
||||
test_paytable_grid()
|
||||
test_golden_converts_in_place()
|
||||
test_one_wild_borrowed_by_three_chains()
|
||||
test_involved_golden_only()
|
||||
test_golden_travels_with_symbol()
|
||||
test_no_wild_without_conversion()
|
||||
test_ladder()
|
||||
test_example_2_9_1_wave1()
|
||||
test_fs_award_and_cap()
|
||||
print("\n全部通过 ✅")
|
||||
@@ -24,7 +24,6 @@
|
||||
<druid.version>1.2.28</druid.version>
|
||||
<yauaa.version>8.1.0</yauaa.version>
|
||||
<bitwalker.version>1.21</bitwalker.version>
|
||||
<swagger.version>3.0.0</swagger.version>
|
||||
<kaptcha.version>2.3.3</kaptcha.version>
|
||||
<tianai-captcha.version>1.5.5</tianai-captcha.version>
|
||||
<pagehelper.boot.version>2.1.1</pagehelper.boot.version>
|
||||
@@ -35,7 +34,6 @@
|
||||
<poi.version>4.1.2</poi.version>
|
||||
<velocity.version>2.3</velocity.version>
|
||||
<jwt.version>0.9.1</jwt.version>
|
||||
<springdoc.version>3.0.2</springdoc.version>
|
||||
</properties>
|
||||
|
||||
<!-- 依赖声明 -->
|
||||
@@ -149,26 +147,6 @@
|
||||
<version>${oshi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- spring-doc -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger3依赖 -->
|
||||
<dependency>
|
||||
<groupId>io.springfox</groupId>
|
||||
<artifactId>springfox-boot-starter</artifactId>
|
||||
<version>${swagger.version}</version>
|
||||
<!-- <exclusions>-->
|
||||
<!-- <exclusion>-->
|
||||
<!-- <groupId>io.swagger</groupId>-->
|
||||
<!-- <artifactId>swagger-models</artifactId>-->
|
||||
<!-- </exclusion>-->
|
||||
<!-- </exclusions>-->
|
||||
</dependency>
|
||||
|
||||
<!-- io常用工具类 -->
|
||||
<dependency>
|
||||
<groupId>commons-io</groupId>
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=true
|
||||
VITE_OUT_DIR=dist-dev
|
||||
|
||||
# 标题
|
||||
VITE_APP_TITLE=ElementAdmin
|
||||
VITE_APP_TITLE=推广后台
|
||||
|
||||
# 是否包分析
|
||||
VITE_USE_BUNDLE_ANALYZER=false
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import request from '@/axios'
|
||||
import type { RegisterUserType, SmsLoginType, UserLoginType, UserType } from './types'
|
||||
import type { DevLoginType, RegisterUserType, SmsLoginType, UserType } from './types'
|
||||
|
||||
interface RoleParams {
|
||||
roleName: string
|
||||
}
|
||||
|
||||
export const loginApi = (data: UserLoginType): Promise<IResponse<UserType>> => {
|
||||
return request.post({ url: '/promo/login', data })
|
||||
}
|
||||
|
||||
export const smsLoginApi = (data: SmsLoginType): Promise<IResponse<UserType>> => {
|
||||
return request.post({ url: '/promo/smsLogin', data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发环境专用的账号密码登录(仅 vite dev server 下可见)。
|
||||
* 后端需在 sys_config 里把 promoDevLoginEnabled 置为 '1',否则返回「账号密码登录未开启」。
|
||||
*/
|
||||
export const devLoginApi = (data: DevLoginType): Promise<IResponse<UserType>> => {
|
||||
return request.post({ url: '/promo/devLogin', data })
|
||||
}
|
||||
|
||||
export const registerApi = (data: RegisterUserType): Promise<IResponse<UserType>> => {
|
||||
return request.post({ url: '/promo/register', data })
|
||||
}
|
||||
@@ -34,6 +38,20 @@ export const getCodeApi = (
|
||||
})
|
||||
}
|
||||
|
||||
/** 点「获取验证码」时的账号前置校验:无推广后台权限的号在这一步就被拒绝,不用先拖滑块 */
|
||||
export const precheckPhoneApi = (
|
||||
phoneNumber: string,
|
||||
parentInvitationCode?: string
|
||||
): Promise<IResponse<UserType>> => {
|
||||
return request.post({
|
||||
url: '/promo/sms/precheck',
|
||||
data: {
|
||||
phoneNumber: phoneNumber,
|
||||
parentInvitationCode
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const loginOutApi = (): Promise<IResponse> => {
|
||||
return request.get({ url: '/mock/user/loginOut' })
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ export interface SmsLoginType {
|
||||
parentInvitationCode?: string
|
||||
}
|
||||
|
||||
/** 开发环境专用的账号密码登录入参(后端 promoDevLoginEnabled=1 才可用) */
|
||||
export interface DevLoginType {
|
||||
/** 推广账号(主播/招商/卡商)填手机号;超管可填登录账号或手机号 */
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface UserType {
|
||||
uid: number
|
||||
username: string
|
||||
|
||||
@@ -5,14 +5,12 @@ import { UserInfo } from '@/components/UserInfo'
|
||||
import { Breadcrumb } from '@/components/Breadcrumb'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { useDesign } from '@/hooks/web/useDesign'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
const { getPrefixCls, variables } = useDesign()
|
||||
|
||||
const prefixCls = getPrefixCls('tool-header')
|
||||
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 面包屑
|
||||
const breadcrumb = computed(() => appStore.getBreadcrumb)
|
||||
@@ -42,9 +40,7 @@ export default defineComponent({
|
||||
{breadcrumb.value ? <Breadcrumb class="<md:hidden"></Breadcrumb> : undefined}
|
||||
</div>
|
||||
) : undefined}
|
||||
{userStore.getUserInfo?.role !== '99' && userStore.getUserInfo?.role !== '05' ? (
|
||||
<span>分享链接:{userStore.getUserInfo?.shareLink}</span>
|
||||
) : undefined}
|
||||
{/* 顶栏的「分享链接」已移除:与首页「我的推广链接」hero 卡重复(2026-09-27) */}
|
||||
<div class="h-full flex items-center">
|
||||
<UserInfo></UserInfo>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,7 @@ export const useAppStore = defineStore('app', {
|
||||
tagsViewIcon: true, // 是否显示标签图标
|
||||
logo: true, // logo
|
||||
fixedHeader: true, // 固定toolheader
|
||||
footer: true, // 显示页脚
|
||||
footer: false, // 不显示页脚(推广后台不需要底部版权栏)
|
||||
greyMode: false, // 是否开始灰色模式,用于特殊悼念日
|
||||
dynamicRouter: true, // 是否动态路由
|
||||
serverDynamicRouter: false, // 是否服务端渲染动态路由
|
||||
@@ -336,7 +336,13 @@ export const useAppStore = defineStore('app', {
|
||||
newTitle !== this.getTitle && this.setTitle(newTitle)
|
||||
}
|
||||
},
|
||||
persist: true
|
||||
persist: {
|
||||
/* footer 不进持久化:本 store 原先 persist: true 会把 footer: true 写进 localStorage,
|
||||
老用户下次进来 hydrate 又把它盖回 true ⇒ 光改上面的默认值页脚去不掉。
|
||||
omit 在「存」和「取」两侧都生效(见 pinia-plugin-persistedstate dist/index.js:23),
|
||||
所以旧数据里的 footer 会被直接忽略,永远取 state 默认值。 */
|
||||
omit: ['footer']
|
||||
}
|
||||
})
|
||||
|
||||
export const useAppStoreWithOut = () => {
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<p class="desc">查看所有招商及其下属主播、普通用户的汇总数据</p>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
三级表格的列一律用 min-width,不要改回 width:
|
||||
Element Plus 只在存在弹性列时才把表格撑满容器,全列写死 width ⇒ 右侧留白。
|
||||
(二级/三级表在展开行内,容器宽度由父级决定,所以更要用 min-width 跟随容器。)
|
||||
详见 docs/修改记录.md「表格右侧留白」。
|
||||
-->
|
||||
<!-- 一级:招商列表 -->
|
||||
<ElTable v-loading="loading" :data="pagedData" border stripe row-key="userId">
|
||||
<ElTableColumn type="expand">
|
||||
@@ -21,26 +27,26 @@
|
||||
|
||||
<!-- 三级:普通用户列表 -->
|
||||
<ElTable :data="anchor.users || []" border size="small">
|
||||
<ElTableColumn prop="userId" label="用户ID" width="90" />
|
||||
<ElTableColumn prop="nickName" label="昵称" width="110" />
|
||||
<ElTableColumn prop="userName" label="用户名" width="120" />
|
||||
<ElTableColumn prop="createTime" label="创建时间" width="155" />
|
||||
<ElTableColumn label="总充值" width="105">
|
||||
<ElTableColumn prop="userId" label="用户ID" min-width="90" />
|
||||
<ElTableColumn prop="nickName" label="昵称" min-width="110" />
|
||||
<ElTableColumn prop="userName" label="用户名" min-width="120" />
|
||||
<ElTableColumn prop="createTime" label="创建时间" min-width="155" />
|
||||
<ElTableColumn label="总充值" min-width="105">
|
||||
<template #default="{ row: u }">
|
||||
{{ formatMoney(u.totalRecharge) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="总流水" width="110">
|
||||
<ElTableColumn label="总流水" min-width="110">
|
||||
<template #default="{ row: u }">
|
||||
{{ formatMoney(u.totalTurnover) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="特殊游戏流水" width="115">
|
||||
<ElTableColumn label="特殊游戏流水" min-width="115">
|
||||
<template #default="{ row: u }">
|
||||
{{ formatMoney(u.specialGameTurnover) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="经典游戏流水" width="115">
|
||||
<ElTableColumn label="经典游戏流水" min-width="115">
|
||||
<template #default="{ row: u }">
|
||||
{{ formatMoney(u.classicGameTurnover) }}
|
||||
</template>
|
||||
@@ -49,16 +55,16 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="userId" label="主播ID" width="120" />
|
||||
<ElTableColumn prop="nickName" label="昵称" width="140" />
|
||||
<ElTableColumn prop="userName" label="用户名" width="150" />
|
||||
<ElTableColumn prop="userCount" label="下属用户数" width="110" />
|
||||
<ElTableColumn label="主播充值" width="130">
|
||||
<ElTableColumn prop="userId" label="主播ID" min-width="120" />
|
||||
<ElTableColumn prop="nickName" label="昵称" min-width="140" />
|
||||
<ElTableColumn prop="userName" label="用户名" min-width="150" />
|
||||
<ElTableColumn prop="userCount" label="下属用户数" min-width="110" />
|
||||
<ElTableColumn label="主播充值" min-width="130">
|
||||
<template #default="{ row: a }">
|
||||
{{ formatMoney(a.selfRecharge) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="总收益" width="130">
|
||||
<ElTableColumn label="总收益" min-width="130">
|
||||
<template #default="{ row: a }">
|
||||
{{ formatMoney(a.totalEarnings) }}
|
||||
</template>
|
||||
@@ -68,26 +74,26 @@
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<ElTableColumn prop="userId" label="招商ID" width="120" />
|
||||
<ElTableColumn prop="nickName" label="昵称" width="140" />
|
||||
<ElTableColumn prop="userName" label="用户名" width="150" />
|
||||
<ElTableColumn prop="anchorCount" label="下属主播数" width="110" />
|
||||
<ElTableColumn label="总充值" width="130">
|
||||
<ElTableColumn prop="userId" label="招商ID" min-width="120" />
|
||||
<ElTableColumn prop="nickName" label="昵称" min-width="140" />
|
||||
<ElTableColumn prop="userName" label="用户名" min-width="150" />
|
||||
<ElTableColumn prop="anchorCount" label="下属主播数" min-width="110" />
|
||||
<ElTableColumn label="总充值" min-width="130">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(row.totalRecharge) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="总流水" width="130">
|
||||
<ElTableColumn label="总流水" min-width="130">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(row.totalTurnover) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="总收益" width="130">
|
||||
<ElTableColumn label="总收益" min-width="130">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(row.totalEarnings) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="createTime" label="创建时间" width="170" />
|
||||
<ElTableColumn prop="createTime" label="创建时间" min-width="170" />
|
||||
</ElTable>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
<ElButton type="primary" @click="handleSearch">搜索</ElButton>
|
||||
<ElButton @click="handleReset">重置</ElButton>
|
||||
</div>
|
||||
<!--
|
||||
数据列一律用 min-width(「操作」列除外,它要固定宽):
|
||||
Element Plus 只在存在弹性列时才把表格撑满容器,全列写死 width ⇒ 右侧留白。
|
||||
详见 docs/修改记录.md「表格右侧留白」。
|
||||
-->
|
||||
<ElTable
|
||||
:data="cardList"
|
||||
row-key="cardCode"
|
||||
@@ -44,8 +49,8 @@
|
||||
v-loading="tableLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
<ElTableColumn prop="id" label="ID" width="80" align="center" />
|
||||
<ElTableColumn prop="cardCode" label="卡密" width="200" align="center">
|
||||
<ElTableColumn prop="id" label="ID" min-width="80" align="center" />
|
||||
<ElTableColumn prop="cardCode" label="卡密" min-width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="card-code">{{ row.cardCode }}</span>
|
||||
<ElButton type="primary" link size="small" @click="copyText(row.cardCode)">
|
||||
@@ -53,26 +58,26 @@
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="amount" label="金额" width="100" align="center" />
|
||||
<ElTableColumn prop="status" label="状态" width="90" align="center">
|
||||
<ElTableColumn prop="amount" label="金额" min-width="100" align="center" />
|
||||
<ElTableColumn prop="status" label="状态" min-width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<ElTag v-if="row.status === 0" type="success" size="small">未使用</ElTag>
|
||||
<ElTag v-else-if="row.status === 2" type="danger" size="small">已销毁</ElTag>
|
||||
<ElTag v-else type="info" size="small">已使用</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="usedByName" label="使用者" width="140" align="center">
|
||||
<ElTableColumn prop="usedByName" label="使用者" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.usedByName || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="usedTime" label="使用时间" width="180" align="center">
|
||||
<ElTableColumn prop="usedTime" label="使用时间" min-width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.usedTime || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="creatorName" label="创建者" width="140" align="center" />
|
||||
<ElTableColumn prop="createTime" label="创建时间" width="180" align="center" />
|
||||
<ElTableColumn prop="creatorName" label="创建者" min-width="140" align="center" />
|
||||
<ElTableColumn prop="createTime" label="创建时间" min-width="180" align="center" />
|
||||
<ElTableColumn label="操作" width="100" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElPopconfirm
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!--
|
||||
三张报表的列一律用 min-width,不要改回 width:
|
||||
Element Plus 只在「存在弹性列」时才把表格撑满容器
|
||||
(element-plus/es/components/table/src/table-layout.mjs 的 updateColumnsWidth)。
|
||||
全列写死 width 时表格宽 = 各列宽之和 ⇒ 容器再宽右侧也会留白。
|
||||
注意 setColumnWidth 会把 `width` 静态字符串 parseInt 成数字 ⇒ 那是固定列;
|
||||
只写 `min-width` 才会把 column.width 置空 ⇒ 成为弹性列。详见 docs/修改记录.md。
|
||||
-->
|
||||
<ElTabs v-model="activeTab">
|
||||
<ElTabPane label="佣金明细记录" name="detail">
|
||||
<div class="search-bar">
|
||||
@@ -10,21 +18,21 @@
|
||||
<ElButton type="primary" :icon="Search" @click="loadDetail">查询</ElButton>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="pagedDetailData" border stripe>
|
||||
<ElTableColumn prop="localTime" label="本地时间" sortable width="170" />
|
||||
<ElTableColumn prop="phone" label="手机号" width="130" />
|
||||
<ElTableColumn prop="consumeAmount" sortable width="120">
|
||||
<ElTableColumn prop="localTime" label="本地时间" sortable min-width="170" />
|
||||
<ElTableColumn prop="phone" label="手机号" min-width="130" />
|
||||
<ElTableColumn prop="consumeAmount" sortable min-width="120">
|
||||
<template #header>
|
||||
<span>该次消费/充值金额</span>
|
||||
</template>
|
||||
<template #default="{ row }"><span style="color:#f56c6c">{{ formatNum(row.consumeAmount) }}</span></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="佣金比例" width="90">
|
||||
<ElTableColumn label="佣金比例" min-width="90">
|
||||
<template #default="{ row }">{{ formatRate(row.commissionRate, row.sourceGame) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="commissionAmount" label="佣金金额" sortable width="100">
|
||||
<ElTableColumn prop="commissionAmount" label="佣金金额" sortable min-width="100">
|
||||
<template #default="{ row }"><span style="color:#67c23a">{{ formatNum(row.commissionAmount) }}</span></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="sourceGame" label="来源" width="120">
|
||||
<ElTableColumn prop="sourceGame" label="来源" min-width="120">
|
||||
<template #default="{ row }">{{ gameTypeLabel(row.sourceGame, row.originalSource) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
@@ -42,17 +50,17 @@
|
||||
<ElButton type="primary" :icon="Search" @click="loadDaily">查询</ElButton>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="pagedDailyData" border stripe>
|
||||
<ElTableColumn prop="phone" label="手机号" width="130" />
|
||||
<ElTableColumn prop="consumeAmount" label="今日消费" sortable width="120">
|
||||
<ElTableColumn prop="phone" label="手机号" min-width="130" />
|
||||
<ElTableColumn prop="consumeAmount" label="今日消费" sortable min-width="120">
|
||||
<template #default="{ row }"><span style="color:#f56c6c">{{ formatNum(row.consumeAmount) }}</span></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="gameType" label="来源" width="120">
|
||||
<ElTableColumn prop="gameType" label="来源" min-width="120">
|
||||
<template #default="{ row }">{{ gameTypeLabel(row.gameType) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="commissionAmount" label="佣金金额" sortable width="120">
|
||||
<ElTableColumn prop="commissionAmount" label="佣金金额" sortable min-width="120">
|
||||
<template #default="{ row }"><span style="color:#67c23a">{{ formatNum(row.commissionAmount) }}</span></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="date" label="日期" sortable width="130" />
|
||||
<ElTableColumn prop="date" label="日期" sortable min-width="130" />
|
||||
</ElTable>
|
||||
<div class="pagination-wrap">
|
||||
<ElPagination v-model:current-page="dailyPage" v-model:page-size="dailySize" :page-sizes="[10,20,50]" :total="dailyData.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dailyPage = 1" />
|
||||
@@ -69,17 +77,17 @@
|
||||
<ElButton type="primary" :icon="Search" @click="loadSummary">查询</ElButton>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="pagedSummaryData" border stripe>
|
||||
<ElTableColumn prop="phone" label="手机号" width="130" />
|
||||
<ElTableColumn prop="gameType" label="来源" width="120">
|
||||
<ElTableColumn prop="phone" label="手机号" min-width="130" />
|
||||
<ElTableColumn prop="gameType" label="来源" min-width="120">
|
||||
<template #default="{ row }">{{ gameTypeLabel(row.gameType) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="totalFlow" label="消费金额" sortable width="120">
|
||||
<ElTableColumn prop="totalFlow" label="消费金额" sortable min-width="120">
|
||||
<template #default="{ row }"><span style="color:#f56c6c">{{ formatNum(row.totalFlow) }}</span></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="totalCommission" label="本次佣金" sortable width="120">
|
||||
<ElTableColumn prop="totalCommission" label="本次佣金" sortable min-width="120">
|
||||
<template #default="{ row }"><span style="color:#67c23a">{{ formatNum(row.totalCommission) }}</span></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="gameCount" label="参加游戏次数" sortable width="140" />
|
||||
<ElTableColumn prop="gameCount" label="参加游戏次数" sortable min-width="140" />
|
||||
</ElTable>
|
||||
<div class="pagination-wrap">
|
||||
<ElPagination v-model:current-page="summaryPage" v-model:page-size="summarySize" :page-sizes="[10,20,50]" :total="summaryData.length" layout="total, sizes, prev, pager, next, jumper" @size-change="summaryPage = 1" />
|
||||
@@ -104,7 +112,7 @@ const gameTypeLabel = (code: number, originalSource?: number) => {
|
||||
4: '对战',
|
||||
38: '疯狂转盘',
|
||||
40: '幸运升级',
|
||||
60: '玫瑰女郎'
|
||||
68: '麻将胡了'
|
||||
}
|
||||
const key = originalSource || code
|
||||
if (key != null && originalMap[key]) return originalMap[key]
|
||||
|
||||
@@ -81,9 +81,9 @@
|
||||
<span style="color: #f56c6c">{{ formatNum(row.crazyWheelConsume) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="玫瑰女郎" width="110">
|
||||
<ElTableColumn label="麻将胡了" width="110">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #e6a23c">{{ formatNum(row.olympusConsume) }}</span>
|
||||
<span style="color: #e6a23c">{{ formatNum(row.mahjongConsume) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
@@ -130,9 +130,9 @@
|
||||
<span style="color: #f56c6c">{{ formatNum(child.crazyWheelConsume) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="玫瑰女郎" width="110">
|
||||
<ElTableColumn label="麻将胡了" width="110">
|
||||
<template #default="{ row: child }">
|
||||
<span style="color: #e6a23c">{{ formatNum(child.olympusConsume) }}</span>
|
||||
<span style="color: #e6a23c">{{ formatNum(child.mahjongConsume) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
+379
-188
@@ -1,8 +1,118 @@
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- 基本信息 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
<!-- ① 我的推广(置顶通栏):把每天真正要用的「链接 / 邀请码」提到最显眼处 -->
|
||||
<section class="cc-card promo">
|
||||
<div class="promo-hero">
|
||||
<div class="hero-main">
|
||||
<div class="hero-label">我的推广链接</div>
|
||||
<div class="hero-link" :title="shareLink">
|
||||
{{ shareLink || '暂未生成推广链接' }}
|
||||
</div>
|
||||
</div>
|
||||
<ElButton
|
||||
type="primary"
|
||||
class="hero-btn"
|
||||
:disabled="!shareLink"
|
||||
@click="copyText(shareLink)"
|
||||
>
|
||||
<ElIcon :size="15"><DocumentCopy /></ElIcon>
|
||||
<span class="hero-btn-text">复制链接</span>
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="promo-cells">
|
||||
<div class="pcell">
|
||||
<div class="pcell-label">我的邀请码</div>
|
||||
<div class="pcell-value">
|
||||
<span class="pcell-strong">{{ userInfo.invitationCode || '-' }}</span>
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="copyText(userInfo.invitationCode)"
|
||||
>
|
||||
<ElIcon :size="14"><DocumentCopy /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="!userInfo.invitationCodeModified"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
@click="editDialogVisible = true"
|
||||
>
|
||||
修改
|
||||
</ElButton>
|
||||
<span v-else class="pcell-tip">(已修改)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcell">
|
||||
<div class="pcell-label">手机号码</div>
|
||||
<div class="pcell-value">{{ userInfo.phoneNumber || '-' }}</div>
|
||||
</div>
|
||||
<div class="pcell">
|
||||
<div class="pcell-label">上级邀请码</div>
|
||||
<div class="pcell-value">{{ userInfo.parentInvitationCode || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ② 返利 KPI 横带:累计 / 今日 / 本周 / 下级 -->
|
||||
<div class="kpi-band">
|
||||
<div v-for="k in kpis" :key="k.label" class="kpi">
|
||||
<div class="kpi-label">{{ k.label }}</div>
|
||||
<div class="kpi-value" :class="k.tone">{{ k.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ 底部两栏:返利比例(左窄) | 周期返利汇总(右宽) -->
|
||||
<div class="bottom-grid">
|
||||
<section class="cc-card rate-panel">
|
||||
<h3 class="cc-title">返利比例</h3>
|
||||
<ul class="rate-list">
|
||||
<li v-for="g in rateRows" :key="g.name" class="rate-row">
|
||||
<span class="rate-name">{{ g.name }}</span>
|
||||
<span class="rate-dots" aria-hidden="true"></span>
|
||||
<span class="rate-val">{{ formatRate(g.rate) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="cc-card table-panel">
|
||||
<h3 class="cc-title">周期返利汇总</h3>
|
||||
<ElTable :data="periodData" border stripe class="commission-table">
|
||||
<ElTableColumn prop="period" label="周期" align="center" width="100" />
|
||||
<ElTableColumn prop="classicBox" label="经典开箱" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.classicBox) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="luckyUpgrade" label="幸运升级" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.luckyUpgrade) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="battle" label="对战" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.battle) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="crazyWheel" label="疯狂转盘" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.crazyWheel) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="mahjong" label="麻将胡了" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.mahjong) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="totalConsume" label="总消费" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.totalConsume) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="recharge" label="充值" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.recharge) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="newSub" label="新增下级" align="center" min-width="100" />
|
||||
<ElTableColumn prop="totalRebate" label="总返利" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.totalRebate) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ④ 账号信息(降级到底部):日常用不到的身份/余额信息 -->
|
||||
<section class="cc-card account-panel">
|
||||
<h3 class="cc-title">账号信息</h3>
|
||||
<ElDescriptions :column="3" border class="custom-desc">
|
||||
<ElDescriptionsItem label="用户账号">
|
||||
{{ userInfo.userId }}
|
||||
@@ -16,121 +126,18 @@
|
||||
<ElDescriptionsItem label="积分余额">
|
||||
{{ userInfo.accountCredits ?? 0 }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="手机号码">
|
||||
{{ userInfo.userName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="注册时间">
|
||||
{{ userInfo.createTime }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="Steam交易链接">
|
||||
{{ userInfo.transactionLink || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="上级邀请码">
|
||||
{{ userInfo.parentInvitationCode || '-' }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="我的邀请码">
|
||||
{{ userInfo.invitationCode }}
|
||||
<ElButton
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
style="margin-left: 8px"
|
||||
@click="copyText(userInfo.invitationCode)"
|
||||
>
|
||||
<ElIcon :size="14"><DocumentCopy /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="!userInfo.invitationCodeModified"
|
||||
type="warning"
|
||||
link
|
||||
size="small"
|
||||
style="margin-left: 8px"
|
||||
@click="editDialogVisible = true"
|
||||
>
|
||||
修改
|
||||
</ElButton>
|
||||
<span v-else style="margin-left: 8px; color: #909399; font-size: 12px">(已修改)</span>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="推广链接">
|
||||
{{ shareLink }}
|
||||
<ElButton
|
||||
v-if="shareLink"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
style="margin-left: 8px"
|
||||
@click="copyText(shareLink)"
|
||||
>
|
||||
<ElIcon :size="14"><DocumentCopy /></ElIcon>
|
||||
</ElButton>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="" />
|
||||
</ElDescriptions>
|
||||
</div>
|
||||
|
||||
<!-- 游戏返利看板 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">游戏返利比例</h3>
|
||||
<div class="game-rate-cards">
|
||||
<div class="rate-card">
|
||||
<div class="rate-card-title">经典开箱</div>
|
||||
<div class="rate-card-value">{{ formatRate(gameRates.classicGameRate) }}</div>
|
||||
</div>
|
||||
<div class="rate-card">
|
||||
<div class="rate-card-title">幸运升级</div>
|
||||
<div class="rate-card-value">{{ formatRate(gameRates.classicGameRate) }}</div>
|
||||
</div>
|
||||
<div class="rate-card">
|
||||
<div class="rate-card-title">对战</div>
|
||||
<div class="rate-card-value">{{ formatRate(gameRates.classicGameRate) }}</div>
|
||||
</div>
|
||||
<div class="rate-card">
|
||||
<div class="rate-card-title">疯狂转盘</div>
|
||||
<div class="rate-card-value">{{ formatRate(gameRates.classicGameRate) }}</div>
|
||||
</div>
|
||||
<div class="rate-card">
|
||||
<div class="rate-card-title">玫瑰女郎</div>
|
||||
<div class="rate-card-value">{{ formatRate(gameRates.newGameRate) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 周期返利汇总 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">周期返利汇总 </h3>
|
||||
<ElTable :data="periodData" border stripe class="commission-table">
|
||||
<ElTableColumn prop="period" label="周期" align="center" width="100" />
|
||||
<ElTableColumn prop="classicBox" label="经典开箱" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.classicBox) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="luckyUpgrade" label="幸运升级" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.luckyUpgrade) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="battle" label="对战" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.battle) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="crazyWheel" label="疯狂转盘" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.crazyWheel) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="olympus" label="玫瑰女郎" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.olympus) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="totalConsume" label="总消费" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.totalConsume) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="recharge" label="充值" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.recharge) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="newSub" label="新增下级" align="center" min-width="100" />
|
||||
<ElTableColumn prop="totalRebate" label="总返利" align="center" min-width="110">
|
||||
<template #default="{ row }">{{ formatNum(row.totalRebate) }}</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 修改邀请码弹窗 -->
|
||||
<ElDialog v-model="editDialogVisible" title="修改邀请码" width="400px">
|
||||
<p style="color: #e6a23c; margin-bottom: 12px">注意:邀请码只能修改一次,请谨慎操作!</p>
|
||||
<p style="color: #8f6418; margin-bottom: 12px">注意:邀请码只能修改一次,请谨慎操作!</p>
|
||||
<ElForm>
|
||||
<ElFormItem label="新邀请码">
|
||||
<ElInput v-model="newInvitationCode" placeholder="请输入 6 位邀请码" maxlength="6" :pattern="/^[a-zA-Z0-9]*$/" :rules="[{ required: true, message: '邀请码只能包含英文字母和数字', trigger: 'blur' }]" />
|
||||
@@ -150,12 +157,10 @@ import { ElDescriptions, ElDescriptionsItem, ElButton, ElMessage, ElDialog, ElFo
|
||||
import { DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { getInfo, getShareDomain, updateInvitationCode } from '@/api/user'
|
||||
import { getGameCommissionData } from '@/api/turnover'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
// 用户基本信息
|
||||
const userInfo = reactive<any>({})
|
||||
const shareLink = ref('')
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 修改邀请码弹窗
|
||||
const editDialogVisible = ref(false)
|
||||
@@ -183,13 +188,6 @@ const submitInvitationCode = async () => {
|
||||
editDialogVisible.value = false
|
||||
newInvitationCode.value = ''
|
||||
await loadUserInfo()
|
||||
// 同步更新顶部 header 中的分享链接
|
||||
if (userStore.getUserInfo) {
|
||||
userStore.setUserInfo({
|
||||
...userStore.getUserInfo,
|
||||
shareLink: shareLink.value
|
||||
})
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.msg || '修改失败')
|
||||
}
|
||||
@@ -205,7 +203,7 @@ const gameRates = reactive({
|
||||
classicGameRate: 0
|
||||
})
|
||||
|
||||
// 周期返利汇总数据
|
||||
// 周期返利汇总数据(后端顺序固定:今日 / 昨日 / 本周 / 总)
|
||||
const periodData = ref([] as any[])
|
||||
|
||||
// 用户类型映射
|
||||
@@ -221,6 +219,38 @@ const userTypeLabel = computed(() => {
|
||||
return map[userInfo.userType] || '未知'
|
||||
})
|
||||
|
||||
/**
|
||||
* 返利比例列表。后端只下发三个比例(充值 / 新游戏 / 经典玩法),
|
||||
* 5 个玩法里「麻将胡了」走新游戏比例,其余 4 个经典玩法共用经典比例
|
||||
* —— 与后端 buildPeriod 里 classicConsume / mahjong 的算法口径一致。
|
||||
*/
|
||||
const rateRows = computed(() => [
|
||||
{ name: '经典开箱', rate: gameRates.classicGameRate },
|
||||
{ name: '幸运升级', rate: gameRates.classicGameRate },
|
||||
{ name: '对战', rate: gameRates.classicGameRate },
|
||||
{ name: '疯狂转盘', rate: gameRates.classicGameRate },
|
||||
{ name: '麻将胡了', rate: gameRates.newGameRate }
|
||||
])
|
||||
|
||||
// 按周期名取 periodData 里的一行
|
||||
const periodOf = (name: string) => periodData.value.find((p: any) => p.period === name)
|
||||
|
||||
/**
|
||||
* 顶部 KPI 横带:累计返利 / 今日返利 / 本周返利 / 累计下级。
|
||||
* 全部取自周期返利汇总的「总 / 今日 / 本周」行,不额外发请求。
|
||||
*/
|
||||
const kpis = computed(() => {
|
||||
const total = periodOf('总')
|
||||
const today = periodOf('今日')
|
||||
const week = periodOf('本周')
|
||||
return [
|
||||
{ label: '累计返利', value: formatNum(total?.totalRebate ?? 0), tone: 'is-primary' },
|
||||
{ label: '今日返利', value: formatNum(today?.totalRebate ?? 0), tone: '' },
|
||||
{ label: '本周返利', value: formatNum(week?.totalRebate ?? 0), tone: '' },
|
||||
{ label: '累计下级', value: String(total?.newSub ?? 0), tone: 'is-success' }
|
||||
]
|
||||
})
|
||||
|
||||
// 复制到剪贴板(兼容非 HTTPS 环境)
|
||||
const copyText = (text: string) => {
|
||||
if (navigator.clipboard) {
|
||||
@@ -322,97 +352,258 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
.home-page {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.user-type-anchor {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
/* 覆盖 ElDescriptions 默认的 label 灰色背景 */
|
||||
.custom-desc :deep(.el-descriptions__label) {
|
||||
background-color: #fff;
|
||||
color: #606266;
|
||||
font-weight: normal;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.custom-desc :deep(.el-descriptions__content) {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* 游戏返利比例卡片 */
|
||||
.game-rate-cards {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rate-card {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
max-width: calc(20% - 12px);
|
||||
background-color: #f5f7fa;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
padding: 20px 15px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
/* 通用卡片外壳(田园核:奶油面 + 亚麻描边) */
|
||||
.cc-card {
|
||||
background-color: #fffdfa;
|
||||
border: 1px solid #eadfd0;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 2px 10px rgba(139, 115, 85, 0.06);
|
||||
}
|
||||
|
||||
.rate-card:hover {
|
||||
background-color: #fbf5ea;
|
||||
border-color: #9a5c35;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 12px rgba(154, 92, 53, 0.10);
|
||||
}
|
||||
|
||||
.rate-card-title {
|
||||
.cc-title {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
font-weight: 700;
|
||||
color: #4f4130;
|
||||
margin: 0 0 14px 0;
|
||||
}
|
||||
|
||||
/* ===== ① 我的推广 ===== */
|
||||
.promo-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
background-color: #fbf5ea;
|
||||
border: 1px solid #eadfd0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hero-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
font-size: 12px;
|
||||
color: #7a664e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #4f4130;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.hero-btn-text {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.promo-cells {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed #eadfd0;
|
||||
}
|
||||
|
||||
.pcell {
|
||||
padding: 0 18px;
|
||||
border-left: 1px solid #eadfd0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcell:first-child {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.pcell-label {
|
||||
font-size: 12px;
|
||||
color: #7a664e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pcell-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
color: #4f4130;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pcell-strong {
|
||||
font-weight: 700;
|
||||
color: #9a5c35;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.pcell-tip {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: #7a664e;
|
||||
}
|
||||
|
||||
/* ===== ② KPI 横带 ===== */
|
||||
.kpi-band {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
background-color: #fffdfa;
|
||||
border: 1px solid #eadfd0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(139, 115, 85, 0.06);
|
||||
}
|
||||
|
||||
.kpi {
|
||||
padding: 16px 20px;
|
||||
border-left: 1px solid #eadfd0;
|
||||
}
|
||||
|
||||
.kpi:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 12px;
|
||||
color: #7a664e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rate-card-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 12px;
|
||||
.kpi-value {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #4f4130;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rate-card-value {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #67c23a;
|
||||
.kpi-value.is-primary {
|
||||
color: #9a5c35;
|
||||
}
|
||||
|
||||
.kpi-value.is-success {
|
||||
color: #3f6b45;
|
||||
}
|
||||
|
||||
/* ===== ③ 底部两栏 ===== */
|
||||
.bottom-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.rate-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rate-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #eadfd0;
|
||||
}
|
||||
|
||||
.rate-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rate-name {
|
||||
font-size: 14px;
|
||||
color: #5f4f3d;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.rate-dots {
|
||||
flex: 1;
|
||||
border-bottom: 1px dotted #d8c3b0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.rate-val {
|
||||
flex: none;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #3f6b45;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.table-panel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 返利汇总表格 */
|
||||
.commission-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.commission-table :deep(.el-table__header th) {
|
||||
background-color: #fafafa !important;
|
||||
color: #606266;
|
||||
font-weight: bold;
|
||||
background-color: #f6efe3 !important;
|
||||
color: #5f4f3d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commission-table :deep(.el-table__row tr:nth-child(odd)) {
|
||||
background-color: #fafafa;
|
||||
/* ===== ④ 账号信息(降级:不做成卡片,别和上面的数据抢注意力) ===== */
|
||||
.account-panel {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.commission-table :deep(.el-table__row tr:nth-child(even)) {
|
||||
background-color: #fff;
|
||||
.user-type-anchor {
|
||||
color: #8f6418;
|
||||
}
|
||||
|
||||
.custom-desc :deep(.el-descriptions__label) {
|
||||
background-color: #f6efe3;
|
||||
color: #7a664e;
|
||||
font-weight: normal;
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.custom-desc :deep(.el-descriptions__content) {
|
||||
background-color: #fffdfa;
|
||||
color: #4f4130;
|
||||
}
|
||||
|
||||
/* 窄屏:底部两栏收成一栏,表格拿满宽度 */
|
||||
@media (max-width: 1200px) {
|
||||
.bottom-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kpi-band {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.promo-cells {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pcell {
|
||||
padding: 0;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -215,86 +215,15 @@ const inviteCode = computed(() => {
|
||||
|
||||
/* =========================
|
||||
验证码行
|
||||
只改按钮,不碰 input / row / col 的布局
|
||||
「获取验证码」按钮的配色写在 LoginForm.vue 里(.login-code-btn)。
|
||||
这里原本有一条 `.el-form-item .el-row .el-col:last-child .el-button`
|
||||
和一组 .send-code / .code-button / .get-code-btn,都是死代码:
|
||||
Form.vue 的实际结构是 ElRow > ElCol > ElFormItem > 插槽内容,
|
||||
el-row 是 el-form-item 的祖先而不是后代,选择器永远匹配不到;
|
||||
类名也对不上(真实类名是 .login-code-btn)。
|
||||
不要再往这里加按钮样式,改 LoginForm.vue。
|
||||
========================= */
|
||||
|
||||
/*
|
||||
LoginForm 常见结构:
|
||||
el-form-item
|
||||
el-row
|
||||
el-col -> input
|
||||
el-col -> button
|
||||
|
||||
这里仅让右侧按钮与输入框同高、同圆角、同玻璃质感。
|
||||
*/
|
||||
:deep(.el-form-item .el-row .el-col:last-child .el-button) {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
|
||||
padding: 0 16px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
border: 1px solid rgba(212, 160, 160, 0.6);
|
||||
|
||||
background: rgba(245, 215, 95, 0.22);
|
||||
|
||||
color: #9a5c35;
|
||||
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
box-shadow: none;
|
||||
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #7d4a2a;
|
||||
|
||||
border-color: #c08a8a;
|
||||
|
||||
background: rgba(245, 215, 95, 0.34);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
&.is-disabled,
|
||||
&:disabled {
|
||||
color: #9a886e;
|
||||
|
||||
border-color: #eadfd0;
|
||||
|
||||
background: #f6efe3;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
如果验证码按钮不是放在 el-row/el-col 里,
|
||||
兼容常见的 send-code / code-button 类。
|
||||
*/
|
||||
:deep(.send-code),
|
||||
:deep(.code-button),
|
||||
:deep(.get-code-btn) {
|
||||
height: 48px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
border: 1px solid rgba(212, 160, 160, 0.6);
|
||||
|
||||
background: rgba(245, 215, 95, 0.22);
|
||||
|
||||
color: #9a5c35;
|
||||
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
登录主按钮
|
||||
========================= */
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="tsx">
|
||||
import { getAdminRoleApi, getCodeApi, smsLoginApi } from '@/api/login'
|
||||
import type { SmsLoginType, UserType } from '@/api/login/types'
|
||||
import {
|
||||
devLoginApi,
|
||||
getAdminRoleApi,
|
||||
getCodeApi,
|
||||
precheckPhoneApi,
|
||||
smsLoginApi
|
||||
} from '@/api/login'
|
||||
import type { DevLoginType, SmsLoginType, UserType } from '@/api/login/types'
|
||||
import { getMyMenus } from '@/api/permission'
|
||||
import { PATH_URL } from '@/axios/service'
|
||||
import { BaseButton } from '@/components/Button'
|
||||
@@ -25,16 +31,25 @@ const { currentRoute, addRoute, push, resolve } = router
|
||||
const { required } = useValidator()
|
||||
const { formRegister, formMethods } = useForm()
|
||||
const { getFormData, getElFormExpose, setValues } = formMethods
|
||||
// 账号密码登录用独立表单实例:与短信表单互斥渲染(v-if 切换),各自持有自己的 el-form
|
||||
const { formRegister: pwdFormRegister, formMethods: pwdFormMethods } = useForm()
|
||||
const { getFormData: getPwdFormData, getElFormExpose: getPwdElFormExpose } = pwdFormMethods
|
||||
|
||||
// 招商邀请链接里带的邀请码(/login?code=xxx),进入后自动填到邀请码框
|
||||
const props = defineProps<{
|
||||
inviteCode?: string
|
||||
}>()
|
||||
|
||||
// 账号密码登录只在本地 dev server(pnpm run dev)下提供:
|
||||
// 生产/测试用 vite build 打包时 import.meta.env.DEV 为 false,入口与表单整块不渲染
|
||||
const devPasswordLogin = import.meta.env.DEV
|
||||
const loginMode = ref<'sms' | 'password'>('sms')
|
||||
|
||||
const loading = ref(false)
|
||||
const redirect = ref('')
|
||||
const getCodeTime = ref(60)
|
||||
const getCodeLoading = ref(false)
|
||||
const captchaCode = ref('')
|
||||
let tacInstance: any = null
|
||||
let codeTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
@@ -61,19 +76,27 @@ const rules = {
|
||||
code: [required()]
|
||||
}
|
||||
|
||||
const passwordRules = {
|
||||
account: [required()],
|
||||
password: [required()]
|
||||
}
|
||||
|
||||
// 两种登录模式共用的标题,保证卡片标题样式完全一致
|
||||
const renderLoginTitle = () => (
|
||||
<div class="login-form-title">
|
||||
<div class="title-decoration"></div>
|
||||
<h2>登录</h2>
|
||||
<div class="title-decoration"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const schema = reactive<FormSchema[]>([
|
||||
{
|
||||
field: 'title',
|
||||
colProps: { span: 24 },
|
||||
formItemProps: {
|
||||
slots: {
|
||||
default: () => (
|
||||
<div class="login-form-title">
|
||||
<div class="title-decoration"></div>
|
||||
<h2>登录</h2>
|
||||
<div class="title-decoration"></div>
|
||||
</div>
|
||||
)
|
||||
default: renderLoginTitle
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -107,8 +130,17 @@ const schema = reactive<FormSchema[]>([
|
||||
if (event.key === 'Enter') signIn()
|
||||
}}
|
||||
/>
|
||||
<ElButton class="login-code-btn" disabled={unref(getCodeLoading)} onClick={getCode}>
|
||||
{unref(getCodeLoading) ? `${unref(getCodeTime)}s` : '获取验证码'}
|
||||
<ElButton
|
||||
class="login-code-btn"
|
||||
disabled={unref(getCodeLoading)}
|
||||
loading={unref(getCodeLoading) && !unref(captchaCode)}
|
||||
onClick={getCode}
|
||||
>
|
||||
{unref(getCodeLoading)
|
||||
? unref(captchaCode)
|
||||
? `${unref(getCodeTime)}s`
|
||||
: '发送中'
|
||||
: '获取验证码'}
|
||||
</ElButton>
|
||||
</div>
|
||||
)
|
||||
@@ -152,6 +184,82 @@ const schema = reactive<FormSchema[]>([
|
||||
}
|
||||
])
|
||||
|
||||
// 账号密码登录表单:字段口径与后端 /promo/devLogin 一致(推广账号填手机号,超管可填账号名或手机号)
|
||||
const passwordSchema = reactive<FormSchema[]>([
|
||||
{
|
||||
field: 'title',
|
||||
colProps: { span: 24 },
|
||||
formItemProps: {
|
||||
slots: {
|
||||
default: renderLoginTitle
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'account',
|
||||
label: '',
|
||||
value: '',
|
||||
colProps: { span: 24 },
|
||||
formItemProps: {
|
||||
slots: {
|
||||
default: (formData) => (
|
||||
<ElInput
|
||||
v-model={formData.account}
|
||||
maxlength={32}
|
||||
clearable
|
||||
placeholder="请输入账号(推广账号填手机号)"
|
||||
onKeydown={(event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') passwordSignIn()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'password',
|
||||
label: '',
|
||||
value: '',
|
||||
colProps: { span: 24 },
|
||||
formItemProps: {
|
||||
slots: {
|
||||
default: (formData) => (
|
||||
<ElInput
|
||||
v-model={formData.password}
|
||||
type="password"
|
||||
showPassword
|
||||
maxlength={32}
|
||||
placeholder="请输入密码"
|
||||
onKeydown={(event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') passwordSignIn()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'login',
|
||||
colProps: { span: 24 },
|
||||
formItemProps: {
|
||||
slots: {
|
||||
default: () => (
|
||||
<div class="login-form-btn w-[100%]">
|
||||
<BaseButton
|
||||
loading={loading.value}
|
||||
type="primary"
|
||||
class="full-width"
|
||||
onClick={passwordSignIn}
|
||||
>
|
||||
登录
|
||||
</BaseButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
watch(
|
||||
() => currentRoute.value,
|
||||
(route: RouteLocationNormalizedLoaded) => {
|
||||
@@ -165,6 +273,32 @@ onBeforeUnmount(() => {
|
||||
if (codeTimer) clearInterval(codeTimer)
|
||||
})
|
||||
|
||||
// 点下按钮就开始倒计时:短信网关往返通常 1~3 秒,等接口返回再倒计时会像卡死
|
||||
const startCountdown = () => {
|
||||
if (codeTimer) clearInterval(codeTimer)
|
||||
captchaCode.value = ''
|
||||
getCodeTime.value = 60
|
||||
getCodeLoading.value = true
|
||||
codeTimer = setInterval(() => {
|
||||
getCodeTime.value--
|
||||
if (getCodeTime.value <= 0) {
|
||||
clearInterval(codeTimer)
|
||||
codeTimer = undefined
|
||||
getCodeTime.value = 60
|
||||
getCodeLoading.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 发送失败(限流/验证码失效/发送异常):解禁按钮,让用户能立刻重试
|
||||
const resetCountdown = () => {
|
||||
if (codeTimer) clearInterval(codeTimer)
|
||||
codeTimer = undefined
|
||||
getCodeTime.value = 60
|
||||
getCodeLoading.value = false
|
||||
captchaCode.value = ''
|
||||
}
|
||||
|
||||
const getCode = async () => {
|
||||
if (getCodeLoading.value) return
|
||||
const { username, parentInvitationCode } = await getFormData<SmsLoginType>()
|
||||
@@ -172,6 +306,10 @@ const getCode = async () => {
|
||||
ElMessage.error('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
// 先验账号再弹滑块:不是推广后台用户(与后台账号校验同一份规则)直接拒绝,
|
||||
// 避免用户白拖一次滑块才被告知没权限;接口失败/被限流时放行走原流程,发送时后端还会再验一次
|
||||
const phoneCheck = await precheckPhoneApi(username, parentInvitationCode).catch(() => null)
|
||||
if (!phoneCheck) return
|
||||
if (typeof window.TAC === 'undefined' || typeof window.CaptchaConfig === 'undefined') {
|
||||
ElMessage.error('滑块验证码未加载,请刷新页面')
|
||||
return
|
||||
@@ -187,19 +325,18 @@ const getCode = async () => {
|
||||
validSuccess: (_res: unknown, _config: unknown, tac: any) => {
|
||||
tac.destroyWindow()
|
||||
tacInstance = null
|
||||
getCodeApi(username, captchaId, '2', parentInvitationCode).then(() => {
|
||||
ElMessage.success('验证码已发送')
|
||||
getCodeLoading.value = true
|
||||
codeTimer = setInterval(() => {
|
||||
getCodeTime.value--
|
||||
if (getCodeTime.value <= 0) {
|
||||
clearInterval(codeTimer)
|
||||
codeTimer = undefined
|
||||
getCodeTime.value = 60
|
||||
getCodeLoading.value = false
|
||||
startCountdown()
|
||||
getCodeApi(username, captchaId, '2', parentInvitationCode)
|
||||
.then((res: any) => {
|
||||
// 业务失败(限流/验证码失效)响应拦截器已弹过错误提示,这里只释放倒计时
|
||||
if (res?.code !== 0 && res?.code !== 200) {
|
||||
resetCountdown()
|
||||
return
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
captchaCode.value = captchaId
|
||||
ElMessage.success('验证码已发送')
|
||||
})
|
||||
.catch(() => resetCountdown())
|
||||
},
|
||||
validFail: (_res: unknown, _config: unknown, tac: any) => {
|
||||
ElMessage.error('滑块验证失败,请重试')
|
||||
@@ -248,6 +385,37 @@ const signIn = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 账号密码登录(仅 dev server 可见):后端 /promo/devLogin 校验 BCrypt 密码后直接签发 token,
|
||||
// 之后的落地流程与短信登录完全一致
|
||||
const passwordSignIn = async () => {
|
||||
if (loading.value) return
|
||||
const formRef = await getPwdElFormExpose()
|
||||
if (
|
||||
!(await formRef
|
||||
?.validateField(['account', 'password'])
|
||||
.then(() => true)
|
||||
.catch(() => false))
|
||||
)
|
||||
return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const formData = await getPwdFormData<DevLoginType>()
|
||||
const res = await devLoginApi({
|
||||
account: formData.account,
|
||||
password: formData.password
|
||||
})
|
||||
if (!res) return
|
||||
|
||||
userStore.setLoginInfo(undefined)
|
||||
userStore.setRememberMe(false)
|
||||
await completeLogin(formData.account, (res as any).token)
|
||||
await enterSystem(formData.account)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleRouters = async () => {
|
||||
const res = (await getMyMenus()) as any
|
||||
return { code: 0, data: res.data || [] }
|
||||
@@ -290,18 +458,96 @@ const enterSystem = async (username: string) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 登录方式切换:仅本地 dev server 渲染,生产/测试构建包不会出现该入口 -->
|
||||
<div v-if="devPasswordLogin" class="login-mode-switch">
|
||||
<button
|
||||
type="button"
|
||||
class="login-mode-item"
|
||||
:class="{ 'is-active': loginMode === 'sms' }"
|
||||
@click="loginMode = 'sms'"
|
||||
>
|
||||
短信验证码登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="login-mode-item"
|
||||
:class="{ 'is-active': loginMode === 'password' }"
|
||||
@click="loginMode = 'password'"
|
||||
>
|
||||
账号密码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
:schema="schema"
|
||||
:rules="rules"
|
||||
v-if="devPasswordLogin && loginMode === 'password'"
|
||||
:schema="passwordSchema"
|
||||
:rules="passwordRules"
|
||||
label-position="top"
|
||||
hide-required-asterisk
|
||||
size="large"
|
||||
@register="formRegister"
|
||||
@register="pwdFormRegister"
|
||||
/>
|
||||
<div id="captcha-box" class="captcha-box"></div>
|
||||
<template v-else>
|
||||
<Form
|
||||
:schema="schema"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
hide-required-asterisk
|
||||
size="large"
|
||||
@register="formRegister"
|
||||
/>
|
||||
<div id="captcha-box" class="captcha-box"></div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* =========================
|
||||
登录方式切换(仅 dev server 渲染)
|
||||
与卡片同色系:默认低调暖棕文字,选中态用奶油底 + 花粉细边
|
||||
对比度:默认 5.9:1 / hover 5.3:1 / 选中 6.3:1,均过 WCAG AA
|
||||
========================= */
|
||||
.login-mode-switch {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-mode-item {
|
||||
padding: 7px 16px;
|
||||
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
color: #7a664e;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #9a5c35;
|
||||
border-color: rgba(212, 160, 160, 0.45);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: #854c2a;
|
||||
font-weight: 600;
|
||||
|
||||
background: #fbf5ea;
|
||||
border-color: rgba(212, 160, 160, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
.captcha-box {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
@@ -322,32 +568,39 @@ const enterSystem = async (username: string) => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 独立圆角按钮:拼接式(el-input-group__append)左侧必须是直角,四角圆角做不到 */
|
||||
/* 独立圆角按钮:拼接式(el-input-group__append)左侧必须是直角,四角圆角做不到。
|
||||
原紫蓝半透明底(rgba(126,132,240,.3))+ 近白字(#eef0ff)是给深色背景配的,
|
||||
搬到奶油卡片上后字面比底色还亮(对比度约 0.8:1),按钮看上去就是一块空白;
|
||||
改用主题色板内色值:默认 6.31:1 / hover 5.99:1 / 倒计时 5.00:1,均过 WCAG AA */
|
||||
:deep(.sms-code-row .login-code-btn) {
|
||||
flex: 0 0 124px;
|
||||
height: 48px;
|
||||
margin: 0 0 0 10px;
|
||||
padding: 0;
|
||||
color: #eef0ff;
|
||||
color: #854c2a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(150, 158, 255, 0.42);
|
||||
border: 1px solid rgba(212, 160, 160, 0.55);
|
||||
border-radius: 12px;
|
||||
background: rgba(126, 132, 240, 0.3);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
|
||||
background: #fbf5ea;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.sms-code-row .login-code-btn:hover) {
|
||||
color: #fff;
|
||||
border-color: rgba(172, 180, 255, 0.62);
|
||||
background: rgba(126, 132, 240, 0.44);
|
||||
color: #854c2a;
|
||||
border-color: #c08a8a;
|
||||
background: #f6efe3;
|
||||
}
|
||||
|
||||
:deep(.sms-code-row .login-code-btn.is-disabled),
|
||||
:deep(.sms-code-row .login-code-btn.is-disabled:hover) {
|
||||
color: rgba(230, 232, 255, 0.45);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
background: rgba(126, 132, 240, 0.14);
|
||||
color: #6b6152;
|
||||
border-color: #eadfd0;
|
||||
background: #f0e8dc;
|
||||
}
|
||||
|
||||
:deep(.login-form-btn .el-button) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { RegisterUserType } from '@/api/login/types'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { completeLogin } from '../completeLogin'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { registerApi, getCodeApi } from '@/api/login'
|
||||
import { registerApi, getCodeApi, precheckPhoneApi } from '@/api/login'
|
||||
import { PATH_URL } from '@/axios/service'
|
||||
|
||||
declare global {
|
||||
@@ -38,12 +38,38 @@ const { required } = useValidator()
|
||||
const getCodeTime = ref(60)
|
||||
const getCodeLoading = ref(false)
|
||||
let tacInstance: any = null
|
||||
let codeTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
// 点下按钮就开始倒计时:短信网关往返通常 1~3 秒,等接口返回再倒计时会像卡死
|
||||
const startCountdown = () => {
|
||||
if (codeTimer) clearInterval(codeTimer)
|
||||
getCodeTime.value = 60
|
||||
getCodeLoading.value = true
|
||||
codeTimer = setInterval(() => {
|
||||
getCodeTime.value--
|
||||
if (getCodeTime.value <= 0) {
|
||||
clearInterval(codeTimer)
|
||||
codeTimer = undefined
|
||||
getCodeTime.value = 60
|
||||
getCodeLoading.value = false
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 发送失败(限流/验证码失效/发送异常):解禁按钮,让用户能立刻重试
|
||||
const resetCountdown = () => {
|
||||
if (codeTimer) clearInterval(codeTimer)
|
||||
codeTimer = undefined
|
||||
getCodeTime.value = 60
|
||||
getCodeLoading.value = false
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (tacInstance) {
|
||||
tacInstance.destroyWindow()
|
||||
tacInstance = null
|
||||
}
|
||||
if (codeTimer) clearInterval(codeTimer)
|
||||
})
|
||||
|
||||
const getCode = async () => {
|
||||
@@ -52,6 +78,12 @@ const getCode = async () => {
|
||||
ElMessage.error('手机号不能为空')
|
||||
return
|
||||
}
|
||||
// 先验账号再弹滑块,与登录页同一份规则(注册页无账号时靠招商邀请码放行);接口失败/被限流时放行走原流程
|
||||
const phoneCheck = await precheckPhoneApi(
|
||||
formData.username,
|
||||
(formData as any).parentInvitationCode
|
||||
).catch(() => null)
|
||||
if (!phoneCheck) return
|
||||
|
||||
// 判断TAC是否加载
|
||||
if (typeof window.TAC === 'undefined' || typeof window.CaptchaConfig === 'undefined') {
|
||||
@@ -71,23 +103,18 @@ const getCode = async () => {
|
||||
tac.destroyWindow()
|
||||
tacInstance = null
|
||||
const captchaId = window._captchaId
|
||||
// 滑块验证成功,发送短信验证码
|
||||
// 滑块验证成功,立刻进入倒计时,再等短信接口返回
|
||||
startCountdown()
|
||||
getCodeApi(formData.username, captchaId)
|
||||
.then(() => {
|
||||
.then((res: any) => {
|
||||
// 业务失败(限流/验证码失效)响应拦截器已弹过错误提示,这里只释放倒计时
|
||||
if (res?.code !== 0 && res?.code !== 200) {
|
||||
resetCountdown()
|
||||
return
|
||||
}
|
||||
ElMessage.success('获取验证码成功')
|
||||
getCodeLoading.value = true
|
||||
const timer = setInterval(() => {
|
||||
getCodeTime.value--
|
||||
if (getCodeTime.value <= 0) {
|
||||
clearInterval(timer)
|
||||
getCodeTime.value = 60
|
||||
getCodeLoading.value = false
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('获取验证码失败')
|
||||
})
|
||||
.catch(() => resetCountdown())
|
||||
},
|
||||
validFail: (res: any, c: any, tac: any) => {
|
||||
ElMessage.error('滑块验证失败,请重试')
|
||||
|
||||
@@ -255,12 +255,15 @@
|
||||
<ElTableColumn prop="id" label="ID" width="60" />
|
||||
<ElTableColumn prop="templateName" label="模板名称" min-width="150" />
|
||||
<ElTableColumn prop="ornamentCount" label="饰品数" width="80" />
|
||||
<ElTableColumn prop="createBy" label="创建人" width="100" />
|
||||
<ElTableColumn prop="createBy" label="创建人" width="100">
|
||||
<template #default="{ row }">{{ row.createBy || '其他主播' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="createTime" label="创建时间" width="170" />
|
||||
<ElTableColumn label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<ElButton type="primary" link size="small" @click="handleLoadTemplate(row)">加载</ElButton>
|
||||
<ElButton type="danger" link size="small" @click="handleDeleteTemplate(row.id)">删除</ElButton>
|
||||
<!-- 后端只允许创建者本人或超管删除;共享模板 createBy 会被置空,这里不给别人显示删除按钮 -->
|
||||
<ElButton v-if="row.createBy" type="danger" link size="small" @click="handleDeleteTemplate(row.id)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
@@ -19,21 +19,22 @@
|
||||
<ElButton type="primary" :icon="Search" @click="loadList">查询</ElButton>
|
||||
</div>
|
||||
|
||||
<!-- 列宽一律用 min-width:全列写死 width 时 Element Plus 不拉伸,表格右侧会留白(见 docs/修改记录.md) -->
|
||||
<ElTable v-loading="loading" :data="pagedData" border stripe>
|
||||
<ElTableColumn prop="rechargeTime" label="充值时间" sortable width="170" />
|
||||
<ElTableColumn prop="userId" label="用户ID" width="120" />
|
||||
<ElTableColumn prop="parentId" label="上级ID" width="120">
|
||||
<ElTableColumn prop="rechargeTime" label="充值时间" sortable min-width="170" />
|
||||
<ElTableColumn prop="userId" label="用户ID" min-width="120" />
|
||||
<ElTableColumn prop="parentId" label="上级ID" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.parentId || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="phone" label="手机号" width="130" />
|
||||
<ElTableColumn prop="userType" label="用户类型" width="100">
|
||||
<ElTableColumn prop="phone" label="手机号" min-width="130" />
|
||||
<ElTableColumn prop="userType" label="用户类型" min-width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.userType === '01' ? '主播' : row.userType === '02' ? '玩家' : row.userType === '04' ? '招商' : row.userType || '-' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="rechargeAmount" label="充值金额" sortable width="120">
|
||||
<ElTableColumn prop="rechargeAmount" label="充值金额" sortable min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #67c23a">{{ formatNum(row.rechargeAmount) }}</span>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!--
|
||||
数据列一律用 min-width(「操作」列除外,它要固定宽):
|
||||
全列写死 width 时 Element Plus 不拉伸表格 ⇒ 右侧留白(见 docs/修改记录.md)。
|
||||
-->
|
||||
<el-table
|
||||
:data="alltableData"
|
||||
style="width: 100%; margin-bottom: 20px; margin-top: 10px"
|
||||
@@ -21,25 +25,25 @@
|
||||
default-expand-all
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
>
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="userName" label="用户名" width="140" />
|
||||
<el-table-column prop="nickName" label="昵称" width="120" />
|
||||
<el-table-column prop="userType" label="用户类型" width="100">
|
||||
<el-table-column prop="userId" label="用户ID" min-width="120" />
|
||||
<el-table-column prop="userName" label="用户名" min-width="140" />
|
||||
<el-table-column prop="nickName" label="昵称" min-width="120" />
|
||||
<el-table-column prop="userType" label="用户类型" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.userType === '01'" type="warning" size="small">主播</el-tag>
|
||||
<el-tag v-else-if="scope.row.userType === '02'" type="info" size="small">玩家</el-tag>
|
||||
<el-tag v-else size="small">未知</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="170" />
|
||||
<el-table-column prop="totalRecharge" label="总充值" width="100" />
|
||||
<el-table-column prop="classicBoxConsume" label="经典开箱" width="100" />
|
||||
<el-table-column prop="battleConsume" label="对战" width="100" />
|
||||
<el-table-column prop="luckUpgradeConsume" label="幸运升级" width="100" />
|
||||
<el-table-column prop="crazyWheelConsume" label="疯狂转盘" width="100" />
|
||||
<el-table-column prop="westDuelConsume" label="荒野争锋" width="100" />
|
||||
<el-table-column prop="sweetConsume" label="极速糖果" width="100" />
|
||||
<el-table-column prop="totalCommission" label="总佣金" width="100" />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="170" />
|
||||
<el-table-column prop="totalRecharge" label="总充值" min-width="100" />
|
||||
<el-table-column prop="classicBoxConsume" label="经典开箱" min-width="100" />
|
||||
<el-table-column prop="battleConsume" label="对战" min-width="100" />
|
||||
<el-table-column prop="luckUpgradeConsume" label="幸运升级" min-width="100" />
|
||||
<el-table-column prop="crazyWheelConsume" label="疯狂转盘" min-width="100" />
|
||||
<el-table-column prop="westDuelConsume" label="荒野争锋" min-width="100" />
|
||||
<el-table-column prop="sweetConsume" label="极速糖果" min-width="100" />
|
||||
<el-table-column prop="totalCommission" label="总佣金" min-width="100" />
|
||||
<el-table-column label="操作" align="center" width="120" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
|
||||
@@ -159,6 +159,9 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
optimizeDeps: {
|
||||
// 默认会扫描 **/*.html,把 dist-dev/dist-pro/stats.html 等构建产物也当入口,
|
||||
// 显式限定为 dev 入口,避免产物目录里的旧代码参与依赖预构建
|
||||
entries: ['index.html'],
|
||||
include: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
|
||||
+1
-7
@@ -29,12 +29,6 @@
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<optional>true</optional> <!-- 表示依赖不会传递 -->
|
||||
</dependency>
|
||||
<!-- spring-doc -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Mysql驱动包 -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
@@ -97,7 +91,7 @@
|
||||
|
||||
<dependency>
|
||||
<groupId>com.ruoyi</groupId>
|
||||
<artifactId>game-olympus-service</artifactId>
|
||||
<artifactId>game-mahjong-service</artifactId>
|
||||
<version>${ruoyi.version}</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -33,7 +31,6 @@ import com.ruoyi.system.service.ISysDictTypeService;
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Api(tags = "管理端 字段值管理")
|
||||
@RestController
|
||||
@RequestMapping("/system/dict/data")
|
||||
public class SysDictDataController extends BaseController
|
||||
@@ -44,7 +41,6 @@ public class SysDictDataController extends BaseController
|
||||
@Autowired
|
||||
private ISysDictTypeService dictTypeService;
|
||||
|
||||
@ApiOperation("获取字典值")
|
||||
@PreAuthorize("@ss.hasPermi('system:dict:list')")
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(SysDictData dictData)
|
||||
@@ -91,7 +87,6 @@ public class SysDictDataController extends BaseController
|
||||
/**
|
||||
* 新增字典类型
|
||||
*/
|
||||
@ApiOperation("新增字典值")
|
||||
@PreAuthorize("@ss.hasPermi('system:dict:add')")
|
||||
@Log(title = "字典数据", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
@@ -104,7 +99,6 @@ public class SysDictDataController extends BaseController
|
||||
/**
|
||||
* 修改保存字典类型
|
||||
*/
|
||||
@ApiOperation("修改字典值")
|
||||
@PreAuthorize("@ss.hasPermi('system:dict:edit')")
|
||||
@Log(title = "字典数据", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.ruoyi.web.controller.system;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import io.swagger.annotations.Api;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -24,7 +23,6 @@ import com.ruoyi.system.service.ISysMenuService;
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Api(tags = "管理端认证api")
|
||||
@RestController
|
||||
public class SysLoginController
|
||||
{
|
||||
|
||||
@@ -11,11 +11,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* swagger 用户测试方法
|
||||
* 用户测试方法(若依自带示例,未对外使用)
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
//@Api(tags = "用户信息管理")
|
||||
@RestController
|
||||
@RequestMapping("/test/user")
|
||||
public class TestController extends BaseController
|
||||
@@ -26,7 +25,6 @@ public class TestController extends BaseController
|
||||
users.put(2, new UserEntity(2, "ry", "admin123", "15666666666"));
|
||||
}
|
||||
|
||||
// @ApiOperation("获取用户列表")
|
||||
@GetMapping("/list")
|
||||
public R<List<UserEntity>> userList()
|
||||
{
|
||||
@@ -34,8 +32,6 @@ public class TestController extends BaseController
|
||||
return R.ok(userList);
|
||||
}
|
||||
|
||||
// @ApiOperation("获取用户详细")
|
||||
// @ApiImplicitParam(name = "userId", value = "用户ID", required = true, dataType = "int", paramType = "path", dataTypeClass = Integer.class)
|
||||
@GetMapping("/{userId}")
|
||||
public R<UserEntity> getUser(@PathVariable Integer userId)
|
||||
{
|
||||
@@ -49,13 +45,6 @@ public class TestController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// @ApiOperation("新增用户")
|
||||
// @ApiImplicitParams({
|
||||
// @ApiImplicitParam(name = "userId", value = "用户id", dataType = "Integer", dataTypeClass = Integer.class),
|
||||
// @ApiImplicitParam(name = "username", value = "用户名称", dataType = "String", dataTypeClass = String.class),
|
||||
// @ApiImplicitParam(name = "password", value = "用户密码", dataType = "String", dataTypeClass = String.class),
|
||||
// @ApiImplicitParam(name = "mobile", value = "用户手机", dataType = "String", dataTypeClass = String.class)
|
||||
// })
|
||||
@PostMapping("/save")
|
||||
public R<String> save(UserEntity user)
|
||||
{
|
||||
@@ -67,7 +56,6 @@ public class TestController extends BaseController
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// @ApiOperation("更新用户")
|
||||
@PutMapping("/update")
|
||||
public R<String> update(@RequestBody UserEntity user)
|
||||
{
|
||||
@@ -84,8 +72,6 @@ public class TestController extends BaseController
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
// @ApiOperation("删除用户信息")
|
||||
// @ApiImplicitParam(name = "userId", value = "用户ID", required = true, dataType = "int", paramType = "path", dataTypeClass = Integer.class)
|
||||
@DeleteMapping("/{userId}")
|
||||
public R<String> delete(@PathVariable Integer userId)
|
||||
{
|
||||
@@ -101,19 +87,14 @@ public class TestController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
//@ApiModel(value = "UserEntity", description = "用户实体")
|
||||
class UserEntity
|
||||
{
|
||||
// @ApiModelProperty("用户ID")
|
||||
private Integer userId;
|
||||
|
||||
// @ApiModelProperty("用户名称")
|
||||
private String username;
|
||||
|
||||
// @ApiModelProperty("用户密码")
|
||||
private String password;
|
||||
|
||||
// @ApiModelProperty("用户手机")
|
||||
private String mobile;
|
||||
|
||||
public UserEntity()
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package com.ruoyi.web.core.config;
|
||||
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import io.swagger.models.auth.In;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import springfox.documentation.builders.PathSelectors;
|
||||
import springfox.documentation.builders.RequestHandlerSelectors;
|
||||
import springfox.documentation.service.ApiKey;
|
||||
import springfox.documentation.service.AuthorizationScope;
|
||||
import springfox.documentation.service.SecurityReference;
|
||||
import springfox.documentation.service.SecurityScheme;
|
||||
import springfox.documentation.spi.DocumentationType;
|
||||
import springfox.documentation.spi.service.contexts.SecurityContext;
|
||||
import springfox.documentation.spring.web.plugins.Docket;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
/**
|
||||
* 后台接口分组
|
||||
*/
|
||||
// @Bean
|
||||
public Docket adminDocket() {
|
||||
return new Docket(DocumentationType.OAS_30)
|
||||
.select()
|
||||
// 扫描注解
|
||||
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.admin.controller"))
|
||||
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
|
||||
// 扫描所有.apis(RequestHandlerSelectors.any())
|
||||
.paths(PathSelectors.any())
|
||||
.build()
|
||||
.groupName("admin")
|
||||
// 设置安全模式,访问token
|
||||
.securitySchemes(securitySchemes())
|
||||
.securityContexts(securityContexts());
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户业务接口分组
|
||||
*/
|
||||
@Bean
|
||||
public Docket userDocket() {
|
||||
return new Docket(DocumentationType.OAS_30)
|
||||
.select()
|
||||
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.user.controller"))
|
||||
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
|
||||
.paths(PathSelectors.any())
|
||||
.build()
|
||||
.groupName("user")
|
||||
.securitySchemes(securitySchemes())
|
||||
.securityContexts(securityContexts());
|
||||
}
|
||||
|
||||
/**
|
||||
* 第三方业务接口分组
|
||||
*/
|
||||
@Bean
|
||||
public Docket thirdPartyDocket() {
|
||||
return new Docket(DocumentationType.OAS_30)
|
||||
.select()
|
||||
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.thirdparty"))
|
||||
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
|
||||
.paths(PathSelectors.any())
|
||||
.build()
|
||||
.groupName("thirdParty")
|
||||
.securitySchemes(securitySchemes())
|
||||
.securityContexts(securityContexts());
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩法业务接口分组
|
||||
*/
|
||||
@Bean
|
||||
public Docket playingMethodDocket() {
|
||||
return new Docket(DocumentationType.OAS_30)
|
||||
.select()
|
||||
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.playingmethod.controller"))
|
||||
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
|
||||
.paths(PathSelectors.any())
|
||||
.build()
|
||||
.groupName("playingMethod")
|
||||
.securitySchemes(securitySchemes())
|
||||
.securityContexts(securityContexts());
|
||||
}
|
||||
|
||||
/**
|
||||
* 糖果玩法业务接口分组
|
||||
*/
|
||||
@Bean
|
||||
public Docket gameSugarMethodDocket() {
|
||||
return new Docket(DocumentationType.OAS_30)
|
||||
.select()
|
||||
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.game.sugar.controller"))
|
||||
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
|
||||
.paths(PathSelectors.any())
|
||||
.build()
|
||||
.groupName("gameSugarMethod")
|
||||
.securitySchemes(securitySchemes())
|
||||
.securityContexts(securityContexts());
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务业务接口分组
|
||||
*/
|
||||
@Bean
|
||||
public Docket taskDocket() {
|
||||
return new Docket(DocumentationType.OAS_30)
|
||||
.select()
|
||||
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.task.controller"))
|
||||
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
|
||||
.paths(PathSelectors.any())
|
||||
.build()
|
||||
.groupName("task")
|
||||
.securitySchemes(securitySchemes())
|
||||
.securityContexts(securityContexts());
|
||||
}
|
||||
|
||||
/**
|
||||
* 推广后台接口分组
|
||||
*/
|
||||
@Bean
|
||||
public Docket promoDocket() {
|
||||
return new Docket(DocumentationType.OAS_30)
|
||||
.select()
|
||||
.apis(RequestHandlerSelectors.basePackage("com.ruoyi.promo.controller"))
|
||||
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
|
||||
.paths(PathSelectors.any())
|
||||
.build()
|
||||
.groupName("promo")
|
||||
.securitySchemes(securitySchemes())
|
||||
.securityContexts(securityContexts());
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全模式,这里指定token通过Authorization请求头传递
|
||||
*/
|
||||
private List<SecurityScheme> securitySchemes() {
|
||||
List<SecurityScheme> apiKeyList = new ArrayList<SecurityScheme>();
|
||||
apiKeyList.add(new ApiKey("Authorization", "Authorization", In.HEADER.toValue()));
|
||||
return apiKeyList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全上下文
|
||||
*/
|
||||
private List<SecurityContext> securityContexts() {
|
||||
List<SecurityContext> securityContexts = new ArrayList<>();
|
||||
securityContexts.add(SecurityContext.builder()
|
||||
.securityReferences(defaultAuth())
|
||||
.operationSelector(o -> o.requestMappingPattern().matches("/.*"))
|
||||
.build());
|
||||
return securityContexts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的安全引用
|
||||
*/
|
||||
private List<SecurityReference> defaultAuth() {
|
||||
AuthorizationScope authorizationScope = new AuthorizationScope("global", "accessEverything");
|
||||
AuthorizationScope[] authorizationScopes = new AuthorizationScope[1];
|
||||
authorizationScopes[0] = authorizationScope;
|
||||
List<SecurityReference> securityReferences = new ArrayList<>();
|
||||
securityReferences.add(new SecurityReference("Authorization", authorizationScopes));
|
||||
return securityReferences;
|
||||
}
|
||||
}
|
||||
@@ -70,12 +70,12 @@ spring:
|
||||
enabled: true
|
||||
statViewServlet:
|
||||
enabled: true
|
||||
# 设置白名单,不填则允许所有访问
|
||||
allow:
|
||||
# 白名单:只允许本机访问(原来留空 = 任意 IP 都能打开控制台)
|
||||
allow: 127.0.0.1
|
||||
url-pattern: /druid/*
|
||||
# 控制台管理用户名和密码
|
||||
login-username: ruoyi
|
||||
login-password: 123456
|
||||
# 控制台管理用户名和密码(河南单独一套,勿用常州/默认值)
|
||||
login-username: druidhn
|
||||
login-password: Hn@Druid#7t4Wq9
|
||||
filter:
|
||||
stat:
|
||||
enabled: true
|
||||
@@ -202,6 +202,12 @@ alipay:
|
||||
apispace:
|
||||
carriersAuthToken: rcpg373exsddqb8p24nixfo6zgdzw35c
|
||||
carriersAuthUrl: https://eolink.o.apispace.com/carriers/carriers-auth
|
||||
|
||||
# 推广后台配置
|
||||
promo:
|
||||
dev-login:
|
||||
# 开发用账号密码登录(POST /promo/devLogin):dev 环境打开,配合前端 pnpm run dev 的登录页入口
|
||||
enabled: true
|
||||
#
|
||||
#hipay:
|
||||
# payUrl: https://xahnob7yqke3.hipay.one/pay/pay/createpay
|
||||
|
||||
@@ -69,13 +69,15 @@ spring:
|
||||
webStatFilter:
|
||||
enabled: true
|
||||
statViewServlet:
|
||||
enabled: true
|
||||
# 设置白名单,不填则允许所有访问
|
||||
allow:
|
||||
# 生产关闭 Druid 控制台:/druid/** 是匿名放行的,开着等于把全部 SQL 语句、URI 统计、连接信息挂到公网
|
||||
# 需要临时排障时改成 true,并保持 allow 白名单 + 非默认口令,用完立刻关回
|
||||
enabled: false
|
||||
# 白名单:只允许本机访问(原来留空 = 任意 IP)
|
||||
allow: 127.0.0.1
|
||||
url-pattern: /druid/*
|
||||
# 控制台管理用户名和密码
|
||||
login-username: ruoyi
|
||||
login-password: 123456
|
||||
# 控制台管理用户名和密码(河南单独一套,临时开启时使用,勿改回默认值)
|
||||
login-username: druidhn
|
||||
login-password: Hn@Druid#7t4Wq9
|
||||
filter:
|
||||
stat:
|
||||
enabled: true
|
||||
|
||||
@@ -94,6 +94,14 @@ aes:
|
||||
# AES 向量(16位,与前端 AES_IV 保持一致)
|
||||
iv: ruoyi@aes@iv2024
|
||||
|
||||
# 推广后台配置
|
||||
promo:
|
||||
dev-login:
|
||||
# 开发用账号密码登录(POST /promo/devLogin)开关。
|
||||
# 仅本地/测试联调使用:置 true 后可用「账号 + 密码」登录,绕过短信验证码。
|
||||
# 默认 false;生产 profile 不覆盖该项,即天然关闭。
|
||||
enabled: false
|
||||
|
||||
# 防止XSS攻击
|
||||
xss:
|
||||
# 过滤开关
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.ruoyi.framework.config;
|
||||
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.core.domain.entity.UserData;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 三端 token 隔离规则验证(不启动 Spring,直接测 SecurityConfig 里的三个判定方法):
|
||||
* <ul>
|
||||
* <li>后台面 /admin|/system|/monitor|/tool|/test → 只认后台 sys_user 账号签发的 token</li>
|
||||
* <li>推广面 /promo/** → 只认推广后台签发的 token(loginScope = promo)</li>
|
||||
* <li>玩家面 /api/** → 只认玩家端签发的 token(loginScope = player)</li>
|
||||
* </ul>
|
||||
* 关键点:主播(01)/招商(04)/卡商(05) 是同一张 tt_user 表、两端都能登录,LoginUser 长得一模一样,
|
||||
* 只有登录端标记能区分,所以「主播的玩家端 token」不能进推广接口,「主播的推广 token」也不能进玩家接口。
|
||||
*/
|
||||
public class SecurityRulesTest {
|
||||
|
||||
private final SecurityConfig config = new SecurityConfig();
|
||||
|
||||
/** 后台账号登录(sys_user) */
|
||||
private static LoginUser adminLogin() {
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUser(new SysUser());
|
||||
loginUser.setLoginScope(LoginUser.SCOPE_ADMIN);
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
/** 推广后台登录(超管 99 / 主播 01 / 招商 04 / 卡商 05) */
|
||||
private static LoginUser promoLogin(String userType) {
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUserData(UserData.builder().userType(userType).build());
|
||||
loginUser.setLoginScope(LoginUser.SCOPE_PROMO);
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
/** 玩家端登录(通用用户 00 / 主播 01 / 招商 04 / 卡商 05) */
|
||||
private static LoginUser playerLogin(String userType) {
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUserData(UserData.builder().userType(userType).build());
|
||||
loginUser.setLoginScope(LoginUser.SCOPE_PLAYER);
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
/** 本次改造前签发的老 token:没有登录端标记 */
|
||||
private static LoginUser legacyLogin(String userType) {
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUserData(UserData.builder().userType(userType).build());
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
/** 已认证的令牌:与 JwtAuthenticationTokenFilter 一致,用三参构造(两参构造是"未认证"状态) */
|
||||
private static Supplier<Authentication> auth(LoginUser principal) {
|
||||
return principal == null ? () -> null
|
||||
: () -> new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList());
|
||||
}
|
||||
|
||||
/** 未认证的令牌:两参构造,isAuthenticated() 为 false */
|
||||
private static Supplier<Authentication> unauthenticated(LoginUser principal) {
|
||||
return () -> new UsernamePasswordAuthenticationToken(principal, null);
|
||||
}
|
||||
|
||||
private boolean adminAllowed(LoginUser principal) {
|
||||
return config.checkAdminUser(auth(principal), null).isGranted();
|
||||
}
|
||||
|
||||
private boolean promoAllowed(LoginUser principal) {
|
||||
return config.checkPromoUser(auth(principal), null).isGranted();
|
||||
}
|
||||
|
||||
private boolean playerAllowed(LoginUser principal) {
|
||||
return config.checkPlayerUser(auth(principal), null).isGranted();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void adminEndOnlyAcceptsBackendAccount() {
|
||||
assertTrue(adminAllowed(adminLogin()), "后台账号应能访问后台接口");
|
||||
assertFalse(adminAllowed(promoLogin("99")), "推广超管 token 不能访问后台接口");
|
||||
assertFalse(adminAllowed(promoLogin("01")), "推广 token 不能访问后台接口");
|
||||
assertFalse(adminAllowed(playerLogin("00")), "玩家 token 不能访问后台接口");
|
||||
assertFalse(adminAllowed(playerLogin("01")), "主播的玩家端 token 不能访问后台接口");
|
||||
assertFalse(adminAllowed(null), "未登录不能访问后台接口");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promoEndOnlyAcceptsPromoToken() {
|
||||
for (String userType : new String[]{"99", "04", "01", "05"}) {
|
||||
assertTrue(promoAllowed(promoLogin(userType)), "推广后台 token(" + userType + ")应能访问推广接口");
|
||||
}
|
||||
assertFalse(promoAllowed(playerLogin("01")), "主播的玩家端 token 不能访问推广接口");
|
||||
assertFalse(promoAllowed(playerLogin("00")), "玩家 token 不能访问推广接口");
|
||||
assertFalse(promoAllowed(adminLogin()), "后台 token 不能访问推广接口");
|
||||
assertFalse(promoAllowed(legacyLogin("01")), "本次改造前的老 token 没有端标记,必须重新登录");
|
||||
assertFalse(promoAllowed(null), "未登录不能访问推广接口");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void playerEndOnlyAcceptsPlayerToken() {
|
||||
for (String userType : new String[]{"00", "01", "04", "05"}) {
|
||||
assertTrue(playerAllowed(playerLogin(userType)), "玩家端 token(" + userType + ")应能访问 /api/**");
|
||||
}
|
||||
assertFalse(playerAllowed(promoLogin("01")), "主播的推广 token 不能访问 /api/**");
|
||||
assertFalse(playerAllowed(promoLogin("99")), "推广超管 token 不能访问 /api/**");
|
||||
assertFalse(playerAllowed(adminLogin()), "后台 token 不能访问 /api/**");
|
||||
assertFalse(playerAllowed(legacyLogin("00")), "本次改造前的老 token 没有端标记,必须重新登录");
|
||||
assertFalse(playerAllowed(null), "未登录不能访问 /api/**");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unauthenticatedTokenIsDeniedEverywhere() {
|
||||
Supplier<Authentication> notAuthenticated = unauthenticated(adminLogin());
|
||||
assertFalse(config.checkAdminUser(notAuthenticated, null).isGranted());
|
||||
assertFalse(config.checkPromoUser(notAuthenticated, null).isGranted());
|
||||
assertFalse(config.checkPlayerUser(notAuthenticated, null).isGranted());
|
||||
}
|
||||
}
|
||||
@@ -192,13 +192,6 @@
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger3依赖 -->
|
||||
<dependency>
|
||||
<groupId>io.springfox</groupId>
|
||||
<artifactId>springfox-boot-starter</artifactId>
|
||||
<version>${swagger.version}</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -22,6 +22,15 @@ public class LoginUser implements UserDetails
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 登录端:游戏管理后台(sys_user 账号) */
|
||||
public static final String SCOPE_ADMIN = "admin";
|
||||
|
||||
/** 登录端:推广后台(promo_admin 超管 / 主播 01 / 招商 04 / 卡商 05) */
|
||||
public static final String SCOPE_PROMO = "promo";
|
||||
|
||||
/** 登录端:玩家端(H5 / App,api_ 前缀登录) */
|
||||
public static final String SCOPE_PLAYER = "player";
|
||||
|
||||
/**
|
||||
* 用户ID
|
||||
*/
|
||||
@@ -79,6 +88,13 @@ public class LoginUser implements UserDetails
|
||||
|
||||
private UserData userData;
|
||||
|
||||
/**
|
||||
* 登录端标记(admin / promo / player):同一个 tt_user 既能登录玩家端也能登录推广后台,
|
||||
* 两端签发的 token 必须互不通用,鉴权时按这个标记区分(见 SecurityConfig 的三条规则)。
|
||||
* 签发时在 ApiUserDetailsServiceImpl 按账号前缀写入,随会话缓存在 Redis 里。
|
||||
*/
|
||||
private String loginScope;
|
||||
|
||||
public LoginUser(Long userId, UserData userData) {
|
||||
this.userId = userId;
|
||||
this.userData = userData;
|
||||
|
||||
@@ -30,7 +30,9 @@ public class MimeTypeUtils
|
||||
// 图片
|
||||
"bmp", "gif", "jpg", "jpeg", "png",
|
||||
// word excel powerpoint
|
||||
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt",
|
||||
// 2026-09-23 去掉 html/htm:上传目录 /profile/** 是匿名可访问的静态目录,
|
||||
// 放行 html/htm 等于允许任意登录用户往自己域名下丢页面(.html 被当静态资源打开就是 XSS/钓鱼面)
|
||||
"doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt",
|
||||
// 压缩文件
|
||||
"rar", "zip", "gz", "bz2",
|
||||
// 视频格式
|
||||
|
||||
@@ -5,14 +5,12 @@ import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.TimeZone;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
@@ -54,11 +52,6 @@ public class ResourcesConfig implements WebMvcConfigurer
|
||||
|
||||
registry.addResourceHandler("/upload/**")
|
||||
.addResourceLocations("file:" + RuoYiConfig.getProfile() + "/upload/");
|
||||
|
||||
/** swagger配置 */
|
||||
registry.addResourceHandler("/swagger-ui/**")
|
||||
.addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/")
|
||||
.setCacheControl(CacheControl.maxAge(5, TimeUnit.HOURS).cachePublic());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ import java.util.function.Supplier;
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
|
||||
public class SecurityConfig {
|
||||
|
||||
@Qualifier("apiUserDetailsServiceImpl")
|
||||
@Autowired
|
||||
private UserDetailsService apiUserDetailsService;
|
||||
@@ -101,13 +102,21 @@ public class SecurityConfig {
|
||||
// 静态资源
|
||||
.requestMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html",
|
||||
"/**/*.css", "/**/*.js", "/profile/**", "/uploads/**", "/upload/**").permitAll()
|
||||
// Swagger & Druid
|
||||
.requestMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
|
||||
// Druid 监控台:生产已在 application-pro.yml 关闭 statViewServlet(enabled=false)
|
||||
// 并限制 allow=127.0.0.1 + 非默认口令;这里保留放行是为了本地开发能打开控制台
|
||||
.requestMatchers("/druid/**").permitAll()
|
||||
// WebSocket
|
||||
.requestMatchers("/api/websocket/skins").permitAll()
|
||||
.requestMatchers("/ws/**").permitAll()
|
||||
// 后台接口:只认「后台 sys_user 账号」的 token(详见 checkAdminUser)
|
||||
.requestMatchers("/admin/**").access(this::checkAdminUser)
|
||||
// 后台接口(游戏管理后台 + 框架自带系统管理/监控/代码生成/测试接口):
|
||||
// 只认「后台 sys_user 账号」的 token,推广 / 玩家 token 一律 403(详见 checkAdminUser)
|
||||
// /common/** 是文件上传下载(只有后台 ruoyi-ui 在用),不放进三端前缀里会对所有端开放
|
||||
.requestMatchers("/admin/**", "/system/**", "/monitor/**", "/tool/**", "/test/**", "/common/**")
|
||||
.access(this::checkAdminUser)
|
||||
// 推广接口:只认推广身份的 token,玩家(00)与后台账号一律 403(详见 checkPromoUser)
|
||||
.requestMatchers("/promo/**").access(this::checkPromoUser)
|
||||
// 玩家接口:只认玩家端身份的 token,推广超管与后台账号一律 403(详见 checkPlayerUser)
|
||||
.requestMatchers("/api/**").access(this::checkPlayerUser)
|
||||
// 其他所有请求都需要认证
|
||||
.anyRequest().authenticated();
|
||||
})
|
||||
@@ -138,8 +147,8 @@ public class SecurityConfig {
|
||||
* → LoginUser.userData 是 UserData,LoginUser.user 为 null → 拒绝(403)。
|
||||
* 这样即使某个后台 Controller 忘了标 @PreAuthorize,也不会被玩家 token 调用。
|
||||
*/
|
||||
private AuthorizationDecision checkAdminUser(Supplier<? extends Authentication> authentication,
|
||||
RequestAuthorizationContext context) {
|
||||
AuthorizationDecision checkAdminUser(Supplier<? extends Authentication> authentication,
|
||||
RequestAuthorizationContext context) {
|
||||
Authentication auth = authentication.get();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return new AuthorizationDecision(false);
|
||||
@@ -150,6 +159,48 @@ public class SecurityConfig {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* /promo/** 的访问判定:只允许「推广后台签发」的 token(loginScope = promo)。
|
||||
*
|
||||
* 同一个 tt_user(主播 01 / 招商 04 / 卡商 05)既能登录玩家端也能登录推广后台,
|
||||
* 两端拿到的 LoginUser(userId + userData)完全一样,光看 userType 区分不了,
|
||||
* 所以这里必须按登录端标记卡:玩家端签发的 token 即使账号是主播,也进不来推广接口。
|
||||
*/
|
||||
AuthorizationDecision checkPromoUser(Supplier<? extends Authentication> authentication,
|
||||
RequestAuthorizationContext context) {
|
||||
LoginUser loginUser = loginUserOf(authentication);
|
||||
if (loginUser == null || loginUser.getUserData() == null) {
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
return new AuthorizationDecision(LoginUser.SCOPE_PROMO.equals(loginUser.getLoginScope()));
|
||||
}
|
||||
|
||||
/**
|
||||
* /api/** 的访问判定:只允许「玩家端签发」的 token(loginScope = player)。
|
||||
*
|
||||
* 推广后台 token(哪怕底层账号就是 tt_user)一律拒绝;后台账号(sys_user)没有 userData,也进不来。
|
||||
*/
|
||||
AuthorizationDecision checkPlayerUser(Supplier<? extends Authentication> authentication,
|
||||
RequestAuthorizationContext context) {
|
||||
LoginUser loginUser = loginUserOf(authentication);
|
||||
if (loginUser == null || loginUser.getUserData() == null) {
|
||||
return new AuthorizationDecision(false);
|
||||
}
|
||||
return new AuthorizationDecision(LoginUser.SCOPE_PLAYER.equals(loginUser.getLoginScope()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出已认证的 LoginUser,其余情况(匿名 / 未认证 / 非本框架主体)返回 null
|
||||
*/
|
||||
private LoginUser loginUserOf(Supplier<? extends Authentication> authentication) {
|
||||
Authentication auth = authentication.get();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
return auth.getPrincipal() instanceof LoginUser loginUser ? loginUser : null;
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
public BCryptPasswordEncoder bCryptPasswordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
|
||||
+3
-1
@@ -61,6 +61,8 @@ public class UserDetailsServiceImpl implements UserDetailsService
|
||||
|
||||
public UserDetails createLoginUser(SysUser user)
|
||||
{
|
||||
return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
|
||||
LoginUser loginUser = new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
|
||||
loginUser.setLoginScope(LoginUser.SCOPE_ADMIN);
|
||||
return loginUser;
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -45,6 +45,11 @@ public class WsTokenService {
|
||||
if (loginUser == null) {
|
||||
return null;
|
||||
}
|
||||
// 只认玩家端 token:推广/后台 token 即使 userId 撞上某个 tt_user 的 id 也不能连玩家 WS
|
||||
// (与 SecurityConfig.checkPlayerUser 口径一致;本次改造前签发的老 token 无端标记,同样要重新登录)
|
||||
if (!LoginUser.SCOPE_PLAYER.equals(loginUser.getLoginScope())) {
|
||||
return null;
|
||||
}
|
||||
return loginUser.getUserId().equals(expectedUserId) ? uuid : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("WS Token 校验异常, userId={}", expectedUserId, e);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 页面标题
|
||||
VUE_APP_TITLE = 后台管理系统
|
||||
VUE_APP_TITLE = 凯科佰娱
|
||||
|
||||
# 开发环境配置
|
||||
ENV = 'development'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 页面标题
|
||||
VUE_APP_TITLE = 常州项目
|
||||
VUE_APP_TITLE = 凯科佰娱
|
||||
|
||||
# 生产环境配置
|
||||
ENV = 'production'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 页面标题
|
||||
VUE_APP_TITLE = 后台管理系统
|
||||
VUE_APP_TITLE = 凯科佰娱
|
||||
|
||||
NODE_ENV = production
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 箱子列表(分页;boxType 只有一个 normal,留参数只为兼容后台筛选框):MahjongAdminController.boxList
|
||||
// 返回 { rows:[{...各身份 targetRtpByAudience/actualRtpByAudience/totalRoundsByAudience}], total }
|
||||
export function getBoxList(params) {
|
||||
return request({ url: '/admin/mahjong/box/list', method: 'get', params })
|
||||
}
|
||||
|
||||
// 新增/编辑箱子:MahjongAdminController.boxSave(price = bet,本游戏无投注倍数、costMultiplier ≡ 1)
|
||||
export function saveBox(data) {
|
||||
return request({ url: '/admin/mahjong/box/save', method: 'post', data })
|
||||
}
|
||||
|
||||
// 某箱某身份的倍率桶分布:MahjongAdminController.boxBuckets
|
||||
// 返回 { list:[{bucketId,count,used,remaining,convergeCount,...}], audience, fixedRounds, convergeRounds, poolExpect }
|
||||
export function getBoxBuckets(boxId, audience) {
|
||||
return request({ url: `/admin/mahjong/box/buckets/${boxId}`, method: 'get', params: { audience }})
|
||||
}
|
||||
|
||||
// 保存某箱某身份倍率桶分布(items 为 [{bucketId,count}]):MahjongAdminController.boxBucketsSave
|
||||
export function saveBoxBuckets(data) {
|
||||
return request({ url: '/admin/mahjong/box/buckets/save', method: 'post', data })
|
||||
}
|
||||
|
||||
// 按目标 RTP 重算某箱某身份分布:MahjongAdminController.calcDistribution
|
||||
export function calcDistribution(data) {
|
||||
return request({ url: '/admin/mahjong/box/buckets/calc', method: 'post', data })
|
||||
}
|
||||
|
||||
// 模拟 payoutCap 约束下的预估实际 RTP:MahjongAdminController.simulateRtp
|
||||
export function simulateRtp(data) {
|
||||
return request({ url: '/admin/mahjong/box/buckets/simulate-rtp', method: 'post', data })
|
||||
}
|
||||
|
||||
// ==================== 分布模板(按身份) ====================
|
||||
|
||||
// 模板列表:MahjongAdminController.templateList
|
||||
export function getBoxTemplates(boxId, audience) {
|
||||
return request({ url: `/admin/mahjong/box/templates/${boxId}`, method: 'get', params: { audience }})
|
||||
}
|
||||
// 保存为模板:MahjongAdminController.templateSave
|
||||
export function saveBoxTemplate(data) {
|
||||
return request({ url: '/admin/mahjong/box/template/save', method: 'post', data })
|
||||
}
|
||||
// 删除模板:MahjongAdminController.templateDelete
|
||||
export function deleteBoxTemplate(id) {
|
||||
return request({ url: `/admin/mahjong/box/template/${id}`, method: 'delete' })
|
||||
}
|
||||
// 应用选中模板:MahjongAdminController.templateApply
|
||||
export function applyBoxTemplate(data) {
|
||||
return request({ url: '/admin/mahjong/box/template/apply', method: 'post', data })
|
||||
}
|
||||
|
||||
// 一键重算所有箱子 × 所有身份(按各箱现有配置):MahjongAdminController.recalcAll
|
||||
export function recalcAll() {
|
||||
return request({ url: '/admin/mahjong/box/recalc_all', method: 'post' })
|
||||
}
|
||||
|
||||
// 重算分布:按参数批量重算(boxType 空=全部;audience 空=全部身份):MahjongAdminController.recalcBatch
|
||||
export function recalcBatch(data) {
|
||||
return request({ url: '/admin/mahjong/box/recalcBatch', method: 'post', data })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 倍率桶定义列表(16 档 0x~500x):MahjongAdminController.bucketList
|
||||
export function getBucketList() {
|
||||
return request({ url: '/admin/mahjong/bucket/list', method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 每日统计列表(分页 + 按日期区间):MahjongAdminController.winList
|
||||
export function getWinList(params) {
|
||||
return request({ url: '/admin/mahjong/win/list', method: 'get', params })
|
||||
}
|
||||
|
||||
// 汇总(按日期区间):MahjongAdminController.winSummary
|
||||
export function getWinSummary(params) {
|
||||
return request({ url: '/admin/mahjong/win/summary', method: 'get', params })
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 返奖饰品列表(含返奖区间 winLow/winHigh、品质图 levelImg):MahjongAdminController.prizeList
|
||||
export function prizeList() {
|
||||
return request({ url: '/admin/mahjong/prize/list', method: 'get' })
|
||||
}
|
||||
|
||||
// 新增/编辑返奖饰品(同步写 game_mahjong_prize_range):MahjongAdminController.prizeSave
|
||||
export function prizeSave(data) {
|
||||
return request({ url: '/admin/mahjong/prize/save', method: 'post', data })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 玩家记录列表(分页 + 按玩家ID/身份筛选;audience=common/anchor/white,空=全部身份)
|
||||
// MahjongAdminController.recordList(行内回填 audience)
|
||||
export function getRecordList(params) {
|
||||
return request({ url: '/admin/mahjong/record/list', method: 'get', params })
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 奖池配置快照(总池 + 各箱池),按身份 audience=common/anchor/white
|
||||
// MahjongAdminController.rewardPoolConfig
|
||||
export function getPoolConfig(params) {
|
||||
return request({ url: '/admin/mahjong/rewardPool/config', method: 'get', params })
|
||||
}
|
||||
|
||||
// 逐箱注水/抽水(type=1 注水 / 2 抽水,含 audience、remark):MahjongAdminController.rewardPoolAdjust
|
||||
export function adjustPool(data) {
|
||||
return request({ url: '/admin/mahjong/rewardPool/adjust', method: 'post', data })
|
||||
}
|
||||
|
||||
// 批量按一轮花费比例注水/抽水:{ audience, type, ratio, remark }
|
||||
// 本游戏无投注倍数,每箱基数 = bet × 总回合数;MahjongAdminController.rewardPoolAdjustBatch
|
||||
export function adjustPoolBatch(data) {
|
||||
return request({ url: '/admin/mahjong/rewardPool/adjustBatch', method: 'post', data })
|
||||
}
|
||||
|
||||
// 重置人工调整:该身份所有箱子注水/抽水累计归零,只保留真实水位(下注−返奖):{ audience, remark }
|
||||
// MahjongAdminController.rewardPoolAdjustReset
|
||||
export function adjustPoolReset(data) {
|
||||
return request({ url: '/admin/mahjong/rewardPool/adjustReset', method: 'post', data })
|
||||
}
|
||||
|
||||
// 奖池调整历史(分页,params 含 audience):MahjongAdminController.rewardPoolAdjustLog
|
||||
export function getPoolLogs(params) {
|
||||
return request({ url: '/admin/mahjong/rewardPool/adjustLog', method: 'get', params })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 预生成回合列表(分页 + 按回合ID/箱子筛选):MahjongAdminController.roundList
|
||||
export function getRoundList(params) {
|
||||
return request({ url: '/admin/mahjong/round/list', method: 'get', params })
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 当前水位快照 + 波段表策略 + 本轮生效的随机数区间
|
||||
export function getWatermarkInfo() {
|
||||
return request({
|
||||
url: '/admin/upgradeWatermark/info',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 保存水位波段表策略(后端带校验:区间合法 + 水位越高随机数越小)
|
||||
export function saveWatermarkSetting(data) {
|
||||
return request({
|
||||
url: '/admin/upgradeWatermark/setting',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 注水 / 抽水(type 1注水 2抽水;调整原因必填)
|
||||
export function adjustWatermark(data) {
|
||||
return request({
|
||||
url: '/admin/upgradeWatermark/adjust',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 重置人工调整(插入抵消日志,SUM(delta) 归零,历史保留)
|
||||
export function resetWatermarkAdjust(data) {
|
||||
return request({
|
||||
url: '/admin/upgradeWatermark/adjustReset',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 调整历史(分页)
|
||||
export function getWatermarkLogs(params) {
|
||||
return request({
|
||||
url: '/admin/upgradeWatermark/adjustLog',
|
||||
method: 'get',
|
||||
params: params
|
||||
})
|
||||
}
|
||||
|
||||
// 按历史记录全量重算累计下注/返奖(纠正 Redis 计数漂移)
|
||||
export function recalcWatermark() {
|
||||
return request({
|
||||
url: '/admin/upgradeWatermark/recalc',
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,31 @@ $cc-shadow-sm: 0 2px 8px rgba(139, 115, 85, 0.10);
|
||||
$cc-shadow-md: 0 6px 18px rgba(139, 115, 85, 0.13);
|
||||
$cc-shadow-lg: 0 12px 32px rgba(139, 115, 85, 0.17);
|
||||
|
||||
/* ---- 组件变量槽位(Element 需要的多级描边 / 浅底)----
|
||||
这些是「中间色」,不承担品牌语义,但必须收在这里:
|
||||
否则同一层底色会在不同文件里写成两个近似值
|
||||
(如 #f6efe3 与 $cc-linen #f3ece0 肉眼几乎分不出,却算成了两套色)。 */
|
||||
$cc-border-1: #d8c3b0; // 主描边:输入框、表格线
|
||||
$cc-border-2: #e2d3c2; // 次描边:轻分隔、禁用描边
|
||||
$cc-border-3: #eadfd0; // 细描边(比 $cc-linen-2 更接近白)
|
||||
$cc-tint-1: #f0e8dc; // 最浅底:凹槽
|
||||
$cc-tint-2: #f6efe3; // 浅底:标签 / 区块
|
||||
$cc-tint-3: #fbf5ea; // 悬停底:整行 hover、标签 hover
|
||||
$cc-terra-deep: #854c2a; // 陶土加深:主色悬停、激活标签渐变末端(白字 6.85:1)
|
||||
|
||||
/* ---- 图标色相:一级菜单图标块 / 统计卡图标共用同一套 4 色 ----
|
||||
陶土、草绿直接复用主色;雏菊金 / 玫瑰粉是「图标专用」的浅档
|
||||
($cc-daisy-deep 太暗、$cc-rose 太艳,塞进 30px 小方块会发黑/发跳),
|
||||
只在图标上用,不再往别处扩散。
|
||||
★ 这两档同时被 .vue 里的内联色引用(scoped style 拿不到 sass 变量),
|
||||
改动时必须同步 .workbuddy/audit-palette.cjs 的白名单。 */
|
||||
$cc-hue-daisy: #a8791f;
|
||||
$cc-hue-rose: #b57474;
|
||||
$cc-icon-hues: ($cc-terra, $cc-grass-deep, $cc-hue-daisy, $cc-hue-rose);
|
||||
|
||||
/* ---- 状态淡底 ---- */
|
||||
$cc-blossom-tint: #f7e9e6; // 花粉淡底:中奖格浅红(别在 .vue 里再写一个近似粉)
|
||||
|
||||
/* ---- 字体 ----
|
||||
Cottagecore 要求衬线,但后台是拿表格干活的:13px 宋体在密集表格里可读性明显下降。
|
||||
因此只在「标题类」(≥ 16px 的页面标题 / 弹窗标题)用衬线,
|
||||
|
||||
@@ -28,11 +28,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-width {
|
||||
.el-button--mini {
|
||||
padding: 7px 10px;
|
||||
width: 60px;
|
||||
}
|
||||
/* ★ 这里原本是 RuoYi 的遗留规则,已删除:
|
||||
.fixed-width .el-button--mini { padding: 7px 10px; width: 60px }
|
||||
.fixed-width 是「操作列」专用类。强设 60px 后,border-box 的内容盒只剩 48px,
|
||||
而本项目的操作按钮普遍是「图标 + 2~4 字」(如 el-icon-date +「发放饰品」≈ 70px)
|
||||
⇒ 文字溢出按钮、盖到相邻按钮上,整列还会折行。
|
||||
按钮宽度改回按内容撑开;文字按钮的左右留白由 skin.scss 的
|
||||
`.el-table .el-button--text { padding: 4px 6px }` 接管。
|
||||
★ 操作列若按钮 ≥3 个,请在模板上显式给 `width`,否则自动列宽会挤压换行。 */
|
||||
|
||||
/* 「更多」是 el-dropdown 包着 el-button,不是 .el-button 的兄弟节点,
|
||||
拿不到 Element 的 `.el-button + .el-button { margin-left: 10px }`,会贴住前一个按钮 */
|
||||
.el-table .cell .el-button + .el-dropdown {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.status-col {
|
||||
|
||||
@@ -28,16 +28,29 @@ $--color-text-secondary: $cc-ink-3;
|
||||
$--color-text-placeholder: $cc-ink-4;
|
||||
|
||||
/* 描边:花粉细边,禁止纯黑硬线 */
|
||||
$--border-color-base: #d8c3b0;
|
||||
$--border-color-light: #e2d3c2;
|
||||
$--border-color-lighter: #eadfd0;
|
||||
$--border-color-extra-light: #f0e8dc;
|
||||
$--border-color-base: $cc-border-1;
|
||||
$--border-color-light: $cc-border-2;
|
||||
$--border-color-lighter: $cc-border-3;
|
||||
$--border-color-extra-light: $cc-tint-1;
|
||||
|
||||
/* 底色 */
|
||||
/* ★ 白:Cottagecore 的「白」是暖白卡片色 #fffdfa,不是 #fff。
|
||||
Element 把 $--color-white 当成**所有白色面的根**——输入框、下拉面板、
|
||||
气泡、对话框、分页底、单选/复选底,以及各 light 变体的 mix 基色。
|
||||
改这一处,比逐个选择器打补丁稳,也不会漏。 */
|
||||
$--color-white: $cc-cream-hi;
|
||||
|
||||
$--background-color-base: $cc-linen;
|
||||
$--disabled-fill-base: $cc-linen;
|
||||
$--disabled-color-base: $cc-ink-4;
|
||||
$--disabled-border-base: #e2d3c2;
|
||||
$--disabled-border-base: $cc-border-2;
|
||||
|
||||
/* Element 写死的两处冷蓝(是 !default 变量,可以覆盖)——
|
||||
① 默认/信息类 toast 的底:原 #edf2fc 淡蓝,RuoYi 里 this.$message('…')
|
||||
不带 type 时就会走它,是页面上会闪的蓝块;
|
||||
② 禁用勾选框填充:原 #edf2fc 同样偏蓝。 */
|
||||
$--message-background-color: mix($cc-cream, $cc-ash, 92%);
|
||||
$--checkbox-disabled-input-fill: $cc-linen-2;
|
||||
|
||||
/* 圆润:Cottagecore 的圆角一律大于 Element 默认的 4px */
|
||||
$--border-radius-base: 12px;
|
||||
@@ -50,11 +63,11 @@ $--box-shadow-dark: $cc-shadow-lg;
|
||||
$--box-shadow-light: $cc-shadow-sm;
|
||||
|
||||
/* 表格:柔和暖线,禁用 #dfe6ec 冷灰 */
|
||||
$--table-border: 1px solid #eadfd0;
|
||||
$--table-border-color: #eadfd0;
|
||||
$--table-border: 1px solid $cc-border-3;
|
||||
$--table-border-color: $cc-border-3;
|
||||
$--table-header-background-color: $cc-linen;
|
||||
$--table-header-font-color: $cc-ink-3;
|
||||
$--table-row-hover-background-color: #fbf5ea;
|
||||
$--table-row-hover-background-color: $cc-tint-3;
|
||||
$--table-current-row-background-color: $cc-linen;
|
||||
$--table-font-color: $cc-ink-2;
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ aside {
|
||||
// 否则「页卡 > 区块卡 > 小卡」三层卡片套在一起,画面很碎(规范也禁止卡片嵌套)。
|
||||
.app-container {
|
||||
padding: 24px;
|
||||
background: #fdfaf5;
|
||||
background: $cc-cream-hi;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 10px rgba(139, 115, 85, 0.07);
|
||||
@@ -145,7 +145,7 @@ aside {
|
||||
position: relative;
|
||||
margin: 6px 10px 6px 0;
|
||||
padding: 10px 24px 24px;
|
||||
background: #fdfaf5;
|
||||
background: $cc-cream-hi;
|
||||
border: 0;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 10px rgba(139, 115, 85, 0.07);
|
||||
@@ -255,3 +255,5 @@ aside {
|
||||
|
||||
// 皮肤层放最后,保证覆盖 Element UI 默认样式
|
||||
@import './skin.scss';
|
||||
// 开关(状态列)已改为自研组件 components/CapsuleSwitch(自带 scoped 样式),
|
||||
// 不再需要历史上那种全局 ::v-deep 覆盖 el-switch 内部结构的写法。
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
}
|
||||
|
||||
@mixin scrollBar {
|
||||
/* 原为 Element 冷蓝灰 #d3dce6 / #99a9bf。当前项目未 include 本 mixin
|
||||
(侧栏走 .el-scrollbar__thumb),顺手改成暖色,避免日后启用时又漏一版冷色。 */
|
||||
&::-webkit-scrollbar-track-piece {
|
||||
background: #d3dce6;
|
||||
background: #ece2d3;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
@@ -16,7 +18,7 @@
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #99a9bf;
|
||||
background: #8b755a;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 通用css样式布局处理
|
||||
* Copyright (c) 2019 ruoyi
|
||||
*
|
||||
* ⚠️ 本文件由 main.js 在 index.scss(含皮肤层 skin.scss)**之后**引入,
|
||||
* 权重相同时它会赢。改这里的任何视觉规则前,先确认不是在和皮肤层打架。
|
||||
*/
|
||||
|
||||
@import './cottagecore.scss';
|
||||
|
||||
/** 基础通用 **/
|
||||
.pt5 {
|
||||
padding-top: 5px;
|
||||
@@ -79,9 +84,12 @@
|
||||
.el-table__header-wrapper, .el-table__fixed-header-wrapper {
|
||||
th {
|
||||
word-break: break-word;
|
||||
background-color: #f8f8f9;
|
||||
color: #515a6e;
|
||||
height: 40px;
|
||||
/* 原为冷灰 #f8f8f9 / 石板蓝 #515a6e —— 权重低于皮肤层的
|
||||
.el-table th.el-table__cell,本来就没生效;这里直接对齐皮肤,
|
||||
免得将来权重一变又漏出冷灰表头。 */
|
||||
background-color: $cc-linen;
|
||||
color: $cc-ink-3;
|
||||
height: 42px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -93,35 +101,32 @@
|
||||
}
|
||||
|
||||
/** 表单布局 **/
|
||||
/* 冷蓝 #6379bb + 冷灰 #ddd 的分区标题(角色分配 / 代码生成两页在用)→ 暖色板 */
|
||||
.form-header {
|
||||
font-size:15px;
|
||||
color:#6379bb;
|
||||
border-bottom:1px solid #ddd;
|
||||
color:$cc-terra;
|
||||
border-bottom:1px solid $cc-line-2;
|
||||
margin:8px 10px 25px 10px;
|
||||
padding-bottom:5px
|
||||
}
|
||||
|
||||
/** 表格布局 **/
|
||||
.pagination-container {
|
||||
position: relative;
|
||||
height: 25px;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 15px;
|
||||
padding: 10px 20px !important;
|
||||
}
|
||||
/* .pagination-container 的旧定义(position:relative / height:25px / margin 15px 10px /
|
||||
padding:10px 20px !important)已删除:
|
||||
它是 AdminLTE 时代的紧凑分页,和皮肤层打架,而且那条 !important 让皮肤层的
|
||||
padding 永远失效;height:25px 又把 32px 高的页码按钮挤出去,看起来就是表格下方
|
||||
多了一条空白白带。分页样式现在只在 assets/styles/skin.scss 里定义一处。 */
|
||||
|
||||
/* tree border */
|
||||
.tree-border {
|
||||
margin-top: 5px;
|
||||
border: 1px solid #e5e6e7;
|
||||
background: #FFFFFF none;
|
||||
border-radius:4px;
|
||||
border: 1px solid $cc-line;
|
||||
background: $cc-cream-hi none;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pagination-container .el-pagination {
|
||||
right: 0;
|
||||
position: absolute;
|
||||
}
|
||||
/* 原来的 .pagination-container .el-pagination { right:0; position:absolute } 已删除。
|
||||
绝对定位让分页脱离文档流(外层白带高度算不准),右对齐改由皮肤层 text-align 负责。 */
|
||||
|
||||
@media ( max-width : 768px) {
|
||||
.pagination-container .el-pagination > .el-pagination__jump {
|
||||
@@ -132,11 +137,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.el-table .fixed-width .el-button--mini {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
width: inherit;
|
||||
}
|
||||
/* 操作列的 .el-button--mini 在 old 版里被抹掉左右内边距(padding:0 + width:inherit),
|
||||
文字按钮会挤成「修改删除」连在一起,和皮肤层 .el-table .el-button--text 的 4px 6px 打架。
|
||||
删掉这条,按钮间距交回皮肤层。 */
|
||||
|
||||
/** 表格更多操作下拉样式 */
|
||||
.el-table .el-dropdown-link,.el-table .el-dropdown-selfdefine {
|
||||
@@ -166,8 +169,8 @@
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
border-bottom: 1px solid #e7eaec;
|
||||
border-top: 1px solid #e7eaec;
|
||||
border-bottom: 1px solid $cc-line-2;
|
||||
border-top: 1px solid $cc-line-2;
|
||||
margin-bottom: -1px;
|
||||
padding: 11px 0px;
|
||||
font-size: 13px;
|
||||
@@ -192,30 +195,14 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* button color */
|
||||
.el-button--cyan.is-active,
|
||||
.el-button--cyan:active {
|
||||
background: #20B2AA;
|
||||
border-color: #20B2AA;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.el-button--cyan:focus,
|
||||
.el-button--cyan:hover {
|
||||
background: #48D1CC;
|
||||
border-color: #48D1CC;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.el-button--cyan {
|
||||
background-color: #20B2AA;
|
||||
border-color: #20B2AA;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
/* 说明:这里原本是 AdminLTE / vue-element-admin 遗留的青色按钮与文字色
|
||||
(#20B2AA / #48D1CC / #1ab394 / #1c84c6 / #23c6c8 / #f8ac59 / #ed5565 / #888888),
|
||||
全是冷色,和 Cottagecore 色板冲突。已按语义映射到色板;
|
||||
.el-button--cyan 全项目 0 处使用,直接删除。 */
|
||||
|
||||
/* text color */
|
||||
.text-navy {
|
||||
color: #1ab394;
|
||||
color: $cc-grass-text;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
@@ -223,23 +210,23 @@
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: #1c84c6;
|
||||
color: $cc-grass-text;
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: #23c6c8;
|
||||
color: $cc-ash;
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: #f8ac59;
|
||||
color: $cc-daisy-deep;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: #ed5565;
|
||||
color: $cc-rose;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #888888;
|
||||
color: $cc-ink-3;
|
||||
}
|
||||
|
||||
/* image */
|
||||
@@ -260,15 +247,16 @@
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 4px #ccc;
|
||||
box-shadow: $cc-shadow-md;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 拖拽列样式 */
|
||||
/* 拖拽列样式(RuoYi 表单构建器 / 表格拖拽排序共用)
|
||||
原来是 vue-element-admin 的 #42b983(Vue 青绿),和暖色板冲突 → 用草绿深档 */
|
||||
.sortable-ghost{
|
||||
opacity: .8;
|
||||
color: #fff!important;
|
||||
background: #42b983!important;
|
||||
background: $cc-grass-deep!important;
|
||||
}
|
||||
|
||||
.top-right-btn {
|
||||
|
||||
@@ -33,12 +33,8 @@ $card-shadow: $cc-shadow-sm;
|
||||
|
||||
/* 一级菜单图标色板:只留 4 个田园色循环(陶土 / 草绿 / 雏菊金 / 玫瑰粉)
|
||||
原来是 8 色高饱和渐变,15 个菜单项齐上像调色盘;
|
||||
现在与仪表盘统计卡共用同一套 4 色,整站配色口径统一。 */
|
||||
$icon-hues:
|
||||
#9a5c35, // 1 陶土
|
||||
#457450, // 2 草绿
|
||||
#a8791f, // 3 雏菊金
|
||||
#b57474; // 4 玫瑰粉
|
||||
现在与统计卡图标共用 cottagecore.scss 的 $cc-icon-hues,整站配色口径统一。 */
|
||||
$icon-hues: $cc-icon-hues;
|
||||
$icon-hue-count: length($icon-hues);
|
||||
|
||||
html,
|
||||
@@ -51,10 +47,10 @@ body {
|
||||
/* 亚麻底整体加深一档(原来是 #fdfaf5→#f3ece0,顶部近白,
|
||||
和奶油卡片几乎同色,卡片边界会糊掉)。现在卡片是亮面、页面是麻面,层次清楚。 */
|
||||
background:
|
||||
radial-gradient(760px 520px at 4% 100%, rgba(90, 143, 90, .11), transparent 62%),
|
||||
radial-gradient(760px 520px at 96% 0%, rgba(212, 160, 160, .13), transparent 62%),
|
||||
radial-gradient(520px 420px at 78% 88%, rgba(245, 215, 95, .14), transparent 60%),
|
||||
linear-gradient(155deg, #f7f1e6 0%, #f3ece0 48%, #ece2d3 100%);
|
||||
radial-gradient(760px 520px at 4% 100%, rgba($cc-grass, .11), transparent 62%),
|
||||
radial-gradient(760px 520px at 96% 0%, rgba($cc-blossom, .13), transparent 62%),
|
||||
radial-gradient(520px 420px at 78% 88%, rgba($cc-daisy, .14), transparent 60%),
|
||||
linear-gradient(155deg, $cc-tint-2 0%, $cc-linen 48%, $cc-linen-2 100%);
|
||||
background-attachment: fixed;
|
||||
|
||||
.main-container {
|
||||
@@ -112,7 +108,7 @@ body {
|
||||
}
|
||||
|
||||
.el-scrollbar__thumb {
|
||||
background: rgba(139, 115, 85, .26);
|
||||
background: rgba($cc-earth, .26);
|
||||
}
|
||||
|
||||
.is-horizontal {
|
||||
@@ -187,7 +183,7 @@ body {
|
||||
.el-menu:not(.el-menu--inline) > div > .el-submenu.is-active > .el-submenu__title {
|
||||
color: $cc-grass-text !important;
|
||||
border-color: $cc-blossom;
|
||||
background: linear-gradient(135deg, $cc-cream-hi, rgba(245, 215, 95, .18)) !important;
|
||||
background: linear-gradient(135deg, $cc-cream-hi, rgba($cc-daisy, .18)) !important;
|
||||
box-shadow: $cc-shadow-md, inset 3px 0 0 $brand;
|
||||
}
|
||||
|
||||
@@ -195,7 +191,7 @@ body {
|
||||
.el-menu:not(.el-menu--inline) > div > a > .el-menu-item.is-active {
|
||||
color: $brand !important;
|
||||
border-color: $cc-blossom;
|
||||
background: linear-gradient(135deg, $cc-cream-hi, rgba(212, 160, 160, .22)) !important;
|
||||
background: linear-gradient(135deg, $cc-cream-hi, rgba($cc-blossom, .22)) !important;
|
||||
box-shadow: $cc-shadow-md, inset 3px 0 0 $brand;
|
||||
}
|
||||
|
||||
@@ -225,10 +221,10 @@ body {
|
||||
border-radius: 11px;
|
||||
/* 浅底 + 同色深图标。底色不能太淡,否则小图标「看不见」(用户反馈对比度不够),
|
||||
所以 tint 用 .22 并加 1px 内描边。 */
|
||||
color: #9a5c35 !important;
|
||||
color: $cc-terra !important;
|
||||
fill: currentColor;
|
||||
background: rgba(154, 92, 53, .22);
|
||||
box-shadow: inset 0 0 0 1px rgba(154, 92, 53, .32);
|
||||
background: rgba($cc-terra, .22);
|
||||
box-shadow: inset 0 0 0 1px rgba($cc-terra, .32);
|
||||
}
|
||||
|
||||
/* 按菜单顺序在这 4 个色里轮换 */
|
||||
@@ -267,7 +263,7 @@ body {
|
||||
bottom: 7px;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(180deg, rgba(154, 92, 53, .38), rgba(212, 160, 160, .22));
|
||||
background: linear-gradient(180deg, rgba($cc-terra, .38), rgba($cc-blossom, .22));
|
||||
}
|
||||
|
||||
.el-menu--inline .el-menu-item,
|
||||
@@ -321,19 +317,19 @@ body {
|
||||
.el-menu--inline .el-menu-item:hover,
|
||||
.el-menu--inline .el-submenu > .el-submenu__title:hover {
|
||||
color: $brand !important;
|
||||
background: rgba(245, 215, 95, .20) !important;
|
||||
background: rgba($cc-daisy, .20) !important;
|
||||
transform: none !important;
|
||||
|
||||
&::before {
|
||||
border-color: $brand;
|
||||
background: rgba(154, 92, 53, .16);
|
||||
background: rgba($cc-terra, .16);
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu--inline .el-menu-item.is-active {
|
||||
color: $brand !important;
|
||||
font-weight: 650;
|
||||
background: linear-gradient(90deg, rgba(212, 160, 160, .30), rgba(245, 215, 95, .16)) !important;
|
||||
background: linear-gradient(90deg, rgba($cc-blossom, .30), rgba($cc-daisy, .16)) !important;
|
||||
}
|
||||
|
||||
.el-menu--inline .el-menu-item.is-active::before {
|
||||
@@ -342,7 +338,7 @@ body {
|
||||
margin-top: -5px;
|
||||
border-color: $brand;
|
||||
background: $brand;
|
||||
box-shadow: 0 0 0 4px rgba(154, 92, 53, .16);
|
||||
box-shadow: 0 0 0 4px rgba($cc-terra, .16);
|
||||
}
|
||||
|
||||
/* 三级:在二级基础上小步缩进,不把内容推到很右边 */
|
||||
@@ -470,14 +466,14 @@ body {
|
||||
background: transparent !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba(245, 215, 95, .24) !important;
|
||||
background: rgba($cc-daisy, .24) !important;
|
||||
color: $brand !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
color: $brand !important;
|
||||
font-weight: 650;
|
||||
background: linear-gradient(90deg, rgba(212, 160, 160, .32), rgba(245, 215, 95, .18)) !important;
|
||||
background: linear-gradient(90deg, rgba($cc-blossom, .32), rgba($cc-daisy, .18)) !important;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
@@ -499,7 +495,7 @@ body {
|
||||
bottom: 7px;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(180deg, rgba(154, 92, 53, .34), rgba(212, 160, 160, .2));
|
||||
background: linear-gradient(180deg, rgba($cc-terra, .34), rgba($cc-blossom, .2));
|
||||
}
|
||||
|
||||
.el-menu-item,
|
||||
@@ -528,7 +524,7 @@ body {
|
||||
|
||||
&:hover {
|
||||
color: $brand !important;
|
||||
background: rgba(245, 215, 95, .24) !important;
|
||||
background: rgba($cc-daisy, .24) !important;
|
||||
|
||||
&::before {
|
||||
border-color: $brand;
|
||||
@@ -538,7 +534,7 @@ body {
|
||||
&.is-active {
|
||||
color: $brand !important;
|
||||
font-weight: 650;
|
||||
background: rgba(212, 160, 160, .26) !important;
|
||||
background: rgba($cc-blossom, .26) !important;
|
||||
|
||||
&::before {
|
||||
border-color: $brand;
|
||||
|
||||
@@ -14,7 +14,7 @@ $brand: $cc-terra;
|
||||
$ink: $cc-ink;
|
||||
$ink-2: $cc-ink-2;
|
||||
$ink-3: $cc-ink-3;
|
||||
$line: #eadfd0;
|
||||
$line: $cc-border-3;
|
||||
|
||||
/* ------------------------------- 按钮 ------------------------------- */
|
||||
.el-button {
|
||||
@@ -59,8 +59,8 @@ $line: #eadfd0;
|
||||
color: #fff;
|
||||
|
||||
&:hover, &:focus {
|
||||
background: #854c2a;
|
||||
border-color: #854c2a;
|
||||
background: $cc-terra-deep;
|
||||
border-color: $cc-terra-deep;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -190,9 +190,15 @@ $line: #eadfd0;
|
||||
box-shadow: 0 0 0 3px rgba(154, 92, 53, .12);
|
||||
}
|
||||
|
||||
/* 搜索条不再显示 label,用 placeholder 表达(与设计图一致) */
|
||||
/* 搜索条恢复显示 label(2026-09-27 用户反馈「搜索框旁边的提示词不显示了」)。
|
||||
此前这里把 inline 表单的 label 整片 display:none、只用 placeholder 表达,实测丢语境:
|
||||
①「最小值 / 最大值」两个框没有前面的「价格区间」就不知道在填什么(同一处还有「请选择」×3 看不出是外观/类型/是否上架);
|
||||
② 只写了 label、没写 placeholder 的框会变成没有任何说明的空白框;
|
||||
③ 清空/选中后 placeholder 消失,条件就再也认不出来了。
|
||||
⇒ 交回 Element 默认的 inline label(float:none + inline-block),只把字号/颜色对齐 size="small" 的控件。 */
|
||||
.el-form--inline .el-form-item__label {
|
||||
display: none;
|
||||
font-size: 13px;
|
||||
color: $ink-3;
|
||||
}
|
||||
|
||||
.el-form--inline .el-form-item {
|
||||
@@ -233,7 +239,7 @@ $line: #eadfd0;
|
||||
}
|
||||
|
||||
.el-table__row:hover > td.el-table__cell {
|
||||
background: #fbf5ea;
|
||||
background: $cc-tint-3;
|
||||
}
|
||||
|
||||
.el-button--text {
|
||||
@@ -249,12 +255,28 @@ $line: #eadfd0;
|
||||
}
|
||||
|
||||
/* ------------------------------- 分页 -------------------------------
|
||||
分页保持在页面最底部(与原型图一致),不做悬浮/吸底
|
||||
------------------------------------------------------------------- */
|
||||
.app-container .pagination-container {
|
||||
margin-top: 6px;
|
||||
padding: 8px 16px 6px;
|
||||
分页保持在页面最底部(与原型图一致),不做悬浮/吸底。
|
||||
|
||||
★ 这里的选择器刻意把 .pagination-container 写两遍(权重 0,3,0):
|
||||
components/Pagination/index.vue 是 scoped 组件,它的规则会被编译成
|
||||
.pagination-container[data-v-xxxxxxx],权重 0,2,0,而且**排在皮肤层后面**
|
||||
⇒ 只写一遍时权重打平、组件赢,原版那条 background:#fff 就会在奶油底上
|
||||
露出一条白带,padding:32px 16px 还会把分页推离表格一大截。
|
||||
组件里那条已经删掉了,这里再抬一档权重,防止以后有人加回去又悄悄复发。
|
||||
|
||||
分页右对齐靠 text-align(原来靠 ruoyi.scss 里 el-pagination 的
|
||||
position:absolute + right:0,那会让分页脱离文档流、外层高度算不准)。 */
|
||||
.app-container .pagination-container.pagination-container {
|
||||
margin: 4px 0 0;
|
||||
padding: 8px 0 2px;
|
||||
background: transparent;
|
||||
border-radius: 14px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 表格里的文字按钮(编辑 / 删除 / 详情…)不许把标签折成两行 */
|
||||
.el-table .el-button--text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
@@ -426,6 +448,103 @@ $line: #eadfd0;
|
||||
border-bottom-color: $cc-line-2;
|
||||
}
|
||||
|
||||
/* ================= Element 内部写死的冷色 =================
|
||||
下面这些色在 element-ui 的 scss 里是**字面量**(不是变量),
|
||||
改 element-variables.scss 覆盖不到,只能在皮肤层压掉。
|
||||
共同特征:都是冷蓝 / 冷灰 / 冷绿,放在奶油底上明显出戏。
|
||||
======================================================== */
|
||||
|
||||
/* 图片上传(ImageUpload 组件,头像 / banner):
|
||||
①「+」虚线框:原 background #fbfdff、dashed #c0ccda、图标 #8c939d,全是冷色;
|
||||
② 上传成功的绿对勾角标:upload.scss 里写死 #13ce66 的冷绿。 */
|
||||
.el-upload--picture-card {
|
||||
background-color: $cc-cream-hi;
|
||||
border: 1px dashed $cc-line;
|
||||
border-radius: 14px; /* 原 6px,和弹层/卡片统一 */
|
||||
|
||||
i {
|
||||
color: $cc-ink-4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $cc-blossom-deep;
|
||||
}
|
||||
}
|
||||
|
||||
/* 上传成功的绿勾角标:upload.scss 里写死 #13ce66,且用的是**带父级**的三段选择器
|
||||
(权重 0,2,0)——只写 `.el-upload-list__item-status-label` 单类会被它压住,必须对齐权重。
|
||||
顺带把角标的黑影 rgba(0,0,0,.2) / #ccc 也换成暖棕柔影。 */
|
||||
.el-upload-list--picture-card .el-upload-list__item-status-label,
|
||||
.el-upload-list--picture .el-upload-list__item-status-label,
|
||||
.el-upload-cover__label {
|
||||
background: $cc-grass-deep;
|
||||
box-shadow: 0 1px 1px rgba(139, 115, 85, .28);
|
||||
}
|
||||
|
||||
/* 多选下拉里已选中的标签底:原 #f0f2f5 冷蓝灰 */
|
||||
.el-select .el-tag,
|
||||
.el-cascader__tags .el-tag {
|
||||
background-color: $cc-linen-2;
|
||||
border-color: $cc-border-2;
|
||||
}
|
||||
|
||||
/* 日期 / 时间 / 颜色面板的冷灰分隔线与近黑文字(原 #e4e4e4 / #dcdcdc / #333) */
|
||||
.el-picker-panel__footer,
|
||||
.el-picker-panel__sidebar,
|
||||
.el-time-panel__footer,
|
||||
.el-date-picker__time-header,
|
||||
.el-date-range-picker__content.is-left,
|
||||
.el-date-range-picker__time-header {
|
||||
border-color: $cc-line-2;
|
||||
background-color: $cc-cream-hi;
|
||||
}
|
||||
|
||||
.el-picker-panel__btn,
|
||||
.el-color-dropdown__btn {
|
||||
border-color: $cc-border-2;
|
||||
color: $cc-ink-2;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 日期面板「快捷选项」选中态:原 #e6f1fe 淡蓝底 */
|
||||
.el-picker-panel__shortcut.active {
|
||||
background-color: rgba(245, 215, 95, .28);
|
||||
color: $cc-terra;
|
||||
}
|
||||
|
||||
.el-color-picker__trigger,
|
||||
.el-color-hue-slider__thumb,
|
||||
.el-color-alpha-slider__thumb {
|
||||
border-color: $cc-border-2;
|
||||
}
|
||||
|
||||
/* 左/右侧标签页(border-card 变体):原 #d1dbe5 */
|
||||
.el-tabs--left.el-tabs--border-card .el-tabs__item.is-left.is-active,
|
||||
.el-tabs--right.el-tabs--border-card .el-tabs__item.is-right.is-active {
|
||||
border-top-color: $cc-border-2;
|
||||
border-bottom-color: $cc-border-2;
|
||||
}
|
||||
|
||||
/* 输入框内滚动条滑块:原 #b4bccc */
|
||||
.el-input::-webkit-scrollbar-thumb {
|
||||
background: $cc-border-1;
|
||||
}
|
||||
|
||||
/* 加载遮罩:Element 默认 rgba(255,255,255,.9),
|
||||
在奶油底页面上是「刺眼白闪」(旋转图标本身已是陶土色) */
|
||||
.el-loading-mask {
|
||||
background-color: rgba(250, 246, 240, .86);
|
||||
}
|
||||
|
||||
/* 斑马纹行 / 描述列表标签底:原 #fafafa 冷灰 */
|
||||
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: $cc-cream;
|
||||
}
|
||||
|
||||
.el-descriptions-item__label.is-bordered-label {
|
||||
background: $cc-linen;
|
||||
}
|
||||
|
||||
/* ------------------------------- 无障碍 -------------------------------
|
||||
规范要求动效提供 reduced-motion 备选:关掉悬停位移/旋转,只留颜色变化
|
||||
------------------------------------------------------------------- */
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<!--
|
||||
胶囊开关(替代 el-switch,用于后台所有布尔/状态列)
|
||||
|
||||
## 为什么自己写一个,而不是继续改 el-switch 的样式
|
||||
|
||||
Element 的 el-switch 把「轨道」「滑块」和「左右两段文字」拆成四个各自定位的元素,
|
||||
文字用 is-active 控制显隐、轨道用 CSS 变量上色。后台原先 17 个页面各自拿
|
||||
::v-deep 去覆盖它,改出来的东西越修越乱(滑块跑出轨道、两段文字同时出现),
|
||||
**连续三次返工都没收敛**。这里改成自己拼 DOM:一个轨道容器 + 一个滑块 + 一段文字,
|
||||
任何时刻只有一段文字、位置全部由本组件算好,不再依赖 Element 内部结构。
|
||||
|
||||
## 视觉规则(用户 2026-09-24 定的口径)
|
||||
|
||||
| 值 | 轨道 | 滑块 | 文字 |
|
||||
|---|---|---|---|
|
||||
| 真(生效) | 绿 #457450 | 白圆点停在**右侧** | 「启用」白字,落在左半边 |
|
||||
| 假(未生效) | 白 + 细描边 | 白圆点停在**左侧** | 「禁用」灰字,落在右半边 |
|
||||
|
||||
⇒ **绿 = 该项生效,白 = 该项未生效**;文字是结果,整颗开关只出现一次。
|
||||
|
||||
## 尺寸要点(改尺寸必读)
|
||||
|
||||
容器 `66×26`;滑块 `22` 停在 `left:2`(开启 `translateX(38px)`);文字框宽 `36`,
|
||||
关闭态 `left:26`、开启态 `left:3` —— **文字只在滑块不在的那半边**,两态重叠宽度都是 0。
|
||||
|
||||
⚠️ 早期版本是 54px 宽、文字框横跨整条轨道(`left:11; width:34`),结果**滑块压住文字**
|
||||
(用户 2026-09-24 反馈「启用」的「用」被盖掉)。**根因是文字框与滑块占用了同一段横向空间**,
|
||||
不是 z-index 或颜色问题。加宽容器把两者分到各半边才根治。
|
||||
|
||||
## 用法(与 el-switch 的 v-model 语义一致,可直接替换)
|
||||
|
||||
<capsule-switch v-model="scope.row.status" @change="save(scope.row)" />
|
||||
// 自定义两端文案
|
||||
<capsule-switch v-model="row.isDefault" active-text="是" inactive-text="否" />
|
||||
// 库里是 '0'/'1' 字符串时(Element 写法兼容)
|
||||
<capsule-switch v-model="row.status" active-value="0" inactive-value="1" />
|
||||
|
||||
若页面用 `:active-value` / `:inactive-value` 传了非布尔值,本组件内部把
|
||||
传入值与 activeValue 比较得出开关位置,change 时再回吐对应的原始值,
|
||||
⇒ 父组件拿到的仍然是原来的 '0'/'1',**不必改后端契约**。
|
||||
-->
|
||||
<div
|
||||
class="capsule-switch"
|
||||
:class="{ 'is-on': isOn, 'is-disabled': disabled }"
|
||||
role="switch"
|
||||
:aria-checked="isOn"
|
||||
:aria-disabled="disabled"
|
||||
tabindex="0"
|
||||
@click="toggle"
|
||||
@keydown.enter.prevent="toggle"
|
||||
@keydown.space.prevent="toggle"
|
||||
>
|
||||
<span class="capsule-switch__text">{{ isOn ? activeText : inactiveText }}</span>
|
||||
<span class="capsule-switch__dot"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'CapsuleSwitch',
|
||||
props: {
|
||||
// 兼容 el-switch:父组件既可能传布尔,也可能传 '0'/'1'
|
||||
value: {
|
||||
type: [Boolean, String, Number],
|
||||
default: false
|
||||
},
|
||||
activeValue: {
|
||||
type: [Boolean, String, Number],
|
||||
default: true
|
||||
},
|
||||
inactiveValue: {
|
||||
type: [Boolean, String, Number],
|
||||
default: false
|
||||
},
|
||||
activeText: {
|
||||
type: String,
|
||||
default: '启用'
|
||||
},
|
||||
inactiveText: {
|
||||
type: String,
|
||||
default: '禁用'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isOn() {
|
||||
return this.value === this.activeValue;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
if (this.disabled) return;
|
||||
const next = this.isOn ? this.inactiveValue : this.activeValue;
|
||||
this.$emit('input', next);
|
||||
this.$emit('change', next);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 尺寸常量:宽 66、高 26,滑块 22,两端留 2px 缝
|
||||
⚠️ 改尺寸必须重算 —— 约束是「文字不能被滑块压住」:
|
||||
关闭:滑块停在左(x 3~25),文字只占右半边(x 27~63),两者不重叠
|
||||
开启:滑块停在右(x 41~63),文字只占左半边(x 3~39),两者不重叠
|
||||
容器宽度必须 ≥ 滑块(22) + 文字所需宽度(36) + 两侧留白(约 8),
|
||||
54px 那版文字框横跨整条轨道 ⇒ 必然被滑块压住(用户 2026-09-24 反馈的正是这个)。 */
|
||||
.capsule-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 66px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
border: 1px solid #d8c3b0;
|
||||
background: #fffdfa;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
transition: background-color 0.3s, border-color 0.3s;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(69, 116, 80, 0.22);
|
||||
}
|
||||
|
||||
/* 滑块:白色正圆 */
|
||||
&__dot {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 2px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(139, 115, 85, 0.3);
|
||||
transition: transform 0.3s;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 文字:整颗开关只渲染这一段,换的是内容不是显隐 ⇒ 不可能出现两段文字。
|
||||
⚠️ 必须 z-index 高于滑块,否则关闭态下会被白圆点盖住(「启用」= 绿底白字,压在绿块上反而难读)。 */
|
||||
&__text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 26px;
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
color: #8b755a;
|
||||
transition: color 0.3s, left 0.3s;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&__dot,
|
||||
&__text {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- 开启:绿底 + 滑块右移 38(2 → 40),文字换到左半边并改白 ---- */
|
||||
&.is-on {
|
||||
border-color: #457450;
|
||||
background: #457450;
|
||||
|
||||
.capsule-switch__dot {
|
||||
transform: translateX(38px);
|
||||
}
|
||||
|
||||
.capsule-switch__text {
|
||||
left: 3px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -376,7 +376,7 @@ export default {
|
||||
.popup-main {
|
||||
position: relative;
|
||||
margin: 10px auto;
|
||||
background: #fff;
|
||||
background: #fffdfa; /* $cc-cream-hi */
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
@@ -385,7 +385,7 @@ export default {
|
||||
overflow: hidden;
|
||||
line-height: 34px;
|
||||
padding-top: 6px;
|
||||
background: #f2f2f2;
|
||||
background: #ece2d3; /* $cc-linen-2 */
|
||||
}
|
||||
.popup-result {
|
||||
box-sizing: border-box;
|
||||
@@ -404,7 +404,7 @@ export default {
|
||||
margin-left: -70px;
|
||||
text-align: center;
|
||||
line-height: 30px;
|
||||
background: #fff;
|
||||
background: #fffdfa; /* $cc-cream-hi */
|
||||
}
|
||||
.popup-result table {
|
||||
text-align: center;
|
||||
@@ -419,7 +419,7 @@ export default {
|
||||
height: 30px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e8e8e8;
|
||||
border: 1px solid rgba(139, 115, 85, 0.2); /* $cc-line-2 */
|
||||
}
|
||||
.popup-result-scroll {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -198,7 +198,7 @@ export default {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.upload-file-list .el-upload-list__item {
|
||||
border: 1px solid #e4e7ed;
|
||||
border: 1px solid rgba(212, 160, 160, 0.55); /* $cc-line */
|
||||
line-height: 2;
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
|
||||
@@ -183,7 +183,7 @@ export default {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
box-shadow: none !important;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
border-bottom: 1px solid rgba(139, 115, 85, 0.2); /* $cc-line-2 */
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default {
|
||||
height: 100%;
|
||||
padding: 0 5px;
|
||||
&:hover {
|
||||
background: #ececec;
|
||||
background: #ece2d3; /* $cc-linen-2 */
|
||||
border-radius: 5px;
|
||||
}
|
||||
.icon {
|
||||
@@ -94,7 +94,7 @@ export default {
|
||||
}
|
||||
}
|
||||
.icon-item.active {
|
||||
background: #ececec;
|
||||
background: #ece2d3; /* $cc-linen-2 */
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +67,13 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-image {
|
||||
border-radius: 5px;
|
||||
background-color: #ebeef5;
|
||||
box-shadow: 0 0 5px 1px #ccc;
|
||||
/* 原为冷灰底(ebeef5)+ 硬灰影(0 0 5px 1px ccc)+ 5px 方角。
|
||||
列表页的头像/图片缩略图全走这个组件,冷灰底 + 硬影在奶油卡上很突兀,
|
||||
而且它在表格里被 skin.scss 压到 32px ⇒ 出错占位图标(原本 30px)几乎铺满整格,
|
||||
看起来像"图裂了"。改成暖色板 + 柔和暖影,占位图缩小到不刺眼。 */
|
||||
border-radius: 10px;
|
||||
background-color: #f3ece0; // $cc-linen
|
||||
box-shadow: 0 1px 3px rgba(139, 115, 85, 0.18);
|
||||
::v-deep .el-image__inner {
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
@@ -83,8 +87,8 @@ export default {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #7a664e;
|
||||
font-size: 30px;
|
||||
color: #8b755a; // $cc-ink-4
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -104,10 +104,12 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination-container {
|
||||
background: #fff;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
/* 只保留「隐藏」这一条。原版的 background:#fff + padding:32px 16px 已经删掉:
|
||||
本组件是 scoped 的,选择器权重是 .pagination-container[data-v-x] (0,2,0),
|
||||
而皮肤层(assets/styles/skin.scss)的 .app-container .pagination-container 也是 (0,2,0)
|
||||
—— 权重打平、组件样式在产物里更靠后,于是原版这两条永远赢:
|
||||
在奶油底 .app-container 上露出一条纯白带,还有 32px 的上下留白。
|
||||
⇒ 分页的底色/间距/圆角一律由皮肤层负责,这里不要再写。 */
|
||||
.pagination-container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -61,13 +61,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 冷中性半透明(纯黑遮罩 / 纯黑影)→ 暖棕:Cottagecore 里不留灰黑 */
|
||||
.rightPanel-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: opacity .3s cubic-bezier(.7, .3, .1, 1);
|
||||
background: rgba(0, 0, 0, .2);
|
||||
background: rgba(79, 65, 48, .22); /* $cc-ink 22% */
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@@ -78,10 +79,10 @@ export default {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, .05);
|
||||
box-shadow: 0 0 15px 0 rgba(139, 115, 85, .13); /* $cc-earth 13% */
|
||||
transition: all .25s cubic-bezier(.7, .3, .1, 1);
|
||||
transform: translate(100%);
|
||||
background: #fff;
|
||||
background: #fffdfa; /* $cc-cream-hi */
|
||||
z-index: 40000;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export default {
|
||||
.screenfull-svg {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
fill: #5a5e66;;
|
||||
fill: #7a664e; /* $cc-ink-3;原为冷板岩(5a5e66),且多打了一个分号 */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: 10px;
|
||||
|
||||
@@ -7,16 +7,6 @@
|
||||
<tags-view v-if="showTagsView" class="navbar-tags-view" />
|
||||
|
||||
<div class="right-menu">
|
||||
<!-- 任意页面都能一键看"当前这个页面"的操作记录 -->
|
||||
<div
|
||||
v-if="showOperLog"
|
||||
v-hasPermi="['monitor:operlog:query']"
|
||||
class="right-menu-item operlog-entry"
|
||||
@click="goOperationLog"
|
||||
>
|
||||
<i class="el-icon-document" />
|
||||
<span class="operlog-text">操作日志</span>
|
||||
</div>
|
||||
<el-dropdown class="avatar-container right-menu-item" trigger="click">
|
||||
<div class="avatar-wrapper">
|
||||
<img :src="avatar" class="user-avatar">
|
||||
@@ -52,21 +42,9 @@ export default {
|
||||
/** 标签页已并入顶栏,是否显示跟随布局设置 */
|
||||
showTagsView() {
|
||||
return this.$store.state.settings.tagsView
|
||||
},
|
||||
/** 已经在操作日志页时不显示入口 */
|
||||
showOperLog() {
|
||||
return this.$route.path !== '/system/log/operlog'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/** 跳到操作日志,并自动按当前页面的菜单名筛选 */
|
||||
goOperationLog() {
|
||||
const pageTitle = (this.$route.meta && this.$route.meta.title) || ''
|
||||
this.$router.push({
|
||||
path: '/system/log/operlog',
|
||||
query: pageTitle ? { title: pageTitle } : {}
|
||||
})
|
||||
},
|
||||
async logout() {
|
||||
this.$confirm('确定注销并退出系统吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
@@ -131,21 +109,6 @@ export default {
|
||||
color: #5f4f3d;
|
||||
}
|
||||
|
||||
/* 操作日志入口:文案按钮,比图标更好找 */
|
||||
.operlog-entry {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s;
|
||||
|
||||
.operlog-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #9a5c35;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -174,7 +137,7 @@ export default {
|
||||
|
||||
.el-icon-caret-bottom {
|
||||
font-size: 12px;
|
||||
color: #9a886e;
|
||||
color: #8b755a; /* $cc-ink-4;原为未加暗的旧值(9a886e) */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ export default {
|
||||
.setting-drawer-content {
|
||||
.setting-drawer-title {
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0, 0, 0, .85);
|
||||
color: #4f4130; /* $cc-ink */
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-weight: bold;
|
||||
@@ -242,13 +242,13 @@ export default {
|
||||
|
||||
.drawer-title {
|
||||
margin-bottom: 12px;
|
||||
color: rgba(0, 0, 0, .85);
|
||||
color: #4f4130; /* $cc-ink */
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.drawer-item {
|
||||
color: rgba(0, 0, 0, .65);
|
||||
color: #5f4f3d; /* $cc-ink-2 */
|
||||
font-size: 14px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
@@ -304,8 +304,9 @@ export default {
|
||||
}
|
||||
|
||||
&.active {
|
||||
/* 渐变两端都保证白字 ≥4.5:1(#9a5c35 为 5.31:1,#7d4a2a 为 7.28:1) */
|
||||
background: linear-gradient(135deg, #9a5c35, #7d4a2a);
|
||||
/* 渐变两端都保证白字 ≥4.5:1(#9a5c35 为 5.31:1,#854c2a 为 6.85:1)
|
||||
末端用 $cc-terra-deep,和 skin.scss 里主色悬停是同一个值 */
|
||||
background: linear-gradient(135deg, #9a5c35, #854c2a);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 12px rgba(139, 115, 85, 0.22);
|
||||
@@ -371,8 +372,8 @@ export default {
|
||||
vertical-align: 2px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
color: #8b98ad;
|
||||
background-color: rgba(139, 152, 173, .18);
|
||||
color: #7a664e; /* $cc-ink-3;原为冷板岩(8b98ad) */
|
||||
background-color: rgba(122, 102, 78, 0.18);
|
||||
transition: all .3s cubic-bezier(.645, .045, .355, 1);
|
||||
transform-origin: 100% 50%;
|
||||
&:before {
|
||||
|
||||
@@ -33,6 +33,8 @@ import ImageUpload from "@/components/ImageUpload"
|
||||
import ImagePreview from "@/components/ImagePreview"
|
||||
// 字典标签组件
|
||||
import DictTag from '@/components/DictTag'
|
||||
// 胶囊开关组件(替代 el-switch)
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch'
|
||||
// 头部标签组件
|
||||
import VueMeta from 'vue-meta'
|
||||
// 字典数据组件
|
||||
@@ -58,6 +60,7 @@ Vue.component('Editor', Editor)
|
||||
Vue.component('FileUpload', FileUpload)
|
||||
Vue.component('ImageUpload', ImageUpload)
|
||||
Vue.component('ImagePreview', ImagePreview)
|
||||
Vue.component('CapsuleSwitch', CapsuleSwitch)
|
||||
|
||||
Vue.use(directive)
|
||||
Vue.use(plugins)
|
||||
|
||||
@@ -53,12 +53,12 @@ export const ACCOUNT_SOURCE_MAP = {
|
||||
52: '极速糖果奖励',
|
||||
56: 'Roll房未发放退回主播',
|
||||
59: '主播Roll房奖池扣款',
|
||||
60: '玫瑰女郎消费',
|
||||
61: '玫瑰女郎奖励',
|
||||
62: '招商周结佣金',
|
||||
63: '流水抽奖奖励',
|
||||
65: '管理员调整余额',
|
||||
66: '管理员调整积分'
|
||||
66: '管理员调整积分',
|
||||
68: '麻将胡了消费',
|
||||
69: '麻将胡了奖励'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
max-width: 100%;
|
||||
margin: 100px auto;
|
||||
.pan-back-btn {
|
||||
background: #008489;
|
||||
background: #9a5c35;
|
||||
color: #fff;
|
||||
border: none!important;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default {
|
||||
.text-jumbo {
|
||||
font-size: 60px;
|
||||
font-weight: 700;
|
||||
color: #484848;
|
||||
color: #4f4130;
|
||||
}
|
||||
.list-unstyled {
|
||||
font-size: 14px;
|
||||
@@ -77,7 +77,7 @@ export default {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
a {
|
||||
color: #008489;
|
||||
color: #9a5c35;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -170,7 +170,7 @@ export default {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 40px;
|
||||
color: #1482f0;
|
||||
color: #9a5c35;
|
||||
opacity: 0;
|
||||
margin-bottom: 20px;
|
||||
animation-name: slideUp;
|
||||
@@ -205,7 +205,7 @@ export default {
|
||||
float: left;
|
||||
width: 110px;
|
||||
height: 36px;
|
||||
background: #1482f0;
|
||||
background: #9a5c35;
|
||||
border-radius: 100px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div style="margin-top: 16px; padding-left: 68px">
|
||||
|
||||
<div v-for="(item, index) in items" :key="index" style="display: flex; align-items: center; margin-bottom: 10px">
|
||||
<span style="width: 100px; color: #606266; font-size: 13px">临时爆率 #{{ index + 1 }}</span>
|
||||
<span style="width: 100px; color: #5f4f3d; font-size: 13px">临时爆率 #{{ index + 1 }}</span>
|
||||
<el-input
|
||||
v-model="item.lowPrice"
|
||||
type="number"
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-row :gutter="10" style="margin-bottom:16px">
|
||||
<el-input v-model="queryParams.name" placeholder="按赌注搜索" clearable style="width:200px;margin-right:10px" @clear="loadData" @keyup.enter.native="loadData">
|
||||
<el-button slot="append" icon="el-icon-search" @click="loadData" />
|
||||
</el-input>
|
||||
<span style="margin-left:10px;margin-right:4px;color:#5f4f3d">身份:</span>
|
||||
<el-radio-group v-model="audienceFilter" size="small" @change="loadData">
|
||||
<el-radio-button v-for="a in audiences" :key="a.key" :label="a.key">{{ a.name }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button icon="el-icon-refresh" style="margin-left:10px" @click="loadData">刷新</el-button>
|
||||
<el-button type="danger" icon="el-icon-refresh" :loading="recalcLoading" style="float:right" @click="openRecalcDialog">一键重算分布</el-button>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="boxList" border>
|
||||
<el-table-column prop="boxId" label="ID" min-width="50" align="center" />
|
||||
<el-table-column prop="bet" label="赌注" min-width="70" align="center" />
|
||||
<el-table-column label="总回合" min-width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="totalRoundsOf(scope.row) != null">{{ totalRoundsOf(scope.row) }}</span>
|
||||
<span v-else style="color:#8b755a">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标RTP" min-width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<!-- 显示 1 位小数:配比是"整数条回合 × 桶内实际均值"分出来的,精确值留在 title 里,鼠标悬停可看 -->
|
||||
<span v-if="targetRtp(scope.row) != null" :title="targetRtp(scope.row) + '%'">{{ Number(targetRtp(scope.row)).toFixed(1) + '%' }}</span>
|
||||
<span v-else style="color:#8b755a">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实时RTP" min-width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="actualRtp(scope.row) != null" :style="{color: actualRtp(scope.row) > (targetRtp(scope.row) != null ? targetRtp(scope.row) : 0) ? '#a4485a' : '#457450'}">{{ actualRtp(scope.row) }}%</span>
|
||||
<span v-else style="color:#8b755a">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="60" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.status === '1' ? 'success' : 'danger'" size="small">{{ scope.row.status === '1' ? '启用' : '禁用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="270" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="info" @click="handleBuckets(scope.row)">饰品配置</el-button>
|
||||
<el-button size="mini" type="success" @click="openTemplateManager(scope.row)">模板管理</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top:16px;text-align:right"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-size.sync="queryParams.pageSize"
|
||||
:current-page.sync="queryParams.pageNum"
|
||||
:page-sizes="[10,20,50,100]"
|
||||
@size-change="loadData"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog
|
||||
:title="'编辑箱子 - 赌注 ' + (form.bet != null ? form.bet : '')"
|
||||
:visible.sync="dialogVisible"
|
||||
width="480px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form ref="form" :model="form" label-width="110px">
|
||||
<el-form-item label="基本赌注"><el-input-number v-model="form.bet" disabled style="width:100%" /></el-form-item>
|
||||
<el-form-item label="总回合数">
|
||||
<el-input-number v-model="form.totalRounds" :min="0" :step="50" style="width:100%" />
|
||||
<span style="color:#7a664e;font-size:12px">身份级参数,仅作用于身份【{{ audienceLabel(audienceFilter) }}】</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标RTP">
|
||||
<el-input-number v-model="form.rtp" :min="0" :max="1" :precision="3" :step="0.05" style="width:100%" />
|
||||
<span style="color:#7a664e;font-size:12px">身份级参数,仅作用于身份【{{ audienceLabel(audienceFilter) }}】</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="固定/收敛回合%">
|
||||
<el-input-number v-model="form.fixedPct" :min="0" :max="100" :step="5" style="width:38%" @change="onFixedPctChange" />
|
||||
<span style="margin-left:4px">%</span>
|
||||
<span style="margin:0 4%">/</span>
|
||||
<el-input-number v-model="form.convergePct" :min="0" :max="100" :step="5" style="width:38%" @change="onConvergePctChange" />
|
||||
<span style="margin-left:4px">%</span>
|
||||
<div style="color:#7a664e;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标,随保存作用于当前身份</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio label="1">启用</el-radio>
|
||||
<el-radio label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="dialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" :loading="saveLoading" @click="submitForm">确 定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重算分布弹窗 -->
|
||||
<el-dialog
|
||||
title="重算分布"
|
||||
:visible.sync="recalcDialogVisible"
|
||||
width="480px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="身份范围">
|
||||
<el-radio-group v-model="recalcForm.onlyCurrent">
|
||||
<el-radio :label="false">全部身份</el-radio>
|
||||
<el-radio :label="true">仅当前身份【{{ audienceLabel(audienceFilter) }}】</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标RTP">
|
||||
<el-input-number v-model="recalcForm.rtp" :min="0" :max="1" :precision="3" :step="0.05" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="总回合">
|
||||
<el-input-number v-model="recalcForm.totalRounds" :min="50" :step="50" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="固定/收敛回合%">
|
||||
<el-input-number v-model="recalcForm.fixedPct" :min="0" :max="100" :step="5" style="width:40%" @change="onRecalcFixedPctChange" />
|
||||
<span style="margin-left:4px">%</span>
|
||||
<span style="margin:0 4%">/</span>
|
||||
<el-input-number v-model="recalcForm.convergePct" :min="0" :max="100" :step="5" style="width:40%" @change="onRecalcConvergePctChange" />
|
||||
<span style="margin-left:4px">%</span>
|
||||
<div style="color:#7a664e;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="recalcDialogVisible = false">取 消</el-button>
|
||||
<el-button type="primary" :loading="recalcLoading" @click="submitRecalc">确认重算</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 饰品配置弹窗(跟随列表页当前身份) -->
|
||||
<el-dialog
|
||||
:title="'饰品配置 - (ID:' + currentBox.boxId + ')【' + audienceLabel(audience) + '】'"
|
||||
:visible.sync="bucketVisible"
|
||||
width="760px"
|
||||
append-to-body
|
||||
>
|
||||
<div style="display:flex;align-items:center;margin-bottom:10px">
|
||||
<el-alert type="info" :closable="false" style="flex:1">
|
||||
身份【{{ audienceLabel(audience) }}】 计算回合(固定): <b>{{ bucketTotal }}</b> |
|
||||
收敛回合: <b>{{ convergeRoundsDerived }}</b> | 总回合: <b>{{ totalRoundsDerived }}</b> |
|
||||
计算RTP(池内实际均值): <b>{{ bucketRTP }}%</b> | 目标RTP: <b>{{ currentBucketTargetRtp }}%</b>
|
||||
</el-alert>
|
||||
<el-button type="warning" size="small" icon="el-icon-refresh" style="margin-left:10px;flex-shrink:0" @click="handleBucketRecalc">重算分布</el-button>
|
||||
</div>
|
||||
<el-table v-loading="bucketLoading" :data="bucketList" border max-height="420">
|
||||
<el-table-column prop="bucketId" label="桶ID" width="60" align="center" />
|
||||
<el-table-column label="饰品" min-width="110" align="center">
|
||||
<template slot-scope="scope">{{ (scope.row.bucketName || scope.row.name || '').replace('x', '倍桶') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="倍率范围" min-width="120" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.rangeLow }} - {{ scope.row.rangeHigh }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="期望倍率" min-width="90" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.expectMul }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回合数" min-width="140" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-input-number v-model="scope.row.count" :min="0" :step="10" size="small" controls-position="right" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余" min-width="70" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span :style="{color: (scope.row.remaining != null ? scope.row.remaining : scope.row.count) <= 0 ? '#a4485a' : '#457450'}">{{ scope.row.remaining != null ? scope.row.remaining : scope.row.count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="动态收敛回合" min-width="100" align="center">
|
||||
<template slot-scope="scope"><span style="color:#8f6418">{{ scope.row.convergeCount || 0 }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div slot="footer">
|
||||
<el-button @click="bucketVisible = false">取 消</el-button>
|
||||
<el-button type="success" icon="el-icon-document-add" @click="handleSaveAsTemplate">保存为模板</el-button>
|
||||
<el-button type="primary" @click="saveBuckets">保存</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 模板管理弹窗(跟随列表页当前身份) -->
|
||||
<el-dialog
|
||||
:title="'模板管理 - (ID:' + templateBox.boxId + ')【' + audienceLabel(templateAudience) + '】'"
|
||||
:visible.sync="templateDialogVisible"
|
||||
width="720px"
|
||||
append-to-body
|
||||
>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px">
|
||||
当前为身份【{{ audienceLabel(templateAudience) }}】的模板。模板保存了该箱子该身份的饰品桶回合分布。选择后点【应用】可将该身份桶数量恢复为模板值,并设为抽干补桶依据。
|
||||
</el-alert>
|
||||
<el-table v-if="boxTemplates.length > 0" :data="boxTemplates" border highlight-current-row @current-change="onTemplateRowChange">
|
||||
<el-table-column label="模板名称" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.name }}
|
||||
<el-tag v-if="scope.row.isActive === 1" type="success" size="mini" style="margin-left:6px">生效中</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="饰品数" width="80" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.items ? scope.row.items.length : 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="固定回合" width="90" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.totalRounds }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="保存时间" width="170" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.createTime }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="danger" @click="handleDeleteTemplate(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="该身份暂无模板,请在饰品配置中修改数量后点【保存为模板】。" />
|
||||
<div slot="footer">
|
||||
<el-button @click="templateDialogVisible = false">关 闭</el-button>
|
||||
<el-button type="primary" :disabled="!selectedTemplateRow" @click="handleApplyTemplate">应用选中模板</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getBoxList, saveBox, getBoxBuckets, saveBoxBuckets, calcDistribution, getBoxTemplates, saveBoxTemplate, deleteBoxTemplate, applyBoxTemplate, recalcBatch } from '@/api/game/mahjong/box/api'
|
||||
import { getBucketList } from '@/api/game/mahjong/bucket/api'
|
||||
|
||||
export default {
|
||||
name: 'MahjongBox',
|
||||
data() {
|
||||
return {
|
||||
loading: false, recalcLoading: false, saveLoading: false, boxList: [], total: 0,
|
||||
queryParams: { pageNum: 1, pageSize: 10, boxType: null },
|
||||
audienceFilter: 'common',
|
||||
dialogVisible: false, bucketVisible: false, currentBox: {},
|
||||
form: { boxId: null, name: '', bet: 1, boxType: 'normal', rtp: 0.8, status: '1', price: 0, totalRounds: 0, fixedPct: 85, convergePct: 15 },
|
||||
// 重算分布弹窗
|
||||
recalcDialogVisible: false,
|
||||
recalcForm: { onlyCurrent: false, boxType: '', rtp: 0.8, totalRounds: 200, fixedPct: 85, convergePct: 15 },
|
||||
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
|
||||
audience: 'common', bucketLoading: false, bucketList: [], bucketAll: [], bucketExpects: {},
|
||||
// 模板
|
||||
templateDialogVisible: false, templateBox: {}, templateAudience: 'common', boxTemplates: [], selectedTemplateRow: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bucketTotal() { return this.bucketList.reduce((s, o) => s + (o.count || 0), 0) },
|
||||
bucketRTP() {
|
||||
const total = this.bucketTotal
|
||||
if (!total || !this.currentBox.bet) return '0.0'
|
||||
let contrib = 0
|
||||
this.bucketList.forEach(o => { const mul = this.bucketExpects[o.bucketId]; if (mul != null) contrib += (o.count || 0) * mul })
|
||||
// 本游戏 costMultiplier ≡ 1(price ≡ bet),此处不再有模式倍率表
|
||||
const cm = Number(this.currentBox.costMultiplier) || 1
|
||||
return (contrib / total / cm * 100).toFixed(1)
|
||||
},
|
||||
convergeRatioVal() {
|
||||
let cr = this.currentBox.rtpConvergeRatio != null ? Number(this.currentBox.rtpConvergeRatio) : 0.15
|
||||
if (!(cr >= 0)) cr = 0.15
|
||||
return Math.min(0.9, Math.max(0, cr))
|
||||
},
|
||||
totalRoundsDerived() { const fixed = this.bucketTotal; return fixed > 0 ? Math.round(fixed / (1 - this.convergeRatioVal)) : 0 },
|
||||
convergeRoundsDerived() { return Math.max(0, this.totalRoundsDerived - this.bucketTotal) },
|
||||
// 当前身份目标RTP(%):优先取该身份分布反推值,无则回退箱子统一值
|
||||
currentBucketTargetRtp() {
|
||||
const m = this.currentBox.targetRtpByAudience
|
||||
const v = m && m[this.audience] != null ? m[this.audience] : null
|
||||
if (v != null) return Number(v).toFixed(1)
|
||||
return this.currentBox.rtp != null ? (this.currentBox.rtp * 100).toFixed(1) : '-'
|
||||
}
|
||||
},
|
||||
created() { this.loadData() },
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true
|
||||
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
|
||||
if (this.queryParams.boxType) params.boxType = this.queryParams.boxType
|
||||
getBoxList(params).then(res => {
|
||||
this.boxList = (res.data && res.data.rows) || []
|
||||
this.total = (res.data && res.data.total) || 0
|
||||
this.loading = false
|
||||
}).catch(() => { this.loading = false })
|
||||
},
|
||||
openRecalcDialog() {
|
||||
// 默认参数取当前列表第一行的当前身份反推值(无数据时用默认)
|
||||
const row = this.boxList[0] || {}
|
||||
let rtp = 0.8, totalRounds = 200, convergePct = 15
|
||||
const t = row.targetRtpByAudience && row.targetRtpByAudience[this.audienceFilter]
|
||||
if (t != null) rtp = Number(t) / 100
|
||||
const tr = row.totalRoundsByAudience && row.totalRoundsByAudience[this.audienceFilter]
|
||||
if (tr != null) totalRounds = Number(tr)
|
||||
if (row.rtpConvergeRatio != null) convergePct = Math.round(Number(row.rtpConvergeRatio) * 100)
|
||||
this.recalcForm = { onlyCurrent: false, boxType: '', rtp, totalRounds, fixedPct: 100 - convergePct, convergePct }
|
||||
this.recalcDialogVisible = true
|
||||
},
|
||||
onRecalcFixedPctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.recalcForm.fixedPct = val; this.recalcForm.convergePct = 100 - val },
|
||||
onRecalcConvergePctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.recalcForm.convergePct = val; this.recalcForm.fixedPct = 100 - val },
|
||||
submitRecalc() {
|
||||
const f = this.recalcForm
|
||||
const params = {
|
||||
boxType: f.boxType || null,
|
||||
audience: f.onlyCurrent ? this.audienceFilter : null,
|
||||
rtp: f.rtp,
|
||||
totalRounds: f.totalRounds,
|
||||
convergeRatio: f.convergePct / 100
|
||||
}
|
||||
this.recalcLoading = true
|
||||
recalcBatch(params).then(() => {
|
||||
this.$message.success('重算完成')
|
||||
this.recalcDialogVisible = false
|
||||
this.loadData()
|
||||
}).catch(e => this.$message.error('重算失败: ' + (e.message || e)))
|
||||
.finally(() => { this.recalcLoading = false })
|
||||
},
|
||||
handleEdit(row) {
|
||||
const convergePct = row.rtpConvergeRatio != null ? Math.round(row.rtpConvergeRatio * 100) : 15
|
||||
// 目标RTP 初始值 = 当前身份分布反推的目标(百分点/100),避免显示成其他身份的值
|
||||
let rtp = row.rtp != null ? row.rtp : 0.8
|
||||
const t = row.targetRtpByAudience && row.targetRtpByAudience[this.audienceFilter]
|
||||
if (t != null) rtp = Number(t) / 100
|
||||
// 总回合 初始值 = 当前身份分布反推的一轮总回合(身份级独立)
|
||||
let totalRounds = row.totalRounds || 0
|
||||
const tr = row.totalRoundsByAudience && row.totalRoundsByAudience[this.audienceFilter]
|
||||
if (tr != null) totalRounds = Number(tr)
|
||||
this.form = { ...row, totalRounds, rtp, status: row.status || '1', convergePct, fixedPct: 100 - convergePct }
|
||||
this.dialogVisible = true
|
||||
},
|
||||
onFixedPctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.form.fixedPct = val; this.form.convergePct = 100 - val },
|
||||
onConvergePctChange(v) { const val = Math.min(100, Math.max(0, v || 0)); this.form.convergePct = val; this.form.fixedPct = 100 - val },
|
||||
submitForm() {
|
||||
const convergeRatio = (this.form.convergePct != null ? this.form.convergePct : 15) / 100
|
||||
// totalRounds 会同时回写 total_rounds,保证 cycleN 与实际池子一致
|
||||
const data = { boxId: this.form.boxId, name: this.form.name, bet: this.form.bet, boxType: this.form.boxType, rtp: this.form.rtp, status: this.form.status, rtpConvergeRatio: convergeRatio, totalRounds: this.form.totalRounds }
|
||||
this.saveLoading = true
|
||||
saveBox(data).then(res => {
|
||||
const boxId = this.form.boxId || (res.data && res.data.boxId)
|
||||
if (!boxId) { this.saveLoading = false; this.$message.success('保存成功'); this.dialogVisible = false; this.loadData(); return }
|
||||
// 保存后只重算当前身份
|
||||
calcDistribution({ boxId, audience: this.audienceFilter, totalRounds: this.form.totalRounds, rtp: this.form.rtp, convergeRatio })
|
||||
.then(() => { this.$message.success('修改成功,已重算【' + this.audienceLabel(this.audienceFilter) + '】身份分布') })
|
||||
.catch(() => { this.$message.success('修改成功(分布计算失败,可稍后用"重算分布"重试)') })
|
||||
.finally(() => { this.saveLoading = false; this.dialogVisible = false; this.loadData() })
|
||||
}).catch(() => { this.saveLoading = false })
|
||||
},
|
||||
handleBuckets(row) {
|
||||
this.currentBox = row
|
||||
this.audience = this.audienceFilter // 跟随列表页当前身份
|
||||
getBucketList().then(bkRes => {
|
||||
this.bucketAll = (bkRes.data && bkRes.data.list) ? bkRes.data.list : []
|
||||
this.bucketExpects = {}
|
||||
this.bucketAll.forEach(o => { this.bucketExpects[o.id] = Number(o.expectMul) })
|
||||
this.bucketVisible = true
|
||||
this._refreshBucketList()
|
||||
})
|
||||
},
|
||||
_refreshBucketList() {
|
||||
this.bucketLoading = true
|
||||
getBoxBuckets(this.currentBox.boxId, this.audience).then(res => {
|
||||
const items = (res.data && res.data.list) ? res.data.list : []
|
||||
// 「计算RTP」必须与后台「目标RTP」同一口径 = 该桶**池内实际均值**(AVG(actual_mul)),
|
||||
// 否则会拿桶定义的名义 expectMul(区间中点)来算,两个数必然对不上。
|
||||
// 池子里还没有该桶的回合时,后端会回退名义值,这里保持同样回退。
|
||||
const pe = (res.data && res.data.poolExpect) || {}
|
||||
const expects = {}
|
||||
this.bucketAll.forEach(o => {
|
||||
expects[o.id] = pe[o.id] != null ? Number(pe[o.id]) : Number(o.expectMul)
|
||||
})
|
||||
this.bucketExpects = expects
|
||||
const map = {}
|
||||
items.forEach(it => { map[it.bucketId] = { count: it.count, remaining: it.remaining != null ? it.remaining : (it.count - (it.used || 0)), convergeCount: it.convergeCount } })
|
||||
// 只有 normal 一种模式:桶范围恒为 1~16
|
||||
this.bucketList = this.bucketAll.filter(o => o.id >= 1 && o.id <= 16)
|
||||
.map(o => ({ ...o, count: (map[o.id] && map[o.id].count) || 0, remaining: map[o.id] ? map[o.id].remaining : 0, convergeCount: map[o.id] ? map[o.id].convergeCount : 0 }))
|
||||
this.bucketLoading = false
|
||||
}).catch(() => { this.bucketLoading = false })
|
||||
},
|
||||
handleBucketRecalc() {
|
||||
const box = this.currentBox
|
||||
const cr = box.rtpConvergeRatio != null ? box.rtpConvergeRatio : 0.15
|
||||
// 目标RTP 优先用当前身份分布反推值,与"按身份编辑"语义一致
|
||||
const t = box.targetRtpByAudience && box.targetRtpByAudience[this.audience] != null ? Number(box.targetRtpByAudience[this.audience]) / 100 : (box.rtp || 0.8)
|
||||
// 总回合 用当前身份反推值(身份级独立)
|
||||
const tr = box.totalRoundsByAudience && box.totalRoundsByAudience[this.audience] != null ? Number(box.totalRoundsByAudience[this.audience]) : (box.totalRounds || 200)
|
||||
calcDistribution({ boxId: box.boxId, audience: this.audience, totalRounds: tr, rtp: t, convergeRatio: cr }).then(() => {
|
||||
this.$message.success('分布已重算并保存')
|
||||
this._refreshBucketList()
|
||||
this.loadData()
|
||||
}).catch(() => { this.$message.error('重算失败') })
|
||||
},
|
||||
saveBuckets() {
|
||||
const items = this.bucketList.filter(o => o.count > 0).map(o => ({ bucketId: o.id, count: o.count }))
|
||||
saveBoxBuckets({ boxId: this.currentBox.boxId, audience: this.audience, items }).then(() => {
|
||||
this.$message.success('保存成功')
|
||||
this.bucketVisible = false
|
||||
this.loadData()
|
||||
})
|
||||
},
|
||||
handleSaveAsTemplate() {
|
||||
const items = this.bucketList.filter(o => o.count > 0).map(o => ({ bucketId: o.id, count: o.count }))
|
||||
if (items.length === 0) return this.$message.warning('当前没有配置任何桶回合数,无法保存模板')
|
||||
this.$prompt('请输入模板名称(同一箱子+身份内不可重复)', '保存为模板【' + this.audienceLabel(this.audience) + '】', { inputPattern: /\S+/, inputErrorMessage: '名称不能为空' })
|
||||
.then(({ value }) => {
|
||||
saveBoxTemplate({ boxId: this.currentBox.boxId, audience: this.audience, name: value.trim(), items })
|
||||
.then(() => this.$message.success('模板【' + value.trim() + '】已保存'))
|
||||
.catch(e => this.$message.error('保存失败: ' + (e.message || e)))
|
||||
}).catch(() => {})
|
||||
},
|
||||
// 模板管理
|
||||
openTemplateManager(row) { this.templateBox = row; this.templateAudience = this.audienceFilter; this.selectedTemplateRow = null; this.templateDialogVisible = true; this._loadTemplates() },
|
||||
_loadTemplates() {
|
||||
getBoxTemplates(this.templateBox.boxId, this.templateAudience).then(res => { this.boxTemplates = (res.data && res.data.list) || [] }).catch(() => { this.boxTemplates = [] })
|
||||
},
|
||||
onTemplateRowChange(row) { this.selectedTemplateRow = row },
|
||||
handleDeleteTemplate(tpl) {
|
||||
this.$confirm('确定删除模板【' + tpl.name + '】吗?', '提示', { type: 'warning' }).then(() => {
|
||||
deleteBoxTemplate(tpl.id).then(() => { this.selectedTemplateRow = null; this.$message.success('已删除'); this._loadTemplates() })
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleApplyTemplate() {
|
||||
if (!this.selectedTemplateRow) return
|
||||
const tpl = this.selectedTemplateRow
|
||||
this.$confirm('将模板【' + tpl.name + '】应用到身份【' + this.audienceLabel(this.templateAudience) + '】,并设为抽干补桶依据,确认?', '应用模板', { type: 'warning' }).then(() => {
|
||||
applyBoxTemplate({ templateId: tpl.id }).then(() => {
|
||||
this.$message.success('模板已应用,桶分布已更新')
|
||||
this.templateDialogVisible = false
|
||||
if (this.bucketVisible && this.currentBox.boxId === this.templateBox.boxId && this.audience === this.templateAudience) this._refreshBucketList()
|
||||
this.loadData()
|
||||
}).catch(e => this.$message.error('应用失败: ' + (e.message || e)))
|
||||
}).catch(() => {})
|
||||
},
|
||||
audienceLabel(key) { const a = this.audiences.find(x => x.key === key); return a ? a.name : key },
|
||||
// 按当前身份筛选取值(后端一次返回4身份)
|
||||
targetRtp(row) {
|
||||
const m = row.targetRtpByAudience
|
||||
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : null
|
||||
},
|
||||
actualRtp(row) {
|
||||
const m = row.actualRtpByAudience
|
||||
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : null
|
||||
},
|
||||
// 当前身份一轮总回合(身份级独立)
|
||||
totalRoundsOf(row) {
|
||||
const m = row.totalRoundsByAudience
|
||||
return m && m[this.audienceFilter] != null ? m[this.audienceFilter] : (row.totalRounds != null ? row.totalRounds : null)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
||||
16 档倍率桶(0x~500x)的定义与落桶区间。单局封顶 500x,所以最高一档的上限就是 500。
|
||||
分布由 Python 预生成落库,此处仅供参考查看。
|
||||
</el-alert>
|
||||
<el-table v-loading="loading" :data="list" border>
|
||||
<el-table-column prop="id" label="桶ID" width="80" align="center" />
|
||||
<el-table-column label="名称" min-width="140" align="center">
|
||||
<template slot-scope="scope">{{ (scope.row.name || '').replace('x', '倍桶') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expectMul" label="期望倍率" min-width="120" align="center" />
|
||||
<el-table-column prop="rangeLow" label="落桶下限" min-width="120" align="center" />
|
||||
<el-table-column prop="rangeHigh" label="落桶上限" min-width="120" align="center" />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getBucketList } from '@/api/game/mahjong/bucket/api'
|
||||
|
||||
export default {
|
||||
name: 'MahjongBucket',
|
||||
data() { return { loading: false, list: [] } },
|
||||
created() {
|
||||
this.loading = true
|
||||
getBucketList().then(res => {
|
||||
this.list = (res.data && res.data.list) || []
|
||||
this.loading = false
|
||||
}).catch(() => { this.loading = false })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-row :gutter="16" style="margin-bottom:16px">
|
||||
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>累计投注</small><h3>{{ summary.totalCost || 0 }}</h3></div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>累计赢奖</small><h3>{{ summary.totalWin || 0 }}</h3></div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>整体RTP</small><h3 :style="{color: rtp > 100 ? '#a4485a' : '#457450'}">{{ rtp }}%</h3></div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="hover"><div class="stat"><small>总局数</small><h3>{{ summary.totalSpins || 0 }}</h3></div></el-card></el-col>
|
||||
</el-row>
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<el-date-picker v-model="queryParams.dateStart" type="date" placeholder="开始日期" value-format="yyyy-MM-dd" style="width:150px;margin-right:8px" />
|
||||
<span style="margin:0 4px">至</span>
|
||||
<el-date-picker v-model="queryParams.dateEnd" type="date" placeholder="结束日期" value-format="yyyy-MM-dd" style="width:150px;margin-right:8px" />
|
||||
<el-button type="primary" icon="el-icon-search" @click="loadAll">查询</el-button>
|
||||
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border>
|
||||
<el-table-column prop="date" label="日期" min-width="120" align="center" />
|
||||
<el-table-column prop="totalCost" label="总投注" min-width="120" align="center" />
|
||||
<el-table-column prop="totalWin" label="总赢奖" min-width="120" align="center" />
|
||||
<el-table-column prop="totalSpins" label="总局数" min-width="90" align="center" />
|
||||
<el-table-column prop="playerCount" label="玩家数" min-width="90" align="center" />
|
||||
<el-table-column label="RTP" min-width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span :style="{color: scope.row.totalCost > 0 && scope.row.totalWin / scope.row.totalCost > 1 ? '#a4485a' : '#457450'}">
|
||||
{{ scope.row.totalCost > 0 ? (Math.round(scope.row.totalWin / scope.row.totalCost * 10000) / 100).toFixed(2) + '%' : '-' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top:16px;text-align:right"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-size.sync="queryParams.pageSize"
|
||||
:current-page.sync="queryParams.pageNum"
|
||||
:page-sizes="[10,20,50]"
|
||||
@size-change="loadData"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getWinList, getWinSummary } from '@/api/game/mahjong/dailywin/api'
|
||||
|
||||
export default {
|
||||
name: 'MahjongDailyWin',
|
||||
data() {
|
||||
return {
|
||||
loading: false, list: [], total: 0, summary: {},
|
||||
queryParams: { pageNum: 1, pageSize: 10, dateStart: new Date().toISOString().slice(0, 10), dateEnd: null }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
rtp() {
|
||||
const c = Number(this.summary.totalCost) || 0
|
||||
const w = Number(this.summary.totalWin) || 0
|
||||
return c > 0 ? (Math.round(w / c * 10000) / 100).toFixed(2) : '0.00'
|
||||
}
|
||||
},
|
||||
created() { this.loadAll() },
|
||||
methods: {
|
||||
loadAll() { this.loadSummary(); this.loadData() },
|
||||
loadSummary() {
|
||||
getWinSummary({ dateStart: this.queryParams.dateStart, dateEnd: this.queryParams.dateEnd }).then(res => {
|
||||
this.summary = res.data || {}
|
||||
})
|
||||
},
|
||||
loadData() {
|
||||
this.loading = true
|
||||
getWinList(this.queryParams).then(res => {
|
||||
this.list = (res.data && res.data.rows) || []
|
||||
this.total = (res.data && res.data.total) || 0
|
||||
this.loading = false
|
||||
}).catch(() => { this.loading = false })
|
||||
},
|
||||
handleReset() {
|
||||
this.queryParams = { pageNum: 1, pageSize: 10, dateStart: new Date().toISOString().slice(0, 10), dateEnd: null }
|
||||
this.loadAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat { text-align: center; }
|
||||
.stat small { color: #7a664e; }
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px">
|
||||
返奖饰品按"结算赢额"匹配返奖区间展示。区间需连续覆盖;未命中时用兜底饰品。
|
||||
保存时会同步写入 <b>game_mahjong_prize_range</b>(按 prizeId 唯一)。
|
||||
</el-alert>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border>
|
||||
<el-table-column prop="id" label="ID" width="60" align="center" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" align="center" show-overflow-tooltip />
|
||||
<el-table-column label="品质" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.grade" size="small" :type="gradeType(scope.row.grade)" :style="gradeStyle(scope.row.grade)">{{ gradeLabel(scope.row.grade) }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="饰品图片" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-image v-if="scope.row.imageUrl" :src="getImg(scope.row.imageUrl)" style="width:40px;height:40px" fit="cover" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="品质图片" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-image v-if="scope.row.levelImg" :src="getImg(scope.row.levelImg)" style="width:40px;height:40px" fit="contain" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="磨损" width="110" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.exteriorName || '崭新出厂' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="兜底" width="70" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.isFallback === 1" type="danger" size="small">是</el-tag>
|
||||
<el-tag v-else type="info" size="small">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="返奖区间" min-width="160" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.winLow != null">{{ scope.row.winLow }} ~ {{ scope.row.winHigh }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" width="70" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.sort }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog
|
||||
title="编辑奖品"
|
||||
:visible.sync="dialogVisible"
|
||||
width="480px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form ref="form" :model="form" label-width="80px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="品质">
|
||||
<el-select v-model="form.grade" style="width:100%">
|
||||
<el-option label="1 - 金" :value="1" />
|
||||
<el-option label="2 - 红" :value="2" />
|
||||
<el-option label="3 - 紫" :value="3" />
|
||||
<el-option label="4 - 蓝" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="饰品图片">
|
||||
<ImageUpload v-model="form.imageUrl" :limit="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="磨损">
|
||||
<el-select v-model="form.exteriorName" placeholder="请选择磨损度" clearable style="width:100%">
|
||||
<el-option label="崭新出厂" value="崭新出厂" />
|
||||
<el-option label="略有磨损" value="略有磨损" />
|
||||
<el-option label="久经沙场" value="久经沙场" />
|
||||
<el-option label="破损不堪" value="破损不堪" />
|
||||
<el-option label="战痕累累" value="战痕累累" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="兜底饰品">
|
||||
<el-switch v-model="form.isFallback" :active-value="1" :inactive-value="0" active-text="是" inactive-text="否" />
|
||||
<div style="color:#7a664e;font-size:12px;margin-top:4px">未命中区间时随机选择一个兜底饰品返奖</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="返奖区间">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<el-input-number v-model="form.winLow" :min="0" :precision="2" :step="0.01" controls-position="right" placeholder="最小值" style="flex:1" />
|
||||
<span>~</span>
|
||||
<el-input-number v-model="form.winHigh" :min="0" :precision="2" :step="0.01" controls-position="right" placeholder="最大值" style="flex:1" />
|
||||
</div>
|
||||
<div style="color:#7a664e;font-size:12px;margin-top:4px">玩家结算赢额落在该区间即获得此饰品(最高到 500x 封顶对应的赢额)</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort" :min="0" :step="1" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { prizeList, prizeSave } from '@/api/game/mahjong/prize/api'
|
||||
import ImageUpload from '@/components/ImageUpload'
|
||||
|
||||
export default {
|
||||
name: 'MahjongPrize',
|
||||
components: { ImageUpload },
|
||||
data() {
|
||||
return { loading: false, list: [], dialogVisible: false, form: {}}
|
||||
},
|
||||
created() { this.getList() },
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true
|
||||
prizeList().then(res => { this.list = (res.data && res.data.list) || []; this.loading = false }).catch(() => { this.loading = false })
|
||||
},
|
||||
getImg(u) { return u ? (u.startsWith('http') ? u : (process.env.VUE_APP_BASE_API || '') + u) : '' },
|
||||
// 品质配色与饰品品质一致:1金/2红/3紫/4蓝
|
||||
gradeType(g) { const m = { 1: 'warning', 2: 'danger', 3: '', 4: '' }; return m[g] === undefined ? 'info' : m[g] },
|
||||
gradeStyle(g) { return g === 3 ? { color: '#a16ee8', background: '#f7f1fe', borderColor: '#e4d5f9' } : null },
|
||||
gradeLabel(g) { return ({ 1: '金', 2: '红', 3: '紫', 4: '蓝' })[g] || ('品质' + g) },
|
||||
handleEdit(row) { this.form = { ...row }; this.dialogVisible = true },
|
||||
submitForm() {
|
||||
if (!this.form.name || !this.form.name.trim()) return this.$message.warning('名称不能为空')
|
||||
prizeSave(this.form).then(() => { this.$message.success('保存成功'); this.dialogVisible = false; this.getList() })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form :inline="true" class="demo-form-inline">
|
||||
<el-form-item label="玩家ID">
|
||||
<el-input v-model="queryParams.userId" placeholder="输入玩家ID" clearable @keyup.enter.native="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户类型">
|
||||
<el-select v-model="queryParams.audience" placeholder="全部" clearable style="width:130px" @change="handleQuery">
|
||||
<el-option v-for="a in audiences" :key="a.key" :label="a.name" :value="a.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
|
||||
<el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="list" border>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="userId" label="玩家ID" width="90" align="center" />
|
||||
<el-table-column label="用户类型" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="audienceTagType(scope.row.audience)" size="small">{{ audienceLabel(scope.row.audience) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="boxId" label="箱子" width="70" align="center" />
|
||||
<el-table-column prop="prizeName" label="饰品名称" min-width="150" align="center" show-overflow-tooltip />
|
||||
<el-table-column label="饰品图片" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-image v-if="scope.row.prizeImageUrl" :src="getImg(scope.row.prizeImageUrl)" style="width:40px;height:40px" fit="cover" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cost" label="花费" width="90" align="center" />
|
||||
<el-table-column prop="settledWin" label="结算赢额" width="100" align="center" />
|
||||
<el-table-column label="播放状态" width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tooltip :content="settleTips[scope.row.settleSource] || '历史值'" placement="top">
|
||||
<el-tag :type="scope.row.settleSource === 'start' ? 'warning' : 'success'" size="small">
|
||||
{{ settleLabels[scope.row.settleSource] || scope.row.settleSource || '—' }}
|
||||
</el-tag>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="时间" width="160" align="center" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top:16px;text-align:right"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-size.sync="queryParams.pageSize"
|
||||
:current-page.sync="queryParams.pageNum"
|
||||
:page-sizes="[10,20,50]"
|
||||
@size-change="loadData"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getRecordList } from '@/api/game/mahjong/record/api'
|
||||
|
||||
export default {
|
||||
name: 'MahjongRecord',
|
||||
data() {
|
||||
return {
|
||||
loading: false, list: [], total: 0,
|
||||
queryParams: { pageNum: 1, pageSize: 10, userId: null, audience: null },
|
||||
// 身份 4 种:白号独立库存、不进真实经济
|
||||
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
|
||||
// 播放状态:start=开局即结算(奖已入背包)但动画没播完;与"结算赢额"不矛盾
|
||||
settleLabels: { start: '未播完', finish: '已播完', reconnect: '重连补结', scheduled: '定时兜底', force_settle: '强制结算' },
|
||||
settleTips: {
|
||||
start: '开局就已结算:钱和饰品当场定下并直接进背包(防断网丢奖)。只是整局动画没播到末帧(玩家中途退出/断网),客户端战绩暂不展示,超 30 分钟兜底放行',
|
||||
finish: '整局已播到末帧,客户端战绩正常展示(站内信也是此刻才发)'
|
||||
}
|
||||
}
|
||||
},
|
||||
created() { this.loadData() },
|
||||
methods: {
|
||||
audienceLabel(key) {
|
||||
const a = this.audiences.find(x => x.key === key)
|
||||
return a ? a.name : (key || '—')
|
||||
},
|
||||
audienceTagType(key) {
|
||||
return { common: '', anchor: 'success', white: 'warning' }[key] || 'info'
|
||||
},
|
||||
loadData() {
|
||||
this.loading = true
|
||||
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
|
||||
if (this.queryParams.userId) params.userId = this.queryParams.userId
|
||||
if (this.queryParams.audience) params.audience = this.queryParams.audience
|
||||
getRecordList(params).then(res => {
|
||||
this.list = (res.data && res.data.rows) || []
|
||||
this.total = (res.data && res.data.total) || 0
|
||||
this.loading = false
|
||||
}).catch(() => { this.loading = false })
|
||||
},
|
||||
handleQuery() { this.queryParams.pageNum = 1; this.loadData() },
|
||||
resetQuery() { this.queryParams = { pageNum: 1, pageSize: 10, userId: null, audience: null }; this.loadData() },
|
||||
getImg(u) { return u ? (u.startsWith('http') ? u : (process.env.VUE_APP_BASE_API || '') + u) : '' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<div class="app-container reward-pool-page">
|
||||
<div class="page-head">
|
||||
<div class="page-head-row">
|
||||
<el-button type="text" size="mini" :icon="showGuide ? 'el-icon-arrow-up' : 'el-icon-question'" @click="showGuide = !showGuide">
|
||||
{{ showGuide ? '收起说明' : '配置说明' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="head-tip">
|
||||
奖池按【箱子 × 身份】独立:每箱每身份一套水位 = 该身份箱内下注 − 箱内返奖 + 该箱该身份人工调整;开局按「(本箱该身份水位 + 本局花费) × 目标RTP」算赔付上限,只有预期赔付不超上限的回合才可被抽中。白号独立库存,水位仅统计与人工调整,不受上限约束(纯概率),不进真实经济。
|
||||
</p>
|
||||
<div class="audience-row">
|
||||
<span class="aud-label">身份:</span>
|
||||
<el-radio-group v-model="audience" size="small" @change="switchAudience">
|
||||
<el-radio-button v-for="a in audiences" :key="a.key" :label="a.key">{{ a.name }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="aud-tip">三个身份(普通/主播/白号)各自独立水位、独立注水/抽水、独立调整历史。</span>
|
||||
</div>
|
||||
<div v-show="showGuide" class="guide-panel">
|
||||
<div class="guide-col">
|
||||
<h5><i class="el-icon-info" /> 机制说明</h5>
|
||||
<ul>
|
||||
<li>每箱每身份独立水位 = 该身份箱内累计下注 − 累计返奖 + 人工调整;三个身份互不串池。</li>
|
||||
<li>白号(audience=white)水位独立统计,但按设计不受水位约束,白号出奖=配比即爆率,注水/抽水只影响记录。</li>
|
||||
<li>抽回合两阶段:固定阶段按分布抽取(确定性);轮末收敛阶段按实时RTP偏差定向选桶纠偏。</li>
|
||||
<li>水位随下注升高、随返奖降低;顶部总池为当前身份全站汇总,仅展示不可调。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="guide-col">
|
||||
<h5><i class="el-icon-setting" /> 如何配置</h5>
|
||||
<ul>
|
||||
<li>新箱上线或水位偏低:建议批量注水一轮花费的 60%~80%(接近目标RTP),相当于提前垫付整轮返奖预算,保证高倍桶全程可正常抽出。</li>
|
||||
<li>日常运营小幅补水:比例 5%~20% 即可;某箱长期不出高倍时检查该箱水位,接近 0 或为负则单箱注水。</li>
|
||||
<li>返奖偏高想收紧:批量或单箱抽水;水位为负时只会出最低倍,请谨慎大额抽水。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="guide-col">
|
||||
<h5><i class="el-icon-s-operation" /> 批量调整规则</h5>
|
||||
<ul>
|
||||
<li>基数 = 该箱抽干一轮的总花费 = 赌注 × 总回合数。<b>本游戏无投注倍数</b>(price ≡ 赌注)。</li>
|
||||
<li>每箱调整金额 = 基数 × 比例。例:赌注 1、1000 回合 → 基数 1000,注水 10% = +100。</li>
|
||||
<li>比例支持 0~1000%;基数为 0 的箱子自动跳过;批量操作记一条汇总历史可审计(调整原因必填,会写进调整历史)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="guide-col">
|
||||
<h5><i class="el-icon-data-line" /> 两阶段RTP</h5>
|
||||
<ul>
|
||||
<li>本轮实时RTP / 已抽数反映当前轮收敛进度;一轮抽满 N 回合后自动重置账本开新轮。</li>
|
||||
<li>收敛占比越高,轮末纠偏能力越强;本游戏只有普通旋转,单局封顶 500x。</li>
|
||||
<li>总回合数 N 与收敛占比在「箱子配置 → 编辑」中配置。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="stat-row">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="sc-icon sc-blue"><i class="el-icon-coin" /></div>
|
||||
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总下注额</small><h3>{{ fmt(pool.totalBet) }}</h3></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="sc-icon sc-orange"><i class="el-icon-present" /></div>
|
||||
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总返奖额</small><h3>{{ fmt(pool.totalWin) }}</h3></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="sc-icon sc-cyan"><i class="el-icon-wallet" /></div>
|
||||
<div class="sc-body"><small>{{ audienceLabel(audience) }}·总池余额</small><h3 :style="{color: Number(pool.balance) < 0 ? '#a4485a' : '#9a5c35'}">{{ fmt(pool.balance) }}</h3></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="sc-icon sc-green"><i class="el-icon-data-line" /></div>
|
||||
<div class="sc-body"><small>{{ audienceLabel(audience) }}·实时RTP</small><h3 :style="{color: rtpPct(pool.rtp) > 100 ? '#a4485a' : '#457450'}">{{ rtpPct(pool.rtp).toFixed(1) }}%</h3></div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" class="batch-card">
|
||||
<div slot="header">
|
||||
<b>批量调整(按一轮花费比例)</b>
|
||||
<span class="sub-tip">身份【{{ audienceLabel(audience) }}】:基数 = 赌注 × 总回合数(随箱子配置动态变化,本游戏无投注倍数)</span>
|
||||
</div>
|
||||
<el-form :inline="true" @submit.native.prevent>
|
||||
<!-- 注:skin.scss 全局隐藏了 inline 表单的 label,所以字段名写成 span,别用 label 属性 -->
|
||||
<el-form-item>
|
||||
<span class="fld-label">调整比例</span>
|
||||
<el-input-number v-model="batchForm.ratio" :min="0.01" :max="1000" :precision="2" :step="5" :controls="false" style="width:120px" placeholder="如 10" />
|
||||
<span style="margin-left:4px">%</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<span class="fld-label">调整原因<span class="req">*</span></span>
|
||||
<el-input v-model="batchForm.remark" maxlength="80" placeholder="必填,如:活动补水" style="width:200px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popconfirm :title="'按【' + audienceLabel(audience) + '】各箱一轮花费 ' + (batchForm.ratio || 0) + '% 注水,预估共 +' + fmt(estBatchTotal) + ',确定?'" @confirm="submitBatch(1)">
|
||||
<el-button slot="reference" type="success" plain icon="el-icon-top" :loading="batchLoading" :disabled="!batchForm.ratio">批量注水</el-button>
|
||||
</el-popconfirm>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popconfirm :title="'按【' + audienceLabel(audience) + '】各箱一轮花费 ' + (batchForm.ratio || 0) + '% 抽水,预估共 -' + fmt(estBatchTotal) + ',确定?'" @confirm="submitBatch(2)">
|
||||
<el-button slot="reference" type="danger" plain icon="el-icon-bottom" :loading="batchLoading" :disabled="!batchForm.ratio">批量抽水</el-button>
|
||||
</el-popconfirm>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-popconfirm :title="'重置将把身份【' + audienceLabel(audience) + '】所有箱子的注水/抽水累计值归 0,只保留真实水位(下注−返奖),确定?'" @confirm="submitReset">
|
||||
<el-button slot="reference" type="warning" plain icon="el-icon-refresh-left" :loading="resetLoading">重置调整</el-button>
|
||||
</el-popconfirm>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="batchForm.ratio">
|
||||
<span class="est-text">预估总额:{{ fmt(estBatchTotal) }}({{ boxes.length }} 个箱子)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="section-head">
|
||||
<h4 style="margin:0">箱子奖池 · 身份【{{ audienceLabel(audience) }}】</h4>
|
||||
<el-button size="mini" type="text" icon="el-icon-refresh" @click="loadConfig">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="boxLoading" :data="pagedBoxes" size="small" border stripe>
|
||||
<el-table-column prop="boxId" label="ID" width="60" align="center" />
|
||||
<el-table-column prop="name" label="箱子" min-width="140" align="center" show-overflow-tooltip />
|
||||
<el-table-column label="赌注" width="80" align="center">
|
||||
<template slot-scope="scope">{{ fmt(scope.row.bet) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="一轮花费(基数)" width="130" align="center">
|
||||
<template slot-scope="scope">
|
||||
<div>{{ fmt(scope.row.cycleCost) }}</div>
|
||||
<div style="font-size:11px;color:#7a664e">{{ fmt(scope.row.costPerRound) }} × {{ scope.row.cycleRounds || '—' }}回合</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="箱内下注" min-width="100" align="center">
|
||||
<template slot-scope="scope">{{ fmt(scope.row.totalBet) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="箱内返奖" min-width="100" align="center">
|
||||
<template slot-scope="scope">{{ fmt(scope.row.totalWin) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="人工调整" min-width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span :style="{color: signColor(scope.row.adjust)}">{{ fmt(scope.row.adjust) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="水位余额" min-width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<b :style="{color: Number(scope.row.balance) < 0 ? '#a4485a' : '#9a5c35'}">{{ fmt(scope.row.balance) }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="累计RTP" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span :style="{color: rtpPct(scope.row.rtp) > 100 ? '#a4485a' : '#457450'}">{{ scope.row.rtp == null ? '—' : rtpPct(scope.row.rtp).toFixed(1) + '%' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本轮RTP/进度" min-width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<div :style="{color: rtpPct(scope.row.cycleRtp) > 100 ? '#a4485a' : '#457450'}">{{ scope.row.cycleRtp == null ? '—' : rtpPct(scope.row.cycleRtp).toFixed(1) + '%' }}</div>
|
||||
<div style="font-size:11px;color:#7a664e">{{ scope.row.cycleDrawn || 0 }} / {{ scope.row.totalRounds || '—' }} 回合</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="收敛占比" width="80" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.convergeRatio == null ? '—' : (Number(scope.row.convergeRatio) * 100).toFixed(0) + '%' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tooltip content="注水(增加水位)" placement="top">
|
||||
<el-button circle size="mini" type="success" plain icon="el-icon-top" @click="openAdjust(scope.row, 1)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="抽水(降低水位)" placement="top">
|
||||
<el-button circle size="mini" type="danger" plain icon="el-icon-bottom" @click="openAdjust(scope.row, 2)" />
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
style="margin-top:12px;text-align:right"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="boxes.length"
|
||||
:page-size.sync="boxSize"
|
||||
:current-page.sync="boxPage"
|
||||
:page-sizes="[10,20,30,50]"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
:title="'调整水位【' + audienceLabel(audience) + '】- ' + (form.boxName || '')"
|
||||
:visible.sync="dialogVisible"
|
||||
width="440px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form label-width="90px" @submit.native.prevent>
|
||||
<el-form-item label="调整类型">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio-button :label="1">注水(增加)</el-radio-button>
|
||||
<el-radio-button :label="2">抽水(减少)</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前水位">
|
||||
<b :style="{color: Number(form.balance) < 0 ? '#a4485a' : '#9a5c35'}">{{ fmt(form.balance) }}</b>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整金额">
|
||||
<el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="100" :controls="false" style="width:100%" placeholder="请输入金额" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.amount" label="调整后水位">
|
||||
<b :style="{color: afterBalance < 0 ? '#a4485a' : '#457450'}">{{ fmt(afterBalance) }}</b>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整原因" required>
|
||||
<el-input v-model="form.remark" maxlength="100" placeholder="必填,如:活动补水" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :type="form.type === 1 ? 'success' : 'danger'" :loading="submitting" @click="submit">确定{{ form.type === 1 ? '注水' : '抽水' }}</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<div class="section-head">
|
||||
<h4 style="margin:0">调整历史 · 身份【{{ audienceLabel(audience) }}】</h4>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="list" border size="small">
|
||||
<el-table-column prop="createTime" label="时间" min-width="150" align="center" />
|
||||
<el-table-column label="身份" width="80" align="center">
|
||||
<template slot-scope="scope"><el-tag size="mini">{{ audienceLabel(scope.row.audience) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="boxName" label="箱子" min-width="130" align="center" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.type === 1 ? 'success' : (scope.row.type === 2 ? 'danger' : 'warning')" size="mini">
|
||||
{{ scope.row.type === 1 ? '注水' : (scope.row.type === 2 ? '抽水' : '重置') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" min-width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span :style="{color: scope.row.type === 1 ? '#457450' : (scope.row.type === 2 ? '#a4485a' : '#8f6418')}">
|
||||
{{ scope.row.type === 1 ? '+' : (scope.row.type === 2 ? '-' : '') }}{{ fmt(scope.row.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调整前余额" min-width="110" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.balanceBefore == null ? '—' : fmt(scope.row.balanceBefore) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调整后余额" min-width="110" align="center">
|
||||
<template slot-scope="scope">{{ scope.row.balanceAfter == null ? '—' : fmt(scope.row.balanceAfter) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总池余额" min-width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<b :style="{color: Number(scope.row.poolBalance) < 0 ? '#a4485a' : '#9a5c35'}">{{ scope.row.poolBalance == null ? '—' : fmt(scope.row.poolBalance) }}</b>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator" label="操作人" min-width="90" align="center" />
|
||||
<el-table-column label="备注" min-width="160" align="center" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span :style="{color: scope.row.remark ? '#4f4130' : '#8b755a'}">{{ scope.row.remark || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top:16px;text-align:right"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-size.sync="queryParams.size"
|
||||
:current-page.sync="queryParams.page"
|
||||
:page-sizes="[10,20,50]"
|
||||
@size-change="loadLogs"
|
||||
@current-change="loadLogs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getPoolConfig, adjustPool, adjustPoolBatch, adjustPoolReset, getPoolLogs } from '@/api/game/mahjong/rewardPool/api'
|
||||
|
||||
export default {
|
||||
name: 'MahjongRewardPool',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
boxLoading: false,
|
||||
submitting: false,
|
||||
batchLoading: false,
|
||||
resetLoading: false,
|
||||
dialogVisible: false,
|
||||
showGuide: false,
|
||||
audience: 'common',
|
||||
audiences: [{ key: 'common', name: '普通' }, { key: 'anchor', name: '主播' }, { key: 'white', name: '白号' }],
|
||||
pool: { totalBet: 0, totalWin: 0, adjust: 0, balance: 0, rtp: 0 },
|
||||
boxes: [],
|
||||
list: [],
|
||||
total: 0,
|
||||
boxPage: 1,
|
||||
boxSize: 10,
|
||||
form: { boxId: undefined, boxName: '', balance: 0, type: 1, amount: undefined, remark: '' },
|
||||
batchForm: { ratio: undefined, remark: '' },
|
||||
queryParams: { page: 1, size: 10, audience: 'common' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pagedBoxes() {
|
||||
const start = (this.boxPage - 1) * this.boxSize
|
||||
return this.boxes.slice(start, start + this.boxSize)
|
||||
},
|
||||
estBatchTotal() {
|
||||
const r = Number(this.batchForm.ratio) || 0
|
||||
if (r <= 0) return 0
|
||||
return this.boxes.reduce((s, b) => s + (Number(b.cycleCost) > 0 ? Number(b.cycleCost) * r / 100 : 0), 0)
|
||||
},
|
||||
afterBalance() {
|
||||
const amt = Number(this.form.amount) || 0
|
||||
return Number(this.form.balance) + (this.form.type === 2 ? -amt : amt)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.loadConfig()
|
||||
this.loadLogs()
|
||||
},
|
||||
methods: {
|
||||
fmt(v) {
|
||||
const n = Number(v)
|
||||
return isNaN(n) ? '0.00' : n.toFixed(2)
|
||||
},
|
||||
rtpPct(v) {
|
||||
const n = Number(v)
|
||||
return isNaN(n) ? 0 : n * 100
|
||||
},
|
||||
signColor(v) {
|
||||
const n = Number(v)
|
||||
return n < 0 ? '#a4485a' : (n > 0 ? '#457450' : '#4f4130')
|
||||
},
|
||||
applyConfig(data) {
|
||||
if (!data) return
|
||||
this.pool = data.total || this.pool
|
||||
this.boxes = data.boxes || []
|
||||
},
|
||||
audienceLabel(key) {
|
||||
const a = this.audiences.find(x => x.key === key)
|
||||
return a ? a.name : (key || '普通')
|
||||
},
|
||||
requiredRemark(v) {
|
||||
if (v && v.trim()) return true
|
||||
this.$message.warning('请填写调整原因(备注)')
|
||||
return false
|
||||
},
|
||||
switchAudience() {
|
||||
this.queryParams.audience = this.audience
|
||||
this.queryParams.page = 1
|
||||
this.loadConfig()
|
||||
this.loadLogs()
|
||||
},
|
||||
loadConfig() {
|
||||
this.boxLoading = true
|
||||
getPoolConfig({ audience: this.audience }).then(res => {
|
||||
this.applyConfig(res.data)
|
||||
this.boxLoading = false
|
||||
}).catch(() => { this.boxLoading = false })
|
||||
},
|
||||
loadLogs() {
|
||||
this.loading = true
|
||||
this.queryParams.audience = this.audience
|
||||
getPoolLogs(this.queryParams).then(res => {
|
||||
this.list = (res.data && res.data.rows) || []
|
||||
this.total = (res.data && res.data.total) || 0
|
||||
this.loading = false
|
||||
}).catch(() => { this.loading = false })
|
||||
},
|
||||
openAdjust(row, type) {
|
||||
this.form = { boxId: row.boxId, boxName: row.name, audience: this.audience, balance: row.balance, type, amount: undefined, remark: '' }
|
||||
this.dialogVisible = true
|
||||
},
|
||||
submit() {
|
||||
if (!this.form.amount || this.form.amount <= 0) {
|
||||
this.$message.warning('请输入大于 0 的调整金额')
|
||||
return
|
||||
}
|
||||
if (!this.requiredRemark(this.form.remark)) return
|
||||
this.submitting = true
|
||||
adjustPool({ boxId: this.form.boxId, audience: this.audience, type: this.form.type, amount: this.form.amount, remark: this.form.remark.trim() }).then(res => {
|
||||
this.applyConfig(res.data)
|
||||
this.$message.success((this.form.type === 1 ? '注水' : '抽水') + '成功')
|
||||
this.dialogVisible = false
|
||||
this.queryParams.page = 1
|
||||
this.loadLogs()
|
||||
this.submitting = false
|
||||
}).catch(() => { this.submitting = false })
|
||||
},
|
||||
submitBatch(type) {
|
||||
if (!this.batchForm.ratio || this.batchForm.ratio <= 0) {
|
||||
this.$message.warning('请输入大于 0 的调整比例')
|
||||
return
|
||||
}
|
||||
if (!this.requiredRemark(this.batchForm.remark)) return
|
||||
this.batchLoading = true
|
||||
adjustPoolBatch({ audience: this.audience, type, ratio: this.batchForm.ratio, remark: this.batchForm.remark.trim() }).then(res => {
|
||||
this.applyConfig(res.data)
|
||||
this.$message.success(`批量${type === 1 ? '注水' : '抽水'}完成:${res.data.adjusted} 个箱子,共 ${this.fmt(res.data.totalAmount)}`)
|
||||
this.batchForm.ratio = undefined
|
||||
this.batchForm.remark = ''
|
||||
this.queryParams.page = 1
|
||||
this.loadLogs()
|
||||
this.batchLoading = false
|
||||
}).catch(() => { this.batchLoading = false })
|
||||
},
|
||||
submitReset() {
|
||||
if (!this.requiredRemark(this.batchForm.remark)) return
|
||||
this.resetLoading = true
|
||||
adjustPoolReset({ audience: this.audience, remark: this.batchForm.remark.trim() }).then(res => {
|
||||
this.applyConfig(res.data)
|
||||
this.$message.success(`重置完成:${res.data.cleared} 个箱子的人工调整已归零(清除净调整 ${this.fmt(res.data.totalCleared)})`)
|
||||
this.batchForm.remark = ''
|
||||
this.queryParams.page = 1
|
||||
this.loadLogs()
|
||||
this.resetLoading = false
|
||||
}).catch(() => { this.resetLoading = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head { margin-bottom: 14px; }
|
||||
.page-head-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.head-tip { color: #7a664e; font-size: 12px; margin: 6px 0 0; }
|
||||
.audience-row { display: flex; align-items: center; margin-top: 10px; }
|
||||
.aud-label { color: #5f4f3d; font-size: 13px; margin-right: 6px; }
|
||||
.aud-tip { color: #7a664e; font-size: 12px; margin-left: 10px; }
|
||||
.guide-panel { display: flex; gap: 16px; background: #f6efe3; border: 1px solid #eadfd0; border-radius: 8px; padding: 14px 16px; margin-top: 10px; }
|
||||
.guide-col { flex: 1; min-width: 0; }
|
||||
.guide-col h5 { margin: 0 0 8px; font-size: 13px; color: #4f4130; }
|
||||
.guide-col h5 i { color: #9a5c35; margin-right: 4px; }
|
||||
.guide-col ul { margin: 0; padding-left: 16px; }
|
||||
.guide-col li { font-size: 12px; color: #5f4f3d; line-height: 1.9; }
|
||||
.stat-row { margin-bottom: 16px; }
|
||||
/* 统计卡图标:与侧栏一级菜单共用 cottagecore.scss 的 $cc-icon-hues 4 色
|
||||
(陶土 / 草绿 / 雏菊金 / 玫瑰粉),底一律 $cc-tint-3。
|
||||
原先第 3 个是冷青 #4a7f8c,在暖底上是唯一的冷点。 */
|
||||
.stat-card { display: flex; align-items: center; gap: 12px; background: #fffdfa; border: 1px solid #eadfd0; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(139,115,85,.10); }
|
||||
.sc-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
|
||||
.sc-blue { background: #fbf5ea; color: #9a5c35; }
|
||||
.sc-orange { background: #fbf5ea; color: #a8791f; }
|
||||
.sc-cyan { background: #fbf5ea; color: #b57474; }
|
||||
.sc-green { background: #fbf5ea; color: #457450; }
|
||||
.sc-body small { color: #7a664e; display: block; }
|
||||
.sc-body h3 { margin: 2px 0 0; font-size: 18px; }
|
||||
.batch-card { margin-bottom: 16px; border-radius: 8px; }
|
||||
.batch-card .sub-tip { color: #7a664e; font-size: 12px; margin-left: 10px; }
|
||||
.fld-label { color: #5f4f3d; font-size: 13px; margin-right: 6px; }
|
||||
.fld-label .req { color: #a4485a; margin-left: 2px; }
|
||||
.batch-card >>> .el-form-item { margin-bottom: 0; }
|
||||
.est-text { color: #8f6418; font-size: 13px; }
|
||||
.section-head { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:12px">
|
||||
预生成回合池(只读):池里存的是倍率,与赌注无关。<b>actualMul</b> 为本局实际总倍率(已按 500x 封顶);
|
||||
<b>hasFs</b>=是否自然触发免费旋转;<b>nearMiss</b>=悬念盘(基础旋恰好 2 个胡且第 5 轴无胡)。
|
||||
</el-alert>
|
||||
|
||||
<el-form :inline="true" :model="queryParams" class="demo-form-inline">
|
||||
<el-form-item label="回合ID">
|
||||
<el-input v-model="queryParams.roundId" placeholder="输入回合ID" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="roundList" border style="width:100%">
|
||||
<el-table-column prop="roundId" label="回合ID" width="100" align="center" />
|
||||
<el-table-column prop="boxId" label="箱子ID" width="80" align="center" />
|
||||
<el-table-column prop="bucketId" label="饰品桶" width="80" align="center" />
|
||||
<el-table-column prop="actualMul" label="返奖倍率" width="100" align="center" />
|
||||
<el-table-column prop="freeSpinCount" label="免费旋转" width="90" align="center" />
|
||||
<el-table-column prop="totalRotations" label="总波数" width="90" align="center" />
|
||||
<el-table-column label="含FS" width="70" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.hasFs === 1" type="success" size="small">是</el-tag>
|
||||
<el-tag v-else type="info" size="small">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="悬念盘" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.nearMiss === 1" type="warning" size="small">是</el-tag>
|
||||
<el-tag v-else type="info" size="small">否</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="生成时间" min-width="160" align="center" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top:16px;text-align:right"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:page-size.sync="queryParams.pageSize"
|
||||
:current-page.sync="queryParams.pageNum"
|
||||
:page-sizes="[10,20,50,100]"
|
||||
@size-change="loadData"
|
||||
@current-change="loadData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getRoundList } from '@/api/game/mahjong/round/api'
|
||||
|
||||
export default {
|
||||
name: 'MahjongRound',
|
||||
data() {
|
||||
return {
|
||||
loading: false, roundList: [], total: 0,
|
||||
queryParams: { pageNum: 1, pageSize: 10, roundId: null, boxType: null }
|
||||
}
|
||||
},
|
||||
created() { this.loadData() },
|
||||
methods: {
|
||||
loadData() {
|
||||
this.loading = true
|
||||
const params = { pageNum: this.queryParams.pageNum, pageSize: this.queryParams.pageSize }
|
||||
if (this.queryParams.roundId) params.roundId = this.queryParams.roundId
|
||||
if (this.queryParams.boxType) params.boxType = this.queryParams.boxType
|
||||
getRoundList(params).then(res => {
|
||||
this.roundList = (res.data && res.data.rows) || []
|
||||
this.total = (res.data && res.data.total) || 0
|
||||
this.loading = false
|
||||
}).catch(() => { this.loading = false })
|
||||
},
|
||||
handleQuery() { this.queryParams.pageNum = 1; this.loadData() },
|
||||
resetQuery() { this.queryParams = { pageNum: 1, pageSize: 10, roundId: null, boxType: null }; this.loadData() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -129,70 +129,6 @@ export default {
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .el-switch {
|
||||
border-radius: 0px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
::v-deep .el-switch__core {
|
||||
width: 70px !important;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
}
|
||||
::v-deep .el-switch__core::after {
|
||||
width: 50%;
|
||||
height: 24px;
|
||||
border-radius: 0px;
|
||||
top: 0px !important;
|
||||
right: 0px !important;
|
||||
left: 0px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::v-deep .el-switch.is-checked .el-switch__core::after {
|
||||
margin-left: -35px;
|
||||
box-sizing: border-box;
|
||||
top: 0px !important;
|
||||
right: 0px !important;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
/*关闭时文字位置设置*/
|
||||
::v-deep .el-switch__label--right {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
z-index: 1;
|
||||
right: 6px;
|
||||
margin-left: 0px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
span {
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
/* 激活时另一个文字消失 */
|
||||
::v-deep .el-switch__label.is-active {
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
/*开启时文字位置设置*/
|
||||
::v-deep .el-switch__label--left {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
z-index: 1;
|
||||
left: 5px;
|
||||
margin-right: 0px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
span {
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
#main {
|
||||
width: 500px;
|
||||
height: 400px;
|
||||
|
||||
@@ -216,79 +216,6 @@ export default {
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .el-switch {
|
||||
border-radius: 0px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
::v-deep .el-switch__core {
|
||||
width: 70px !important;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
::v-deep .el-switch__core::after {
|
||||
width: 50%;
|
||||
height: 24px;
|
||||
border-radius: 0px;
|
||||
top: 0px !important;
|
||||
right: 0px !important;
|
||||
left: 0px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::v-deep .el-switch.is-checked .el-switch__core::after {
|
||||
margin-left: -35px;
|
||||
box-sizing: border-box;
|
||||
top: 0px !important;
|
||||
right: 0px !important;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*关闭时文字位置设置*/
|
||||
::v-deep .el-switch__label--right {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
z-index: 1;
|
||||
right: 6px;
|
||||
margin-left: 0px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 激活时另一个文字消失 */
|
||||
::v-deep .el-switch__label.is-active {
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*开启时文字位置设置*/
|
||||
::v-deep .el-switch__label--left {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
z-index: 1;
|
||||
left: 5px;
|
||||
margin-right: 0px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
#main {
|
||||
width: 500px;
|
||||
height: 400px;
|
||||
|
||||
@@ -263,79 +263,6 @@ export default {
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .el-switch {
|
||||
border-radius: 0px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
::v-deep .el-switch__core {
|
||||
width: 70px !important;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
::v-deep .el-switch__core::after {
|
||||
width: 50%;
|
||||
height: 24px;
|
||||
border-radius: 0px;
|
||||
top: 0px !important;
|
||||
right: 0px !important;
|
||||
left: 0px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::v-deep .el-switch.is-checked .el-switch__core::after {
|
||||
margin-left: -35px;
|
||||
box-sizing: border-box;
|
||||
top: 0px !important;
|
||||
right: 0px !important;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*关闭时文字位置设置*/
|
||||
::v-deep .el-switch__label--right {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
z-index: 1;
|
||||
right: 6px;
|
||||
margin-left: 0px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 激活时另一个文字消失 */
|
||||
::v-deep .el-switch__label.is-active {
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*开启时文字位置设置*/
|
||||
::v-deep .el-switch__label--left {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
z-index: 1;
|
||||
left: 5px;
|
||||
margin-right: 0px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
#main {
|
||||
width: 500px;
|
||||
height: 400px;
|
||||
|
||||
@@ -369,70 +369,6 @@ export default {
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .el-switch {
|
||||
border-radius: 0px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
::v-deep .el-switch__core {
|
||||
width: 70px !important;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
}
|
||||
::v-deep .el-switch__core::after {
|
||||
width: 50%;
|
||||
height: 24px;
|
||||
border-radius: 0px;
|
||||
top: 0px !important;
|
||||
right: 0px !important;
|
||||
left: 0px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::v-deep .el-switch.is-checked .el-switch__core::after {
|
||||
margin-left: -35px;
|
||||
box-sizing: border-box;
|
||||
top: 0px !important;
|
||||
right: 0px !important;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
}
|
||||
/*关闭时文字位置设置*/
|
||||
::v-deep .el-switch__label--right {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
z-index: 1;
|
||||
right: 6px;
|
||||
margin-left: 0px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
span {
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
/* 激活时另一个文字消失 */
|
||||
::v-deep .el-switch__label.is-active {
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
/*开启时文字位置设置*/
|
||||
::v-deep .el-switch__label--left {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
z-index: 1;
|
||||
left: 5px;
|
||||
margin-right: 0px;
|
||||
color: rgba(255, 255, 255, 1);
|
||||
span {
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
#main {
|
||||
width: 500px;
|
||||
height: 400px;
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
<div style="margin-bottom: 6px;">
|
||||
<strong>单局奖池投放比例 (bufferRatio = {{ config.bufferRatio }})</strong>:一局最多从奖池拿出 <span style="color: #8f6418;">{{ bufferRatioPct }} 的余额</span> 来发奖
|
||||
</div>
|
||||
<div style="padding-left: 16px; color: #606266; font-size: 12px; margin-bottom: 8px;">
|
||||
<div style="padding-left: 16px; color: #5f4f3d; font-size: 12px; margin-bottom: 8px;">
|
||||
• 例:奖池余额 10000 元 → 本项上限 10000 × {{ config.bufferRatio }} = <strong>{{ poolCapDemo }} 元</strong><br>
|
||||
• 奖池 = 累计真实下注 − 累计真实返奖(机器人、白号不计)<br>
|
||||
• 口径提醒:这里填的是"最多拿出多少",不是"保留多少"——{{ config.bufferRatio }} = 最多拿出 {{ bufferRatioPct }},剩下 {{ bufferKeepPct }} 留在池里
|
||||
@@ -214,7 +214,7 @@
|
||||
<div style="margin-bottom: 6px;">
|
||||
<strong>目标RTP (rtp = {{ config.rtp }},累计口径)</strong>:按"总共该发多少 − 已经发了多少"算出<span style="color: #8f6418;">还能发多少</span>,这就是本局的返奖预算(是发奖上限,不是平台欠玩家钱)
|
||||
</div>
|
||||
<div style="padding-left: 16px; color: #606266; font-size: 12px; margin-bottom: 8px;">
|
||||
<div style="padding-left: 16px; color: #5f4f3d; font-size: 12px; margin-bottom: 8px;">
|
||||
• 本局可发额度 = 累计真实投注 × {{ rtpPct }} − 累计真实返奖;<br>
|
||||
• 例:累计投注 10 万、累计返奖 7 万 → 该发 {{ rtpDemo.should }} 万、已发 7 万 → 本局额度 <strong>{{ rtpDemo.room }} 万</strong><br>
|
||||
• 它只是"这局最多能发多少":实际发多少仍由转盘概率决定,没发完的额度继续攒到以后(所以池子厚了能一局爆大奖)。本局投注在封盘时已计入累计,历史刚好发到 {{ rtpPct }} 时,额度正好 = 本局投注 × {{ rtpPct }}<br>
|
||||
@@ -222,8 +222,8 @@
|
||||
• 实际返奖率请以首页「实时RTP」为准,不要拿本配置值当预期
|
||||
</div>
|
||||
|
||||
<div style="padding: 8px; background: #F4F4F5; border-radius: 4px; margin-top: 8px;">
|
||||
<strong style="color: #303133;">协同机制</strong>:发奖上限 = <span style="color: #9a5c35;">min(奖池余额×{{ config.bufferRatio }}, 本局可发额度)</span>,两条都要满足<br>
|
||||
<div style="padding: 8px; background: #f3ece0; border-radius: 4px; margin-top: 8px;">
|
||||
<strong style="color: #4f4130;">协同机制</strong>:发奖上限 = <span style="color: #9a5c35;">min(奖池余额×{{ config.bufferRatio }}, 本局可发额度)</span>,两条都要满足<br>
|
||||
<span style="color: #7a664e; font-size: 12px;">
|
||||
• 谁小听谁的:池子不够厚 → 按奖池的 {{ bufferRatioPct }} 卡;池子够厚 → 按返奖额度卡<br>
|
||||
• 额度够时,单局返奖可以远超本局投注("池子厚就能爆大奖")<br>
|
||||
@@ -353,6 +353,8 @@ export default {
|
||||
},
|
||||
syncMultiplierTable() {
|
||||
const multipliers = this.config.multipliers || [];
|
||||
// 这里的 color 必须与后端契约一致(gold/red/purple),也是 cellCounts() 的 key:
|
||||
// 写成颜色值(如 #a4485a)会同时导致标签显示成色号、红色那一行算不到盘面格数(整行倍率被跳过)
|
||||
const colors = ['gold', 'red', 'purple'];
|
||||
const symbols = ['A', 'B', 'C', 'D'];
|
||||
this.multiplierTable = colors.map(color => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="倍率预览">
|
||||
<template slot-scope="scope">
|
||||
<div v-for="color in ['gold', 'red', 'purple']" :key="color" style="display: inline-block; margin-right: 16px;">
|
||||
<div v-for="color in ['gold', '#a4485a', 'purple']" :key="color" style="display: inline-block; margin-right: 16px;">
|
||||
<el-tag :type="colorTagType(color)" size="mini">{{ colorLabel(color) }}</el-tag>
|
||||
<span v-for="sym in ['A', 'B', 'C', 'D']" :key="sym" style="margin-left: 6px; font-size: 12px;">
|
||||
{{ sym }}:<b>{{ getMultiplier(scope.row, color, sym) }}</b>
|
||||
@@ -124,7 +124,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
matrixByColor() {
|
||||
const colors = ['gold', 'red', 'purple'];
|
||||
const colors = ['gold', '#a4485a', 'purple'];
|
||||
return colors.map(color => ({
|
||||
color,
|
||||
items: this.currentMatrixList.filter(i => i.color === color)
|
||||
@@ -156,7 +156,7 @@ export default {
|
||||
openConfig(row) {
|
||||
this.currentBetAmount = row.betAmount;
|
||||
// 确保12条记录存在(即使数据库为空也生成骨架)
|
||||
const colors = ['gold', 'red', 'purple'];
|
||||
const colors = ['gold', '#a4485a', 'purple'];
|
||||
const symbols = ['A', 'B', 'C', 'D'];
|
||||
const list = [];
|
||||
colors.forEach(color => {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<!-- 回合结果优化展示 -->
|
||||
<div style="margin-bottom: 12px; line-height: 2;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<strong style="color: #303133;">回合结果:</strong>
|
||||
<strong style="color: #4f4130;">回合结果:</strong>
|
||||
<el-tag v-if="scope.row.result != null" type="primary" size="medium" style="font-size: 16px; font-weight: bold;">
|
||||
{{ scope.row.result }}
|
||||
</el-tag>
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
<!-- 外圈符号序列(高亮第result格) -->
|
||||
<div style="margin-bottom: 6px;">
|
||||
<strong style="color: #606266;">外圈符号:</strong>
|
||||
<strong style="color: #5f4f3d;">外圈符号:</strong>
|
||||
<span v-if="scope.row.symbols" style="font-family: 'Courier New', monospace; font-size: 14px;">
|
||||
<span
|
||||
v-for="(sym, idx) in scope.row.symbols.split('')"
|
||||
@@ -45,7 +45,7 @@
|
||||
fontWeight: idx == scope.row.result ? 'bold' : 'normal',
|
||||
fontSize: idx == scope.row.result ? '16px' : '14px',
|
||||
color: idx == scope.row.result ? '#8f6418' : '#7a664e',
|
||||
backgroundColor: idx == scope.row.result ? '#FDF6EC' : 'transparent',
|
||||
backgroundColor: idx == scope.row.result ? '#fbf5ea' : 'transparent',
|
||||
padding: idx == scope.row.result ? '3px 0' : '0',
|
||||
borderRadius: '3px',
|
||||
border: idx == scope.row.result ? '2px solid #8f6418' : 'none',
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<!-- 内圈颜色序列(高亮第result格) -->
|
||||
<div>
|
||||
<strong style="color: #606266;">内圈颜色:</strong>
|
||||
<strong style="color: #5f4f3d;">内圈颜色:</strong>
|
||||
<span v-if="scope.row.innerColors" style="font-family: 'Courier New', monospace; font-size: 14px;">
|
||||
<span
|
||||
v-for="(color, idx) in formatInnerColors(scope.row.innerColors).split('')"
|
||||
@@ -67,7 +67,7 @@
|
||||
textAlign: 'center',
|
||||
fontWeight: idx == scope.row.result ? 'bold' : 'normal',
|
||||
fontSize: idx == scope.row.result ? '16px' : '14px',
|
||||
color: idx == scope.row.result ? getHighlightColor(color) : '#C0C4CC',
|
||||
color: idx == scope.row.result ? getHighlightColor(color) : '#8b755a',
|
||||
backgroundColor: idx == scope.row.result ? getHighlightBgColor(color) : 'transparent',
|
||||
padding: idx == scope.row.result ? '3px 0' : '0',
|
||||
borderRadius: '3px',
|
||||
@@ -106,7 +106,10 @@
|
||||
<el-tag v-if="scope.row.poolTypes" :type="poolTagType(scope.row.poolTypes)" size="mini">{{ poolLabel(scope.row.poolTypes) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="betSymbols" label="投注符号" width="140" align="center" />
|
||||
<!-- ★ min-width 而不是 width:展开列(48)+其余 11 列原全为固定宽,合计 1368px,
|
||||
没有弹性列时表格宽度恒等于列宽之和(table-layout.js:182-194),1920 视口下右侧空 216px。
|
||||
投注符号是拼接字符串、长度波动最大,交给它吃剩余宽度最合适。 -->
|
||||
<el-table-column prop="betSymbols" label="投注符号" min-width="140" align="center" />
|
||||
<el-table-column prop="totalBetAmount" label="总下注" width="100" align="right" />
|
||||
<el-table-column prop="userRoundPayout" label="回合实际赔付" width="120" align="right">
|
||||
<template slot-scope="scope">
|
||||
@@ -220,9 +223,10 @@ export default {
|
||||
const map = { '金': '#8f6418', '红': '#a4485a', '紫': '#7a664e' };
|
||||
return map[color] || '#9a5c35';
|
||||
},
|
||||
/** 高亮背景色(用于中奖格背景) */
|
||||
/** 高亮背景色(用于中奖格背景)
|
||||
取值是 cottagecore.scss 的 $cc-tint-3 / $cc-blossom-tint / $cc-linen */
|
||||
getHighlightBgColor(color) {
|
||||
const map = { '金': '#FDF6EC', '红': '#FEF0F0', '紫': '#F4F4F5' };
|
||||
const map = { '金': '#fbf5ea', '红': '#f7e9e6', '紫': '#f3ece0' };
|
||||
return map[color] || '#fbf5ea';
|
||||
}
|
||||
}
|
||||
|
||||
+366
-186
@@ -16,87 +16,133 @@
|
||||
>刷新数据</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片网格 -->
|
||||
<div class="stat-grid">
|
||||
<div
|
||||
v-for="card in statCards"
|
||||
:key="card.key"
|
||||
class="stat-card"
|
||||
:class="{ clickable: !!card.route }"
|
||||
@click="card.route && handto(card.route)"
|
||||
>
|
||||
<div class="stat-icon" :class="'theme-' + card.theme">
|
||||
<i :class="card.icon"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value">{{ card.value }}</div>
|
||||
<div class="stat-label">
|
||||
{{ card.label }}
|
||||
<el-tooltip v-if="card.note" :content="card.note" placement="top">
|
||||
<i class="el-icon-info stat-note-icon"></i>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<i v-if="card.route" class="el-icon-arrow-right stat-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 主体:左窄轨 + 右侧主区 -->
|
||||
<div class="home-body">
|
||||
<!-- 左窄轨:核心指标 + 待办 -->
|
||||
<div class="rail">
|
||||
<div class="rail-title">核心指标</div>
|
||||
|
||||
<!-- 实时 RTP -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<span class="title-bar"></span>
|
||||
<span>实时 RTP</span>
|
||||
</div>
|
||||
<span class="section-sub">每 30 秒自动刷新</span>
|
||||
</div>
|
||||
<div class="rtp-grid">
|
||||
<div
|
||||
v-for="(item, idx) in visibleRtpList"
|
||||
:key="item.name || idx"
|
||||
class="rtp-card"
|
||||
:class="'rtp-theme-' + (idx % 5)"
|
||||
>
|
||||
<div class="rtp-name">
|
||||
<span class="rtp-dot"></span>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="rtp-value">
|
||||
{{ item.rtp !== null && item.rtp !== undefined ? item.rtp.toFixed(2) + '%' : '--' }}
|
||||
</div>
|
||||
<div class="rtp-progress">
|
||||
<div class="rtp-progress-inner" :style="{ width: Math.min(Number(item.rtp) || 0, 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="rtp-rows">
|
||||
<div class="rtp-row">
|
||||
<span>投注</span>
|
||||
<span>¥{{ item.bet !== null && item.bet !== undefined ? Number(item.bet).toFixed(2) : '--' }}</span>
|
||||
<div class="rail-metrics">
|
||||
<div
|
||||
v-for="m in railMetrics"
|
||||
:key="m.key"
|
||||
class="rail-metric"
|
||||
:class="{ clickable: !!m.route }"
|
||||
@click="m.route && handto(m.route)"
|
||||
>
|
||||
<div class="rail-metric-label">
|
||||
<span>{{ m.label }}</span>
|
||||
<el-tooltip v-if="m.note" :content="m.note" placement="top">
|
||||
<i class="el-icon-info rail-note"></i>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="rtp-row">
|
||||
<span>奖励</span>
|
||||
<span>¥{{ item.reward !== null && item.reward !== undefined ? Number(item.reward).toFixed(2) : '--' }}</span>
|
||||
<div class="rail-metric-value">{{ m.value }}</div>
|
||||
<i v-if="m.route" class="el-icon-arrow-right rail-arrow"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rail-todo">
|
||||
<div
|
||||
class="rail-todo-item clickable"
|
||||
:class="{ alert: totaldeliver > 0 }"
|
||||
@click="handto(3)"
|
||||
>
|
||||
<span class="rail-todo-label">待处理提货申请</span>
|
||||
<span class="rail-todo-value">{{ totaldeliver === null ? '--' : totaldeliver }}</span>
|
||||
</div>
|
||||
<div class="rail-todo-item">
|
||||
<span class="rail-todo-label">提货余额</span>
|
||||
<span class="rail-todo-value">¥{{ balance }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧主区 -->
|
||||
<div class="main-col">
|
||||
<!-- 实时 RTP -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<span class="title-bar"></span>
|
||||
<span>实时 RTP</span>
|
||||
</div>
|
||||
<span class="section-sub">每 30 秒自动刷新</span>
|
||||
</div>
|
||||
<div class="rtp-grid">
|
||||
<div
|
||||
v-for="(item, idx) in visibleRtpList"
|
||||
:key="item.name || idx"
|
||||
class="rtp-card"
|
||||
:class="'rtp-theme-' + (idx % 5)"
|
||||
>
|
||||
<div class="rtp-name">
|
||||
<span class="rtp-dot"></span>
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="rtp-value">
|
||||
{{ item.rtp !== null && item.rtp !== undefined ? item.rtp.toFixed(2) + '%' : '--' }}
|
||||
</div>
|
||||
<div class="rtp-progress">
|
||||
<div class="rtp-progress-inner" :style="{ width: Math.min(Number(item.rtp) || 0, 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="rtp-rows">
|
||||
<div class="rtp-row">
|
||||
<span>投注</span>
|
||||
<span>¥{{ item.bet !== null && item.bet !== undefined ? Number(item.bet).toFixed(2) : '--' }}</span>
|
||||
</div>
|
||||
<div class="rtp-row">
|
||||
<span>奖励</span>
|
||||
<span>¥{{ item.reward !== null && item.reward !== undefined ? Number(item.reward).toFixed(2) : '--' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!visibleRtpList.length" class="rtp-empty">暂无 RTP 数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 经营数据(次级指标带)—— 放在图表上方:图表最高,放最后滚到底正好 -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<span class="title-bar"></span>
|
||||
<span>经营数据</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-stats">
|
||||
<div v-for="s in miniStats" :key="s.key" class="mini-stat">
|
||||
<div class="mini-icon" :class="'theme-' + s.theme">
|
||||
<i :class="s.icon"></i>
|
||||
</div>
|
||||
<div class="mini-info">
|
||||
<div class="mini-label">
|
||||
<span>{{ s.label }}</span>
|
||||
<el-tooltip v-if="s.note" :content="s.note" placement="top">
|
||||
<i class="el-icon-info mini-note"></i>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="mini-value">{{ s.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!visibleRtpList.length" class="rtp-empty">暂无 RTP 数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 流水统计图表 -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<span class="title-bar"></span>
|
||||
<span>流水统计</span>
|
||||
<span class="section-sub chart-sub">{{ chartTypeLabel }}</span>
|
||||
</div>
|
||||
<div class="chart-tabs">
|
||||
<span :class="['tab-item', chartType === 'hour' ? 'active' : '']" @click="switchChartType('hour')">小时</span>
|
||||
<span :class="['tab-item', chartType === 'day' ? 'active' : '']" @click="switchChartType('day')">天</span>
|
||||
<span :class="['tab-item', chartType === 'week' ? 'active' : '']" @click="switchChartType('week')">周</span>
|
||||
<!-- 流水统计图表 -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<span class="title-bar"></span>
|
||||
<span>流水统计</span>
|
||||
<span class="section-sub chart-sub">{{ chartTypeLabel }}</span>
|
||||
</div>
|
||||
<div class="chart-tabs">
|
||||
<span :class="['tab-item', chartType === 'hour' ? 'active' : '']" @click="switchChartType('hour')">小时</span>
|
||||
<span :class="['tab-item', chartType === 'day' ? 'active' : '']" @click="switchChartType('day')">天</span>
|
||||
<span :class="['tab-item', chartType === 'week' ? 'active' : '']" @click="switchChartType('week')">周</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lineChart" style="width: 100%; height: 400px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lineChart" style="width: 100%; height: 400px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -145,25 +191,49 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// 隐藏极速糖果、Roll 房、荒野争锋卡片
|
||||
// 隐藏已下架玩法的 RTP 卡片。按后端返回的 name 精确匹配。
|
||||
// 2026-09-22:「玫瑰女郎」(= 后端 olympus 玩法)已下架并隐藏;
|
||||
// 新游戏「麻将胡了」不在隐藏列表里,正常展示。
|
||||
visibleRtpList() {
|
||||
const hidden = ["极速糖果", "Roll 房", "Roll房", "荒野争锋"];
|
||||
const hidden = ["极速糖果", "Roll 房", "Roll房", "荒野争锋", "玫瑰女郎"];
|
||||
return this.rtpList.filter(item => !hidden.includes(item.name));
|
||||
},
|
||||
chartTypeLabel() {
|
||||
return this.chartType === "hour" ? "今日(按小时)" : this.chartType === "week" ? "近 4 周" : "近 7 天";
|
||||
},
|
||||
statCards() {
|
||||
/* 左窄轨:只放「平台体量」三项,给足字号做层级;
|
||||
其余指标下沉到右侧主区底部的「经营数据」带。 */
|
||||
railMetrics() {
|
||||
return [
|
||||
{
|
||||
key: "online",
|
||||
label: "在线用户",
|
||||
note: "当前在线的用户数,点击可查看在线用户列表",
|
||||
value: this.total === null ? "--" : this.total,
|
||||
route: 1
|
||||
},
|
||||
{
|
||||
key: "totalRecharge",
|
||||
label: "总充值",
|
||||
note: "历史累计充值总额(排除无效用户)",
|
||||
value: "¥" + this.totalRecord,
|
||||
route: 2
|
||||
},
|
||||
{
|
||||
key: "totalFlow",
|
||||
label: "总流水",
|
||||
note: "历史累计全部游戏投注消费总额(已排除无效用户)",
|
||||
value: "¥" + Number(this.totalFlow).toFixed(2)
|
||||
}
|
||||
];
|
||||
},
|
||||
/* 右侧主区底部:其余 6 项,紧凑成一条指标带 */
|
||||
miniStats() {
|
||||
return [
|
||||
{ key: "online", label: "在线用户", note: "当前在线的用户数,点击可查看在线用户列表", value: this.total === null ? "--" : this.total, icon: "el-icon-user-solid", theme: "blue", route: 1 },
|
||||
{ key: "totalRecharge", label: "总充值", note: "历史累计充值总额(排除无效用户)", value: "¥" + this.totalRecord, icon: "el-icon-s-order", theme: "orange", route: 2 },
|
||||
{ key: "deliver", label: "待处理提货申请", note: "待审核处理的提货申请数量,点击可前往处理", value: this.totaldeliver === null ? "--" : this.totaldeliver, icon: "el-icon-shopping-cart-full", theme: "green", route: 3 },
|
||||
{ key: "balance", label: "提货余额", note: "第三方发货平台账户余额,用于采购发货", value: "$" + this.balance, icon: "el-icon-wallet", theme: "red" },
|
||||
{ key: "todayRecharge", label: "今日总充值", note: "今日0点至今的充值总额(已排除无效用户)", value: "¥" + this.todayRecharge, icon: "el-icon-money", theme: "purple" },
|
||||
{ key: "todayBet", label: "今日总投注", note: "今日全部游戏的投注消费总额(已排除无效用户)", value: "¥" + this.todayBet, icon: "el-icon-coin", theme: "cyan" },
|
||||
{ key: "todayReward", label: "今日总返奖", note: "今日全游戏产出饰品总价值(按背包产出统计,含已分解)", value: "¥" + this.todayReward, icon: "el-icon-present", theme: "pink" },
|
||||
{ key: "deliveryAmount", label: "总发货金额", note: "已完成发货订单的采购价合计(已排除无效用户)", value: "¥" + Number(this.totalDeliveryAmount).toFixed(2), icon: "el-icon-box", theme: "amber" },
|
||||
{ key: "totalFlow", label: "总流水", note: "历史累计全部游戏投注消费总额(已排除无效用户)", value: "¥" + Number(this.totalFlow).toFixed(2), icon: "el-icon-data-line", theme: "indigo" },
|
||||
{ key: "historyReward", label: "历史总返奖", note: "全游戏产出饰品总价值(按背包产出统计,含已分解)", value: "¥" + Number(this.historyReward).toFixed(2), icon: "el-icon-trophy", theme: "teal" },
|
||||
{ key: "rollRoom", label: "Roll 房今日返奖", note: "不含下注(仅饰品产出)", value: "¥" + Number(this.rollRoomReward || 0).toFixed(2), icon: "el-icon-house", theme: "sky" }
|
||||
];
|
||||
@@ -311,7 +381,7 @@ export default {
|
||||
getChartDataList() {
|
||||
return getChartData(this.chartType).then(res => {
|
||||
const data = res.data;
|
||||
this.drawLineChart(data.dateList, data.rechargeList, data.boxOpenList, data.fightBoxOpenList, data.luckyUpgradeList, data.westernDuelList, data.wheelList, data.roseladyList);
|
||||
this.drawLineChart(data.dateList, data.rechargeList, data.boxOpenList, data.fightBoxOpenList, data.luckyUpgradeList, data.westernDuelList, data.wheelList, data.roseladyList, data.mahjongList);
|
||||
});
|
||||
},
|
||||
switchChartType(type) {
|
||||
@@ -337,7 +407,7 @@ export default {
|
||||
this.rollRoomReward = 0;
|
||||
});
|
||||
},
|
||||
drawLineChart(dateList, rechargeList, boxOpenList, fightBoxOpenList, luckyUpgradeList, westernDuelList, wheelList, roseladyList) {
|
||||
drawLineChart(dateList, rechargeList, boxOpenList, fightBoxOpenList, luckyUpgradeList, westernDuelList, wheelList, roseladyList, mahjongList) {
|
||||
var chartDom = document.getElementById("lineChart");
|
||||
if (this.lineChartInstance) {
|
||||
this.lineChartInstance.dispose();
|
||||
@@ -354,7 +424,10 @@ export default {
|
||||
{ name: "对战开箱流水", data: fightBoxOpenList, color: "#4a9163" },
|
||||
{ name: "幸运升级流水", data: luckyUpgradeList, color: "#4a7f8c" },
|
||||
{ name: "疯狂转盘流水", data: wheelList, color: "#7a6a9c" },
|
||||
{ name: "玫瑰女郎流水", data: roseladyList, color: "#a35790" }
|
||||
// 麻将胡了流水:沿用原玫瑰女郎的品红 #a35790(色相与前 5 条继续错开,不撞色)
|
||||
{ name: "麻将胡了流水", data: mahjongList, color: "#a35790" }
|
||||
// 玫瑰女郎流水(= 后端 olympus 玩法)已随玩法下架移除,2026-09-22;
|
||||
// roseladyList 参数仍由 getChartData 传入但不再使用(与 westernDuelList 写法一致,方便将来一行恢复)。
|
||||
];
|
||||
var option = {
|
||||
tooltip: {
|
||||
@@ -475,107 +548,95 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 统计卡片网格 ========== */
|
||||
.stat-grid {
|
||||
/* ========== 主体两栏:左窄轨 + 右主区 ========== */
|
||||
.home-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
/* start:左轨按内容高度,不跟着右列拉长(否则底部空一大截) */
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 1200px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* ========== 左窄轨 ========== */
|
||||
.rail {
|
||||
/* 右列很长,左轨跟着滚,随时能看到核心数字 */
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
padding: 20px;
|
||||
background: #fffdfa;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 14px rgba(139, 115, 85, 0.10);
|
||||
border: 1px solid rgba(212, 160, 160, 0.55);
|
||||
transition:
|
||||
transform 0.5s ease-in-out,
|
||||
box-shadow 0.5s ease-in-out,
|
||||
border-color 0.5s ease-in-out;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
/* 规范:悬停用「小幅放大 + ≤1deg 微旋」,避免机械式直上直下 */
|
||||
transform: scale(1.015) rotate(-0.3deg);
|
||||
border-color: #c08a8a;
|
||||
box-shadow: 0 10px 26px rgba(139, 115, 85, 0.16);
|
||||
@media (max-width: 1200px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
transform: rotate(-1deg) scale(1.04);
|
||||
}
|
||||
.stat-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.rail-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #7a664e;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.rail-metric {
|
||||
position: relative;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid #f3ece0;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
flex-shrink: 0;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
/* 底色与图标色由 .theme-* 提供:浅底 + 同色深图标(水彩感),
|
||||
不再是一格一色的高饱和实心块 */
|
||||
background: rgba(154, 92, 53, 0.22);
|
||||
transition: transform 0.5s ease-in-out;
|
||||
|
||||
i {
|
||||
font-size: 26px;
|
||||
color: #9a5c35;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
min-width: 0;
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #4f4130;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.stat-label {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: #7a664e;
|
||||
|
||||
.stat-note-icon {
|
||||
margin-left: 4px;
|
||||
color: #7a664e;
|
||||
cursor: help;
|
||||
&.clickable:hover {
|
||||
.rail-metric-value {
|
||||
color: #9a5c35;
|
||||
}
|
||||
.rail-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stat-arrow {
|
||||
.rail-metric-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #7a664e;
|
||||
|
||||
.rail-note {
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.rail-metric-value {
|
||||
margin-top: 6px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.4px;
|
||||
color: #4f4130;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rail-arrow {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
margin-top: -7px;
|
||||
font-size: 14px;
|
||||
@@ -585,11 +646,74 @@ export default {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
/* 统计卡图标配色
|
||||
11 个卡片只用 5 个田园色(陶土 / 草绿 / 苔绿 / 雏菊金 / 玫瑰粉),循环使用。
|
||||
浅底 + 同色深图标。
|
||||
tint 从 .14~.18 提到 .22~.26:原来太淡,小图标几乎"看不见"(用户反馈对比度不够)。
|
||||
类名(blue/orange/...)只是 card.theme 的旧键名,沿用不改,免得动模板。 */
|
||||
/* ========== 左轨待办区 ========== */
|
||||
.rail-todo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f3ece0;
|
||||
}
|
||||
|
||||
.rail-todo-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f6efe3;
|
||||
border: 1px solid rgba(212, 160, 160, 0.40);
|
||||
transition:
|
||||
background-color 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.clickable:hover {
|
||||
background: #fbf5ea;
|
||||
border-color: #c08a8a;
|
||||
}
|
||||
|
||||
/* 有积压时用雏菊金描边 + 数值变色提醒。
|
||||
刻意不给 background:留空才不会和 hover 的底色打架(同权重,靠顺序定胜负太脆) */
|
||||
&.alert {
|
||||
border-color: rgba(168, 121, 31, 0.32);
|
||||
|
||||
.rail-todo-value {
|
||||
color: #7d5512;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rail-todo-label {
|
||||
font-size: 12px;
|
||||
color: #7a664e;
|
||||
}
|
||||
|
||||
.rail-todo-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: #4f4130;
|
||||
}
|
||||
|
||||
/* ========== 右侧主区 ========== */
|
||||
.main-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ========== 统计卡图标配色 ==========
|
||||
浅底 + 同色深图标(水彩感)。五个田园色循环使用:
|
||||
陶土 / 草绿 / 苔绿 / 雏菊金 / 玫瑰粉。
|
||||
tint 用 .22~.26:太淡小图标会"看不见"。
|
||||
类名(blue/orange/...)沿用旧键名不改,免得动逻辑。 */
|
||||
.theme-blue,
|
||||
.theme-indigo {
|
||||
background: rgba(154, 92, 53, 0.22) !important;
|
||||
@@ -622,14 +746,13 @@ export default {
|
||||
i { color: #8f3f50 !important; }
|
||||
}
|
||||
|
||||
/* ========== 区块卡片(RTP / 图表) ========== */
|
||||
/* ========== 区块卡片(RTP / 图表 / 经营数据) ========== */
|
||||
.section-card {
|
||||
background: #faf6f0;
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 6px 18px rgba(139, 115, 85, 0.11);
|
||||
border: 1px solid rgba(212, 160, 160, 0.55);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -670,21 +793,13 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== RTP 卡片 ========== */
|
||||
/* ========== RTP 卡片 ==========
|
||||
右侧主区宽度随屏幕浮动(有左轨占位),用 auto-fit 自适应
|
||||
—— 宽屏一行 5 张,窄屏自动折行,不再写死 5 列。 */
|
||||
.rtp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rtp-card {
|
||||
@@ -769,9 +884,7 @@ export default {
|
||||
|
||||
/* RTP 主题色(只给圆点 + 进度条上色)
|
||||
★ 与上方折线图的颜色一一对应:同一游戏在哪儿都是同一个色,
|
||||
这样「颜色」本身就带信息,不用每次重新认。
|
||||
(原来是品红 #ec4899 / 青 #14b8a6 / 紫 #8b5cf6 / 翠绿 #10b981 / 琥珀 #f59e0b,
|
||||
属于规范禁止的高饱和荧光色,已换掉。) */
|
||||
这样「颜色」本身就带信息,不用每次重新认。 */
|
||||
.rtp-theme-0 { /* 疯狂转盘 */
|
||||
.rtp-dot { background: #7a6a9c; }
|
||||
.rtp-progress-inner { background: linear-gradient(90deg, #a89cc0, #7a6a9c); }
|
||||
@@ -788,11 +901,78 @@ export default {
|
||||
.rtp-dot { background: #4a9163; }
|
||||
.rtp-progress-inner { background: linear-gradient(90deg, #8fb8a0, #4a9163); }
|
||||
}
|
||||
.rtp-theme-4 { /* 玫瑰女郎 */
|
||||
.rtp-theme-4 { /* 麻将胡了(idx%5=4) */
|
||||
.rtp-dot { background: #a35790; }
|
||||
.rtp-progress-inner { background: linear-gradient(90deg, #c69bb8, #a35790); }
|
||||
}
|
||||
|
||||
/* ========== 经营数据(次级指标带) ========== */
|
||||
.mini-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: #f6efe3;
|
||||
border: 1px solid rgba(212, 160, 160, 0.40);
|
||||
transition:
|
||||
background-color 0.5s ease-in-out,
|
||||
border-color 0.5s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: #fbf5ea;
|
||||
border-color: #c08a8a;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-icon {
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
i {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #7a664e;
|
||||
white-space: nowrap;
|
||||
|
||||
.mini-note {
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-value {
|
||||
margin-top: 2px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: #4f4130;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ========== 图表切换标签 ========== */
|
||||
.chart-tabs {
|
||||
display: flex;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user