diff --git a/docs/updata_sql.md b/docs/updata_sql.md index 14bab01..e234e9d 100644 --- a/docs/updata_sql.md +++ b/docs/updata_sql.md @@ -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; diff --git a/docs/修改记录.md b/docs/修改记录.md index a41ec19..2db92b0 100644 --- a/docs/修改记录.md +++ b/docs/修改记录.md @@ -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` 的 `` 由 `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 - **头像管理:「是否默认」可同时多条为「是」的缺陷修复(唯一性约束 + 存量数据清洗 + 开关交互)** diff --git a/promo-ui2/src/api/login/index.ts b/promo-ui2/src/api/login/index.ts index 0cf21fe..e7fd3a1 100644 --- a/promo-ui2/src/api/login/index.ts +++ b/promo-ui2/src/api/login/index.ts @@ -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 }) } diff --git a/promo-ui2/src/api/login/types.ts b/promo-ui2/src/api/login/types.ts index a8a9830..70abdb8 100644 --- a/promo-ui2/src/api/login/types.ts +++ b/promo-ui2/src/api/login/types.ts @@ -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 diff --git a/promo-ui2/src/layout/components/ToolHeader.vue b/promo-ui2/src/layout/components/ToolHeader.vue index 8ca1c18..c636052 100644 --- a/promo-ui2/src/layout/components/ToolHeader.vue +++ b/promo-ui2/src/layout/components/ToolHeader.vue @@ -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> diff --git a/promo-ui2/src/store/modules/app.ts b/promo-ui2/src/store/modules/app.ts index 53b5352..72d310d 100644 --- a/promo-ui2/src/store/modules/app.ts +++ b/promo-ui2/src/store/modules/app.ts @@ -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 = () => { diff --git a/promo-ui2/src/views/BusinessManage/index.vue b/promo-ui2/src/views/BusinessManage/index.vue index 50a8a94..e9b0ac9 100644 --- a/promo-ui2/src/views/BusinessManage/index.vue +++ b/promo-ui2/src/views/BusinessManage/index.vue @@ -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"> diff --git a/promo-ui2/src/views/CardMerchant/index.vue b/promo-ui2/src/views/CardMerchant/index.vue index 1736d90..60eee45 100644 --- a/promo-ui2/src/views/CardMerchant/index.vue +++ b/promo-ui2/src/views/CardMerchant/index.vue @@ -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 diff --git a/promo-ui2/src/views/CommissionReport/index.vue b/promo-ui2/src/views/CommissionReport/index.vue index 2813344..50c627c 100644 --- a/promo-ui2/src/views/CommissionReport/index.vue +++ b/promo-ui2/src/views/CommissionReport/index.vue @@ -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" /> diff --git a/promo-ui2/src/views/Home/index.vue b/promo-ui2/src/views/Home/index.vue index b0061a5..b47c78b 100644 --- a/promo-ui2/src/views/Home/index.vue +++ b/promo-ui2/src/views/Home/index.vue @@ -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> diff --git a/promo-ui2/src/views/Login/Login.vue b/promo-ui2/src/views/Login/Login.vue index 3e92f1a..e3d0a20 100644 --- a/promo-ui2/src/views/Login/Login.vue +++ b/promo-ui2/src/views/Login/Login.vue @@ -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; - } - /* ========================= 登录主按钮 ========================= */ diff --git a/promo-ui2/src/views/Login/components/LoginForm.vue b/promo-ui2/src/views/Login/components/LoginForm.vue index aa5045d..1e9be4c 100644 --- a/promo-ui2/src/views/Login/components/LoginForm.vue +++ b/promo-ui2/src/views/Login/components/LoginForm.vue @@ -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) { diff --git a/promo-ui2/src/views/SubordinateRecharge/index.vue b/promo-ui2/src/views/SubordinateRecharge/index.vue index 43cfac0..d2f17f8 100644 --- a/promo-ui2/src/views/SubordinateRecharge/index.vue +++ b/promo-ui2/src/views/SubordinateRecharge/index.vue @@ -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> diff --git a/promo-ui2/src/views/subFlow/index.vue b/promo-ui2/src/views/subFlow/index.vue index f2a7178..a56c5e3 100644 --- a/promo-ui2/src/views/subFlow/index.vue +++ b/promo-ui2/src/views/subFlow/index.vue @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index 9ee5228..7896850 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 22adf6c..210d28f 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -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: # 过滤开关 diff --git a/ruoyi-ui/.env.development b/ruoyi-ui/.env.development index 6139601..d395f30 100644 --- a/ruoyi-ui/.env.development +++ b/ruoyi-ui/.env.development @@ -1,5 +1,5 @@ # 页面标题 -VUE_APP_TITLE = 河南项目 +VUE_APP_TITLE = 凯科佰娱 # 开发环境配置 ENV = 'development' diff --git a/ruoyi-ui/.env.production b/ruoyi-ui/.env.production index 4b2b327..2e9f7b2 100644 --- a/ruoyi-ui/.env.production +++ b/ruoyi-ui/.env.production @@ -1,5 +1,5 @@ # 页面标题 -VUE_APP_TITLE = 河南项目 +VUE_APP_TITLE = 凯科佰娱 # 生产环境配置 ENV = 'production' diff --git a/ruoyi-ui/.env.staging b/ruoyi-ui/.env.staging index 1faba05..23846df 100644 --- a/ruoyi-ui/.env.staging +++ b/ruoyi-ui/.env.staging @@ -1,5 +1,5 @@ # 页面标题 -VUE_APP_TITLE = 河南项目 +VUE_APP_TITLE = 凯科佰娱 NODE_ENV = production diff --git a/ruoyi-ui/src/assets/styles/skin.scss b/ruoyi-ui/src/assets/styles/skin.scss index 0d192b6..43326b6 100644 --- a/ruoyi-ui/src/assets/styles/skin.scss +++ b/ruoyi-ui/src/assets/styles/skin.scss @@ -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 { diff --git a/ruoyi-ui/src/views/game/wheel/config/index.vue b/ruoyi-ui/src/views/game/wheel/config/index.vue index 2fac90d..2e8d500 100644 --- a/ruoyi-ui/src/views/game/wheel/config/index.vue +++ b/ruoyi-ui/src/views/game/wheel/config/index.vue @@ -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 }; diff --git a/ruoyi-ui/src/views/index.vue b/ruoyi-ui/src/views/index.vue index c69ab8b..6ce8a01 100644 --- a/ruoyi-ui/src/views/index.vue +++ b/ruoyi-ui/src/views/index.vue @@ -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; diff --git a/ruoyi-ui/src/views/login.vue b/ruoyi-ui/src/views/login.vue index b80380b..320250c 100644 --- a/ruoyi-ui/src/views/login.vue +++ b/ruoyi-ui/src/views/login.vue @@ -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" diff --git a/ruoyi-ui/src/views/register.vue b/ruoyi-ui/src/views/register.vue index adb62e2..f560770 100644 --- a/ruoyi-ui/src/views/register.vue +++ b/ruoyi-ui/src/views/register.vue @@ -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" /> diff --git a/ruoyi-ui/vue.config.js b/ruoyi-ui/vue.config.js index 0166b4c..cf9f5a2 100644 --- a/ruoyi-ui/vue.config.js +++ b/ruoyi-ui/vue.config.js @@ -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 // 端口 diff --git a/skins-promo/src/main/java/com/ruoyi/promo/config/PromoWebConfig.java b/skins-promo/src/main/java/com/ruoyi/promo/config/PromoWebConfig.java index ae04338..f88d5ef 100644 --- a/skins-promo/src/main/java/com/ruoyi/promo/config/PromoWebConfig.java +++ b/skins-promo/src/main/java/com/ruoyi/promo/config/PromoWebConfig.java @@ -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"); } } diff --git a/skins-promo/src/main/java/com/ruoyi/promo/contract/request/DevLoginRequest.java b/skins-promo/src/main/java/com/ruoyi/promo/contract/request/DevLoginRequest.java new file mode 100644 index 0000000..ec2ec05 --- /dev/null +++ b/skins-promo/src/main/java/com/ruoyi/promo/contract/request/DevLoginRequest.java @@ -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; +} diff --git a/skins-promo/src/main/java/com/ruoyi/promo/controller/PromoLoginController.java b/skins-promo/src/main/java/com/ruoyi/promo/controller/PromoLoginController.java index 03ce613..8e9adba 100644 --- a/skins-promo/src/main/java/com/ruoyi/promo/controller/PromoLoginController.java +++ b/skins-promo/src/main/java/com/ruoyi/promo/controller/PromoLoginController.java @@ -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) { diff --git a/skins-promo/src/main/java/com/ruoyi/promo/service/PromoLoginService.java b/skins-promo/src/main/java/com/ruoyi/promo/service/PromoLoginService.java index 3fd85e3..7d76603 100644 --- a/skins-promo/src/main/java/com/ruoyi/promo/service/PromoLoginService.java +++ b/skins-promo/src/main/java/com/ruoyi/promo/service/PromoLoginService.java @@ -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); diff --git a/skins-promo/src/main/java/com/ruoyi/promo/service/impl/PromoLoginServiceImpl.java b/skins-promo/src/main/java/com/ruoyi/promo/service/impl/PromoLoginServiceImpl.java index b7dd0c9..bba94c2 100644 --- a/skins-promo/src/main/java/com/ruoyi/promo/service/impl/PromoLoginServiceImpl.java +++ b/skins-promo/src/main/java/com/ruoyi/promo/service/impl/PromoLoginServiceImpl.java @@ -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()); diff --git a/skins-service/service-admin/src/main/resources/com/ruoyi/admin/mapper/TtUpgradeRewardLogMapper.xml b/skins-service/service-admin/src/main/resources/com/ruoyi/admin/mapper/TtUpgradeRewardLogMapper.xml index 5b4264d..3ace6df 100644 --- a/skins-service/service-admin/src/main/resources/com/ruoyi/admin/mapper/TtUpgradeRewardLogMapper.xml +++ b/skins-service/service-admin/src/main/resources/com/ruoyi/admin/mapper/TtUpgradeRewardLogMapper.xml @@ -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,