Compare commits

..
12 Commits
314 changed files with 7568 additions and 4421 deletions
+14 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)。
+405
View File
@@ -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()
-22
View File
@@ -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
View File
@@ -20,7 +20,7 @@ VITE_SOURCEMAP=true
VITE_OUT_DIR=dist-dev
# 标题
VITE_APP_TITLE=ElementAdmin
VITE_APP_TITLE=推广后台
# 是否包分析
VITE_USE_BUNDLE_ANALYZER=false
+23 -5
View File
@@ -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' })
}
+7
View File
@@ -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>
+8 -2
View File
@@ -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 = () => {
+28 -22
View File
@@ -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">
+13 -8
View File
@@ -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
+25 -17
View File
@@ -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]
+4 -4
View File
@@ -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
View File
@@ -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>
+7 -78
View File
@@ -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('滑块验证失败,请重试')
+5 -2
View File
@@ -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>
+17 -13
View File
@@ -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
+3
View File
@@ -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',
-6
View File
@@ -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>
@@ -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());
}
}
-7
View File
@@ -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();
@@ -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;
}
}
@@ -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 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 后台管理系统
VUE_APP_TITLE = 凯科佰娱
# 开发环境配置
ENV = 'development'
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 常州项目
VUE_APP_TITLE = 凯科佰娱
# 生产环境配置
ENV = 'production'
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 后台管理系统
VUE_APP_TITLE = 凯科佰娱
NODE_ENV = production
@@ -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 的页面标题 / 弹窗标题)用衬线,
+13 -5
View File
@@ -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;
+4 -2
View File
@@ -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 内部结构的写法。
+4 -2
View File
@@ -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;
}
}
+43 -55
View File
@@ -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 {
+22 -26
View File
@@ -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;
+130 -11
View File
@@ -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>
+4 -4
View File
@@ -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;
+1 -1
View File
@@ -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;
}
}
+2 -2
View File
@@ -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>
+6 -4
View File
@@ -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;
}
+4 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+1 -38
View File
@@ -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 {
+3
View File
@@ -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)
+3 -3
View File
@@ -53,12 +53,12 @@ export const ACCOUNT_SOURCE_MAP = {
52: '极速糖果奖励',
56: 'Roll房未发放退回主播',
59: '主播Roll房奖池扣款',
60: '玫瑰女郎消费',
61: '玫瑰女郎奖励',
62: '招商周结佣金',
63: '流水抽奖奖励',
65: '管理员调整余额',
66: '管理员调整积分'
66: '管理员调整积分',
68: '麻将胡了消费',
69: '麻将胡了奖励'
};
/**
+3 -3
View File
@@ -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;
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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"
+11 -11
View File
@@ -4,7 +4,7 @@
<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:#606266">身份:</span>
<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>
@@ -18,20 +18,20 @@
<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:#c0c4cc">-</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:#c0c4cc">-</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) ? '#f56c6c' : '#67c23a'}">{{ actualRtp(scope.row) }}%</span>
<span v-else style="color:#c0c4cc">-</span>
<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">
@@ -71,11 +71,11 @@
<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:#909399;font-size:12px">身份级参数,仅作用于身份【{{ audienceLabel(audienceFilter) }}】</span>
<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:#909399;font-size:12px">身份级参数,仅作用于身份【{{ audienceLabel(audienceFilter) }}】</span>
<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" />
@@ -83,7 +83,7 @@
<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:#909399;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标,随保存作用于当前身份</div>
<div style="color:#7a664e;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标,随保存作用于当前身份</div>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="form.status">
@@ -124,7 +124,7 @@
<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:#909399;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标</div>
<div style="color:#7a664e;font-size:12px">固定% + 收敛% = 100;收敛回合用于一轮内把实时RTP动态拉回目标</div>
</el-form-item>
</el-form>
<div slot="footer">
@@ -166,11 +166,11 @@
</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 ? '#f56c6c' : '#67c23a'}">{{ scope.row.remaining != null ? scope.row.remaining : scope.row.count }}</span>
<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:#e6a23c">{{ scope.row.convergeCount || 0 }}</span></template>
<template slot-scope="scope"><span style="color:#8f6418">{{ scope.row.convergeCount || 0 }}</span></template>
</el-table-column>
</el-table>
<div slot="footer">
@@ -3,7 +3,7 @@
<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 ? 'red' : 'green'}">{{ rtp }}%</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>
@@ -23,7 +23,7 @@
<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 ? 'red' : 'green'}">
<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>
@@ -88,5 +88,5 @@ export default {
<style scoped>
.stat { text-align: center; }
.stat small { color: #909399; }
.stat small { color: #7a664e; }
</style>
@@ -80,7 +80,7 @@
</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:#909399;font-size:12px;margin-top:4px">未命中区间时随机选择一个兜底饰品返奖</div>
<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">
@@ -88,7 +88,7 @@
<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:#909399;font-size:12px;margin-top:4px">玩家结算赢额落在该区间即获得此饰品(最高到 500x 封顶对应的赢额)</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%" />
@@ -69,13 +69,13 @@
<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 ? '#f56c6c' : '#409eff'}">{{ fmt(pool.balance) }}</h3></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 ? '#f56c6c' : '#67c23a'}">{{ rtpPct(pool.rtp).toFixed(1) }}%</h3></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>
@@ -130,7 +130,7 @@
<el-table-column label="一轮花费(基数)" width="130" align="center">
<template slot-scope="scope">
<div>{{ fmt(scope.row.cycleCost) }}</div>
<div style="font-size:11px;color:#909399">{{ fmt(scope.row.costPerRound) }} × {{ scope.row.cycleRounds || '—' }}回合</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">
@@ -146,18 +146,18 @@
</el-table-column>
<el-table-column label="水位余额" min-width="100" align="center">
<template slot-scope="scope">
<b :style="{color: Number(scope.row.balance) < 0 ? '#f56c6c' : '#409eff'}">{{ fmt(scope.row.balance) }}</b>
<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 ? '#f56c6c' : '#67c23a'}">{{ scope.row.rtp == null ? '—' : rtpPct(scope.row.rtp).toFixed(1) + '%' }}</span>
<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 ? '#f56c6c' : '#67c23a'}">{{ scope.row.cycleRtp == null ? '—' : rtpPct(scope.row.cycleRtp).toFixed(1) + '%' }}</div>
<div style="font-size:11px;color:#909399">{{ scope.row.cycleDrawn || 0 }} / {{ scope.row.totalRounds || '—' }} 回合</div>
<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">
@@ -198,13 +198,13 @@
</el-radio-group>
</el-form-item>
<el-form-item label="当前水位">
<b :style="{color: Number(form.balance) < 0 ? '#f56c6c' : '#409eff'}">{{ fmt(form.balance) }}</b>
<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 ? '#f56c6c' : '#67c23a'}">{{ fmt(afterBalance) }}</b>
<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="必填,如:活动补水" />
@@ -234,7 +234,7 @@
</el-table-column>
<el-table-column label="金额" min-width="110" align="center">
<template slot-scope="scope">
<span :style="{color: scope.row.type === 1 ? '#67c23a' : (scope.row.type === 2 ? '#f56c6c' : '#e6a23c')}">
<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>
@@ -247,13 +247,13 @@
</el-table-column>
<el-table-column label="总池余额" min-width="110" align="center">
<template slot-scope="scope">
<b :style="{color: Number(scope.row.poolBalance) < 0 ? '#f56c6c' : '#409eff'}">{{ scope.row.poolBalance == null ? '—' : fmt(scope.row.poolBalance) }}</b>
<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 ? '#303133' : '#c0c4cc'}">{{ scope.row.remark || '—' }}</span>
<span :style="{color: scope.row.remark ? '#4f4130' : '#8b755a'}">{{ scope.row.remark || '—' }}</span>
</template>
</el-table-column>
</el-table>
@@ -329,7 +329,7 @@ export default {
},
signColor(v) {
const n = Number(v)
return n < 0 ? '#f56c6c' : (n > 0 ? '#67c23a' : '#303133')
return n < 0 ? '#a4485a' : (n > 0 ? '#457450' : '#4f4130')
},
applyConfig(data) {
if (!data) return
@@ -423,30 +423,33 @@ export default {
<style scoped>
.page-head { margin-bottom: 14px; }
.page-head-row { display: flex; align-items: center; justify-content: space-between; }
.head-tip { color: #909399; font-size: 12px; margin: 6px 0 0; }
.head-tip { color: #7a664e; font-size: 12px; margin: 6px 0 0; }
.audience-row { display: flex; align-items: center; margin-top: 10px; }
.aud-label { color: #606266; font-size: 13px; margin-right: 6px; }
.aud-tip { color: #909399; font-size: 12px; margin-left: 10px; }
.guide-panel { display: flex; gap: 16px; background: #f7f9fc; border: 1px solid #e4e9f2; border-radius: 8px; padding: 14px 16px; 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: #303133; }
.guide-col h5 i { color: #409eff; margin-right: 4px; }
.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: #606266; line-height: 1.9; }
.guide-col li { font-size: 12px; color: #5f4f3d; line-height: 1.9; }
.stat-row { margin-bottom: 16px; }
.stat-card { display: flex; align-items: center; gap: 12px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px; padding: 14px 16px; box-shadow: 0 1px 4px rgba(0,0,0,.04); }
/* 统计卡图标:与侧栏一级菜单共用 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: #ecf5ff; color: #409eff; }
.sc-orange { background: #fdf6ec; color: #e6a23c; }
.sc-cyan { background: #e8f7f7; color: #13a8a8; }
.sc-green { background: #f0f9eb; color: #67c23a; }
.sc-body small { color: #909399; display: block; }
.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: #909399; font-size: 12px; margin-left: 10px; }
.fld-label { color: #606266; font-size: 13px; margin-right: 6px; }
.fld-label .req { color: #f56c6c; margin-left: 2px; }
.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: #e6a23c; font-size: 13px; }
.est-text { color: #8f6418; font-size: 13px; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 10px; }
</style>
@@ -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
View File
@@ -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;
+7 -5
View File
@@ -2,7 +2,7 @@
<div class="login">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form">
<h3 class="title">后台管理系统</h3>
<h3 class="title">凯科佰娱</h3>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
@@ -169,12 +169,14 @@ export default {
.title {
margin: 0px auto 30px auto;
text-align: center;
color: #707070;
color: #4f4130;
}
.login-form {
border-radius: 6px;
background: #ffffff;
border-radius: 20px;
background: #fffdfa;
border: 1px solid rgba(212, 160, 160, .55);
box-shadow: 0 12px 32px rgba(139, 115, 85, .17);
width: 400px;
padding: 25px 25px 5px 25px;
.el-input {
@@ -192,7 +194,7 @@ export default {
.login-tip {
font-size: 13px;
text-align: center;
color: #bfbfbf;
color: #8b755a;
}
.login-code {
width: 33%;
+1 -1
View File
@@ -112,7 +112,7 @@
></el-switch>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<el-table-column width="220" label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
+1 -1
View File
@@ -137,7 +137,7 @@
</template>
</el-table-column>
<el-table-column label="来源IP" align="center" prop="operIp" width="130" :show-overflow-tooltip="true" />
<el-table-column label="操作" align="center" width="80" class-name="small-padding fixed-width">
<el-table-column label="操作" align="center" width="90" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
+8 -6
View File
@@ -1,7 +1,7 @@
<template>
<div class="register">
<el-form ref="registerForm" :model="registerForm" :rules="registerRules" class="register-form">
<h3 class="title">后台管理系统</h3>
<h3 class="title">凯科佰娱</h3>
<el-form-item prop="username">
<el-input v-model="registerForm.username" type="text" auto-complete="off" placeholder="账号">
<svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
@@ -126,7 +126,7 @@ export default {
this.loading = true;
register(this.registerForm).then(res => {
const username = this.registerForm.username;
this.$alert("<font color='red'>恭喜你,您的账号 " + username + " 注册成功!</font>", '系统提示', {
this.$alert("<font color='#a4485a'>恭喜你,您的账号 " + username + " 注册成功!</font>", '系统提示', {
dangerouslyUseHTMLString: true,
type: 'success'
}).then(() => {
@@ -157,12 +157,14 @@ export default {
.title {
margin: 0px auto 30px auto;
text-align: center;
color: #707070;
color: #4f4130;
}
.register-form {
border-radius: 6px;
background: #ffffff;
border-radius: 20px;
background: #fffdfa;
border: 1px solid rgba(212, 160, 160, .55);
box-shadow: 0 12px 32px rgba(139, 115, 85, .17);
width: 400px;
padding: 25px 25px 5px 25px;
.el-input {
@@ -180,7 +182,7 @@ export default {
.register-tip {
font-size: 13px;
text-align: center;
color: #bfbfbf;
color: #8b755a;
}
.register-code {
width: 33%;
@@ -24,7 +24,11 @@
<!-- 卡密列表 -->
<el-table :data="cardList" v-loading="tableLoading" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="70" align="center" />
<el-table-column prop="cardCode" label="卡密编码" width="200" align="center">
<!-- ★ 这一列必须是 min-width:卡密编码最长(32 位等宽字符 + 复制按钮),
且它改宽后本表就有了弹性列,才会撑满容器。
原来 8 列全是固定 width,列宽和恒为 1000px,《Element》table-layout.js:182-194
走 else 分支 ⇒ bodyWidth = 列宽之和,容器再宽也不拉伸,右侧空一条。 -->
<el-table-column prop="cardCode" label="卡密编码" min-width="200" align="center">
<template slot-scope="scope">
<span style="font-family: 'Courier New', monospace; color: #9a5c35; font-weight: 600; margin-right: 6px;">{{ scope.row.cardCode }}</span>
<el-button type="text" size="mini" @click="copyText(scope.row.cardCode)">复制</el-button>
+2 -2
View File
@@ -74,7 +74,7 @@
</template>
</el-table-column>
<el-table-column align="center" prop="createTime" label="下单时间"></el-table-column>
<el-table-column align="center" prop="address" label="操作">
<el-table-column width="250" align="center" prop="address" label="操作">
<template slot-scope="scope">
<!-- <el-button type="text" @click="tradeBuy(scope.row)">查询</el-button> -->
<el-button type="text" @click="tradeBuyCs2pifa(scope.row)">查询cs2pifa</el-button>
@@ -153,7 +153,7 @@ export default {
},
methods: {
getPriceColor(row) {
if (!row.cs340Price) return '#606266'; // 无三方价格时默认色
if (!row.cs340Price) return '#5f4f3d'; // 无三方价格时默认色
return row.ornamentsPrice > row.cs340Price ? '#a4485a' : '#457450'; // 溢价红色,未溢价绿色
},
handleSubmit(formName) {
@@ -71,7 +71,7 @@
style="display:flex; align-items:center;justify-content:center;line-height: 1;"
>
<span
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #F39c12; display:inline-block;"
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #8f6418; display:inline-block;"
></span>
人工发货
</div>
@@ -80,7 +80,7 @@
style="display:flex; align-items:center;justify-content:center;"
>
<span
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #00a65a; display:inline-block;"
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #457450; display:inline-block;"
></span>
自动发货
</div>
@@ -93,7 +93,7 @@
style="display:flex; align-items:center;justify-content:center;line-height: 1;"
>
<span
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: green; display:inline-block;"
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #457450; display:inline-block;"
></span>
待发货
</div>
@@ -102,7 +102,7 @@
style="display:flex; align-items:center;justify-content:center;"
>
<span
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #696969; display:inline-block;"
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #7a664e; display:inline-block;"
></span>
待收货
</div>
@@ -111,7 +111,7 @@
style="display:flex; align-items:center;justify-content:center;"
>
<span
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #696969; display:inline-block;"
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #7a664e; display:inline-block;"
></span>
订单完成
</div>
@@ -121,7 +121,7 @@
>
订单取消
<span
style="width: 8px; height:8px; margin-left:5px; border-radius: 50%; background-color: #DD4b39; display:inline-block;"
style="width: 8px; height:8px; margin-left:5px; border-radius: 50%; background-color: #a4485a; display:inline-block;"
></span>
</div>
</template>
@@ -119,7 +119,7 @@
</template>
</el-table-column>
<el-table-column align="center" prop="createTime" label="申请时间" width="160"></el-table-column>
<el-table-column align="center" class-name="operation-column" label="操作" width="120">
<el-table-column align="center" class-name="operation-column" label="操作" width="130">
<template slot-scope="scope">
<div v-if="scope.row.status === 0" style="display: flex; justify-content: center; align-items: center;">
<el-button type="text" style="color: #457450; margin: 0 5px;" @click="handleApprove(scope.row)">同意</el-button>
@@ -43,7 +43,7 @@
style="display:flex; align-items:center;justify-content:center;line-height: 1;"
>
<span
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #F39c12; display:inline-block;"
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #8f6418; display:inline-block;"
></span>
人工发货
</div>
@@ -52,7 +52,7 @@
style="display:flex; align-items:center;justify-content:center;"
>
<span
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #00a65a; display:inline-block;"
style="width: 8px; height:8px; margin-right:5px; border-radius: 50%; background-color: #457450; display:inline-block;"
></span>
自动发货
</div>
@@ -0,0 +1,530 @@
<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">
水位是<b>整个幸运升级</b>的全局指标:水位 = (Σ下注 − Σ返奖 + 人工调整) / Σ下注,<b>排除白号</b>(白号的量单独展示,不进水位口径)。
只需设一个<b>目标水位</b>:水位高于它 → 随机数中心下移(放水);低于它 → 上移(收紧),长期会自动收敛回这个值。
</p>
<div v-show="showGuide" class="guide-panel">
<div class="guide-col">
<h5><i class="el-icon-info" /> 随机数是什么</h5>
<ul>
<li>随机数的单位是「饰品价的百分比」,等于本轮出货所需的累计进度。</li>
<li>理论 RTP = 100 / 随机数。随机数 125 → 玩家平均要投到 1.25 倍原价才出。</li>
<li>实测 RTP 会比名义值低 15%~20%(判定最后一拉整段计入、溢出丢弃),所以<b>以页面上的实测 RTP 为准</b>。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-setting" /> 目标水位怎么定</h5>
<ul>
<li>目标水位 = 平台希望长期维持的净收益比例。定 30% ⇒ 长期平均 RTP 约 70%。</li>
<li>系统会自动把水位拉回这个值(水位高就放水、低就收紧),<b>不用手配随机数</b>。</li>
<li>水位从 0 起步,离目标值较远时前几轮会明显偏放水或偏收紧,属正常的收敛过程。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-s-grid" /> 饰品的随机数区间</h5>
<ul>
<li>随机数的取值范围由<b>每个饰品自己的区间</b>限定(在「幸运升级 → 饰品列表」里配)。</li>
<li><b>整个区间始终都可能出</b>,水位只改变概率重心:水位高 → 靠下限的数出现得更频繁;
水位正好等于目标时是均匀分布(区间内等概率)。</li>
<li>⚠️ <b>区间还限定了水位能调到的范围</b>(硬约束):区间越窄、下限越高,能开的水位上限越低。
目标水位必须落在所有在售饰品区间都能覆盖的范围内,否则水位会停在附近而不是目标值。</li>
<li>区间越宽,单轮波动越大、越不容易被玩家归纳出规律。</li>
</ul>
</div>
<div class="guide-col">
<h5><i class="el-icon-s-operation" /> 注水 / 抽水</h5>
<ul>
<li>水位从 0 起步,上线初期想立刻把水位拉到目标值就注水。</li>
<li>调整原因必填,每次调整都会写入历史(可审计)。</li>
<li>「重置调整」只把人工调整归零,不影响真实流水;历史记录全部保留。</li>
<li>累计量走 Redis 增量计数,怀疑漂移时点「按历史重算」从流水全量重算。</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-terra"><i class="el-icon-odometer" /></div>
<div class="sc-body">
<small>当前水位</small>
<h3 :style="{ color: info.watermark < 0 ? '#a4485a' : '#9a5c35' }">{{ pct(info.watermark) }}</h3>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-grass"><i class="el-icon-data-line" /></div>
<div class="sc-body">
<small>实测 RTP(排除白号)</small>
<h3 :style="{ color: info.rtp > 100 ? '#a4485a' : '#457450' }">{{ pct(info.rtp) }}</h3>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-daisy"><i class="el-icon-coin" /></div>
<div class="sc-body">
<small>累计下注(排除白号)</small>
<h3>{{ fmt(info.totalBet) }}</h3>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="sc-icon sc-rose"><i class="el-icon-present" /></div>
<div class="sc-body">
<small>累计返奖(排除白号)</small>
<h3>{{ fmt(info.totalWin) }}</h3>
</div>
</div>
</el-col>
</el-row>
<div class="band-live">
<span class="bl-item">
<span class="bl-label">目标水位</span>
<b class="bl-value">{{ info.targetWatermark == null ? '—' : Number(info.targetWatermark).toFixed(1) + '%' }}</b>
</span>
<span class="bl-item">
<span class="bl-label">离目标</span>
<b class="bl-value" :style="{ color: gapColor }">{{ gapText }}</b>
</span>
<span class="bl-item">
<span class="bl-label">下一轮随机数</span>
<b class="bl-value" style="color:#9a5c35">{{ info.centerNow == null ? '—' : Number(info.centerNow).toFixed(1) }}</b>
</span>
<span class="bl-item">
<span class="bl-label">人工调整</span>
<b class="bl-value" :style="{ color: Number(info.adjust) < 0 ? '#a4485a' : (Number(info.adjust) > 0 ? '#457450' : '#4f4130') }">{{ fmt(info.adjust) }}</b>
</span>
<span class="bl-item">
<span class="bl-label">实测标定系数</span>
<b class="bl-value">{{ info.calibration == null ? '样本不足' : info.calibration }}</b>
</span>
<span class="bl-item">
<span class="bl-label">白号(不进水位口径)</span>
<b class="bl-value" style="color:#8f6418">下注 {{ fmt(info.blankBet) }} / 返奖 {{ fmt(info.blankWin) }}</b>
</span>
<span class="bl-item">
<span class="bl-label">策略开关</span>
<el-tag :type="info.enabled ? 'success' : 'info'" size="mini">{{ info.enabled ? '水位驱动已启用' : '已关闭(回退固定区间)' }}</el-tag>
</span>
</div>
<el-card shadow="never" class="op-card">
<div slot="header">
<b>水位策略</b>
<span class="sub-tip">改完<b>立即生效</b>,不需要点保存</span>
</div>
<el-form :inline="true" @submit.native.prevent>
<el-form-item>
<span class="fld-label">目标水位</span>
<el-input-number
v-model="form.targetWatermark"
:precision="1"
:step="1"
:min="-50"
:max="95"
:controls="false"
style="width:140px"
placeholder="如 30"
@change="autoSaveSetting"
/>
<span class="fld-unit">%</span>
<span class="fld-note" style="margin-left:10px">平台希望长期维持的净收益比例;定 30% ⇒ 长期平均 RTP ≈ 70%</span>
</el-form-item>
</el-form>
<div class="setting-flags">
<span class="fld-label">策略开关</span>
<el-switch v-model="form.enabled" active-color="#457450" inactive-color="#a4485a" @change="autoSaveSetting" />
<span class="fld-note" style="margin-left:8px">
{{ form.enabled ? '按水位动态出随机数' : '关闭后回退到每个饰品的固定区间(旧行为)' }}
</span>
</div>
<div class="range-calc">
<span class="fld-label">随机数区间</span>
<el-input-number v-model="form.rangeMin" :precision="0" :min="1" :controls="false" style="width:84px" placeholder="下限" />
<span class="rc-sep">~</span>
<el-input-number v-model="form.rangeMax" :precision="0" :min="2" :controls="false" style="width:84px" placeholder="上限" />
<span class="rc-arrow">⇒</span>
<span class="fld-label">可维持水位</span>
<b class="rc-result" :style="{ color: rangeResult.color }">{{ rangeResult.text }}</b>
<el-popover placement="top" width="440" trigger="hover" popper-class="wm-help">
<div slot="reference" class="help-ref">?</div>
<div class="wm-help-body">
<p><b>随机数区间</b>:该饰品每轮随机数的允许取值范围。整个区间<b>始终都可能出</b>,
水位只改变概率重心 —— 水位高于目标时,靠下限的数出现得更频繁。</p>
<p><b>为什么它决定了水位范围</b>:随机数的中心由目标水位自动推算,
但推算出的中心只能落在「下限 + 10% 区间宽度」到「上限 − 10% 区间宽度」之间(超出会被夹住)。
把这两个端点代回 <code>水位 = 1 − 100 ÷ (标定系数 × 随机数)</code>,就是左边显示的可维持范围。</p>
<p><b>怎么用</b>:目标水位要落在<b>各在售饰品区间都能覆盖</b>的范围内,否则水位会停在附近而不是目标值。
想把水位开得更高,把区间上限抬高即可 —— 上限 160 时水位最多约 45%,想到 60% 上限要到 210 以上。
(区间在「幸运升级 → 饰品列表」里按身份分别配置。)</p>
</div>
</el-popover>
</div>
</el-card>
<el-card shadow="never" class="op-card">
<div slot="header">
<b>人工注水 / 抽水</b>
<span class="sub-tip">注水 = 抬高水位(更容易出货);抽水 = 降低水位(收紧)</span>
</div>
<el-form :inline="true" @submit.native.prevent>
<el-form-item>
<span class="fld-label">金额</span>
<el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="100" :controls="false" style="width:140px" placeholder="如 1000" />
</el-form-item>
<el-form-item>
<span class="fld-label">调整原因<span class="req">*</span></span>
<el-input v-model="form.remark" maxlength="100" placeholder="必填,如:上线初期补水" style="width:220px" />
</el-form-item>
<el-form-item>
<el-popconfirm :title="'确认注水 ' + fmt(form.amount) + '?水位将上升。'" @confirm="submitAdjust(1)">
<el-button slot="reference" type="success" plain icon="el-icon-top" :loading="adjusting" :disabled="!form.amount">注水</el-button>
</el-popconfirm>
<el-popconfirm :title="'确认抽水 ' + fmt(form.amount) + '?水位将下降。'" @confirm="submitAdjust(2)">
<el-button slot="reference" type="danger" plain icon="el-icon-bottom" :loading="adjusting" :disabled="!form.amount" style="margin-left:8px">抽水</el-button>
</el-popconfirm>
</el-form-item>
<el-form-item>
<el-popconfirm title="把人工调整累计值归零(只清人工部分,不动真实流水;历史记录保留)?" @confirm="submitReset">
<el-button slot="reference" type="warning" plain icon="el-icon-refresh-left" :loading="resetting">重置调整</el-button>
</el-popconfirm>
<el-popconfirm title="按 tt_upgrade_record 全量重算累计下注/返奖,覆盖 Redis 计数(用于纠正漂移)?" @confirm="submitRecalc">
<el-button slot="reference" plain icon="el-icon-refresh" :loading="recalcing" style="margin-left:8px">按历史重算</el-button>
</el-popconfirm>
</el-form-item>
</el-form>
</el-card>
<div class="section-head">
<h4 style="margin:0">调整历史</h4>
<el-button size="mini" type="text" icon="el-icon-refresh" @click="loadLogs">刷新</el-button>
</div>
<el-table v-loading="loading" :data="logs" 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 :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.watermarkBefore == null ? '—' : scope.row.watermarkBefore + '%' }}</template>
</el-table-column>
<el-table-column label="调整后水位" min-width="110" align="center">
<template slot-scope="scope">{{ scope.row.watermarkAfter == null ? '—' : scope.row.watermarkAfter + '%' }}</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="query.size"
:current-page.sync="query.page"
:page-sizes="[10,20,50]"
@size-change="loadLogs"
@current-change="loadLogs"
/>
</div>
</template>
<script>
import {
getWatermarkInfo,
saveWatermarkSetting,
adjustWatermark,
resetWatermarkAdjust,
getWatermarkLogs,
recalcWatermark
} from '@/api/skins/luckyUpgrade/rewardPool/api'
/** 样本不足时的兜底标定系数,与后端 UpgradeWatermarkService.DEFAULT_CALIBRATION 保持一致 */
const FALLBACK_CALIBRATION = 1.19
export default {
name: 'LuckyUpgradeRewardPool',
data() {
return {
showGuide: false,
loading: false,
saving: false,
adjusting: false,
resetting: false,
recalcing: false,
info: {
enabled: true,
watermark: 0,
rtp: 0,
totalBet: 0,
totalWin: 0,
blankBet: 0,
blankWin: 0,
adjust: 0,
targetWatermark: 30,
gain: 2,
targetGap: 0,
targetCenter: null,
centerNow: null,
calibration: null,
effectiveCalibration: FALLBACK_CALIBRATION
},
logs: [],
total: 0,
form: {
targetWatermark: 30,
enabled: true,
gain: 2,
luckyValueMode: 'raw',
rangeMin: undefined,
rangeMax: undefined,
amount: undefined,
remark: ''
},
query: { page: 1, size: 10 }
}
},
computed: {
gapText() {
const g = Number(this.info.targetGap)
if (isNaN(g)) return '—'
const sign = g > 0 ? '+' : ''
return sign + g.toFixed(2) + '%' + (g > 0 ? '(下一轮放水)' : (g < 0 ? '(下一轮收紧)' : ''))
},
gapColor() {
const g = Number(this.info.targetGap)
if (isNaN(g) || g === 0) return '#4f4130'
return g > 0 ? '#457450' : '#a4485a'
},
/**
* 按照输入的随机数区间,反推这个区间能维持的水位范围。
* 随机数中心的可表达范围 = 下限 + [10%, 90%] × 区间宽度(超出会被夹取),
* 再代回 水位 = 1 − 100 / (标定系数 × 随机数) 即得。
*/
rangeWatermark() {
const lo = Number(this.form.rangeMin)
const hi = Number(this.form.rangeMax)
if (isNaN(lo) || isNaN(hi) || lo <= 0 || hi <= lo) return null
const k = Number(this.info.effectiveCalibration) || FALLBACK_CALIBRATION
const w = hi - lo
const watermarkAt = (center) => (1 - 100 / (k * center)) * 100
return { min: watermarkAt(lo + 0.1 * w), max: watermarkAt(lo + 0.9 * w) }
},
rangeResult() {
if (this.form.rangeMin == null || this.form.rangeMax == null) {
return { text: '—(填入区间后自动换算)', color: '#7a664e' }
}
const r = this.rangeWatermark
if (!r) return { text: '—(需满足 0 < 下限 < 上限)', color: '#7a664e' }
const text = r.min.toFixed(1) + '% ~ ' + r.max.toFixed(1) + '%'
const t = Number(this.form.targetWatermark)
if (isNaN(t)) return { text, color: '#4f4130' }
const ok = t >= r.min && t <= r.max
return {
text: ok ? text : text + ' ⚠ 目标水位 ' + t.toFixed(1) + '% 不在范围内',
color: ok ? '#457450' : '#a4485a'
}
}
},
created() {
this.loadInfo()
this.loadLogs()
},
methods: {
fmt(v) {
const n = Number(v)
return isNaN(n) ? '0.00' : n.toFixed(2)
},
pct(v) {
const n = Number(v)
return isNaN(n) ? '0.00%' : n.toFixed(2) + '%'
},
applyInfo(data) {
if (!data) return
this.info = data
this.form.targetWatermark = data.targetWatermark == null ? 30 : Number(data.targetWatermark)
this.form.enabled = data.enabled !== false
this.form.gain = data.gain == null ? 2 : Number(data.gain)
this.form.luckyValueMode = data.luckyValueMode || 'raw'
},
loadInfo() {
getWatermarkInfo().then(res => this.applyInfo(res.data))
},
loadLogs() {
this.loading = true
getWatermarkLogs(this.query)
.then(res => {
this.logs = (res.data && res.data.rows) || []
this.total = (res.data && res.data.total) || 0
this.loading = false
})
.catch(() => {
this.loading = false
})
},
requireRemark() {
if (this.form.remark && this.form.remark.trim()) return true
this.$message.warning('请填写调整原因')
return false
},
/** 目标水位 / 策略开关改动即保存(本页没有保存按钮) */
autoSaveSetting() {
if (this.saving) return
const t = Number(this.form.targetWatermark)
if (isNaN(t) || t <= -50 || t >= 95) {
this.$message.error('目标水位需在 -50% ~ 95% 之间')
this.loadInfo()
return
}
if (t === Number(this.info.targetWatermark) && this.form.enabled === (this.info.enabled !== false)) {
return
}
this.saving = true
saveWatermarkSetting({
enabled: this.form.enabled,
luckyValueMode: this.form.luckyValueMode,
targetWatermark: t,
gain: Number(this.form.gain) || 2
})
.then(res => {
this.applyInfo(res.data)
this.$message.success('已生效')
this.saving = false
})
.catch(() => {
this.saving = false
this.loadInfo()
})
},
submitAdjust(type) {
if (!this.form.amount || this.form.amount <= 0) {
this.$message.warning('请输入大于 0 的金额')
return
}
if (!this.requireRemark()) return
this.adjusting = true
adjustWatermark({ type, amount: this.form.amount, remark: this.form.remark.trim() })
.then(res => {
this.applyInfo(res.data)
this.$message.success((type === 1 ? '注水' : '抽水') + '成功,当前水位 ' + this.pct(res.data.watermark))
this.form.amount = undefined
this.form.remark = ''
this.query.page = 1
this.loadLogs()
this.adjusting = false
})
.catch(() => {
this.adjusting = false
})
},
submitReset() {
if (!this.requireRemark()) return
this.resetting = true
resetWatermarkAdjust({ remark: this.form.remark.trim() })
.then(res => {
this.applyInfo(res.data)
this.$message.success('重置完成,清除净调整 ' + this.fmt(res.data.cleared))
this.form.remark = ''
this.query.page = 1
this.loadLogs()
this.resetting = false
})
.catch(() => {
this.resetting = false
})
},
submitRecalc() {
this.recalcing = true
recalcWatermark()
.then(res => {
this.applyInfo(res.data)
this.$message.success('已按历史流水重算累计量')
this.recalcing = false
})
.catch(() => {
this.recalcing = false
})
}
}
}
</script>
<style scoped lang="scss">
.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; line-height: 1.8; }
.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: 12px; }
.stat-card { display: flex; align-items: center; gap: 12px; background: #fffdfa; border: 1px solid #eadfd0; border-radius: 8px; padding: 14px 16px; }
.sc-icon { width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0; }
.sc-terra { background: rgba(154, 92, 53, .22); color: #9a5c35; }
.sc-grass { background: rgba(69, 116, 80, .22); color: #457450; }
.sc-daisy { background: rgba(168, 121, 31, .24); color: #a8791f; }
.sc-rose { background: rgba(181, 116, 116, .24); color: #b57474; }
.sc-body small { color: #7a664e; display: block; }
.sc-body h3 { margin: 2px 0 0; font-size: 18px; }
.band-live { display: flex; flex-wrap: wrap; gap: 10px 26px; align-items: center; background: #f6efe3; border: 1px solid #eadfd0; border-radius: 8px; padding: 10px 16px; margin-bottom: 14px; }
.bl-item { display: flex; align-items: center; gap: 6px; }
.bl-label { color: #7a664e; font-size: 12px; }
.bl-value { color: #4f4130; font-size: 14px; }
.op-card { margin-bottom: 16px; border-radius: 8px; }
.op-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; }
.fld-unit { color: #7a664e; font-size: 13px; margin-left: 6px; }
.fld-note { color: #7a664e; font-size: 12px; }
.fld-note b { color: #9a5c35; font-size: 14px; }
.op-card >>> .el-form-item { margin-bottom: 0; }
.setting-flags { display: flex; align-items: center; margin-top: 14px; }
.range-calc { display: flex; align-items: center; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.rc-sep { color: #7a664e; font-size: 13px; }
.rc-arrow { color: #9a5c35; font-size: 13px; margin: 0 4px; }
.rc-result { font-size: 14px; }
.help-ref {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 50%;
border: 1px solid #eadfd0; color: #9a5c35;
font-size: 12px; cursor: help; user-select: none;
}
.help-ref:hover { border-color: #9a5c35; background: #f6efe3; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin: 16px 0 10px; }
</style>
<style lang="scss">
.wm-help .wm-help-body { font-size: 12px; line-height: 1.9; color: #5f4f3d; }
.wm-help .wm-help-body p { margin: 0 0 8px; }
.wm-help .wm-help-body p:last-child { margin-bottom: 0; }
.wm-help .wm-help-body code { background: #f6efe3; padding: 1px 4px; border-radius: 3px; color: #9a5c35; }
</style>
+11 -77
View File
@@ -50,39 +50,37 @@
<el-table-column label="奖励区间" align="center" prop="awardSection" />
<el-table-column label="启用状态" align="center" prop="status">
<template slot-scope="scope">
<el-switch
<capsule-switch
v-model="scope.row.status"
:active-value="'0'"
:inactive-value="'1'"
inactive-color="#D9534F"
active-color="#5CB85C"
inactive-text="是"
active-text="否"
active-value="0"
inactive-value="1"
active-text="启用"
inactive-text="禁用"
@change="updateUserStatus(scope.row)"
></el-switch>
/>
</template>
</el-table-column>
<el-table-column label="排序" align="center" prop="sortBy" />
<el-table-column label="条件类型" align="center" prop="conditionType">
<template slot-scope="scope">
<div v-if="scope.row.conditionType == '0'" class="df">
<span class="ds" style="background: #00a65a;"></span>
<span class="ds" style="background: #457450;"></span>
日充值
</div>
<div v-if="scope.row.conditionType == '1'" class="df">
<span class="ds" style="background: #00a65a;"></span>
<span class="ds" style="background: #457450;"></span>
周充值
</div>
<div v-if="scope.row.conditionType == '2'" class="df">
<span class="ds" style="background: #f39c12;"></span>
<span class="ds" style="background: #8f6418;"></span>
月充值
</div>
<div v-if="scope.row.conditionType == '3'" class="df">
<span class="ds" style="background: #DD4B39;"></span>
<span class="ds" style="background: #a4485a;"></span>
总充值
</div>
<div v-if="scope.row.conditionType == '4'" class="df">
<span class="ds" style="background: #DD4B39;"></span>
<span class="ds" style="background: #a4485a;"></span>
单笔充值
</div>
</template>
@@ -427,68 +425,4 @@ export default {
border-radius: 50%;
display: inline-block;
}
::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;
}
}
</style>
@@ -50,19 +50,19 @@
<el-table-column align="center" prop="status" label="状态">
<template slot-scope="scope">
<div v-if="scope.row.status == '0'" class="df">
<span style=" background-color: #3c8dbc;" class="ds"></span>
<span style=" background-color: #9a5c35;" class="ds"></span>
背包
</div>
<div v-else-if="scope.row.status == '5'" class="df">
<span class="ds" style="background-color: #f39c12;"></span>
<span class="ds" style="background-color: #8f6418;"></span>
{{ (source === '1' || source == 1) ? '经典开箱分解' : '对战自动分解' }}
</div>
<div v-else-if="scope.row.status == '1'" class="df">
<span class="ds" style="background-color: #DD4B39;"></span>
<span class="ds" style="background-color: #a4485a;"></span>
在背包不显示
</div>
<div v-else class="df">
<span class="ds" style="background-color: gray;"></span>
<span class="ds" style="background-color: #7a664e;"></span>
{{ scope.row.status ? String(scope.row.status) : '(未知状态)' }}
</div>
</template>
+10 -78
View File
@@ -33,30 +33,26 @@
<!-- 仅在通用“宝箱分类”菜单展示是否对战类型;对战/经典分类已由路由锁定类型 -->
<el-table-column v-if="$route.query.type == null" align="center" prop="isFightType" label="是否对战类型">
<template slot-scope="scope">
<el-switch
<capsule-switch
v-model="scope.row.isFightType"
:active-value="'0'"
:inactive-value="'1'"
inactive-color="#D9534F"
active-color="#5CB85C"
inactive-text="是"
active-text="否"
active-value="0"
inactive-value="1"
active-text="是"
inactive-text="否"
@change="updateUserStatus(scope.row)"
></el-switch>
/>
</template>
</el-table-column>
<el-table-column align="center" prop="isShow" label="是否显示">
<template slot-scope="scope">
<el-switch
<capsule-switch
v-model="scope.row.isShow"
:active-value="'0'"
:inactive-value="'1'"
active-color="#5CB85C"
inactive-color="#D9534F"
active-value="1"
inactive-value="0"
active-text="显示"
inactive-text="隐藏"
@change="updateUserStatus(scope.row)"
></el-switch>
/>
</template>
</el-table-column>
<el-table-column align="center" prop="sort" label="宝箱分类排序"></el-table-column>
@@ -213,68 +209,4 @@ export default {
.home {
padding: 20px;
}
::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;
}
}
</style>
+3 -3
View File
@@ -353,7 +353,7 @@
<el-table-column v-if="false" align="center" prop="compOdds" label="补偿数量"></el-table-column>
<el-table-column v-if="false" align="center" prop="realCompOdds" label="真实补偿数量" width="100"></el-table-column>
<el-table-column align="center" label="操作">
<el-table-column width="170" align="center" label="操作">
<template slot-scope="scope">
<el-button type="text" @click="handleChange(scope.row)">修改</el-button>
<el-button type="text" @click="handleView(scope.row)">查看</el-button>
@@ -1129,7 +1129,7 @@ export default {
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e4e7ed;
border: 1px solid #eadfd0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
@@ -1146,7 +1146,7 @@ export default {
object-fit: contain;
border-radius: 4px;
flex-shrink: 0;
background: #f5f5f5;
background: #f3ece0;
}
.ornament-info {
flex: 1;
+15 -85
View File
@@ -265,7 +265,7 @@
<el-table-column align="center" label="箱内饰品总价值" prop="aggregateAmount" />
<el-table-column align="center" label="每轮利润" prop="profit" />
<el-table-column align="center" label="每轮利润率" prop="profitMargin" />
<el-table-column
<el-table-column width="130"
align="center"
class-name="small-padding fixed-width"
label="操作"
@@ -320,16 +320,14 @@
<el-input-number v-model="form.sort" :min="1" :max="1000" label="请选择"></el-input-number>
</el-form-item>
<el-form-item label="是否对战宝箱" prop="isFight">
<el-switch
<capsule-switch
v-model="form.isFight"
:active-value="'0'"
:inactive-value="'1'"
active-color="#5CB85C"
inactive-color="#D9534F"
active-text="否"
inactive-text="是"
active-value="1"
inactive-value="0"
active-text="是"
inactive-text="否"
:disabled="$route.query.isFight != null"
></el-switch>
/>
</el-form-item>
<!-- 宝箱类型已隐藏(编辑时保留原值提交)
<el-form-item label="宝箱类型" prop="boxType">
@@ -347,27 +345,23 @@
<el-input-number v-model="form.compAmount" :min="0" :max="1000" label="请选择"></el-input-number>
</el-form-item>
<el-form-item label="宝箱状态" prop="status">
<el-switch
<capsule-switch
v-model="form.status"
:active-value="'1'"
:inactive-value="'0'"
active-color="#D9534F"
inactive-color="#5CB85C"
active-value="0"
inactive-value="1"
active-text="正常"
inactive-text="停用"
></el-switch>
/>
</el-form-item>
<el-form-item label="是否首页推荐" prop="isHome">
<el-switch
<capsule-switch
v-model="form.isHome"
:active-value="'1'"
:inactive-value="'0'"
active-color="#D9534F"
inactive-color="#5CB85C"
active-value="1"
inactive-value="0"
active-text="是"
inactive-text="否"
@change="hh()"
></el-switch>
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
@@ -1381,70 +1375,6 @@ export default {
text-decoration-style: dashed;
text-underline-position: under;
}
::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;
}
}
.magicbox {
margin: 0 auto;
margin-top: 5px;
@@ -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"
@@ -16,7 +16,7 @@
<el-table-column align="left" label="获胜" style="margin-left: 5px;">
<template slot-scope="scope">
<span v-if="scope.row.isWin">
<span class="ds" style="background-color: #00a65a;"></span>
<span class="ds" style="background-color: #457450;"></span>
是
<el-button
type="text"
@@ -25,7 +25,7 @@
>赢家所得饰品</el-button>
</span>
<span v-else>
<span class="ds" style="background-color: #dd4b39;"></span>否
<span class="ds" style="background-color: #a4485a;"></span>否
</span>
</template>
</el-table-column>
+2 -2
View File
@@ -48,11 +48,11 @@
>
<template slot-scope="scope">
<div v-if="scope.row.status == '0'" class="df">
<span style=" background-color: #00a65a;" class="ds"></span>
<span style=" background-color: #457450;" class="ds"></span>
等待中
</div>
<div v-if="scope.row.status == '2'" class="df">
<span class="ds" style="background-color: #dd4b39;"></span>
<span class="ds" style="background-color: #a4485a;"></span>
已结束
<el-button type="text" @click="handleView(scope.row)" style="margin-left: 45px;">查看结果</el-button>
</div>
@@ -427,7 +427,7 @@ export default {
.section-title {
font-size: 16px;
font-weight: bold;
color: #303133;
color: #4f4130;
padding: 8px 0;
}
</style>
@@ -73,7 +73,7 @@
<ol style="margin-top: 6px; padding-left: 16px;">
<li><strong>档位共享目标数</strong>:多个分组共用同一价格档位时,系统维持该档位的总目标房间数(target_count),而非各分组独立统计。</li>
<li><strong>随机选择分组</strong>:机器人从所有符合档位条件的分组中<strong>随机选择一个</strong>来创建房间。这意味着某些分组可能被选中多次,而另一些分组可能暂时未被选中。</li>
<li><strong>价位匹配规则</strong>:根据分组内所有宝箱的总价进行匹配,计算公式为:<code style="background:#f5f5f5;padding:2px 4px;">Σ(箱数 × 单价)</code>。</li>
<li><strong>价位匹配规则</strong>:根据分组内所有宝箱的总价进行匹配,计算公式为:<code style="background:#f3ece0;padding:2px 4px;">Σ(箱数 × 单价)</code>。</li>
</ol>
</div>
</el-alert>
@@ -126,7 +126,7 @@
<dict-tag :options="dict.type.fight_model" :value="scope.row.model"/>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<el-table-column width="240" label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"

Some files were not shown because too many files have changed in this diff Show More