修改河南项目的管理后台ui

This commit is contained in:
2026-09-27 19:12:59 +08:00
parent 34c7fa9d72
commit 3248ee4b83
31 changed files with 1605 additions and 558 deletions
+127
View File
@@ -160,6 +160,133 @@ SELECT COUNT(*) FROM sys_role_menu WHERE menu_id=3208;
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;
+251
View File
@@ -7,6 +7,257 @@
---
## 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
- **头像管理:「是否默认」可同时多条为「是」的缺陷修复(唯一性约束 + 存量数据清洗 + 开关交互)**
+9 -1
View File
@@ -1,5 +1,5 @@
import request from '@/axios'
import type { RegisterUserType, SmsLoginType, UserType } from './types'
import type { DevLoginType, RegisterUserType, SmsLoginType, UserType } from './types'
interface RoleParams {
roleName: string
@@ -9,6 +9,14 @@ 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 })
}
+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
+24 -16
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" />
+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="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>
</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, precheckPhoneApi, 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,12 +31,20 @@ 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)
@@ -62,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
}
}
},
@@ -162,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) => {
@@ -287,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 || [] }
@@ -329,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%;
@@ -361,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) {
@@ -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
@@ -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
@@ -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:
# 过滤开关
+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
+8 -2
View File
@@ -190,9 +190,15 @@ $line: $cc-border-3;
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 {
@@ -255,7 +255,7 @@ export default {
poolStatsList: [],
multiplierTable: [
{ color: 'gold', A: 1, B: 1, C: 1, D: 1 },
{ color: '#a4485a', A: 1, B: 1, C: 1, D: 1 },
{ color: 'red', A: 1, B: 1, C: 1, D: 1 },
{ color: 'purple', A: 1, B: 1, C: 1, D: 1 }
],
// 奖池调节相关
@@ -353,7 +353,9 @@ export default {
},
syncMultiplierTable() {
const multipliers = this.config.multipliers || [];
const colors = ['gold', '#a4485a', 'purple'];
// 这里的 color 必须与后端契约一致(gold/red/purple),也是 cellCounts() 的 key:
// 写成颜色值(如 #a4485a)会同时导致标签显示成色号、红色那一行算不到盘面格数(整行倍率被跳过)
const colors = ['gold', 'red', 'purple'];
const symbols = ['A', 'B', 'C', 'D'];
this.multiplierTable = colors.map(color => {
const row = { color };
+355 -180
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>
@@ -155,17 +201,39 @@ export default {
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" }
];
@@ -480,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;
@@ -590,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;
@@ -627,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 {
@@ -675,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 {
@@ -774,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); }
@@ -798,6 +906,73 @@ export default {
.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;
+1 -1
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"
+1 -1
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" />
+1 -1
View File
@@ -7,7 +7,7 @@ function resolve(dir) {
const CompressionPlugin = require('compression-webpack-plugin')
const name = process.env.VUE_APP_TITLE || '河南项目' // 网页标题
const name = process.env.VUE_APP_TITLE || '凯科佰娱' // 网页标题
const port = process.env.port || process.env.npm_config_port || 80 // 端口
@@ -9,7 +9,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 推广后台 Web 配置:注册维护拦截器。
* <p>
* 对 /promo/** 生效,放行登录/注册接口(登录/注册由 PromoLoginController 自行提示维护)。
* 对 /promo/** 生效,放行登录/注册/开发用登录接口(这几条由 PromoLoginController 自行提示维护)。
*
* @author ruoyi
*/
@@ -23,6 +23,6 @@ public class PromoWebConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(promoMaintenanceInterceptor)
.addPathPatterns("/promo/**")
.excludePathPatterns("/promo/smsLogin", "/promo/register");
.excludePathPatterns("/promo/smsLogin", "/promo/register", "/promo/devLogin");
}
}
@@ -0,0 +1,18 @@
package com.ruoyi.promo.contract.request;
import lombok.Data;
/**
* 推广后台「开发用」账号密码登录请求体。
* <p>
* 仅供本地 / 测试环境联调使用,由 sys_config 的 {@code promoDevLoginEnabled} 控制是否可用。
*/
@Data
public class DevLoginRequest {
/** 登录账号:推广账号(主播/招商/卡商)填手机号;超管可填登录账号或手机号 */
private String account;
/** 登录密码(明文,服务端按 BCrypt 校验) */
private String password;
}
@@ -21,12 +21,14 @@ import com.ruoyi.admin.mapper.PromoAdminMapper;
import com.ruoyi.admin.mapper.TtUserMapper;
import com.ruoyi.common.redis.config.RedisLock;
import com.ruoyi.domain.other.ApiVerificationCodeLoginBody;
import com.ruoyi.promo.contract.request.DevLoginRequest;
import com.ruoyi.promo.contract.request.RegisterRequest;
import com.ruoyi.promo.contract.request.UpdateInvitationCodeRequest;
import com.ruoyi.promo.contract.response.GetShareDomainResponse;
import com.ruoyi.promo.service.PromoLoginService;
import com.ruoyi.system.service.ISysConfigService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -59,6 +61,10 @@ public class PromoLoginController extends BaseController {
@Autowired
private RedisLock redisLock;
/** 开发用账号密码登录开关:application-dev.yml 置 true,其余环境不配置即为 false */
@Value("${promo.dev-login.enabled:false}")
private boolean devLoginEnabled;
@PostMapping("/getInfo")
public R<Map<String, Object>> getPromoInfo() {
var loginUser = getLoginUser();
@@ -271,6 +277,30 @@ public class PromoLoginController extends BaseController {
}
}
/**
* 开发用账号密码登录:仅本地/测试联调使用,绕过短信验证码直接按密码登录。
* <p>
* 由配置项 {@code promo.dev-login.enabled} 控制(默认 false,只有 dev profile 置 true);
* 生产 profile 不配置该项 ⇒ 接口恒返回「账号密码登录未开启」。
*/
@Anonymous
@PostMapping("/devLogin")
public AjaxResult devLogin(@RequestBody DevLoginRequest body) {
if (!devLoginEnabled) {
return AjaxResult.error("账号密码登录未开启");
}
if ("1".equals(configService.selectConfigByKey("promoMaintenance"))) {
return AjaxResult.error("系统维护中,请稍后再试");
}
try {
AjaxResult ajax = AjaxResult.success();
ajax.put(Constants.TOKEN, promoLoginService.devLogin(body.getAccount(), body.getPassword()));
return ajax;
} catch (IllegalArgumentException e) {
return AjaxResult.error(e.getMessage());
}
}
@Anonymous
@PostMapping("/register")
public AjaxResult register(@RequestBody RegisterRequest request) {
@@ -18,6 +18,14 @@ public interface PromoLoginService {
String createUserToken(String phoneNumber);
/**
* 开发用账号密码登录:仅本地/测试环境联调使用,由 sys_config 的 promoDevLoginEnabled 控制是否可用。
* 账号口径与 {@link #createUserToken} 一致(推广账号手机号 / 超管账号),只是把短信验证码换成密码校验。
*
* @return 推广后台 token
*/
String devLogin(String account, String password);
R<TtUser> register(RegisterRequest request);
R<TtUser> updateInvitationCode(UpdateInvitationCodeRequest request);
@@ -14,6 +14,7 @@ import com.ruoyi.admin.service.TtUserAvatarService;
import com.ruoyi.admin.service.TtUserService;
import com.ruoyi.admin.util.RandomUtils;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.domain.entity.UserData;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.utils.DateUtils;
@@ -214,8 +215,48 @@ public class PromoLoginServiceImpl implements PromoLoginService {
@Override
public String createUserToken(String phoneNumber) {
sysLoginService.validateBlackIp(phoneNumber);
LoginUser loginUser = (LoginUser) apiUserDetailsService.loadUserByUsername("promo_user_" + phoneNumber);
return issueToken(loginUser, phoneNumber);
}
/**
* 开发用账号密码登录(仅本地/测试联调,生产 profile 未配置 promo.dev-login.enabled 即为关闭)。
* <p>
* 账号解析完全复用 {@link #createUserToken} 的 {@code promo_user_} 入口,
* 因此推广账号(主播/招商/卡商,登录名即手机号)与超管(promo_admin 的 username 或 phone_number)
* 都能登录,区别只是「短信验证码」这一步换成了 BCrypt 密码校验。
*/
@Override
public String devLogin(String account, String password) {
String acc = StringUtils.trim(account);
String rawPassword = password == null ? "" : password;
if (StringUtils.isEmpty(acc) || StringUtils.isEmpty(rawPassword)) {
throw new IllegalArgumentException("账号和密码不能为空");
}
LoginUser loginUser;
try {
loginUser = (LoginUser) apiUserDetailsService.loadUserByUsername("promo_user_" + acc);
} catch (Exception e) {
// 账号不存在 / 已停用 / 已删除 一律收敛为同一句提示,避免这个接口被拿来枚举账号
log.warn("[推广后台·开发用密码登录] 账号加载失败 account={} reason={}", acc, e.getMessage());
throw new IllegalArgumentException("账号或密码错误");
}
UserData userData = loginUser.getUserData();
if (userData == null || StringUtils.isEmpty(userData.getPassword())
|| !SecurityUtils.matchesPassword(rawPassword, userData.getPassword())) {
log.warn("[推广后台·开发用密码登录] 密码校验失败 account={}", acc);
throw new IllegalArgumentException("账号或密码错误");
}
return issueToken(loginUser, acc);
}
/**
* 校验黑名单 IP 并签发推广后台 token(短信登录与开发用密码登录共用)
*/
private String issueToken(LoginUser loginUser, String blackIpAccount) {
sysLoginService.validateBlackIp(blackIpAccount);
// 超级管理员来自 promo_admin 表,其 id 与 tt_user 不同表,不能按 id 回写 tt_user 登录信息
if (!UserType.SUPER_ADMIN.getCode().equals(loginUser.getUserData().getUserType())) {
recordLoginInfo(loginUser.getUserId());
@@ -18,8 +18,8 @@
SELECT
COALESCE(SUM(CASE WHEN is_blank THEN 0 ELSE r.amount_consumed END), 0) AS totalBet,
COALESCE(SUM(CASE WHEN is_blank THEN 0 ELSE r.gain_ornaments_price END), 0) AS totalWin,
COALESCE(SUM(CASE WHEN is_blank THEN r.amount_consumed END, 0), 0) AS blankBet,
COALESCE(SUM(CASE WHEN is_blank THEN r.gain_ornaments_price END, 0), 0) AS blankWin,
COALESCE(SUM(CASE WHEN is_blank THEN r.amount_consumed END), 0) AS blankBet,
COALESCE(SUM(CASE WHEN is_blank THEN r.gain_ornaments_price END), 0) AS blankWin,
COALESCE(SUM(CASE
WHEN NOT is_blank AND r.round_target IS NOT NULL THEN r.amount_consumed
ELSE 0 END), 0) AS calBet,