Compare commits

46 Commits

Author SHA1 Message Date
0d19e4b6c4 1.新增时间筛选功能 2026-06-14 13:08:32 +08:00
b20a1d276f 1.将创建奖励对照表的10,15,20,25的默认权重设置为10 2026-06-05 13:44:14 +08:00
5e54859a30 1.玩家抽奖记录/dice/play_record/index新增时间筛选 2026-06-05 10:17:37 +08:00
226675c6bd 1.增加/dice/reward_config_record/index的关联删除/dice/play_record_test/index中的测试详细数据 2026-06-04 15:49:44 +08:00
e7a4a47329 1.修复一键测试权重BUG时每次压注的底注为随机时可能会出现底注丢失的情况 2026-06-04 15:35:05 +08:00
99949c4c3c 1.优化一键测试权重时每次压注的底注为随机 2026-06-04 15:08:24 +08:00
6ef3663957 1.优化一键测试权重时每次压注的底注为随机 2026-06-04 15:00:55 +08:00
f8969097d7 1.优化色子奖励权重表备注没有更新上去 2026-06-04 14:55:33 +08:00
0cc81299f0 1.优化色子奖励权重表备注没有更新上去 2026-06-04 14:42:22 +08:00
8a4a268526 1.优化彩金池配置中的playerDefault时自动修改绑定该配置的用户 2026-06-04 14:24:22 +08:00
16a59c28d4 1.修改彩金池配置中的playerDefault时自动修改绑定该配置的用户 2026-06-04 13:56:33 +08:00
eb9ade6d16 1.修改彩金池配置中的playerDefault时自动修改绑定该配置的用户 2026-06-04 13:47:35 +08:00
8c6c122dc2 1.新增默认彩金池配置
2.优化关联彩金池配置的名称显示
3.优化一键测试权重
4.优化底注配置
2026-06-04 12:29:23 +08:00
dfb37dd33a 1.新增默认彩金池配置
2.优化关联彩金池配置的名称显示
3.优化一键测试权重
4.优化底注配置
2026-06-04 12:21:57 +08:00
5d316ef7d6 1.抽奖测试记录页面/dice/play_record_test/index增加彩金池配置筛选条件 2026-06-04 10:31:48 +08:00
58a4b229a8 1.优化/dice/reward_config/index页面中的备注根据结算金额来变化 2026-06-04 09:45:26 +08:00
0c4da1540d 1.优化/dice/reward_config/index页面中的备注根据设置的档位来进行变化
2.检查为什么色子奖励权重有104条显示,应该只有26条(对应色子点数5-30)
3.移除掉所有实际金额在后台的显示
2026-06-04 09:33:29 +08:00
1980ff4af0 1.优化测试权重表单样式 2026-06-03 17:47:24 +08:00
5eb0ac24cd 1.优化抽奖券的抽奖逻辑 2026-06-03 17:36:57 +08:00
307a942b8e 1.优化抽奖券的抽奖逻辑 2026-06-03 17:23:13 +08:00
51105dd1e0 1.优化彩金池配置UI显示 2026-06-03 16:45:57 +08:00
136c18e413 1.优化渠道删除失败问题 2026-06-03 15:41:56 +08:00
9fb98dee3f 1.新增描述文档 2026-06-03 13:53:45 +08:00
37b0ee134e 1.修复抽奖档位不统一的问题 2026-06-02 17:31:23 +08:00
c0d5258aee 1.修复抽奖档位不统一的问题 2026-06-02 17:04:55 +08:00
13dacc8fdd 1.优化奖励配置设置-创建奖励对照表单样式 2026-06-02 15:24:17 +08:00
79c84c198a 1.优化奖励配置设置 2026-06-02 14:51:10 +08:00
3f97905ffa 1.优化中奖,当余额不足支付惩罚时,直接扣完钱包 2026-06-01 14:09:26 +08:00
d77e390fa3 1.优化角色管理页面/system/role中角色级别排序 2026-06-01 09:56:17 +08:00
906539995d 1.修改游戏档位和奖励金额的配置
2.优化T4和T5的奖励权重可以修改
2026-06-01 09:53:24 +08:00
18944f0d48 优化指南菜单页面样式 2026-05-30 18:41:53 +08:00
90abab14a3 1.新增菜单后台操作指南,方便管理员查看使用 2026-05-30 17:48:55 +08:00
a4c8f623be DB数据库文件 2026-05-26 09:43:42 +08:00
e0b303c5d4 1.优化/api/v1/getGameUrl接口,无需再验证password 2026-05-26 09:43:28 +08:00
77db2357ba 1.更新平台对接文档 2026-05-25 09:57:48 +08:00
cde5a851e5 1.对接平台接口新增api-key参数 2026-05-25 09:31:24 +08:00
9a43e1d8f2 1.优化查询用户信息接口/api/v1/getPlayerInfo,如果没有用户则创建 2026-05-19 17:18:51 +08:00
37c0035bfc 1.优化运维管理中渠道列表选择渠道的筛选错误问题
2.优化渠道运维管理目录菜单权限
2026-05-19 15:31:04 +08:00
5628af683f 1.优化主站彩金池配置页面当前彩金池多渠道显示错误问题 2026-05-19 14:58:44 +08:00
a5e2f2fdf7 1.优化玩家抽奖时没有记录dept_id的问题 2026-05-19 14:34:17 +08:00
5cd8330c9e 1.优化玩家管理页面/dice/player/index获取游戏链接逻辑 2026-05-19 14:18:31 +08:00
1b65e25f11 1.修复打包报错 2026-05-19 12:25:16 +08:00
1f25280dfd 1.所有接口需要根据agent_id绑定渠道
2.移除所有记录页面的更新按钮,只能查看数据
3.将所有软删除修改为硬删除
2026-05-19 12:04:34 +08:00
b089f302de 1.修复页面/game,报错404 not Found 2026-05-19 09:53:43 +08:00
dd264b1e97 1.将部门修改为渠道,并且所有dice_表关联渠道表
2.将所有配置表,记录表设置关联渠道
3.优化后台页面设置
2026-05-19 09:49:02 +08:00
085454fb78 1.优化工作台页面/dashboard/console
2.移除岗位管理相关代码和数据库
2026-05-18 11:41:34 +08:00
297 changed files with 13599 additions and 3552 deletions

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -19,6 +19,7 @@
- **请求方法**:项目路由多数使用 `Route::any`,对接建议统一使用 **POST**(便于 body 传参);个别接口文档中标注了 GET 参数。
- **编码**`UTF-8`
- **Content-Type**:建议 `application/x-www-form-urlencoded``application/json`(以平台实际实现为准)
- **必带凭证**:所有 `/api/v1/*` 接口均需带 `api-key`(与服务端 `.env``API_KEY` 一致);除 `/api/v1/authToken` 外另需 `auth-token`。详见 §2.2。
### 1.3 统一返回结构
@@ -49,10 +50,10 @@
## 2. 鉴权与对接流程(平台侧 /api/v1
平台侧接口分两步:
平台侧接口需统一携带请求头 **`api-key`**(与服务端 `.env``API_KEY` 一致),业务接口另需 **`auth-token`**。
1. **获取 `auth-token`**
2. **携带 `auth-token` 调用 `/api/v1/*` 业务接口**
1. **获取 `auth-token`**(同时携带 `api-key`
2. **携带 `api-key` + `auth-token` 调用 `/api/v1/*` 业务接口**
### 2.1 获取 auth-token
@@ -100,11 +101,22 @@ signature = md5(agent_id + secret + time)
- 密钥错误/签名错误/时间戳无效:`code=403`
- 服务端未配置密钥或生成失败:`code=500`
### 2.2 调用 v1 业务接口(携带 auth-token
### 2.2 平台 api-key所有 /api/v1/* 必填
`/api/v1/authToken` 外,其余 `/api/v1/*` 接口需要在请求头携带:
- **取值**:与服务端环境变量 `API_KEY` 完全一致(部署在 `server/.env`
- **适用范围**:所有 `/api/v1/*` 接口(含 `/api/v1/authToken` 与业务接口)
- **携带方式**(任选其一,按优先级读取,先命中即采用):
1. 请求头 `api-key: <API_KEY>`**推荐**
2. URL 查询参数 `api_key=<API_KEY>`(或 `api-key=<API_KEY>`
3. body 表单/JSON 字段 `api_key`(或 `api-key`
- **未携带或错误**`401` / `403`
- `auth-token: <authtoken>`
### 2.3 调用 v1 业务接口(携带 auth-token
`/api/v1/authToken` 外,其余 `/api/v1/*` 接口需要携带:
- `api-key: <与 API_KEY 一致>`(请求头 / query / body 任选其一,参见 2.2
- `auth-token: <authtoken>`(仅支持请求头)
`auth-token` 过期或失效,返回 `code=402`,需要重新调用 `/api/v1/authToken` 获取新 token。
@@ -116,7 +128,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getGameUrl`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
- **说明**:根据平台用户名创建/登录玩家并生成登录 JWT返回可直接打开的游戏地址。
#### 请求参数body
@@ -124,7 +136,6 @@ signature = md5(agent_id + secret + time)
| 参数名 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| username | 是 | string | 玩家唯一账号(平台侧用户名) |
| password | 否 | string | 默认 `123456` |
| time | 否 | int/string | 默认当前时间戳 |
| lang | 否 | string | `zh` / `en`,默认 `zh` |
@@ -144,7 +155,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getPlayerInfo`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
#### 请求参数
@@ -160,7 +171,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getPlayerGameRecord`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
#### 请求参数
@@ -181,7 +192,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getPlayerWalletRecord`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
参数与时间规则同 3.3(无 `page`,仅 `limit` 限制条数),返回钱包流水列表(附带 `dice_player`)。
@@ -189,7 +200,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/getPlayerTicketRecord`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
参数与时间规则同 3.3,返回中奖券记录列表(附带 `dice_player`)。
@@ -197,7 +208,7 @@ signature = md5(agent_id + secret + time)
- **路径**`/api/v1/setPlayerWallet`
- **方法**POST
- **请求头**`auth-token`
- **请求头**`api-key``auth-token`
- **说明**:平台为玩家加币/扣币,生成钱包流水。
#### 请求参数
@@ -256,9 +267,9 @@ signature = md5(agent_id + secret + time)
| --- | --- | --- |
| 200 | 成功 | 请求成功 |
| 400 | 请求参数错误 | 缺参、参数格式不合法、范围错误 |
| 401 | 未授权 | 未携带 `auth-token``token` |
| 401 | 未授权 | 未携带 `api-key``auth-token``token` |
| 402 | token 无效或已过期 | `auth-token/token` 过期、签名错误、被挤下线等 |
| 403 | 鉴权失败 | `secret` 错误、签名验证失败、时间戳无效等 |
| 403 | 鉴权失败 | `api-key` 无效、`secret` 错误、签名验证失败、时间戳无效等 |
| 404 | 资源不存在 | 用户不存在等 |
| 422 | 业务逻辑错误 | 余额不足、业务校验失败等 |
| 500 | 服务器内部错误 | 服务端异常或配置缺失 |

View File

@@ -128,7 +128,7 @@
| 文档 | 内容 |
| --- | --- |
| [`API对接文档.md`](API对接文档.md) | 平台 `/api/v1/*``auth-token`)、玩家 `/api/*``token`)、统一返回码、联调建议。 |
| [`API对接文档.md`](API对接文档.md) | 平台 `/api/v1/*``api-key` + `auth-token`)、玩家 `/api/*``token`)、统一返回码、联调建议。 |
| `server/docs/` | 性能、权重测试、出点分析等专项说明(按需阅读)。 |
**与玩法直接相关的玩家接口示例**

View File

@@ -8,7 +8,8 @@
},
"scripts": {
"dev": "vite --open",
"build": "vue-tsc --noEmit && vite build",
"sync:flowcharts": "node scripts/sync-dice-flowcharts.mjs",
"build": "node scripts/sync-dice-flowcharts.mjs && vue-tsc --noEmit && vite build",
"serve": "vite preview",
"lint": "eslint",
"fix": "eslint --fix",

View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>为何最终抽到该奖励</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
margin: 0;
padding: 24px 32px 48px;
background: #f5f7fa;
color: #1a1a2e;
line-height: 1.6;
}
header { max-width: 1100px; margin: 0 auto 16px; }
h1 { font-size: 1.5rem; margin: 0 0 8px; font-weight: 600; }
.subtitle { color: #5c6370; font-size: 0.95rem; margin: 0; }
.card {
max-width: 1100px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.copy-hint {
max-width: 1100px;
margin: 12px auto 0;
font-size: 0.88rem;
color: #606266;
}
.copy-box {
max-width: 1100px;
margin: 8px auto 0;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px 14px;
}
.copy-box summary { cursor: pointer; font-size: 0.9rem; color: #409eff; }
.copy-box pre {
margin: 10px 0 0;
font-size: 0.78rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
max-height: 280px;
overflow: auto;
color: #303133;
}
.legend {
max-width: 1100px;
margin: 20px auto 0;
padding: 16px 20px;
background: #fff;
border-radius: 12px;
font-size: 0.9rem;
color: #444;
}
.legend h2 { font-size: 1rem; margin: 0 0 10px; }
.legend ul { margin: 0; padding-left: 1.2em; }
.legend li { margin: 4px 0; }
.mermaid { display: flex; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>为何最终抽到的是这个奖励</h1>
<p class="subtitle">业务说明:一局抽奖从开局到到账的决策顺序(仅用本项目菜单与业务用语)</p>
</header>
<p class="copy-hint">复制方式展开下方「Mermaid 源码」全选复制,粘贴到 ProcessOn / draw.io / 飞书文档等支持 Mermaid 的流程图工具;或直接用浏览器打开本页看图。</p>
<div class="card">
<pre class="mermaid">
flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0
</pre>
</div>
<details class="copy-box">
<summary>Mermaid 源码(可复制,与同目录 .mmd 文件一致)</summary>
<pre id="mermaid-src">flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0</pre>
</details>
<div class="legend">
<h2>读图要点</h2>
<ul>
<li><strong>两步抽签</strong>:先抽档位 T1T5大奖 / 小赚 / 抽水 / 惩罚 / 再来一次),再在该档位 + 方向的多条奖励里按权重抽具体点数与结算金额。</li>
<li><strong>免费局与杀分局</strong>:都用杀分奖池的档位概率;一般不会出豹子大奖,也不会抽到只能组成豹子的点数 5、30。</li>
<li><strong>普通付费局</strong>:彩金池未到杀分条件时,用该玩家在「玩家管理」里的档位权重,才可能按「大奖权重」出豹子。</li>
<li><strong>玩家最终看到</strong>:色子点数、五颗骰子图案、到账平台币(普通奖 + 豹子奖)、是否获得「再来一次」免费券。</li>
</ul>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
flowchart: { curve: 'basis', padding: 16, nodeSpacing: 28, rankSpacing: 40 }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>后台如何配置中奖逻辑</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
margin: 0;
padding: 24px 32px 48px;
background: #f0f4f8;
color: #1a1a2e;
line-height: 1.55;
}
header { max-width: 1200px; margin: 0 auto 16px; }
h1 { font-size: 1.45rem; margin: 0 0 6px; font-weight: 600; }
.subtitle { color: #5c6370; font-size: 0.92rem; margin: 0; }
.tip {
max-width: 1200px;
margin: 0 auto 16px;
padding: 12px 16px;
background: #fff8e6;
border-left: 4px solid #e6a23c;
border-radius: 6px;
font-size: 0.9rem;
}
.card {
max-width: 1200px;
margin: 0 auto 20px;
background: #fff;
border-radius: 12px;
padding: 24px 20px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.card h2 { font-size: 1.05rem; margin: 0 0 12px; color: #303133; }
.copy-hint {
max-width: 1200px;
margin: 0 auto 12px;
font-size: 0.88rem;
color: #606266;
}
.copy-box {
max-width: 1200px;
margin: 8px auto 20px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px 14px;
}
.copy-box summary { cursor: pointer; font-size: 0.9rem; color: #409eff; }
.copy-box pre {
margin: 10px 0 0;
font-size: 0.76rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
max-height: 320px;
overflow: auto;
}
.steps {
max-width: 1200px;
margin: 0 auto;
display: grid;
gap: 10px;
}
.step {
background: #fff;
border-radius: 10px;
padding: 12px 16px;
border: 1px solid #e4e7ed;
font-size: 0.9rem;
}
.step strong { color: #409eff; }
.mermaid { display: flex; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>后台如何配置中奖逻辑</h1>
<p class="subtitle">按「一局真实抽奖」顺序:每个环节对应左侧菜单与页面按钮(与前台逻辑一致)</p>
</header>
<p class="tip">菜单根目录:<strong>大富翁-色子游戏</strong>。多渠道后台请先选顶部<strong>渠道</strong>,再改该渠道数据。</p>
<p class="copy-hint">复制展开「Mermaid 源码」粘贴到流程图工具;日常维护也可打开同目录 <code>dice-后台中奖逻辑配置.mmd</code></p>
<div class="card">
<h2>主流程图(抽奖环节 → 去哪点哪个按钮)</h2>
<pre class="mermaid">
flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8
</pre>
</div>
<details class="copy-box">
<summary>Mermaid 源码(可复制,与同目录 .mmd 文件一致)</summary>
<pre id="mermaid-src">flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8</pre>
</details>
<div class="card">
<h2>首次搭建推荐顺序(与上图环节对应)</h2>
<pre class="mermaid">
flowchart TD
O([开始配置]) --> R1[奖励配置 · 页签「奖励索引」· 按钮「保存」]
R1 --> R2[奖励配置 · 页签「大奖权重」· 按钮「保存」]
R2 --> R3[奖励配置 · 按钮「创建奖励对照」·「确认导入」]
R3 --> W[色子奖励权重 · 按钮「权重配比」· 按钮「提交」]
W --> P1[彩金池配置 · 行内「编辑」default / killScore ·「提交」]
P1 --> P2[彩金池配置 ·「查看当前彩金池」·「保存安全线」]
P2 --> PL[玩家管理 · 行内「编辑」· 档位权重 ·「提交」]
PL --> T{要仿真?}
T -->|是| Test[色子奖励权重 ·「一键测试权重」·「开始测试」]
Test --> Imp[权重测试记录 ·「查看详情」·「导入到当前配置」·「确认导入」]
T -->|否| Live([上线])
Imp --> Live
style O fill:#e8f4fc
style Live fill:#e8fce8
</pre>
</div>
<div class="steps">
<div class="step"><strong>档位含义</strong>T1 大奖 · T2 小赚 · T3 抽水 · T4 惩罚 · T5 再来一次(由「奖励索引」结算金额规则决定,见页内说明)。</div>
<div class="step"><strong>改「奖励索引」后</strong>:必须再点「创建奖励对照」→「确认导入」,否则抽奖仍用旧对照表。</div>
<div class="step"><strong>核对真实对局</strong>:大富翁-色子游戏 → 玩家抽奖记录(看奖励档位、色子点数、摇色子中奖平台币、中大奖平台币、底注、方向)。</div>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
flowchart: { curve: 'basis', padding: 14, nodeSpacing: 24, rankSpacing: 36 }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8

View File

@@ -0,0 +1,26 @@
/**
* 将 server/docs/flowcharts 同步到 saiadmin-artd/public/docs/flowcharts
* 构建前执行,保证部署包内含最新流程图 HTML/MMD
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const artdRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(artdRoot, '..')
const srcDir = path.join(repoRoot, 'server', 'docs', 'flowcharts')
const destDir = path.join(artdRoot, 'public', 'docs', 'flowcharts')
if (!fs.existsSync(srcDir)) {
console.warn('[sync-dice-flowcharts] 源目录不存在,跳过:', srcDir)
process.exit(0)
}
fs.mkdirSync(destDir, { recursive: true })
const names = fs.readdirSync(srcDir).filter((n) => /\.(html|mmd)$/i.test(n))
for (const name of names) {
fs.copyFileSync(path.join(srcDir, name), path.join(destDir, name))
console.log('[sync-dice-flowcharts] copied', name)
}
console.log('[sync-dice-flowcharts] done,', names.length, 'file(s)')

View File

@@ -1,32 +1,38 @@
import request from '@/utils/http'
export type DashboardQueryParams = {
dept_id?: number
/** 统计日期,格式 YYYY-MM-DD默认当日 */
date?: string
}
/**
* 大富翁工作台卡片统计(玩家注册、充值、提现、游玩次数,含较上周对比)
* @returns 响应
*/
export function fetchStatistics() {
export function fetchStatistics(params?: DashboardQueryParams) {
return request.get<any>({
url: '/core/dice/dashboard/statistics'
url: '/core/dice/dashboard/statistics',
params
})
}
/**
* 近期玩家充值统计近10天每日充值金额
* @returns 响应
*/
export function fetchRechargeChart() {
export function fetchRechargeChart(params?: DashboardQueryParams) {
return request.get<any>({
url: '/core/dice/dashboard/rechargeChart'
url: '/core/dice/dashboard/rechargeChart',
params
})
}
/**
* 月度玩家充值汇总当年1-12月每月充值金额
* @returns 响应
*/
export function fetchRechargeBarChart() {
export function fetchRechargeBarChart(params?: DashboardQueryParams) {
return request.get<any>({
url: '/core/dice/dashboard/rechargeBarChart'
url: '/core/dice/dashboard/rechargeBarChart',
params
})
}
@@ -39,11 +45,11 @@ export interface WalletRecordItem {
/**
* 工作台-玩家充值记录最新50条
* @returns 列表
*/
export function fetchWalletRecordList() {
export function fetchWalletRecordList(params?: DashboardQueryParams) {
return request.get<WalletRecordItem[]>({
url: '/core/dice/dashboard/walletRecordList'
url: '/core/dice/dashboard/walletRecordList',
params
})
}
@@ -52,15 +58,34 @@ export interface NewPlayerItem {
name: string
coin: number
total_ticket_count: number
create_time: string
}
/** 玩家游玩记录项 */
export interface PlayRecordItem {
player_name: string
reward_tier: string
reward_tier_label: string
win_coin: number
create_time: string
}
/**
* 工作台-新增玩家记录最新50条
* @returns 列表
*/
export function fetchNewPlayerList() {
export function fetchNewPlayerList(params?: DashboardQueryParams) {
return request.get<NewPlayerItem[]>({
url: '/core/dice/dashboard/newPlayerList'
url: '/core/dice/dashboard/newPlayerList',
params
})
}
/**
* 工作台-玩家游玩记录最新50条
*/
export function fetchPlayRecordList(params?: DashboardQueryParams) {
return request.get<PlayRecordItem[]>({
url: '/core/dice/dashboard/playRecordList',
params
})
}

View File

@@ -0,0 +1,26 @@
import request from '@/utils/http'
/**
* 后台操作指南 API
*/
export default {
/**
* 读取 Markdown 内容
*/
read() {
return request.get<Api.Common.ApiData>({
url: '/core/adminGuide/read'
})
},
/**
* 保存 Markdown 内容
*/
save(params: { content: string }) {
return request.post<Api.Common.ApiData>({
url: '/core/adminGuide/save',
data: params,
showSuccessMessage: true
})
}
}

View File

@@ -59,7 +59,8 @@ export default {
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/dept/destroy',
data: params
data: params,
showErrorMessage: false
})
},
@@ -71,5 +72,19 @@ export default {
return request.get<Api.Common.ApiData[]>({
url: '/core/dept/accessDept'
})
},
destroyPreview(ids: string | number | Array<string | number>) {
const idStr = Array.isArray(ids) ? ids.join(',') : String(ids)
return request.get<Api.Common.ApiData>({
url: '/core/dept/destroyPreview',
params: { ids: idStr }
})
},
syncChannelConfigs() {
return request.post<any>({
url: '/core/dept/syncChannelConfigs'
})
}
}

View File

@@ -1,75 +0,0 @@
import request from '@/utils/http'
/**
* 岗位API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/post/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/core/post/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/core/post/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/post/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/post/destroy',
data: params
})
},
/**
* 可操作岗位
* @returns 数据列表
*/
accessPost() {
return request.get<Api.Common.ApiData[]>({
url: '/core/post/accessPost'
})
}
}

View File

@@ -79,9 +79,10 @@ export default {
* 可操作角色
* @returns 数据列表
*/
accessRole() {
accessRole(params?: Record<string, unknown>) {
return request.get<Api.Common.ApiData[]>({
url: '/core/role/accessRole'
url: '/core/role/accessRole',
params
})
},

View File

@@ -0,0 +1,144 @@
<template>
<div v-if="props.enabled" class="art-full-height super-admin-channel-shell">
<div class="box-border flex gap-3 h-full max-md:block max-md:gap-0 max-md:h-auto">
<div class="channel-list-panel flex-shrink-0 h-full max-md:w-full max-md:h-auto max-md:mb-5">
<ElCard
class="channel-tree-card tree-card art-card-xs flex flex-col h-full mt-0"
shadow="never"
v-loading="loadingChannels"
>
<template #header>
<b class="channel-list-title">{{ $t('common.channelScope.listTitle') }}</b>
</template>
<ElScrollbar>
<ElTree
:data="displayTreeData"
:props="{ children: 'children', label: 'label' }"
node-key="id"
:current-node-key="selectedDeptId"
default-expand-all
highlight-current
@node-click="onNodeClick"
/>
</ElScrollbar>
</ElCard>
</div>
<div class="flex flex-col flex-grow min-w-0 min-h-0">
<div v-if="selectedDisplayLabel" class="channel-banner mb-3 text-sm text-g-500">
{{ bannerLabel }}<b>{{ selectedDisplayLabel }}</b>
</div>
<div class="flex flex-col flex-1 min-h-0 min-w-0">
<slot />
</div>
</div>
</div>
</div>
<slot v-else />
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { isRoleChannelRoute } from '@/utils/channelLayout'
import {
DEFAULT_CHANNEL_ID,
useChannelDeptScope,
type ChannelTreeNode
} from '@/composables/useChannelDeptScope'
const route = useRoute()
defineOptions({ name: 'SuperAdminChannelShell' })
const props = withDefaults(
defineProps<{
enabled?: boolean
}>(),
{
enabled: true
}
)
const { t } = useI18n()
const {
treeData,
selectedDeptId,
loadingChannels,
handleChannelClick,
provideScope,
isConfigScope,
isAllChannelScope,
showDefaultTemplate
} = useChannelDeptScope()
provideScope()
const displayTreeData = computed(() => {
const nodes = showDefaultTemplate.value
? treeData.value
: treeData.value.filter((n) => n.id !== DEFAULT_CHANNEL_ID)
const defaultLabel = isRoleChannelRoute(route)
? t('common.channelScope.defaultRoleTemplate')
: t('common.channelScope.defaultTemplate')
const emptyNodeLabel = isAllChannelScope.value ? t('common.channelScope.allChannels') : defaultLabel
return nodes.map((node) =>
node.id === DEFAULT_CHANNEL_ID && !node.label ? { ...node, label: emptyNodeLabel } : node
)
})
const selectedDisplayLabel = computed(() => {
const item = displayTreeData.value.find((node) => node.id === selectedDeptId.value)
return item?.label ?? ''
})
const bannerLabel = computed(() => {
if (isConfigScope.value) {
return t('common.channelScope.currentConfig')
}
if (isRoleChannelRoute(route)) {
return t('common.channelScope.currentRole')
}
return t('common.channelScope.currentChannel')
})
const onNodeClick = (data: ChannelTreeNode) => {
handleChannelClick(data)
}
</script>
<style scoped>
.super-admin-channel-shell {
min-height: 0;
}
/* 原 w-64(16rem) 的 0.6 倍 */
.channel-list-panel {
width: 9.6rem;
}
.channel-tree-card :deep(.el-card__header) {
padding: 10px 12px;
}
.channel-list-title {
font-size: 13px;
}
.channel-tree-card :deep(.el-tree-node__content) {
height: 30px;
padding-right: 4px;
}
.channel-tree-card :deep(.el-tree-node__label) {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-banner b {
color: var(--el-color-primary);
}
</style>

View File

@@ -18,22 +18,34 @@
<!-- 缓存路由动画 -->
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
<KeepAlive :max="10" :exclude="keepAliveExclude">
<SuperAdminChannelShell
v-if="route.meta.keepAlive && shouldWrapChannelLayout(route)"
:key="'ch-' + route.path"
>
<component class="art-page-view" :is="Component" :key="route.path" />
</SuperAdminChannelShell>
<component
v-else-if="route.meta.keepAlive"
class="art-page-view"
:is="Component"
:key="route.path"
v-if="route.meta.keepAlive"
/>
</KeepAlive>
</Transition>
<!-- 非缓存路由动画 -->
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
<SuperAdminChannelShell
v-if="!route.meta.keepAlive && shouldWrapChannelLayout(route)"
:key="'ch-' + route.path"
>
<component class="art-page-view" :is="Component" :key="route.path" />
</SuperAdminChannelShell>
<component
v-else-if="!route.meta.keepAlive"
class="art-page-view"
:is="Component"
:key="route.path"
v-if="!route.meta.keepAlive"
/>
</Transition>
</RouterView>
@@ -53,6 +65,8 @@
import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight'
import { useSettingStore } from '@/store/modules/setting'
import { useWorktabStore } from '@/store/modules/worktab'
import SuperAdminChannelShell from '@/components/channel/SuperAdminChannelShell.vue'
import { shouldWrapSuperAdminChannelLayout as shouldWrapChannelLayout } from '@/utils/channelLayout'
defineOptions({ name: 'ArtPageContent' })

View File

@@ -0,0 +1,12 @@
<!-- 兼容旧引用超管渠道栏已提升至全局 SuperAdminChannelShell -->
<template>
<slot :dept-id="deptId" :dept-params="deptParams" />
</template>
<script setup lang="ts">
import { useInjectedChannelDept, DEFAULT_CHANNEL_ID } from '@/composables/useChannelDeptScope'
const channel = useInjectedChannelDept()
const deptId = computed(() => channel?.selectedDeptId.value ?? DEFAULT_CHANNEL_ID)
const deptParams = computed(() => ({ dept_id: deptId.value }))
</script>

View File

@@ -0,0 +1,6 @@
/** @deprecated 请使用 useChannelDeptScope */
export {
DEFAULT_CHANNEL_ID,
useChannelDeptScope as useChannelConfigScope,
type ChannelTreeNode
} from './useChannelDeptScope'

View File

@@ -0,0 +1,289 @@
import type { InjectionKey, Ref, ComputedRef } from 'vue'
import { useRoute } from 'vue-router'
import deptApi from '@/api/system/dept'
import { router } from '@/router'
import { useUserStore } from '@/store/modules/user'
import {
isAllChannelScopeRoute,
isConfigChannelRoute,
isNoChannelLayoutRoute,
isRoleChannelRoute,
isSuperAdminUser
} from '@/utils/channelLayout'
export interface ChannelTreeNode {
id: number
label: string
children?: ChannelTreeNode[]
}
/** 默认配置模板dept_id = 0 */
export const DEFAULT_CHANNEL_ID = 0
export interface ChannelDeptScopeContext {
treeData: Ref<ChannelTreeNode[]>
selectedDeptId: Ref<number>
loadingChannels: Ref<boolean>
isSuperAdmin: Ref<boolean>
selectedDeptLabel: Ref<string>
deptQueryParams: ComputedRef<{ dept_id: number }>
isConfigScope: ComputedRef<boolean>
isAllChannelScope: ComputedRef<boolean>
showDefaultTemplate: ComputedRef<boolean>
}
export const CHANNEL_DEPT_SCOPE_KEY: InjectionKey<ChannelDeptScopeContext> =
Symbol('channelDeptScope')
/**
* 当前应用中处于激活态的超管渠道上下文。
* 仅有一个 SuperAdminChannelShell 实例,所以可以在模块作用域内缓存其 ctx
* 供 setInterval / 异步回调 / 事件处理器等"setup 外"路径取用——
* 这些路径上 Vue 的 inject() 会失效getCurrentInstance() 为 null
*/
let activeChannelCtx: ChannelDeptScopeContext | null = null
export function provideChannelDeptScope(ctx: ChannelDeptScopeContext) {
provide(CHANNEL_DEPT_SCOPE_KEY, ctx)
activeChannelCtx = ctx
onScopeDispose(() => {
if (activeChannelCtx === ctx) {
activeChannelCtx = null
}
})
}
export function useInjectedChannelDept(): ChannelDeptScopeContext | null {
// 仅在组件 setup 同步路径下调用 inject() 才可靠;
// 否则异步回调、setInterval、事件处理器等退化为读取模块级激活上下文
// 避免渠道切换后 getCurrentPool/withChannelDeptParams 等仍按超管自身部门发请求。
if (getCurrentInstance()) {
const ctx = inject(CHANNEL_DEPT_SCOPE_KEY, null)
if (ctx) {
return ctx
}
}
return activeChannelCtx
}
/** 超管全局渠道栏:创建并 provide 渠道上下文 */
export function useChannelDeptScope() {
const route = useRoute()
const userStore = useUserStore()
const treeData = ref<ChannelTreeNode[]>([])
const selectedDeptId = ref<number>(DEFAULT_CHANNEL_ID)
const loadingChannels = ref(false)
const isConfigScope = computed(() => isConfigChannelRoute(route))
const isRoleScope = computed(() => isRoleChannelRoute(route))
const isAllChannelScope = computed(() => isAllChannelScopeRoute(route))
const showDefaultTemplate = computed(() => isConfigScope.value || isRoleScope.value)
const isSuperAdmin = computed(() => isSuperAdminUser())
const selectedDeptLabel = computed(() => {
const find = (nodes: ChannelTreeNode[]): string => {
for (const n of nodes) {
if (n.id === selectedDeptId.value) {
return n.label
}
if (n.children?.length) {
const sub = find(n.children)
if (sub) return sub
}
}
return ''
}
return find(treeData.value)
})
const deptQueryParams = computed(() => {
const id = selectedDeptId.value
if (isAllChannelScope.value && id <= 0) {
return { dept_id: 0 }
}
if (!showDefaultTemplate.value && id <= 0) {
return { dept_id: 0 }
}
return { dept_id: id }
})
const loadChannels = async () => {
loadingChannels.value = true
try {
const list = await deptApi.accessDept()
const channels = Array.isArray(list) ? list : []
const nodes: ChannelTreeNode[] = channels.map((item: Record<string, unknown>) => ({
id: Number(item.id ?? item.value),
label: String(item.label ?? item.name ?? item.id)
}))
if (isSuperAdmin.value) {
if (showDefaultTemplate.value || isAllChannelScope.value) {
treeData.value = [{ id: DEFAULT_CHANNEL_ID, label: '' }, ...nodes]
if (!treeData.value.some((n) => n.id === selectedDeptId.value)) {
selectedDeptId.value = DEFAULT_CHANNEL_ID
}
} else {
treeData.value = nodes
if (nodes.length > 0) {
const valid = nodes.some((n) => n.id === selectedDeptId.value)
if (!valid || selectedDeptId.value <= 0) {
selectedDeptId.value = nodes[0].id
}
}
}
} else {
treeData.value = nodes
if (nodes.length > 0) {
selectedDeptId.value = nodes[0].id
}
}
} finally {
loadingChannels.value = false
}
}
const handleChannelClick = (data: ChannelTreeNode) => {
selectedDeptId.value = Number(data.id)
}
const ctx: ChannelDeptScopeContext = {
treeData,
selectedDeptId,
loadingChannels,
isSuperAdmin,
selectedDeptLabel,
deptQueryParams,
isConfigScope,
isAllChannelScope,
showDefaultTemplate
}
onMounted(() => {
loadChannels()
})
return {
...ctx,
loadChannels,
handleChannelClick,
provideScope: () => provideChannelDeptScope(ctx)
}
}
/** 将当前选中渠道写入列表查询参数并刷新 */
export function bindChannelDeptToSearchParams(
searchParams: Record<string, unknown>,
refresh: () => void,
options?: { immediate?: boolean; enabled?: boolean }
) {
const channel = useInjectedChannelDept()
if (!channel || options?.enabled === false) {
return
}
const apply = (deptId: number) => {
if (channel.isAllChannelScope.value && deptId <= 0) {
delete searchParams.dept_id
refresh()
return
}
if (!channel.showDefaultTemplate.value && deptId <= 0) {
return
}
searchParams.dept_id = deptId
refresh()
}
watch(
() => channel.selectedDeptId.value,
(deptId) => apply(deptId),
{ immediate: options?.immediate ?? true }
)
}
/** 工作台等非 useTable 页面:渠道切换时重新拉数 */
export function useChannelDeptReload(loadFn: () => void | Promise<void>) {
const channel = useInjectedChannelDept()
if (!channel) {
onMounted(() => {
void loadFn()
})
return
}
watch(
() => channel.selectedDeptId.value,
(deptId) => {
if (channel.isAllChannelScope.value && deptId <= 0) {
void loadFn()
return
}
if (!channel.showDefaultTemplate.value && deptId <= 0) {
return
}
void loadFn()
},
{ immediate: true }
)
}
/** 请求参数:业务页附带 dept_id渠道管理员固定本渠道 */
export function getChannelDeptRequestParams(): { dept_id?: number } {
const channel = useInjectedChannelDept()
const route = getCurrentInstance() ? useRoute() : router.currentRoute.value
if (isNoChannelLayoutRoute(route)) {
return {}
}
if (channel?.isSuperAdmin.value) {
const deptId = channel.selectedDeptId.value
if (channel.isAllChannelScope.value && deptId <= 0) {
return {}
}
if (!channel.showDefaultTemplate.value && deptId <= 0) {
return {}
}
if (deptId > 0) {
return { dept_id: deptId }
}
if (channel.showDefaultTemplate.value) {
return { dept_id: deptId }
}
return {}
}
const userStore = useUserStore()
const dept = userStore.info?.department
if (dept && Number(dept.id) > 0) {
return { dept_id: Number(dept.id) }
}
return {}
}
/** 保存/更新时附带 dept_id新增优先渠道栏更新优先行内 dept_id避免默认模板 0 覆盖真实渠道) */
export function withChannelDeptParams<T extends Record<string, unknown>>(payload: T): T {
const rowDeptRaw = payload.dept_id
const hasRowDept =
rowDeptRaw !== undefined && rowDeptRaw !== null && rowDeptRaw !== ''
const rowDeptNum = hasRowDept ? Number(rowDeptRaw) : NaN
const isUpdate =
payload.id !== undefined && payload.id !== null && payload.id !== ''
if (isUpdate && hasRowDept && Number.isFinite(rowDeptNum) && rowDeptNum >= 0) {
return { ...payload, dept_id: rowDeptNum }
}
const extra = getChannelDeptRequestParams()
if ('dept_id' in extra) {
return { ...payload, ...extra }
}
if (hasRowDept && Number.isFinite(rowDeptNum) && rowDeptNum > 0) {
return { ...payload, dept_id: rowDeptNum }
}
const channel = useInjectedChannelDept()
if (channel && channel.selectedDeptId.value > 0) {
return { ...payload, dept_id: channel.selectedDeptId.value }
}
return payload
}

View File

@@ -0,0 +1,98 @@
import type { InjectionKey, Ref, ComputedRef } from 'vue'
import { getChannelDeptRequestParams, useInjectedChannelDept } from '@/composables/useChannelDeptScope'
export interface DashboardQueryParams {
dept_id?: number
date?: string
}
export interface DashboardScopeContext {
selectedDate: Ref<string | null>
hasDateFilter: ComputedRef<boolean>
queryParams: ComputedRef<DashboardQueryParams>
}
export const DASHBOARD_SCOPE_KEY: InjectionKey<DashboardScopeContext> = Symbol('dashboardScope')
function formatDateYmd(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
export function getTodayDateString(): string {
return formatDateYmd(new Date())
}
/** 工作台页面 provide 日期筛选上下文 */
export function provideDashboardScope() {
const selectedDate = ref<string | null>(getTodayDateString())
const hasDateFilter = computed(() => !!selectedDate.value)
const queryParams = computed<DashboardQueryParams>(() => {
const params: DashboardQueryParams = {
...getChannelDeptRequestParams()
}
if (selectedDate.value) {
params.date = selectedDate.value
}
return params
})
const ctx: DashboardScopeContext = { selectedDate, hasDateFilter, queryParams }
provide(DASHBOARD_SCOPE_KEY, ctx)
return ctx
}
export function useDashboardScope(): DashboardScopeContext {
const ctx = inject(DASHBOARD_SCOPE_KEY, null)
if (ctx) {
return ctx
}
const selectedDate = ref<string | null>(getTodayDateString())
const hasDateFilter = computed(() => !!selectedDate.value)
return {
selectedDate,
hasDateFilter,
queryParams: computed<DashboardQueryParams>(() => {
const params: DashboardQueryParams = {
...getChannelDeptRequestParams()
}
if (selectedDate.value) {
params.date = selectedDate.value
}
return params
})
}
}
/** 工作台:渠道或日期变化时重新拉数 */
export function useDashboardReload(loadFn: () => void | Promise<void>) {
const channel = useInjectedChannelDept()
const { selectedDate } = useDashboardScope()
const run = () => {
void loadFn()
}
watch(selectedDate, run)
if (!channel) {
onMounted(run)
return
}
watch(
() => channel.selectedDeptId.value,
(deptId) => {
if (channel.isAllChannelScope.value && deptId <= 0) {
run()
return
}
if (!channel.showDefaultTemplate.value && deptId <= 0) {
return
}
run()
},
{ immediate: true }
)
}

View File

@@ -35,11 +35,16 @@ import {
createErrorHandler
} from '../../utils/table/tableUtils'
import { tableConfig } from '../../utils/table/tableConfig'
import { bindChannelDeptToSearchParams, useInjectedChannelDept, getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
// 类型推导工具类型
type InferApiParams<T> = T extends (params: infer P) => any ? P : never
type InferApiResponse<T> = T extends (params: any) => Promise<infer R> ? R : never
type InferRecordType<T> = T extends Api.Common.PaginatedResponse<infer U> ? U : never
type InferRecordType<T> = T extends Api.Common.PaginatedResponse<infer U>
? U
: T extends Api.Common.ApiPage<infer U>
? U
: never
// 优化的配置接口 - 支持自动类型推导
export interface UseTableConfig<
@@ -441,6 +446,23 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
// 智能防抖搜索函数
const debouncedGetDataByPage = createSmartDebounce(getDataByPage, debounceTime)
const channelScope = useInjectedChannelDept()
const hasChannelScope = !!channelScope
bindChannelDeptToSearchParams(
searchParams as Record<string, unknown>,
() => {
void getDataByPage()
},
{ immediate: hasChannelScope }
)
if (!hasChannelScope) {
const channelDeptParams = getChannelDeptRequestParams()
if (channelDeptParams.dept_id !== undefined) {
Object.assign(searchParams as Record<string, unknown>, channelDeptParams)
}
}
// 重置搜索参数
const resetSearchParams = async (): Promise<void> => {
// 取消防抖的搜索
@@ -645,7 +667,7 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
}
// 挂载时自动加载数据
if (immediate) {
if (immediate && !hasChannelScope) {
onMounted(async () => {
await getData()
})

View File

@@ -37,7 +37,16 @@
"tips": "Prompt",
"cancel": "Cancel",
"confirm": "Confirm",
"logOutTips": "Do you want to log out?"
"logOutTips": "Do you want to log out?",
"channelScope": {
"listTitle": "Channels",
"defaultTemplate": "Default template",
"defaultRoleTemplate": "Default role template",
"allChannels": "All",
"currentConfig": "Current config",
"currentChannel": "Current channel",
"currentRole": "Current roles"
}
},
"uiMsg": {
"titlePrompt": "Prompt",
@@ -291,23 +300,51 @@
"gohome": "Go Home"
},
"console": {
"filter": {
"date": "Statistics Date",
"placeholder": "Select date",
"today": "Today",
"clear": "Clear"
},
"card": {
"playerRegister": "Player Register",
"playerCharge": "Player Charge",
"playerWithdraw": "Player Withdraw",
"playerPlayCount": "Player Play Count",
"vsLastWeek": "vs Last Week"
"vsLastWeek": "vs Last Week",
"vsYesterday": "vs Yesterday",
"viewRechargeRecords": "View recharge records",
"viewWithdrawRecords": "View withdraw records",
"viewPlayRecords": "View play records",
"viewRegisterRecords": "View registered players"
},
"nav": {
"viewAllRecharge": "View all recharge",
"viewAllPlay": "View all play records",
"viewAllRegister": "View all registrations"
},
"newPlayer": {
"title": "New Players",
"subtitle": "Latest 50 new player records",
"subtitleByDate": "New players on {date} (up to 50)",
"player": "Player",
"balance": "Balance",
"ticket": "Tickets"
"ticket": "Tickets",
"registerTime": "Register Time"
},
"playRecord": {
"title": "Player Play Records",
"subtitle": "Latest 50 play records",
"subtitleByDate": "Play records on {date} (up to 50)",
"player": "Player",
"reward": "Reward Tier",
"winCoin": "Win Amount",
"playTime": "Play Time"
},
"walletRecord": {
"title": "Player Charge Records",
"subtitle": "Latest 50 charge records",
"subtitleByDate": "Charge records on {date} (up to 50)",
"player": "Player",
"chargeAmount": "Amount",
"chargeTime": "Charge Time"
@@ -378,8 +415,7 @@
"role": "Role Management",
"userCenter": "User Center",
"menu": "Menu Management",
"dept": "Department Management",
"post": "Post Management",
"dept": "Channel Management",
"config": "System Config"
},
"safeguard": {
@@ -411,6 +447,9 @@
"rewardConfigRecord": "Dice Weight Test Records",
"playRecordTest": "Draw Records (Test Weight)",
"config": "Game Config"
},
"game": {
"title": "Game Management"
}
},
"table": {
@@ -430,6 +469,10 @@
"max": "Max",
"startTime": "Start Time",
"endTime": "End Time",
"quickDate": "Quick date",
"quickToday": "Today",
"quickYesterday": "Yesterday",
"quickLast7Days": "Last 7 days",
"placeholderUsername": "Username",
"placeholderNickname": "Nickname",
"placeholderPhone": "Phone",
@@ -445,8 +488,6 @@
"placeholderDeptCode": "Dept Code",
"placeholderRoleName": "Role Name",
"placeholderRoleCode": "Role Code",
"placeholderPostName": "Post Name",
"placeholderPostCode": "Post Code",
"placeholderMenuName": "Menu Name",
"placeholderMenuRoute": "Menu Route",
"placeholderOperator": "Operator",
@@ -510,14 +551,12 @@
"system": {
"username": "Username",
"phone": "Phone",
"dept": "Department",
"dept": "Channel",
"dashboard": "Dashboard",
"loginTime": "Last Login",
"agentId": "Agent ID",
"postName": "Post Name",
"postCode": "Post Code",
"deptName": "Dept Name",
"deptCode": "Dept Code",
"deptName": "Channel Name",
"deptCode": "Channel Code",
"leader": "Leader",
"roleName": "Role Name",
"roleCode": "Role Code",

View File

@@ -27,6 +27,8 @@
"labelIsDefault": "Default Ante",
"placeholderName": "Please enter name",
"placeholderTitle": "Please enter title",
"placeholderNameAuto": "Auto from multiplier, e.g. x5",
"placeholderTitleAuto": "Auto from multiplier, e.g. x5",
"ruleNameRequired": "Please enter name",
"ruleTitleRequired": "Please enter title",
"ruleMultRequired": "Please enter ante multiplier",

View File

@@ -0,0 +1,68 @@
{
"form": {
"dialogTitleAdd": "Add Game",
"dialogTitleEdit": "Edit Game",
"provider": "Provider",
"placeholderProvider": "Enter provider name",
"providerCode": "Provider Code",
"placeholderProviderCode": "Enter provider code",
"gameCode": "Game Code",
"placeholderGameCode": "Enter game code",
"gameKey": "Game Key",
"placeholderGameKey": "Enter unique game key",
"gameName": "Name (ZH)",
"placeholderGameName": "Enter Chinese name",
"gameNameEn": "Name (EN)",
"placeholderGameNameEn": "Enter English name",
"gameType": "Game Type",
"placeholderGameType": "Enter game type",
"sort": "Sort",
"logo": "Logo URL",
"tabPicker": "Pick Image",
"tabUpload": "Upload Image",
"gameUrl": "Game URL",
"placeholderGameUrl": "Enter game URL",
"hallUrl": "Hall URL",
"placeholderHallUrl": "Enter hall URL",
"status": "Status",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"remark": "Remark",
"placeholderRemark": "Enter remark",
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully",
"ruleProviderRequired": "Provider is required",
"ruleProviderCodeRequired": "Provider code is required",
"ruleGameCodeRequired": "Game code is required",
"ruleGameKeyRequired": "Game key is required",
"ruleGameNameRequired": "Chinese name is required",
"ruleGameTypeRequired": "Game type is required"
},
"search": {
"providerCode": "Provider Code",
"placeholderProviderCode": "Enter provider code",
"gameCode": "Game Code",
"placeholderGameCode": "Enter game code",
"gameType": "Game Type",
"placeholderGameType": "Enter game type",
"status": "Status",
"placeholderStatus": "Select status",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled"
},
"table": {
"id": "ID",
"provider": "Provider",
"providerCode": "Provider Code",
"gameCode": "Game Code",
"gameKey": "Game Key",
"gameName": "Name (ZH)",
"gameNameEn": "Name (EN)",
"gameType": "Type",
"sort": "Sort",
"status": "Status",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"updateTime": "Update Time"
}
}

View File

@@ -5,10 +5,13 @@
"dialogTitleEdit": "Edit Lottery Pool Config",
"placeholderName": "Please enter name",
"placeholderRemark": "Please enter remark",
"placeholderPoolName": "Pool display name, e.g. Normal pool",
"placeholderConfigNote": "Optional notes about this pool",
"poolType": "Pool Type",
"placeholderPoolType": "Please select pool type",
"poolTypeNormal": "Normal",
"poolTypeKill": "Kill",
"poolTypeFree": "Free",
"poolTypeKill": "Force score kill",
"poolTypeT1": "T1 High",
"safetyLine": "Safety Line",
"t1Weight": "T1 Pool Weight (%)",
@@ -21,19 +24,22 @@
"currentPoolTitle": "Current Lottery Pool",
"loading": "Loading...",
"poolName": "Pool Name",
"playerProfit": "Player Total Profit (profit_amount):",
"poolProfitAmount": "Pool cumulative profit (profit_amount):",
"realtime": "Live",
"profitCalcHint": "Profit per round: paid = win_coin (incl. BIGWIN) - paid_amount (= ante×1); free = win_coin. Refreshes every 2s while open.",
"tierRuleTitle": "Tier Rule",
"tierRuleContent": "When player profit in this pool is below safety line, use player T*_weight; when above or equal, use pool T*_weight (kill).",
"killScoreWeights": "Kill weights",
"killWeightNote": "(Kill weights from pool config type=1; edit in list.)",
"btnResetProfit": "Reset Player Total Profit",
"btnSaveSafetyLine": "Save Safety Line",
"profitCalcHint": "Accumulated on name=default (Normal) pool: paid += win_coin paid_amount (ante×1); free += win_coin. Compared with safety line to decide paid-draw kill switch. Refreshes every 2s while open.",
"tierRuleTitle": "Paid draw tier rule",
"tierRuleContent": "Compares default pool profit_amount (not per-player profit). Below safety line or kill off: paid uses player T*_weight; at/above safety line with kill on: paid uses killScore pool. Free draws always use channel name=free pool weights (fallback default if missing); safety line N/A.",
"enableKillScore": "Enable kill score",
"killScoreWeights": "Kill weights (killScore)",
"killWeightNote": "Edit killScore (Force Kill) row in the list for kill weights. This dialog only configures default pool safety line and kill switch.",
"btnResetProfit": "Reset pool cumulative profit",
"btnSaveSafetyLine": "Save safety line & kill switch",
"safetyLineDefaultOnlyHint": "Only the default (Normal) pool safety line affects kill logic; do not set safety line on other pool types.",
"safetyLineNotUsedReadonly": "This pool type does not use safety line for kill logic. Edit the Normal (default) row or use View Current Pool.",
"ruleSafetyLineRequired": "Please enter safety line",
"msgGetPoolFailed": "Failed to get lottery pool",
"msgSaveSuccess": "Save Success",
"msgResetProfitSuccess": "Player total profit reset to 0",
"msgResetProfitSuccess": "Pool cumulative profit reset to 0",
"msgResetFailed": "Reset failed",
"ruleNameRequired": "Name is required",
"rulePoolTypeRequired": "Please select pool type",
@@ -54,13 +60,18 @@
"placeholderName": "Please enter name",
"placeholderPoolType": "Please select pool type",
"poolTypeNormal": "Normal",
"poolTypeKill": "Force Kill",
"poolTypeFree": "Free",
"poolTypeKill": "Force Score Kill",
"poolTypeT1": "T1 High Rate"
},
"table": {
"name": "Name",
"name": "Code",
"poolName": "Pool Name",
"configNote": "Remark",
"poolType": "Pool Type",
"safetyLine": "Safety Line",
"safetyLineNotUsed": "Not used for kill",
"safetyLineTip": "Normal (default) row only",
"t1PoolWeight": "T1 Pool Weight",
"t2PoolWeight": "T2 Pool Weight",
"t3PoolWeight": "T3 Pool Weight",

View File

@@ -34,7 +34,15 @@
"placeholderRewardTier": "Select reward tier",
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully",
"validateFailed": "Validation failed, please check required fields and format"
"validateFailed": "Validation failed, please check required fields and format",
"rulePlayerRequired": "Please select player",
"ruleLotteryConfigRequired": "Please select lottery pool config",
"ruleLotteryTypeRequired": "Please select draw type",
"ruleIsWinRequired": "Please select big win status",
"ruleWinCoinRequired": "Win coin is required",
"ruleRollArrayLength": "Roll array must have 5 numbers",
"ruleRollArrayValues": "Enter 5 numbers, each between 1 and 6",
"ruleRewardTierRequired": "Please select reward tier"
},
"toolbar": {
"platformTotalProfit": "Platform Total Profit"
@@ -49,6 +57,7 @@
"rollNumber": "Roll Number",
"rewardTier": "Reward Tier",
"rewardConfig": "Reward Config",
"createTime": "Created At",
"usernameFuzzy": "Username (fuzzy)",
"nameFuzzy": "Name (fuzzy)",
"uiTextFuzzy": "UI Text (fuzzy)",
@@ -76,6 +85,7 @@
"rollArray": "Roll Array",
"rollNumber": "Roll Number",
"rewardTier": "Reward Tier",
"remark": "Remark",
"createTime": "Create Time",
"updateTime": "Update Time"
}

View File

@@ -4,6 +4,8 @@
"platformTotalProfit": "Platform Total Profit"
},
"search": {
"lotteryPoolConfig": "Lottery Pool Config",
"placeholderLotteryPool": "Select pool (search by name)",
"rewardConfigRecordId": "Weight Test Record ID",
"drawType": "Draw Type",
"direction": "Direction",

View File

@@ -14,12 +14,16 @@
"status": "Status",
"adminId": "Admin",
"placeholderAdmin": "Select admin (optional)",
"placeholderAdminTree": "Select admin by channel",
"unassignedChannel": "Unassigned channel",
"coin": "Coin",
"placeholderCoinAdd": "Default 0 on create, read-only",
"lotteryPoolConfig": "Lottery Pool Config",
"placeholderLotteryPool": "Leave empty for custom weights below, or select pool",
"currentConfig": "Current Config",
"configLabelName": "Name",
"configLabelPoolName": "Pool name",
"configLabelCode": "Code",
"configLabelType": "Type",
"configLabelWeights": "T1T5 Weights",
"configLabelRemark": "Remark",
@@ -44,7 +48,18 @@
"ruleEnterCoin": "Please enter coin change",
"ruleCoinPositive": "Coin change must be greater than 0",
"ruleDeductExceed": "Deduct cannot exceed current balance",
"operateSuccess": "Success"
"operateSuccess": "Success",
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully",
"rulePasswordRequired": "Password is required",
"ruleUsernameRequired": "Username is required",
"ruleNicknameRequired": "Nickname is required",
"rulePhoneRequired": "Phone is required",
"ruleStatusRequired": "Status is required",
"ruleCoinRequired": "Coin is required",
"configTypeDefault": "Default",
"configTypeKillScore": "Kill score",
"configTypeUp": "Up score"
},
"search": {
"username": "Username",
@@ -57,7 +72,8 @@
"placeholderNickname": "Please enter nickname",
"placeholderPhoneFuzzy": "Phone (fuzzy)",
"placeholderAll": "All",
"exactSearch": "Exact"
"exactSearch": "Exact",
"createTime": "Register Time"
},
"table": {
"username": "Username",

View File

@@ -14,7 +14,12 @@
"placeholderTotalDrawCount": "Auto sum",
"placeholderRemark": "Remark (required)",
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully"
"editSuccess": "Updated successfully",
"rulePlayerRequired": "Please select player",
"ruleUseCoinsRequired": "Coins used is required",
"rulePaidDrawRequired": "Paid draw count is required",
"ruleFreeDrawRequired": "Free draw count is required",
"ruleRemarkRequired": "Remark is required"
},
"search": {
"player": "Player",

View File

@@ -1,4 +1,9 @@
{
"toolbar": {
"coinChangeSummary": "Net Coin Change",
"coinInflow": "Inflow",
"coinOutflow": "Outflow"
},
"form": {
"dialogTitleAdd": "Add Wallet Record",
"dialogTitleEdit": "Edit Wallet Record",
@@ -19,7 +24,10 @@
"placeholderWalletAfter": "Auto calculated",
"placeholderRemark": "Optional",
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully"
"editSuccess": "Updated successfully",
"ruleUserRequired": "Please select user",
"ruleCoinRequired": "Coin change is required",
"ruleTypeRequired": "Please select type"
},
"search": {
"type": "Type",

View File

@@ -25,8 +25,6 @@
"emptyTier": "No data for this tier",
"sumLineDual": "Tier weight sum (clockwise): {cw}; counter-clockwise: {ccw} (each row 110000, ratio draw within tier, sum not limited)",
"sumLineSingle": "Tier weight sum: {sum} (each row 110000, ratio draw within tier, sum not limited)",
"t4t5NoteSingle": "T4 and T5 have a single outcome; no weight configuration.",
"t4t5NoteDual": "T4 and T5 have a single outcome when hit; no weight configuration.",
"colEndIndexId": "End Index (id)",
"colGridNumber": "Points (grid_number)",
"colDicePoints": "Dice Points",
@@ -45,32 +43,40 @@
},
"weightEdit": {
"title": "Dice Reward (dice_reward) Weight Ratio",
"globalTip": "You are editing weights on dice_reward (DiceReward), split by end_index into clockwise and counter-clockwise; the draw uses the set for the current direction."
"globalTip": "You are editing weights on dice_reward (DiceReward), split by end_index into clockwise and counter-clockwise; the draw uses the set for the current direction. T4/T5 support the same multi-point weight ratio as T1T3."
},
"weightRatio": {
"title": "Weight Ratio",
"globalTip": "Configure dice_reward weights: first by direction (clockwise / counter-clockwise), then by tier (T1T5); each row weight 110000, ratio draw within tier.",
"globalTip": "Configure dice_reward weights: first by direction (clockwise / counter-clockwise), then by tier (T1T5); each row weight 110000, ratio draw within tier for dice points (T4/T5 use the same logic as T1T3 and support multiple point weights).",
"tabClockwise": "Clockwise",
"tabCounterclockwise": "Counter-clockwise"
},
"weightTest": {
"title": "One-Click Weight Test",
"alertTitle": "Bonus pool logic",
"alertBody": "Test mode is non-kill by default. You can enable kill mode below with switch + safety line: once simulated player cumulative profit reaches the line, paid draws switch to killScore.",
"alertBody": "Kill switching is off by default. Enable “Test kill mode below and set a test safety line (independent from the lottery pool config) to simulate kill triggers.",
"chainModeHint": "Simulation: set paid spin counts only (CW/CCW). If a paid draw hits “play again” (or T5), the next draw is free with the same ante, lottery type free, paid amount 0. Free-draw tier odds are configured below (including chained free plays).",
"killModeHint": "When test kill mode is enabled: use simulated player cumulative profit as trigger; once cumulative profit >= safety line, subsequent paid draws use killScore. Free draws still follow the configured free settings.",
"labelKillModeEnabled": "Enable test kill mode",
"killModeHint": "When test kill mode is on: start from default pool profit_amount and accumulate each spin (paid: win_coin - paid_amount; free: win_coin). Once profit >= the test safety line below, subsequent paid draws use killScore; free draws still use the name=free pool.",
"killModePanelTitle": "Test kill mode",
"killModeSwitchOn": "On",
"killModeSwitchOff": "Off",
"labelTestSafetyLine": "Test safety line",
"testSafetyLineHint": "Used for this weight test only, independent from the pool config safety line. Use a lower value to observe kill switching quickly.",
"poolProfitRef": "Reference: default pool profit {profit}, pool config safety line {line}",
"killModeOffHint": "When off, all draws follow paid/free settings without kill switching.",
"sectionPaid": "Paid draws",
"sectionFreeAfterPlayAgain": "Free draw tier odds (after play-again)",
"tierProbHintFreeChain": "When using custom tier odds: T1T5 below apply when a free draw runs (tier roll; combined with dice_reward row weights).",
"sectionFreeAfterPlayAgain": "Free draws (play again)",
"tierProbHintFreeChain": "Custom tiers: T1T5 odds for free draws (combined with dice_reward row weights).",
"stepPaid": "Paid ticket",
"stepFree": "Free ticket",
"labelLotteryTypePaid": "Test pool type",
"labelLotteryTypeFree": "Test pool type",
"labelLotteryTypePaid": "Paid tier pool",
"labelLotteryTypeFree": "Free tier pool",
"labelAnte": "Ante",
"placeholderPaidPool": "Leave empty for custom tier odds below (default: default)",
"placeholderFreePool": "Leave empty for custom tier odds below (default: killScore)",
"placeholderAnte": "Select ante config",
"anteRandomOption": "Random (each paid spin picks independently from channel ante configs)",
"placeholderPaidPool": "Leave empty to set T1T5 weights manually",
"placeholderFreePool": "Leave empty to set T1T5 weights manually",
"selectedPoolHint": "Selected pool: {name}",
"tierProbHint": "Custom tier odds (T1T5), each 0100%, sum of five must not exceed 100%",
"tierFieldLabel": "Tier {tier} (%)",
"tierSumError": "Current sum of five tiers is {sum}%, cannot exceed 100%",
@@ -81,7 +87,7 @@
"btnNext": "Next",
"btnStart": "Start test",
"btnCancel": "Cancel",
"warnAnte": "Ante must be greater than 0",
"warnAnte": "Please select ante",
"warnPaidSpins": "Paid clockwise + counter-clockwise spin counts must be greater than 0",
"warnTestSafetyLine": "Test safety line must be greater than or equal to 0",
"warnTotalSpins": "At least one of paid/free direction spin counts must be greater than 0",

View File

@@ -8,6 +8,18 @@
"tabIndex": "Reward Index",
"tabBigwin": "Big Win Weights",
"tipIndex": "Dice points must be between 5 and 30 and unique in this table.",
"tierRecommendRules": "[Settlement vs tier] T1 (big prize): >2; T2 (small win): 2≥amount>1; T3 (rake): 1≥amount>0; T4 (penalty): 0>amount; T5 (try again): 0=amount. Set recommended settlement per tier below. The Tier column is auto-calculated from settlement and cannot be edited manually.",
"tierRecommendRealEv": "Recommended settlement",
"tierRecommendAutoMatch": "Auto-match tier when settlement changes",
"tierRecommendApplyAmount": "Fill recommended amount for rows with tier set",
"tierRecommendApplyAmountOk": "Filled recommended settlement for {n} row(s)",
"tierRecommendNoTierRows": "No rows with a tier inferable from settlement",
"tierRecommendMatchTier": "Match all tiers from settlement",
"tierRecommendMatchTierOk": "Matched tier for {n} row(s) from settlement",
"tierRecommendMatchTierNone": "No rows to match",
"tierRecommendT5UiText": "再来一次",
"tierRecommendT5UiTextEn": "Once again",
"colTierAutoHint": "Auto-matched from settlement",
"tipBigwin": "Left to right: big-win points (read-only), display text, real EV, remark, weight (0~10000). Points 5 and 30 are fixed at 100%. This tab saves big-win weights only.",
"colId": "Index (id)",
"colDicePoints": "Dice Points",
@@ -36,6 +48,19 @@
"confirmCreateRefMsg": "Create reward reference by rule: start_index is the id of the cell for grid_number in reward config; clockwise end_index=(start_index+roll)%26; counter-clockwise end_index=start_index-roll if >=0 else 26+start_index-roll. Existing data will be cleared, then 26 points (530) for both directions will be generated. Continue?",
"confirmCreateRefOk": "Create",
"confirmCreateRefCancel": "Cancel",
"createRefPreviewTitle": "Create Reward Reference Preview",
"createRefPreviewClockwise": "Clockwise",
"createRefPreviewCounterclockwise": "Counter-clockwise",
"createRefPreviewTipUnchanged": "Dice points mapping is unchanged: weights in the preview are reused from current dice_reward; importing will not override existing weights.",
"createRefPreviewTipChanged": "Dice points mapping has changed: preview weights use defaults (100 for normal sums; 10 for sums 10/15/20/25; 1 for 5/30). After importing, adjust weights in the Dice Reward page if needed.",
"createRefPreviewSkipped": "{n} dice point(s) are missing in the reward index and were skipped (please complete all 26 points from 5 to 30).",
"createRefPreviewRefresh": "Refresh preview",
"createRefPreviewImport": "Import",
"createRefPreviewImportOk": "Imported reward reference",
"createRefPreviewImportNoop": "Mapping unchanged, nothing to import (existing weights kept)",
"createRefPreviewDiff": "Diff (old → new)",
"createRefPreviewNoDiff": "No change",
"createRefPreviewWeightsSaved": "Weights saved",
"createRefSuccess": "Created for 26 dice points (530), clockwise + counter-clockwise: clockwise added {cwNew}, counter-clockwise added {ccwNew}; clockwise updated {cwUp}, counter-clockwise updated {ccwUp}{skippedPart}",
"createRefSuccessSkipped": "; {n} point(s) used fallback start index",
"createRefSuccessSimple": "Created successfully",
@@ -54,7 +79,7 @@
"infoNoBigwin": "No BIGWIN rows. Set tier to BIGWIN in the Reward Index tab first.",
"btnRuleGenerate": "Generate by rules",
"ruleGenerateTitle": "Generate reward index by rules",
"ruleGenerateRules": "[Generation logic (same as Create Reward Reference)]\n• 26 cells ordered by id ascending are positions 025; each rows grid_number is 530 and unique.\n• Roll D (530): start at the cell whose grid_number equals D (start_index); clockwise landing = (start position + D) mod 26; counter-clockwise = start D (if negative, +26).\n• Each reference rows “dice points” column is the roll D; tier / real_ev / display text come from the config at the landing id.\n\n[Leopard rolls]\nFor rolls 5, 10, 15, 20, 25, 30, clockwise and counter-clockwise landing tiers must NOT be T4 or T5 (avoid leopard roll + penalty / once again).\n\n[Settlement amount vs tier]\nSettlement < 0 → T4; 0 < Settlement < 100 → T3; 100 < Settlement < 200 → T2; Settlement > 200 → T1; T5 “once again” settlement = 0. You can set a unified settlement standard for each tier below; generated rows write those values into the config, and details can be edited later in the table.\n\n[Inputs in this dialog]\nCount: T1/T4/T5 are fixed; T2 is minimum. Clockwise and counter-clockwise weighted counts (each roll result counts once) must each satisfy the entered values; T1, T4, and T5 are entered separately.\nSettlement standard: all cells in the same tier use the same value. On generation, T1T4 use ui_text / ui_text_en = settlement real_ev; T5 is fixed to \"再来一次\" / \"Once again\". Remarks still distinguish break-even / small win, etc.",
"ruleGenerateRules": "[Generation logic (same as Create Reward Reference)]\n• 26 cells ordered by id ascending are positions 025; each rows grid_number is 530 and unique.\n• Roll D (530): start at the cell whose grid_number equals D (start_index); clockwise landing = (start position + D) mod 26; counter-clockwise = start D (if negative, +26).\n• Each reference rows “dice points” column is the roll D; tier / real_ev / display text come from the config at the landing id.\n\n[Leopard rolls]\nFor rolls 5, 10, 15, 20, 25, 30, clockwise and counter-clockwise landing tiers must NOT be T4 or T5 (avoid leopard roll + penalty / try again).\n\n[Settlement vs tier]\nT1: >2; T2: 2≥amount>1; T3: 1≥amount>0; T4: 0>amount; T5: 0=amount. Set recommended settlement per tier below.\n\n[Inputs in this dialog]\nCount: T1/T4/T5 are fixed; T2 is minimum. Clockwise and counter-clockwise weighted counts must each satisfy the entered values.\nSettlement standard: same tier uses the same value. T1T4 use ui_text = settlement; T5 is fixed to \"再来一次\" / \"Once again\".",
"ruleGenT1Row": "T1 (big prize)",
"ruleGenT2Row": "T2 (small win / break-even)",
"ruleGenT3RealEvOnly": "T3 (rake)",
@@ -64,11 +89,11 @@
"ruleGenFixedCount": "Fixed count (CW & CCW)",
"ruleGenRealEvStd": "real_ev standard",
"ruleGenRealEvEditHint": "After saving, you can still edit display text, EN, real_ev and remarks per row in the table above.",
"ruleGenInvalidT1RealEv": "T1 settlement amount must satisfy: value > 200",
"ruleGenInvalidT2RealEv": "T2 settlement amount must satisfy: 100 < value < 200",
"ruleGenInvalidT3RealEv": "T3 settlement amount must satisfy: 0 < value < 100",
"ruleGenInvalidT4RealEv": "T4 settlement amount must satisfy: value < 0",
"ruleGenInvalidT5RealEv": "T5 try again” real_ev must be 0",
"ruleGenInvalidT1RealEv": "T1 (big prize) settlement must satisfy: value > 2",
"ruleGenInvalidT2RealEv": "T2 (small win) settlement must satisfy: 1 < value 2",
"ruleGenInvalidT3RealEv": "T3 (rake) settlement must satisfy: 0 < value 1",
"ruleGenInvalidT4RealEv": "T4 (penalty) settlement must satisfy: value < 0",
"ruleGenInvalidT5RealEv": "T5 (try again) settlement must be 0",
"ruleGenT1Min": "T1 fixed count (CW & CCW)",
"ruleGenT2Min": "T2 min (CW & CCW)",
"ruleGenT4Max": "T4 fixed count (CW & CCW)",

View File

@@ -17,6 +17,8 @@
"chainModeNo": "No",
"paidPlannedSpins": "Planned paid spins",
"ante": "Ante",
"anteRandom": "Random",
"testSafetyLine": "Safety line",
"playAgainCount": "Play-again count",
"progressDraws": "{over} done",
"progressFailed": "{over} before fail",
@@ -51,11 +53,13 @@
"testCountProgress": "In progress: {over} done",
"testCountFailed": "{over} before failure",
"chainModeLabel": "Chain play-again",
"killModeOff": "Kill mode off",
"paidPlannedSpins": "Planned paid spins",
"testSafetyLine": "Test safety line",
"createTime": "Created at",
"admin": "Operator",
"paidPoolId": "Paid lottery pool config ID",
"freePoolId": "Free lottery pool config ID",
"paidPoolId": "Paid lottery pool",
"freePoolId": "Free lottery pool",
"bigwinSnapshot": "BIGWIN weight snapshot",
"sectionPaidTier": "Paid draw tier odds (T1T5, used in test)",
"sectionFreeTier": "Free draw tier odds (T1T5, used in test)",

View File

@@ -0,0 +1,27 @@
{
"title": "Admin Guide",
"toolbar": {
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"refresh": "Refresh"
},
"meta": {
"filePath": "File Path",
"updateTime": "Updated At"
},
"catalog": {
"title": "Catalog",
"empty": "No headings"
},
"image": {
"zoom": "Click to view full size"
},
"message": {
"loadFailed": "Failed to load admin guide",
"saveSuccess": "Saved successfully",
"saveFailed": "Failed to save",
"cancelConfirm": "You have unsaved changes. Cancel editing?",
"editRequired": "Please click Edit before saving"
}
}

View File

@@ -1,38 +1,35 @@
{
"search": {
"deptName": "channel(Department) Name",
"deptCode": "Dept Code",
"deptName": "Channel Name",
"deptCode": "Channel Code",
"status": "Status",
"placeholderDeptName": "Please enter dept name",
"placeholderDeptCode": "Please enter dept code",
"placeholderDeptName": "Please enter channel name",
"placeholderDeptCode": "Please enter channel code",
"searchSelectPlaceholder": "Please select"
},
"table": {
"deptName": "channel(Department) Name",
"deptCode": "Dept Code",
"leader": "Leader",
"deptName": "Channel Name",
"deptCode": "Channel Code",
"leader": "Channel Leader",
"sort": "Sort",
"status": "Status",
"createTime": "Create Time"
},
"form": {
"titleAdd": "Add Department",
"titleEdit": "Edit Department",
"labelParentDept": "Parent Department",
"labelDeptName": "Dept Name",
"labelDeptCode": "Dept Code",
"labelLeader": "Leader",
"titleAdd": "Add Channel",
"titleEdit": "Edit Channel",
"labelDeptName": "Channel Name",
"labelDeptCode": "Channel Code",
"labelLeader": "Channel Leader",
"labelRemark": "Description",
"labelSort": "Sort",
"labelStatus": "Enabled",
"placeholderDeptName": "Please enter dept name",
"placeholderDeptCode": "Please enter dept code",
"placeholderDeptName": "Please enter channel name",
"placeholderDeptCode": "Please enter channel code",
"placeholderRemark": "Please enter description",
"placeholderSort": "Please enter sort",
"noParentDept": "No parent department",
"ruleParentDeptRequired": "Please select parent department",
"ruleDeptNameRequired": "Please enter dept name",
"ruleDeptCodeRequired": "Please enter dept code",
"ruleDeptNameRequired": "Please enter channel name",
"ruleDeptCodeRequired": "Please enter channel code",
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully"
}

View File

@@ -1,34 +0,0 @@
{
"search": {
"postName": "Post Name",
"postCode": "Post Code",
"status": "Status",
"placeholderPostName": "Please enter post name",
"placeholderPostCode": "Please enter post code",
"searchSelectPlaceholder": "Please select"
},
"table": {
"postName": "Post Name",
"postCode": "Post Code",
"sort": "Sort",
"status": "Status",
"createTime": "Create Time"
},
"form": {
"titleAdd": "Add Post",
"titleEdit": "Edit Post",
"labelName": "Post Name",
"labelCode": "Post Code",
"labelRemark": "Description",
"labelSort": "Sort",
"labelStatus": "Enabled",
"placeholderName": "Please enter post name",
"placeholderCode": "Please enter post code",
"placeholderRemark": "Please enter description",
"placeholderSort": "Please enter sort",
"ruleNameRequired": "Please enter post name",
"ruleCodeRequired": "Please enter post code",
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully"
}
}

View File

@@ -10,7 +10,7 @@
"table": {
"username": "Username",
"phone": "Phone",
"dept": "Department",
"dept": "Channel",
"dashboard": "Dashboard",
"loginTime": "Last Login",
"agentId": "Agent ID",
@@ -28,9 +28,8 @@
"labelPasswordConfirm": "Confirm Password",
"labelEmail": "Email",
"labelPhone": "Phone",
"labelDept": "Department",
"labelDept": "Channel",
"labelRole": "Role",
"labelPost": "Post",
"labelGender": "Gender",
"labelStatus": "Status",
"labelRemark": "Remark",
@@ -43,12 +42,15 @@
"rulePasswordRequired": "Please enter password",
"rulePasswordLength": "Length must be between 6 and 20 characters",
"rulePasswordConfirmRequired": "Please enter confirm password",
"ruleDeptRequired": "Please select department",
"ruleDeptRequired": "Please select channel",
"ruleRoleRequired": "Please select role",
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully"
},
"ui": {
"channelList": "Channel List",
"viewingChannel": "Current channel",
"defaultConfigTemplate": "Default config template",
"promptNewPassword": "Please enter a new password",
"passwordLengthError": "Password length must be between 6 and 16",
"passwordChanged": "Password updated",

View File

@@ -37,7 +37,16 @@
"tips": "提示",
"cancel": "取消",
"confirm": "确定",
"logOutTips": "您是否要退出登录?"
"logOutTips": "您是否要退出登录?",
"channelScope": {
"listTitle": "渠道列表",
"defaultTemplate": "默认配置模板",
"defaultRoleTemplate": "默认角色模板",
"allChannels": "全部",
"currentConfig": "当前配置",
"currentChannel": "当前渠道",
"currentRole": "当前角色范围"
}
},
"uiMsg": {
"titlePrompt": "提示",
@@ -291,23 +300,51 @@
"gohome": "返回首页"
},
"console": {
"filter": {
"date": "统计日期",
"placeholder": "选择日期",
"today": "今日",
"clear": "清空"
},
"card": {
"playerRegister": "玩家注册",
"playerCharge": "玩家充值",
"playerWithdraw": "玩家提现",
"playerPlayCount": "玩家游玩次数",
"vsLastWeek": "较上周"
"vsLastWeek": "较上周",
"vsYesterday": "较昨日",
"viewRechargeRecords": "查看充值记录",
"viewWithdrawRecords": "查看提现记录",
"viewPlayRecords": "查看游玩记录",
"viewRegisterRecords": "查看注册玩家"
},
"nav": {
"viewAllRecharge": "查看全部充值",
"viewAllPlay": "查看全部游玩",
"viewAllRegister": "查看全部注册"
},
"newPlayer": {
"title": "新增玩家",
"subtitle": "最新50条新增玩家记录",
"subtitleByDate": "{date} 新增玩家记录最多50条",
"player": "玩家",
"balance": "余额",
"ticket": "抽奖券"
"ticket": "抽奖券",
"registerTime": "注册时间"
},
"playRecord": {
"title": "玩家游玩记录",
"subtitle": "最新50条游玩记录",
"subtitleByDate": "{date} 游玩记录最多50条",
"player": "玩家",
"reward": "中奖档位",
"winCoin": "赢取平台币",
"playTime": "游玩时间"
},
"walletRecord": {
"title": "玩家充值记录",
"subtitle": "最新50条充值记录",
"subtitleByDate": "{date} 充值记录最多50条",
"player": "玩家",
"chargeAmount": "充值金额",
"chargeTime": "充值时间"
@@ -374,8 +411,7 @@
"role": "角色管理",
"userCenter": "个人中心",
"menu": "菜单管理",
"dept": "渠道(部门)管理",
"post": "岗位管理",
"dept": "渠道管理",
"config": "系统配置"
},
"safeguard": {
@@ -407,6 +443,9 @@
"rewardConfigRecord": "权重测试记录",
"playRecordTest": "抽奖记录(测试权重)",
"config": "游戏配置"
},
"game": {
"title": "游戏管理"
}
},
"table": {
@@ -426,6 +465,10 @@
"max": "最大",
"startTime": "开始时间",
"endTime": "结束时间",
"quickDate": "快捷日期",
"quickToday": "今日",
"quickYesterday": "昨日",
"quickLast7Days": "近7天",
"placeholderUsername": "请输入用户名",
"placeholderNickname": "请输入昵称",
"placeholderPhone": "请输入手机号",
@@ -437,12 +480,10 @@
"placeholderTaskName": "请输入任务名称",
"placeholderTableName": "请输入数据表名称",
"placeholderDataSource": "请输入数据源名称",
"placeholderDeptName": "请输入部门名称",
"placeholderDeptCode": "请输入部门编码",
"placeholderDeptName": "请输入渠道名称",
"placeholderDeptCode": "请输入渠道编码",
"placeholderRoleName": "请输入角色名称",
"placeholderRoleCode": "请输入角色编码",
"placeholderPostName": "请输入岗位名称",
"placeholderPostCode": "请输入岗位编码",
"placeholderMenuName": "请输入菜单名称",
"placeholderMenuRoute": "请输入菜单路由",
"placeholderOperator": "请输入操作用户",
@@ -506,15 +547,13 @@
"system": {
"username": "用户名",
"phone": "手机号",
"dept": "部门",
"dept": "渠道",
"dashboard": "首页",
"loginTime": "上次登录",
"agentId": "代理ID",
"postName": "岗位名称",
"postCode": "岗位编码",
"deptName": "部门名称",
"deptCode": "部门编码",
"leader": "部门领导",
"deptName": "渠道名称",
"deptCode": "渠道编码",
"leader": "渠道负责人",
"roleName": "角色名称",
"roleCode": "角色编码",
"level": "角色级别",
@@ -534,7 +573,7 @@
"titleEn": "标题(英文)",
"value": "值",
"valueEn": "值(英文)",
"noParentDept": "无上级部门",
"noParentDept": "无上级渠道",
"noParentMenu": "无上级菜单",
"input": "文本框",
"textarea": "文本域",

View File

@@ -27,6 +27,8 @@
"labelIsDefault": "默认底注",
"placeholderName": "请输入名称",
"placeholderTitle": "请输入标题",
"placeholderNameAuto": "随底注倍率自动生成,如 x5",
"placeholderTitleAuto": "随底注倍率自动生成,如 x5",
"ruleNameRequired": "请输入名称",
"ruleTitleRequired": "请输入标题",
"ruleMultRequired": "请输入底注倍率",

View File

@@ -0,0 +1,68 @@
{
"form": {
"dialogTitleAdd": "新增游戏",
"dialogTitleEdit": "编辑游戏",
"provider": "供应商",
"placeholderProvider": "请输入供应商名称",
"providerCode": "供应商编码",
"placeholderProviderCode": "请输入供应商编码",
"gameCode": "游戏编号",
"placeholderGameCode": "请输入游戏编号",
"gameKey": "游戏唯一值",
"placeholderGameKey": "请输入游戏唯一值",
"gameName": "中文名称",
"placeholderGameName": "请输入中文名称",
"gameNameEn": "英文名称",
"placeholderGameNameEn": "请输入英文名称",
"gameType": "游戏类型",
"placeholderGameType": "请输入游戏类型",
"sort": "排序",
"logo": "Logo地址",
"tabPicker": "图片选择",
"tabUpload": "图片上传",
"gameUrl": "游戏地址",
"placeholderGameUrl": "请输入游戏地址",
"hallUrl": "大厅地址",
"placeholderHallUrl": "请输入大厅地址",
"status": "状态",
"statusEnabled": "启用",
"statusDisabled": "禁用",
"remark": "备注",
"placeholderRemark": "请输入备注",
"addSuccess": "新增成功",
"editSuccess": "更新成功",
"ruleProviderRequired": "请输入供应商",
"ruleProviderCodeRequired": "请输入供应商编码",
"ruleGameCodeRequired": "请输入游戏编号",
"ruleGameKeyRequired": "请输入游戏唯一值",
"ruleGameNameRequired": "请输入中文名称",
"ruleGameTypeRequired": "请输入游戏类型"
},
"search": {
"providerCode": "供应商编码",
"placeholderProviderCode": "请输入供应商编码",
"gameCode": "游戏编号",
"placeholderGameCode": "请输入游戏编号",
"gameType": "游戏类型",
"placeholderGameType": "请输入游戏类型",
"status": "状态",
"placeholderStatus": "请选择状态",
"statusEnabled": "启用",
"statusDisabled": "禁用"
},
"table": {
"id": "ID",
"provider": "供应商",
"providerCode": "供应商编码",
"gameCode": "游戏编号",
"gameKey": "游戏唯一值",
"gameName": "中文名",
"gameNameEn": "英文名",
"gameType": "类型",
"sort": "排序",
"status": "状态",
"statusEnabled": "启用",
"statusDisabled": "禁用",
"updateTime": "更新时间"
}
}

View File

@@ -5,10 +5,14 @@
"dialogTitleEdit": "编辑色子奖池配置",
"placeholderName": "请输入名称",
"placeholderRemark": "请输入备注",
"placeholderPoolName": "请输入奖池名称,如:正常池",
"placeholderConfigNote": "选填,用于说明该奖池用途或规则",
"poolType": "奖池类型",
"poolName": "奖池名称",
"placeholderPoolType": "请选择奖池类型",
"poolTypeNormal": "正常",
"poolTypeKill": "强制杀猪",
"poolTypeFree": "免费",
"poolTypeKill": "强制杀分",
"poolTypeT1": "T1高倍率",
"safetyLine": "安全线",
"t1Weight": "T1池权重(%)",
@@ -21,19 +25,22 @@
"currentPoolTitle": "当前彩金池",
"loading": "加载中...",
"poolName": "池子名称",
"playerProfit": "玩家累计盈利profit_amount",
"poolProfitAmount": "彩金池累计盈利profit_amount",
"realtime": "实时",
"profitCalcHint": "计算方式:付费每局按“赢取平台币 win_coin含 BIGWIN减去付费金额 压注金额paid_amount= 压注倍数ante×1”累加;免费每局按“玩家赢得平台币win_coin”累加。弹窗打开期间每 2 秒自动刷新",
"tierRuleTitle": "抽奖档位规则",
"tierRuleContent": "当玩家在当前彩金池的累计盈利 低于安全线 时,按 玩家 的 T*_weight 权重抽取档位;当累计盈利 高于或等于安全线 时,按 当前彩金池 的 T*_weight 权重抽取档位(杀分)。",
"killScoreWeights": "杀分权重",
"killWeightNote": "杀分权重来自奖池配置,请在列表中编辑对应记录",
"btnResetProfit": "重置玩家累计盈利",
"btnSaveSafetyLine": "保存安全线",
"profitCalcHint": "累计在 name=default正常奖池上付费每局 += win_coin paid_amountante×1免费每局 += win_coin。用于与安全线比较判定付费抽奖是否切换杀分。弹窗打开期间每 2 秒自动刷新",
"tierRuleTitle": "付费抽奖档位规则",
"tierRuleContent": "比较对象为 default 奖池的 profit_amount非单个玩家盈利。当 profit_amount 低于安全线或未开启杀分时,付费按玩家 T*_weight 抽档;当 profit_amount 高于或等于安全线且已开启杀分时,付费按 killScore 奖池抽档。免费抽奖始终按本渠道 name=free 奖池权重(无 free 时回退 default与安全线无关。",
"enableKillScore": "开启杀分",
"killScoreWeights": "杀分权重killScore",
"killWeightNote": "杀分权重请在列表中编辑 name=killScore强制杀分记录本弹窗仅配置 default 奖池的安全线与杀分开关。",
"btnResetProfit": "重置彩金池累计盈利",
"btnSaveSafetyLine": "保存安全线与杀分开关",
"safetyLineDefaultOnlyHint": "仅 name=default正常奖池的安全线参与杀分判定其它奖池类型请勿在此配置安全线。",
"safetyLineNotUsedReadonly": "当前奖池类型不参与杀分判定,安全线仅对「正常(default)」奖池生效,请通过「查看当前彩金池」或编辑正常行修改。",
"ruleSafetyLineRequired": "请输入安全线",
"msgGetPoolFailed": "获取彩金池失败",
"msgSaveSuccess": "保存成功",
"msgResetProfitSuccess": "玩家累计盈利已重置为 0",
"msgResetProfitSuccess": "彩金池累计盈利已重置为 0",
"msgResetFailed": "重置失败",
"ruleNameRequired": "名称必需填写",
"rulePoolTypeRequired": "请选择奖池类型",
@@ -51,16 +58,22 @@
},
"search": {
"poolType": "奖池类型",
"poolName": "奖池名称",
"placeholderName": "请输入名称",
"placeholderPoolType": "请选择奖池类型",
"poolTypeNormal": "正常",
"poolTypeKill": "强制杀猪",
"poolTypeFree": "免费",
"poolTypeKill": "强制杀分",
"poolTypeT1": "T1高倍率"
},
"table": {
"name": "名称",
"name": "内部标识",
"poolName": "奖池名称",
"configNote": "备注",
"poolType": "奖池类型",
"safetyLine": "安全线",
"safetyLineNotUsed": "不参与杀分判定",
"safetyLineTip": "仅「正常(default)」行有效",
"t1PoolWeight": "T1池权重",
"t2PoolWeight": "T2池权重",
"t3PoolWeight": "T3池权重",

View File

@@ -34,7 +34,15 @@
"placeholderRewardTier": "请选择中奖档位",
"addSuccess": "新增成功",
"editSuccess": "修改成功",
"validateFailed": "表单验证失败,请检查必填项与格式"
"validateFailed": "表单验证失败,请检查必填项与格式",
"rulePlayerRequired": "请选择玩家",
"ruleLotteryConfigRequired": "请选择彩金池配置",
"ruleLotteryTypeRequired": "请选择抽奖类型",
"ruleIsWinRequired": "请选择是否中大奖",
"ruleWinCoinRequired": "赢取平台币必填",
"ruleRollArrayLength": "摇取点数必须为 5 个数",
"ruleRollArrayValues": "摇取点数必须填写 5 个数,每个 16",
"ruleRewardTierRequired": "请选择中奖档位"
},
"toolbar": {
"platformTotalProfit": "平台总盈利"
@@ -49,6 +57,7 @@
"rollNumber": "摇取点数和",
"rewardTier": "中奖档位",
"rewardConfig": "奖励配置",
"createTime": "创建时间",
"usernameFuzzy": "用户名模糊",
"nameFuzzy": "名称模糊",
"uiTextFuzzy": "前端显示文本模糊",
@@ -76,6 +85,7 @@
"rollArray": "摇取点数",
"rollNumber": "摇取点数和",
"rewardTier": "中奖档位",
"remark": "备注",
"createTime": "创建时间",
"updateTime": "更新时间"
}

View File

@@ -4,6 +4,8 @@
"platformTotalProfit": "平台总盈利"
},
"search": {
"lotteryPoolConfig": "彩金池配置",
"placeholderLotteryPool": "请选择彩金池(可搜索 name",
"rewardConfigRecordId": "测试记录ID",
"drawType": "抽奖类型",
"direction": "方向",

View File

@@ -14,12 +14,16 @@
"status": "状态",
"adminId": "所属管理员",
"placeholderAdmin": "选择后台管理员(可选)",
"placeholderAdminTree": "按渠道选择后台管理员",
"unassignedChannel": "未分配渠道",
"coin": "平台币",
"placeholderCoinAdd": "创建时默认0不可改",
"lotteryPoolConfig": "彩金池配置",
"placeholderLotteryPool": "留空则使用下方自定义权重,或选择彩金池",
"currentConfig": "当前配置",
"configLabelName": "名称",
"configLabelPoolName": "奖池名称",
"configLabelCode": "内部标识",
"configLabelType": "类型",
"configLabelWeights": "T1T5 权重",
"configLabelRemark": "备注",
@@ -44,7 +48,18 @@
"ruleEnterCoin": "请输入平台币变动",
"ruleCoinPositive": "平台币变动必须大于 0",
"ruleDeductExceed": "扣点不能超过当前余额",
"operateSuccess": "操作成功"
"operateSuccess": "操作成功",
"addSuccess": "新增成功",
"editSuccess": "修改成功",
"rulePasswordRequired": "密码必需填写",
"ruleUsernameRequired": "用户名必需填写",
"ruleNicknameRequired": "昵称必需填写",
"rulePhoneRequired": "手机号必需填写",
"ruleStatusRequired": "状态必需填写",
"ruleCoinRequired": "平台币必需填写",
"configTypeDefault": "默认",
"configTypeKillScore": "杀分",
"configTypeUp": "上分"
},
"search": {
"username": "用户名",
@@ -57,7 +72,8 @@
"placeholderNickname": "请输入昵称",
"placeholderPhoneFuzzy": "手机号模糊查询",
"placeholderAll": "全部",
"exactSearch": "精确搜索"
"exactSearch": "精确搜索",
"createTime": "注册时间"
},
"table": {
"username": "用户名",

View File

@@ -14,7 +14,12 @@
"placeholderTotalDrawCount": "自动求和",
"placeholderRemark": "请输入备注(必填)",
"addSuccess": "新增成功",
"editSuccess": "修改成功"
"editSuccess": "修改成功",
"rulePlayerRequired": "请选择玩家",
"ruleUseCoinsRequired": "消耗硬币必需填写",
"rulePaidDrawRequired": "购买抽奖次数必需填写",
"ruleFreeDrawRequired": "赠送抽奖次数必需填写",
"ruleRemarkRequired": "备注必需填写"
},
"search": {
"player": "玩家",

View File

@@ -1,4 +1,9 @@
{
"toolbar": {
"coinChangeSummary": "平台币净变化",
"coinInflow": "流入",
"coinOutflow": "流出"
},
"form": {
"dialogTitleAdd": "新增玩家钱包流水",
"dialogTitleEdit": "编辑玩家钱包流水",
@@ -19,7 +24,10 @@
"placeholderWalletAfter": "根据平台币变化自动计算",
"placeholderRemark": "选填",
"addSuccess": "新增成功",
"editSuccess": "修改成功"
"editSuccess": "修改成功",
"ruleUserRequired": "请选择用户",
"ruleCoinRequired": "平台币变化必填",
"ruleTypeRequired": "请选择类型"
},
"search": {
"type": "类型",

View File

@@ -25,8 +25,6 @@
"emptyTier": "该档位暂无配置数据",
"sumLineDual": "当前档位权重合计(顺时针):{cw};逆时针:{ccw}(各条 1-10000档位内按权重比抽取和不限制",
"sumLineSingle": "当前档位权重合计:{sum}(各条 1-10000档位内按权重比抽取和不限制",
"t4t5NoteSingle": "T4、T5 仅单一结果,无需配置权重。",
"t4t5NoteDual": "T4、T5 档位抽中时仅有一个结果,无需配置权重。",
"colEndIndexId": "结束索引(id)",
"colGridNumber": "点数(grid_number)",
"colDicePoints": "色子点数",
@@ -45,32 +43,40 @@
},
"weightEdit": {
"title": "奖励对照表dice_reward权重配比",
"globalTip": "编辑的是奖励对照表dice_reward / DiceReward 模型的权重按结束索引end_index区分顺时针与逆时针两套权重抽奖时按当前方向取对应权重。"
"globalTip": "编辑的是奖励对照表dice_reward / DiceReward 模型的权重按结束索引end_index区分顺时针与逆时针两套权重抽奖时按当前方向取对应权重T4/T5 与 T1-T3 相同支持多点数权重配比。"
},
"weightRatio": {
"title": "权重配比",
"globalTip": "配置奖励对照表dice_reward的权重一级按方向顺时针/逆时针二级按档位T1-T5各条权重 1-10000档位内按权重比抽取。",
"globalTip": "配置奖励对照表dice_reward的权重一级按方向顺时针/逆时针二级按档位T1-T5各条权重 1-10000档位内按权重比抽取骰子点数T4/T5 与 T1-T3 逻辑相同,可配置多个点数的权重配比)。",
"tabClockwise": "顺时针",
"tabCounterclockwise": "逆时针"
},
"weightTest": {
"title": "一键测试权重",
"alertTitle": "彩金池逻辑说明",
"alertBody": "测试模式默认不启用杀分切换;可通过下方“杀分开关 + 安全线”在测试内启用:当模拟玩家累计盈利达到安全线后,付费抽奖切换到 killScore。",
"alertBody": "测试模式默认不启用杀分切换;开启下方「测试内杀分」后,可单独设置测试安全线(与彩金池配置中的安全线无关),用于模拟杀分触发。",
"chainModeHint": "模拟方式:只配置付费抽奖次数(顺/逆时针)。付费抽到「再来一次」或 T5 时,下一局自动为免费抽奖,底注与触发局相同,抽奖类型记为免费、付费金额记为 0。免费抽奖的档位概率由下方「免费抽奖」配置决定含通过再来一次触发的后续免费局。",
"killModeHint": "杀分开关开启后:以“模拟玩家累计盈利”作为判定值;当累计盈利 >= 安全线时,后续付费抽奖 killScore 配置抽取;免费抽奖仍按“免费抽奖配置”执行。",
"labelKillModeEnabled": "开启测试内杀分",
"killModeHint": "杀分开关开启后:从 default 奖池当前 profit_amount 起步逐局累加(付费=win_coin-paid_amount免费=win_coin当累计盈利 ≥ 下方「测试安全线时,后续付费抽奖 killScore免费抽奖仍走 name=free 奖池。",
"killModePanelTitle": "测试内杀分",
"killModeSwitchOn": "已开启",
"killModeSwitchOff": "已关闭",
"labelTestSafetyLine": "测试安全线",
"testSafetyLineHint": "仅用于本次权重测试,与彩金池配置页的安全线独立;可设较小值以便快速观察杀分切换效果。",
"poolProfitRef": "参考:当前 default 池盈利 {profit},彩金池配置安全线 {line}",
"killModeOffHint": "关闭时全程按付费/免费配置抽档,不模拟杀分切换。",
"sectionPaid": "付费抽奖",
"sectionFreeAfterPlayAgain": "免费抽奖(再来一次后的档位概率",
"tierProbHintFreeChain": "当使用自定义档位时:以下为「免费抽奖」时 T1T5 档位概率(仅在有免费局时参与摇档,与 dice_reward 格子权重共同决定结果)。",
"sectionFreeAfterPlayAgain": "免费抽奖(再来一次)",
"tierProbHintFreeChain": "自定义档位时:免费局 T1T5 档位概率(与 dice_reward 格子权重共同决定结果)。",
"stepPaid": "付费抽奖券",
"stepFree": "免费抽奖券",
"labelLotteryTypePaid": "测试数据档位类型",
"labelLotteryTypeFree": "测试数据档位类型",
"labelAnte": "底注 ante",
"placeholderPaidPool": "不选则下方自定义档位概率(默认 default",
"placeholderFreePool": "不选则下方自定义档位概率(默认 killScore",
"labelLotteryTypePaid": "付费档位奖池",
"labelLotteryTypeFree": "免费档位奖池",
"labelAnte": "底注",
"placeholderAnte": "请选择底注配置",
"anteRandomOption": "随机(每局付费抽奖从当前渠道底注配置中独立抽取",
"placeholderPaidPool": "不选则下方手动设定 T1T5 档位权重",
"placeholderFreePool": "不选则下方手动设定 T1T5 档位权重",
"selectedPoolHint": "已选奖池:{name}",
"tierProbHint": "自定义档位概率T1T5每档 0-100%,五档之和不能超过 100%",
"tierFieldLabel": "档位 {tier}%",
"tierSumError": "当前五档之和为 {sum}%,不能超过 100%",
@@ -81,7 +87,7 @@
"btnNext": "下一步",
"btnStart": "开始测试",
"btnCancel": "取消",
"warnAnte": "底注 ante 必须大于 0",
"warnAnte": "请选择底注",
"warnPaidSpins": "付费抽奖顺时针与逆时针次数之和须大于 0",
"warnTestSafetyLine": "测试安全线必须大于或等于 0",
"warnTotalSpins": "付费或免费至少一种方向次数之和大于 0",

View File

@@ -8,7 +8,19 @@
"tabIndex": "奖励索引",
"tabBigwin": "大奖权重",
"tipIndex": "色子点数须在 530 之间且本表内不重复。",
"tipBigwin": "从左至右:中大奖点数(不可改)、显示信息、实际中奖、备注、权重(0~10000)。点数 5、30 权重固定 100%。本表单独立提交,仅提交大奖权重。",
"tierRecommendRules": "【结算金额与档位】【大奖】T1结算金额>2【小赚】T22>=结算金额>1【抽水】T31>=结算金额>0【惩罚】T40>结算金额【再来一次】T50=结算金额。下方可为各档位填写推荐结算金额;表格中「所属档位」随结算金额自动计算,不可手动修改。",
"tierRecommendRealEv": "推荐结算金额",
"tierRecommendAutoMatch": "修改结算金额时自动匹配档位,并实时更新备注(大奖/小赚/抽水/惩罚/再来一次)",
"tierRecommendApplyAmount": "将推荐金额填入已选档位的行",
"tierRecommendApplyAmountOk": "已为 {n} 行填入推荐结算金额",
"tierRecommendNoTierRows": "没有可根据结算金额推断档位的行",
"tierRecommendMatchTier": "按结算金额匹配全部档位",
"tierRecommendMatchTierOk": "已根据结算金额为 {n} 行匹配档位",
"tierRecommendMatchTierNone": "没有可匹配档位的行",
"tierRecommendT5UiText": "再来一次",
"tierRecommendT5UiTextEn": "Once again",
"colTierAutoHint": "根据结算金额自动匹配",
"tipBigwin": "从左至右:中大奖点数(不可改)、显示信息、结算金额、备注、权重(0~10000)。点数 5、30 权重固定 100%。本表单独立提交,仅提交大奖权重。",
"colId": "索引(id)",
"colDicePoints": "色子点数",
"colDisplayText": "显示文本",
@@ -26,7 +38,7 @@
"colBigwinPoints": "中大奖点数",
"colDisplayInfo": "显示信息",
"colDisplayInfoEn": "显示信息(英文)",
"colRealPrize": "实际中奖",
"colRealPrize": "结算金额",
"colWeightRange": "权重(0-10000)",
"placeholderDisplayInfoZh": "显示信息(中文)",
"placeholderDisplayInfoEn": "显示信息(英文)",
@@ -36,6 +48,19 @@
"confirmCreateRefMsg": "按规则创建奖励对照:起始索引 start_index=奖励配置中 grid_number 对应格位的 id顺时针 end_index=(start_index+摇取点数)%26逆时针 end_index=start_index-摇取点数≥0 则取该值,否则 26+start_index-摇取点数。先清空现有数据再为 5-30 共 26 个点数、顺/逆时针分别生成。是否继续?",
"confirmCreateRefOk": "确定创建",
"confirmCreateRefCancel": "取消",
"createRefPreviewTitle": "创建奖励对照预览",
"createRefPreviewClockwise": "顺时针",
"createRefPreviewCounterclockwise": "逆时针",
"createRefPreviewTipUnchanged": "检测到色子点数映射未变化预览中权重将复用当前奖励对照表dice_reward的权重导入时不会覆盖现有权重。",
"createRefPreviewTipChanged": "检测到色子点数映射已变化:预览中权重将使用默认值(普通点数默认 100点数和 10/15/20/25 默认 105/30 默认 1确认导入后可再到「奖励对照」页面调整权重。",
"createRefPreviewSkipped": "有 {n} 个点数在当前奖励索引中缺失,已跳过生成(请先补齐 530 共 26 个点数)。",
"createRefPreviewRefresh": "刷新预览",
"createRefPreviewImport": "确认导入",
"createRefPreviewImportOk": "已导入奖励对照表",
"createRefPreviewImportNoop": "色子点数映射未变化,无需导入(已保留现有权重)",
"createRefPreviewDiff": "差异(旧 → 新)",
"createRefPreviewNoDiff": "无变化",
"createRefPreviewWeightsSaved": "已保存权重",
"createRefSuccess": "已按 5-30 共 26 个点数、顺时针+逆时针创建:顺时针新增 {cwNew} 条、逆时针新增 {ccwNew} 条;顺时针更新 {cwUp} 条、逆时针更新 {ccwUp} 条{skippedPart}",
"createRefSuccessSkipped": "{n} 个点数使用兜底起始索引",
"createRefSuccessSimple": "创建成功",
@@ -54,7 +79,7 @@
"infoNoBigwin": "暂无 BIGWIN 档位配置,请先在「奖励索引」中设置 tier 为 BIGWIN",
"btnRuleGenerate": "按规则生成",
"ruleGenerateTitle": "按规则生成奖励索引",
"ruleGenerateRules": "【生成逻辑(与创建奖励对照一致)】\n• 盘面 26 格按 id 升序为位置 025每条配置的 grid_number 为 530 且不重复。\n• 摇取点数 D530起点为「grid_number=D」所在格位的 id即 start_index顺时针落点位置 = (起点位置 + D) mod 26逆时针落点 = 起点位置 D若小于 0 则 +26。\n• 对照表每条记录的「色子点数」列为摇取点数 D档位、真实结算、显示文案取自落点格位对应 id 的配置。\n\n【豹子摇取点数】\n摇取点数为 5、10、15、20、25、30 时,其顺/逆时针落点档位不能为 T4、T5避免对照表上出现豹子点数 + 惩罚/再来一次)。\n\n【结算金额 与 档位】\n结算金额 < 0 → T40 < 结算金额 < 100 → T3100 < 结算金额 < 200 → T2200 < 结算金额 → T1T5「再来一次」结算金额=0。下方可为各档位填写统一的 结算金额 标准,生成时写入配置;细则可稍后在表格中再改。\n\n【本弹窗输入】\n条数T1/T4/T5「固定」T2「不少于」——顺时针与逆时针的加权条数每条摇取结果计一次须分别满足所填数值T1、T4 与 T5 分开填写。\n结算金额 标准:同档位各格使用同一数值。生成时 T1T4 的 显示文本 / 显示文本(英文) = 结算金额T5 固定为「再来一次」/「Once again」。备注仍区分完美回本/小赚等。",
"ruleGenerateRules": "【生成逻辑(与创建奖励对照一致)】\n• 盘面 26 格按 id 升序为位置 025每条配置的 grid_number 为 530 且不重复。\n• 摇取点数 D530起点为「grid_number=D」所在格位的 id即 start_index顺时针落点位置 = (起点位置 + D) mod 26逆时针落点 = 起点位置 D若小于 0 则 +26。\n• 对照表每条记录的「色子点数」列为摇取点数 D档位、真实结算、显示文案取自落点格位对应 id 的配置。\n\n【豹子摇取点数】\n摇取点数为 5、10、15、20、25、30 时,其顺/逆时针落点档位不能为 T4、T5避免对照表上出现豹子点数 + 惩罚/再来一次)。\n\n【结算金额 与 档位】\n【大奖】T1>2【小赚】T22>=金额>1【抽水】T31>=金额>0【惩罚】T40>金额【再来一次】T50=金额。下方可为各档位填写推荐结算金额标准,生成时写入配置。\n\n【本弹窗输入】\n条数T1/T4/T5「固定」T2「不少于」——顺时针与逆时针的加权条数每条摇取结果计一次须分别满足所填数值T1、T4 与 T5 分开填写。\n结算金额 标准:同档位各格使用同一数值。生成时 T1T4 的显示文本 = 结算金额T5 固定为「再来一次」/「Once again」。备注仍区分完美回本/小赚等。",
"ruleGenT1Row": "T1 大奖",
"ruleGenT2Row": "T2 小赚/回本",
"ruleGenT3RealEvOnly": "T3 抽水",
@@ -64,11 +89,11 @@
"ruleGenFixedCount": "固定条数(顺/逆)",
"ruleGenRealEvStd": "结算金额",
"ruleGenRealEvEditHint": "生成并保存后,仍可在本页表格中逐条修改显示文案、英文、真实结算与备注。",
"ruleGenInvalidT1RealEv": "T1结算金额 满足:200 < 值",
"ruleGenInvalidT2RealEv": "T2结算金额 满足100 < 值 < 200",
"ruleGenInvalidT3RealEv": "T3结算金额 满足0 < 值 < 100",
"ruleGenInvalidT4RealEv": "T4结算金额 满足:值 < 0",
"ruleGenInvalidT5RealEv": "T5再来一次」的 结算金额 须为 0",
"ruleGenInvalidT1RealEv": "T1(大奖)结算金额满足:值 > 2",
"ruleGenInvalidT2RealEv": "T2(小赚)结算金额满足1 < 值 2",
"ruleGenInvalidT3RealEv": "T3(抽水)结算金额满足0 < 值 1",
"ruleGenInvalidT4RealEv": "T4(惩罚)结算金额满足:值 < 0",
"ruleGenInvalidT5RealEv": "T5再来一次结算金额须为 0",
"ruleGenT1Min": "T1 固定条数(顺/逆)",
"ruleGenT2Min": "T2 最少条数(顺/逆)",
"ruleGenT4Max": "T4 固定条数(顺/逆)",

View File

@@ -17,6 +17,8 @@
"chainModeNo": "否",
"paidPlannedSpins": "计划付费次数",
"ante": "底注",
"anteRandom": "随机",
"testSafetyLine": "安全线",
"playAgainCount": "再来一次次数",
"progressDraws": "已完成 {over} 次",
"progressFailed": "失败前 {over} 次",
@@ -51,11 +53,13 @@
"testCountProgress": "进行中:已完成 {over} 次",
"testCountFailed": "失败前 {over} 次",
"chainModeLabel": "链式再来一次",
"killModeOff": "未开启杀分",
"paidPlannedSpins": "计划付费次数",
"testSafetyLine": "测试安全线",
"createTime": "创建时间",
"admin": "执行管理员",
"paidPoolId": "付费奖池配置ID",
"freePoolId": "免费奖池配置ID",
"paidPoolId": "付费彩金池",
"freePoolId": "免费彩金池",
"bigwinSnapshot": "BIGWIN 权重快照",
"sectionPaidTier": "付费抽奖档位概率T1-T5测试时使用",
"sectionFreeTier": "免费抽奖档位概率T1-T5测试时使用",

View File

@@ -0,0 +1,27 @@
{
"title": "后台操作指南",
"toolbar": {
"edit": "编辑",
"save": "保存",
"cancel": "取消",
"refresh": "刷新"
},
"meta": {
"filePath": "文档路径",
"updateTime": "更新时间"
},
"catalog": {
"title": "目录",
"empty": "暂无目录"
},
"image": {
"zoom": "点击查看原图"
},
"message": {
"loadFailed": "加载操作指南失败",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"cancelConfirm": "当前有未保存的修改,确定取消编辑吗?",
"editRequired": "请先点击编辑后再保存"
}
}

View File

@@ -1,38 +1,35 @@
{
"search": {
"deptName": "渠道(部门)名称",
"deptCode": "部门编码",
"deptName": "渠道名称",
"deptCode": "渠道编码",
"status": "状态",
"placeholderDeptName": "请输入部门名称",
"placeholderDeptCode": "请输入部门编码",
"placeholderDeptName": "请输入渠道名称",
"placeholderDeptCode": "请输入渠道编码",
"searchSelectPlaceholder": "请选择"
},
"table": {
"deptName": "渠道(部门)名称",
"deptCode": "部门编码",
"leader": "部门领导",
"deptName": "渠道名称",
"deptCode": "渠道编码",
"leader": "渠道负责人",
"sort": "排序",
"status": "状态",
"createTime": "创建时间"
},
"form": {
"titleAdd": "新增部门",
"titleEdit": "编辑部门",
"labelParentDept": "上级部门",
"labelDeptName": "部门名称",
"labelDeptCode": "部门编码",
"labelLeader": "部门领导",
"titleAdd": "新增渠道",
"titleEdit": "编辑渠道",
"labelDeptName": "渠道名称",
"labelDeptCode": "渠道编码",
"labelLeader": "渠道负责人",
"labelRemark": "描述",
"labelSort": "排序",
"labelStatus": "启用",
"placeholderDeptName": "请输入部门名称",
"placeholderDeptCode": "请输入部门编码",
"placeholderRemark": "请输入部门描述",
"placeholderDeptName": "请输入渠道名称",
"placeholderDeptCode": "请输入渠道编码",
"placeholderRemark": "请输入渠道描述",
"placeholderSort": "请输入排序",
"noParentDept": "无上级部门",
"ruleParentDeptRequired": "请选择上级部门",
"ruleDeptNameRequired": "请输入部门名称",
"ruleDeptCodeRequired": "请输入部门编码",
"ruleDeptNameRequired": "请输入渠道名称",
"ruleDeptCodeRequired": "请输入渠道编码",
"addSuccess": "新增成功",
"editSuccess": "修改成功"
}

View File

@@ -1,34 +0,0 @@
{
"search": {
"postName": "岗位名称",
"postCode": "岗位编码",
"status": "状态",
"placeholderPostName": "请输入岗位名称",
"placeholderPostCode": "请输入岗位编码",
"searchSelectPlaceholder": "请选择"
},
"table": {
"postName": "岗位名称",
"postCode": "岗位编码",
"sort": "排序",
"status": "状态",
"createTime": "创建时间"
},
"form": {
"titleAdd": "新增岗位",
"titleEdit": "编辑岗位",
"labelName": "岗位名称",
"labelCode": "岗位编码",
"labelRemark": "描述",
"labelSort": "排序",
"labelStatus": "启用",
"placeholderName": "请输入岗位名称",
"placeholderCode": "请输入岗位编码",
"placeholderRemark": "请输入岗位描述",
"placeholderSort": "请输入排序",
"ruleNameRequired": "请输入岗位名称",
"ruleCodeRequired": "请输入岗位编码",
"addSuccess": "新增成功",
"editSuccess": "修改成功"
}
}

View File

@@ -10,7 +10,7 @@
"table": {
"username": "用户名",
"phone": "手机号",
"dept": "部门",
"dept": "渠道",
"dashboard": "首页",
"loginTime": "上次登录",
"agentId": "代理ID",
@@ -28,9 +28,8 @@
"labelPasswordConfirm": "确认密码",
"labelEmail": "邮箱",
"labelPhone": "手机号",
"labelDept": "部门",
"labelDept": "渠道",
"labelRole": "角色",
"labelPost": "岗位",
"labelGender": "性别",
"labelStatus": "状态",
"labelRemark": "备注",
@@ -43,12 +42,15 @@
"rulePasswordRequired": "请输入密码",
"rulePasswordLength": "长度在 6 到 20 个字符",
"rulePasswordConfirmRequired": "请输入确认密码",
"ruleDeptRequired": "请选择部门",
"ruleDeptRequired": "请选择渠道",
"ruleRoleRequired": "请选择角色",
"addSuccess": "新增成功",
"editSuccess": "修改成功"
},
"ui": {
"channelList": "渠道列表",
"viewingChannel": "当前配置渠道",
"defaultConfigTemplate": "默认配置模板",
"promptNewPassword": "请输入新密码",
"passwordLengthError": "密码长度在6到16之间",
"passwordChanged": "修改密码成功",

View File

@@ -46,12 +46,21 @@ export async function loadPageLocale(routePath: string): Promise<void> {
const modules = locale === LanguageEnum.EN ? enModules : zhModules
const tryPaths: string[] = [path]
// 兼容别名路由:例如 /user 实际页面为 /system/user
// 兼容别名路由:菜单 path 为短名但 locale 文件位于模块子目录
// 例如:/user -> system/user/game -> dice/game
if (!path.includes('/')) {
tryPaths.push(`system/${path}`)
}
if (path === 'user') {
tryPaths.push('system/user')
// 兜底:在任意一级子目录下查找同名文件
const suffix = `/${path}.json`
const localePrefix = `./langs/${locale}/`
for (const key of Object.keys(modules)) {
if (key.startsWith(localePrefix) && key.endsWith(suffix)) {
const candidate = key.slice(localePrefix.length, -'.json'.length)
if (!tryPaths.includes(candidate)) {
tryPaths.push(candidate)
}
}
}
}
let matchedPath: string | null = null

View File

@@ -0,0 +1,79 @@
import type { RouteLocationNormalized } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
/** 页面自带左侧渠道栏,不再包一层全局渠道壳 */
const BUILTIN_CHANNEL_LAYOUT_PATHS = [
'/system/user',
'/system/dept'
]
/** 运维页里不需要按渠道分栏的页面 */
const NO_CHANNEL_LAYOUT_PATHS = [
'/safeguard/dict',
'/safeguard/attachment',
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log',
'/admin_guide'
]
/** 日志页左侧首项为「全部」dept_id=0 表示不按渠道过滤 */
const ALL_CHANNEL_SCOPE_PATHS = [
'/safeguard/login-log',
'/safeguard/oper-log'
]
export function isSuperAdminUser(): boolean {
const userStore = useUserStore()
return Number(userStore.info?.id ?? 0) === 1
}
/** 游戏配置类页面:显示「默认配置模板」 */
export function isConfigChannelRoute(route: Pick<RouteLocationNormalized, 'path' | 'meta'>): boolean {
if (route.meta?.channelScope === 'config') {
return true
}
return /\/(config|ante_config|lottery_pool_config|reward_config|game)(\/|$)/.test(route.path)
}
/** 角色管理左侧渠道树含默认模板dept_id=0与各渠道角色 */
export function isRoleChannelRoute(route: Pick<RouteLocationNormalized, 'path' | 'meta'>): boolean {
if (route.meta?.channelScope === 'role') {
return true
}
return route.path.startsWith('/system/role')
}
export function isAllChannelScopeRoute(route: Pick<RouteLocationNormalized, 'path' | 'meta'>): boolean {
if (route.meta?.channelScope === 'all') {
return true
}
return ALL_CHANNEL_SCOPE_PATHS.some((item) => route.path.startsWith(item))
}
export function isNoChannelLayoutRoute(route: Pick<RouteLocationNormalized, 'path' | 'meta'>): boolean {
if (route.meta?.noChannelLayout === true) {
return true
}
return NO_CHANNEL_LAYOUT_PATHS.some((item) => route.path.startsWith(item))
}
export function shouldWrapSuperAdminChannelLayout(route: RouteLocationNormalized): boolean {
if (!isSuperAdminUser()) {
return false
}
if (route.meta?.isFullPage) {
return false
}
const path = route.path
if (isNoChannelLayoutRoute(route)) {
return false
}
for (let i = 0; i < BUILTIN_CHANNEL_LAYOUT_PATHS.length; i++) {
if (path.startsWith(BUILTIN_CHANNEL_LAYOUT_PATHS[i])) {
return false
}
}
return true
}

View File

@@ -16,10 +16,11 @@
*/
import { AppRouteRecord } from '@/types/router'
import { router } from '@/router'
import { resolveAppAssetUrl } from '@/utils/navigation/resolveAppAssetUrl'
// 打开外部链接
// 打开外部链接(含站内静态页,自动拼接部署 base
export const openExternalLink = (link: string) => {
window.open(link, '_blank')
window.open(resolveAppAssetUrl(link), '_blank', 'noopener,noreferrer')
}
/**

View File

@@ -0,0 +1,19 @@
/**
* 解析菜单外链 / 静态资源路径(兼容 VITE_BASE_URL 子目录部署)
*/
export function resolveAppAssetUrl(relativeOrAbsolute: string): string {
const link = relativeOrAbsolute.trim()
if (!link) {
return link
}
if (/^https?:\/\//i.test(link)) {
return link
}
const base = import.meta.env.BASE_URL || '/'
const normalizedBase = base.endsWith('/') ? base : `${base}/`
const path = link.startsWith('/') ? link.slice(1) : link
if (typeof window === 'undefined') {
return `${normalizedBase}${path}`
}
return `${window.location.origin}${normalizedBase}${path}`
}

View File

@@ -25,7 +25,6 @@ export const MAP_PATH_TO_MENU_I18N_KEY: Record<string, string> = {
'/system/user-center': 'menus.system.userCenter',
'/system/menu': 'menus.system.menu',
'/system/dept': 'menus.system.dept',
'/system/post': 'menus.system.post',
'/system/config': 'menus.system.config',
'/safeguard': 'menus.safeguard.title',
'/safeguard/dict': 'menus.safeguard.dict',
@@ -62,6 +61,10 @@ export const MAP_PATH_TO_MENU_I18N_KEY: Record<string, string> = {
'/dice/play_record_test/index': 'menus.dice.playRecordTest',
'/dice/config': 'menus.dice.config',
'/dice/config/index': 'menus.dice.config',
'game': 'menus.game.title',
'game/index': 'menus.game.title',
'/game': 'menus.game.title',
'/game/index': 'menus.game.title',
'/result/success': 'menus.result.success',
'/result/fail': 'menus.result.fail',
'/exception/403': 'menus.exception.forbidden',

View File

@@ -1,9 +1,25 @@
<!-- 工作台页面 -->
<!-- 工作台页面大富翁色子游戏数据统计 -->
<template>
<div>
<template v-if="userInfo.dashboard === 'statistics'">
<CardList></CardList>
<div class="dashboard-filter-bar art-card flex flex-wrap items-center gap-3 px-5 py-3 mb-5 max-sm:mb-4">
<span class="text-g-700 text-sm shrink-0">{{ $t('console.filter.date') }}</span>
<ElDatePicker
v-model="selectedDate"
type="date"
value-format="YYYY-MM-DD"
clearable
:placeholder="$t('console.filter.placeholder')"
class="dashboard-date-picker"
/>
<ElButton type="primary" link @click="resetToToday">{{ $t('console.filter.today') }}</ElButton>
<ElButton v-if="selectedDate" type="primary" link @click="clearDateFilter">
{{ $t('console.filter.clear') }}
</ElButton>
</div>
<CardList />
<template v-if="isStatisticsDashboard">
<ElRow :gutter="20">
<ElCol :sm="24" :md="12" :lg="10">
<ActiveUser />
@@ -12,30 +28,22 @@
<SalesOverview />
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :sm="24" :md="12" :lg="12">
<WalletRecordList />
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<NewPlayerList />
</ElCol>
</ElRow>
</template>
<template v-if="userInfo.dashboard === 'work'">
<ElRow :gutter="20">
<ElCol :sm="24" :md="24" :lg="12">
<NewUser />
</ElCol>
<ElCol :sm="24" :md="12" :lg="6">
<Dynamic />
</ElCol>
<ElCol :sm="24" :md="12" :lg="6">
<TodoList />
</ElCol>
</ElRow>
</template>
<ElRow :gutter="20">
<ElCol :sm="24" :md="12" :lg="12">
<NewPlayerList />
</ElCol>
<ElCol :sm="24" :md="12" :lg="12">
<WalletRecordList />
</ElCol>
</ElRow>
<ElRow :gutter="20">
<ElCol :sm="24" :md="24" :lg="24">
<PlayRecordList />
</ElCol>
</ElRow>
</div>
</template>
@@ -45,17 +53,40 @@
import SalesOverview from './modules/sales-overview.vue'
import WalletRecordList from './modules/wallet-record-list.vue'
import NewPlayerList from './modules/new-player-list.vue'
import NewUser from './modules/new-user.vue'
import Dynamic from './modules/dynamic-stats.vue'
import TodoList from './modules/todo-list.vue'
import PlayRecordList from './modules/play-record-list.vue'
import { useCommon } from '@/hooks/core/useCommon'
import { useUserStore } from '@/store/modules/user'
import { getTodayDateString, provideDashboardScope } from '@/composables/useDashboardScope'
defineOptions({ name: 'Console' })
const userStore = useUserStore()
const userInfo = userStore.getUserInfo
const { selectedDate } = provideDashboardScope()
const resetToToday = () => {
selectedDate.value = getTodayDateString()
}
const clearDateFilter = () => {
selectedDate.value = null
}
/** 统计页额外展示充值图表 */
const isStatisticsDashboard = computed(
() => userStore.getUserInfo.dashboard === 'statistics'
)
const { scrollToTop } = useCommon()
scrollToTop()
</script>
<style lang="scss" scoped>
.dashboard-filter-bar {
min-height: 52px;
}
.dashboard-date-picker {
width: 180px;
max-width: 100%;
}
</style>

View File

@@ -18,6 +18,7 @@
<script setup lang="ts">
import { fetchRechargeBarChart } from '@/api/dashboard'
import { getChannelDeptRequestParams, useChannelDeptReload } from '@/composables/useChannelDeptScope'
/**
* 充值金额数据
@@ -29,10 +30,12 @@
*/
const xData = ref<string[]>([])
onMounted(async () => {
fetchRechargeBarChart().then((data: any) => {
const loadChart = () => {
fetchRechargeBarChart(getChannelDeptRequestParams()).then((data: any) => {
yData.value = data?.recharge_amount ?? []
xData.value = data?.recharge_month ?? []
})
})
}
useChannelDeptReload(loadChart)
</script>

View File

@@ -1,11 +1,14 @@
<template>
<ElRow :gutter="20" class="flex">
<ElCol :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<div
class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4 dashboard-stat-card dashboard-stat-card--clickable"
@click="goPlayerList"
>
<span class="text-g-700 text-sm">{{ $t('console.card.playerRegister') }}</span>
<ArtCountTo class="text-[26px] font-medium mt-2" :target="statData.player_count" :duration="1300" />
<div class="flex-c mt-1">
<span class="text-xs text-g-600">{{ $t('console.card.vsLastWeek') }}</span>
<span class="text-xs text-g-600">{{ compareLabel }}</span>
<span
class="ml-1 text-xs font-semibold"
:class="changeClass(statData.player_count_change)"
@@ -13,6 +16,9 @@
{{ formatChange(statData.player_count_change) }}
</span>
</div>
<ElButton type="primary" link class="dashboard-stat-link" @click.stop="goPlayerList">
{{ $t('console.card.viewRegisterRecords') }}
</ElButton>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
@@ -21,7 +27,10 @@
</div>
</ElCol>
<ElCol :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<div
class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4 dashboard-stat-card dashboard-stat-card--clickable"
@click="goWalletRecord(0)"
>
<span class="text-g-700 text-sm">{{ $t('console.card.playerCharge') }}</span>
<ArtCountTo
class="text-[26px] font-medium mt-2"
@@ -30,7 +39,7 @@
:decimals="2"
/>
<div class="flex-c mt-1">
<span class="text-xs text-g-600">{{ $t('console.card.vsLastWeek') }}</span>
<span class="text-xs text-g-600">{{ compareLabel }}</span>
<span
class="ml-1 text-xs font-semibold"
:class="changeClass(statData.charge_amount_change)"
@@ -38,6 +47,9 @@
{{ formatChange(statData.charge_amount_change) }}
</span>
</div>
<ElButton type="primary" link class="dashboard-stat-link" @click.stop="goWalletRecord(0)">
{{ $t('console.card.viewRechargeRecords') }}
</ElButton>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
@@ -46,7 +58,10 @@
</div>
</ElCol>
<ElCol :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<div
class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4 dashboard-stat-card dashboard-stat-card--clickable"
@click="goWalletRecord(1)"
>
<span class="text-g-700 text-sm">{{ $t('console.card.playerWithdraw') }}</span>
<ArtCountTo
class="text-[26px] font-medium mt-2"
@@ -55,7 +70,7 @@
:decimals="2"
/>
<div class="flex-c mt-1">
<span class="text-xs text-g-600">{{ $t('console.card.vsLastWeek') }}</span>
<span class="text-xs text-g-600">{{ compareLabel }}</span>
<span
class="ml-1 text-xs font-semibold"
:class="changeClass(statData.withdraw_amount_change)"
@@ -63,6 +78,9 @@
{{ formatChange(statData.withdraw_amount_change) }}
</span>
</div>
<ElButton type="primary" link class="dashboard-stat-link" @click.stop="goWalletRecord(1)">
{{ $t('console.card.viewWithdrawRecords') }}
</ElButton>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
@@ -71,7 +89,10 @@
</div>
</ElCol>
<ElCol :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<div
class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4 dashboard-stat-card dashboard-stat-card--clickable"
@click="goPlayRecord"
>
<span class="text-g-700 text-sm">{{ $t('console.card.playerPlayCount') }}</span>
<ArtCountTo
class="text-[26px] font-medium mt-2"
@@ -79,7 +100,7 @@
:duration="1300"
/>
<div class="flex-c mt-1">
<span class="text-xs text-g-600">{{ $t('console.card.vsLastWeek') }}</span>
<span class="text-xs text-g-600">{{ compareLabel }}</span>
<span
class="ml-1 text-xs font-semibold"
:class="changeClass(statData.play_count_change)"
@@ -87,6 +108,9 @@
{{ formatChange(statData.play_count_change) }}
</span>
</div>
<ElButton type="primary" link class="dashboard-stat-link" @click.stop="goPlayRecord">
{{ $t('console.card.viewPlayRecords') }}
</ElButton>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
@@ -99,6 +123,21 @@
<script setup lang="ts">
import { fetchStatistics } from '@/api/dashboard'
import { useDashboardReload, useDashboardScope } from '@/composables/useDashboardScope'
import { useI18n } from 'vue-i18n'
import {
dashboardDateNavParams,
openPlayRecord,
openPlayerList,
openWalletRecord
} from '@/views/plugin/dice/utils/dashboardRecordNav'
const { t } = useI18n()
const { queryParams, hasDateFilter, selectedDate } = useDashboardScope()
const compareLabel = computed(() =>
hasDateFilter.value ? t('console.card.vsYesterday') : t('console.card.vsLastWeek')
)
const statData = ref({
player_count: 0,
@@ -123,8 +162,8 @@
return 'text-g-600'
}
onMounted(() => {
fetchStatistics().then((data: any) => {
const loadStatistics = () => {
fetchStatistics(queryParams.value).then((data: any) => {
statData.value = {
player_count: data?.player_count ?? 0,
player_count_change: data?.player_count_change ?? 0,
@@ -136,5 +175,41 @@
play_count_change: data?.play_count_change ?? 0
}
})
})
}
useDashboardReload(loadStatistics)
const goWalletRecord = (type: number) => {
openWalletRecord({
type,
...dashboardDateNavParams(selectedDate.value)
})
}
const goPlayRecord = () => {
openPlayRecord(dashboardDateNavParams(selectedDate.value))
}
const goPlayerList = () => {
openPlayerList(dashboardDateNavParams(selectedDate.value))
}
</script>
<style scoped>
.dashboard-stat-card--clickable {
cursor: pointer;
transition: box-shadow 0.2s ease;
}
.dashboard-stat-card--clickable:hover {
box-shadow: 0 4px 12px rgb(0 0 0 / 8%);
}
.dashboard-stat-link {
align-self: flex-start;
margin-top: 4px;
padding: 0;
height: auto;
font-size: 12px;
}
</style>

View File

@@ -1,10 +1,13 @@
<template>
<div class="art-card p-5 overflow-hidden mb-5 max-sm:mb-4">
<div class="art-card-header mb-4">
<div class="art-card-header mb-4 flex items-start justify-between gap-3">
<div class="title">
<h4>{{ $t('console.newPlayer.title') }}</h4>
<p class="text-g-600 text-sm mt-1">{{ $t('console.newPlayer.subtitle') }}</p>
<p class="text-g-600 text-sm mt-1">{{ listSubtitle }}</p>
</div>
<ElButton type="primary" link @click="goPlayerList">
{{ $t('console.nav.viewAllRegister') }}
</ElButton>
</div>
<ArtTable
class="w-full"
@@ -17,11 +20,23 @@
>
<template #default>
<ElTableColumn :label="$t('console.newPlayer.player')" prop="name" min-width="120" align="center" />
<ElTableColumn :label="$t('console.newPlayer.balance')" prop="coin" min-width="120" align="center">
<ElTableColumn :label="$t('console.newPlayer.balance')" prop="coin" min-width="100" align="center">
<template #default="scope">
{{ formatCoin(scope.row.coin) }}
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('console.newPlayer.ticket')"
prop="total_ticket_count"
min-width="90"
align="center"
/>
<ElTableColumn
:label="$t('console.newPlayer.registerTime')"
prop="create_time"
min-width="170"
align="center"
/>
</template>
</ArtTable>
</div>
@@ -29,17 +44,37 @@
<script setup lang="ts">
import { fetchNewPlayerList, type NewPlayerItem } from '@/api/dashboard'
import { useDashboardReload, useDashboardScope } from '@/composables/useDashboardScope'
import { useI18n } from 'vue-i18n'
import {
dashboardDateNavParams,
openPlayerList
} from '@/views/plugin/dice/utils/dashboardRecordNav'
const { t } = useI18n()
const { queryParams, selectedDate, hasDateFilter } = useDashboardScope()
const tableData = ref<NewPlayerItem[]>([])
const listSubtitle = computed(() =>
hasDateFilter.value && selectedDate.value
? t('console.newPlayer.subtitleByDate', { date: selectedDate.value })
: t('console.newPlayer.subtitle')
)
function formatCoin(val: number | undefined): string {
if (val === undefined || val === null) return '0.00'
return Number(val).toFixed(2)
}
onMounted(() => {
fetchNewPlayerList().then((data) => {
const loadList = () => {
fetchNewPlayerList(queryParams.value).then((data) => {
tableData.value = Array.isArray(data) ? data : []
})
})
}
useDashboardReload(loadList)
const goPlayerList = () => {
openPlayerList(dashboardDateNavParams(selectedDate.value))
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<div class="art-card p-5 overflow-hidden mb-5 max-sm:mb-4">
<div class="art-card-header mb-4 flex items-start justify-between gap-3">
<div class="title">
<h4>{{ $t('console.playRecord.title') }}</h4>
<p class="text-g-600 text-sm mt-1">{{ listSubtitle }}</p>
</div>
<ElButton type="primary" link @click="goPlayRecords">
{{ $t('console.nav.viewAllPlay') }}
</ElButton>
</div>
<ArtTable
class="w-full"
:data="tableData"
style="width: 100%"
size="default"
:border="false"
:stripe="true"
:header-cell-style="{ background: 'transparent' }"
>
<template #default>
<ElTableColumn
:label="$t('console.playRecord.player')"
prop="player_name"
min-width="120"
align="center"
/>
<ElTableColumn
:label="$t('console.playRecord.reward')"
prop="reward_tier_label"
min-width="140"
align="center"
/>
<ElTableColumn
:label="$t('console.playRecord.winCoin')"
prop="win_coin"
min-width="120"
align="center"
>
<template #default="scope">
{{ formatCoin(scope.row.win_coin) }}
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('console.playRecord.playTime')"
prop="create_time"
min-width="170"
align="center"
/>
</template>
</ArtTable>
</div>
</template>
<script setup lang="ts">
import { fetchPlayRecordList, type PlayRecordItem } from '@/api/dashboard'
import { useDashboardReload, useDashboardScope } from '@/composables/useDashboardScope'
import { useI18n } from 'vue-i18n'
import {
dashboardDateNavParams,
openPlayRecord
} from '@/views/plugin/dice/utils/dashboardRecordNav'
const { t } = useI18n()
const { queryParams, selectedDate, hasDateFilter } = useDashboardScope()
const tableData = ref<PlayRecordItem[]>([])
const listSubtitle = computed(() =>
hasDateFilter.value && selectedDate.value
? t('console.playRecord.subtitleByDate', { date: selectedDate.value })
: t('console.playRecord.subtitle')
)
function formatCoin(val: number | undefined): string {
if (val === undefined || val === null) return '0.00'
return Number(val).toFixed(2)
}
const loadList = () => {
fetchPlayRecordList(queryParams.value).then((data) => {
tableData.value = Array.isArray(data) ? data : []
})
}
useDashboardReload(loadList)
const goPlayRecords = () => {
openPlayRecord(dashboardDateNavParams(selectedDate.value))
}
</script>

View File

@@ -17,6 +17,7 @@
<script setup lang="ts">
import { fetchRechargeChart } from '@/api/dashboard'
import { getChannelDeptRequestParams, useChannelDeptReload } from '@/composables/useChannelDeptScope'
/**
* 充值金额数据
@@ -28,10 +29,12 @@
*/
const xData = ref<string[]>([])
onMounted(async () => {
fetchRechargeChart().then((data: any) => {
const loadChart = () => {
fetchRechargeChart(getChannelDeptRequestParams()).then((data: any) => {
yData.value = data?.recharge_amount ?? []
xData.value = data?.recharge_date ?? []
})
})
}
useChannelDeptReload(loadChart)
</script>

View File

@@ -1,10 +1,13 @@
<template>
<div class="art-card p-5 overflow-hidden mb-5 max-sm:mb-4">
<div class="art-card-header mb-4">
<div class="art-card-header mb-4 flex items-start justify-between gap-3">
<div class="title">
<h4>{{ $t('console.walletRecord.title') }}</h4>
<p class="text-g-600 text-sm mt-1">{{ $t('console.walletRecord.subtitle') }}</p>
<p class="text-g-600 text-sm mt-1">{{ listSubtitle }}</p>
</div>
<ElButton type="primary" link @click="goWalletRecords">
{{ $t('console.nav.viewAllRecharge') }}
</ElButton>
</div>
<ArtTable
class="w-full"
@@ -30,17 +33,40 @@
<script setup lang="ts">
import { fetchWalletRecordList, type WalletRecordItem } from '@/api/dashboard'
import { useDashboardReload, useDashboardScope } from '@/composables/useDashboardScope'
import { useI18n } from 'vue-i18n'
import {
dashboardDateNavParams,
openWalletRecord
} from '@/views/plugin/dice/utils/dashboardRecordNav'
const { t } = useI18n()
const { queryParams, selectedDate, hasDateFilter } = useDashboardScope()
const tableData = ref<WalletRecordItem[]>([])
const listSubtitle = computed(() =>
hasDateFilter.value && selectedDate.value
? t('console.walletRecord.subtitleByDate', { date: selectedDate.value })
: t('console.walletRecord.subtitle')
)
function formatCoin(val: number | undefined): string {
if (val === undefined || val === null) return '0.00'
return Number(val).toFixed(2)
}
onMounted(() => {
fetchWalletRecordList().then((data) => {
const loadList = () => {
fetchWalletRecordList(queryParams.value).then((data) => {
tableData.value = Array.isArray(data) ? data : []
})
})
}
useDashboardReload(loadList)
const goWalletRecords = () => {
openWalletRecord({
type: 0,
...dashboardDateNavParams(selectedDate.value)
})
}
</script>

View File

@@ -109,6 +109,7 @@
} = useTable({
core: {
apiFn: api.list,
apiParams: { limit: 100 },
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: 'page.table.id', width: 80, align: 'center' },

View File

@@ -9,13 +9,19 @@
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item :label="$t('page.form.labelName')" prop="name">
<el-input v-model="formData.name" :placeholder="$t('page.form.placeholderName')" />
<el-input v-model="formData.name" disabled :placeholder="$t('page.form.placeholderNameAuto')" />
</el-form-item>
<el-form-item :label="$t('page.form.labelTitle')" prop="title">
<el-input v-model="formData.title" :placeholder="$t('page.form.placeholderTitle')" />
<el-input v-model="formData.title" disabled :placeholder="$t('page.form.placeholderTitleAuto')" />
</el-form-item>
<el-form-item :label="$t('page.form.labelMult')" prop="mult">
<el-input-number v-model="formData.mult" :min="1" :step="1" style="width: 100%" />
<el-input-number
v-model="formData.mult"
:min="1"
:step="1"
style="width: 100%"
@update:model-value="syncNameTitleFromMult"
/>
</el-form-item>
<el-form-item :label="$t('page.form.labelIsDefault')" prop="is_default">
<el-radio-group v-model="formData.is_default">
@@ -36,6 +42,7 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { withChannelDeptParams } from '@/composables/useChannelDeptScope'
const { t } = useI18n()
@@ -86,6 +93,13 @@
const formData = reactive({ ...initialFormData })
function syncNameTitleFromMult() {
const mult = Number(formData.mult) || 1
const label = `x${mult}`
formData.name = label
formData.title = label
}
watch(
() => props.modelValue,
async (newVal) => {
@@ -98,6 +112,7 @@
if (typeof props.data.title === 'string') formData.title = props.data.title
formData.mult = Number(props.data.mult ?? 1) || 1
formData.is_default = Number(props.data.is_default ?? 0) === 1 ? 1 : 0
syncNameTitleFromMult()
}
)
@@ -111,10 +126,10 @@
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
await api.save(withChannelDeptParams(formData))
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(formData)
await api.update(withChannelDeptParams(formData))
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')

View File

@@ -36,5 +36,16 @@ export default {
url: '/core/dice/ante_config/DiceAnteConfig/destroy',
data: params
})
},
/** 底注下拉(按渠道) */
async getOptions(params?: Record<string, unknown>) {
const res = await request.get<
Array<{ id: number; name: string; title: string; mult: number; is_default: number }>
>({
url: '/core/dice/ante_config/DiceAnteConfig/getOptions',
params
})
return Array.isArray(res) ? res : []
}
}

View File

@@ -1,4 +1,29 @@
import request from '@/utils/http'
import {
normalizeLotteryPoolOption,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
export type LotteryPoolConfigOption = LotteryPoolOption & {
t1_weight: number
t2_weight: number
t3_weight: number
t4_weight: number
t5_weight: number
}
/** 规范化接口返回的彩金池配置(含权重) */
export function parseLotteryPoolConfigOption(raw: Record<string, unknown>): LotteryPoolConfigOption {
const base = normalizeLotteryPoolOption(raw)
return {
...base,
t1_weight: Number(raw.t1_weight ?? 0),
t2_weight: Number(raw.t2_weight ?? 0),
t3_weight: Number(raw.t3_weight ?? 0),
t4_weight: Number(raw.t4_weight ?? 0),
t5_weight: Number(raw.t5_weight ?? 0)
}
}
/**
* 色子奖池配置 API 接口
@@ -20,31 +45,14 @@ export default {
* 获取 DiceLotteryPoolConfig 列表数据,含 id、name、t1_weightt5_weight用于一键测试权重档位类型下拉
* name 映射default=原 type=0killScore=原 type=1up=原 type=2
*/
async getOptions(): Promise<
Array<{
id: number
name: string
t1_weight: number
t2_weight: number
t3_weight: number
t4_weight: number
t5_weight: number
}>
> {
async getOptions(params?: Record<string, unknown>): Promise<LotteryPoolConfigOption[]> {
const res = await request.get<any>({
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions'
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions',
params
})
const rows = Array.isArray(res) ? res : (Array.isArray((res as any)?.data) ? (res as any).data : [])
if (!Array.isArray(rows)) return []
return rows.map((r: any) => ({
id: Number(r.id),
name: String(r.name ?? r.id ?? ''),
t1_weight: Number(r.t1_weight ?? 0),
t2_weight: Number(r.t2_weight ?? 0),
t3_weight: Number(r.t3_weight ?? 0),
t4_weight: Number(r.t4_weight ?? 0),
t5_weight: Number(r.t5_weight ?? 0)
}))
return rows.map((r: Record<string, unknown>) => parseLotteryPoolConfigOption(r))
},
/**
@@ -97,7 +105,7 @@ export default {
/**
* 获取当前彩金池Redis 实例化,无则按 type=0 创建),含玩家累计盈利 profit_amount 实时值
*/
getCurrentPool() {
getCurrentPool(params?: { dept_id?: number }) {
return request.get<{
id: number
name: string
@@ -110,14 +118,15 @@ export default {
t5_weight: number
profit_amount: number
}>({
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getCurrentPool'
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getCurrentPool',
params
})
},
/**
* 更新当前彩金池:仅 safety_line、t1_weightt5_weight不可改 profit_amount
*/
updateCurrentPool(params: { safety_line?: number; kill_enabled?: number }) {
updateCurrentPool(params: { safety_line?: number; kill_enabled?: number; dept_id?: number }) {
return request.post<any>({
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/updateCurrentPool',
data: params
@@ -127,9 +136,10 @@ export default {
/**
* 重置当前彩金池的玩家累计盈利profit_amount 置为 0
*/
resetProfitAmount() {
resetProfitAmount(params?: { dept_id?: number }) {
return request.post<any>({
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/resetProfitAmount'
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/resetProfitAmount',
data: params || {}
})
}
}

View File

@@ -1,4 +1,5 @@
import request from '@/utils/http'
import { normalizeLotteryPoolOption, type LotteryPoolOption } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
/**
* 玩家抽奖记录 API接口
@@ -39,18 +40,6 @@ export default {
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/dice/play_record/DicePlayRecord/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
@@ -64,16 +53,20 @@ export default {
},
/** 获取玩家选项id、username */
getPlayerOptions() {
getPlayerOptions(params?: Record<string, unknown>) {
return request.get<{ id: number; username: string }[]>({
url: '/core/dice/play_record/DicePlayRecord/getPlayerOptions'
url: '/core/dice/play_record/DicePlayRecord/getPlayerOptions',
params
})
},
/** 获取彩金池配置选项(id、name */
getLotteryConfigOptions() {
return request.get<{ id: number; name: string }[]>({
url: '/core/dice/play_record/DicePlayRecord/getLotteryConfigOptions'
/** 获取彩金池配置选项(含奖池名称 */
async getLotteryConfigOptions(params?: Record<string, unknown>): Promise<LotteryPoolOption[]> {
const res = await request.get<any>({
url: '/core/dice/play_record/DicePlayRecord/getLotteryConfigOptions',
params
})
const rows = (Array.isArray(res) ? res : (res?.data ?? [])) as Array<Record<string, unknown>>
return rows.map((r) => normalizeLotteryPoolOption(r))
}
}

View File

@@ -39,18 +39,6 @@ export default {
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/dice/play_record_test/DicePlayRecordTest/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID

View File

@@ -1,4 +1,5 @@
import request from '@/utils/http'
import { normalizeLotteryPoolOption, type LotteryPoolOption } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
/**
* 大富翁-玩家 API接口
@@ -84,26 +85,27 @@ export default {
},
/**
* 获取彩金池配置选项DiceLotteryPoolConfig.id、name,供 lottery_config_id 下拉使用
* @returns [ { id, name } ]
* 获取彩金池配置选项,供 lottery_config_id 下拉使用(含奖池名称 display_name
*/
async getLotteryConfigOptions(): Promise<Array<{ id: number; name: string }>> {
async getLotteryConfigOptions(params?: Record<string, unknown>): Promise<LotteryPoolOption[]> {
const res = await request.get<any>({
url: '/core/dice/player/DicePlayer/getLotteryConfigOptions'
url: '/core/dice/player/DicePlayer/getLotteryConfigOptions',
params
})
const rows = (Array.isArray(res) ? res : (res?.data ?? [])) as Array<{ id: number; name: string }>
return rows.map((r) => ({ id: Number(r.id), name: String(r.name ?? r.id ?? '') }))
const rows = (Array.isArray(res) ? res : (res?.data ?? [])) as Array<Record<string, unknown>>
return rows.map((r) => normalizeLotteryPoolOption(r))
},
/**
* 获取后台管理员选项SystemUser供 admin_id 下拉使用
* @returns [ { id, username, realname, label } ]
*/
async getSystemUserOptions(): Promise<
async getSystemUserOptions(params?: Record<string, unknown>): Promise<
Array<{ id: number; username: string; realname: string; label: string }>
> {
const res = await request.get<any>({
url: '/core/dice/player/DicePlayer/getSystemUserOptions'
url: '/core/dice/player/DicePlayer/getSystemUserOptions',
params
})
const rows = (Array.isArray(res) ? res : (res?.data ?? [])) as Array<{
id: number
@@ -117,5 +119,29 @@ export default {
realname: String(r.realname ?? ''),
label: String(r.label ?? r.username ?? r.id ?? '')
}))
},
/**
* 超管:按渠道树状展示全部管理员;非超管:扁平列表
*/
async getSystemUserTreeOptions(params?: Record<string, unknown>): Promise<
Array<{
id: number | string
label: string
disabled?: boolean
children?: Array<{ id: number; username: string; realname: string; label: string }>
}>
> {
const res = await request.get<any>({
url: '/core/dice/player/DicePlayer/getSystemUserTreeOptions',
params
})
const rows = (Array.isArray(res) ? res : (res?.data ?? [])) as Array<{
id: number | string
label: string
disabled?: boolean
children?: Array<{ id: number; username: string; realname: string; label: string }>
}>
return rows
}
}

View File

@@ -39,18 +39,6 @@ export default {
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/dice/player_ticket_record/DicePlayerTicketRecord/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
@@ -66,9 +54,10 @@ export default {
/**
* 获取玩家选项id、username用于下拉
*/
getPlayerOptions() {
getPlayerOptions(params?: Record<string, unknown>) {
return request.get<Api.Common.ApiData>({
url: '/core/dice/player_ticket_record/DicePlayerTicketRecord/getPlayerOptions'
url: '/core/dice/player_ticket_record/DicePlayerTicketRecord/getPlayerOptions',
params
})
}
}

View File

@@ -10,7 +10,11 @@ export default {
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
return request.get<Api.Common.ApiPage & {
total_coin_change?: number
total_coin_inflow?: number
total_coin_outflow?: number
}>({
url: '/core/dice/player_wallet_record/DicePlayerWalletRecord/index',
params
})
@@ -39,18 +43,6 @@ export default {
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/dice/player_wallet_record/DicePlayerWalletRecord/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
@@ -66,9 +58,10 @@ export default {
/**
* 获取玩家选项id、username用于下拉
*/
getPlayerOptions() {
getPlayerOptions(params?: Record<string, unknown>) {
return request.get<{ id: number; username: string }[]>({
url: '/core/dice/player_wallet_record/DicePlayerWalletRecord/getPlayerOptions'
url: '/core/dice/player_wallet_record/DicePlayerWalletRecord/getPlayerOptions',
params
})
},

View File

@@ -19,19 +19,20 @@ export default {
* 权重编辑弹窗:按档位分组获取当前方向的配置+权重(单方向)
* @param direction 0=顺时针 1=逆时针
*/
weightRatioList(direction: 0 | 1) {
weightRatioList(direction: 0 | 1, params?: Record<string, unknown>) {
return request.get<Api.Common.ApiData>({
url: '/core/dice/reward/DiceReward/weightRatioList',
params: { direction }
params: { direction, ...(params || {}) }
})
},
/**
* 权重编辑弹窗:按档位分组获取配置+顺时针/逆时针权重dice_reward 双方向)
*/
weightRatioListWithDirection() {
weightRatioListWithDirection(params?: Record<string, unknown>) {
return request.get<Api.Common.ApiData>({
url: '/core/dice/reward/DiceReward/weightRatioListWithDirection'
url: '/core/dice/reward/DiceReward/weightRatioListWithDirection',
params
})
},
@@ -39,10 +40,13 @@ export default {
* 权重编辑弹窗:按 DiceReward 主键 id 批量更新 weight
* @param items [{ id: DiceReward.id, weight: 1-10000 }, ...]
*/
batchUpdateWeights(items: Array<{ id: number; weight: number }>) {
batchUpdateWeights(
items: Array<{ id: number; weight: number }>,
extra?: Record<string, unknown>
) {
return request.post<any>({
url: '/core/dice/reward/DiceReward/batchUpdateWeights',
data: { items }
data: { items, ...(extra || {}) }
})
},
@@ -62,6 +66,7 @@ export default {
*/
startWeightTest(params: {
ante?: number
ante_config_id?: number
lottery_config_id?: number
paid_lottery_config_id?: number
free_lottery_config_id?: number

View File

@@ -66,19 +66,44 @@ export default {
/**
* 批量更新奖励索引配置第一页id、grid_number、ui_text、real_ev、tier、remark
*/
batchUpdate(items: Array<{ id: number; grid_number?: number; ui_text?: string; real_ev?: number; tier?: string; remark?: string }>) {
batchUpdate(
items: Array<{ id: number; grid_number?: number; ui_text?: string; real_ev?: number; tier?: string; remark?: string }>,
extra?: Record<string, any>
) {
return request.post<any>({
url: '/core/dice/reward_config/DiceRewardConfig/batchUpdate',
data: { items }
data: { items, ...(extra || {}) }
})
},
/**
* 按规则生成并保存奖励索引(需 dice:reward_config:index:tierRecommend 权限)
*/
generateIndexByRules(
items: Array<{
id: number
grid_number?: number
ui_text?: string
ui_text_en?: string
real_ev?: number
tier?: string
remark?: string
}>,
extra?: Record<string, any>
) {
return request.post<any>({
url: '/core/dice/reward_config/DiceRewardConfig/generateIndexByRules',
data: { items, ...(extra || {}) }
})
},
/**
* T1-T5、BIGWIN 权重配比:按档位分组获取配置列表
*/
weightRatioList() {
weightRatioList(params?: Record<string, unknown>) {
return request.get<Api.Common.ApiData>({
url: '/core/dice/reward_config/DiceRewardConfig/weightRatioList'
url: '/core/dice/reward_config/DiceRewardConfig/weightRatioList',
params
})
},
@@ -86,34 +111,51 @@ export default {
* T1-T5、BIGWIN 权重配比:批量更新顺时针/逆时针权重(写入 dice_reward
*/
/** 按 DiceReward 主键 id 批量更新 weightitems: [{ id, weight }, ...] */
batchUpdateWeights(items: Array<{ id: number; weight: number }>) {
batchUpdateWeights(
items: Array<{ id: number; weight: number }>,
extra?: Record<string, unknown>
) {
return request.post<any>({
url: '/core/dice/reward_config/DiceRewardConfig/batchUpdateWeights',
data: { items }
data: { items, ...(extra || {}) }
})
},
/**
* 大奖权重:按 grid_number 批量保存 BIGWIN 权重(无需 reward id不存在则自动创建
*/
saveBigwinWeightsByGrid(items: Array<{ grid_number: number; weight: number }>) {
saveBigwinWeightsByGrid(
items: Array<{ grid_number: number; weight: number }>,
extra?: Record<string, unknown>
) {
return request.post<any>({
url: '/core/dice/reward_config/DiceRewardConfig/saveBigwinWeightsByGrid',
data: { items }
data: { items, ...(extra || {}) }
})
},
/**
* 创建奖励对照:按当前奖励配置为顺时针(0)、逆时针(1)生成所有色子可能对应的 dice_reward 记录,权重默认 1可在奖励对照页权重编辑中调整
*/
createRewardReference() {
createRewardReference(params?: Record<string, any>) {
return request.post<{
created_clockwise: number
created_counterclockwise: number
updated_clockwise: number
updated_counterclockwise: number
}>({
url: '/core/dice/reward_config/DiceRewardConfig/createRewardReference'
url: '/core/dice/reward_config/DiceRewardConfig/createRewardReference',
data: params || {}
})
},
/**
* 创建奖励对照(预览):不写库,返回将要生成的对照与权重预览
*/
createRewardReferencePreview(params?: Record<string, any>) {
return request.post<any>({
url: '/core/dice/reward_config/DiceRewardConfig/createRewardReferencePreview',
data: params || {}
})
}
}

View File

@@ -39,18 +39,6 @@ export default {
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/dice/reward_config_record/DiceRewardConfigRecord/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID

View File

@@ -0,0 +1,58 @@
<template>
<div class="quick-date-range-bar">
<span class="quick-date-label">{{ $t('table.searchBar.quickDate') }}</span>
<ElButton
v-for="item in presetItems"
:key="item.key"
size="small"
:type="modelValue === item.key ? 'primary' : 'default'"
@click="selectPreset(item.key)"
>
{{ item.label }}
</ElButton>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import {
type DatePresetKey,
getCreateTimeRangeByPreset
} from '@/views/plugin/dice/utils/dateRangePresets'
const modelValue = defineModel<DatePresetKey | null>({ default: null })
const emit = defineEmits<{
select: [range: [string, string], preset: DatePresetKey]
}>()
const { t } = useI18n()
const presetItems = computed(() => [
{ key: 'today' as const, label: t('table.searchBar.quickToday') },
{ key: 'yesterday' as const, label: t('table.searchBar.quickYesterday') },
{ key: 'last7days' as const, label: t('table.searchBar.quickLast7Days') }
])
const selectPreset = (preset: DatePresetKey) => {
modelValue.value = preset
emit('select', getCreateTimeRangeByPreset(preset), preset)
}
</script>
<style lang="scss" scoped>
.quick-date-range-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.quick-date-label {
font-size: 14px;
color: var(--el-text-color-regular);
margin-right: 4px;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,112 @@
import type { Ref } from 'vue'
import { useRoute } from 'vue-router'
import type { LocationQuery } from 'vue-router'
import {
detectPresetFromRange,
type DatePresetKey,
hasRecordRouteFilter,
parseRecordRouteQuery
} from '@/views/plugin/dice/utils/dateRangePresets'
export interface RecordRouteInit {
hasFilter: boolean
create_time?: [string, string]
type?: number
activePreset: DatePresetKey | null
}
export function getRecordRouteInit(query: LocationQuery, withType = false): RecordRouteInit {
const filter = parseRecordRouteQuery(query)
const create_time = filter.create_time
const activePreset = filter.datePreset ?? detectPresetFromRange(create_time ?? null)
return {
hasFilter: hasRecordRouteFilter(query),
create_time,
type: withType ? filter.type : undefined,
activePreset
}
}
/** 将路由 query 同步到搜索表单 */
export function syncSearchFormFromRoute(
query: LocationQuery,
searchForm: Ref<Record<string, unknown>>,
activeDatePreset: Ref<DatePresetKey | null>,
options?: { withType?: boolean }
): boolean {
const init = getRecordRouteInit(query, options?.withType)
if (init.create_time) {
searchForm.value.create_time = init.create_time
activeDatePreset.value = init.activePreset
} else {
searchForm.value.create_time = undefined
activeDatePreset.value = null
}
if (options?.withType) {
searchForm.value.type = init.type !== undefined ? init.type : undefined
}
return init.hasFilter
}
export function buildRecordRouteQueryKey(query: LocationQuery, withType = false): string {
const parts = [String(query.date ?? ''), String(query.datePreset ?? '')]
if (withType) {
parts.push(String(query.type ?? ''))
}
return parts.join('|')
}
/**
* 监听路由 query 变化并触发查询(解决 KeepAlive 下跳转带参不刷新的问题)
*/
export function useRecordRouteSync(options: {
searchForm: Ref<Record<string, unknown>>
activeDatePreset: Ref<DatePresetKey | null>
onSearch: (params: Record<string, unknown>) => void
withType?: boolean
skipImmediateTableLoad?: boolean
}) {
const route = useRoute()
const routeInit = getRecordRouteInit(route.query, options.withType)
let lastQueryKey = buildRecordRouteQueryKey(route.query, options.withType)
const applyRoute = () => {
const hasFilter = syncSearchFormFromRoute(
route.query,
options.searchForm,
options.activeDatePreset,
{ withType: options.withType }
)
if (hasFilter) {
options.onSearch({ ...options.searchForm.value })
}
}
onMounted(() => {
if (routeInit.hasFilter) {
applyRoute()
}
})
watch(
() => buildRecordRouteQueryKey(route.query, options.withType),
(newKey) => {
if (newKey === lastQueryKey) return
lastQueryKey = newKey
applyRoute()
}
)
onActivated(() => {
const key = buildRecordRouteQueryKey(route.query, options.withType)
if (key !== lastQueryKey) {
lastQueryKey = key
applyRoute()
}
})
return {
routeInit,
skipImmediateTableLoad: options.skipImmediateTableLoad ?? routeInit.hasFilter
}
}

View File

@@ -107,6 +107,7 @@
} = useTable({
core: {
apiFn: api.list,
apiParams: { limit: 100 },
columnsFactory: () => [
// { type: 'selection' },
{ prop: 'group', label: 'page.table.group', minWidth: 140, align: 'center' },

View File

@@ -47,6 +47,7 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { withChannelDeptParams } from '@/composables/useChannelDeptScope'
const { t } = useI18n()
@@ -162,10 +163,10 @@
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
await api.save(withChannelDeptParams(formData))
ElMessage.success(t('page.form.saveSuccess'))
} else {
await api.update(formData)
await api.update(withChannelDeptParams(formData))
ElMessage.success(t('page.form.updateSuccess'))
}
emit('success')

View File

@@ -10,7 +10,7 @@
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
{{ $t('table.actions.add') }}
</ElButton>
<ElButton
v-permission="'dice:game:index:destroy'"
@@ -21,7 +21,7 @@
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
{{ $t('table.actions.delete') }}
</ElButton>
</ElSpace>
</template>
@@ -40,7 +40,9 @@
@pagination:current-change="handleCurrentChange"
>
<template #status="{ row }">
<ElTag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '启用' : '禁用' }}</ElTag>
<ElTag :type="row.status === 1 ? 'success' : 'info'">{{
row.status === 1 ? $t('page.table.statusEnabled') : $t('page.table.statusDisabled')
}}</ElTag>
</template>
<template #operation="{ row }">
<div class="flex gap-2">
@@ -103,22 +105,23 @@
} = useTable({
core: {
apiFn: api.list,
apiParams: { limit: 100 },
columnsFactory: () => [
{ type: 'selection', align: 'center' },
{ prop: 'id', label: 'ID', width: 80, align: 'center' },
{ prop: 'provider', label: '供应商', minWidth: 120, align: 'center' },
{ prop: 'provider_code', label: '供应商编码', minWidth: 120, align: 'center' },
{ prop: 'game_code', label: '游戏编号', minWidth: 120, align: 'center' },
{ prop: 'game_key', label: '游戏唯一值', minWidth: 120, align: 'center' },
{ prop: 'game_name', label: '中文名', minWidth: 120, align: 'center' },
{ prop: 'game_name_en', label: '英文名', minWidth: 120, align: 'center' },
{ prop: 'game_type', label: '类型', minWidth: 90, align: 'center' },
{ prop: 'sort', label: '排序', width: 80, align: 'center' },
{ prop: 'status', label: '状态', width: 90, align: 'center', useSlot: true },
{ prop: 'update_time', label: '更新时间', minWidth: 160, align: 'center' },
{ prop: 'id', label: 'page.table.id', width: 80, align: 'center' },
{ prop: 'provider', label: 'page.table.provider', minWidth: 120, align: 'center' },
{ prop: 'provider_code', label: 'page.table.providerCode', minWidth: 120, align: 'center' },
{ prop: 'game_code', label: 'page.table.gameCode', minWidth: 120, align: 'center' },
{ prop: 'game_key', label: 'page.table.gameKey', minWidth: 120, align: 'center' },
{ prop: 'game_name', label: 'page.table.gameName', minWidth: 120, align: 'center' },
{ prop: 'game_name_en', label: 'page.table.gameNameEn', minWidth: 120, align: 'center' },
{ prop: 'game_type', label: 'page.table.gameType', minWidth: 90, align: 'center' },
{ prop: 'sort', label: 'page.table.sort', width: 80, align: 'center' },
{ prop: 'status', label: 'page.table.status', width: 90, align: 'center', useSlot: true },
{ prop: 'update_time', label: 'page.table.updateTime', minWidth: 160, align: 'center' },
{
prop: 'operation',
label: '操作',
label: 'table.actions.operation',
width: 110,
align: 'center',
fixed: 'right',

View File

@@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增游戏' : '编辑游戏'"
:title="dialogType === 'add' ? $t('page.form.dialogTitleAdd') : $t('page.form.dialogTitleEdit')"
width="680px"
align-center
:close-on-click-modal="false"
@@ -10,55 +10,55 @@
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="供应商" prop="provider">
<el-input v-model="formData.provider" placeholder="请输入供应商名称" />
<el-form-item :label="$t('page.form.provider')" prop="provider">
<el-input v-model="formData.provider" :placeholder="$t('page.form.placeholderProvider')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="供应商编码" prop="provider_code">
<el-input v-model="formData.provider_code" placeholder="请输入供应商编码" />
<el-form-item :label="$t('page.form.providerCode')" prop="provider_code">
<el-input v-model="formData.provider_code" :placeholder="$t('page.form.placeholderProviderCode')" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="游戏编号" prop="game_code">
<el-input v-model="formData.game_code" placeholder="请输入游戏编号" />
<el-form-item :label="$t('page.form.gameCode')" prop="game_code">
<el-input v-model="formData.game_code" :placeholder="$t('page.form.placeholderGameCode')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="游戏唯一值" prop="game_key">
<el-input v-model="formData.game_key" placeholder="请输入游戏唯一值" />
<el-form-item :label="$t('page.form.gameKey')" prop="game_key">
<el-input v-model="formData.game_key" :placeholder="$t('page.form.placeholderGameKey')" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="中文名称" prop="game_name">
<el-input v-model="formData.game_name" placeholder="请输入中文名称" />
<el-form-item :label="$t('page.form.gameName')" prop="game_name">
<el-input v-model="formData.game_name" :placeholder="$t('page.form.placeholderGameName')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="英文名称" prop="game_name_en">
<el-input v-model="formData.game_name_en" placeholder="请输入英文名称" />
<el-form-item :label="$t('page.form.gameNameEn')" prop="game_name_en">
<el-input v-model="formData.game_name_en" :placeholder="$t('page.form.placeholderGameNameEn')" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="游戏类型" prop="game_type">
<el-input v-model="formData.game_type" placeholder="请输入游戏类型" />
<el-form-item :label="$t('page.form.gameType')" prop="game_type">
<el-input v-model="formData.game_type" :placeholder="$t('page.form.placeholderGameType')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-form-item :label="$t('page.form.sort')" prop="sort">
<el-input-number v-model="formData.sort" :min="1" :step="1" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="Logo地址" prop="logo">
<el-form-item :label="$t('page.form.logo')" prop="logo">
<el-tabs v-model="logoInputMode" class="w-full">
<el-tab-pane label="图片选择" name="picker">
<el-tab-pane :label="$t('page.form.tabPicker')" name="picker">
<sa-image-picker
v-model="formData.logo"
:multiple="false"
@@ -67,7 +67,7 @@
height="120px"
/>
</el-tab-pane>
<el-tab-pane label="图片上传" name="upload">
<el-tab-pane :label="$t('page.form.tabUpload')" name="upload">
<sa-image-upload
v-model="formData.logo"
:multiple="false"
@@ -78,33 +78,42 @@
</el-tab-pane>
</el-tabs>
</el-form-item>
<el-form-item label="游戏地址" prop="game_url">
<el-input v-model="formData.game_url" placeholder="请输入游戏地址" />
<el-form-item :label="$t('page.form.gameUrl')" prop="game_url">
<el-input v-model="formData.game_url" :placeholder="$t('page.form.placeholderGameUrl')" />
</el-form-item>
<el-form-item label="大厅地址" prop="hall_url">
<el-input v-model="formData.hall_url" placeholder="请输入大厅地址" />
<el-form-item :label="$t('page.form.hallUrl')" prop="hall_url">
<el-input v-model="formData.hall_url" :placeholder="$t('page.form.placeholderHallUrl')" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-form-item :label="$t('page.form.status')" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
<el-radio :value="1">{{ $t('page.form.statusEnabled') }}</el-radio>
<el-radio :value="0">{{ $t('page.form.statusDisabled') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="请输入备注" />
<el-form-item :label="$t('page.form.remark')" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
:placeholder="$t('page.form.placeholderRemark')"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
<el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleSubmit">{{ $t('table.form.submit') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/game/index'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { withChannelDeptParams } from '@/composables/useChannelDeptScope'
const { t } = useI18n()
interface Props {
modelValue: boolean
@@ -121,40 +130,24 @@
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const logoInputMode = ref<'picker' | 'upload'>('picker')
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
set: (val) => emit('update:modelValue', val)
})
type GameFormData = {
id: number | null
provider: string
provider_code: string
game_code: string
game_key: string
game_name: string
game_name_en: string
game_type: string
logo: string
game_url: string
hall_url: string
status: number
sort: number
remark: string
}
const formRef = ref<FormInstance>()
const logoInputMode = ref('picker')
const initialFormData: GameFormData = {
id: null,
provider: 'Dicey Fun',
provider_code: 'DF',
const initialFormData = {
id: undefined as number | undefined,
provider: '',
provider_code: '',
game_code: '',
game_key: '',
game_name: '',
game_name_en: '',
game_type: 'slot',
game_type: '',
logo: '',
game_url: '',
hall_url: '',
@@ -166,12 +159,12 @@
const formData = reactive({ ...initialFormData })
const rules = computed<FormRules>(() => ({
provider: [{ required: true, message: '请输入供应商', trigger: 'blur' }],
provider_code: [{ required: true, message: '请输入供应商编码', trigger: 'blur' }],
game_code: [{ required: true, message: '请输入游戏编号', trigger: 'blur' }],
game_key: [{ required: true, message: '请输入游戏唯一值', trigger: 'blur' }],
game_name: [{ required: true, message: '请输入中文名称', trigger: 'blur' }],
game_type: [{ required: true, message: '请输入游戏类型', trigger: 'blur' }]
provider: [{ required: true, message: t('page.form.ruleProviderRequired'), trigger: 'blur' }],
provider_code: [{ required: true, message: t('page.form.ruleProviderCodeRequired'), trigger: 'blur' }],
game_code: [{ required: true, message: t('page.form.ruleGameCodeRequired'), trigger: 'blur' }],
game_key: [{ required: true, message: t('page.form.ruleGameKeyRequired'), trigger: 'blur' }],
game_name: [{ required: true, message: t('page.form.ruleGameNameRequired'), trigger: 'blur' }],
game_type: [{ required: true, message: t('page.form.ruleGameTypeRequired'), trigger: 'blur' }]
}))
watch(
@@ -209,11 +202,11 @@
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
await api.save(withChannelDeptParams(formData))
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(formData)
ElMessage.success('更新成功')
await api.update(withChannelDeptParams(formData))
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')
handleClose()

View File

@@ -8,25 +8,41 @@
@search="handleSearch"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="供应商编码" prop="provider_code">
<el-input v-model="formData.provider_code" placeholder="请输入供应商编码" clearable />
<el-form-item :label="$t('page.search.providerCode')" prop="provider_code">
<el-input
v-model="formData.provider_code"
:placeholder="$t('page.search.placeholderProviderCode')"
clearable
/>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="游戏编号" prop="game_code">
<el-input v-model="formData.game_code" placeholder="请输入游戏编号" clearable />
<el-form-item :label="$t('page.search.gameCode')" prop="game_code">
<el-input
v-model="formData.game_code"
:placeholder="$t('page.search.placeholderGameCode')"
clearable
/>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="游戏类型" prop="game_type">
<el-input v-model="formData.game_type" placeholder="请输入游戏类型" clearable />
<el-form-item :label="$t('page.search.gameType')" prop="game_type">
<el-input
v-model="formData.game_type"
:placeholder="$t('page.search.placeholderGameType')"
clearable
/>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
<el-form-item :label="$t('page.search.status')" prop="status">
<el-select
v-model="formData.status"
:placeholder="$t('page.search.placeholderStatus')"
clearable
>
<el-option :label="$t('page.search.statusEnabled')" :value="1" />
<el-option :label="$t('page.search.statusDisabled')" :value="0" />
</el-select>
</el-form-item>
</el-col>

View File

@@ -1,6 +1,5 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<div class="flex-1 min-h-0 flex flex-col">
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<ElCard class="art-table-card" shadow="never">
@@ -30,6 +29,12 @@
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<template #safety_line="{ row }">
<span v-if="isDefaultPoolRow(row)" class="font-mono">{{
formatSafetyLine(row.safety_line)
}}</span>
<span v-else class="text-gray-400 text-xs">{{ $t('page.table.safetyLineNotUsed') }}</span>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
@@ -82,13 +87,10 @@
getData()
}
// 奖池类型展示:按 name 映射
const typeFormatter = (row: Record<string, unknown>) => {
const n = String(row.name ?? '')
if (n === 'default') return t('page.search.poolTypeNormal')
if (n === 'killScore') return t('page.search.poolTypeKill')
if (n === 'up') return t('page.search.poolTypeT1')
return n || '-'
const poolNameFormatter = (row: Record<string, unknown>) => {
const remark = String(row.remark ?? '').trim()
if (remark) return remark
return String(row.name ?? '').trim() || '-'
}
// 权重列带 %
@@ -97,6 +99,17 @@
return v != null && v !== '' ? `${v}%` : '-'
}
/** 仅 name=default正常奖池的安全线参与杀分判定 */
function isDefaultPoolRow(row: Record<string, unknown>): boolean {
return String(row.name ?? '') === 'default'
}
function formatSafetyLine(val: unknown): string {
if (val === null || val === undefined || val === '') return '-'
const n = typeof val === 'number' ? val : Number(val)
return Number.isFinite(n) ? n.toFixed(2) : '-'
}
// 表格配置
const {
columns,
@@ -115,9 +128,26 @@
core: {
apiFn: api.list,
columnsFactory: () => [
{ prop: 'name', label: 'page.table.name', align: 'center' },
{ prop: 'name', label: 'page.table.poolType', width: 100, align: 'center', formatter: typeFormatter },
{ prop: 'safety_line', label: 'page.table.safetyLine', align: 'center' },
{ prop: 'remark', label: 'page.table.poolName', minWidth: 120, align: 'center', formatter: poolNameFormatter },
{
prop: 'config_note',
label: 'page.table.configNote',
minWidth: 140,
align: 'center',
showOverflowTooltip: true,
formatter: (row: Record<string, unknown>) => {
const v = String(row.config_note ?? '').trim()
return v || '-'
}
},
{ prop: 'name', label: 'page.table.name', width: 110, align: 'center' },
{
prop: 'safety_line',
label: 'page.table.safetyLine',
minWidth: 120,
align: 'center',
useSlot: true
},
{
prop: 't1_weight',
label: 'page.table.t1PoolWeight',

View File

@@ -16,7 +16,7 @@
</div>
<div class="profit-row mb-3">
<div class="flex items-center gap-2">
<span class="text-gray-500">{{ $t('page.form.playerProfit') }}</span>
<span class="text-gray-500">{{ $t('page.form.poolProfitAmount') }}</span>
<span class="font-mono text-lg" :class="profitAmountClass">{{
displayProfitAmount
}}</span>
@@ -41,8 +41,9 @@
:precision="2"
style="width: 100%"
/>
<div class="text-gray-500 text-xs mt-1">{{ $t('page.table.safetyLineTip') }}</div>
</el-form-item>
<el-form-item label="开启杀分">
<el-form-item :label="$t('page.form.enableKillScore')">
<el-switch v-model="formData.kill_enabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item :label="$t('page.form.killScoreWeights')">
@@ -85,8 +86,13 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import {
getChannelDeptRequestParams,
withChannelDeptParams
} from '@/composables/useChannelDeptScope'
const { t } = useI18n()
const channelDeptParams = () => getChannelDeptRequestParams()
interface PoolData {
id: number
@@ -151,7 +157,7 @@
if (!visible.value) return
try {
loading.value = true
const res = await api.getCurrentPool()
const res = await api.getCurrentPool(channelDeptParams())
const data = res as unknown as PoolData
if (data && typeof data === 'object') {
pool.value = data
@@ -172,7 +178,7 @@
stopPolling()
return
}
api.getCurrentPool().then((res) => {
api.getCurrentPool(channelDeptParams()).then((res) => {
const data = res as unknown as PoolData
if (pool.value && data && typeof data === 'object' && data.profit_amount != null) {
pool.value.profit_amount = data.profit_amount
@@ -193,10 +199,12 @@
try {
await formRef.value?.validate?.()
saving.value = true
await api.updateCurrentPool({
safety_line: formData.safety_line,
kill_enabled: formData.kill_enabled
})
await api.updateCurrentPool(
withChannelDeptParams({
safety_line: formData.safety_line,
kill_enabled: formData.kill_enabled
})
)
ElMessage.success(t('page.form.msgSaveSuccess'))
await loadPool()
emit('success')
@@ -211,7 +219,7 @@
if (!pool.value) return
try {
resetting.value = true
await api.resetProfitAmount()
await api.resetProfitAmount(channelDeptParams())
ElMessage.success(t('page.form.msgResetProfitSuccess'))
await loadPool()
emit('success')
@@ -232,6 +240,8 @@
() => props.modelValue,
(open) => {
if (open) {
// 切换渠道后再打开弹窗时,先清掉旧池数据,避免视觉残留
pool.value = null
loadPool().then(() => startPolling())
} else {
stopPolling()

View File

@@ -15,24 +15,41 @@
:disabled="dialogType === 'edit'"
/>
</el-form-item>
<el-form-item :label="$t('form.labelRemark')" prop="remark">
<el-form-item :label="$t('page.form.poolName')" prop="remark">
<el-input
v-model="formData.remark"
:placeholder="$t('page.form.placeholderPoolName')"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item :label="$t('form.labelRemark')" prop="config_note">
<el-input
v-model="formData.config_note"
type="textarea"
:rows="3"
:placeholder="$t('page.form.placeholderRemark')"
:placeholder="$t('page.form.placeholderConfigNote')"
maxlength="500"
show-word-limit
/>
</el-form-item>
<!-- dice_lottery_pool_config 已移除 type 字段 name 区分 default/killScore/upname -->
<el-form-item :label="$t('page.form.safetyLine')" prop="safety_line">
<el-form-item v-if="showSafetyLineField" :label="$t('page.form.safetyLine')" prop="safety_line">
<el-input-number
v-model="formData.safety_line"
:min="0"
:precision="2"
style="width: 100%"
/>
<div class="text-gray-500 text-xs mt-1">{{ $t('page.table.safetyLineTip') }}</div>
</el-form-item>
<el-form-item v-else-if="showSafetyLineReadonlyHint" :label="$t('page.form.safetyLine')">
<el-alert
type="info"
:closable="false"
show-icon
:title="$t('page.form.safetyLineNotUsedReadonly')"
/>
</el-form-item>
<el-form-item :label="$t('page.form.t1Weight')" prop="t1_weight">
<el-slider v-model="formData.t1_weight" :min="0" :max="100" :step="0.01" show-input />
@@ -70,6 +87,7 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { useInjectedChannelDept, withChannelDeptParams } from '@/composables/useChannelDeptScope'
const { t } = useI18n()
@@ -110,25 +128,46 @@
return WEIGHT_KEYS.reduce((sum, key) => sum + Number(formData[key] ?? 0), 0)
})
function isDefaultPoolName(name: unknown): boolean {
return String(name ?? '') === 'default'
}
const showSafetyLineField = computed(() => isDefaultPoolName(formData.name))
const showSafetyLineReadonlyHint = computed(() => {
const n = String(formData.name ?? '').trim()
return n !== '' && !isDefaultPoolName(n)
})
/**
* 表单验证规则
*/
const rules = computed<FormRules>(() => ({
name: [{ required: true, message: t('page.form.ruleNameRequired'), trigger: 'blur' }],
t1_weight: [{ required: true, message: t('page.form.ruleT1Required'), trigger: 'blur' }],
t2_weight: [{ required: true, message: t('page.form.ruleT2Required'), trigger: 'blur' }],
t3_weight: [{ required: true, message: t('page.form.ruleT3Required'), trigger: 'blur' }],
t4_weight: [{ required: true, message: t('page.form.ruleT4Required'), trigger: 'blur' }],
t5_weight: [{ required: true, message: t('page.form.ruleT5Required'), trigger: 'blur' }]
}))
const rules = computed<FormRules>(() => {
const base: FormRules = {
name: [{ required: true, message: t('page.form.ruleNameRequired'), trigger: 'blur' }],
t1_weight: [{ required: true, message: t('page.form.ruleT1Required'), trigger: 'blur' }],
t2_weight: [{ required: true, message: t('page.form.ruleT2Required'), trigger: 'blur' }],
t3_weight: [{ required: true, message: t('page.form.ruleT3Required'), trigger: 'blur' }],
t4_weight: [{ required: true, message: t('page.form.ruleT4Required'), trigger: 'blur' }],
t5_weight: [{ required: true, message: t('page.form.ruleT5Required'), trigger: 'blur' }]
}
if (showSafetyLineField.value) {
base.safety_line = [
{ required: true, message: t('page.form.ruleSafetyLineRequired'), trigger: 'blur' }
]
}
return base
})
/**
* 初始数据(权重为数字便于输入与校验)
*/
const initialFormData = {
id: null as number | null,
dept_id: undefined as number | undefined,
name: '',
remark: '',
config_note: '',
safety_line: 0 as number,
t1_weight: 0 as number,
t2_weight: 0 as number,
@@ -174,6 +213,7 @@
if (!props.data) return
const numKeys = [
'id',
'dept_id',
'safety_line',
't1_weight',
't2_weight',
@@ -204,6 +244,8 @@
/**
* 提交表单
*/
const channelScope = useInjectedChannelDept()
const handleSubmit = async () => {
if (!formRef.value) return
try {
@@ -212,11 +254,22 @@
ElMessage.warning(t('page.form.msgWeightsMust100'))
return
}
const submitData = withChannelDeptParams({
...formData,
dept_id:
formData.dept_id ??
props.data?.dept_id ??
channelScope?.selectedDeptId.value
})
const { safety_line, ...submitWithoutSafetyLine } = submitData
const payload = isDefaultPoolName(submitData.name)
? submitData
: submitWithoutSafetyLine
if (props.dialogType === 'add') {
await api.save(formData)
await api.save(payload)
ElMessage.success(t('page.form.msgAddSuccess'))
} else {
await api.update(formData)
await api.update(payload)
ElMessage.success(t('page.form.msgUpdateSuccess'))
}
emit('success')

View File

@@ -1,7 +1,8 @@
<template>
<div class="art-full-height">
<QuickDateRangeBar v-model="activeDatePreset" @select="handleQuickDateSelect" />
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<TableSearch v-model="searchForm" @search="handleSearch" @reset="handleResetSearch" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
@@ -138,12 +139,24 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/play_record/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import { lotteryPoolRowLabel } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
import QuickDateRangeBar from '@/views/plugin/dice/components/QuickDateRangeBar.vue'
import {
detectPresetFromRange,
type DatePresetKey
} from '@/views/plugin/dice/utils/dateRangePresets'
import { getRecordRouteInit, useRecordRouteSync } from '@/views/plugin/dice/composables/useRecordRouteQuery'
const { t } = useI18n()
const route = useRoute()
const routeInit = getRecordRouteInit(route.query)
const activeDatePreset = ref<DatePresetKey | null>(routeInit.activePreset)
// 搜索表单
const searchForm = ref<Record<string, unknown>>({
@@ -157,9 +170,40 @@
roll_number_max: undefined,
reward_ui_text: undefined,
reward_tier: undefined,
direction: undefined
direction: undefined,
create_time: routeInit.create_time
})
const PLAY_RECORD_SEARCH_KEYS = [
'username',
'lottery_config_name',
'lottery_type',
'is_win',
'win_coin_min',
'win_coin_max',
'roll_number_min',
'roll_number_max',
'reward_ui_text',
'reward_tier',
'direction',
'create_time_min',
'create_time_max'
] as const
const applySearchParams = (params: Record<string, unknown>) => {
const p = { ...params }
if (Array.isArray(p.create_time) && p.create_time.length === 2) {
p.create_time_min = p.create_time[0]
p.create_time_max = p.create_time[1]
}
delete p.create_time
const paramsRecord = searchParams as Record<string, unknown>
PLAY_RECORD_SEARCH_KEYS.forEach((key) => {
delete paramsRecord[key]
})
Object.assign(searchParams, p)
}
/** 当前筛选下平台总盈利(付费金额 paid_amount 求和 - 玩家总收益) */
const totalWinCoin = ref<number | null>(null)
@@ -169,16 +213,37 @@
return res
}
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
applySearchParams(params)
getData()
}
const handleQuickDateSelect = (range: [string, string], preset: DatePresetKey) => {
searchForm.value.create_time = range
activeDatePreset.value = preset
handleSearch({ ...searchForm.value })
}
const handleResetSearch = () => {
activeDatePreset.value = null
searchForm.value.create_time = undefined
resetSearchParams()
}
watch(
() => searchForm.value.create_time,
(range) => {
if (Array.isArray(range) && range.length === 2) {
activeDatePreset.value = detectPresetFromRange([range[0], range[1]])
} else {
activeDatePreset.value = null
}
}
)
const usernameFormatter = (row: Record<string, any>) =>
row?.dicePlayer?.username ?? row?.player_id ?? '-'
const lotteryConfigNameFormatter = (row: Record<string, any>) =>
row?.diceLotteryPoolConfig?.name ?? row?.lottery_config_id ?? '-'
const lotteryConfigNameFormatter = (row: Record<string, any>) => lotteryPoolRowLabel(row)
const rewardTierFormatter = (row: Record<string, any>) => row?.reward_tier ?? '-'
/** 摇取点数格式化为 1,3,4,5,6,6 */
@@ -221,6 +286,8 @@
core: {
apiFn: listApi,
apiParams: { limit: 100 },
excludeParams: ['create_time'],
immediate: !routeInit.hasFilter,
columnsFactory: () => [
// { type: 'selection' },
{ prop: 'id', label: 'page.table.id', width: 80 },
@@ -253,6 +320,13 @@
width: 100,
formatter: (row: Record<string, any>) => rewardTierFormatter(row)
},
{
prop: 'remark',
label: 'page.table.remark',
width: 200,
showOverflowTooltip: true,
formatter: (row: Record<string, any>) => row?.remark || '-'
},
{ prop: 'create_time', label: 'page.table.createTime', width: 170 },
{ prop: 'update_time', label: 'page.table.updateTime', width: 170 },
{
@@ -277,6 +351,12 @@
handleSelectionChange
// selectedRows
} = useSaiAdmin()
useRecordRouteSync({
searchForm,
activeDatePreset,
onSearch: handleSearch
})
</script>
<style scoped>

View File

@@ -1,13 +1,13 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? $t('page.form.dialogTitleAdd') : $t('page.form.dialogTitleEdit')"
:title="$t('page.form.dialogTitleEdit')"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form ref="formRef" :model="formData" label-width="120px">
<el-form-item :label="$t('page.form.player')" prop="player_id">
<el-select
v-model="formData.player_id"
@@ -15,7 +15,7 @@
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
>
<el-option
v-for="item in playerOptions"
@@ -32,12 +32,12 @@
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
>
<el-option
v-for="item in lotteryConfigOptions"
:key="item.id"
:label="item.name"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -48,7 +48,7 @@
:placeholder="$t('form.placeholderSelect')"
clearable
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
>
<el-option :label="$t('page.form.paid')" :value="0" />
<el-option :label="$t('page.form.free')" :value="1" />
@@ -60,7 +60,7 @@
:placeholder="$t('form.placeholderSelect')"
clearable
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
>
<el-option :label="$t('page.form.noBigWin')" :value="0" />
<el-option :label="$t('page.form.bigWin')" :value="1" />
@@ -72,7 +72,7 @@
:placeholder="$t('page.form.placeholderWinCoin')"
:precision="0"
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.superWinCoin')" prop="super_win_coin">
@@ -82,7 +82,7 @@
:precision="0"
:min="0"
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.rewardWinCoin')" prop="reward_win_coin">
@@ -92,7 +92,7 @@
:precision="0"
:min="0"
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.direction')" prop="direction">
@@ -101,7 +101,7 @@
:placeholder="$t('page.form.placeholderDirection')"
clearable
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
>
<el-option :label="$t('page.form.clockwise')" :value="0" />
<el-option :label="$t('page.form.anticlockwise')" :value="1" />
@@ -113,7 +113,7 @@
:placeholder="$t('page.form.placeholderStartIndex')"
:min="0"
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.targetIndex')" prop="target_index">
@@ -122,7 +122,7 @@
:placeholder="$t('page.form.placeholderTargetIndex')"
:min="0"
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.rollArray')" prop="rollArrayItems">
@@ -137,7 +137,7 @@
controls-position="right"
placeholder=""
class="roll-array-input"
:disabled="dialogType === 'edit'"
disabled
/>
</div>
<div class="roll-array-hint">{{ $t('page.form.rollArrayHint') }}</div>
@@ -150,7 +150,7 @@
:max="30"
:precision="0"
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.rewardTier')" prop="reward_tier">
@@ -160,7 +160,7 @@
clearable
filterable
style="width: 100%"
:disabled="true"
disabled
>
<el-option label="T1" value="T1" />
<el-option label="T2" value="T2" />
@@ -172,19 +172,19 @@
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">{{ dialogType === 'edit' ? $t('form.close') : $t('common.cancel') }}</el-button>
<el-button v-if="dialogType === 'add'" type="primary" @click="handleSubmit">{{ $t('table.form.submit') }}</el-button>
<el-button @click="handleClose">{{ $t('form.close') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/play_record/index'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
const { t } = useI18n()
import { getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
import {
lotteryPoolOptionLabel,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
import type { FormInstance } from 'element-plus'
interface Props {
modelValue: boolean
@@ -199,7 +199,7 @@
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
dialogType: 'edit',
data: undefined
})
@@ -212,34 +212,8 @@
set: (value) => emit('update:modelValue', value)
})
const rules = reactive<FormRules>({
player_id: [{ required: true, message: '请选择玩家', trigger: 'change' }],
lottery_config_id: [{ required: true, message: '请选择彩金池配置', trigger: 'change' }],
lottery_type: [{ required: true, message: '请选择抽奖类型', trigger: 'change' }],
is_win: [{ required: true, message: '请选择是否中大奖', trigger: 'change' }],
win_coin: [{ required: true, message: '赢取平台币必填', trigger: 'blur' }],
rollArrayItems: [
{
validator: (_rule: any, value: (number | null)[], callback: (e?: Error) => void) => {
if (!value || value.length !== 5) {
callback(new Error('摇取点数必须为 5 个数'))
return
}
const ok = value.every((n) => n != null && n >= 1 && n <= 6)
if (!ok) {
callback(new Error('摇取点数必须填写 5 个数,每个 16'))
return
}
callback()
},
trigger: 'change'
}
],
reward_tier: [{ required: true, message: '请选择中奖档位', trigger: 'change' }]
})
const playerOptions = ref<Array<{ id: number; username: string }>>([])
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
const initialFormData = {
id: null as number | null,
@@ -272,9 +246,10 @@
if (open) {
initPage()
try {
const deptParams = getChannelDeptRequestParams()
const [players, lotteryConfigs] = await Promise.all([
api.getPlayerOptions(),
api.getLotteryConfigOptions()
api.getPlayerOptions(deptParams),
api.getLotteryConfigOptions(deptParams)
])
playerOptions.value = Array.isArray(players) ? players : ((players as any)?.data ?? [])
lotteryConfigOptions.value = Array.isArray(lotteryConfigs)
@@ -374,46 +349,6 @@
visible.value = false
formRef.value?.resetFields()
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
const payload = { ...formData } as Record<string, unknown>
// 将 5 个输入值拼成 [1,2,3,4,5] 格式,确保每项为 16 的整数
const items = formData.rollArrayItems
const rollArray = items.map((n) => {
const v = n != null ? Number(n) : 1
return Math.min(6, Math.max(1, Number.isNaN(v) ? 1 : Math.floor(v)))
})
payload.roll_array = rollArray
payload.roll_number = formData.roll_number ?? rollArray.reduce((s, n) => s + n, 0)
delete payload.rollArrayItems
if (props.dialogType === 'add') {
delete payload.id
await api.save(payload)
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(payload)
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')
handleClose()
} catch (error: any) {
let msg = t('page.form.validateFailed')
if (error?.message) {
msg = error.message
} else if (typeof error === 'string') {
msg = error
} else if (error && typeof error === 'object') {
const first = Object.values(error).find((v: any) => v?.[0]?.message)
if (first && Array.isArray(first)) {
msg = (first[0] as any).message || msg
}
}
ElMessage.warning(msg)
}
}
</script>
<style lang="scss" scoped>

View File

@@ -105,6 +105,19 @@
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(8)">
<el-form-item :label="$t('page.search.createTime')" prop="create_time">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
:range-separator="$t('table.searchBar.rangeSeparator')"
:start-placeholder="$t('table.searchBar.startTime')"
:end-placeholder="$t('table.searchBar.endTime')"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>

View File

@@ -155,10 +155,12 @@
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { lotteryPoolRowLabel } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
const { t } = useI18n()
// 搜索表单(与 play_record 对齐:方向、赢取平台币范围、是否中大奖、中奖档位、点数和)
const searchForm = ref<Record<string, unknown>>({
lottery_config_id: undefined,
reward_config_record_id: undefined,
lottery_type: undefined,
direction: undefined,
@@ -180,8 +182,7 @@
return res
}
const lotteryConfigNameFormatter = (row: Record<string, any>) =>
row?.diceLotteryPoolConfig?.name ?? row?.lottery_config_id ?? '-'
const lotteryConfigNameFormatter = (row: Record<string, any>) => lotteryPoolRowLabel(row)
const rewardTierFormatter = (row: Record<string, any>) => row?.reward_tier ?? '-'
/** 摇取点数格式化为 1,3,4,5,6 */

View File

@@ -1,15 +1,15 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? $t('page.form.titleAdd') : $t('page.form.titleEdit')"
:title="$t('page.form.titleEdit')"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form ref="formRef" :model="formData" label-width="120px">
<el-form-item :label="$t('page.form.labelLotteryConfigId')" prop="lottery_config_id">
<el-input v-model="formData.lottery_config_id" :placeholder="$t('page.form.placeholderLotteryConfigId')" />
<el-input v-model="formData.lottery_config_id" :placeholder="$t('page.form.placeholderLotteryConfigId')" disabled />
</el-form-item>
<el-form-item :label="$t('page.form.drawType')" prop="lottery_type">
<el-select
@@ -17,13 +17,20 @@
:placeholder="$t('form.placeholderSelect')"
clearable
style="width: 100%"
disabled
>
<el-option :label="$t('page.search.paid')" :value="0" />
<el-option :label="$t('page.search.free')" :value="1" />
</el-select>
</el-form-item>
<el-form-item :label="$t('page.search.direction')" prop="direction">
<el-select v-model="formData.direction" :placeholder="$t('form.placeholderSelect')" clearable style="width: 100%">
<el-select
v-model="formData.direction"
:placeholder="$t('form.placeholderSelect')"
clearable
style="width: 100%"
disabled
>
<el-option :label="$t('page.search.clockwise')" :value="0" />
<el-option :label="$t('page.search.anticlockwise')" :value="1" />
</el-select>
@@ -35,6 +42,7 @@
:precision="0"
controls-position="right"
style="width: 100%"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.search.paidAmount')" prop="paid_amount">
@@ -44,10 +52,11 @@
:precision="0"
controls-position="right"
style="width: 100%"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.search.isBigWin')" prop="is_win">
<el-select v-model="formData.is_win" :placeholder="$t('form.placeholderSelect')" clearable style="width: 100%">
<el-select v-model="formData.is_win" :placeholder="$t('form.placeholderSelect')" clearable style="width: 100%" disabled>
<el-option :label="$t('page.search.noBigWin')" :value="0" />
<el-option :label="$t('page.search.bigWin')" :value="1" />
</el-select>
@@ -59,6 +68,7 @@
:precision="0"
controls-position="right"
style="width: 100%"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.search.rewardTier')" prop="reward_tier">
@@ -67,6 +77,7 @@
:placeholder="$t('page.form.placeholderRewardTier')"
clearable
style="width: 100%"
disabled
>
<el-option label="T1" value="T1" />
<el-option label="T2" value="T2" />
@@ -77,19 +88,19 @@
</el-select>
</el-form-item>
<el-form-item :label="$t('page.table.startIndex')" prop="start_index">
<el-input v-model="formData.start_index" :placeholder="$t('page.form.placeholderStartIndex')" />
<el-input v-model="formData.start_index" :placeholder="$t('page.form.placeholderStartIndex')" disabled />
</el-form-item>
<el-form-item :label="$t('page.form.labelTargetIndex')" prop="target_index">
<el-input v-model="formData.target_index" :placeholder="$t('page.form.placeholderTargetIndex')" />
<el-input v-model="formData.target_index" :placeholder="$t('page.form.placeholderTargetIndex')" disabled />
</el-form-item>
<el-form-item :label="$t('page.search.rollNumber')" prop="roll_number">
<el-input v-model="formData.roll_number" :placeholder="$t('page.form.placeholderRollNumber')" />
<el-input v-model="formData.roll_number" :placeholder="$t('page.form.placeholderRollNumber')" disabled />
</el-form-item>
<el-form-item :label="$t('page.form.labelRollArray')" prop="roll_array">
<el-input v-model="formData.roll_array" :placeholder="$t('page.form.placeholderRollArray')" />
<el-input v-model="formData.roll_array" :placeholder="$t('page.form.placeholderRollArray')" disabled />
</el-form-item>
<el-form-item :label="$t('page.form.labelStatus')" prop="status">
<sa-radio v-model="formData.status" dict="data_status" />
<sa-radio v-model="formData.status" dict="data_status" disabled />
</el-form-item>
<el-form-item :label="$t('page.table.superWinCoin')" prop="super_win_coin">
<el-input-number
@@ -98,6 +109,7 @@
:precision="0"
controls-position="right"
style="width: 100%"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.table.rewardWinCoin')" prop="reward_win_coin">
@@ -107,24 +119,21 @@
:precision="0"
controls-position="right"
style="width: 100%"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.labelAdminId')" prop="admin_id">
<el-input v-model="formData.admin_id" :placeholder="$t('page.form.placeholderAdminId')" />
<el-input v-model="formData.admin_id" :placeholder="$t('page.form.placeholderAdminId')" disabled />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleSubmit">{{ $t('table.form.submit') }}</el-button>
<el-button @click="handleClose">{{ $t('form.close') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/play_record_test/index'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import type { FormInstance } from 'element-plus'
interface Props {
modelValue: boolean
@@ -139,12 +148,11 @@
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
dialogType: 'edit',
data: undefined
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
const formRef = ref<FormInstance>()
@@ -156,18 +164,6 @@
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = computed<FormRules>(() => ({
lottery_config_id: [{ required: true, message: t('page.form.ruleLotteryConfigIdRequired'), trigger: 'blur' }],
lottery_type: [{ required: true, message: t('page.form.ruleDrawTypeRequired'), trigger: 'blur' }],
is_win: [{ required: true, message: t('page.form.ruleIsBigWinRequired'), trigger: 'blur' }],
direction: [{ required: true, message: t('page.form.ruleDirectionRequired'), trigger: 'blur' }],
reward_tier: [{ required: true, message: t('page.form.ruleRewardTierRequired'), trigger: 'blur' }],
status: [{ required: true, message: t('page.form.ruleStatusRequired'), trigger: 'blur' }]
}))
/**
* 初始数据
*/
@@ -197,7 +193,7 @@
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
* 监听弹窗打开,初始化表单数据(仅查看)
*/
watch(
() => props.modelValue,
@@ -212,9 +208,7 @@
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
@@ -222,7 +216,7 @@
}
/**
* 初始化表单数据
* 回填表单数据
*/
function normalizePlatformCoin(val: unknown): number {
if (val === '' || val === null || val === undefined) return 0
@@ -244,34 +238,11 @@
}
}
/**
* 关闭弹窗并重置表单
* 关闭弹窗
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
const payload = { ...formData }
if (props.dialogType === 'add') {
await api.save(payload)
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(payload)
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -8,6 +8,29 @@
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item :label="$t('page.search.lotteryPoolConfig')" prop="lottery_config_id">
<el-select
v-model="formData.lottery_config_id"
:placeholder="$t('page.search.placeholderLotteryPool')"
clearable
filterable
remote
reserve-keyword
:loading="lotteryPoolLoading"
:remote-method="filterLotteryPoolOptions"
style="width: 100%"
@visible-change="onLotteryPoolDropdownVisible"
>
<el-option
v-for="item in lotteryPoolOptions"
:key="item.id"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item :label="$t('page.search.rewardConfigRecordId')" prop="reward_config_record_id">
<el-input-number
@@ -120,6 +143,17 @@
</template>
<script setup lang="ts">
import lotteryPoolApi from '../../../api/lottery_pool_config/index'
import {
getChannelDeptRequestParams,
useInjectedChannelDept
} from '@/composables/useChannelDeptScope'
import {
filterLotteryPoolOptionsByQuery,
lotteryPoolOptionLabel,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
interface Props {
modelValue: Record<string, any>
}
@@ -131,6 +165,57 @@
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
const channelScope = useInjectedChannelDept()
const lotteryPoolAllOptions = ref<LotteryPoolOption[]>([])
const lotteryPoolOptions = ref<LotteryPoolOption[]>([])
const lotteryPoolLoading = ref(false)
function resolveDeptParams(): Record<string, unknown> {
const extra = getChannelDeptRequestParams()
if (extra.dept_id !== undefined) {
return extra
}
if (channelScope) {
return { dept_id: channelScope.selectedDeptId.value }
}
return {}
}
async function loadLotteryPoolOptions() {
lotteryPoolLoading.value = true
try {
const list = await lotteryPoolApi.getOptions(resolveDeptParams())
lotteryPoolAllOptions.value = list
lotteryPoolOptions.value = list
} catch {
lotteryPoolAllOptions.value = []
lotteryPoolOptions.value = []
} finally {
lotteryPoolLoading.value = false
}
}
function filterLotteryPoolOptions(query: string) {
lotteryPoolOptions.value = filterLotteryPoolOptionsByQuery(lotteryPoolAllOptions.value, query)
}
function onLotteryPoolDropdownVisible(visible: boolean) {
if (visible && lotteryPoolAllOptions.value.length === 0) {
void loadLotteryPoolOptions()
}
}
onMounted(() => {
void loadLotteryPoolOptions()
})
watch(
() => channelScope?.selectedDeptId.value,
() => {
void loadLotteryPoolOptions()
}
)
const searchBarRef = ref()
const formData = computed({

View File

@@ -1,7 +1,8 @@
<template>
<div class="art-full-height">
<QuickDateRangeBar v-model="activeDatePreset" @select="handleQuickDateSelect" />
<!-- 搜索条件 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<TableSearch v-model="searchForm" @search="handleSearch" @reset="handleResetSearch" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格操作 -->
@@ -108,15 +109,27 @@
import { ElMessage } from 'element-plus'
import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/player/index'
import { withChannelDeptParams } from '@/composables/useChannelDeptScope'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import WalletOperateDialog from './modules/WalletOperateDialog.vue'
import { lotteryPoolRowLabel } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
import QuickDateRangeBar from '@/views/plugin/dice/components/QuickDateRangeBar.vue'
import {
detectPresetFromRange,
type DatePresetKey
} from '@/views/plugin/dice/utils/dateRangePresets'
import { getRecordRouteInit, useRecordRouteSync } from '@/views/plugin/dice/composables/useRecordRouteQuery'
const { t } = useI18n()
const { copy } = useClipboard()
const route = useRoute()
const routeInit = getRecordRouteInit(route.query)
const activeDatePreset = ref<DatePresetKey | null>(routeInit.activePreset)
// 搜索表单
const searchForm = ref({
@@ -125,25 +138,77 @@
phone: undefined,
status: undefined,
coin: undefined,
lottery_config_id: undefined
lottery_config_id: undefined,
create_time: routeInit.create_time
})
const PLAYER_SEARCH_KEYS = [
'username',
'name',
'phone',
'status',
'coin',
'lottery_config_id',
'create_time_min',
'create_time_max'
] as const
const applySearchParams = (params: Record<string, unknown>) => {
const p = { ...params }
if (Array.isArray(p.create_time) && p.create_time.length === 2) {
p.create_time_min = p.create_time[0]
p.create_time_max = p.create_time[1]
}
delete p.create_time
const paramsRecord = searchParams as Record<string, unknown>
PLAYER_SEARCH_KEYS.forEach((key) => {
delete paramsRecord[key]
})
Object.assign(searchParams, p)
}
// 搜索
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
applySearchParams(params)
getData()
}
const handleQuickDateSelect = (range: [string, string], preset: DatePresetKey) => {
searchForm.value.create_time = range
activeDatePreset.value = preset
handleSearch({ ...searchForm.value })
}
const handleResetSearch = () => {
activeDatePreset.value = null
searchForm.value.create_time = undefined
resetSearchParams()
}
watch(
() => searchForm.value.create_time,
(range) => {
if (Array.isArray(range) && range.length === 2) {
activeDatePreset.value = detectPresetFromRange([range[0], range[1]])
} else {
activeDatePreset.value = null
}
}
)
// 权重列显示为百分比
const weightFormatter = (prop: string) => (row: any) => {
const cellValue = row[prop]
return cellValue != null && cellValue !== '' ? `${cellValue}%` : '-'
}
// 根据 lottery_config_id 显示彩金池配置名称
const lotteryConfigNameFormatter = (row: any) =>
row?.diceLotteryPoolConfig?.name ??
(row?.lottery_config_id ? `#${row.lottery_config_id}` : t('page.table.customConfig'))
const lotteryConfigNameFormatter = (row: any) => {
const label = lotteryPoolRowLabel(row)
if (label === '-' && !row?.lottery_config_id) {
return t('page.table.customConfig')
}
return label
}
// 表格
const {
@@ -162,6 +227,8 @@
} = useTable({
core: {
apiFn: api.list,
excludeParams: ['create_time'],
immediate: !routeInit.hasFilter,
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'username', label: 'page.table.username', align: 'center' },
@@ -244,7 +311,7 @@
const handleStatusChange = async (row: Record<string, any>, status: number) => {
row._statusLoading = true
try {
await api.updateStatus({ id: row.id, status })
await api.updateStatus(withChannelDeptParams({ id: row.id, status }))
row.status = status
} catch {
refreshData()
@@ -295,4 +362,10 @@
ElMessage.warning(msg)
}
}
useRecordRouteSync({
searchForm,
activeDatePreset,
onSearch: handleSearch
})
</script>

View File

@@ -59,6 +59,7 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { withChannelDeptParams } from '@/composables/useChannelDeptScope'
const { t } = useI18n()
@@ -157,12 +158,14 @@
return
}
submitting.value = true
await walletRecordApi.adminOperate({
player_id: props.player.id,
type: formData.type!,
coin,
remark: formData.remark?.trim() || undefined
})
await walletRecordApi.adminOperate(
withChannelDeptParams({
player_id: props.player.id,
type: formData.type!,
coin,
remark: formData.remark?.trim() || undefined
})
)
ElMessage.success(t('page.form.operateSuccess'))
emit('success')
handleClose()

View File

@@ -35,7 +35,20 @@
<sa-switch v-model="formData.status" />
</el-form-item>
<el-form-item :label="$t('page.form.adminId')" prop="admin_id">
<el-tree-select
v-if="useAdminTreeSelect"
v-model="formData.admin_id"
:data="systemUserTreeOptions"
:props="systemUserTreeProps"
:placeholder="$t('page.form.placeholderAdminTree')"
clearable
filterable
check-strictly
style="width: 100%"
:loading="systemUserOptionsLoading"
/>
<el-select
v-else
v-model="formData.admin_id"
:placeholder="$t('page.form.placeholderAdmin')"
clearable
@@ -75,7 +88,7 @@
<el-option
v-for="item in lotteryConfigOptions"
:key="item.id"
:label="(item.name && String(item.name).trim()) || `#${item.id}`"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</el-select>
@@ -84,12 +97,12 @@
<el-form-item v-if="currentLotteryConfig" :label="$t('page.form.currentConfig')" class="current-config-block">
<div class="current-lottery-config">
<div class="config-row">
<span class="config-label">{{ $t('page.form.configLabelName') }}</span>
<span>{{ currentLotteryConfig.name ?? '-' }}</span>
<span class="config-label">{{ $t('page.form.configLabelPoolName') }}</span>
<span>{{ lotteryPoolDisplayLabel(currentLotteryConfig) }}</span>
</div>
<div class="config-row">
<span class="config-label">{{ $t('page.form.configLabelType') }}</span>
<span>{{ lotteryConfigTypeText(currentLotteryConfig.name) }}</span>
<span class="config-label">{{ $t('page.form.configLabelCode') }}</span>
<span>{{ currentLotteryConfig.name ?? '-' }}</span>
</div>
<div class="config-row">
<span class="config-label">{{ $t('page.form.configLabelWeights') }}</span>
@@ -170,8 +183,18 @@
<script setup lang="ts">
import api from '../../../api/player/index'
import lotteryConfigApi from '../../../api/lottery_pool_config/index'
import lotteryConfigApi, {
parseLotteryPoolConfigOption,
type LotteryPoolConfigOption
} from '../../../api/lottery_pool_config/index'
import { useI18n } from 'vue-i18n'
import {
lotteryPoolDisplayLabel,
lotteryPoolOptionLabel,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
import { getChannelDeptRequestParams, withChannelDeptParams } from '@/composables/useChannelDeptScope'
import { isSuperAdminUser } from '@/utils/channelLayout'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
@@ -222,16 +245,18 @@
/** 新增时密码必填,编辑时选填 */
const passwordRules = computed(() =>
props.dialogType === 'add' ? [{ required: true, message: '密码必需填写', trigger: 'blur' }] : []
props.dialogType === 'add'
? [{ required: true, message: t('page.form.rulePasswordRequired'), trigger: 'blur' }]
: []
)
const rules = reactive<FormRules>({
username: [{ required: true, message: '用户名必需填写', trigger: 'blur' }],
name: [{ required: true, message: '昵称必需填写', trigger: 'blur' }],
phone: [{ required: true, message: '手机号必需填写', trigger: 'blur' }],
status: [{ required: true, message: '状态必需填写', trigger: 'blur' }],
coin: [{ required: true, message: '平台币必需填写', trigger: 'blur' }]
})
const rules = computed<FormRules>(() => ({
username: [{ required: true, message: t('page.form.ruleUsernameRequired'), trigger: 'blur' }],
name: [{ required: true, message: t('page.form.ruleNicknameRequired'), trigger: 'blur' }],
phone: [{ required: true, message: t('page.form.rulePhoneRequired'), trigger: 'blur' }],
status: [{ required: true, message: t('page.form.ruleStatusRequired'), trigger: 'blur' }],
coin: [{ required: true, message: t('page.form.ruleCoinRequired'), trigger: 'blur' }]
}))
const initialFormData = {
id: null as number | null,
@@ -255,23 +280,53 @@
const formData = reactive({ ...initialFormData })
/** 彩金池配置下拉选项DiceLotteryConfig id、name */
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
/** 彩金池选项加载中 */
const lotteryConfigLoading = ref(false)
/** 后台管理员下拉选项SystemUser */
const systemUserOptions = ref<
Array<{ id: number; username: string; realname: string; label: string }>
>([])
/** 超管:按渠道分组的管理员树 */
const systemUserTreeOptions = ref<
Array<{
id: number | string
label: string
disabled?: boolean
children?: Array<{ id: number; username: string; realname: string; label: string }>
}>
>([])
const useAdminTreeSelect = computed(() => isSuperAdminUser())
const systemUserTreeProps = {
label: 'label',
value: 'id',
children: 'children',
disabled: 'disabled'
}
/** 管理员选项加载中 */
const systemUserOptionsLoading = ref(false)
/** 当前选中的 DiceLotteryConfig 完整数据(用于展示) */
const currentLotteryConfig = ref<Record<string, any> | null>(null)
const currentLotteryConfig = ref<LotteryPoolConfigOption | null>(null)
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object'
}
function extractReadPayload(res: unknown): Record<string, unknown> | null {
if (!isRecord(res)) {
return null
}
if (isRecord(res.data)) {
return res.data
}
return res
}
function lotteryConfigTypeText(name: unknown): string {
const n = String(name ?? '')
if (n === 'default') return '默认'
if (n === 'killScore') return '杀分'
if (n === 'up') return '上分'
if (n === 'default') return t('page.form.configTypeDefault')
if (n === 'killScore') return t('page.form.configTypeKillScore')
if (n === 'up') return t('page.form.configTypeUp')
return n || '-'
}
@@ -281,6 +336,15 @@
return v == null || v === 0
}
/** 将彩金池配置的 T1T5 写入表单(绑定彩金池时展示与提交均以池为准) */
function applyPoolWeightsToForm(cfg: LotteryPoolConfigOption) {
formData.t1_weight = Number(cfg.t1_weight ?? 0)
formData.t2_weight = Number(cfg.t2_weight ?? 0)
formData.t3_weight = Number(cfg.t3_weight ?? 0)
formData.t4_weight = Number(cfg.t4_weight ?? 0)
formData.t5_weight = Number(cfg.t5_weight ?? 0)
}
/** 根据当前 lottery_config_id 加载 DiceLotteryConfig并将五个权重写入当前 player.*_weight */
async function loadCurrentLotteryConfig() {
const id = formData.lottery_config_id
@@ -290,12 +354,11 @@
}
try {
const res = await lotteryConfigApi.read(id)
const row = (res as any)?.data ?? (res as any)
if (row && typeof row === 'object') {
const payload = extractReadPayload(res)
if (payload) {
const row = parseLotteryPoolConfigOption(payload)
currentLotteryConfig.value = row
WEIGHT_FIELDS.forEach((key) => {
;(formData as any)[key] = Number(row[key] ?? 0)
})
applyPoolWeightsToForm(row)
} else {
currentLotteryConfig.value = null
}
@@ -304,6 +367,15 @@
}
}
watch(
() => currentLotteryConfig.value,
(cfg) => {
if (cfg && !isLotteryConfigEmpty()) {
applyPoolWeightsToForm(cfg)
}
}
)
watch(
() => props.modelValue,
(newVal) => {
@@ -319,11 +391,10 @@
}
try {
const res = await lotteryConfigApi.read(lotteryConfigId)
const row = (res as any)?.data ?? (res as any)
if (row && typeof row === 'object') {
WEIGHT_FIELDS.forEach((key) => {
;(formData as any)[key] = Number(row[key] ?? 0)
})
const payload = extractReadPayload(res)
if (payload) {
const row = parseLotteryPoolConfigOption(payload)
applyPoolWeightsToForm(row)
currentLotteryConfig.value = row
} else {
currentLotteryConfig.value = null
@@ -335,12 +406,43 @@
}
/** 加载后台管理员选项 */
function normalizeAdminTreeLabels(
nodes: Array<{
id: number | string
label: string
disabled?: boolean
children?: Array<{ id: number; username: string; realname: string; label: string }>
}>
) {
return nodes.map((node) => {
const item = { ...node }
if (item.label === '__unassigned__') {
item.label = t('page.form.unassignedChannel')
}
if (item.children?.length) {
item.children = item.children.map((child) => ({
...child,
label: child.label || child.username || `#${child.id}`
}))
}
return item
})
}
async function loadSystemUserOptions() {
systemUserOptionsLoading.value = true
try {
systemUserOptions.value = await api.getSystemUserOptions()
if (useAdminTreeSelect.value) {
const tree = await api.getSystemUserTreeOptions(getChannelDeptRequestParams())
systemUserTreeOptions.value = normalizeAdminTreeLabels(tree)
systemUserOptions.value = []
} else {
systemUserOptions.value = await api.getSystemUserOptions(getChannelDeptRequestParams())
systemUserTreeOptions.value = []
}
} catch {
systemUserOptions.value = []
systemUserTreeOptions.value = []
} finally {
systemUserOptionsLoading.value = false
}
@@ -363,7 +465,7 @@
async function loadLotteryConfigOptions() {
lotteryConfigLoading.value = true
try {
lotteryConfigOptions.value = await api.getLotteryConfigOptions()
lotteryConfigOptions.value = await api.getLotteryConfigOptions(getChannelDeptRequestParams())
} catch {
lotteryConfigOptions.value = []
} finally {
@@ -376,6 +478,7 @@
'status',
'coin',
'lottery_config_id',
'admin_id',
't1_weight',
't2_weight',
't3_weight',
@@ -397,7 +500,7 @@
;(formData as any)[key] = val != null ? Number(val) || null : null
} else if (key === 'lottery_config_id' || key === 'admin_id') {
const num = Number(val)
;(formData as any)[key] = val != null && !Number.isNaN(num) && num !== 0 ? num : null
;(formData as any)[key] = val != null && !Number.isNaN(num) && num > 0 ? num : null
} else {
;(formData as any)[key] = Number(val) || 0
}
@@ -421,18 +524,21 @@
ElMessage.warning(t('page.form.ruleWeightsSumMustBe100'))
return
}
if (!isLotteryConfigEmpty() && currentLotteryConfig.value) {
applyPoolWeightsToForm(currentLotteryConfig.value)
}
const payload = { ...formData }
if (isLotteryConfigEmpty()) {
;(payload as any).lottery_config_id = null
payload.lottery_config_id = null
}
if (props.dialogType === 'edit' && !payload.password) {
delete (payload as any).password
}
if (props.dialogType === 'add') {
await api.save(payload)
await api.save(withChannelDeptParams(payload))
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(payload)
await api.update(withChannelDeptParams(payload))
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')

View File

@@ -54,17 +54,31 @@
<el-option
v-for="item in lotteryConfigOptions"
:key="item.id"
:label="item.name"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(8)">
<el-form-item :label="$t('page.search.createTime')" prop="create_time">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
:range-separator="$t('table.searchBar.rangeSeparator')"
:start-placeholder="$t('table.searchBar.startTime')"
:end-placeholder="$t('table.searchBar.endTime')"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
import api from '../../../api/player/index'
import { lotteryPoolOptionLabel, type LotteryPoolOption } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
interface Props {
modelValue: Record<string, any>
@@ -77,7 +91,7 @@
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
/** 从玩家控制器获取 DiceLotteryPoolConfig id/name 列表,用于 lottery_config_id 筛选 */
onMounted(async () => {

View File

@@ -1,13 +1,13 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? $t('page.form.dialogTitleAdd') : $t('page.form.dialogTitleEdit')"
:title="$t('page.form.dialogTitleEdit')"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form ref="formRef" :model="formData" label-width="120px">
<el-form-item :label="$t('page.form.player')" prop="player_id">
<el-select
v-model="formData.player_id"
@@ -15,7 +15,7 @@
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
>
<el-option
v-for="item in playerOptions"
@@ -30,7 +30,7 @@
v-model="formData.use_coins"
:placeholder="$t('page.form.placeholderUseCoins')"
:min="0"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.paidDrawCount')" prop="paid_ticket_count">
@@ -38,8 +38,7 @@
v-model="formData.paid_ticket_count"
:placeholder="$t('page.form.placeholderPaidDrawCount')"
:min="0"
@change="onTicketCountChange"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.freeDrawCount')" prop="free_ticket_count">
@@ -47,8 +46,7 @@
v-model="formData.free_ticket_count"
:placeholder="$t('page.form.placeholderFreeDrawCount')"
:min="0"
@change="onTicketCountChange"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.totalDrawCount')" prop="total_ticket_count">
@@ -68,24 +66,20 @@
maxlength="500"
show-word-limit
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleSubmit">{{ $t('table.form.submit') }}</el-button>
<el-button @click="handleClose">{{ $t('form.close') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/player_ticket_record/index'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
const { t } = useI18n()
import { getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
import type { FormInstance } from 'element-plus'
interface Props {
modelValue: boolean
@@ -100,7 +94,7 @@
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
dialogType: 'edit',
data: undefined
})
@@ -116,17 +110,6 @@
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
player_id: [{ required: true, message: '请选择玩家', trigger: 'change' }],
use_coins: [{ required: true, message: '消耗硬币必需填写', trigger: 'blur' }],
paid_ticket_count: [{ required: true, message: '购买抽奖次数必需填写', trigger: 'blur' }],
free_ticket_count: [{ required: true, message: '赠送抽奖次数必需填写', trigger: 'blur' }],
remark: [{ required: true, message: '备注必需填写', trigger: 'blur' }]
})
/** 玩家下拉选项id、username */
const playerOptions = ref<Array<{ id: number; username: string }>>([])
@@ -137,10 +120,6 @@
return paid + free
})
function onTicketCountChange() {
formData.total_ticket_count = totalTicketCountComputed.value
}
/**
* 初始数据
*/
@@ -160,7 +139,7 @@
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单并拉取玩家选项(与 player_wallet_record 一致)
* 监听弹窗打开,初始化表单并拉取玩家选项
*/
watch(
() => props.modelValue,
@@ -168,7 +147,7 @@
if (open) {
initPage()
try {
const list = await api.getPlayerOptions()
const list = await api.getPlayerOptions(getChannelDeptRequestParams())
const arr = Array.isArray(list) ? list : (list as any)?.data
playerOptions.value = Array.isArray(arr)
? (arr as Array<{ id: number; username: string }>)
@@ -181,7 +160,7 @@
)
/**
* 初始化页面数据(仅重置表单、回填编辑数据,不在此处请求玩家列表)
* 初始化页面数据
*/
const initPage = async () => {
Object.assign(formData, { ...initialFormData })
@@ -192,7 +171,7 @@
}
/**
* 初始化表单数据
* 回填表单数据
*/
const initForm = () => {
if (!props.data) return
@@ -214,34 +193,10 @@
}
/**
* 关闭弹窗并重置表单
* 关闭弹窗
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单total_ticket_count 由 paid_ticket_count + free_ticket_count 自动求和,提交前写入)
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
formData.total_ticket_count = totalTicketCountComputed.value
await formRef.value.validate()
if (props.dialogType === 'add') {
const rest = { ...formData } as Record<string, unknown>
delete rest.id
await api.save(rest)
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(formData)
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -1,12 +1,26 @@
<template>
<div class="art-full-height">
<QuickDateRangeBar v-model="activeDatePreset" @select="handleQuickDateSelect" />
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<TableSearch v-model="searchForm" @search="handleSearch" @reset="handleResetSearch" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<span v-if="totalCoinChange !== null" class="table-summary-inline">
{{ $t('page.toolbar.coinChangeSummary') }}<strong :class="coinSummaryClass">{{
formatMoney2(totalCoinChange)
}}</strong>
<template v-if="totalCoinInflow !== null && totalCoinOutflow !== null">
{{ $t('page.toolbar.coinInflow') }} <strong class="coin-summary-positive">{{
formatMoney2(totalCoinInflow)
}}</strong>
/ {{ $t('page.toolbar.coinOutflow') }} <strong class="coin-summary-negative">{{
formatMoney2(totalCoinOutflow)
}}</strong>
</template>
</span>
<!-- <ElSpace wrap>-->
<!-- <ElButton-->
<!-- v-permission="'dice:player_wallet_record:index:save'"-->
@@ -82,33 +96,123 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useTable } from '@/hooks/core/useTable'
import { defaultResponseAdapter } from '@/utils/table/tableUtils'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/player_wallet_record/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import QuickDateRangeBar from '@/views/plugin/dice/components/QuickDateRangeBar.vue'
import {
detectPresetFromRange,
type DatePresetKey
} from '@/views/plugin/dice/utils/dateRangePresets'
import { getRecordRouteInit, useRecordRouteSync } from '@/views/plugin/dice/composables/useRecordRouteQuery'
const route = useRoute()
const routeInit = getRecordRouteInit(route.query, true)
const activeDatePreset = ref<DatePresetKey | null>(routeInit.activePreset)
// 搜索表单
const searchForm = ref({
type: undefined,
type: undefined as number | undefined,
username: undefined,
coin_min: undefined,
coin_max: undefined,
create_time: undefined as [string, string] | undefined
create_time: routeInit.create_time
})
if (routeInit.type !== undefined) {
searchForm.value.type = routeInit.type
}
/** 当前筛选条件下平台币净变化、流入、流出 */
const totalCoinChange = ref<number | null>(null)
const totalCoinInflow = ref<number | null>(null)
const totalCoinOutflow = ref<number | null>(null)
const applyCoinSummary = (res: Record<string, unknown> | undefined) => {
const summary = res?.total_coin_change
totalCoinChange.value =
summary !== undefined && summary !== null && summary !== '' ? Number(summary) : null
const inflow = res?.total_coin_inflow
totalCoinInflow.value =
inflow !== undefined && inflow !== null && inflow !== '' ? Number(inflow) : null
const outflow = res?.total_coin_outflow
totalCoinOutflow.value =
outflow !== undefined && outflow !== null && outflow !== '' ? Number(outflow) : null
}
const coinSummaryClass = computed(() => {
if (totalCoinChange.value === null) return ''
if (totalCoinChange.value > 0) return 'coin-summary-positive'
if (totalCoinChange.value < 0) return 'coin-summary-negative'
return ''
})
// 搜索处理:将 create_time 区间转为 create_time_min / create_time_max
const handleSearch = (params: Record<string, any>) => {
const WALLET_SEARCH_KEYS = [
'type',
'username',
'coin_min',
'coin_max',
'create_time_min',
'create_time_max'
] as const
let summaryRequestSeq = 0
const listApi = async (params: Record<string, any>) => {
const reqId = ++summaryRequestSeq
const res = await api.list(params)
if (reqId === summaryRequestSeq) {
applyCoinSummary(res as Record<string, unknown> | undefined)
}
return res
}
const applySearchParams = (params: Record<string, any>) => {
const p = { ...params }
if (Array.isArray(p.create_time) && p.create_time.length === 2) {
p.create_time_min = p.create_time[0]
p.create_time_max = p.create_time[1]
}
delete p.create_time
const paramsRecord = searchParams as Record<string, unknown>
WALLET_SEARCH_KEYS.forEach((key) => {
delete paramsRecord[key]
})
Object.assign(searchParams, p)
}
// 搜索处理:将 create_time 区间转为 create_time_min / create_time_max
const handleSearch = (params: Record<string, any>) => {
applySearchParams(params)
getData()
}
const handleQuickDateSelect = (range: [string, string], preset: DatePresetKey) => {
searchForm.value.create_time = range
activeDatePreset.value = preset
handleSearch({ ...searchForm.value })
}
const handleResetSearch = () => {
activeDatePreset.value = null
searchForm.value.create_time = undefined
resetSearchParams()
}
watch(
() => searchForm.value.create_time,
(range) => {
if (Array.isArray(range) && range.length === 2) {
activeDatePreset.value = detectPresetFromRange([range[0], range[1]])
} else {
activeDatePreset.value = null
}
}
)
const { t } = useI18n()
// 类型展示0=充值 1=提现 2=购买抽奖次数 3=管理员加点 4=管理员扣点 5=抽奖
const typeFormatter = (row: Record<string, unknown>) => {
@@ -169,8 +273,10 @@
refreshData
} = useTable({
core: {
apiFn: api.list,
apiFn: listApi,
apiParams: { limit: 100 },
excludeParams: ['create_time'],
immediate: !routeInit.hasFilter,
columnsFactory: () => [
{ type: 'selection', align: 'center' },
{ prop: 'id', label: 'page.table.id', width: 80, align: 'center' },
@@ -231,6 +337,28 @@
useSlot: true
}
]
},
hooks: {
onSuccess(_data, response) {
applyCoinSummary(response as unknown as Record<string, unknown>)
}
},
transform: {
responseAdapter(response) {
const raw = (response ?? {}) as Record<string, unknown>
const base = defaultResponseAdapter(response)
const extra = base as Record<string, unknown>
if (raw.total_coin_change !== undefined && raw.total_coin_change !== null) {
extra.total_coin_change = raw.total_coin_change
}
if (raw.total_coin_inflow !== undefined && raw.total_coin_inflow !== null) {
extra.total_coin_inflow = raw.total_coin_inflow
}
if (raw.total_coin_outflow !== undefined && raw.total_coin_outflow !== null) {
extra.total_coin_outflow = raw.total_coin_outflow
}
return base
}
}
})
@@ -245,9 +373,35 @@
handleSelectionChange
// selectedRows
} = useSaiAdmin()
useRecordRouteSync({
searchForm,
activeDatePreset,
onSearch: handleSearch,
withType: true
})
</script>
<style lang="scss" scoped>
.table-summary-inline {
margin-right: 12px;
font-size: 14px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
.table-summary-inline strong {
font-weight: 600;
}
.coin-summary-positive {
color: var(--el-color-success);
}
.coin-summary-negative {
color: var(--el-color-danger);
}
/* 类型 tag 放大一倍large + scale */
:deep(.wallet-record-type-tag) {
transform: scale(0.8);

View File

@@ -1,13 +1,13 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? $t('page.form.dialogTitleAdd') : $t('page.form.dialogTitleEdit')"
:title="$t('page.form.dialogTitleEdit')"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form ref="formRef" :model="formData" label-width="120px">
<el-form-item :label="$t('page.form.user')" prop="player_id">
<el-select
v-model="formData.player_id"
@@ -15,8 +15,7 @@
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
@change="onPlayerChange"
disabled
>
<el-option
v-for="item in playerOptions"
@@ -32,7 +31,7 @@
:placeholder="$t('page.form.placeholderType')"
clearable
style="width: 100%"
:disabled="dialogType === 'edit'"
disabled
>
<el-option :label="$t('page.form.typeRecharge')" :value="0" />
<el-option :label="$t('page.form.typeWithdraw')" :value="1" />
@@ -48,8 +47,7 @@
:precision="2"
:step="1"
style="width: 100%"
@change="onCoinChange"
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
<el-form-item :label="$t('page.form.walletBefore')" prop="wallet_before">
@@ -78,24 +76,20 @@
:placeholder="$t('page.form.placeholderRemark')"
maxlength="500"
show-word-limit
:disabled="dialogType === 'edit'"
disabled
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleSubmit">{{ $t('table.form.submit') }}</el-button>
<el-button @click="handleClose">{{ $t('form.close') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/player_wallet_record/index'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
const { t } = useI18n()
import { getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
import type { FormInstance } from 'element-plus'
interface Props {
modelValue: boolean
@@ -110,7 +104,7 @@
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
dialogType: 'edit',
data: undefined
})
@@ -126,12 +120,6 @@
set: (value) => emit('update:modelValue', value)
})
const rules = reactive<FormRules>({
player_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
coin: [{ required: true, message: '平台币变化必填', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }]
})
const initialFormData: {
id: number | null
player_id: number | null
@@ -152,43 +140,13 @@
const formData = reactive({ ...initialFormData })
/** 选择用户后拉取当前平台币作为钱包操作前 */
async function onPlayerChange(playerId: number | null) {
if (playerId == null) {
formData.wallet_before = 0
calcWalletAfter()
return
}
try {
const res = await api.getPlayerWalletBefore(playerId)
const before = res?.wallet_before ?? 0
formData.wallet_before = Number(before)
calcWalletAfter()
} catch {
formData.wallet_before = 0
calcWalletAfter()
}
}
/** 平台币变化时重算钱包操作后 */
function onCoinChange() {
calcWalletAfter()
}
/** 钱包操作后 = 钱包操作前 + 平台币变化 */
function calcWalletAfter() {
const before = Number(formData.wallet_before) || 0
const coin = Number(formData.coin) || 0
formData.wallet_after = Number((before + coin).toFixed(2))
}
watch(
() => props.modelValue,
async (open) => {
if (open) {
initPage()
try {
const list = await api.getPlayerOptions()
const list = await api.getPlayerOptions(getChannelDeptRequestParams())
playerOptions.value = Array.isArray(list) ? list : []
} catch {
playerOptions.value = []
@@ -229,24 +187,4 @@
visible.value = false
formRef.value?.resetFields()
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
calcWalletAfter()
const payload = { ...formData }
if (props.dialogType === 'add') {
await api.save(payload)
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(payload)
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -46,17 +46,25 @@
</ElCard>
<WeightRatioDialog v-model="weightRatioVisible" @success="refreshData" />
<WeightTestDialog v-model="weightTestVisible" @success="refreshData" />
<WeightTestDialog
v-model="weightTestVisible"
:channel-dept-id="channelDeptId"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useInjectedChannelDept } from '@/composables/useChannelDeptScope'
import api from '../../api/reward/index'
import TableSearch from './modules/table-search.vue'
import WeightRatioDialog from './modules/weight-ratio-dialog.vue'
import WeightTestDialog from './modules/weight-test-dialog.vue'
const channelScope = useInjectedChannelDept()
const channelDeptId = computed(() => channelScope?.selectedDeptId.value)
const currentDirection = ref<0 | 1>(0)
const weightRatioVisible = ref(false)
const weightTestVisible = ref(false)
@@ -70,13 +78,6 @@
return api.list({ ...params, direction: currentDirection.value })
}
function formatMoney2(val: unknown): string {
if (val === '' || val === null || val === undefined) return '-'
const n = typeof val === 'number' ? val : Number(val)
if (!Number.isFinite(n)) return '-'
return n.toFixed(2)
}
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, { ...params, direction: currentDirection.value })
getData()
@@ -124,13 +125,6 @@
align: 'center',
showOverflowTooltip: true
},
{
prop: 'real_ev',
label: 'page.table.realEv',
width: 110,
align: 'center',
formatter: (row: Record<string, any>) => formatMoney2(row?.real_ev)
},
{ prop: 'remark', label: 'page.table.remark', minWidth: 80, align: 'center', showOverflowTooltip: true },
{ prop: 'weight', label: 'page.table.weight', width: 110, align: 'center' }
]

View File

@@ -15,7 +15,7 @@
<el-tab-pane v-for="t in tierKeys" :key="t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">{{ $t('page.weightShared.emptyTier') }}</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<div class="chart-wrap">
<div class="chart-row">
<ArtBarChart
:x-axis-name="$t('page.weightShared.xAxisEndIndex')"
@@ -31,7 +31,7 @@
/>
</div>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
<div class="weight-sum">
{{
$t('page.weightShared.sumLineDual', {
cw: getTierSum(t, 'clockwise'),
@@ -39,7 +39,6 @@
})
}}
</div>
<div class="weight-sum weight-sum-t4t5" v-else>{{ $t('page.weightShared.t4t5NoteSingle') }}</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column
:label="$t('page.weightShared.colEndIndexId')"
@@ -54,17 +53,6 @@
width="80"
align="center"
/>
<el-table-column
:label="$t('page.weightShared.colRealEv')"
prop="real_ev"
width="90"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span>{{ formatMoney2(row?.real_ev) }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('page.weightShared.colUiText')"
prop="ui_text"
@@ -89,7 +77,6 @@
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
@@ -106,7 +93,7 @@
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeight(row, 'clockwise') <= 1"
:disabled="getItemWeight(row, 'clockwise') <= 1"
@click="
setItemWeightByRow(
t,
@@ -122,7 +109,6 @@
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@@ -140,7 +126,7 @@
type="primary"
link
:disabled="
isWeightDisabled(row, t) || getItemWeight(row, 'clockwise') >= 10000
getItemWeight(row, 'clockwise') >= 10000
"
@click="
setItemWeightByRow(
@@ -166,7 +152,6 @@
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
@@ -184,7 +169,7 @@
type="primary"
link
:disabled="
isWeightDisabled(row, t) || getItemWeight(row, 'counterclockwise') <= 1
getItemWeight(row, 'counterclockwise') <= 1
"
@click="
setItemWeightByRow(
@@ -201,7 +186,6 @@
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@@ -219,7 +203,6 @@
type="primary"
link
:disabled="
isWeightDisabled(row, t) ||
getItemWeight(row, 'counterclockwise') >= 10000
"
@click="
@@ -262,6 +245,7 @@
}
import { useI18n } from 'vue-i18n'
import { getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
const { t } = useI18n()
@@ -360,11 +344,6 @@
else row[key] = v
}
function isWeightDisabled(row: WeightRow, tier: string): boolean {
if (tier === 'T4' || tier === 'T5') return true
return false
}
function normalizeWeightValue(v: unknown): number {
const num = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(num)) return 1
@@ -447,7 +426,7 @@
function loadData() {
loading.value = true
api
.weightRatioListWithDirection()
.weightRatioListWithDirection(getChannelDeptRequestParams())
.then((res: any) => {
grouped.value = parsePayload(res)
})
@@ -464,8 +443,8 @@
const items: Array<{ id: number; weight: number }> = []
for (const t of TIER_KEYS) {
for (const row of getTierItems(t)) {
const w0 = isWeightDisabled(row, t) ? 10000 : getItemWeight(row, 'clockwise')
const w1 = isWeightDisabled(row, t) ? 10000 : getItemWeight(row, 'counterclockwise')
const w0 = getItemWeight(row, 'clockwise')
const w1 = getItemWeight(row, 'counterclockwise')
const rid0 = row.reward_id_clockwise != null ? row.reward_id_clockwise : 0
const rid1 = row.reward_id_counterclockwise != null ? row.reward_id_counterclockwise : 0
if (rid0 > 0) items.push({ id: rid0, weight: w0 })
@@ -483,7 +462,7 @@
}
submitting.value = true
api
.batchUpdateWeights(items)
.batchUpdateWeights(items, getChannelDeptRequestParams())
.then(() => {
ElMessage.success(t('page.weightShared.saveSuccess'))
emit('success')
@@ -529,9 +508,6 @@
margin-bottom: 12px;
font-size: 13px;
}
.weight-sum-t4t5 {
color: var(--el-text-color-secondary);
}
.global-tip {
margin-bottom: 12px;
padding: 10px 12px;

View File

@@ -18,7 +18,7 @@
<el-tab-pane v-for="t in tierKeys" :key="'cw-' + t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">{{ $t('page.weightShared.emptyTier') }}</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<div class="chart-wrap">
<ArtBarChart
:key="'cw-' + activeDirection + '-' + t"
:x-axis-name="$t('page.weightShared.xAxisGridNumber')"
@@ -27,12 +27,11 @@
height="180px"
/>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
<div class="weight-sum">
{{
$t('page.weightShared.sumLineSingle', { sum: getTierSumForCurrentDirection(t) })
}}
</div>
<div class="weight-sum weight-sum-t4t5" v-else>{{ $t('page.weightShared.t4t5NoteSingle') }}</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column
:label="$t('page.weightShared.colGridNumber')"
@@ -48,17 +47,6 @@
align="center"
show-overflow-tooltip
/>
<el-table-column
:label="$t('page.weightShared.colRealEv')"
prop="real_ev"
width="90"
align="center"
show-overflow-tooltip
>
<template #default="{ row }">
<span>{{ formatMoney2(row?.real_ev) }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('page.weightShared.colUiText')"
prop="ui_text"
@@ -87,7 +75,6 @@
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
@@ -103,9 +90,7 @@
<el-button
type="primary"
link
:disabled="
isWeightDisabled(row, t) || getItemWeightForCurrentDirection(row) <= 1
"
:disabled="getItemWeightForCurrentDirection(row) <= 1"
@click="
setItemWeightForCurrentDirection(
t,
@@ -120,7 +105,6 @@
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@@ -136,10 +120,7 @@
<el-button
type="primary"
link
:disabled="
isWeightDisabled(row, t) ||
getItemWeightForCurrentDirection(row) >= 10000
"
:disabled="getItemWeightForCurrentDirection(row) >= 10000"
@click="
setItemWeightForCurrentDirection(
t,
@@ -163,7 +144,7 @@
<el-tab-pane v-for="t in tierKeys" :key="'ccw-' + t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">{{ $t('page.weightShared.emptyTier') }}</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<div class="chart-wrap">
<ArtBarChart
:key="'ccw-' + activeDirection + '-' + t"
:x-axis-name="$t('page.weightShared.xAxisGridNumber')"
@@ -172,12 +153,11 @@
height="180px"
/>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
<div class="weight-sum">
{{
$t('page.weightShared.sumLineSingle', { sum: getTierSumForCurrentDirection(t) })
}}
</div>
<div class="weight-sum weight-sum-t4t5" v-else>{{ $t('page.weightShared.t4t5NoteSingle') }}</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column
:label="$t('page.weightShared.colGridNumber')"
@@ -193,13 +173,6 @@
align="center"
show-overflow-tooltip
/>
<el-table-column
:label="$t('page.weightShared.colRealEv')"
prop="real_ev"
width="90"
align="center"
show-overflow-tooltip
/>
<el-table-column
:label="$t('page.weightShared.colUiText')"
prop="ui_text"
@@ -228,7 +201,6 @@
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
@@ -244,9 +216,7 @@
<el-button
type="primary"
link
:disabled="
isWeightDisabled(row, t) || getItemWeightForCurrentDirection(row) <= 1
"
:disabled="getItemWeightForCurrentDirection(row) <= 1"
@click="
setItemWeightForCurrentDirection(
t,
@@ -261,7 +231,6 @@
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@@ -277,10 +246,7 @@
<el-button
type="primary"
link
:disabled="
isWeightDisabled(row, t) ||
getItemWeightForCurrentDirection(row) >= 10000
"
:disabled="getItemWeightForCurrentDirection(row) >= 10000"
@click="
setItemWeightForCurrentDirection(
t,
@@ -319,6 +285,7 @@
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
function formatMoney2(val: unknown): string {
if (val === '' || val === null || val === undefined) return '-'
const n = typeof val === 'number' ? val : Number(val)
@@ -432,11 +399,6 @@
grouped.value[tier][dir] = [...list]
}
function isWeightDisabled(row: WeightRow, tier: string): boolean {
if (tier === 'T4' || tier === 'T5') return true
return false
}
function normalizeWeightValue(v: unknown): number {
const num = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(num)) return 1
@@ -482,7 +444,7 @@
function loadData() {
loading.value = true
api
.weightRatioListWithDirection()
.weightRatioListWithDirection(getChannelDeptRequestParams())
.then((res: any) => {
grouped.value = parsePayload(res)
})
@@ -505,7 +467,7 @@
for (const row of list) {
const rid = row.reward_id != null ? Number(row.reward_id) : 0
if (rid <= 0) continue
const w = isWeightDisabled(row, t) ? 10000 : toWeightPrecision(row.weight ?? 1)
const w = toWeightPrecision(row.weight ?? 1)
items.push({ id: rid, reward_id: rid, weight: w })
}
}
@@ -521,7 +483,7 @@
}
submitting.value = true
api
.batchUpdateWeights(items)
.batchUpdateWeights(items, getChannelDeptRequestParams())
.then(() => {
ElMessage.success(t('page.weightShared.saveSuccess'))
emit('success')
@@ -566,9 +528,6 @@
margin-bottom: 12px;
font-size: 13px;
}
.weight-sum-t4t5 {
color: var(--el-text-color-secondary);
}
.global-tip {
margin-bottom: 12px;
padding: 10px 12px;

View File

@@ -2,146 +2,211 @@
<ElDialog
v-model="visible"
:title="$t('page.weightTest.title')"
width="560px"
width="960px"
top="4vh"
class="weight-test-dialog"
:close-on-click-modal="false"
destroy-on-close
@close="onClose"
>
<ElAlert type="info" :closable="false" show-icon class="weight-test-tip">
<template #title>{{ $t('page.weightTest.alertTitle') }}</template>
{{ $t('page.weightTest.alertBody') }}
</ElAlert>
<ElAlert type="warning" :closable="false" show-icon class="weight-test-tip chain-tip">
{{ $t('page.weightTest.chainModeHint') }}
</ElAlert>
<ElAlert type="info" :closable="false" show-icon class="weight-test-tip chain-tip">
{{ $t('page.weightTest.killModeHint') }}
</ElAlert>
<ElForm :model="form" label-width="140px">
<ElFormItem :label="$t('page.weightTest.labelAnte')" prop="ante" required>
<ElInputNumber v-model="form.ante" :min="1" :step="1" style="width: 100%" />
</ElFormItem>
<ElFormItem :label="$t('page.weightTest.labelKillModeEnabled')" prop="kill_mode_enabled">
<ElSwitch v-model="form.kill_mode_enabled" />
</ElFormItem>
<ElFormItem :label="$t('page.weightTest.labelTestSafetyLine')" prop="test_safety_line">
<ElInputNumber
v-model="form.test_safety_line"
:min="0"
:step="100"
:disabled="!form.kill_mode_enabled"
style="width: 100%"
/>
</ElFormItem>
<div class="weight-test-dialog-body">
<ElAlert type="info" :closable="false" show-icon class="weight-test-tip compact-tip">
<div class="tip-lines">
<div>{{ $t('page.weightTest.alertBody') }}</div>
<div>{{ $t('page.weightTest.chainModeHint') }}</div>
<div>{{ $t('page.weightTest.killModeHint') }}</div>
</div>
</ElAlert>
<div class="section-title">{{ $t('page.weightTest.sectionPaid') }}</div>
<ElFormItem
:label="$t('page.weightTest.labelLotteryTypePaid')"
prop="paid_lottery_config_id"
>
<ElSelect
v-model="form.paid_lottery_config_id"
:placeholder="$t('page.weightTest.placeholderPaidPool')"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in paidLotteryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<template v-if="form.paid_lottery_config_id == null">
<div class="tier-label">{{ $t('page.weightTest.tierProbHint') }}</div>
<ElRow :gutter="12" class="tier-row">
<ElCol v-for="t in tierKeys" :key="'paid-' + t" :span="8">
<div class="tier-field">
<label class="tier-field-label">{{
$t('page.weightTest.tierFieldLabel', { tier: t })
}}</label>
<input
type="number"
:value="getPaidTier(t)"
min="0"
max="100"
placeholder="0"
class="tier-input"
@input="setPaidTier(t, $event)"
/>
</div>
<ElForm :model="form" label-width="108px" class="weight-test-form">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem :label="$t('page.weightTest.labelAnte')" prop="ante_config_id" required>
<ElSelect
v-model="form.ante_config_id"
:placeholder="$t('page.weightTest.placeholderAnte')"
filterable
style="width: 100%"
@change="syncAnteFromSelect"
>
<ElOption
:label="$t('page.weightTest.anteRandomOption')"
:value="RANDOM_ANTE_CONFIG_ID"
/>
<ElOption
v-for="item in anteOptions"
:key="item.id"
:label="anteOptionLabel(item)"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
<div v-if="paidTierSum > 100" class="tier-error">{{
$t('page.weightTest.tierSumError', { sum: paidTierSum })
}}</div>
</template>
<ElFormItem :label="$t('page.weightTest.labelCwCount')" prop="paid_s_count" required>
<ElSelect
v-model="form.paid_s_count"
:placeholder="$t('page.weightTest.placeholderSelect')"
style="width: 100%"
>
<ElOption v-for="c in countOptions" :key="c" :label="String(c)" :value="c" />
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('page.weightTest.labelCcwCount')" prop="paid_n_count" required>
<ElSelect
v-model="form.paid_n_count"
:placeholder="$t('page.weightTest.placeholderSelect')"
style="width: 100%"
>
<ElOption v-for="c in countOptions" :key="c" :label="String(c)" :value="c" />
</ElSelect>
</ElFormItem>
<div class="section-title">{{ $t('page.weightTest.sectionFreeAfterPlayAgain') }}</div>
<ElFormItem
:label="$t('page.weightTest.labelLotteryTypeFree')"
prop="free_lottery_config_id"
>
<ElSelect
v-model="form.free_lottery_config_id"
:placeholder="$t('page.weightTest.placeholderFreePool')"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in freeLotteryOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
<template v-if="form.free_lottery_config_id == null">
<div class="tier-label">{{ $t('page.weightTest.tierProbHintFreeChain') }}</div>
<ElRow :gutter="12" class="tier-row">
<ElCol v-for="t in tierKeys" :key="'free-' + t" :span="8">
<div class="tier-field">
<label class="tier-field-label">{{
$t('page.weightTest.tierFieldLabel', { tier: t })
}}</label>
<input
type="number"
:value="getFreeTier(t)"
min="0"
max="100"
placeholder="0"
class="tier-input"
@input="setFreeTier(t, $event)"
<div class="kill-mode-panel">
<div class="kill-mode-head">
<div class="kill-mode-title">{{ $t('page.weightTest.killModePanelTitle') }}</div>
<ElSwitch
v-model="form.kill_mode_enabled"
:active-text="$t('page.weightTest.killModeSwitchOn')"
:inactive-text="$t('page.weightTest.killModeSwitchOff')"
inline-prompt
/>
</div>
<div v-if="form.kill_mode_enabled" class="kill-mode-body">
<div class="kill-mode-field">
<div class="kill-mode-field-label">{{ $t('page.weightTest.labelTestSafetyLine') }}</div>
<ElInputNumber
v-model="form.test_safety_line"
:min="0"
:step="100"
controls-position="right"
class="kill-mode-field-input"
/>
</div>
<div class="kill-mode-hint">{{ $t('page.weightTest.testSafetyLineHint') }}</div>
<div v-if="defaultPoolInfo" class="kill-mode-ref">
{{ $t('page.weightTest.poolProfitRef', {
profit: defaultPoolInfo.profit_amount,
line: defaultPoolInfo.safety_line
}) }}
</div>
</div>
<div v-else class="kill-mode-off-hint">{{ $t('page.weightTest.killModeOffHint') }}</div>
</div>
<ElRow :gutter="20" class="section-row">
<ElCol :span="12">
<div class="section-title">{{ $t('page.weightTest.sectionPaid') }}</div>
<ElForm :model="form" label-position="top" class="section-form">
<ElFormItem :label="$t('page.weightTest.labelLotteryTypePaid')" prop="paid_lottery_config_id">
<ElSelect
v-model="form.paid_lottery_config_id"
:placeholder="$t('page.weightTest.placeholderPaidPool')"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in lotteryOptions"
:key="'paid-pool-' + item.id"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</ElSelect>
<div v-if="selectedPaidPool" class="pool-selected-hint">
{{ $t('page.weightTest.selectedPoolHint', { name: lotteryPoolDisplayLabel(selectedPaidPool) }) }}
</div>
<div v-if="selectedPaidPool" class="pool-weights-preview">
{{ poolTierWeightsText(selectedPaidPool) }}
</div>
</ElFormItem>
<template v-if="form.paid_lottery_config_id == null">
<div class="tier-label">{{ $t('page.weightTest.tierProbHint') }}</div>
<ElRow :gutter="8" class="tier-row">
<ElCol v-for="t in tierKeys" :key="'paid-' + t" :span="8">
<div class="tier-field">
<label class="tier-field-label">{{
$t('page.weightTest.tierFieldLabel', { tier: t })
}}</label>
<input
type="number"
:value="getPaidTier(t)"
min="0"
max="100"
placeholder="0"
class="tier-input"
@input="setPaidTier(t, $event)"
/>
</div>
</ElCol>
</ElRow>
<div v-if="paidTierSum > 100" class="tier-error">{{
$t('page.weightTest.tierSumError', { sum: paidTierSum })
}}</div>
</template>
<ElRow :gutter="12">
<ElCol :span="12">
<ElFormItem :label="$t('page.weightTest.labelCwCount')" prop="paid_s_count" required>
<ElSelect
v-model="form.paid_s_count"
:placeholder="$t('page.weightTest.placeholderSelect')"
style="width: 100%"
>
<ElOption v-for="c in countOptions" :key="c" :label="String(c)" :value="c" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem :label="$t('page.weightTest.labelCcwCount')" prop="paid_n_count" required>
<ElSelect
v-model="form.paid_n_count"
:placeholder="$t('page.weightTest.placeholderSelect')"
style="width: 100%"
>
<ElOption v-for="c in countOptions" :key="'n-' + c" :label="String(c)" :value="c" />
</ElSelect>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ElCol>
<ElCol :span="12">
<div class="section-title">{{ $t('page.weightTest.sectionFreeAfterPlayAgain') }}</div>
<ElForm :model="form" label-position="top" class="section-form">
<ElFormItem :label="$t('page.weightTest.labelLotteryTypeFree')" prop="free_lottery_config_id">
<ElSelect
v-model="form.free_lottery_config_id"
:placeholder="$t('page.weightTest.placeholderFreePool')"
clearable
filterable
style="width: 100%"
>
<ElOption
v-for="item in lotteryOptions"
:key="'free-pool-' + item.id"
:label="lotteryPoolOptionLabel(item)"
:value="item.id"
/>
</ElSelect>
<div v-if="selectedFreePool" class="pool-selected-hint">
{{ $t('page.weightTest.selectedPoolHint', { name: lotteryPoolDisplayLabel(selectedFreePool) }) }}
</div>
<div v-if="selectedFreePool" class="pool-weights-preview">
{{ poolTierWeightsText(selectedFreePool) }}
</div>
</ElFormItem>
<template v-if="form.free_lottery_config_id == null">
<div class="tier-label">{{ $t('page.weightTest.tierProbHintFreeChain') }}</div>
<ElRow :gutter="8" class="tier-row">
<ElCol v-for="t in tierKeys" :key="'free-' + t" :span="8">
<div class="tier-field">
<label class="tier-field-label">{{
$t('page.weightTest.tierFieldLabel', { tier: t })
}}</label>
<input
type="number"
:value="getFreeTier(t)"
min="0"
max="100"
placeholder="0"
class="tier-input"
@input="setFreeTier(t, $event)"
/>
</div>
</ElCol>
</ElRow>
<div v-if="freeTierSum > 100" class="tier-error">{{
$t('page.weightTest.tierSumError', { sum: freeTierSum })
}}</div>
</template>
</ElForm>
</ElCol>
</ElRow>
<div v-if="freeTierSum > 100" class="tier-error">{{
$t('page.weightTest.tierSumError', { sum: freeTierSum })
}}</div>
</template>
</ElForm>
</ElForm>
</div>
<template #footer>
<ElButton
@@ -160,11 +225,30 @@
<script setup lang="ts">
import api from '../../../api/reward/index'
import lotteryPoolApi from '../../../api/lottery_pool_config/index'
import anteConfigApi from '../../../api/ante_config/index'
import lotteryPoolApi, { type LotteryPoolConfigOption } from '../../../api/lottery_pool_config/index'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import {
getChannelDeptRequestParams,
useInjectedChannelDept,
withChannelDeptParams
} from '@/composables/useChannelDeptScope'
import {
lotteryPoolDisplayLabel,
lotteryPoolOptionLabel
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
/** 底注下拉「随机」选项值(非真实 ante_config.id */
const RANDOM_ANTE_CONFIG_ID = -1
const props = defineProps<{
/** 父页面渠道栏选中值(弹窗 teleport 后 inject 可能失效) */
channelDeptId?: number
}>()
const { t } = useI18n()
const channelScope = useInjectedChannelDept()
const countOptions = [0, 100, 500, 1000, 5000]
const tierKeys = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
@@ -172,8 +256,13 @@
const visible = defineModel<boolean>({ default: false })
const emit = defineEmits<{ (e: 'success'): void }>()
const anteOptions = ref<
Array<{ id: number; name: string; title: string; mult: number; is_default: number }>
>([])
const form = reactive({
ante: 1,
ante_config_id: undefined as number | undefined,
paid_lottery_config_id: undefined as number | undefined,
free_lottery_config_id: undefined as number | undefined,
paid_tier_weights: { T1: 20, T2: 20, T3: 20, T4: 20, T5: 20 } as Record<string, number>,
@@ -181,18 +270,16 @@
paid_s_count: 100,
paid_n_count: 100,
kill_mode_enabled: false,
test_safety_line: 5000
test_safety_line: 0
})
const lotteryOptions = ref<Array<{ id: number; name: string }>>([])
/** 付费抽奖券可选档位name=default */
const paidLotteryOptions = computed(() =>
lotteryOptions.value.filter((r) => r.name === 'default')
const lotteryOptions = ref<LotteryPoolConfigOption[]>([])
const selectedPaidPool = computed(() =>
lotteryOptions.value.find((r) => r.id === form.paid_lottery_config_id) ?? null
)
/** 免费抽奖券可选档位:优先 name=killScore若无则显示全部以便下拉有选项 */
const freeLotteryOptions = computed(() => {
const list = lotteryOptions.value.filter((r) => r.name === 'killScore')
return list.length > 0 ? list : lotteryOptions.value
})
const selectedFreePool = computed(() =>
lotteryOptions.value.find((r) => r.id === form.free_lottery_config_id) ?? null
)
const defaultPoolInfo = ref<{ safety_line: number; kill_enabled: number; profit_amount: number } | null>(null)
const running = ref(false)
function onClose() {
@@ -230,20 +317,96 @@
tierKeys.reduce((s, t) => s + (form.free_tier_weights[t] ?? 0), 0)
)
function resolveDeptParams(): { dept_id?: number } {
if (props.channelDeptId !== undefined && props.channelDeptId !== null) {
return { dept_id: props.channelDeptId }
}
const extra = getChannelDeptRequestParams()
if (extra.dept_id !== undefined) {
return extra
}
if (channelScope) {
return { dept_id: channelScope.selectedDeptId.value }
}
return {}
}
function resolveSubmitDeptId(): number | undefined {
const params = resolveDeptParams()
if (params.dept_id !== undefined) {
return params.dept_id
}
return undefined
}
function anteOptionLabel(item: { name: string; title: string; mult: number }): string {
const label = (item.title || item.name || '').trim()
return label ? `${label} (×${item.mult})` : `×${item.mult}`
}
function poolTierWeightsText(pool: LotteryPoolConfigOption): string {
const parts = tierKeys.map((t) => {
const key = `${t.toLowerCase()}_weight` as keyof LotteryPoolConfigOption
const v = pool[key]
return `${t} ${v ?? 0}%`
})
return parts.join(' · ')
}
function syncAnteFromSelect() {
const opt = anteOptions.value.find((o) => o.id === form.ante_config_id)
if (opt) {
form.ante = opt.mult
}
}
async function loadAnteOptions() {
try {
const list = await anteConfigApi.getOptions(resolveDeptParams())
anteOptions.value = list
const def = list.find((i) => i.is_default === 1) ?? list[0]
if (def) {
form.ante_config_id = def.id
form.ante = def.mult
} else {
form.ante_config_id = undefined
form.ante = 1
}
} catch {
anteOptions.value = []
form.ante_config_id = undefined
}
}
async function loadDefaultPoolInfo() {
try {
const pool = await lotteryPoolApi.getCurrentPool(resolveDeptParams())
const safetyLine = Number(pool?.safety_line ?? 0)
defaultPoolInfo.value = {
safety_line: safetyLine,
kill_enabled: Number(pool?.kill_enabled ?? 1),
profit_amount: Number(pool?.profit_amount ?? 0)
}
form.test_safety_line = safetyLine
} catch {
defaultPoolInfo.value = null
}
}
async function loadLotteryOptions() {
try {
const list = await lotteryPoolApi.getOptions()
lotteryOptions.value = list.map((r: { id: number; name: string }) => ({
id: r.id,
name: r.name
}))
const normal = list.find((r: { name?: string }) => r.name === 'default')
if (normal) {
const list = await lotteryPoolApi.getOptions(resolveDeptParams())
lotteryOptions.value = list
const playerDefault = list.find((r) => r.name === 'playerDefault')
const normal = list.find((r) => r.name === 'default')
if (playerDefault) {
form.paid_lottery_config_id = playerDefault.id
} else if (normal) {
form.paid_lottery_config_id = normal.id
}
const kill = list.find((r: { name?: string }) => r.name === 'killScore')
if (kill) {
form.free_lottery_config_id = kill.id
const freePool = list.find((r: { name?: string }) => r.name === 'free')
if (freePool) {
form.free_lottery_config_id = freePool.id
} else if (list.length > 0) {
form.free_lottery_config_id = list[0].id
}
@@ -263,6 +426,11 @@
kill_mode_enabled: form.kill_mode_enabled,
test_safety_line: form.test_safety_line
}
if (form.ante_config_id === RANDOM_ANTE_CONFIG_ID) {
payload.ante_random = true
} else {
payload.ante_config_id = form.ante_config_id
}
if (form.paid_lottery_config_id != null) {
payload.paid_lottery_config_id = form.paid_lottery_config_id
} else {
@@ -277,7 +445,15 @@
}
function validateForm(): boolean {
if (form.ante == null || form.ante <= 0) {
const isRandomAnte = form.ante_config_id === RANDOM_ANTE_CONFIG_ID
if (!isRandomAnte && (form.ante_config_id == null || form.ante_config_id <= 0)) {
ElMessage.warning(t('page.weightTest.warnAnte'))
return false
}
if (!isRandomAnte) {
syncAnteFromSelect()
}
if (!isRandomAnte && (form.ante == null || form.ante <= 0)) {
ElMessage.warning(t('page.weightTest.warnAnte'))
return false
}
@@ -320,7 +496,12 @@
if (!validateForm()) return
running.value = true
try {
await api.startWeightTest(buildPayload())
const payload = buildPayload()
const deptId = resolveSubmitDeptId()
if (deptId !== undefined) {
payload.dept_id = deptId
}
await api.startWeightTest(withChannelDeptParams(payload))
ElMessage.success(t('page.weightTest.successCreated'))
visible.value = false
emit('success')
@@ -333,73 +514,212 @@
watch(visible, (v) => {
if (v) {
loadLotteryOptions()
void loadAnteOptions()
void loadLotteryOptions()
void loadDefaultPoolInfo()
} else {
onClose()
}
})
watch(
() => props.channelDeptId ?? channelScope?.selectedDeptId.value,
() => {
if (visible.value) {
void loadAnteOptions()
void loadLotteryOptions()
void loadDefaultPoolInfo()
}
}
)
</script>
<style lang="scss" scoped>
.weight-test-tip {
margin-bottom: 16px;
.weight-test-dialog-body {
max-height: calc(100vh - 168px);
overflow: visible;
}
.chain-tip {
margin-top: -8px;
.compact-tip {
margin-bottom: 12px;
:deep(.el-alert__content) {
line-height: 1.45;
}
}
.section-title {
font-size: 14px;
.tip-lines {
font-size: 12px;
color: var(--el-text-color-regular);
div + div {
margin-top: 4px;
}
}
.weight-test-form {
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-form-item__label) {
white-space: nowrap;
}
}
.section-form {
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-form-item__label) {
white-space: nowrap;
line-height: 1.4;
padding-bottom: 4px;
}
}
.kill-mode-panel {
margin-bottom: 14px;
padding: 12px 14px;
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-light);
}
.kill-mode-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.kill-mode-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 8px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--el-border-color-lighter);
line-height: 1.4;
}
.tier-label {
font-size: 13px;
.kill-mode-body {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--el-border-color);
}
.pool-selected-hint {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
.pool-weights-preview {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-regular);
font-family: ui-monospace, monospace;
}
.kill-mode-field {
max-width: 280px;
}
.kill-mode-field-label {
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.4;
margin-bottom: 6px;
white-space: nowrap;
}
.kill-mode-field-input {
width: 100%;
}
.kill-mode-hint,
.kill-mode-off-hint,
.kill-mode-ref {
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
.kill-mode-ref {
margin-top: 4px;
color: var(--el-text-color-placeholder);
}
.section-row {
margin-top: 4px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0 0 10px;
padding-bottom: 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tier-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.tier-row {
margin-bottom: 8px;
}
.tier-row {
margin-bottom: 12px;
}
.tier-field {
margin-bottom: 12px;
margin-bottom: 6px;
}
.tier-field-label {
display: block;
font-size: 14px;
font-size: 12px;
color: var(--el-text-color-regular);
margin-bottom: 4px;
line-height: 1.5;
margin-bottom: 2px;
line-height: 1.4;
}
.tier-input {
display: block;
width: 100%;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
padding: 4px 8px;
font-size: 13px;
line-height: 1.4;
color: var(--el-text-color-regular);
background-color: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
box-sizing: border-box;
}
.tier-input:hover {
border-color: var(--el-border-color-hover);
}
.tier-input:focus {
outline: none;
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px var(--el-color-primary-light-7);
}
.tier-input::placeholder {
color: var(--el-text-color-placeholder);
}
.tier-error {
font-size: 12px;
color: var(--el-color-danger);
margin-top: 4px;
margin-bottom: 8px;
margin-bottom: 6px;
}
</style>
<style lang="scss">
.weight-test-dialog.el-dialog {
margin-bottom: 4vh;
}
.weight-test-dialog .el-dialog__body {
padding-top: 12px;
padding-bottom: 8px;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="art-full-height reward-config-form">
<ElCard shadow="never" class="form-card">
<div class="reward-config-form flex-1 min-h-0 flex flex-col">
<ElCard shadow="never" class="form-card flex-1 min-h-0 flex flex-col">
<template #header>
<div class="card-header">
<span>{{ $t('page.toolbar.gameRewardConfig') }}</span>
@@ -21,9 +21,38 @@
<ElTabPane :label="$t('page.configPage.tabIndex')" name="index">
<div class="tab-panel">
<div class="panel-tip">{{ $t('page.configPage.tipIndex') }}</div>
<div class="index-toolbar">
<div v-if="canTierRecommend" class="tier-recommend-panel">
<div class="tier-recommend-rules">{{ $t('page.configPage.tierRecommendRules') }}</div>
<div class="tier-recommend-grid">
<div v-for="tk in TIER_RECOMMEND_KEYS" :key="tk" class="tier-recommend-cell">
<span class="tier-recommend-label">{{ tk }}</span>
<span class="tier-recommend-hint">{{ $t('page.configPage.tierRecommendRealEv') }}</span>
<ElInputNumber
v-model="tierRecommend[tk]"
:disabled="tk === 'T5'"
:step="0.1"
:precision="2"
controls-position="right"
size="small"
class="tier-recommend-input"
/>
</div>
</div>
<div class="tier-recommend-actions">
<ElCheckbox v-model="autoMatchTierOnRealEv">{{
$t('page.configPage.tierRecommendAutoMatch')
}}</ElCheckbox>
<ElButton size="small" @click="handleApplyRecommendRealEv" v-ripple>{{
$t('page.configPage.tierRecommendApplyAmount')
}}</ElButton>
<ElButton size="small" type="primary" plain @click="handleMatchAllTiersFromRealEv" v-ripple>{{
$t('page.configPage.tierRecommendMatchTier')
}}</ElButton>
</div>
</div>
<div v-if="canTierRecommend" class="index-toolbar">
<ElButton
v-permission="'dice:reward_config:index:batchUpdate'"
v-permission="PERM_TIER_RECOMMEND"
type="default"
@click="openRuleGenerateDialog"
v-ripple
@@ -99,38 +128,18 @@
<template #default="{ row }">
<ElInputNumber
v-model="row.real_ev"
@change="handleRealEvChange(row)"
controls-position="right"
size="small"
:step="1"
:step="0.1"
:precision="2"
class="full-width"
@update:model-value="() => handleRealEvChange(row)"
/>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('page.configPage.colRealReward')"
min-width="130"
align="center"
>
<template #default="{ row }">
<span>{{ formatMoney2(calcRealReward(row.real_ev)) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('page.configPage.colTier')" width="100" align="center">
<template #default="{ row }">
<ElSelect
v-model="row.tier"
:placeholder="$t('page.configPage.placeholderTierSelect')"
clearable
size="small"
class="full-width"
>
<ElOption label="T1" value="T1" />
<ElOption label="T2" value="T2" />
<ElOption label="T3" value="T3" />
<ElOption label="T4" value="T4" />
<ElOption label="T5" value="T5" />
</ElSelect>
<span class="tier-readonly">{{ displayRowTier(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn
@@ -142,7 +151,7 @@
<ElInput
v-model="row.remark"
size="small"
:placeholder="$t('page.configPage.placeholderRemark')"
:placeholder="remarkPlaceholderForRow(row)"
/>
</template>
</ElTableColumn>
@@ -167,6 +176,7 @@
<ElTable
v-loading="loading"
:data="bigwinRows"
row-key="id"
border
size="default"
class="config-table bigwin-table"
@@ -214,11 +224,12 @@
<template #default="{ row }">
<ElInputNumber
v-model="row.real_ev"
@change="handleRealEvChange(row)"
controls-position="right"
size="small"
:step="1"
:step="0.1"
:precision="2"
class="full-width"
@update:model-value="() => handleRealEvChange(row)"
/>
</template>
</ElTableColumn>
@@ -231,7 +242,7 @@
<ElInput
v-model="row.remark"
size="small"
:placeholder="$t('page.configPage.placeholderRemark')"
:placeholder="remarkPlaceholderForRow(row)"
/>
</template>
</ElTableColumn>
@@ -243,14 +254,17 @@
<template #default="{ row }">
<div class="weight-cell">
<ElSlider
v-model="row.weight"
:model-value="row.weight"
:min="0"
:max="10000"
:step="100"
:disabled="isBigwinWeightDisabled(row)"
@update:model-value="
(v: number | number[]) => setBigwinRowWeight(row, Array.isArray(v) ? v[0] : v)
"
/>
<ElInputNumber
v-model="row.weight"
:model-value="row.weight"
:min="0"
:max="10000"
:step="100"
@@ -258,6 +272,7 @@
controls-position="right"
size="small"
class="weight-input"
@update:model-value="(v: number | undefined) => setBigwinRowWeight(row, v)"
/>
</div>
<span v-if="isBigwinWeightDisabled(row)" class="weight-tip">{{
@@ -286,6 +301,7 @@
</ElCard>
<ElDialog
v-if="canTierRecommend"
v-model="ruleGenerateDialogVisible"
:title="$t('page.configPage.ruleGenerateTitle')"
:width="ruleGenDialogWidth"
@@ -393,6 +409,7 @@
<ElInputNumber
v-model="ruleGenT4RealEv"
class="rule-gen-input-num"
:disabled="true"
:step="1"
:controls="ruleGenInputControls"
:size="ruleGenInputSize"
@@ -421,8 +438,8 @@
<ElInputNumber
v-model="ruleGenT5RealEv"
class="rule-gen-input-num"
:disabled="true"
:step="1"
:step="0.1"
:precision="2"
:controls="ruleGenInputControls"
:size="ruleGenInputSize"
controls-position="right"
@@ -442,25 +459,46 @@
</div>
</template>
</ElDialog>
<CreateRewardReferencePreviewDialog
v-model="createRewardPreviewVisible"
:dept-id="filterDeptId"
@success="loadIndexList"
/>
</div>
</template>
<script setup lang="ts">
import {
DEFAULT_CHANNEL_ID,
getChannelDeptRequestParams,
useChannelDeptReload,
useInjectedChannelDept
} from '@/composables/useChannelDeptScope'
import { useWindowSize } from '@vueuse/core'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../../api/reward_config/index'
import { checkAuth } from '@/utils/tool'
import CreateRewardReferencePreviewDialog from './modules/create-reward-reference-preview-dialog.vue'
import {
buildRowsFromTiers,
computeBoardFrequencies,
DEFAULT_TIER_REAL_EV_STANDARDS,
defaultRemarkForTier,
generateTiers,
inferTierFromRealEv,
summarizeCounts,
type TierRealEvStandards,
validateTierRealEvStandards
} from '../utils/generateIndexByRules'
const { t } = useI18n()
/** 档位结算推荐配置T1-T5 推荐金额栏、按规则生成/导入) */
const PERM_TIER_RECOMMEND = 'dice:reward_config:index:tierRecommend'
const canTierRecommend = computed(() => checkAuth(PERM_TIER_RECOMMEND))
const { width: viewportWidth } = useWindowSize()
/** 窄屏:单列、标签置顶、全屏弹窗 */
const isRuleGenMobile = computed(() => viewportWidth.value < 640)
@@ -484,37 +522,126 @@
weight: number
}
const channelScope = useInjectedChannelDept()
const filterDeptId = computed(() => {
const scopedId = channelScope?.selectedDeptId.value
if (scopedId !== undefined && scopedId !== null) {
if (scopedId > 0 || channelScope?.showDefaultTemplate.value) {
return scopedId
}
}
const extra = getChannelDeptRequestParams()
if (extra.dept_id !== undefined) {
return extra.dept_id
}
return DEFAULT_CHANNEL_ID
})
const activeTab = ref<'index' | 'bigwin'>('index')
const loading = ref(false)
const savingIndex = ref(false)
const savingBigwin = ref(false)
const createRewardLoading = ref(false)
const createRewardPreviewVisible = ref(false)
const ruleGenerateDialogVisible = ref(false)
const ruleGenSubmitting = ref(false)
const ruleGenT1Fixed = ref(3)
const ruleGenT2Min = ref(5)
const ruleGenT4Fixed = ref(1)
const ruleGenT5Fixed = ref(1)
const TIER_RECOMMEND_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
type TierRecommendKey = (typeof TIER_RECOMMEND_KEYS)[number]
const tierRecommend = reactive<TierRealEvStandards>({
T1: DEFAULT_TIER_REAL_EV_STANDARDS.T1,
T2: DEFAULT_TIER_REAL_EV_STANDARDS.T2,
T3: DEFAULT_TIER_REAL_EV_STANDARDS.T3,
T4: DEFAULT_TIER_REAL_EV_STANDARDS.T4,
T5: DEFAULT_TIER_REAL_EV_STANDARDS.T5
})
const autoMatchTierOnRealEv = ref(false)
const ruleGenT1RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T1)
const ruleGenT2RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T2)
const ruleGenT3RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T3)
const ruleGenT4RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T4)
const ruleGenT5RealEv = ref(DEFAULT_TIER_REAL_EV_STANDARDS.T5)
function syncRuleGenFromTierRecommend() {
ruleGenT1RealEv.value = tierRecommend.T1
ruleGenT2RealEv.value = tierRecommend.T2
ruleGenT3RealEv.value = tierRecommend.T3
ruleGenT4RealEv.value = tierRecommend.T4
ruleGenT5RealEv.value = tierRecommend.T5
}
function syncTierRecommendFromRuleGen() {
tierRecommend.T1 = Number(ruleGenT1RealEv.value)
tierRecommend.T2 = Number(ruleGenT2RealEv.value)
tierRecommend.T3 = Number(ruleGenT3RealEv.value)
tierRecommend.T4 = Number(ruleGenT4RealEv.value)
tierRecommend.T5 = Number(ruleGenT5RealEv.value)
}
function applyRealEvDisplay(row: IndexRow, n: number) {
const text = Number.isNaN(n) ? '' : Number(n).toFixed(2)
row.ui_text = text
row.ui_text_en = text
}
function rowRealEvNumber(row: IndexRow): number {
return typeof row.real_ev === 'number' && !Number.isNaN(row.real_ev)
? row.real_ev
: Number(row.real_ev)
}
function syncRowTierFromRealEv(row: IndexRow) {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
if (tier !== '') {
row.tier = tier
}
}
/** 按当前结算金额推断档位并写入对应备注T1大奖/T2小赚/T3抽水/T4惩罚/T5再来一次 */
function syncRemarkFromSettlement(row: IndexRow) {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
if (tier === '') {
return
}
row.remark = defaultRemarkForTier(tier)
}
function remarkPlaceholderForRow(row: IndexRow): string {
const tier = displayRowTier(row)
if (tier === '' || tier === '-') {
return t('page.configPage.placeholderRemark')
}
return defaultRemarkForTier(tier)
}
function displayRowTier(row: IndexRow): string {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
return tier !== '' ? tier : row.tier || '-'
}
function applyRecommendRealEvToRow(row: IndexRow, tier: TierRecommendKey) {
const ev = tierRecommend[tier]
row.real_ev = ev
if (tier === 'T5') {
row.ui_text = t('page.configPage.tierRecommendT5UiText')
row.ui_text_en = t('page.configPage.tierRecommendT5UiTextEn')
} else {
applyRealEvDisplay(row, ev)
}
syncRowTierFromRealEv(row)
syncRemarkFromSettlement(row)
}
/** 奖励索引 id 与后端 DiceRewardConfigLogic 一致025 */
const REWARD_INDEX_MIN = 0
const REWARD_INDEX_MAX = 25
const ALLOWED_INDEX_TIERS = ['T1', 'T2', 'T3', 'T4', 'T5', 'BIGWIN'] as const
function isAllowedIndexTier(s: string): boolean {
for (let i = 0; i < ALLOWED_INDEX_TIERS.length; i++) {
if (ALLOWED_INDEX_TIERS[i] === s) {
return true
}
}
return false
}
/** 第一页数据(来自 api.list即 DiceRewardConfig 表) */
const indexRows = ref<IndexRow[]>([])
/** 奖励索引 Tab排除 tier=BIGWIN仅显示 T1T5 */
@@ -524,6 +651,15 @@
/** 原始 list 快照,用于重置 */
let indexRowsSnapshot: IndexRow[] = []
function isAllowedIndexTier(s: string): boolean {
for (let i = 0; i < ALLOWED_INDEX_TIERS.length; i++) {
if (ALLOWED_INDEX_TIERS[i] === s) {
return true
}
}
return false
}
function toWeight(v: unknown): number {
const n = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(n)) return 0
@@ -543,22 +679,65 @@
}
}
function calcRealReward(realEv: unknown): number {
const n = typeof realEv === 'number' && !Number.isNaN(realEv) ? realEv : Number(realEv)
if (Number.isNaN(n)) {
return -1
}
return n - 1
/** BIGWIN 行不参与按 real_ev 推断 T1-T5避免编辑时从大奖权重表消失 */
function setBigwinRowWeight(row: IndexRow, v: number | number[] | undefined | null) {
const n = Array.isArray(v) ? v[0] : v
row.weight = toWeight(n)
}
function handleRealEvChange(row: IndexRow) {
const n =
typeof row.real_ev === 'number' && !Number.isNaN(row.real_ev)
? row.real_ev
: Number(row.real_ev)
const text = Number.isNaN(n) ? '' : Number(n).toFixed(2)
row.ui_text = text
row.ui_text_en = text
if (row.tier === 'BIGWIN') {
row.remark = defaultRemarkForTier('BIGWIN')
return
}
const n = rowRealEvNumber(row)
syncRowTierFromRealEv(row)
const tier = inferTierFromRealEv(n)
if (tier === 'T5') {
row.ui_text = t('page.configPage.tierRecommendT5UiText')
row.ui_text_en = t('page.configPage.tierRecommendT5UiTextEn')
} else {
applyRealEvDisplay(row, n)
}
syncRemarkFromSettlement(row)
}
function handleApplyRecommendRealEv() {
if (!canTierRecommend.value) {
return
}
let count = 0
for (const row of indexRowsExcludeBigwin.value) {
const tier = inferTierFromRealEv(rowRealEvNumber(row))
if (tier === 'T1' || tier === 'T2' || tier === 'T3' || tier === 'T4' || tier === 'T5') {
applyRecommendRealEvToRow(row, tier)
count++
}
}
if (count === 0) {
ElMessage.info(t('page.configPage.tierRecommendNoTierRows'))
return
}
ElMessage.success(t('page.configPage.tierRecommendApplyAmountOk', { n: count }))
}
function handleMatchAllTiersFromRealEv() {
if (!canTierRecommend.value) {
return
}
let count = 0
for (const row of indexRowsExcludeBigwin.value) {
syncRowTierFromRealEv(row)
syncRemarkFromSettlement(row)
if (row.tier !== '') {
count++
}
}
if (count === 0) {
ElMessage.info(t('page.configPage.tierRecommendMatchTierNone'))
return
}
ElMessage.success(t('page.configPage.tierRecommendMatchTierOk', { n: count }))
}
function formatMoney2(val: unknown): string {
@@ -569,54 +748,36 @@
}
async function handleCreateRewardReference() {
try {
await ElMessageBox.confirm(
t('page.configPage.confirmCreateRefMsg'),
t('page.configPage.confirmCreateRefTitle'),
{
confirmButtonText: t('page.configPage.confirmCreateRefOk'),
cancelButtonText: t('page.configPage.confirmCreateRefCancel'),
type: 'warning'
}
)
} catch {
return
createRewardPreviewVisible.value = true
}
function extractIndexList(res: unknown): Record<string, unknown>[] {
if (Array.isArray(res)) {
return res as Record<string, unknown>[]
}
createRewardLoading.value = true
try {
const res: any = await api.createRewardReference()
const data = res?.data ?? res
let msg = t('page.configPage.createRefSuccessSimple')
if (typeof data === 'object' && data !== null) {
const skipped = Number(data.skipped ?? 0)
const skippedPart =
skipped > 0 ? t('page.configPage.createRefSuccessSkipped', { n: skipped }) : ''
msg = t('page.configPage.createRefSuccess', {
cwNew: data.created_clockwise ?? 0,
ccwNew: data.created_counterclockwise ?? 0,
cwUp: data.updated_clockwise ?? 0,
ccwUp: data.updated_counterclockwise ?? 0,
skippedPart
})
if (res && typeof res === 'object') {
const obj = res as Record<string, unknown>
if (Array.isArray(obj.data)) {
return obj.data as Record<string, unknown>[]
}
if (Array.isArray(obj.records)) {
return obj.records as Record<string, unknown>[]
}
ElMessage.success(msg)
loadIndexList()
} catch (e: any) {
ElMessage.error(e?.message ?? t('page.configPage.createRefFail'))
} finally {
createRewardLoading.value = false
}
return []
}
function loadIndexList() {
loading.value = true
return api
.list({ limit: 200 })
.then((res: any) => {
const list = res?.data?.records ?? res?.records ?? res?.data ?? []
const rows = Array.isArray(list)
? list.map((r: Record<string, unknown>) => normalizeIndexRow(r))
: []
.list({ saiType: 'all', limit: 200, dept_id: filterDeptId.value })
.then((res: unknown) => {
const rows = extractIndexList(res).map((r) => normalizeIndexRow(r))
for (const row of rows) {
if (row.tier !== 'BIGWIN') {
syncRowTierFromRealEv(row)
}
}
indexRows.value = rows
indexRowsSnapshot = rows.map((r) => ({ ...r }))
})
@@ -628,6 +789,17 @@
})
}
/** 挂载时拉数;超管切换左侧渠道时重新拉数 */
useChannelDeptReload(loadIndexList)
watch(
() => filterDeptId.value,
(deptId, prev) => {
if (deptId !== prev) {
loadIndexList()
}
}
)
function isBigwinWeightDisabled(row: IndexRow): boolean {
return row.grid_number === 5 || row.grid_number === 30
}
@@ -700,6 +872,10 @@
}
function openRuleGenerateDialog() {
if (!canTierRecommend.value) {
return
}
syncRuleGenFromTierRecommend()
ruleGenerateDialogVisible.value = true
}
@@ -736,6 +912,9 @@
}
async function handleRuleGenerateApply() {
if (!canTierRecommend.value) {
return
}
const grids = extractGrids26()
if (grids === null) {
ElMessage.warning(t('page.configPage.ruleGenNeedFullGrid'))
@@ -745,12 +924,13 @@
const t2 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT2Min.value))))
const x4 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT4Fixed.value))))
const x5 = Math.max(0, Math.min(26, Math.floor(Number(ruleGenT5Fixed.value))))
syncTierRecommendFromRuleGen()
const standards = {
T1: Number(ruleGenT1RealEv.value),
T2: Number(ruleGenT2RealEv.value),
T3: Number(ruleGenT3RealEv.value),
T4: Number(ruleGenT4RealEv.value),
T5: Number(ruleGenT5RealEv.value)
T1: Number(tierRecommend.T1),
T2: Number(tierRecommend.T2),
T3: Number(tierRecommend.T3),
T4: Number(tierRecommend.T4),
T5: Number(tierRecommend.T5)
}
const invalidKey = validateTierRealEvStandards(standards)
if (invalidKey !== null) {
@@ -805,7 +985,7 @@
remark: r.remark
})
)
await api.batchUpdate(indexPayload)
await api.generateIndexByRules(indexPayload, { dept_id: filterDeptId.value })
ElMessage.success(
t('page.configPage.ruleGenSuccess', {
cwT1: sc.cw.T1,
@@ -843,6 +1023,9 @@
return
}
const toSave = indexRows.value.filter((r) => r.tier !== 'BIGWIN')
for (const row of toSave) {
syncRowTierFromRealEv(row)
}
savingIndex.value = true
try {
const indexPayload = toSave.map((r) => ({
@@ -854,7 +1037,7 @@
tier: r.tier,
remark: r.remark
}))
await api.batchUpdate(indexPayload)
await api.batchUpdate(indexPayload, { dept_id: filterDeptId.value })
ElMessage.success(t('page.configPage.saveSuccess'))
indexRowsSnapshot = indexRows.value.map((r) => ({ ...r }))
} catch (e: any) {
@@ -912,17 +1095,17 @@
ui_text: r.ui_text,
ui_text_en: r.ui_text_en,
real_ev: r.real_ev,
tier: r.tier,
tier: 'BIGWIN',
remark: r.remark
}))
await api.batchUpdate(batchPayload)
await api.batchUpdate(batchPayload, { dept_id: filterDeptId.value })
const weightItems = rows.map((r) => ({
grid_number: r.grid_number,
weight: isBigwinWeightDisabled(r)
? 10000
: Math.max(0, Math.min(10000, Math.floor(r.weight)))
}))
await api.saveBigwinWeightsByGrid(weightItems)
await api.saveBigwinWeightsByGrid(weightItems, { dept_id: filterDeptId.value })
ElMessage.success(t('page.configPage.saveSuccess'))
loadIndexList()
} catch (e: any) {
@@ -938,9 +1121,6 @@
ElMessage.info(t('page.configPage.resetBigwinReloaded'))
}
onMounted(() => {
loadIndexList()
})
</script>
<style lang="scss" scoped>
@@ -1016,6 +1196,61 @@
margin-bottom: 12px;
line-height: 1.5;
}
.tier-recommend-panel {
margin-bottom: 12px;
padding: 12px 14px;
background: var(--el-fill-color-light);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
.tier-recommend-rules {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
margin-bottom: 12px;
}
.tier-recommend-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
margin-bottom: 12px;
}
.tier-recommend-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
min-width: 140px;
flex: 1 1 160px;
}
.tier-recommend-label {
font-weight: 600;
font-size: 13px;
color: var(--el-text-color-primary);
min-width: 28px;
}
.tier-recommend-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.tier-recommend-input {
width: 120px;
flex: 1;
min-width: 100px;
max-width: 160px;
}
.tier-readonly {
font-weight: 500;
color: var(--el-text-color-primary);
}
.tier-recommend-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
}
.index-toolbar {
display: flex;
align-items: center;

View File

@@ -0,0 +1,343 @@
<template>
<ElDialog
v-model="visible"
:title="$t('page.configPage.createRefPreviewTitle')"
width="860px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<div v-loading="loading" class="dialog-body">
<div class="preview-tip">
<div v-if="meta.unchanged" class="tip-line">
{{ $t('page.configPage.createRefPreviewTipUnchanged') }}
</div>
<div v-else class="tip-line">
{{ $t('page.configPage.createRefPreviewTipChanged') }}
</div>
<div class="tip-line" v-if="meta.skipped > 0">
{{ $t('page.configPage.createRefPreviewSkipped', { n: meta.skipped }) }}
</div>
</div>
<ElTabs v-model="activeDirection" type="card" class="direction-tabs">
<ElTabPane :label="$t('page.configPage.createRefPreviewClockwise')" name="0">
<ElTabs v-model="activeTier" type="card" class="tier-tabs">
<ElTabPane v-for="t in tierKeys" :key="'cw-' + t" :label="t" :name="t">
<ElTable
:data="getTierItems(t, 0)"
:row-class-name="rowClassName"
border
size="small"
class="preview-table"
>
<ElTableColumn :label="$t('page.table.dicePoints')" prop="grid_number" width="90" align="center" />
<ElTableColumn label="start" prop="start_index" width="78" align="center" />
<ElTableColumn :label="$t('page.table.endIndex')" prop="id" width="78" align="center" />
<ElTableColumn :label="$t('page.table.displayText')" prop="ui_text" width="90" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.remark')" prop="remark" min-width="80" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.weight')" width="130" align="center">
<template #default="{ row }">
<ElInputNumber
v-model="row.weight"
:min="1"
:max="10000"
:step="1"
controls-position="right"
size="small"
class="weight-input"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('page.configPage.createRefPreviewDiff')" min-width="120" align="center">
<template #default="{ row }">
<span v-if="row?.diff_changed" class="diff-text">
{{ formatDiff(row) }}
</span>
<span v-else class="diff-text diff-text-ok">{{ $t('page.configPage.createRefPreviewNoDiff') }}</span>
</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
</ElTabs>
</ElTabPane>
<ElTabPane :label="$t('page.configPage.createRefPreviewCounterclockwise')" name="1">
<ElTabs v-model="activeTier" type="card" class="tier-tabs">
<ElTabPane v-for="t in tierKeys" :key="'ccw-' + t" :label="t" :name="t">
<ElTable
:data="getTierItems(t, 1)"
:row-class-name="rowClassName"
border
size="small"
class="preview-table"
>
<ElTableColumn :label="$t('page.table.dicePoints')" prop="grid_number" width="90" align="center" />
<ElTableColumn label="start" prop="start_index" width="78" align="center" />
<ElTableColumn :label="$t('page.table.endIndex')" prop="id" width="78" align="center" />
<ElTableColumn :label="$t('page.table.displayText')" prop="ui_text" width="90" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.remark')" prop="remark" min-width="80" align="center" show-overflow-tooltip />
<ElTableColumn :label="$t('page.table.weight')" width="120" align="center">
<template #default="{ row }">
<ElInputNumber
v-model="row.weight"
:min="1"
:max="10000"
:step="1"
controls-position="right"
size="small"
class="weight-input"
/>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('page.configPage.createRefPreviewDiff')" min-width="120" align="center">
<template #default="{ row }">
<span v-if="row?.diff_changed" class="diff-text">
{{ formatDiff(row) }}
</span>
<span v-else class="diff-text diff-text-ok">{{ $t('page.configPage.createRefPreviewNoDiff') }}</span>
</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
</ElTabs>
</ElTabPane>
</ElTabs>
</div>
<template #footer>
<ElButton @click="refreshPreview" :loading="loading" v-ripple>{{
$t('page.configPage.createRefPreviewRefresh')
}}</ElButton>
<ElButton @click="handleClose" v-ripple>{{ $t('common.cancel') }}</ElButton>
<ElButton type="primary" :loading="submitting" @click="handleImport" v-ripple>{{
$t('page.configPage.createRefPreviewImport')
}}</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../../../api/reward_config/index'
const { t } = useI18n()
const TIER_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
const tierKeys = TIER_KEYS
interface Props {
modelValue: boolean
deptId: number
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
deptId: 0
})
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const activeDirection = ref<'0' | '1'>('0')
const activeTier = ref('T1')
const loading = ref(false)
const submitting = ref(false)
const meta = reactive<{ unchanged: boolean; skipped: number }>({ unchanged: false, skipped: 0 })
const preview = ref<Record<string, { 0: any[]; 1: any[] }>>({})
function rowClassName({ row }: { row: any }): string {
return row?.diff_changed ? 'row-diff' : ''
}
function formatDiff(row: any): string {
if (!row) return ''
const oldStart = row.old_start_index != null ? String(row.old_start_index) : '-'
const oldEnd = row.old_end_index != null ? String(row.old_end_index) : '-'
const oldTier = row.old_tier != null ? String(row.old_tier) : '-'
const newStart = row.start_index != null ? String(row.start_index) : '-'
const newEnd = row.id != null ? String(row.id) : '-'
const newTier = row.tier != null ? String(row.tier) : '-'
return `${oldStart}/${oldEnd}/${oldTier}${newStart}/${newEnd}/${newTier}`
}
function getTierItems(tier: string, direction: 0 | 1): any[] {
const tierData = preview.value?.[tier]
if (!tierData) return []
const rows = direction === 0 ? tierData[0] : tierData[1]
return Array.isArray(rows) ? rows : []
}
async function refreshPreview() {
loading.value = true
try {
const res: any = await api.createRewardReferencePreview({ dept_id: props.deptId })
const data = res?.data ?? res
meta.unchanged = Boolean(data?.unchanged)
meta.skipped = Number(data?.skipped ?? 0)
preview.value = (data?.preview ?? {}) as any
activeDirection.value = '0'
activeTier.value = 'T1'
} catch (e: any) {
ElMessage.error(e?.message ?? 'preview failed')
} finally {
loading.value = false
}
}
function toWeight(v: unknown): number {
const num = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(num)) return 1
return Math.max(1, Math.min(10000, Math.floor(num)))
}
function collectDesiredWeights(): Record<string, number> {
const map: Record<string, number> = {}
for (const t of tierKeys) {
const tierData = preview.value?.[t]
if (!tierData) continue
const cw = Array.isArray(tierData[0]) ? tierData[0] : []
const ccw = Array.isArray(tierData[1]) ? tierData[1] : []
for (const r of cw) {
const gn = r?.grid_number != null ? Number(r.grid_number) : NaN
if (!Number.isNaN(gn)) map[`0:${gn}`] = toWeight(r?.weight)
}
for (const r of ccw) {
const gn = r?.grid_number != null ? Number(r.grid_number) : NaN
if (!Number.isNaN(gn)) map[`1:${gn}`] = toWeight(r?.weight)
}
}
return map
}
async function applyWeights(desired: Record<string, number>) {
const res: any = await api.weightRatioList({ dept_id: props.deptId })
const raw = res?.data ?? res
const items: Array<{ id: number; weight: number }> = []
for (const t of tierKeys) {
const tierData = raw?.[t]
if (!tierData) continue
const list0 = Array.isArray(tierData[0]) ? tierData[0] : []
const list1 = Array.isArray(tierData[1]) ? tierData[1] : []
for (const r of list0) {
const rid = r?.reward_id != null ? Number(r.reward_id) : 0
const gn = r?.grid_number != null ? Number(r.grid_number) : NaN
if (rid > 0 && !Number.isNaN(gn)) {
const w = desired[`0:${gn}`]
if (w != null) items.push({ id: rid, weight: w })
}
}
for (const r of list1) {
const rid = r?.reward_id != null ? Number(r.reward_id) : 0
const gn = r?.grid_number != null ? Number(r.grid_number) : NaN
if (rid > 0 && !Number.isNaN(gn)) {
const w = desired[`1:${gn}`]
if (w != null) items.push({ id: rid, weight: w })
}
}
}
if (items.length > 0) {
await api.batchUpdateWeights(items, { dept_id: props.deptId })
}
}
async function handleImport() {
submitting.value = true
try {
const desired = collectDesiredWeights()
if (!meta.unchanged) {
await api.createRewardReference({ dept_id: props.deptId })
await applyWeights(desired)
ElMessage.success(t('page.configPage.createRefPreviewImportOk'))
} else {
// 映射未变化:直接保存权重即可
await applyWeights(desired)
ElMessage.success(t('page.configPage.createRefPreviewWeightsSaved'))
}
emit('success')
handleClose()
} catch (e: any) {
ElMessage.error(e?.message ?? 'import failed')
} finally {
submitting.value = false
}
}
function handleClose() {
visible.value = false
}
watch(
() => props.modelValue,
(open) => {
if (open) {
refreshPreview()
}
}
)
</script>
<style scoped lang="scss">
.dialog-body {
min-height: 120px;
}
.preview-tip {
margin-bottom: 8px;
padding: 8px 10px;
font-size: 12px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 6px;
line-height: 1.5;
}
.tip-line + .tip-line {
margin-top: 4px;
}
.preview-table {
margin-top: 4px;
}
.weight-input {
width: 110px;
}
:deep(.row-diff td) {
background: var(--el-color-warning-light-9);
}
.diff-text {
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 1.2;
word-break: break-all;
}
.diff-text-ok {
color: var(--el-text-color-secondary);
}
/* 减少弹窗内容区与表格留白 */
:deep(.el-dialog__body) {
padding: 12px 14px;
}
:deep(.el-tabs__header) {
margin: 0 0 8px 0;
}
:deep(.el-tabs__item) {
height: 34px;
line-height: 34px;
}
:deep(.el-table .cell) {
padding: 4px 6px;
}
:deep(.el-table__row) {
height: 34px;
}
</style>

View File

@@ -83,6 +83,7 @@
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { withChannelDeptParams } from '@/composables/useChannelDeptScope'
interface Props {
modelValue: boolean
@@ -229,10 +230,10 @@
delete payload.weight
}
if (props.dialogType === 'add') {
await api.save(payload)
await api.save(withChannelDeptParams(payload))
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(payload)
await api.update(withChannelDeptParams(payload))
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')

View File

@@ -50,13 +50,6 @@
show-overflow-tooltip
/>
<el-table-column :label="$t('page.weightRatio.colDicePoints')" prop="grid_number" width="80" align="center" />
<el-table-column
:label="$t('page.weightRatio.colRealEv')"
prop="real_ev"
width="90"
align="center"
show-overflow-tooltip
/>
<el-table-column
:label="$t('page.weightRatio.colUiText')"
prop="ui_text"
@@ -173,6 +166,7 @@
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
const { t } = useI18n()
@@ -360,7 +354,7 @@
function loadData() {
api
.weightRatioList()
.weightRatioList(getChannelDeptRequestParams())
.then((res: any) => {
grouped.value = parseWeightRatioPayload(res)
})
@@ -393,7 +387,7 @@
}
submitting.value = true
api
.batchUpdateWeights(items)
.batchUpdateWeights(items, getChannelDeptRequestParams())
.then(() => {
ElMessage.success(t('page.weightRatio.saveSuccess'))
emit('success')

View File

@@ -53,7 +53,7 @@ export interface TierRealEvStandards {
T5: number
}
/** 默认标准(与规则弹窗说明一致 */
/** 默认推荐结算金额(满足档位区间规则,可在页面修改 */
export const DEFAULT_TIER_REAL_EV_STANDARDS: TierRealEvStandards = {
T1: 3,
T2: 1.5,
@@ -62,6 +62,55 @@ export const DEFAULT_TIER_REAL_EV_STANDARDS: TierRealEvStandards = {
T5: 0
}
/**
* 按结算金额推断档位(与奖励配置页规则说明一致)
* T1 大奖:>2
* T2 小赚2>=金额>1
* T3 抽水1>=金额>0
* T4 惩罚0>金额
* T5 再来一次:=0
*/
/** 各档位默认备注(与结算金额推断档位规则一致,修改结算金额时实时同步) */
export const TIER_REMARK_BY_TIER: Record<IndexTier, string> = {
T1: '大奖',
T2: '小赚',
T3: '抽水',
T4: '惩罚',
T5: '再来一次'
}
export function defaultRemarkForTier(tier: IndexTier | 'BIGWIN' | string): string {
if (tier === 'BIGWIN' || tier === 'T1') {
return TIER_REMARK_BY_TIER.T1
}
if (tier === 'T2' || tier === 'T3' || tier === 'T4' || tier === 'T5') {
return TIER_REMARK_BY_TIER[tier]
}
return ''
}
export function inferTierFromRealEv(realEv: number): IndexTier | '' {
if (!Number.isFinite(realEv)) {
return ''
}
if (realEv === 0) {
return 'T5'
}
if (realEv < 0) {
return 'T4'
}
if (realEv > 2) {
return 'T1'
}
if (realEv > 1 && realEv <= 2) {
return 'T2'
}
if (realEv > 0 && realEv <= 1) {
return 'T3'
}
return ''
}
/**
* 校验档位与 real_ev 区间是否一致;通过返回 null否则返回 i18n 键名(不含 page.configPage. 前缀)
*/
@@ -69,10 +118,10 @@ export function validateTierRealEvStandards(s: TierRealEvStandards): string | nu
if (!Number.isFinite(s.T1) || !(s.T1 > 2)) {
return 'ruleGenInvalidT1RealEv'
}
if (!Number.isFinite(s.T2) || !(s.T2 > 1 && s.T2 < 2)) {
if (!Number.isFinite(s.T2) || !(s.T2 > 1 && s.T2 <= 2)) {
return 'ruleGenInvalidT2RealEv'
}
if (!Number.isFinite(s.T3) || !(s.T3 > 0 && s.T3 < 1)) {
if (!Number.isFinite(s.T3) || !(s.T3 > 0 && s.T3 <= 1)) {
return 'ruleGenInvalidT3RealEv'
}
if (!Number.isFinite(s.T4) || !(s.T4 < 0)) {
@@ -353,7 +402,7 @@ export function buildRowsFromTiers(
const f = uiTextByTierWhenStandards(tier, real_ev)
ui_text = f.ui_text
ui_text_en = f.ui_text_en
remark = '前端需要在播放一次动画(特殊)'
remark = '再来一次'
}
} else if (tier === 'T1') {
real_ev = 101 + ((id * 17 + grid_number * 3) % 398)
@@ -393,10 +442,9 @@ export function buildRowsFromTiers(
remark = '惩罚'
} else {
real_ev = 0
const f = uiTextFromRealEv(real_ev)
ui_text = f.ui_text
ui_text_en = f.ui_text_en
remark = '前端需要在播放一次动画(特殊)'
ui_text = '再来一次'
ui_text_en = 'Once again'
remark = '再来一次'
}
rows.push({
id,

View File

@@ -143,6 +143,35 @@
return Number(row.paid_n_count ?? 0)
}
function formatTestSafetyLine(row: Record<string, unknown>): string {
const dash = t('page.detail.dash')
if (Number(row.kill_mode_enabled ?? 0) !== 1) {
return dash
}
const line = row.test_safety_line
if (line === null || line === undefined || line === '') {
return dash
}
const n = Number(line)
return Number.isFinite(n) ? String(n) : dash
}
function formatAnteCell(row: Record<string, unknown>): string {
const snap = row.tier_weights_snapshot
const isRandom =
snap &&
typeof snap === 'object' &&
(snap as { ante_random?: boolean }).ante_random === true
if (isRandom) {
return t('page.table.anteRandom')
}
const ante = row.ante
if (ante === null || ante === undefined || ante === '') {
return t('page.detail.dash')
}
return String(ante)
}
// 平台赚取金额展示(未完成或空显示 —)
function formatPlatformProfit(v: unknown): string {
const dash = t('page.detail.dash')
@@ -228,8 +257,16 @@
{
prop: 'ante',
label: 'page.table.ante',
width: 90,
align: 'center'
width: 100,
align: 'center',
formatter: (row: Record<string, unknown>) => formatAnteCell(row)
},
{
prop: 'test_safety_line',
label: 'page.table.testSafetyLine',
width: 100,
align: 'center',
formatter: (row: Record<string, unknown>) => formatTestSafetyLine(row)
},
{
prop: 'play_again_count',

View File

@@ -20,6 +20,12 @@
<el-descriptions-item :label="$t('page.detail.paidPlannedSpins')">
{{ record.paid_planned_spins ?? $t('page.detail.dash') }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.testSafetyLine')">
{{ formatTestSafetyLineDetail(record) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.table.ante')">
{{ formatAnteDetail(record) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.testCount')">
{{ formatTestCountDisplay(record) }}
</el-descriptions-item>
@@ -30,10 +36,10 @@
{{ record.admin_name ?? record.admin_id ?? $t('page.detail.dash') }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.paidPoolId')">
{{ record.paid_lottery_config_id ?? record.lottery_config_id ?? $t('page.detail.dash') }}
{{ formatRecordPaidPoolName(record) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.freePoolId')">
{{ record.free_lottery_config_id ?? $t('page.detail.dash') }}
{{ formatRecordFreePoolName(record) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('page.detail.bigwinSnapshot')">
<template v-if="bigwinWeightDisplay.length">
@@ -182,7 +188,7 @@
<el-option
v-for="opt in paidLotteryOptions"
:key="opt.id"
:label="opt.name"
:label="lotteryPoolOptionLabel(opt)"
:value="opt.id"
/>
</el-select>
@@ -199,7 +205,7 @@
<el-option
v-for="opt in freeLotteryOptions"
:key="opt.id"
:label="opt.name"
:label="lotteryPoolOptionLabel(opt)"
:value="opt.id"
/>
</el-select>
@@ -224,6 +230,11 @@
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import recordApi from '../../../api/reward_config_record/index'
import lotteryConfigApi from '../../../api/lottery_pool_config/index'
import {
lotteryPoolLabelById,
lotteryPoolOptionLabel,
type LotteryPoolOption
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
@@ -242,6 +253,9 @@
over_play_count?: number
chain_free_mode?: number | boolean | string
paid_planned_spins?: number
ante?: number
kill_mode_enabled?: number
test_safety_line?: number
create_time?: string
admin_id?: number | null
admin_name?: string
@@ -278,6 +292,35 @@
return t('page.table.chainModeNo')
}
function formatTestSafetyLineDetail(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
if (Number(record.kill_mode_enabled ?? 0) !== 1) {
return t('page.detail.killModeOff')
}
const line = record.test_safety_line
if (line === null || line === undefined) {
return t('page.detail.dash')
}
return String(line)
}
function formatAnteDetail(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
const snap = record.tier_weights_snapshot
const isRandom =
snap &&
typeof snap === 'object' &&
(snap as { ante_random?: boolean }).ante_random === true
if (isRandom) {
return t('page.table.anteRandom')
}
const ante = record.ante
if (ante === null || ante === undefined) {
return t('page.detail.dash')
}
return String(ante)
}
function formatTestCountDisplay(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
const status = Number(record.status)
@@ -316,7 +359,18 @@
const importing = ref(false)
const importPaidLotteryConfigId = ref<number | null>(null)
const importFreeLotteryConfigId = ref<number | null>(null)
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
function formatRecordPaidPoolName(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
const id = record.paid_lottery_config_id ?? record.lottery_config_id ?? null
return lotteryPoolLabelById(id, lotteryConfigOptions.value)
}
function formatRecordFreePoolName(record: RecordRow | null): string {
if (!record) return t('page.detail.dash')
return lotteryPoolLabelById(record.free_lottery_config_id ?? null, lotteryConfigOptions.value)
}
function tierWeightsToTableData(weightsMap: Record<string, number> | null | undefined) {
const dash = t('page.detail.dash')
@@ -496,6 +550,15 @@
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
void loadLotteryOptions()
}
}
)
function openImport() {
importPaidLotteryConfigId.value =
props.record?.paid_lottery_config_id ?? props.record?.lottery_config_id ?? null

View File

@@ -1,35 +1,31 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? $t('page.form.titleAdd') : $t('page.form.titleEdit')"
:title="$t('page.form.titleEdit')"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form ref="formRef" :model="formData" label-width="120px">
<el-form-item :label="$t('page.form.labelTestCount')" prop="test_count">
<el-input v-model="formData.test_count" :placeholder="$t('page.form.placeholderTestCount')" />
<el-input v-model="formData.test_count" :placeholder="$t('page.form.placeholderTestCount')" disabled />
</el-form-item>
<el-form-item :label="$t('page.form.labelWeightSnapshot')" prop="weight_config_snapshot">
<el-input v-model="formData.weight_config_snapshot" :placeholder="$t('page.form.placeholderWeightSnapshot')" />
<el-input v-model="formData.weight_config_snapshot" :placeholder="$t('page.form.placeholderWeightSnapshot')" disabled />
</el-form-item>
<el-form-item :label="$t('page.form.labelResultCounts')" prop="result_counts">
<el-input v-model="formData.result_counts" :placeholder="$t('page.form.placeholderResultCounts')" />
<el-input v-model="formData.result_counts" :placeholder="$t('page.form.placeholderResultCounts')" disabled />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleSubmit">{{ $t('table.form.submit') }}</el-button>
<el-button @click="handleClose">{{ $t('form.close') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/reward_config_record/index'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import type { FormInstance } from 'element-plus'
interface Props {
modelValue: boolean
@@ -44,12 +40,11 @@
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
dialogType: 'edit',
data: undefined
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
const formRef = ref<FormInstance>()
@@ -61,13 +56,6 @@
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = computed<FormRules>(() => ({
test_count: [{ required: true, message: t('page.form.ruleTestCountRequired'), trigger: 'blur' }]
}))
/**
* 初始数据
*/
@@ -75,7 +63,7 @@
id: null,
test_count: 100,
weight_config_snapshot: '',
result_counts: '',
result_counts: ''
}
/**
@@ -84,7 +72,7 @@
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
* 监听弹窗打开,初始化表单数据(仅查看)
*/
watch(
() => props.modelValue,
@@ -99,9 +87,7 @@
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
@@ -109,7 +95,7 @@
}
/**
* 初始化表单数据
* 回填表单数据
*/
const initForm = () => {
if (props.data) {
@@ -122,31 +108,10 @@
}
/**
* 关闭弹窗并重置表单
* 关闭弹窗
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(formData)
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,65 @@
import { router } from '@/router'
import type { DatePresetKey } from './dateRangePresets'
export const WALLET_RECORD_PATH = '/dice/player_wallet_record/index'
export const PLAY_RECORD_PATH = '/dice/play_record/index'
export const PLAYER_PATH = '/dice/player/index'
export interface WalletRecordNavOptions {
type?: number
date?: string | null
datePreset?: DatePresetKey
}
export interface PlayRecordNavOptions {
date?: string | null
datePreset?: DatePresetKey
}
function buildDateQuery(opts: { date?: string | null; datePreset?: DatePresetKey }) {
const query: Record<string, string> = {}
if (opts.date) {
query.date = opts.date
} else if (opts.datePreset) {
query.datePreset = opts.datePreset
}
return query
}
export function buildWalletRecordRouteQuery(opts: WalletRecordNavOptions): Record<string, string> {
const query = buildDateQuery(opts)
if (opts.type !== undefined && opts.type !== null) {
query.type = String(opts.type)
}
return query
}
export function buildPlayRecordRouteQuery(opts: PlayRecordNavOptions): Record<string, string> {
return buildDateQuery(opts)
}
export function openWalletRecord(opts: WalletRecordNavOptions) {
const query = buildWalletRecordRouteQuery(opts)
void router.push({ path: WALLET_RECORD_PATH, query }).catch(() => undefined)
}
export function openPlayRecord(opts: PlayRecordNavOptions) {
const query = buildPlayRecordRouteQuery(opts)
void router.push({ path: PLAY_RECORD_PATH, query }).catch(() => undefined)
}
export function openPlayerList(opts: PlayRecordNavOptions) {
const query = buildPlayRecordRouteQuery(opts)
void router.push({ path: PLAYER_PATH, query }).catch(() => undefined)
}
/** 工作台当前统计日期转跳转参数:有选中日用 date清空周统计时用近7天 */
export function dashboardDateNavParams(selectedDate: string | null): {
date?: string
datePreset?: DatePresetKey
} {
if (selectedDate) {
return { date: selectedDate }
}
return { datePreset: 'last7days' }
}

View File

@@ -0,0 +1,101 @@
import type { LocationQuery } from 'vue-router'
export type DatePresetKey = 'today' | 'yesterday' | 'last7days'
function pad2(n: number): string {
return String(n).padStart(2, '0')
}
export function formatDateYmd(date: Date): string {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
}
function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0)
}
function endOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999)
}
export function formatDateTime(date: Date): string {
return `${formatDateYmd(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
}
export function getCreateTimeRangeByPreset(preset: DatePresetKey): [string, string] {
const now = new Date()
if (preset === 'today') {
return [formatDateTime(startOfDay(now)), formatDateTime(endOfDay(now))]
}
if (preset === 'yesterday') {
const day = new Date(now)
day.setDate(day.getDate() - 1)
return [formatDateTime(startOfDay(day)), formatDateTime(endOfDay(day))]
}
const start = new Date(now)
start.setDate(start.getDate() - 6)
return [formatDateTime(startOfDay(start)), formatDateTime(endOfDay(now))]
}
export function getCreateTimeRangeByDate(ymd: string): [string, string] | undefined {
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return undefined
const parts = ymd.split('-').map((v) => Number(v))
const date = new Date(parts[0], parts[1] - 1, parts[2])
if (Number.isNaN(date.getTime())) return undefined
return [formatDateTime(startOfDay(date)), formatDateTime(endOfDay(date))]
}
export function detectPresetFromRange(range?: [string, string] | null): DatePresetKey | null {
if (!range || range.length !== 2) return null
const presets: DatePresetKey[] = ['today', 'yesterday', 'last7days']
for (const preset of presets) {
const expected = getCreateTimeRangeByPreset(preset)
if (expected[0] === range[0] && expected[1] === range[1]) {
return preset
}
}
return null
}
function readQueryString(query: LocationQuery, key: string): string {
const raw = query[key]
if (Array.isArray(raw)) return raw[0] ? String(raw[0]) : ''
return raw != null ? String(raw) : ''
}
export interface RecordRouteFilter {
create_time?: [string, string]
type?: number
datePreset?: DatePresetKey
}
export function parseRecordRouteQuery(query: LocationQuery): RecordRouteFilter {
const result: RecordRouteFilter = {}
const date = readQueryString(query, 'date')
const datePreset = readQueryString(query, 'datePreset') as DatePresetKey
if (date) {
const range = getCreateTimeRangeByDate(date)
if (range) {
result.create_time = range
}
} else if (datePreset === 'today' || datePreset === 'yesterday' || datePreset === 'last7days') {
result.datePreset = datePreset
result.create_time = getCreateTimeRangeByPreset(datePreset)
}
const typeRaw = readQueryString(query, 'type')
if (typeRaw !== '' && typeRaw !== undefined) {
const typeNum = Number(typeRaw)
if (Number.isFinite(typeNum)) {
result.type = typeNum
}
}
return result
}
export function hasRecordRouteFilter(query: LocationQuery): boolean {
const filter = parseRecordRouteQuery(query)
return filter.create_time != null || filter.type !== undefined
}

View File

@@ -0,0 +1,99 @@
/** 彩金池选项/关联行通用结构 */
export interface LotteryPoolOption {
id: number
name?: string
remark?: string
display_name?: string
}
/** 彩金池后台展示名(奖池名称):优先 display_name / remark不用内部 name 作为首选 */
export function lotteryPoolDisplayLabel(item?: LotteryPoolOption | null): string {
if (!item) {
return '-'
}
const display = String(item.display_name ?? '').trim()
if (display) {
return display
}
const remark = String(item.remark ?? '').trim()
if (remark) {
return remark
}
return String(item.name ?? '').trim() || '-'
}
/** 下拉选项文案默认仅奖池名称withId=true 时附带 ID */
export function lotteryPoolOptionLabel(
item: LotteryPoolOption,
options?: { withId?: boolean }
): string {
const label = lotteryPoolDisplayLabel(item)
if (options?.withId && item.id > 0) {
return label !== '-' ? `${label} (#${item.id})` : `#${item.id}`
}
return label !== '-' ? label : `#${item.id}`
}
/** 列表行关联彩金池(含 diceLotteryPoolConfig 关联) */
export function lotteryPoolRowLabel(row?: {
diceLotteryPoolConfig?: LotteryPoolOption | null
lottery_config_id?: number | null | string
} | null): string {
if (!row) {
return '-'
}
const pool = row.diceLotteryPoolConfig
if (pool && (pool.id || pool.remark || pool.name || pool.display_name)) {
return lotteryPoolDisplayLabel(pool)
}
const id = row.lottery_config_id
if (id !== null && id !== undefined && id !== '') {
return `#${id}`
}
return '-'
}
/** 规范化接口返回的彩金池选项 */
export function normalizeLotteryPoolOption(raw: Record<string, unknown>): LotteryPoolOption {
const id = Number(raw.id ?? 0)
const name = String(raw.name ?? '')
const remark = String(raw.remark ?? '')
const displayName = String(raw.display_name ?? '').trim()
return {
id,
name,
remark,
display_name: displayName !== '' ? displayName : remark !== '' ? remark : name
}
}
/** 按奖池名称 / 内部标识 / ID 过滤下拉 */
export function filterLotteryPoolOptionsByQuery(
list: LotteryPoolOption[],
query: string
): LotteryPoolOption[] {
const q = (query || '').trim().toLowerCase()
if (!q) {
return [...list]
}
return list.filter((item) => {
const label = lotteryPoolDisplayLabel(item).toLowerCase()
const code = String(item.name ?? '').toLowerCase()
return label.includes(q) || code.includes(q) || String(item.id).includes(q)
})
}
/** 根据 ID 从选项列表解析奖池名称 */
export function lotteryPoolLabelById(
poolId: number | null | undefined,
options: LotteryPoolOption[]
): string {
if (poolId == null || poolId <= 0) {
return '-'
}
const found = options.find((o) => o.id === poolId)
if (found) {
return lotteryPoolDisplayLabel(found)
}
return `#${poolId}`
}

View File

@@ -0,0 +1,549 @@
<template>
<div class="art-full-height admin-guide-page">
<ElCard class="art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<b>{{ $t('page.title') }}</b>
<div v-if="meta.filePath" class="mt-1 text-xs text-g-500">
{{ $t('page.meta.filePath') }}{{ meta.filePath }}
<span v-if="meta.updateTime" class="ml-3">
{{ $t('page.meta.updateTime') }}{{ meta.updateTime }}
</span>
</div>
</div>
<ElSpace wrap>
<ElButton
v-permission="'system:admin_guide:index:read'"
:loading="loading"
@click="loadContent"
>
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
{{ $t('page.toolbar.refresh') }}
</ElButton>
<ElButton
v-if="!isEditing"
v-permission="'system:admin_guide:index:edit'"
type="primary"
@click="startEdit"
>
<template #icon>
<ArtSvgIcon icon="ri:pencil-line" />
</template>
{{ $t('page.toolbar.edit') }}
</ElButton>
<template v-if="isEditing">
<ElButton @click="handleCancel">
{{ $t('page.toolbar.cancel') }}
</ElButton>
<ElButton
v-permission="'system:admin_guide:index:save'"
type="primary"
:loading="saving"
@click="handleSave"
>
<template #icon>
<ArtSvgIcon icon="ri:save-line" />
</template>
{{ $t('page.toolbar.save') }}
</ElButton>
</template>
</ElSpace>
</div>
</template>
<div v-loading="loading" class="admin-guide-body flex-1 min-h-0">
<SaMdEditor
v-if="isEditing"
v-model="editContent"
class="admin-guide-editor"
height="calc(100vh - 220px)"
min-height="480px"
/>
<div v-else class="admin-guide-read flex h-full min-h-0">
<aside class="admin-guide-catalog flex-shrink-0">
<div class="catalog-title">{{ $t('page.catalog.title') }}</div>
<ElScrollbar class="catalog-scroll">
<nav v-if="tocList.length" class="guide-toc">
<button
v-for="(item, index) in tocList"
:key="`${item.level}-${item.text}-${index}`"
type="button"
class="guide-toc-item"
:class="[
`guide-toc-level-${item.level}`,
{ 'is-active': activeTocIndex === index }
]"
:title="item.text"
@click="scrollToHeading(item, index)"
>
{{ item.text }}
</button>
</nav>
<div v-else class="guide-toc-empty">{{ $t('page.catalog.empty') }}</div>
</ElScrollbar>
</aside>
<div
id="admin-guide-scroll"
ref="previewScrollRef"
class="admin-guide-preview-wrap flex-1 min-h-0 overflow-auto"
>
<MdPreview
:editor-id="previewEditorId"
:model-value="previewContent"
:theme="previewTheme"
preview-theme="github"
no-img-zoom-in
class="admin-guide-preview"
@on-html-changed="handlePreviewHtmlReady"
/>
</div>
</div>
</div>
</ElCard>
<ElImageViewer
v-if="imageViewerVisible"
:key="imageViewerIndex"
:url-list="previewImageUrls"
:initial-index="imageViewerIndex"
:hide-on-click-modal="true"
:z-index="3000"
teleported
@close="closeImageViewer"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElImageViewer, ElMessageBox } from 'element-plus'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import SaMdEditor from '@/components/sai/sa-md-editor/index.vue'
import { useSettingStore } from '@/store/modules/setting'
import api from '@/api/system/admin_guide'
defineOptions({ name: 'SystemAdminGuide' })
interface GuideTocItem {
level: number
text: string
}
const { t } = useI18n()
const settingStore = useSettingStore()
const previewEditorId = 'admin-guide-preview'
const previewScrollRef = ref<HTMLElement | null>(null)
const loading = ref(false)
const saving = ref(false)
const isEditing = ref(false)
const previewContent = ref('')
const editContent = ref('')
const originalContent = ref('')
const meta = ref<{ filePath?: string; updateTime?: string }>({})
const tocList = ref<GuideTocItem[]>([])
const activeTocIndex = ref(-1)
const previewImageUrls = ref<string[]>([])
const imageViewerVisible = ref(false)
const imageViewerIndex = ref(0)
let previewImageClickHandler: ((event: Event) => void) | null = null
let previewEnhanceTimer: ReturnType<typeof setTimeout> | null = null
const previewTheme = computed(() => (settingStore.isDark ? 'dark' : 'light'))
const getStaticFileBase = (): string => {
const apiUrl = import.meta.env.VITE_API_URL || ''
if (apiUrl.startsWith('http')) {
return apiUrl.replace(/\/$/, '')
}
const proxyUrl = import.meta.env.VITE_API_PROXY_URL || ''
if (proxyUrl) {
return String(proxyUrl).replace(/\/$/, '')
}
return ''
}
const resolveGuideImages = (content: string): string => {
const staticBase = getStaticFileBase()
if (!staticBase) {
return content
}
return content.replace(/!\[([^\]]*)\]\((\/docs\/picture\/[^)]+)\)/g, (_match, alt, path) => {
return `![${alt}](${staticBase}${path})`
})
}
const parseTocFromMarkdown = (content: string): GuideTocItem[] => {
const items: GuideTocItem[] = []
for (const line of content.split('\n')) {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (!match) {
continue
}
items.push({
level: match[1].length,
text: match[2].trim()
})
}
return items
}
const refreshTocList = () => {
tocList.value = parseTocFromMarkdown(originalContent.value)
activeTocIndex.value = -1
}
const collectPreviewImages = () => {
const container = previewScrollRef.value
if (!container) {
previewImageUrls.value = []
return
}
const imgs = container.querySelectorAll('img')
previewImageUrls.value = Array.from(imgs).map((img) => {
const el = img as HTMLImageElement
return el.currentSrc || el.src
})
}
const findImageIndex = (img: HTMLImageElement): number => {
collectPreviewImages()
const targetSrc = img.currentSrc || img.src
let index = previewImageUrls.value.indexOf(targetSrc)
if (index >= 0) {
return index
}
index = previewImageUrls.value.findIndex((url) => targetSrc.endsWith(url) || url.endsWith(targetSrc))
if (index >= 0) {
return index
}
previewImageUrls.value = [...previewImageUrls.value, targetSrc]
return previewImageUrls.value.length - 1
}
const openImageViewerByImg = (img: HTMLImageElement) => {
const index = findImageIndex(img)
openImageViewer(index)
}
const openImageViewer = (index: number) => {
if (index < 0 || index >= previewImageUrls.value.length) {
return
}
imageViewerIndex.value = index
imageViewerVisible.value = true
}
const closeImageViewer = () => {
imageViewerVisible.value = false
}
const unbindPreviewImageClick = (clearTimer = true) => {
const container = previewScrollRef.value
if (container && previewImageClickHandler) {
container.removeEventListener('click', previewImageClickHandler, true)
}
previewImageClickHandler = null
if (clearTimer && previewEnhanceTimer) {
clearTimeout(previewEnhanceTimer)
previewEnhanceTimer = null
}
}
const bindPreviewImageClick = () => {
unbindPreviewImageClick(false)
const container = previewScrollRef.value
if (!container) {
return
}
collectPreviewImages()
previewImageClickHandler = (event: Event) => {
const imgEl = (event.target as Element | null)?.closest('img')
if (!(imgEl instanceof HTMLImageElement)) {
return
}
if (!container.contains(imgEl)) {
return
}
event.preventDefault()
event.stopPropagation()
openImageViewerByImg(imgEl)
}
container.addEventListener('click', previewImageClickHandler, true)
const imgs = container.querySelectorAll('img')
imgs.forEach((img) => {
const el = img as HTMLImageElement
el.style.cursor = 'zoom-in'
el.title = t('page.image.zoom')
})
}
const handlePreviewHtmlReady = () => {
if (previewEnhanceTimer) {
clearTimeout(previewEnhanceTimer)
}
previewEnhanceTimer = setTimeout(() => {
bindPreviewImageClick()
previewEnhanceTimer = null
}, 50)
}
const setupPreviewEnhancements = async () => {
await nextTick()
refreshTocList()
handlePreviewHtmlReady()
}
const scrollToHeading = (item: GuideTocItem, index: number) => {
const container = previewScrollRef.value
if (!container) {
return
}
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6')
for (const heading of Array.from(headings)) {
if (heading.textContent?.trim() === item.text) {
activeTocIndex.value = index
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
}
}
const loadContent = async () => {
loading.value = true
unbindPreviewImageClick()
try {
const res = await api.read()
const data = res as { content?: string; file_path?: string; update_time?: string }
const rawContent = data.content ?? ''
editContent.value = rawContent
originalContent.value = rawContent
previewContent.value = resolveGuideImages(rawContent)
meta.value = {
filePath: data.file_path,
updateTime: data.update_time
}
await setupPreviewEnhancements()
} catch {
// 错误由 http 工具统一处理
} finally {
loading.value = false
}
}
const startEdit = () => {
editContent.value = originalContent.value
isEditing.value = true
unbindPreviewImageClick()
}
const handleCancel = async () => {
if (editContent.value !== originalContent.value) {
try {
await ElMessageBox.confirm(t('page.message.cancelConfirm'), {
type: 'warning'
})
} catch {
return
}
}
editContent.value = originalContent.value
isEditing.value = false
await setupPreviewEnhancements()
}
const handleSave = async () => {
if (!isEditing.value) {
return
}
saving.value = true
try {
const res = await api.save({ content: editContent.value })
const data = res as { content?: string; file_path?: string; update_time?: string }
const savedContent = data.content ?? editContent.value
editContent.value = savedContent
originalContent.value = savedContent
previewContent.value = resolveGuideImages(savedContent)
meta.value = {
filePath: data.file_path ?? meta.value.filePath,
updateTime: data.update_time ?? meta.value.updateTime
}
isEditing.value = false
await setupPreviewEnhancements()
} catch {
// 错误由 http 工具统一处理
} finally {
saving.value = false
}
}
watch(previewContent, async () => {
if (!isEditing.value) {
await nextTick()
handlePreviewHtmlReady()
}
})
onMounted(() => {
loadContent()
})
onBeforeUnmount(() => {
unbindPreviewImageClick()
})
</script>
<style scoped lang="scss">
.admin-guide-page {
:deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding-top: 12px;
}
}
.admin-guide-body {
width: 100%;
min-height: 0;
}
.admin-guide-read {
min-height: 0;
gap: 0;
}
.admin-guide-catalog {
display: flex;
flex-direction: column;
width: 240px;
min-height: 0;
padding: 4px 12px 12px 4px;
border-right: 1px solid var(--el-border-color-lighter);
}
.catalog-title {
flex-shrink: 0;
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.catalog-scroll {
flex: 1;
min-height: 0;
}
.guide-toc {
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 8px;
}
.guide-toc-item {
display: block;
width: 100%;
padding: 5px 8px;
overflow: hidden;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-regular);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
transition: color 0.2s, background-color 0.2s;
&:hover,
&.is-active {
color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
}
.guide-toc-level-1 {
padding-left: 8px;
font-weight: 600;
}
.guide-toc-level-2 {
padding-left: 20px;
}
.guide-toc-level-3 {
padding-left: 32px;
font-size: 12px;
}
.guide-toc-level-4 {
padding-left: 44px;
font-size: 12px;
}
.guide-toc-level-5,
.guide-toc-level-6 {
padding-left: 56px;
font-size: 12px;
}
.guide-toc-empty {
padding: 8px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.admin-guide-preview-wrap {
position: relative;
padding: 8px 12px 8px 16px;
}
.admin-guide-preview {
padding: 0;
}
.admin-guide-editor {
width: 100%;
}
@media (max-width: 768px) {
.admin-guide-read {
flex-direction: column;
}
.admin-guide-catalog {
width: 100%;
max-height: 200px;
padding-right: 0;
border-right: none;
border-bottom: 1px solid var(--el-border-color-lighter);
}
}
</style>
<style lang="scss">
.admin-guide-preview-wrap .md-editor-preview-wrapper img,
.admin-guide-preview-wrap .md-editor-preview img,
.admin-guide-editor .md-editor-preview-wrapper img,
.admin-guide-editor .md-editor-preview img {
display: block;
width: 50% !important;
max-width: 50% !important;
height: auto !important;
margin: 8px 0;
cursor: zoom-in;
}
</style>

View File

@@ -1,10 +1,8 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
@@ -14,30 +12,22 @@
</template>
{{ $t('table.actions.add') }}
</ElButton>
<ElButton @click="toggleExpand" v-ripple>
<template #icon>
<ArtSvgIcon v-if="isExpanded" icon="ri:collapse-diagonal-line" />
<ArtSvgIcon v-else icon="ri:expand-diagonal-line" />
</template>
{{ isExpanded ? $t('table.searchBar.collapse') : $t('table.searchBar.expand') }}
<ElButton v-permission="'core:dept:update'" @click="handleSyncConfigs" v-ripple>
补齐渠道配置
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:default-expand-all="true"
@sort-change="handleSortChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
@@ -48,20 +38,26 @@
<SaButton
v-permission="'core:dept:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
@click="openDeleteDialog(row)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 编辑弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
<DeleteChannelDialog
v-model="deleteDialogVisible"
:dept-id="deleteDeptId"
:dept-name="deleteDeptName"
@success="refreshData"
/>
</div>
</template>
@@ -69,27 +65,26 @@
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/system/dept'
import { ElMessage } from 'element-plus'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import DeleteChannelDialog from './modules/delete-channel-dialog.vue'
// 状态管理
const isExpanded = ref(true)
const tableRef = ref()
// 搜索表单
const searchForm = ref({
name: undefined,
code: undefined,
status: undefined
})
// 搜索处理
const deleteDialogVisible = ref(false)
const deleteDeptId = ref<number | null>(null)
const deleteDeptName = ref('')
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
@@ -118,26 +113,20 @@
}
})
// 编辑配置
const { dialogType, dialogVisible, dialogData, showDialog, deleteRow } = useSaiAdmin()
const { dialogType, dialogVisible, dialogData, showDialog } = useSaiAdmin()
/**
* 切换展开/收起所有菜单
*/
const toggleExpand = (): void => {
isExpanded.value = !isExpanded.value
nextTick(() => {
if (tableRef.value?.elTableRef && data.value) {
const processRows = (rows: any[]) => {
rows.forEach((row) => {
if (row.children?.length) {
tableRef.value.elTableRef.toggleRowExpansion(row, isExpanded.value)
processRows(row.children)
}
})
}
processRows(data.value)
}
})
const openDeleteDialog = (row: any) => {
deleteDeptId.value = row.id
deleteDeptName.value = row.name
deleteDialogVisible.value = true
}
const handleSyncConfigs = async () => {
try {
await api.syncChannelConfigs()
ElMessage.success('已为缺失配置的渠道补齐默认配置')
} catch (e: any) {
ElMessage.error(e?.message || '补齐失败')
}
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<el-dialog
v-model="visible"
title="删除渠道"
width="560px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<div v-loading="loading">
<p class="mb-3 text-sm text-gray-600">确定删除渠道{{ deptName }}可勾选一并删除的关联数据</p>
<el-alert
v-if="preview?.user_count > 0"
type="error"
:closable="false"
class="mb-3"
:title="`该渠道下仍有 ${preview.user_count} 个用户,请先转移或删除用户`"
/>
<el-checkbox-group v-model="checkedTables" class="flex flex-col gap-2">
<el-checkbox
v-for="item in preview?.relations || []"
:key="item.table"
:label="item.table"
:disabled="preview?.user_count > 0"
>
{{ item.label }}{{ item.count }}
</el-checkbox>
</el-checkbox-group>
<p v-if="!preview?.relations?.length" class="text-sm text-gray-500">无关联业务数据仅删除渠道本身</p>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button
type="danger"
:loading="submitting"
:disabled="preview?.user_count > 0"
@click="handleConfirm"
>
确认删除
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/dept'
import { ElMessage } from 'element-plus'
interface Props {
modelValue: boolean
deptId?: number | null
deptName?: string
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
deptId: null,
deptName: ''
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const loading = ref(false)
const submitting = ref(false)
const preview = ref<any>(null)
const checkedTables = ref<string[]>([])
watch(
() => props.modelValue,
async (open) => {
if (!open || !props.deptId) {
return
}
loading.value = true
checkedTables.value = []
try {
const res: any = await api.destroyPreview(props.deptId)
const list = res?.data ?? res
preview.value = Array.isArray(list) ? list[0] : list
checkedTables.value = (preview.value?.relations || []).map((r: any) => r.table)
} finally {
loading.value = false
}
}
)
const handleClose = () => {
visible.value = false
}
const handleConfirm = async () => {
if (!props.deptId) {
return
}
submitting.value = true
try {
await api.delete({ ids: props.deptId, delete_tables: checkedTables.value })
ElMessage.success('删除成功')
emit('success')
handleClose()
} catch (e: unknown) {
let msg = '删除失败'
if (e !== null && typeof e === 'object' && 'message' in e) {
const m = Reflect.get(e, 'message')
if (typeof m === 'string' && m !== '') {
msg = m
}
}
ElMessage.error(msg)
} finally {
submitting.value = false
}
}
</script>

View File

@@ -8,15 +8,6 @@
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item :label="$t('page.form.labelParentDept')" prop="parent_id">
<el-tree-select
v-model="formData.parent_id"
:data="optionData.treeData"
:render-after-expand="false"
check-strictly
clearable
/>
</el-form-item>
<el-form-item :label="$t('page.form.labelDeptName')" prop="name">
<el-input v-model="formData.name" :placeholder="$t('page.form.placeholderDeptName')" />
</el-form-item>
@@ -75,36 +66,21 @@
const { t } = useI18n()
const formRef = ref<FormInstance>()
const optionData = reactive({
treeData: <any[]>[]
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = computed<FormRules>(() => ({
parent_id: [
{ required: true, message: t('page.form.ruleParentDeptRequired'), trigger: 'change' }
],
name: [{ required: true, message: t('page.form.ruleDeptNameRequired'), trigger: 'blur' }],
code: [{ required: true, message: t('page.form.ruleDeptCodeRequired'), trigger: 'blur' }]
}))
/**
* 初始数据
*/
const initialFormData = {
id: null,
parent_id: null,
level: '',
parent_id: 0,
level: '0',
name: '',
code: '',
leader_id: null,
@@ -113,14 +89,8 @@
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
@@ -130,33 +100,14 @@
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
const data = await api.list({ tree: true })
optionData.treeData = [
{
id: 0,
value: 0,
label: t('page.form.noParentDept'),
children: data
}
]
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
@@ -167,21 +118,17 @@
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
formData.parent_id = 0
formData.level = '0'
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success(t('page.form.addSuccess'))

View File

@@ -1,142 +0,0 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton v-permission="'core:post:save'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
{{ $t('table.actions.add') }}
</ElButton>
<ElButton
v-permission="'core:post:destroy'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
{{ $t('table.actions.delete') }}
</ElButton>
<SaImport
v-permission="'core:post:import'"
download-url="/core/post/downloadTemplate"
upload-url="/core/post/import"
@success="refreshData"
/>
<SaExport v-permission="'core:post:export'" url="/core/post/export" />
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:post:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:post:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 编辑弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/system/post'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
// 搜索表单
const searchForm = ref({
name: undefined,
code: undefined,
status: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: 'table.columns.common.no', width: 100, align: 'center' },
{ prop: 'name', label: 'page.table.postName', minWidth: 120 },
{ prop: 'code', label: 'page.table.postCode', minWidth: 120 },
{ prop: 'remark', label: 'table.columns.common.description', minWidth: 150, showOverflowTooltip: true },
{ prop: 'sort', label: 'page.table.sort', width: 100 },
{ prop: 'status', label: 'page.table.status', saiType: 'dict', saiDict: 'data_status', width: 100 },
{ prop: 'create_time', label: 'page.table.createTime', width: 180, sortable: true },
{ prop: 'operation', label: 'table.actions.operation', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
</script>

View File

@@ -1,166 +0,0 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? $t('page.form.titleAdd') : $t('page.form.titleEdit')"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item :label="$t('page.form.labelName')" prop="name">
<el-input v-model="formData.name" :placeholder="$t('page.form.placeholderName')" />
</el-form-item>
<el-form-item :label="$t('page.form.labelCode')" prop="code">
<el-input v-model="formData.code" :placeholder="$t('page.form.placeholderCode')" />
</el-form-item>
<el-form-item :label="$t('page.form.labelRemark')" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
:placeholder="$t('page.form.placeholderRemark')"
/>
</el-form-item>
<el-form-item :label="$t('page.form.labelSort')" prop="sort">
<el-input-number v-model="formData.sort" :placeholder="$t('page.form.placeholderSort')" />
</el-form-item>
<el-form-item :label="$t('page.form.labelStatus')" prop="status">
<sa-radio v-model="formData.status" dict="data_status" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleSubmit">{{ $t('table.form.submit') }}</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/post'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const { t } = useI18n()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = computed<FormRules>(() => ({
name: [{ required: true, message: t('page.form.ruleNameRequired'), trigger: 'blur' }],
code: [{ required: true, message: t('page.form.ruleCodeRequired'), trigger: 'blur' }]
}))
/**
* 初始数据
*/
const initialFormData = {
id: null,
name: '',
code: '',
remark: '',
sort: 100,
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(formData)
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -1,77 +0,0 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item :label="$t('page.search.postName')" prop="name">
<el-input v-model="formData.name" :placeholder="$t('page.search.placeholderPostName')" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item :label="$t('page.search.postCode')" prop="code">
<el-input v-model="formData.code" :placeholder="$t('page.search.placeholderPostCode')" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item :label="$t('page.search.status')" prop="status">
<sa-select v-model="formData.status" dict="data_status" :placeholder="$t('page.search.searchSelectPlaceholder')" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -125,6 +125,10 @@
} = useTable({
core: {
apiFn: api.list,
apiParams: {
orderField: 'level',
orderType: 'desc'
},
columnsFactory: () => [
{ prop: 'id', label: 'table.columns.common.no', minWidth: 60, align: 'center' },
{ prop: 'name', label: 'page.table.roleName', minWidth: 120 },

View File

@@ -41,6 +41,7 @@
<script setup lang="ts">
import api from '@/api/system/role'
import { withChannelDeptParams } from '@/composables/useChannelDeptScope'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
@@ -155,11 +156,12 @@
if (!formRef.value) return
try {
await formRef.value.validate()
const payload = withChannelDeptParams({ ...formData })
if (props.dialogType === 'add') {
await api.save(formData)
await api.save(payload)
ElMessage.success(t('page.form.addSuccess'))
} else {
await api.update(formData)
await api.update(payload)
ElMessage.success(t('page.form.editSuccess'))
}
emit('success')

View File

@@ -1,16 +1,24 @@
<template>
<div class="art-full-height">
<div class="box-border flex gap-4 h-full max-md:block max-md:gap-0 max-md:h-auto">
<div class="flex-shrink-0 w-64 h-full max-md:w-full max-md:h-auto max-md:mb-5">
<ElCard class="tree-card art-card-xs flex flex-col h-full mt-0" shadow="never">
<div
v-show="showChannelSidebar"
class="flex-shrink-0 w-64 h-full max-md:w-full max-md:h-auto max-md:mb-5"
>
<ElCard
class="tree-card art-card-xs flex flex-col h-full mt-0"
shadow="never"
v-loading="channelTreeLoading"
>
<template #header>
<b>部门列表</b>
<b>{{ $t('page.ui.channelList') }}</b>
</template>
<ElScrollbar>
<ElTree
:data="treeData"
:props="{ children: 'children', label: 'label' }"
node-key="id"
:current-node-key="currentChannelId"
default-expand-all
highlight-current
@node-click="handleNodeClick"
@@ -136,10 +144,19 @@
import WorkDialog from './modules/work-dialog.vue'
import api from '@/api/system/user'
import deptApi from '@/api/system/dept'
import { isSuperAdminUser } from '@/utils/channelLayout'
const userStore = useUserStore()
const treeData = ref([])
interface ChannelTreeNode {
id: number
label: string
children?: ChannelTreeNode[]
}
const treeData = ref<ChannelTreeNode[]>([])
const channelTreeLoading = ref(false)
const currentChannelId = ref<number | undefined>(undefined)
// 编辑框
const { dialogType, dialogVisible, dialogData, showDialog, handleSelectionChange, deleteRow } =
@@ -228,24 +245,79 @@
const handleReset = () => {
searchForm.value.dept_id = undefined
resetSearchParams()
if (isSuperAdminUser()) {
currentChannelId.value = undefined
} else {
applyDefaultChannelSelection()
}
}
/**
* 切换部门
* @param data
*/
const handleNodeClick = (data: any) => {
const handleNodeClick = (data: ChannelTreeNode) => {
currentChannelId.value = data.id
searchParams.dept_id = data.id
getData()
}
/** 仅超管显示左侧渠道列表;渠道管理员固定本渠道,由后端过滤 */
const showChannelSidebar = computed(() => isSuperAdminUser())
const normalizeChannelTree = (list: unknown[]): ChannelTreeNode[] => {
if (!Array.isArray(list)) {
return []
}
return list
.map((item) => {
const row = item as Record<string, unknown>
const id = Number(row.id ?? row.value ?? 0)
const label = String(row.label ?? row.name ?? '')
return { id, label }
})
.filter((node) => node.id > 0 && node.label !== '')
}
const fallbackChannelTree = (): ChannelTreeNode[] => {
const dept = userStore.info?.department
if (!dept || Number(dept.id) <= 0) {
return []
}
return [
{
id: Number(dept.id),
label: String(dept.name ?? '')
}
]
}
const applyDefaultChannelSelection = () => {
if (treeData.value.length === 0 || isSuperAdminUser()) {
return
}
const first = treeData.value[0]
currentChannelId.value = first.id
searchParams.dept_id = first.id
getData()
}
/**
* 获取部门数据
* 获取可操作渠道(渠道管理员至少展示本渠道)
*/
const getDeptList = () => {
deptApi.accessDept().then((data: any) => {
treeData.value = data
})
const getDeptList = async () => {
channelTreeLoading.value = true
try {
const data = await deptApi.accessDept()
const nodes = normalizeChannelTree(Array.isArray(data) ? data : [])
treeData.value = nodes.length > 0 ? nodes : fallbackChannelTree()
applyDefaultChannelSelection()
} catch {
treeData.value = fallbackChannelTree()
applyDefaultChannelSelection()
} finally {
channelTreeLoading.value = false
}
}
/**
@@ -283,6 +355,10 @@
}
onMounted(() => {
getDeptList()
if (isSuperAdminUser()) {
getDeptList()
} else {
applyDefaultChannelSelection()
}
})
</script>

View File

@@ -52,13 +52,14 @@
<el-row>
<el-col :span="12">
<el-form-item :label="$t('page.form.labelDept')" prop="dept_id">
<el-tree-select
v-model="formData.dept_id"
:data="optionData.deptData"
:render-after-expand="false"
check-strictly
clearable
/>
<el-select v-model="formData.dept_id" clearable filterable>
<el-option
v-for="item in optionData.deptData"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -76,18 +77,6 @@
</el-row>
<el-row>
<el-col :span="12">
<el-form-item :label="$t('page.form.labelPost')" prop="post_ids">
<el-select v-model="formData.post_ids" multiple clearable>
<el-option
v-for="post in optionData.postList"
:key="(post as any)?.id"
:value="(post as any)?.id"
:label="(post as any)?.name"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="$t('page.form.labelGender')" prop="gender">
<sa-radio v-model="formData.gender" dict="gender" valueType="string" />
@@ -129,7 +118,6 @@
import api from '@/api/system/user'
import deptApi from '@/api/system/dept'
import roleApi from '@/api/system/role'
import postApi from '@/api/system/post'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
@@ -156,8 +144,7 @@
const formRef = ref<FormInstance>()
const optionData = reactive({
deptData: <any>[],
roleList: <any>[],
postList: <any>[]
roleList: <any>[]
})
/**
@@ -207,7 +194,6 @@
phone: '',
email: '',
role_ids: [],
post_ids: [],
status: 1,
gender: '',
remark: ''
@@ -221,6 +207,41 @@
/**
* 监听弹窗打开,初始化表单数据
*/
const flattenDeptOptions = (list: any[], result: { id: number; label: string }[] = []) => {
for (const item of list) {
const id = item.id ?? item.value
if (id !== undefined && id !== null) {
result.push({
id,
label: String(item.label ?? item.name ?? id)
})
}
if (item.children?.length) {
flattenDeptOptions(item.children, result)
}
}
return result
}
const loadRoleOptions = async () => {
const deptId = formData.dept_id
const params =
deptId !== undefined && deptId !== null && deptId !== ''
? { dept_id: deptId }
: undefined
const roleData = await roleApi.accessRole(params)
optionData.roleList = Array.isArray(roleData) ? roleData : []
}
watch(
() => formData.dept_id,
() => {
if (props.modelValue) {
void loadRoleOptions()
}
}
)
watch(
() => props.modelValue,
(newVal) => {
@@ -229,33 +250,23 @@
}
}
)
// 初始化页面数据
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 部门数据
const deptData = await deptApi.accessDept()
optionData.deptData = deptData
optionData.deptData = flattenDeptOptions(Array.isArray(deptData) ? deptData : [])
// 角色数据
const roleData = await roleApi.accessRole()
optionData.roleList = roleData
// 岗位数据
const postData = await postApi.accessPost()
optionData.postList = postData
// 如果有数据,则填充数据
if (props.data) {
if (props.data?.id) {
await nextTick()
if (props.data.id) {
let data = await api.read(props.data.id)
if (data.postList) {
const post = (data.postList as any[])?.map((item: any) => item.id)
data.post_ids = post
}
const role = (data.roleList as any[])?.map((item: any) => item.id)
data.role_ids = role
data.password = ''
initForm(data)
}
const data = await api.read(props.data.id)
const role = (data.roleList as any[])?.map((item: any) => item.id)
data.role_ids = role
data.password = ''
initForm(data)
await loadRoleOptions()
} else {
await loadRoleOptions()
}
}

View File

@@ -1,10 +1,11 @@
# 数据库配置
DB_TYPE=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=dafuweng-v3
DB_USER=dafuweng-v3
DB_PASSWORD=tA6rciKLKxpFNGAm
DB_PASSWORD=123456
DB_PREFIX=
DB_POOL_MAX=32
DB_POOL_MIN=4
@@ -28,14 +29,16 @@ WEBMAN_CHANNEL_LISTEN_HOST=0.0.0.0
GAME_URL=dice-v3-game.h55555game.top
# API 鉴权与用户(可选,不填则用默认值)
# 平台对接 /api/v1/* 请求头 api-key必填与对接方约定
API_KEY=
# authToken 签名密钥(必填,与客户端约定,用于 signature 校验)
API_AUTH_TOKEN_SECRET=xF75oK91TQj13s0UmNIr1NBWMWGfflNO
API_AUTH_TOKEN_SECRET=
# authToken 时间戳允许误差秒数,防重放,默认 300
API_AUTH_TOKEN_TIME_TOLERANCE=300
API_AUTH_TOKEN_EXP=86400
# API_USER_TOKEN_EXP=604800
API_USER_CACHE_EXPIRE=86400
API_USER_ENCRYPT_KEY=Wj818SK8dhKBKNOY3PUTmZfhQDMCXEZi
API_USER_ENCRYPT_KEY=
# 验证码配置,支持cache|session
CAPTCHA_MODE=cache

View File

@@ -10,6 +10,7 @@ use support\think\Db;
use app\api\logic\GameLogic;
use app\api\logic\PlayStartLogic;
use app\api\util\ReturnCode;
use app\dice\helper\AdminScopeHelper;
use app\dice\model\config\DiceConfig;
use app\dice\model\ante_config\DiceAnteConfig;
use app\dice\model\play_record\DicePlayRecord;
@@ -34,7 +35,12 @@ class GameController extends BaseController
*/
public function config(Request $request): Response
{
$rows = DiceConfig::select('name', 'group', 'title', 'title_en', 'value', 'value_en', 'create_time', 'update_time')->get();
$configDeptId = $this->resolvePlayerConfigDeptIdFromRequest($request);
$rows = (new DiceConfig())
->field('name,group,title,title_en,value,value_en,create_time,update_time')
->where('dept_id', $configDeptId)
->select()
->toArray();
$lang = $request->header('lang', 'zh');
if (!is_string($lang) || $lang === '') {
$lang = 'zh';
@@ -43,15 +49,15 @@ class GameController extends BaseController
$isEn = $langLower === 'en' || str_starts_with($langLower, 'en-');
$data = [];
foreach ($rows as $row) {
$group = $row->group ?? '';
$group = $row['group'] ?? '';
if (!isset($data[$group])) {
$data[$group] = [];
}
$title = $row->title;
$value = $row->value;
$title = $row['title'] ?? '';
$value = $row['value'] ?? '';
if ($isEn) {
$titleEn = $row->title_en ?? '';
$valueEn = $row->value_en ?? '';
$titleEn = $row['title_en'] ?? '';
$valueEn = $row['value_en'] ?? '';
if ($titleEn !== '') {
$title = $titleEn;
}
@@ -60,11 +66,11 @@ class GameController extends BaseController
}
}
$data[$group][] = [
'name' => $row->name,
'name' => $row['name'] ?? '',
'title' => $title,
'value' => $value,
'create_time' => $row->create_time,
'update_time' => $row->update_time,
'create_time' => $row['create_time'] ?? '',
'update_time' => $row['update_time'] ?? '',
];
}
return $this->success($data);
@@ -107,7 +113,8 @@ class GameController extends BaseController
*/
public function lotteryPool(Request $request): Response
{
$list = DiceRewardConfig::getCachedList();
$configDeptId = $this->resolvePlayerConfigDeptIdFromRequest($request);
$list = DiceRewardConfig::getCachedList($configDeptId);
$list = array_values(array_filter($list, function ($row) {
return (string) ($row['tier'] ?? '') !== 'BIGWIN';
}));
@@ -145,9 +152,9 @@ class GameController extends BaseController
*/
public function anteConfig(Request $request): Response
{
// 用于后续抽奖校验:在接口中实例化 model后续逻辑可复用相同的数据读取方式。
$configDeptId = $this->resolvePlayerConfigDeptIdFromRequest($request);
$anteConfigModel = new DiceAnteConfig();
$rows = $anteConfigModel->order('id', 'asc')->select()->toArray();
$rows = $anteConfigModel->where('dept_id', $configDeptId)->order('id', 'asc')->select()->toArray();
return $this->success($rows);
}
@@ -200,7 +207,8 @@ class GameController extends BaseController
$rewardTier = array_key_exists('reward_tier', $data) ? (string) ($data['reward_tier'] ?? '') : '';
$targetIndex = array_key_exists('target_index', $data) ? (int) ($data['target_index'] ?? 0) : 0;
if ($rewardTier !== 'BIGWIN' && $targetIndex > 0) {
$configRow = DiceRewardConfig::getCachedById($targetIndex);
$configDeptId = AdminScopeHelper::resolvePlayerConfigDeptId($player);
$configRow = DiceRewardConfig::getCachedById($targetIndex, $configDeptId);
if ($configRow !== null) {
$uiText = '';
$uiTextEn = '';
@@ -236,14 +244,19 @@ class GameController extends BaseController
$timeoutRecord = null;
$timeout_message = '';
$adminId = null;
$timeoutDeptId = null;
try {
$timeoutPlayer = DicePlayer::find($userId);
$adminId = ($timeoutPlayer && ($timeoutPlayer->admin_id ?? null)) ? (int) $timeoutPlayer->admin_id : null;
if ($timeoutPlayer && isset($timeoutPlayer->dept_id) && $timeoutPlayer->dept_id !== null && $timeoutPlayer->dept_id !== '') {
$timeoutDeptId = (int) $timeoutPlayer->dept_id;
}
} catch (\Throwable $_) {
}
try {
$timeoutRecord = DicePlayRecord::create([
'player_id' => $userId,
'dept_id' => $timeoutDeptId,
'admin_id' => $adminId,
'lottery_config_id' => 0,
'lottery_type' => 0,
@@ -273,4 +286,20 @@ class GameController extends BaseController
Db::execute('SELECT RELEASE_LOCK(?)', [$lockName]);
}
}
/**
* 从 token 注入的 player_id 解析所属渠道配置 ID
*/
private function resolvePlayerConfigDeptIdFromRequest(Request $request): int
{
$userId = (int) ($request->player_id ?? 0);
if ($userId <= 0) {
return AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
}
$player = DicePlayer::find($userId);
if (!$player) {
return AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
}
return AdminScopeHelper::resolvePlayerConfigDeptId($player);
}
}

View File

@@ -6,6 +6,7 @@ namespace app\api\controller\v1;
use app\api\cache\AuthTokenCache;
use app\api\controller\BaseController;
use app\api\util\ReturnCode;
use plugin\saiadmin\app\model\system\SystemUser;
use support\Request;
use support\Response;
use Tinywan\Jwt\JwtToken;
@@ -54,6 +55,14 @@ class AuthTokenController extends BaseController
return $this->fail('Signature verification failed', ReturnCode::FORBIDDEN);
}
$agent = SystemUser::where('agent_id', $agentId)->find();
if (!$agent || (int) ($agent->status ?? 0) !== 1) {
return $this->fail('Invalid agent_id', ReturnCode::FORBIDDEN);
}
if (empty($agent->dept_id) || (int) $agent->dept_id <= 0) {
return $this->fail('Agent channel is not configured', ReturnCode::FORBIDDEN);
}
$exp = (int) config('api.auth_token_exp', 86400);
$tokenResult = JwtToken::generateToken([
'id' => 0,

View File

@@ -7,7 +7,6 @@ use app\api\logic\UserLogic;
use app\api\util\ReturnCode;
use app\dice\model\game\DiceGame;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\app\model\system\SystemUser;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\player_wallet_record\DicePlayerWalletRecord;
use app\dice\model\player_ticket_record\DicePlayerTicketRecord;
@@ -60,7 +59,7 @@ class GameController extends BaseController
public function getGameList(Request $request): Response
{
$lang = $this->resolveLang($request->post('lang', 'zh'));
$games = $this->buildPublicGameList($lang);
$games = $this->buildPublicGameList($lang, $this->agentDeptId($request));
return $this->success([
'game_list' => $games,
]);
@@ -73,7 +72,7 @@ class GameController extends BaseController
public function getGameHall(Request $request): Response
{
$lang = $this->resolveLang($request->post('lang', 'zh'));
$games = $this->buildPublicGameList($lang);
$games = $this->buildPublicGameList($lang, $this->agentDeptId($request));
$hallUrl = '';
if (!empty($games)) {
$hallUrl = $games[0]['hall_url'] ?? '';
@@ -93,36 +92,26 @@ class GameController extends BaseController
public function getGameUrl(Request $request): Response
{
$username = trim((string) ($request->post('username', '')));
$password = trim((string) ($request->post('password', '123456')));
$time = trim((string) ($request->post('time', '')));
if ($username === '') {
return $this->fail('username is required', ReturnCode::PARAMS_ERROR);
}
if ($password === '') {
$password = '123456';
}
if ($time === '') {
$time = (string) time();
}
$adminId = null;
$adminIdsInTopDept = null;
$agentId = trim((string) ($request->agent_id ?? ''));
if ($agentId !== '') {
$systemUser = SystemUser::where('agent_id', $agentId)->find();
if ($systemUser) {
$adminId = (int) $systemUser->id;
$adminIdsInTopDept = UserLogic::getAdminIdsByAgentIdTopDept($agentId);
}
}
$deptId = $this->agentDeptId($request);
$adminId = $this->agentAdminId($request);
$adminIdsInTopDept = UserLogic::getAdminIdsByAgentIdTopDept(trim((string) ($request->agent_id ?? '')));
$lang = trim((string) ($request->post('lang', 'zh')));
$lang = in_array($lang, ['en', 'zh'], true) ? $lang : 'zh';
try {
$logic = new UserLogic();
$result = $logic->loginByUsername($username, $password, $lang, 0.0, $time, $adminId, $adminIdsInTopDept);
// 平台 v1 已通过 api-key + auth-token 双重校验,此处不再做 password 校验
$result = $logic->loginByUsername($username, '', $lang, 0.0, $time, $adminId, $adminIdsInTopDept, $deptId, true);
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage(), ReturnCode::PARAMS_ERROR);
}
@@ -145,24 +134,36 @@ class GameController extends BaseController
{
$usernameRaw = $request->input('username', '');
$username = is_string($usernameRaw) ? trim($usernameRaw) : '';
$deptId = $this->agentDeptId($request);
if ($username === '') {
return $this->fail('username is required', ReturnCode::PARAMS_ERROR);
}
$cached = UserCache::getPlayerInfoSnapshotByUsername($username);
$cached = UserCache::getPlayerInfoSnapshotByUsername($this->scopedUsername($deptId, $username));
if ($cached !== null) {
return $this->success($cached);
}
$player = DicePlayer::field(self::PLAYER_INFO_DB_FIELDS)->where('username', $username)->find();
try {
$logic = new UserLogic();
$player = $logic->findOrCreatePlayerByUsername(
$username,
$this->agentAdminId($request),
$deptId > 0 ? $deptId : null
);
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage(), ReturnCode::PARAMS_ERROR);
}
$player = DicePlayer::field(self::PLAYER_INFO_DB_FIELDS)->where('id', (int) $player->id)->find();
if (!$player) {
return $this->fail('User not found', ReturnCode::NOT_FOUND);
return $this->fail('User not found', ReturnCode::PARAMS_ERROR);
}
$hidden = ['password', 'lottery_config_id', 't1_weight', 't2_weight', 't3_weight', 't4_weight', 't5_weight', 'delete_time'];
$info = $player->hidden($hidden)->toArray();
UserCache::setPlayerInfoSnapshotByUsername($username, $info);
UserCache::setPlayerInfoSnapshotByUsername($this->scopedUsername($deptId, $username), $info);
return $this->success($info);
}
@@ -276,6 +277,7 @@ class GameController extends BaseController
public function getPlayerGameRecord(Request $request): Response
{
$username = trim((string) ($request->post('username', '')));
$deptId = $this->agentDeptId($request);
$startCreateTime = trim((string) ($request->post('start_create_time', '')));
$endCreateTime = trim((string) ($request->post('end_create_time', '')));
$window = $this->resolvePullRecordTimeWindow($startCreateTime, $endCreateTime);
@@ -284,10 +286,10 @@ class GameController extends BaseController
}
$limit = $this->resolvePullRecordLimit($request);
$query = DicePlayRecord::order('id', 'desc');
$query = DicePlayRecord::where('dept_id', $deptId)->order('id', 'desc');
if ($username !== '') {
$player = DicePlayer::where('username', $username)->find();
$player = $this->findPlayerByUsername($username, $deptId);
if (!$player) {
return $this->success([]);
}
@@ -300,7 +302,7 @@ class GameController extends BaseController
$list = $query->limit($limit)->select()->toArray();
$playerIds = array_unique(array_column($list, 'player_id'));
if (!empty($playerIds)) {
$players = DicePlayer::whereIn('id', $playerIds)->field('id,username,phone')->select()->toArray();
$players = DicePlayer::whereIn('id', $playerIds)->where('dept_id', $deptId)->field('id,username,phone')->select()->toArray();
$playerMap = [];
foreach ($players as $p) {
$playerMap[(int) ($p['id'] ?? 0)] = $p;
@@ -321,6 +323,7 @@ class GameController extends BaseController
public function getPlayerWalletRecord(Request $request): Response
{
$username = trim((string) ($request->post('username', '')));
$deptId = $this->agentDeptId($request);
$startCreateTime = trim((string) ($request->post('start_create_time', '')));
$endCreateTime = trim((string) ($request->post('end_create_time', '')));
$window = $this->resolvePullRecordTimeWindow($startCreateTime, $endCreateTime);
@@ -329,10 +332,10 @@ class GameController extends BaseController
}
$limit = $this->resolvePullRecordLimit($request);
$query = DicePlayerWalletRecord::order('id', 'desc');
$query = DicePlayerWalletRecord::where('dept_id', $deptId)->order('id', 'desc');
if ($username !== '') {
$player = DicePlayer::where('username', $username)->find();
$player = $this->findPlayerByUsername($username, $deptId);
if (!$player) {
return $this->success([]);
}
@@ -357,6 +360,7 @@ class GameController extends BaseController
public function getPlayerTicketRecord(Request $request): Response
{
$username = trim((string) ($request->post('username', '')));
$deptId = $this->agentDeptId($request);
$startCreateTime = trim((string) ($request->post('start_create_time', '')));
$endCreateTime = trim((string) ($request->post('end_create_time', '')));
$window = $this->resolvePullRecordTimeWindow($startCreateTime, $endCreateTime);
@@ -365,10 +369,10 @@ class GameController extends BaseController
}
$limit = $this->resolvePullRecordLimit($request);
$query = DicePlayerTicketRecord::order('id', 'desc');
$query = DicePlayerTicketRecord::where('dept_id', $deptId)->order('id', 'desc');
if ($username !== '') {
$player = DicePlayer::where('username', $username)->find();
$player = $this->findPlayerByUsername($username, $deptId);
if (!$player) {
return $this->success([]);
}
@@ -394,6 +398,7 @@ class GameController extends BaseController
public function setPlayerWallet(Request $request): Response
{
$username = trim((string) ($request->post('username', '')));
$deptId = $this->agentDeptId($request);
$coin = $request->post('coin');
if ($username === '') {
@@ -408,9 +413,9 @@ class GameController extends BaseController
return $this->fail('coin cannot be 0', ReturnCode::PARAMS_ERROR);
}
$player = DicePlayer::where('username', $username)->find();
$player = $this->findPlayerByUsername($username, $deptId);
if (!$player) {
return $this->fail('User not found', ReturnCode::NOT_FOUND);
return $this->fail('User not found', ReturnCode::PARAMS_ERROR);
}
$walletBefore = (float) ($player->coin ?? 0);
@@ -430,6 +435,7 @@ class GameController extends BaseController
$adminId = ($player->admin_id ?? null) ? (int) $player->admin_id : null;
$record = DicePlayerWalletRecord::create([
'dept_id' => $deptId,
'player_id' => (int) $player->id,
'admin_id' => $adminId,
'coin' => $coinVal,
@@ -452,6 +458,7 @@ class GameController extends BaseController
UserCache::deleteUser($player->id);
if ($player->username !== '') {
UserCache::deletePlayerByUsername($player->username);
UserCache::deletePlayerByUsername($this->scopedUsername($deptId, (string) $player->username));
}
$recordArr = $record->toArray();
@@ -471,13 +478,14 @@ class GameController extends BaseController
return $langValue;
}
private function buildPublicGameList(string $lang): array
private function buildPublicGameList(string $lang, int $deptId): array
{
$rows = DiceGame::where('status', 1)
->orderBy('sort', 'asc')
->orderBy('id', 'asc')
->select(array_merge(self::GAME_PUBLIC_FIELDS, ['game_name', 'game_name_en']))
->get()
->where('dept_id', $deptId)
->order('sort', 'asc')
->order('id', 'asc')
->field(array_merge(self::GAME_PUBLIC_FIELDS, ['game_name', 'game_name_en']))
->select()
->toArray();
if (empty($rows)) {
return [];
@@ -495,4 +503,26 @@ class GameController extends BaseController
}
return $games;
}
private function agentDeptId(Request $request): int
{
return (int) ($request->agent_dept_id ?? 0);
}
private function agentAdminId(Request $request): ?int
{
$adminId = (int) ($request->agent_admin_id ?? 0);
return $adminId > 0 ? $adminId : null;
}
private function scopedUsername(int $deptId, string $username): string
{
return $deptId . ':' . $username;
}
private function findPlayerByUsername(string $username, int $deptId): ?DicePlayer
{
$player = DicePlayer::where('username', $username)->where('dept_id', $deptId)->find();
return $player ?: null;
}
}

View File

@@ -36,4 +36,26 @@ return [
'BATCH_DELETE_FORBIDDEN' => 'Batch delete is not allowed',
'SUPER_ADMIN_CANNOT_DELETE' => 'Super admin cannot be deleted',
'OLD_PASSWORD_WRONG' => 'Old password is incorrect',
'ADD_SUCCESS' => 'Added successfully',
'UPDATE_SUCCESS' => 'Updated successfully',
'DELETE_SUCCESS' => 'Deleted successfully',
'ADD_FAILED' => 'Add failed',
'UPDATE_FAILED' => 'Update failed',
'DELETE_FAILED' => 'Delete failed',
'NOT_FOUND' => 'Data not found',
'ANTE_MUST_POSITIVE' => 'Ante must be greater than 0',
'ANTE_NOT_ALLOWED' => 'Ante %s is not available for current channel, please select from ante config',
'ANTE_CONFIG_NOT_FOUND' => 'Ante config not found',
'ANTE_CONFIG_NOT_IN_CHANNEL' => 'Ante config does not belong to current channel',
'POOL_CONFIG_NOT_IN_CHANNEL' => 'Pool config does not belong to current channel',
'CHANNEL_DEPT_ID_REQUIRED' => 'Please select a channel, or assign a valid administrator/player for this record',
'INVALID_CHANNEL_DEPT_ID' => 'Invalid channel. Please reselect channel or administrator',
'PLAYER_USERNAME_DEPT_UNIQUE' => 'Username already exists in this channel',
'NO_PERMISSION_UPDATE' => 'No permission to update this record',
'NO_PERMISSION_VIEW' => 'No permission to view this record',
'NO_PERMISSION_OPERATE_PLAYER' => 'No permission to operate this player',
'PLEASE_SELECT_DATA' => 'Please select data to delete',
'OPERATION_SUCCESS' => 'Operation successful',
'TEST_DATA_CLEARED' => 'Test data cleared',
'CLEAR_FAILED' => 'Clear failed: %s',
];

View File

@@ -9,6 +9,28 @@ declare(strict_types=1);
return [
'success' => 'Success',
'fail' => 'Fail',
'add success' => 'Added successfully',
'update success' => 'Updated successfully',
'save success' => 'Saved successfully',
'delete success' => 'Deleted successfully',
'add failed' => 'Add failed',
'update failed' => 'Update failed',
'delete failed' => 'Delete failed',
'not found' => 'Data not found',
'operation success' => 'Operation successful',
'test data cleared' => 'Test data cleared',
'ante must be greater than 0' => 'Ante must be greater than 0',
'ante not allowed: %s' => 'Ante %s is not available for current channel, please select from ante config',
'pool config does not belong to current channel' => 'Pool config does not belong to current channel',
'no permission to update this record' => 'No permission to update this record',
'no permission to view this record' => 'No permission to view this record',
'no permission to operate this player' => 'No permission to operate this player',
'please select data to delete' => 'Please select data to delete',
'please select player' => 'Please select player',
'please login first' => 'Please login first',
'missing player_id' => 'Missing player_id',
'Player not found' => 'Player not found',
'record not found' => 'Record not found',
'username、password 不能为空' => 'username and password are required',
'请携带 token' => 'Please provide token',
'token 无效' => 'Invalid or expired token',
@@ -26,6 +48,9 @@ return [
'没有原因' => 'Unknown reason',
'缺少参数agent_id、secret、time、signature 不能为空' => 'Missing parameters: agent_id, secret, time, signature are required',
'服务端未配置 API_AUTH_TOKEN_SECRET' => 'API_AUTH_TOKEN_SECRET is not configured',
'服务端未配置 API_KEY' => 'API_KEY is not configured',
'请携带 api-key' => 'Please provide api-key',
'api-key 无效' => 'Invalid api-key',
'密钥错误' => 'Invalid secret',
'时间戳已过期或无效,请同步时间' => 'Timestamp expired or invalid, please sync time',
'签名验证失败' => 'Signature verification failed',

View File

@@ -36,5 +36,27 @@ return [
'BATCH_DELETE_FORBIDDEN' => '禁止批量删除操作',
'SUPER_ADMIN_CANNOT_DELETE' => '超级管理员禁止删除',
'OLD_PASSWORD_WRONG' => '原密码错误',
'ADD_SUCCESS' => '添加成功',
'UPDATE_SUCCESS' => '修改成功',
'DELETE_SUCCESS' => '删除成功',
'ADD_FAILED' => '添加失败',
'UPDATE_FAILED' => '修改失败',
'DELETE_FAILED' => '删除失败',
'NOT_FOUND' => '数据不存在',
'ANTE_MUST_POSITIVE' => '底注必须大于 0',
'ANTE_NOT_ALLOWED' => '底注 %s 在当前渠道不可用,请从底注配置中选择',
'ANTE_CONFIG_NOT_FOUND' => '底注配置不存在',
'ANTE_CONFIG_NOT_IN_CHANNEL' => '底注配置不属于当前渠道',
'POOL_CONFIG_NOT_IN_CHANNEL' => '奖池配置不属于当前渠道',
'CHANNEL_DEPT_ID_REQUIRED' => '请选择所属渠道,或为记录指定有效的所属管理员/玩家',
'INVALID_CHANNEL_DEPT_ID' => '渠道无效,请重新选择所属渠道或管理员',
'PLAYER_USERNAME_DEPT_UNIQUE' => '该渠道下用户名已存在',
'NO_PERMISSION_UPDATE' => '无权限修改该记录',
'NO_PERMISSION_VIEW' => '无权限查看该记录',
'NO_PERMISSION_OPERATE_PLAYER' => '无权限操作该玩家',
'PLEASE_SELECT_DATA' => '请选择要删除的数据',
'OPERATION_SUCCESS' => '操作成功',
'TEST_DATA_CLEARED' => '测试数据已清空',
'CLEAR_FAILED' => '清空失败:%s',
];

View File

@@ -70,10 +70,14 @@ class GameLogic
UserCache::setUser($playerId, $updatedUserArr);
$adminId = ($player->admin_id ?? null) ? (int) $player->admin_id : null;
$playerDeptId = isset($player->dept_id) && $player->dept_id !== null && $player->dept_id !== ''
? (int) $player->dept_id
: null;
try {
Db::transaction(function () use (
$player,
$playerId,
$playerDeptId,
$adminId,
$cost,
$coinBefore,
@@ -93,6 +97,7 @@ class GameLogic
DicePlayerWalletRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId,
'coin' => round(-$cost, 2),
'type' => self::WALLET_TYPE_BUY_DRAW,
@@ -106,6 +111,7 @@ class GameLogic
DicePlayerTicketRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId,
'use_coins' => round($cost, 2),
'ante' => 1,

View File

@@ -6,6 +6,7 @@ namespace app\api\logic;
use app\api\cache\UserCache;
use app\api\util\ApiLang;
use app\api\service\LotteryService;
use app\dice\helper\AdminScopeHelper;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\ante_config\DiceAnteConfig;
@@ -49,6 +50,8 @@ class PlayStartLogic
private const SUPER_WIN_GRID_NUMBERS = [5, 10, 15, 20, 25, 30];
/** 5 和 30 抽到即豹子,不参与 BIGWIN 权重判定10/15/20/25 按 BIGWIN weight 判定是否豹子 */
private const SUPER_WIN_ALWAYS_GRID_NUMBERS = [5, 30];
/** T4 惩罚格余额不足时写入游玩记录的备注 */
private const REMARK_T4_INSUFFICIENT_BALANCE = '惩罚格奖励:玩家余额不足,已扣尽钱包剩余余额';
/**
* 执行一局游戏
@@ -64,6 +67,11 @@ class PlayStartLogic
throw new ApiException('User not found');
}
$configDeptId = AdminScopeHelper::resolvePlayerConfigDeptId($player);
$playerDeptId = isset($player->dept_id) && $player->dept_id !== null && $player->dept_id !== ''
? (int) $player->dept_id
: null;
$coin = (float) $player->coin;
if ($ante <= 0) {
throw new ApiException('ante must be a positive integer');
@@ -71,7 +79,7 @@ class PlayStartLogic
// 注数合规校验ante 必须存在于 dice_ante_config.mult
$anteConfigModel = new DiceAnteConfig();
$exists = $anteConfigModel->where('mult', $ante)->count();
$exists = $anteConfigModel->where('mult', $ante)->where('dept_id', $configDeptId)->count();
if ($exists <= 0) {
throw new ApiException('当前注数不合规,请选择正确的注数');
}
@@ -109,8 +117,9 @@ class PlayStartLogic
}
}
$configType0 = DiceLotteryPoolConfig::where('name', 'default')->find();
$configType1 = DiceLotteryPoolConfig::where('name', 'killScore')->find();
$configType0 = DiceLotteryPoolConfig::where('name', 'default')->where('dept_id', $configDeptId)->find();
$configKill = DiceLotteryPoolConfig::where('name', 'killScore')->where('dept_id', $configDeptId)->find();
$configFree = DiceLotteryPoolConfig::where('name', 'free')->where('dept_id', $configDeptId)->find();
if (!$configType0) {
throw new ApiException('Lottery pool config not found (name=default required)');
}
@@ -118,44 +127,43 @@ class PlayStartLogic
// 付费抽奖:开始前扣除费用 ante * UNIT_COST
$paidAmount = $ticketType === self::LOTTERY_TYPE_PAID ? round($ante * self::UNIT_COST, 2) : 0.0;
// 游玩前余额校验(按 T4 惩罚最大值兜底):
// 门槛 = paidAmount(压注*1) + abs(T4最小real_ev)*ante
$t4List = DiceRewardConfig::getCachedByTier('T4');
$t4MinRealEv = null;
foreach ($t4List as $row) {
$ev = $row['real_ev'] ?? null;
if ($ev === null || $ev === '') {
continue;
}
$evFloat = filter_var($ev, FILTER_VALIDATE_FLOAT);
if ($evFloat === false) {
continue;
}
if ($t4MinRealEv === null || $evFloat < $t4MinRealEv) {
$t4MinRealEv = $evFloat;
}
}
$t4PenaltyAbs = $t4MinRealEv === null ? 0.0 : abs($t4MinRealEv) * $ante;
$needMinBalance = round($paidAmount + $t4PenaltyAbs, 2);
if ($coin < $needMinBalance) {
// 付费抽奖余额不足单局费用ante * UNIT_COST时不允许开始惩罚格不足部分在局内扣尽剩余余额
if ($ticketType === self::LOTTERY_TYPE_PAID && $coin < $paidAmount) {
throw new ApiException('余额不足');
}
// 彩金池累计盈利:用于判断是否触发杀分(不再依赖单个玩家累计盈利)
// 该值来自 dice_lottery_pool_config.profit_amount
// 该值来自 dice_lottery_pool_config.profit_amountdefault 奖池)
$poolProfitTotal = $configType0->profit_amount ?? 0;
$safetyLine = (int) ($configType0->safety_line ?? 0);
$killEnabled = ((int) ($configType0->kill_enabled ?? 1)) === 1;
// 盈利>=安全线且开启杀分:付费/免费都用 killScore盈利<安全线:付费用玩家权重,免费用 killScore无则用 default
// 记录 lottery_config_id用池权重时记对应池付费用玩家权重时记 default
$usePoolWeights = ($ticketType === self::LOTTERY_TYPE_PAID && $killEnabled && $poolProfitTotal >= $safetyLine && $configType1 !== null)
|| ($ticketType === self::LOTTERY_TYPE_FREE);
$config = $usePoolWeights
? (($ticketType === self::LOTTERY_TYPE_FREE && $configType1 === null) ? $configType0 : $configType1)
: $configType0;
$usePaidKill = $ticketType === self::LOTTERY_TYPE_PAID
&& $killEnabled
&& $poolProfitTotal >= $safetyLine
&& $configKill !== null;
$playerLinkedPool = $this->resolvePlayerLinkedPoolConfig($player, $configDeptId);
if ($ticketType === self::LOTTERY_TYPE_FREE) {
// 免费抽奖券:使用本渠道 name=free 奖池档位权重;无 free 时回退 default
$config = $configFree ?? $configType0;
$usePoolWeights = true;
} elseif ($usePaidKill) {
$config = $configKill;
$usePoolWeights = true;
} else {
// 付费未触发杀分:关联 playerDefault 时实时读该池权重;否则按玩家行内 T*_weight
$config = $configType0;
$usePoolWeights = false;
if ($playerLinkedPool !== null && $playerLinkedPool->isPlayerDefaultTemplate()) {
$usePoolWeights = true;
$config = $playerLinkedPool;
}
}
// 按档位 T1-T5 抽取后,从 DiceReward 表按当前方向取该档位数据,再按 weight 抽取一条得到 grid_number
$rewardInstance = DiceReward::getCachedInstance();
$rewardInstance = DiceReward::getCachedInstance($configDeptId);
$byTierDirection = $rewardInstance['by_tier_direction'] ?? [];
$maxTierRetry = 10;
$chosen = null;
@@ -197,9 +205,9 @@ class PlayStartLogic
$rollNumber = (int) ($chosen['grid_number'] ?? 0);
$realEv = (float) ($chosen['real_ev'] ?? 0);
// T5/再来一次:以奖励行 tier 为准,并以摇奖档位 $tier 兜底(与 reward_tier 展示一致,避免 dice_reward 行缺 tier 时不发券)
$isTierT5 = (string) ($chosen['tier'] ?? '') === 'T5';
if ($isTierT5 === false && (string) ($tier ?? '') === 'T5') {
$isTierT5 = true;
$isTierPlayAgain = (string) ($chosen['tier'] ?? '') === 'T5';
if ($isTierPlayAgain === false && (string) ($tier ?? '') === 'T5') {
$isTierPlayAgain = true;
}
// 摇色子中奖:按 dice_reward_config.real_ev 直接结算(已乘 ante
$rewardWinCoin = round($realEv * $ante, 2);
@@ -216,7 +224,7 @@ class PlayStartLogic
$superWinCoin = 0.0;
$rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber);
} else {
$bigWinConfig = DiceRewardConfig::getCachedByTierAndGridNumber('BIGWIN', $rollNumber);
$bigWinConfig = DiceRewardConfig::getCachedByTierAndGridNumber('BIGWIN', $rollNumber, $configDeptId);
$alwaysSuperWin = in_array($rollNumber, self::SUPER_WIN_ALWAYS_GRID_NUMBERS, true);
$doSuperWin = $alwaysSuperWin;
if (!$doSuperWin) {
@@ -240,7 +248,7 @@ class PlayStartLogic
// 中 BIGWIN 豹子:不走原奖励流程,不记录原奖励,不触发 T5 再来一次,仅发放豹子奖金
$rewardWinCoin = 0.0;
$realEv = 0.0;
$isTierT5 = false;
$isTierPlayAgain = false;
} else {
$rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber);
}
@@ -259,7 +267,11 @@ class PlayStartLogic
$winCoin = round($superWinCoin + $rewardWinCoin, 2); // 赢取平台币 = 中大奖 + 摇色子中奖(豹子时 rewardWinCoin 已为 0
$record = null;
$settledWinCoin = $winCoin;
$configId = (int) $config->id;
if ($ticketType === self::LOTTERY_TYPE_PAID && !$usePaidKill && $playerLinkedPool !== null) {
$configId = (int) $playerLinkedPool->id;
}
$type0ConfigId = (int) $configType0->id;
$rewardId = ($isWin === 1 && $superWinCoin > 0) ? 0 : $targetIndex; // 中豹子不记录原奖励配置 id
$configName = (string) ($config->name ?? '');
@@ -267,6 +279,7 @@ class PlayStartLogic
try {
Db::transaction(function () use (
$playerId,
$playerDeptId,
$adminId,
$configId,
$type0ConfigId,
@@ -284,30 +297,12 @@ class PlayStartLogic
$startIndex,
$targetIndex,
$rollArray,
$isTierT5,
$isTierPlayAgain,
$tier,
&$record
&$record,
&$settledWinCoin
) {
$rewardTier = ($isWin === 1 && $superWinCoin > 0) ? 'BIGWIN' : (string) ($tier ?? '');
$record = DicePlayRecord::create([
'player_id' => $playerId,
'admin_id' => $adminId,
'lottery_config_id' => $configId,
'lottery_type' => $ticketType,
'ante' => $ante,
'paid_amount' => $paidAmount,
'is_win' => $isWin,
'win_coin' => $winCoin,
'super_win_coin' => $superWinCoin,
'reward_win_coin' => $rewardWinCoin,
'direction' => $direction,
'reward_tier' => $rewardTier,
'start_index' => $startIndex,
'target_index' => $targetIndex,
'roll_array' => is_array($rollArray) ? json_encode($rollArray) : $rollArray,
'roll_number' => is_array($rollArray) ? array_sum($rollArray) : 0,
'status' => self::RECORD_STATUS_SUCCESS,
]);
$p = DicePlayer::find($playerId);
if (!$p) {
@@ -316,10 +311,41 @@ class PlayStartLogic
$coinBefore = (float) $p->coin;
// 开始前先扣付费金额,再加中奖金额(免费抽奖 paid_amount=0
$coinAfter = round($coinBefore - $paidAmount + $winCoin, 2);
// T4 惩罚兜底:扣完购券费用后若余额不足以承受本次惩罚(导致为负),统一按“余额不足”提示
$recordWinCoin = $winCoin;
$recordRewardWinCoin = $rewardWinCoin;
$playRecordRemark = null;
// T4 惩罚:扣完购券费用后若不足以支付全额惩罚,则扣尽钱包剩余余额并记录备注
if ($rewardTier === 'T4' && $coinAfter < 0) {
throw new ApiException('余额不足');
$walletRemain = round($coinBefore - $paidAmount, 2);
$coinAfter = 0.0;
$recordWinCoin = round(-$walletRemain, 2);
$recordRewardWinCoin = $recordWinCoin;
$playRecordRemark = self::REMARK_T4_INSUFFICIENT_BALANCE;
}
$settledWinCoin = $recordWinCoin;
$record = DicePlayRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId,
'lottery_config_id' => $configId,
'lottery_type' => $ticketType,
'ante' => $ante,
'paid_amount' => $paidAmount,
'is_win' => $isWin,
'win_coin' => $recordWinCoin,
'super_win_coin' => $superWinCoin,
'reward_win_coin' => $recordRewardWinCoin,
'direction' => $direction,
'reward_tier' => $rewardTier,
'start_index' => $startIndex,
'target_index' => $targetIndex,
'roll_array' => is_array($rollArray) ? json_encode($rollArray) : $rollArray,
'roll_number' => is_array($rollArray) ? array_sum($rollArray) : 0,
'status' => self::RECORD_STATUS_SUCCESS,
'remark' => $playRecordRemark,
]);
$p->coin = $coinAfter;
// 免费抽奖消耗:优先消耗 free_ticket.count耗尽则清空 free_ticket否则兼容旧 free_ticket_count
if ($ticketType === self::LOTTERY_TYPE_FREE) {
@@ -354,6 +380,7 @@ class PlayStartLogic
$freeCnt = $isPaidPlay ? 0 : 1;
DicePlayerTicketRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId,
'use_coins' => $paidAmount,
'ante' => $ante,
@@ -366,7 +393,7 @@ class PlayStartLogic
// 若本局中奖档位为 T5则额外赠送 1 次免费抽奖次数:
// - 新结构:写入 free_ticketante=本局注数count+1
// - 兼容旧结构free_ticket_count +1
if ($isTierT5) {
if ($isTierPlayAgain) {
$ft = $p->free_ticket ?? null;
$ftAnte = null;
$ftCount = 0;
@@ -392,6 +419,7 @@ class PlayStartLogic
DicePlayerTicketRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId,
'ante' => $ante,
'free_ticket_count' => 1,
@@ -419,7 +447,7 @@ class PlayStartLogic
// 彩金池累计盈利累加在 name=default 彩金池上:
// 付费:每局按「本局赢取平台币 win_coin - 抽奖费用 paid_amountante*UNIT_COST
// 免费券paid_amount=0只计入 win_coin
$perPlayProfit = ($ticketType === self::LOTTERY_TYPE_PAID) ? ($winCoin - $paidAmount) : $winCoin;
$perPlayProfit = ($ticketType === self::LOTTERY_TYPE_PAID) ? ($recordWinCoin - $paidAmount) : $recordWinCoin;
$addProfit = round($perPlayProfit, 2);
try {
DiceLotteryPoolConfig::where('id', $type0ConfigId)->update([
@@ -438,6 +466,7 @@ class PlayStartLogic
$walletAfterBuy = round($coinBefore - $paidAmount, 2);
DicePlayerWalletRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId,
'coin' => round(-$paidAmount, 2),
'type' => self::WALLET_TYPE_BUY_DRAW,
@@ -448,11 +477,15 @@ class PlayStartLogic
}
$walletBeforeDraw = $coinBefore - $paidAmount;
$drawRemark = ($winCoin >= 0 ? '抽奖中奖' : '抽奖惩罚') . '|play_record_id=' . $record->id;
$drawRemark = ($recordWinCoin >= 0 ? '抽奖中奖' : '抽奖惩罚') . '|play_record_id=' . $record->id;
if ($playRecordRemark !== null) {
$drawRemark .= '|' . $playRecordRemark;
}
DicePlayerWalletRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId,
'coin' => $winCoin,
'coin' => $recordWinCoin,
'type' => self::WALLET_TYPE_DRAW,
'wallet_before' => round($walletBeforeDraw, 2),
'wallet_after' => $coinAfter,
@@ -464,6 +497,7 @@ class PlayStartLogic
try {
$record = DicePlayRecord::create([
'player_id' => $playerId,
'dept_id' => $playerDeptId,
'admin_id' => $adminId ?? null,
'lottery_config_id' => $configId ?? 0,
'lottery_type' => $ticketType,
@@ -639,9 +673,10 @@ class PlayStartLogic
* @param array|null $customTierWeights 自定义档位权重 ['T1'=>x, 'T2'=>x, ...],非空时忽略 config 的档位权重
* @return array 可直接用于 DicePlayRecordTest::create 的字段 + tier用于统计档位概率
*/
public function simulateOnePlay($config, int $direction, int $lotteryType = 0, int $ante = 1, ?array $customTierWeights = null): array
public function simulateOnePlay($config, int $direction, int $lotteryType = 0, int $ante = 1, ?array $customTierWeights = null, ?int $configDeptId = null): array
{
$rewardInstance = DiceReward::getCachedInstance();
$useKillMode = false;
$rewardInstance = DiceReward::getCachedInstance($configDeptId);
$byTierDirection = $rewardInstance['by_tier_direction'] ?? [];
$maxTierRetry = 10;
$chosen = null;
@@ -698,7 +733,7 @@ class PlayStartLogic
$superWinCoin = 0.0;
$rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber);
} else {
$bigWinConfig = DiceRewardConfig::getCachedByTierAndGridNumber('BIGWIN', $rollNumber);
$bigWinConfig = DiceRewardConfig::getCachedByTierAndGridNumber('BIGWIN', $rollNumber, $configDeptId);
$alwaysSuperWin = in_array($rollNumber, self::SUPER_WIN_ALWAYS_GRID_NUMBERS, true);
$doSuperWin = $alwaysSuperWin;
if (!$doSuperWin) {
@@ -763,4 +798,24 @@ class PlayStartLogic
'grants_free_ticket' => $grantsFreeTicket,
];
}
/**
* 玩家已选彩金池配置(用于 playerDefault 运行时权重)
*/
private function resolvePlayerLinkedPoolConfig(DicePlayer $player, int $configDeptId): ?DiceLotteryPoolConfig
{
$linkedId = (int) ($player->lottery_config_id ?? 0);
if ($linkedId <= 0) {
return null;
}
$cfg = DiceLotteryPoolConfig::find($linkedId);
if (!$cfg) {
return null;
}
$poolDeptId = AdminScopeHelper::normalizeRecordDeptId($cfg->dept_id ?? null);
if ($poolDeptId !== AdminScopeHelper::normalizeRecordDeptId($configDeptId)) {
return null;
}
return $cfg;
}
}

View File

@@ -5,7 +5,6 @@ namespace app\api\logic;
use app\dice\model\player\DicePlayer;
use app\api\cache\UserCache;
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\exception\ApiException;
use Tinywan\Jwt\JwtToken;
@@ -44,46 +43,8 @@ class UserLogic
}
/**
* 根据 parent_id 向上遍历找到顶级部门parent_id=0
*/
private static function getTopDeptIdByParentId(int $deptId): ?int
{
$currentId = $deptId;
$visited = [];
while ($currentId > 0 && !isset($visited[$currentId])) {
$visited[$currentId] = true;
$dept = SystemDept::find($currentId);
if (!$dept) {
return null;
}
$parentId = (int) ($dept->parent_id ?? 0);
if ($parentId === 0) {
return $currentId;
}
$currentId = $parentId;
}
return $currentId > 0 ? $currentId : null;
}
/**
* 根据顶级部门 id递归获取其下所有部门 id含自身仅用 id 和 parent_id
*/
private static function getAllDeptIdsUnderTop(int $topId): array
{
$deptIds = [$topId];
$prevCount = 0;
while (count($deptIds) > $prevCount) {
$prevCount = count($deptIds);
$children = SystemDept::whereIn('parent_id', $deptIds)->column('id');
$deptIds = array_unique(array_merge($deptIds, array_map('intval', $children)));
}
return array_values($deptIds);
}
/**
* 根据 agent_id 获取当前管理员所在顶级部门下的所有管理员 ID 列表
* 使用 SystemDept 的 id 和 parent_id 字段遍历:先向上找顶级部门(parent_id=0),再向下收集所有子部门
* 用于 getGameUrl 接口判断 DicePlayer 是否属于该部门,同顶级部门下不重复创建玩家
* 根据 agent_id 获取同渠道下的所有管理员 ID 列表
* 用于 getGameUrl 接口判断 DicePlayer 是否属于该渠道,同渠道下不重复创建玩家
*
* @param string $agentId 代理标识sa_system_user.agent_id
* @return int[] 管理员 ID 列表,空数组表示未找到或无法解析
@@ -103,16 +64,56 @@ class UserLogic
return [(int) $admin->id];
}
$deptId = (int) $deptId;
$topId = self::getTopDeptIdByParentId($deptId);
if ($topId === null) {
return [(int) $admin->id];
$adminIds = SystemUser::where('dept_id', $deptId)->column('id');
return array_map('intval', $adminIds ?: [(int) $admin->id]);
}
/**
* 按用户名查找玩家;不存在则创建并绑定渠道/管理员(供 getPlayerInfo 等接口)
*
* @param int|null $adminId 关联后台管理员 IDsa_system_user.id
* @param int|null $deptId 所属渠道 ID
*/
public function findOrCreatePlayerByUsername(string $username, ?int $adminId = null, ?int $deptId = null): DicePlayer
{
$username = trim($username);
if ($username === '') {
throw new ApiException('username is required');
}
$deptIds = self::getAllDeptIdsUnderTop($topId);
if (empty($deptIds)) {
$deptIds = [$deptId];
$query = DicePlayer::where('username', $username);
if ($deptId !== null && $deptId > 0) {
$query->where('dept_id', $deptId);
}
$adminIds = SystemUser::whereIn('dept_id', $deptIds)->column('id');
return array_map('intval', $adminIds);
$player = $query->find();
if ($player) {
if ((int) ($player->status ?? 1) === 0) {
throw new ApiException('Account is disabled');
}
return $player;
}
$player = new DicePlayer();
$player->username = $username;
$player->phone = $username;
$player->password = $this->hashPassword('123456');
$player->status = self::STATUS_NORMAL;
$player->coin = 0;
if ($deptId !== null && $deptId > 0) {
$player->dept_id = $deptId;
}
if ($adminId !== null && $adminId > 0) {
$player->admin_id = $adminId;
if ($deptId === null || $deptId <= 0) {
$adminUser = SystemUser::find($adminId);
if ($adminUser && !empty($adminUser->dept_id)) {
$player->dept_id = $adminUser->dept_id;
}
}
}
$player->save();
return $player;
}
/**
@@ -123,7 +124,7 @@ class UserLogic
* @param int|null $adminId 创建新用户时关联的后台管理员IDsa_system_user.id可选
* @param int[]|null $adminIdsInTopDept 当前管理员顶级部门下的所有管理员ID用于按部门范围查找玩家为空时退化为仅按 username 查找
*/
public function loginByUsername(string $username, string $password, string $lang, float $coin, string $time, ?int $adminId = null, ?array $adminIdsInTopDept = null): array
public function loginByUsername(string $username, string $password, string $lang, float $coin, string $time, ?int $adminId = null, ?array $adminIdsInTopDept = null, ?int $deptId = null, bool $skipPasswordValidation = false): array
{
$username = trim($username);
if ($username === '') {
@@ -131,6 +132,9 @@ class UserLogic
}
$query = DicePlayer::where('username', $username);
if ($deptId !== null && $deptId > 0) {
$query->where('dept_id', $deptId);
}
if ($adminIdsInTopDept !== null && !empty($adminIdsInTopDept)) {
$query->whereIn('admin_id', $adminIdsInTopDept);
}
@@ -139,9 +143,11 @@ class UserLogic
if ((int) ($player->status ?? 1) === 0) {
throw new ApiException('Account is disabled and cannot log in');
}
$hashed = $this->hashPassword($password);
if ($player->password !== $hashed) {
throw new ApiException('Wrong password');
if (!$skipPasswordValidation) {
$hashed = $this->hashPassword($password);
if ($player->password !== $hashed) {
throw new ApiException('Wrong password');
}
}
$currentCoin = (float) $player->coin;
$player->coin = $currentCoin + $coin;
@@ -153,8 +159,15 @@ class UserLogic
$player->password = $this->hashPassword($password);
$player->status = self::STATUS_NORMAL;
$player->coin = $coin;
if ($deptId !== null && $deptId > 0) {
$player->dept_id = $deptId;
}
if ($adminId !== null && $adminId > 0) {
$player->admin_id = $adminId;
$adminUser = SystemUser::find($adminId);
if (($deptId === null || $deptId <= 0) && $adminUser && !empty($adminUser->dept_id)) {
$player->dept_id = $adminUser->dept_id;
}
}
$player->save();
}
@@ -168,6 +181,7 @@ class UserLogic
]);
$token = $tokenResult['access_token'];
UserCache::setSessionByUsername($username, $token);
UserCache::setCurrentUserToken((int) $player->id, $token);
$userArr = $player->hidden(['password', 'lottery_config_id', 't1_weight', 't2_weight', 't3_weight', 't4_weight', 't5_weight'])->toArray();
UserCache::setUser((int) $player->id, $userArr);

View File

@@ -20,6 +20,7 @@ class ApiAccessLogMiddleware implements MiddlewareInterface
/** 请求头名称(小写) */
private const SENSITIVE_HEADER_NAMES = [
'api-key',
'auth-token',
'token',
'authorization',
@@ -32,6 +33,8 @@ class ApiAccessLogMiddleware implements MiddlewareInterface
'secret',
'signature',
'token',
'api-key',
'api_key',
'auth-token',
'auth_token',
'old_token',

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use app\api\util\ReturnCode;
use plugin\saiadmin\exception\ApiException;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
/**
* 校验对接平台 api-key与 .env 中 API_KEY 一致)
* 仅用于 /api/v1/* 平台对接接口
*
* 取值优先级(按顺序读取,首个非空即采用):
* 1. 请求头 api-key推荐
* 2. 查询参数 api_key / api-key
* 3. body 表单/JSON api_key / api-key
*/
class ApiKeyMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$expected = (string) config('api.platform_api_key', '');
if ($expected === '') {
throw new ApiException('API_KEY is not configured', ReturnCode::SERVER_ERROR);
}
$apiKey = $this->resolveApiKey($request);
if ($apiKey === '') {
throw new ApiException('Please provide api-key', ReturnCode::UNAUTHORIZED);
}
if (!hash_equals($expected, $apiKey)) {
throw new ApiException('Invalid api-key', ReturnCode::FORBIDDEN);
}
return $handler($request);
}
private function resolveApiKey(Request $request): string
{
$headerValue = $request->header('api-key');
if ($headerValue !== null && trim((string) $headerValue) !== '') {
return trim((string) $headerValue);
}
foreach (['api_key', 'api-key'] as $key) {
$val = $request->get($key);
if ($val !== null && trim((string) $val) !== '') {
return trim((string) $val);
}
}
foreach (['api_key', 'api-key'] as $key) {
$val = $request->post($key);
if ($val !== null && trim((string) $val) !== '') {
return trim((string) $val);
}
}
return '';
}
}

View File

@@ -5,6 +5,7 @@ namespace app\api\middleware;
use app\api\cache\AuthTokenCache;
use app\api\util\ReturnCode;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\exception\ApiException;
use Tinywan\Jwt\JwtToken;
use Tinywan\Jwt\Exception\JwtTokenException;
@@ -53,7 +54,17 @@ class AuthTokenMiddleware implements MiddlewareInterface
throw new ApiException('auth-token invalid or expired', ReturnCode::TOKEN_INVALID);
}
$agent = SystemUser::where('agent_id', $agentId)->find();
if (!$agent || (int) ($agent->status ?? 0) !== 1) {
throw new ApiException('Invalid agent_id', ReturnCode::FORBIDDEN);
}
if (empty($agent->dept_id) || (int) $agent->dept_id <= 0) {
throw new ApiException('Agent channel is not configured', ReturnCode::FORBIDDEN);
}
$request->agent_id = $agentId;
$request->agent_admin_id = (int) $agent->id;
$request->agent_dept_id = (int) $agent->dept_id;
return $handler($request);
}
}

View File

@@ -53,10 +53,14 @@ class TokenMiddleware implements MiddlewareInterface
if ($username === '') {
throw new ApiException('Invalid or expired token', ReturnCode::TOKEN_INVALID);
}
$userId = (int) ($extend['id'] ?? 0);
if ($userId <= 0) {
throw new ApiException('Invalid or expired token', ReturnCode::TOKEN_INVALID);
}
$currentToken = UserCache::getSessionTokenByUsername($username);
$currentToken = UserCache::getCurrentUserToken($userId);
if ($currentToken === null || $currentToken === '') {
$player = DicePlayer::where('username', $username)->find();
$player = DicePlayer::find($userId);
if (!$player) {
throw new ApiException('Please register', ReturnCode::TOKEN_INVALID);
}
@@ -68,17 +72,17 @@ class TokenMiddleware implements MiddlewareInterface
// 优先从 Redis 缓存取玩家,避免每次请求都查库
$player = null;
$cached = UserCache::getPlayerByUsername($username);
if ($cached !== null && isset($cached['id'])) {
$cached = UserCache::getUser($userId);
if (!empty($cached) && isset($cached['id']) && (int) $cached['id'] === $userId) {
$player = (new DicePlayer())->data($cached, true);
}
if ($player === null) {
$player = DicePlayer::where('username', $username)->find();
$player = DicePlayer::find($userId);
if (!$player) {
UserCache::deleteSessionByUsername($username);
throw new ApiException('Please login again', ReturnCode::TOKEN_INVALID);
}
UserCache::setPlayerByUsername($username, $player->hidden(['password'])->toArray());
UserCache::setUser($userId, $player->hidden(['password'])->toArray());
}
$request->player_id = (int) $player->id;
$request->player = $player;

View File

@@ -83,6 +83,47 @@ class LotteryService
Cache::set($key, json_encode($data), self::EXPIRE);
}
/**
* 使玩家彩金池 Redis 快照失效(下次 getOrCreate 从库重建)
*/
public static function invalidatePlayerLotteryCache(int $playerId): void
{
if ($playerId <= 0) {
return;
}
Cache::delete(self::getRedisKey($playerId));
}
/**
* 若 Redis 中已有该玩家彩金池快照,则仅更新 player_weights避免全量重建
*
* @param array{t1_weight?:int,t2_weight?:int,t3_weight?:int,t4_weight?:int,t5_weight?:int} $weights
*/
public static function patchPlayerWeightsCache(int $playerId, array $weights): void
{
if ($playerId <= 0) {
return;
}
$key = self::getRedisKey($playerId);
$cached = Cache::get($key);
if (!$cached || !is_string($cached)) {
return;
}
$data = json_decode($cached, true);
if (!is_array($data)) {
Cache::delete($key);
return;
}
$data['player_weights'] = [
't1_weight' => (int) ($weights['t1_weight'] ?? 0),
't2_weight' => (int) ($weights['t2_weight'] ?? 0),
't3_weight' => (int) ($weights['t3_weight'] ?? 0),
't4_weight' => (int) ($weights['t4_weight'] ?? 0),
't5_weight' => (int) ($weights['t5_weight'] ?? 0),
];
Cache::set($key, json_encode($data), self::EXPIRE);
}
/** 根据奖池配置的 t1_weight..t5_weight 权重随机抽取档位 T1-T5 */
public static function drawTierByWeights(DiceLotteryPoolConfig $config): string
{

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace app\dice\basic;
use plugin\saiadmin\basic\think\BaseLogic;
/**
* 大富翁逻辑层基类:删除均为硬删除
*/
class DiceBaseLogic extends BaseLogic
{
/**
* @param mixed $ids
*/
public function destroy($ids): bool
{
return $this->model->destroy($ids, true);
}
}

View File

@@ -7,8 +7,10 @@ use app\dice\helper\AdminScopeHelper;
use app\dice\model\player\DicePlayer;
use app\dice\model\player_wallet_record\DicePlayerWalletRecord;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\reward\DiceRewardConfig;
use plugin\saiadmin\basic\BaseController;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
use support\think\Db;
@@ -21,53 +23,51 @@ class DiceDashboardController extends BaseController
* 工作台卡片统计:玩家注册、充值、提现、游玩次数(含较上周对比)
*/
#[Permission('工作台数据统计', 'core:console:list')]
public function statistics(): Response
public function statistics(Request $request): Response
{
$thisWeekStart = date('Y-m-d 00:00:00', strtotime('monday this week'));
$thisWeekEnd = date('Y-m-d 23:59:59', strtotime('sunday this week'));
$lastWeekStart = date('Y-m-d 00:00:00', strtotime('monday last week'));
$lastWeekEnd = date('Y-m-d 23:59:59', strtotime('sunday last week'));
[$thisStart, $thisEnd, $lastStart, $lastEnd] = $this->resolveDateRanges($request);
$adminInfo = $this->adminInfo ?? null;
$filterDeptId = $request->input('dept_id');
$playerQueryThis = DicePlayer::whereBetween('create_time', [$thisWeekStart, $thisWeekEnd]);
$playerQueryLast = DicePlayer::whereBetween('create_time', [$lastWeekStart, $lastWeekEnd]);
AdminScopeHelper::applyAdminScope($playerQueryThis, $adminInfo);
AdminScopeHelper::applyAdminScope($playerQueryLast, $adminInfo);
$playerQueryThis = DicePlayer::whereBetween('create_time', [$thisStart, $thisEnd]);
$playerQueryLast = DicePlayer::whereBetween('create_time', [$lastStart, $lastEnd]);
AdminScopeHelper::applyAdminScope($playerQueryThis, $adminInfo, $filterDeptId);
AdminScopeHelper::applyAdminScope($playerQueryLast, $adminInfo, $filterDeptId);
$playerThis = $playerQueryThis->count();
$playerLast = $playerQueryLast->count();
$chargeQueryThis = DicePlayerWalletRecord::where('type', 0)
->where('coin', '>', 0)
->whereBetween('create_time', [$thisWeekStart, $thisWeekEnd]);
->whereBetween('create_time', [$thisStart, $thisEnd]);
$chargeQueryLast = DicePlayerWalletRecord::where('type', 0)
->where('coin', '>', 0)
->whereBetween('create_time', [$lastWeekStart, $lastWeekEnd]);
AdminScopeHelper::applyAdminScope($chargeQueryThis, $adminInfo);
AdminScopeHelper::applyAdminScope($chargeQueryLast, $adminInfo);
->whereBetween('create_time', [$lastStart, $lastEnd]);
AdminScopeHelper::applyAdminScope($chargeQueryThis, $adminInfo, $filterDeptId);
AdminScopeHelper::applyAdminScope($chargeQueryLast, $adminInfo, $filterDeptId);
$chargeThis = $chargeQueryThis->sum('coin');
$chargeLast = $chargeQueryLast->sum('coin');
$withdrawQueryThis = DicePlayerWalletRecord::where('type', 1)
->whereBetween('create_time', [$thisWeekStart, $thisWeekEnd]);
->whereBetween('create_time', [$thisStart, $thisEnd]);
$withdrawQueryLast = DicePlayerWalletRecord::where('type', 1)
->whereBetween('create_time', [$lastWeekStart, $lastWeekEnd]);
AdminScopeHelper::applyAdminScope($withdrawQueryThis, $adminInfo);
AdminScopeHelper::applyAdminScope($withdrawQueryLast, $adminInfo);
->whereBetween('create_time', [$lastStart, $lastEnd]);
AdminScopeHelper::applyAdminScope($withdrawQueryThis, $adminInfo, $filterDeptId);
AdminScopeHelper::applyAdminScope($withdrawQueryLast, $adminInfo, $filterDeptId);
$withdrawThis = $withdrawQueryThis->sum(Db::raw('ABS(coin)'));
$withdrawLast = $withdrawQueryLast->sum(Db::raw('ABS(coin)'));
$playQueryThis = DicePlayRecord::whereBetween('create_time', [$thisWeekStart, $thisWeekEnd]);
$playQueryLast = DicePlayRecord::whereBetween('create_time', [$lastWeekStart, $lastWeekEnd]);
AdminScopeHelper::applyAdminScope($playQueryThis, $adminInfo);
AdminScopeHelper::applyAdminScope($playQueryLast, $adminInfo);
$playQueryThis = DicePlayRecord::whereBetween('create_time', [$thisStart, $thisEnd]);
$playQueryLast = DicePlayRecord::whereBetween('create_time', [$lastStart, $lastEnd]);
AdminScopeHelper::applyAdminScope($playQueryThis, $adminInfo, $filterDeptId);
AdminScopeHelper::applyAdminScope($playQueryLast, $adminInfo, $filterDeptId);
$playThis = $playQueryThis->count();
$playLast = $playQueryLast->count();
$playerChange = $this->calcWeekChange($playerThis, $playerLast);
$chargeChange = $this->calcWeekChange((float) $chargeThis, (float) $chargeLast);
$withdrawChange = $this->calcWeekChange((float) $withdrawThis, (float) $withdrawLast);
$playChange = $this->calcWeekChange($playThis, $playLast);
$playerChange = $this->calcPeriodChange($playerThis, $playerLast);
$chargeChange = $this->calcPeriodChange((float) $chargeThis, (float) $chargeLast);
$withdrawChange = $this->calcPeriodChange((float) $withdrawThis, (float) $withdrawLast);
$playChange = $this->calcPeriodChange($playThis, $playLast);
return $this->success([
'player_count' => $playerThis,
@@ -78,6 +78,7 @@ class DiceDashboardController extends BaseController
'withdraw_amount_change' => $withdrawChange,
'play_count' => $playThis,
'play_count_change' => $playChange,
'date' => $this->resolveRequestDate($request),
]);
}
@@ -85,13 +86,11 @@ class DiceDashboardController extends BaseController
* 近期玩家充值统计近10天每日充值金额
*/
#[Permission('工作台数据统计', 'core:console:list')]
public function rechargeChart(): Response
public function rechargeChart(Request $request): Response
{
$adminInfo = $this->adminInfo ?? null;
$allowedIds = AdminScopeHelper::getAllowedAdminIds($adminInfo);
$adminCondition = '';
if ($allowedIds !== null) {
if (empty($allowedIds)) {
$deptCondition = $this->buildWalletSqlDeptCondition($adminInfo, $request->input('dept_id'));
if ($deptCondition === '__empty__') {
$data = [];
foreach (range(0, 9) as $n) {
$data[] = ['recharge_date' => date('Y-m-d', strtotime("-{$n} days")), 'recharge_amount' => 0];
@@ -101,9 +100,6 @@ class DiceDashboardController extends BaseController
'recharge_amount' => array_map('floatval', array_column($data, 'recharge_amount')),
'recharge_date' => array_column($data, 'recharge_date'),
]);
}
$idsStr = implode(',', array_map('intval', $allowedIds));
$adminCondition = " AND w.admin_id IN ({$idsStr})";
}
$sql = "
SELECT
@@ -116,7 +112,7 @@ class DiceDashboardController extends BaseController
UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9) a
) d
LEFT JOIN dice_player_wallet_record w
ON DATE(w.create_time) = d.date AND w.type = 0 AND w.coin > 0 {$adminCondition}
ON DATE(w.create_time) = d.date AND w.type = 0 AND w.coin > 0 {$deptCondition}
GROUP BY d.date
ORDER BY d.date ASC
";
@@ -131,13 +127,11 @@ class DiceDashboardController extends BaseController
* 月度玩家充值汇总当年1-12月每月充值金额
*/
#[Permission('工作台数据统计', 'core:console:list')]
public function rechargeBarChart(): Response
public function rechargeBarChart(Request $request): Response
{
$adminInfo = $this->adminInfo ?? null;
$allowedIds = AdminScopeHelper::getAllowedAdminIds($adminInfo);
$adminCondition = '';
if ($allowedIds !== null) {
if (empty($allowedIds)) {
$deptCondition = $this->buildWalletSqlDeptCondition($adminInfo, $request->input('dept_id'));
if ($deptCondition === '__empty__') {
$data = [];
for ($m = 1; $m <= 12; $m++) {
$data[] = ['recharge_month' => sprintf('%02d月', $m), 'recharge_amount' => 0];
@@ -146,9 +140,6 @@ class DiceDashboardController extends BaseController
'recharge_amount' => array_map('floatval', array_column($data, 'recharge_amount')),
'recharge_month' => array_column($data, 'recharge_month'),
]);
}
$idsStr = implode(',', array_map('intval', $allowedIds));
$adminCondition = " AND w.admin_id IN ({$idsStr})";
}
$sql = "
SELECT
@@ -162,7 +153,7 @@ class DiceDashboardController extends BaseController
LEFT JOIN dice_player_wallet_record w
ON YEAR(w.create_time) = YEAR(CURDATE())
AND MONTH(w.create_time) = m.month_num
AND w.type = 0 AND w.coin > 0 {$adminCondition}
AND w.type = 0 AND w.coin > 0 {$deptCondition}
GROUP BY m.month_num
ORDER BY m.month_num ASC
";
@@ -178,7 +169,7 @@ class DiceDashboardController extends BaseController
* 返回:玩家账号(DicePlayer.username)、充值金额(coin)、充值时间(create_time)
*/
#[Permission('工作台数据统计', 'core:console:list')]
public function walletRecordList(): Response
public function walletRecordList(Request $request): Response
{
$adminInfo = $this->adminInfo ?? null;
$query = DicePlayerWalletRecord::with([
@@ -186,10 +177,11 @@ class DiceDashboardController extends BaseController
$q->field('id,username');
},
])
->where('type', 0)
->order('create_time', 'desc')
->where('type', 0);
$this->applyDashboardDateFilter($query, $request);
$query->order('create_time', 'desc')
->limit(50);
AdminScopeHelper::applyAdminScope($query, $adminInfo);
AdminScopeHelper::applyAdminScope($query, $adminInfo, $request->input('dept_id'));
$list = $query->select();
$rows = [];
foreach ($list as $row) {
@@ -208,13 +200,14 @@ class DiceDashboardController extends BaseController
* 返回:玩家账号(username)、余额(coin)、抽奖券(total_ticket_count)
*/
#[Permission('工作台数据统计', 'core:console:list')]
public function newPlayerList(): Response
public function newPlayerList(Request $request): Response
{
$adminInfo = $this->adminInfo ?? null;
$query = DicePlayer::field('username,coin,total_ticket_count,create_time')
->order('create_time', 'desc')
$query = DicePlayer::field('username,coin,total_ticket_count,create_time');
$this->applyDashboardDateFilter($query, $request);
$query->order('create_time', 'desc')
->limit(50);
AdminScopeHelper::applyAdminScope($query, $adminInfo);
AdminScopeHelper::applyAdminScope($query, $adminInfo, $request->input('dept_id'));
$list = $query->select();
$rows = [];
foreach ($list as $row) {
@@ -222,16 +215,141 @@ class DiceDashboardController extends BaseController
'name' => $row->getAttr('username'),
'coin' => $row->getAttr('coin'),
'total_ticket_count' => $row->getAttr('total_ticket_count'),
'create_time' => $row->getAttr('create_time'),
];
}
return $this->success($rows);
}
private function calcWeekChange($current, $last): float
/**
* 工作台-玩家游玩记录最新50条
* 返回:玩家账号、中奖档位、赢取平台币、游玩时间
*/
#[Permission('工作台数据统计', 'core:console:list')]
public function playRecordList(Request $request): Response
{
$adminInfo = $this->adminInfo ?? null;
$query = DicePlayRecord::with([
'dicePlayer' => function ($q) {
$q->field('id,username');
},
])
->where('status', 1)
->field('id,player_id,reward_tier,win_coin,create_time');
$this->applyDashboardDateFilter($query, $request);
$query->order('create_time', 'desc')
->limit(50);
AdminScopeHelper::applyAdminScope($query, $adminInfo, $request->input('dept_id'));
$list = $query->select();
$tierLabels = $this->buildRewardTierLabels();
$rows = [];
foreach ($list as $row) {
$player = $row->dicePlayer;
$tier = $row->getAttr('reward_tier');
$rows[] = [
'player_name' => $player ? $player->getAttr('username') : '',
'reward_tier' => $tier,
'reward_tier_label' => $tierLabels[$tier] ?? $tier,
'win_coin' => $row->getAttr('win_coin'),
'create_time' => $row->getAttr('create_time'),
];
}
return $this->success($rows);
}
/**
* @return array<string, string>
*/
private function buildRewardTierLabels(): array
{
$rows = DiceRewardConfig::field('tier,ui_text')->select();
$labels = [];
foreach ($rows as $row) {
$tier = $row->getAttr('tier');
if ($tier === '' || $tier === null) {
continue;
}
if (!isset($labels[$tier])) {
$labels[$tier] = $row->getAttr('ui_text') ?: $tier;
}
}
return $labels;
}
private function calcPeriodChange($current, $last): float
{
if ($last == 0) {
return $current > 0 ? 100.0 : 0.0;
}
return round((($current - $last) / $last) * 100, 1);
}
/**
* 解析工作台统计区间:有 date 按单日对比昨日,无 date 按本周对比上周
*
* @return array{0: string, 1: string, 2: string, 3: string}
*/
private function resolveDateRanges(Request $request): array
{
$date = $this->resolveRequestDate($request);
if ($date === '') {
return [
date('Y-m-d 00:00:00', strtotime('monday this week')),
date('Y-m-d 23:59:59', strtotime('sunday this week')),
date('Y-m-d 00:00:00', strtotime('monday last week')),
date('Y-m-d 23:59:59', strtotime('sunday last week')),
];
}
$lastDate = date('Y-m-d', strtotime($date . ' -1 day'));
return [
$date . ' 00:00:00',
$date . ' 23:59:59',
$lastDate . ' 00:00:00',
$lastDate . ' 23:59:59',
];
}
private function resolveRequestDate(Request $request): string
{
$date = trim((string) $request->input('date', ''));
if ($date === '' || strtotime($date) === false) {
return '';
}
return date('Y-m-d', strtotime($date));
}
/**
* @param mixed $query
*/
private function applyDashboardDateFilter($query, Request $request, string $column = 'create_time'): void
{
$date = $this->resolveRequestDate($request);
if ($date === '') {
return;
}
$query->whereBetween($column, [$date . ' 00:00:00', $date . ' 23:59:59']);
}
/**
* 钱包流水 SQL 渠道条件;非超管无渠道时返回 __empty__
*/
private function buildWalletSqlDeptCondition(?array $adminInfo, $requestDeptId): string
{
if (AdminScopeHelper::getDeptId($adminInfo) !== null) {
$deptId = AdminScopeHelper::getDeptId($adminInfo);
if ($deptId <= 0) {
return '__empty__';
}
return ' AND w.dept_id = ' . $deptId;
}
$target = AdminScopeHelper::resolveBusinessDeptId($adminInfo, $requestDeptId);
if ($target !== null && $target > 0) {
return ' AND w.dept_id = ' . $target;
}
return '';
}
}

View File

@@ -6,7 +6,9 @@
// +----------------------------------------------------------------------
namespace app\dice\controller\ante_config;
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\ante_config\DiceAnteConfigLogic;
use app\dice\model\ante_config\DiceAnteConfig;
use app\dice\validate\ante_config\DiceAnteConfigValidate;
use plugin\saiadmin\basic\BaseController;
use plugin\saiadmin\service\Permission;
@@ -34,10 +36,32 @@ class DiceAnteConfigController extends BaseController
['is_default', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 底注下拉选项(按渠道),供一键测试权重等使用
*/
#[Permission('底注配置列表', 'dice:ante_config:index:index')]
public function getOptions(Request $request): Response
{
$query = DiceAnteConfig::field('id,name,title,mult,is_default')->order('mult', 'asc')->order('id', 'asc');
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$list = $query->select();
$data = $list->map(static function ($item) {
return [
'id' => (int) $item['id'],
'name' => (string) ($item['name'] ?? ''),
'title' => (string) ($item['title'] ?? ''),
'mult' => (int) ($item['mult'] ?? 0),
'is_default' => (int) ($item['is_default'] ?? 0),
];
})->toArray();
return $this->success($data);
}
#[Permission('底注配置读取', 'dice:ante_config:index:read')]
public function read(Request $request): Response
{
@@ -52,6 +76,7 @@ class DiceAnteConfigController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareConfigSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
return $result ? $this->success('add success') : $this->fail('add failed');
}
@@ -60,8 +85,19 @@ class DiceAnteConfigController extends BaseController
public function update(Request $request): Response
{
$data = $request->post();
if ($data === [] || $data === null) {
$data = $request->all();
}
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), is_array($data) ? $data : []);
$model = $this->logic->read($data['id'] ?? 0);
if ($model) {
$recordDeptId = is_array($model) ? ($model['dept_id'] ?? null) : ($model->dept_id ?? null);
if (! AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $recordDeptId, $requestDeptId)) {
return $this->fail('no permission to update this record');
}
}
$result = $this->logic->edit($data['id'], $data, $this->adminInfo ?? null, $requestDeptId);
return $result ? $this->success('update success') : $this->fail('update failed');
}

View File

@@ -6,6 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\controller\config;
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\config\DiceConfigLogic;
use app\dice\validate\config\DiceConfigValidate;
@@ -42,6 +43,7 @@ class DiceConfigController extends BaseController
['title', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$data = $this->logic->getList($query);
return $this->success($data);
}
@@ -74,6 +76,7 @@ class DiceConfigController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareConfigSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
@@ -91,8 +94,20 @@ class DiceConfigController extends BaseController
public function update(Request $request): Response
{
$data = $request->post();
if ($data === [] || $data === null) {
$data = $request->all();
}
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), is_array($data) ? $data : []);
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $requestDeptId);
$model = $this->logic->read($data['id'] ?? 0);
if ($model) {
$recordDeptId = is_array($model) ? ($model['dept_id'] ?? null) : ($model->dept_id ?? null);
if (! AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $recordDeptId, $requestDeptId)) {
return $this->fail('no permission to update this record');
}
}
$result = $this->logic->edit($data['id'], $data, $this->adminInfo ?? null, $requestDeptId);
if ($result) {
return $this->success('update success');
} else {

View File

@@ -4,6 +4,7 @@
// +----------------------------------------------------------------------
namespace app\dice\controller\game;
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\game\DiceGameLogic;
use app\dice\validate\game\DiceGameValidate;
use plugin\saiadmin\basic\BaseController;
@@ -33,6 +34,7 @@ class DiceGameController extends BaseController
['status', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$data = $this->logic->getList($query);
return $this->success($data);
}
@@ -54,6 +56,7 @@ class DiceGameController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareConfigSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if (!$result) {
return $this->fail('add failed');
@@ -65,8 +68,19 @@ class DiceGameController extends BaseController
public function update(Request $request): Response
{
$data = $request->post();
if ($data === [] || $data === null) {
$data = $request->all();
}
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), is_array($data) ? $data : []);
$model = $this->logic->read($data['id'] ?? 0);
if ($model) {
$recordDeptId = is_array($model) ? ($model['dept_id'] ?? null) : ($model->dept_id ?? null);
if (! AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $recordDeptId, $requestDeptId)) {
return $this->fail('no permission to update this record');
}
}
$result = $this->logic->edit($data['id'], $data, $this->adminInfo ?? null, $requestDeptId);
if (!$result) {
return $this->fail('update failed');
}

View File

@@ -7,6 +7,7 @@
namespace app\dice\controller\lottery_pool_config;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\lottery_pool_config\DiceLotteryPoolConfigLogic;
use app\dice\validate\lottery_pool_config\DiceLotteryPoolConfigValidate;
@@ -37,18 +38,23 @@ class DiceLotteryPoolConfigController extends BaseController
#[Permission('色子奖池配置列表', 'dice:lottery_pool_config:index:index')]
public function getOptions(Request $request): Response
{
$list = DiceLotteryPoolConfig::field('id,name,t1_weight,t2_weight,t3_weight,t4_weight,t5_weight')
->order('id', 'asc')
->select();
$query = DiceLotteryPoolConfig::field('id,name,remark,t1_weight,t2_weight,t3_weight,t4_weight,t5_weight')
->order('id', 'asc');
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$list = $query->select();
$data = $list->map(function ($item) {
$row = is_array($item) ? $item : $item->toArray();
$display = DiceLotteryPoolConfig::displayLabel($row);
return [
'id' => (int) $item['id'],
'name' => (string) ($item['name'] ?? ''),
't1_weight' => (int) ($item['t1_weight'] ?? 0),
't2_weight' => (int) ($item['t2_weight'] ?? 0),
't3_weight' => (int) ($item['t3_weight'] ?? 0),
't4_weight' => (int) ($item['t4_weight'] ?? 0),
't5_weight' => (int) ($item['t5_weight'] ?? 0),
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'display_name' => $display,
't1_weight' => (int) ($row['t1_weight'] ?? 0),
't2_weight' => (int) ($row['t2_weight'] ?? 0),
't3_weight' => (int) ($row['t3_weight'] ?? 0),
't4_weight' => (int) ($row['t4_weight'] ?? 0),
't5_weight' => (int) ($row['t5_weight'] ?? 0),
];
})->toArray();
return $this->success($data);
@@ -67,6 +73,7 @@ class DiceLotteryPoolConfigController extends BaseController
['type', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$data = $this->logic->getList($query);
return $this->success($data);
}
@@ -99,6 +106,7 @@ class DiceLotteryPoolConfigController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareConfigSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
@@ -116,8 +124,19 @@ class DiceLotteryPoolConfigController extends BaseController
public function update(Request $request): Response
{
$data = $request->post();
if ($data === [] || $data === null) {
$data = $request->all();
}
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), is_array($data) ? $data : []);
$model = $this->logic->read($data['id'] ?? 0);
if ($model) {
$recordDeptId = is_array($model) ? ($model['dept_id'] ?? null) : ($model->dept_id ?? null);
if (! AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $recordDeptId, $requestDeptId)) {
return $this->fail('no permission to update this record');
}
}
$result = $this->logic->edit($data['id'], $data, $this->adminInfo ?? null, $requestDeptId);
if ($result) {
return $this->success('update success');
} else {
@@ -152,7 +171,8 @@ class DiceLotteryPoolConfigController extends BaseController
#[Permission('色子奖池配置列表', 'dice:lottery_pool_config:index:getCurrentPool')]
public function getCurrentPool(Request $request): Response
{
$data = $this->logic->getCurrentPool();
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$data = $this->logic->getCurrentPool($deptId);
return $this->success($data);
}
@@ -163,7 +183,12 @@ class DiceLotteryPoolConfigController extends BaseController
public function updateCurrentPool(Request $request): Response
{
$data = $request->post();
$this->logic->updateCurrentPool($data);
if ($data === [] || $data === null) {
$data = $request->all();
}
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), is_array($data) ? $data : []);
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $requestDeptId);
$this->logic->updateCurrentPool($data, $deptId);
return $this->success('save success');
}
@@ -173,7 +198,8 @@ class DiceLotteryPoolConfigController extends BaseController
#[Permission('色子奖池配置修改', 'dice:lottery_pool_config:index:resetProfitAmount')]
public function resetProfitAmount(Request $request): Response
{
$this->logic->resetProfitAmount();
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$this->logic->resetProfitAmount($deptId);
return $this->success('reset success');
}
}

View File

@@ -51,9 +51,11 @@ class DicePlayRecordController extends BaseController
['reward_ui_text', ''],
['reward_tier', ''],
['direction', ''],
['create_time_min', ''],
['create_time_max', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$query->with([
'dicePlayer',
'diceLotteryPoolConfig',
@@ -78,7 +80,7 @@ class DicePlayRecordController extends BaseController
public function getPlayerOptions(Request $request): Response
{
$query = DicePlayer::field('id,username');
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$list = $query->select();
$data = $list->map(function ($item) {
return ['id' => $item['id'], 'username' => $item['username'] ?? ''];
@@ -92,9 +94,21 @@ class DicePlayRecordController extends BaseController
#[Permission('玩家抽奖记录列表', 'dice:play_record:index:index')]
public function getLotteryConfigOptions(Request $request): Response
{
$list = DiceLotteryPoolConfig::field('id,name')->select();
$query = DiceLotteryPoolConfig::field('id,name,remark')->order('id', 'asc');
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
$request->input('dept_id'),
$request->all()
);
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $requestDeptId);
$list = $query->select();
$data = $list->map(function ($item) {
return ['id' => $item['id'], 'name' => $item['name'] ?? ''];
$row = is_array($item) ? $item : $item->toArray();
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'display_name' => DiceLotteryPoolConfig::displayLabel($row),
];
})->toArray();
return $this->success($data);
}
@@ -112,8 +126,7 @@ class DicePlayRecordController extends BaseController
if (!$model) {
return $this->fail('not found');
}
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($model->admin_id ?? 0), $allowedIds, true)) {
if (!AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $model->dept_id ?? null)) {
return $this->fail('no permission to view this record');
}
$data = is_array($model) ? $model : $model->toArray();
@@ -130,6 +143,7 @@ class DicePlayRecordController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareBusinessSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
@@ -138,24 +152,6 @@ class DicePlayRecordController extends BaseController
}
}
/**
* 更新数据
* @param Request $request
* @return Response
*/
#[Permission('玩家抽奖记录修改', 'dice:play_record:index:update')]
public function update(Request $request): Response
{
$data = $request->post();
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
if ($result) {
return $this->success('update success');
} else {
return $this->fail('update failed');
}
}
/**
* 删除数据
* @param Request $request

View File

@@ -6,6 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\controller\play_record_test;
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\play_record_test\DicePlayRecordTestLogic;
use app\dice\validate\play_record_test\DicePlayRecordTestValidate;
@@ -39,6 +40,7 @@ class DicePlayRecordTestController extends BaseController
{
$where = $request->more([
['reward_config_record_id', ''],
['lottery_config_id', ''],
['lottery_type', ''],
['direction', ''],
['is_win', ''],
@@ -50,6 +52,7 @@ class DicePlayRecordTestController extends BaseController
['roll_number', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$query->with(['diceLotteryPoolConfig']);
// 按当前筛选条件统计:平台总盈利 = 付费金额(paid_amount 求和) - 玩家总收益(win_coin 求和)
@@ -92,6 +95,7 @@ class DicePlayRecordTestController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareBusinessSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
@@ -100,24 +104,6 @@ class DicePlayRecordTestController extends BaseController
}
}
/**
* 更新数据
* @param Request $request
* @return Response
*/
#[Permission('玩家抽奖记录(测试数据)修改', 'dice:play_record_test:index:update')]
public function update(Request $request): Response
{
$data = $request->post();
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
if ($result) {
return $this->success('update success');
} else {
return $this->fail('update failed');
}
}
/**
* 删除数据
* @param Request $request

View File

@@ -8,6 +8,7 @@ namespace app\dice\controller\player;
use app\dice\helper\AdminScopeHelper;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\player\DicePlayerLogic;
@@ -42,9 +43,21 @@ class DicePlayerController extends BaseController
#[Permission('玩家列表', 'dice:player:index:index')]
public function getLotteryConfigOptions(Request $request): Response
{
$list = DiceLotteryPoolConfig::field('id,name')->order('id', 'asc')->select();
$query = DiceLotteryPoolConfig::field('id,name,remark')->order('id', 'asc');
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
$request->input('dept_id'),
$request->all()
);
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $requestDeptId);
$list = $query->select();
$data = $list->map(function ($item) {
return ['id' => (int) $item['id'], 'name' => (string) ($item['name'] ?? '')];
$row = is_array($item) ? $item : $item->toArray();
return [
'id' => (int) ($row['id'] ?? 0),
'name' => (string) ($row['name'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'display_name' => DiceLotteryPoolConfig::displayLabel($row),
];
})->toArray();
return $this->success($data);
}
@@ -57,12 +70,17 @@ class DicePlayerController extends BaseController
#[Permission('玩家列表', 'dice:player:index:index')]
public function getSystemUserOptions(Request $request): Response
{
$query = SystemUser::field('id,username,realname')->where('status', 1)->order('id', 'asc');
if (isset($this->adminInfo['id']) && (int) $this->adminInfo['id'] > 1) {
$deptList = $this->adminInfo['deptList'] ?? [];
if (!empty($deptList)) {
$query->auth($deptList);
$query = SystemUser::field('id,username,realname,dept_id')->where('status', 1)->order('id', 'asc');
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
$request->input('dept_id'),
$request->all()
);
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null) {
if ($allowedIds === []) {
return $this->success([]);
}
$query->whereIn('id', $allowedIds);
}
$list = $query->select();
$data = $list->map(function ($item) {
@@ -71,12 +89,89 @@ class DicePlayerController extends BaseController
'id' => (int) $item['id'],
'username' => (string) ($item['username'] ?? ''),
'realname' => (string) ($item['realname'] ?? ''),
'dept_id' => isset($item['dept_id']) ? (int) $item['dept_id'] : null,
'label' => $label ?: (string) $item['id'],
];
})->toArray();
return $this->success($data);
}
/**
* 超管:按渠道树状展示全部管理员;非超管:同 getSystemUserOptions 扁平列表
*/
#[Permission('玩家列表', 'dice:player:index:index')]
public function getSystemUserTreeOptions(Request $request): Response
{
if (!AdminScopeHelper::isSuperAdmin($this->adminInfo ?? null)) {
return $this->getSystemUserOptions($request);
}
$users = SystemUser::field('id,username,realname,dept_id')
->where('status', 1)
->order('id', 'asc')
->select()
->toArray();
$depts = SystemDept::field('id,name')
->where('status', 1)
->order('id', 'asc')
->select()
->toArray();
$deptNameMap = [];
foreach ($depts as $dept) {
$deptNameMap[(int) $dept['id']] = (string) ($dept['name'] ?? $dept['id']);
}
$grouped = [];
$unassigned = [];
foreach ($users as $user) {
$item = [
'id' => (int) $user['id'],
'username' => (string) ($user['username'] ?? ''),
'realname' => (string) ($user['realname'] ?? ''),
'dept_id' => isset($user['dept_id']) ? (int) $user['dept_id'] : null,
];
$label = trim($item['realname']) ?: $item['username'];
$item['label'] = $label ?: (string) $item['id'];
$deptId = $item['dept_id'] ?? 0;
if ($deptId > 0 && isset($deptNameMap[$deptId])) {
if (!isset($grouped[$deptId])) {
$grouped[$deptId] = [];
}
$grouped[$deptId][] = $item;
} else {
$unassigned[] = $item;
}
}
$tree = [];
foreach ($depts as $dept) {
$deptId = (int) $dept['id'];
$children = $grouped[$deptId] ?? [];
if ($children === []) {
continue;
}
$tree[] = [
'id' => 'dept_' . $deptId,
'label' => (string) ($dept['name'] ?? $deptId),
'disabled' => true,
'children' => $children,
];
}
if ($unassigned !== []) {
$tree[] = [
'id' => 'dept_unassigned',
'label' => '__unassigned__',
'disabled' => true,
'children' => $unassigned,
];
}
return $this->success($tree);
}
/**
* 数据列表
* @param Request $request
@@ -92,9 +187,11 @@ class DicePlayerController extends BaseController
['status', ''],
['coin', ''],
['lottery_config_id', ''],
['create_time_min', ''],
['create_time_max', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$query->with(['diceLotteryPoolConfig']);
$data = $this->logic->getList($query);
return $this->success($data);
@@ -113,8 +210,7 @@ class DicePlayerController extends BaseController
if (!$model) {
return $this->fail('not found');
}
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($model->admin_id ?? 0), $allowedIds, true)) {
if (!AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $model->dept_id ?? null)) {
return $this->fail('no permission to view this record');
}
$data = is_array($model) ? $model : $model->toArray();
@@ -130,20 +226,32 @@ class DicePlayerController extends BaseController
public function save(Request $request): Response
{
$data = $request->post();
$this->validate('save', $data);
// 类型转化
if (empty($data['admin_id']) && isset($this->adminInfo['id']) && (int) $this->adminInfo['id'] > 0) {
$data['admin_id'] = (int) $this->adminInfo['id'];
}
$result = $this->logic->add($data);
if ($result && isset($result['id'])) {
// 出于安全:删除该玩家缓存,后续 API 按需重建
UserCache::deleteUser($result['id']);
$player = DicePlayer::find($result['id']);
AdminScopeHelper::prepareBusinessSaveData(
$data,
$this->adminInfo ?? null,
$request->input('dept_id'),
$data
);
$this->validate('save', $data);
try {
$result = $this->logic->add($data);
} catch (\Throwable $e) {
if (self::isDeptUsernameDuplicateException($e)) {
return $this->fail('PLAYER_USERNAME_DEPT_UNIQUE');
}
throw $e;
}
$playerId = is_array($result) ? ($result['id'] ?? null) : $result;
if ($playerId) {
UserCache::deleteUser($playerId);
$player = DicePlayer::find($playerId);
if ($player && $player->username !== '') {
UserCache::deletePlayerByUsername($player->username);
}
return $this->success('add success');
return $this->success('ADD_SUCCESS');
}
return $this->fail('add failed');
}
@@ -157,14 +265,16 @@ class DicePlayerController extends BaseController
public function update(Request $request): Response
{
$data = $request->post();
$this->validate('update', $data);
$model = $this->logic->read($data['id'] ?? 0);
if ($model) {
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($model->admin_id ?? 0), $allowedIds, true)) {
if (!AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $model->dept_id ?? null, $request->input('dept_id'))) {
return $this->fail('no permission to update this record');
}
if (!isset($data['dept_id']) || $data['dept_id'] === '' || $data['dept_id'] === null) {
$data['dept_id'] = $model->dept_id ?? null;
}
}
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
if ($result) {
// 出于安全:删除该玩家缓存,后续 API 按需重建
@@ -173,7 +283,7 @@ class DicePlayerController extends BaseController
if ($player && $player->username !== '') {
UserCache::deletePlayerByUsername($player->username);
}
return $this->success('update success');
return $this->success('UPDATE_SUCCESS');
}
return $this->fail('update failed');
}
@@ -196,8 +306,7 @@ class DicePlayerController extends BaseController
}
$model = $this->logic->read($id);
if ($model) {
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($model->admin_id ?? 0), $allowedIds, true)) {
if (!AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $model->dept_id ?? null, $request->input('dept_id'))) {
return $this->fail('no permission to update this record');
}
}
@@ -227,8 +336,7 @@ class DicePlayerController extends BaseController
if (!$player) {
return $this->fail('not found');
}
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($player->admin_id ?? 0), $allowedIds, true)) {
if (!AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $player->dept_id ?? null)) {
return $this->fail('no permission to view this record');
}
if ((int) ($player->status ?? 1) === 0) {
@@ -255,6 +363,7 @@ class DicePlayerController extends BaseController
return $this->fail('generate token failed');
}
UserCache::setSessionByUsername($username, $token);
UserCache::setCurrentUserToken((int) $player->id, $token);
$userArr = $player->hidden(['password', 'lottery_config_id', 't1_weight', 't2_weight', 't3_weight', 't4_weight', 't5_weight'])->toArray();
UserCache::setUser((int) $player->id, $userArr);
UserCache::setPlayerByUsername($username, $userArr);
@@ -263,6 +372,9 @@ class DicePlayerController extends BaseController
if ($gameUrlBase === '') {
return $this->fail('GAME_URL is not configured');
}
if (!str_starts_with($gameUrlBase, 'http://') && !str_starts_with($gameUrlBase, 'https://')) {
$gameUrlBase = 'https://' . $gameUrlBase;
}
$tokenInUrl = str_replace('%3D', '=', urlencode($token));
$url = $gameUrlBase . '/?token=' . $tokenInUrl . '&lang=' . $lang;
return $this->success(['url' => $url]);
@@ -281,13 +393,12 @@ class DicePlayerController extends BaseController
return $this->fail('please select data to delete');
}
$ids = is_array($ids) ? $ids : explode(',', (string) $ids);
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null) {
$models = $this->logic->model->whereIn('id', $ids)->column('admin_id', 'id');
$deptId = AdminScopeHelper::getDeptId($this->adminInfo ?? null);
if ($deptId !== null) {
$models = $this->logic->model->whereIn('id', $ids)->column('dept_id', 'id');
$validIds = [];
foreach ($ids as $id) {
$adminId = (int) ($models[$id] ?? 0);
if (in_array($adminId, $allowedIds, true)) {
if (AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $models[$id] ?? null)) {
$validIds[] = $id;
}
}
@@ -311,4 +422,17 @@ class DicePlayerController extends BaseController
return $this->fail('delete failed');
}
/**
* 判断是否违反 dice_player (dept_id, username) 唯一索引
*/
private static function isDeptUsernameDuplicateException(\Throwable $e): bool
{
$message = $e->getMessage();
if ($message === '') {
return false;
}
return str_contains($message, 'uk_dice_player_dept_username')
|| (str_contains($message, 'Duplicate entry') && str_contains($message, 'username'));
}
}

View File

@@ -53,7 +53,7 @@ class DicePlayerTicketRecordController extends BaseController
['create_time_max', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$query->with([
'dicePlayer',
]);
@@ -70,7 +70,7 @@ class DicePlayerTicketRecordController extends BaseController
public function getPlayerOptions(Request $request): Response
{
$query = DicePlayer::field('id,username');
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$list = $query->select();
$data = $list->map(function ($item) {
return ['id' => $item['id'], 'username' => $item['username'] ?? ''];
@@ -91,8 +91,7 @@ class DicePlayerTicketRecordController extends BaseController
if (!$model) {
return $this->fail('not found');
}
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($model->admin_id ?? 0), $allowedIds, true)) {
if (!AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $model->dept_id ?? null, $request->input('dept_id'))) {
return $this->fail('no permission to view this record');
}
$data = is_array($model) ? $model : $model->toArray();
@@ -109,6 +108,7 @@ class DicePlayerTicketRecordController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareBusinessSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
@@ -117,24 +117,6 @@ class DicePlayerTicketRecordController extends BaseController
}
}
/**
* 更新数据
* @param Request $request
* @return Response
*/
#[Permission('抽奖券获取记录修改', 'dice:player_ticket_record:index:update')]
public function update(Request $request): Response
{
$data = $request->post();
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
if ($result) {
return $this->success('update success');
} else {
return $this->fail('update failed');
}
}
/**
* 删除数据
* @param Request $request

View File

@@ -47,13 +47,28 @@ class DicePlayerWalletRecordController extends BaseController
['create_time_min', ''],
['create_time_max', ''],
]);
$deptId = $request->input('dept_id');
$query = $this->logic->search($where);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $deptId);
$sumQuery = clone $query;
$totalCoinChange = round((float) $sumQuery->sum('coin'), 2);
$inflowQuery = clone $query;
$totalCoinInflow = round((float) $inflowQuery->where('coin', '>', 0)->sum('coin'), 2);
$outflowQuery = clone $query;
$totalCoinOutflow = round((float) $outflowQuery->where('coin', '<', 0)->sum('coin'), 2);
$query->with([
'dicePlayer',
'operator',
]);
$data = $this->logic->getList($query);
$data['total_coin_change'] = $totalCoinChange;
$data['total_coin_inflow'] = $totalCoinInflow;
$data['total_coin_outflow'] = $totalCoinOutflow;
return $this->success($data);
}
@@ -66,7 +81,7 @@ class DicePlayerWalletRecordController extends BaseController
public function getPlayerOptions(Request $request): Response
{
$query = DicePlayer::field('id,username');
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$list = $query->select();
$data = $list->map(function ($item) {
return ['id' => $item['id'], 'username' => $item['username'] ?? ''];
@@ -87,12 +102,11 @@ class DicePlayerWalletRecordController extends BaseController
if ($playerId === null || $playerId === '') {
return $this->fail('missing player_id');
}
$player = DicePlayer::field('coin,admin_id')->where('id', $playerId)->find();
$player = DicePlayer::field('coin,dept_id')->where('id', $playerId)->find();
if (!$player) {
return $this->fail('Player not found');
}
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($player->admin_id ?? 0), $allowedIds, true)) {
if (!AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $player->dept_id ?? null)) {
return $this->fail('no permission to operate this player');
}
return $this->success(['wallet_before' => (float) $player['coin']]);
@@ -111,8 +125,7 @@ class DicePlayerWalletRecordController extends BaseController
if (!$model) {
return $this->fail('not found');
}
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($model->admin_id ?? 0), $allowedIds, true)) {
if (!AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $model->dept_id ?? null)) {
return $this->fail('no permission to view this record');
}
$data = is_array($model) ? $model : $model->toArray();
@@ -166,12 +179,9 @@ class DicePlayerWalletRecordController extends BaseController
return $this->fail('please login first');
}
$player = DicePlayer::field('admin_id')->where('id', $playerId)->find();
if ($player) {
$allowedIds = AdminScopeHelper::getAllowedAdminIds($this->adminInfo ?? null);
if ($allowedIds !== null && !in_array((int) ($player->admin_id ?? 0), $allowedIds, true)) {
return $this->fail('no permission to operate this player');
}
$player = DicePlayer::field('dept_id')->where('id', $playerId)->find();
if ($player && !AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $player->dept_id ?? null, $request->input('dept_id'))) {
return $this->fail('no permission to operate this player');
}
try {
@@ -183,20 +193,21 @@ class DicePlayerWalletRecordController extends BaseController
}
/**
* 更新数据
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('玩家钱包流水修改', 'dice:player_wallet_record:index:update')]
public function update(Request $request): Response
#[Permission('玩家钱包流水添加', 'dice:player_wallet_record:index:save')]
public function save(Request $request): Response
{
$data = $request->post();
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
$this->validate('save', $data);
AdminScopeHelper::prepareBusinessSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('update success');
} else {
return $this->fail('update failed');
return $this->success('add success');
}
return $this->fail('add failed');
}
}

View File

@@ -4,6 +4,7 @@
// +----------------------------------------------------------------------
namespace app\dice\controller\reward;
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward\DiceRewardLogic;
use app\dice\logic\reward_config_record\DiceRewardConfigRecordLogic;
use app\dice\model\reward\DiceReward;
@@ -42,11 +43,18 @@ class DiceRewardController extends BaseController
$orderType = $request->input('orderType', 'asc');
$logic = new DiceRewardLogic();
$data = $logic->getListWithConfig($direction, [
'tier' => $tier,
'orderField' => $orderField,
'orderType' => $orderType,
], $page, $limit);
$data = $logic->getListWithConfig(
$direction,
[
'tier' => $tier,
'orderField' => $orderField,
'orderType' => $orderType,
],
$page,
$limit,
$this->adminInfo ?? null,
$request->input('dept_id')
);
return $this->success($data);
}
@@ -61,8 +69,10 @@ class DiceRewardController extends BaseController
if (!in_array($direction, [DiceReward::DIRECTION_CLOCKWISE, DiceReward::DIRECTION_COUNTERCLOCKWISE], true)) {
$direction = DiceReward::DIRECTION_CLOCKWISE;
}
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
DiceReward::refreshCache($deptId);
$logic = new DiceRewardLogic();
$data = $logic->getListGroupedByTierForDirection($direction);
$data = $logic->getListGroupedByTierForDirection($direction, $deptId);
return $this->success($data);
}
@@ -74,9 +84,10 @@ class DiceRewardController extends BaseController
#[Permission('奖励对照列表', 'dice:reward:index:index')]
public function weightRatioListWithDirection(Request $request): Response
{
DiceReward::refreshCache();
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
DiceReward::refreshCache($deptId);
$logic = new DiceRewardLogic();
$data = $logic->getListGroupedByTierWithDirection();
$data = $logic->getListGroupedByTierWithDirection($deptId);
return $this->success($data);
}
@@ -85,7 +96,7 @@ class DiceRewardController extends BaseController
* 参数lottery_config_id 可选paid_tier_weights / free_tier_weights 自定义档位;
* paid_s_count, paid_n_count
* chain_free_mode=1仅按付费次数模拟付费抽到再来一次/T5 则在队列中插入免费局同底注、lottery_type=免费、paid_amount=0
* kill_mode_enabled=1测试内启用杀分当模拟玩家累计盈利达到 test_safety_line 后,付费抽奖切到 killScore
* kill_mode_enabled=1测试内启用杀分当模拟盈利达到 test_safety_line 后,付费抽奖切到 killScore
*/
#[Permission('一键测试权重', 'dice:reward:index:startWeightTest')]
public function startWeightTest(Request $request): Response
@@ -103,11 +114,15 @@ class DiceRewardController extends BaseController
'chain_free_mode' => $post['chain_free_mode'] ?? null,
'kill_mode_enabled' => $post['kill_mode_enabled'] ?? null,
'test_safety_line' => $post['test_safety_line'] ?? null,
'dept_id' => $post['dept_id'] ?? null,
'ante_config_id' => $post['ante_config_id'] ?? null,
'ante_random' => $post['ante_random'] ?? null,
];
$adminId = isset($this->adminInfo['id']) ? (int) $this->adminInfo['id'] : null;
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), $post);
try {
$logic = new DiceRewardConfigRecordLogic();
$recordId = $logic->createWeightTestRecord($params, $adminId);
$recordId = $logic->createWeightTestRecord($params, $adminId, $this->adminInfo ?? null, $requestDeptId);
return $this->success(['record_id' => $recordId]);
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
@@ -167,8 +182,9 @@ class DiceRewardController extends BaseController
return $this->fail('parameter items must be an array');
}
try {
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$logic = new DiceRewardLogic();
$logic->batchUpdateWeights($items);
$logic->batchUpdateWeights($items, $deptId);
return $this->success('save success');
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
@@ -191,8 +207,9 @@ class DiceRewardController extends BaseController
return $this->fail('parameter items must be an array');
}
try {
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$logic = new DiceRewardLogic();
$logic->batchUpdateWeightsByDirection($direction, $items);
$logic->batchUpdateWeightsByDirection($direction, $items, $deptId);
return $this->success('save success');
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());

View File

@@ -6,9 +6,11 @@
// +----------------------------------------------------------------------
namespace app\dice\controller\reward_config;
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use app\dice\logic\reward\DiceRewardLogic;
use app\dice\model\reward\DiceReward;
use app\dice\validate\reward_config\DiceRewardConfigValidate;
use plugin\saiadmin\service\Permission;
use support\Request;
@@ -46,7 +48,9 @@ class DiceRewardConfigController extends BaseController
['tier', ''],
]);
$query = $this->logic->search($where);
$data = $this->logic->getList($query);
AdminScopeHelper::applyConfigScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
// 奖励索引 + 大奖权重共约 32 条,配置页需一次返回本渠道全部数据
$data = $query->order('id', 'asc')->select()->toArray();
return $this->success($data);
}
@@ -78,6 +82,7 @@ class DiceRewardConfigController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareConfigSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
@@ -95,8 +100,19 @@ class DiceRewardConfigController extends BaseController
public function update(Request $request): Response
{
$data = $request->post();
if ($data === [] || $data === null) {
$data = $request->all();
}
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), is_array($data) ? $data : []);
$model = $this->logic->read($data['id'] ?? 0);
if ($model) {
$recordDeptId = is_array($model) ? ($model['dept_id'] ?? null) : ($model->dept_id ?? null);
if (! AdminScopeHelper::canAccessDept($this->adminInfo ?? null, $recordDeptId, $requestDeptId)) {
return $this->fail('no permission to update this record');
}
}
$result = $this->logic->edit($data['id'], $data, $this->adminInfo ?? null, $requestDeptId);
if ($result) {
return $this->success('update success');
} else {
@@ -111,6 +127,25 @@ class DiceRewardConfigController extends BaseController
*/
#[Permission('修改奖励索引', 'dice:reward_config:index:batchUpdate')]
public function batchUpdate(Request $request): Response
{
return $this->doBatchUpdateIndex($request);
}
/**
* 按规则生成并保存奖励索引(需档位结算推荐配置权限,与 batchUpdate 写入逻辑相同)
*
* @param Request $request items: [{ id, grid_number?, ui_text?, ui_text_en?, real_ev?, tier?, remark? }, ...]
*/
#[Permission('档位结算推荐配置', 'dice:reward_config:index:tierRecommend')]
public function generateIndexByRules(Request $request): Response
{
return $this->doBatchUpdateIndex($request);
}
/**
* @return Response
*/
private function doBatchUpdateIndex(Request $request): Response
{
$items = $request->post('items', []);
if (! is_array($items)) {
@@ -123,7 +158,9 @@ class DiceRewardConfigController extends BaseController
foreach ($items as $item) {
$this->validate('batch_update', array_merge($item, ['id' => $item['id']]));
}
$this->logic->batchUpdate($items);
$requestDeptId = AdminScopeHelper::pickRequestDeptId($request->input('dept_id'), $request->post());
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $requestDeptId);
$this->logic->batchUpdate($items, $deptId);
return $this->success('save success');
}
@@ -139,7 +176,7 @@ class DiceRewardConfigController extends BaseController
if (empty($ids)) {
return $this->fail('please select data to delete');
}
$result = $this->logic->destroy($ids);
$result = $this->logic->destroy($ids, $this->adminInfo ?? null, $request->input('dept_id'));
if ($result) {
return $this->success('delete success');
} else {
@@ -155,8 +192,10 @@ class DiceRewardConfigController extends BaseController
#[Permission('奖励配置列表', 'dice:reward_config:index:index')]
public function weightRatioList(Request $request): Response
{
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
DiceReward::refreshCache($deptId);
$rewardLogic = new DiceRewardLogic();
$data = $rewardLogic->getListGroupedByTierWithDirection();
$data = $rewardLogic->getListGroupedByTierWithDirection($deptId);
return $this->success($data);
}
@@ -174,8 +213,9 @@ class DiceRewardConfigController extends BaseController
return $this->fail('parameter items must be an array');
}
try {
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$rewardLogic = new DiceRewardLogic();
$rewardLogic->batchUpdateWeights($items);
$rewardLogic->batchUpdateWeights($items, $deptId);
return $this->success('save success');
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
@@ -199,7 +239,8 @@ class DiceRewardConfigController extends BaseController
if ($err !== null) {
return $this->fail($err);
}
$this->logic->batchUpdateBigwinWeight($items);
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$this->logic->batchUpdateBigwinWeight($items, $deptId);
return $this->success('save success');
}
@@ -214,13 +255,31 @@ class DiceRewardConfigController extends BaseController
{
try {
$rewardLogic = new DiceRewardLogic();
$result = $rewardLogic->createRewardReferenceFromConfig();
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$result = $rewardLogic->createRewardReferenceFromConfig($deptId);
return $this->success($result, 'create reward mapping success');
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
}
}
/**
* 创建奖励对照(预览):不写入 dice_reward仅计算并返回预览分组数据。
* 若当前 dice_reward 与计算结果一致,则 unchanged=true并在预览中复用现有权重导入时仍沿用旧权重
*/
#[Permission('创建奖励对照', 'dice:reward_config:index:createRewardReference')]
public function createRewardReferencePreview(Request $request): Response
{
try {
$rewardLogic = new DiceRewardLogic();
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo ?? null, $request->input('dept_id'));
$result = $rewardLogic->createRewardReferencePreviewFromConfig($deptId);
return $this->success($result, 'preview reward mapping success');
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
}
}
/**
* 权重配比测试:仅模拟落点统计,不创建游玩记录。按当前配置在内存中模拟 N 次抽奖,返回各 grid_number 落点次数,可选保存到 dice_reward_config_record。
* @param Request $request test_count: 100|500|1000, save_record: bool, lottery_config_id: int|null 奖池配置ID用于设定 T1-T5 概率

View File

@@ -8,6 +8,7 @@ namespace app\dice\controller\reward_config_record;
use app\dice\logic\reward_config_record\DiceRewardConfigRecordLogic;
use app\dice\validate\reward_config_record\DiceRewardConfigRecordValidate;
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\basic\BaseController;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\service\Permission;
@@ -42,6 +43,7 @@ class DiceRewardConfigRecordController extends BaseController
['ante', ''],
]);
$query = $this->logic->search($where);
AdminScopeHelper::applyAdminScope($query, $this->adminInfo ?? null, $request->input('dept_id'));
$data = $this->logic->getList($query);
return $this->success($data);
}
@@ -96,6 +98,7 @@ class DiceRewardConfigRecordController extends BaseController
{
$data = $request->post();
$this->validate('save', $data);
AdminScopeHelper::prepareBusinessSaveData($data, $this->adminInfo ?? null, $request->input('dept_id'), $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
@@ -104,24 +107,6 @@ class DiceRewardConfigRecordController extends BaseController
}
}
/**
* 更新数据
* @param Request $request
* @return Response
*/
#[Permission('奖励配置权重测试记录修改', 'dice:reward_config_record:index:update')]
public function update(Request $request): Response
{
$data = $request->post();
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
if ($result) {
return $this->success('update success');
} else {
return $this->fail('update failed');
}
}
/**
* 删除数据
* @param Request $request

View File

@@ -3,21 +3,75 @@ declare(strict_types=1);
namespace app\dice\helper;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\exception\ApiException;
/**
* 管理员数据范围辅助类
* 用于获取当前管理员及其部门下属管理员可访问的数据范围
* 大富翁数据范围:按渠道 dept_id 隔离(关联 sa_system_dept
*/
class AdminScopeHelper
{
/** 超管查看默认配置模板 */
public const DEFAULT_TEMPLATE_DEPT = 0;
/**
* 获取当前管理员可访问的 admin_id 列表
* 超级管理员(id=1) 返回 null 表示不限制
* 普通管理员返回其本人及部门下属管理员的 id 列表
* 当前管理员所属渠道 ID超级管理员(id=1) 返回 null 表示不限制
*/
public static function getDeptId(?array $adminInfo): ?int
{
if (empty($adminInfo) || !isset($adminInfo['id'])) {
return null;
}
$adminId = (int) $adminInfo['id'];
if ($adminId <= 1) {
return null;
}
$deptList = $adminInfo['deptList'] ?? [];
if (!empty($deptList['id'])) {
return (int) $deptList['id'];
}
if (!empty($adminInfo['dept_id'])) {
return (int) $adminInfo['dept_id'];
}
return 0;
}
public static function isSuperAdmin(?array $adminInfo): bool
{
return !empty($adminInfo['id']) && (int) $adminInfo['id'] <= 1;
}
/**
* 解析配置类接口的渠道 ID请求参数 dept_id
* 超管0 或空=默认模板(null)>0=指定渠道
* 普通管理员:固定本渠道
*/
public static function resolveConfigDeptId(?array $adminInfo, $requestDeptId): int
{
$scopeDeptId = self::getDeptId($adminInfo);
if ($scopeDeptId !== null) {
return $scopeDeptId > 0 ? $scopeDeptId : self::DEFAULT_TEMPLATE_DEPT;
}
if ($requestDeptId === null || $requestDeptId === '') {
return self::DEFAULT_TEMPLATE_DEPT;
}
$id = (int) $requestDeptId;
if ($id === self::DEFAULT_TEMPLATE_DEPT) {
return self::DEFAULT_TEMPLATE_DEPT;
}
return $id > 0 ? $id : self::DEFAULT_TEMPLATE_DEPT;
}
public static function isTemplateDeptId($deptId): bool
{
return $deptId === null || $deptId === '' || (int) $deptId === self::DEFAULT_TEMPLATE_DEPT;
}
/**
* 同渠道下可访问的管理员 ID
*
* @param array|null $adminInfo 当前登录管理员信息(含 id、deptList
* @return int[]|null null=不限制(超级管理员),否则为可访问的 admin_id 数组
* @return int[]|null null=不限制
*/
public static function getAllowedAdminIds(?array $adminInfo): ?array
{
@@ -28,33 +82,285 @@ class AdminScopeHelper
if ($adminId <= 1) {
return null;
}
$deptList = $adminInfo['deptList'] ?? [];
if (empty($deptList) || !isset($deptList['id'])) {
$deptId = self::getDeptId($adminInfo);
if ($deptId === null) {
return null;
}
if ($deptId <= 0) {
return [$adminId];
}
$query = SystemUser::field('id');
$query->auth($deptList);
$ids = $query->column('id');
return array_map('intval', $ids ?: []);
$ids = SystemUser::where('dept_id', $deptId)->column('id');
return array_map('intval', $ids ?: [$adminId]);
}
public static function fillDeptId(array &$data, ?array $adminInfo, $requestDeptId = null): void
{
if (isset($data['dept_id']) && $data['dept_id'] !== '' && $data['dept_id'] !== null) {
return;
}
$deptId = self::resolveConfigDeptId($adminInfo, $requestDeptId ?? ($data['filter_dept_id'] ?? null));
if ($deptId > 0) {
$data['dept_id'] = $deptId;
} elseif (!isset($data['dept_id']) || self::isTemplateDeptId($data['dept_id'])) {
$data['dept_id'] = self::DEFAULT_TEMPLATE_DEPT;
}
}
/**
* 对查询应用 admin_id 范围过滤
*
* @param object $query ThinkORM 查询对象
* @param array|null $adminInfo 当前登录管理员信息
* @return void
* 业务数据新增:按渠道写入 dept_id玩家、记录等
* 优先级:已有 dept_id → 所属管理员 admin_id → 请求渠道 → 当前登录人渠道
*/
public static function applyAdminScope($query, ?array $adminInfo): void
public static function fillBusinessDeptId(array &$data, ?array $adminInfo, $requestDeptId = null): void
{
$allowedIds = self::getAllowedAdminIds($adminInfo);
if ($allowedIds === null) {
if (isset($data['dept_id']) && $data['dept_id'] !== '' && $data['dept_id'] !== null) {
$data['dept_id'] = (int) $data['dept_id'];
if ($data['dept_id'] > 0) {
return;
}
}
if (!empty($data['player_id'])) {
$playerDeptId = self::resolveDeptIdByPlayerId($data['player_id']);
if ($playerDeptId !== null && $playerDeptId > 0) {
$data['dept_id'] = $playerDeptId;
return;
}
}
if (!empty($data['admin_id'])) {
$ownerDeptId = self::resolveDeptIdByAdminId($data['admin_id']);
if ($ownerDeptId !== null && $ownerDeptId > 0) {
$data['dept_id'] = $ownerDeptId;
return;
}
}
$deptId = self::resolveBusinessDeptId($adminInfo, $requestDeptId);
if ($deptId !== null && $deptId > 0) {
$data['dept_id'] = $deptId;
return;
}
if (empty($allowedIds)) {
$scopeDeptId = self::getDeptId($adminInfo);
if ($scopeDeptId !== null && $scopeDeptId > 0) {
$data['dept_id'] = $scopeDeptId;
}
}
/**
* 业务新增:解析请求渠道并填充 dept_id缺失时抛错
*/
public static function prepareBusinessSaveData(
array &$data,
?array $adminInfo,
$inputDeptId = null,
array $body = []
): void {
$requestDeptId = self::pickRequestDeptId($inputDeptId, $body);
self::fillBusinessDeptId($data, $adminInfo, $requestDeptId);
self::assertBusinessDeptId($data);
}
/**
* 配置新增:解析请求渠道并填充 dept_id
*/
public static function prepareConfigSaveData(
array &$data,
?array $adminInfo,
$inputDeptId = null,
array $body = []
): void {
$requestDeptId = self::pickRequestDeptId($inputDeptId, $body);
self::fillDeptId($data, $adminInfo, $requestDeptId);
}
/**
* 业务表 dept_id 必须 > 0非默认模板
*/
public static function assertBusinessDeptId(array $data): void
{
if (!isset($data['dept_id']) || $data['dept_id'] === '' || $data['dept_id'] === null) {
throw new ApiException('CHANNEL_DEPT_ID_REQUIRED');
}
if ((int) $data['dept_id'] <= 0) {
throw new ApiException('INVALID_CHANNEL_DEPT_ID');
}
}
/**
* 根据玩家 ID 解析所属渠道
*/
public static function resolveDeptIdByPlayerId($playerId): ?int
{
if ($playerId === null || $playerId === '') {
return null;
}
$player = DicePlayer::field('dept_id,admin_id')->find($playerId);
if (!$player || $player->isEmpty()) {
return null;
}
$deptId = $player->dept_id ?? null;
if ($deptId !== null && $deptId !== '' && (int) $deptId > 0) {
return (int) $deptId;
}
if (!empty($player->admin_id)) {
return self::resolveDeptIdByAdminId($player->admin_id);
}
return null;
}
/**
* 根据后台管理员 ID 解析所属渠道
*/
public static function resolveDeptIdByAdminId($adminId): ?int
{
if ($adminId === null || $adminId === '') {
return null;
}
$admin = SystemUser::find($adminId);
if (!$admin || $admin->isEmpty()) {
return null;
}
$deptId = $admin->dept_id ?? null;
if ($deptId !== null && $deptId !== '' && (int) $deptId > 0) {
return (int) $deptId;
}
return null;
}
/**
* 规范化记录上的 dept_idnull 视为默认模板 0
*/
public static function normalizeRecordDeptId($recordDeptId): int
{
if ($recordDeptId === null || $recordDeptId === '') {
return self::DEFAULT_TEMPLATE_DEPT;
}
return (int) $recordDeptId;
}
/**
* 玩家端读取游戏配置时使用的渠道 ID玩家 dept_id 优先,否则按所属管理员)
*/
public static function resolvePlayerConfigDeptId($player): int
{
$deptId = null;
$adminId = null;
if (is_array($player)) {
$deptId = $player['dept_id'] ?? null;
$adminId = $player['admin_id'] ?? null;
} elseif (is_object($player)) {
$deptId = $player->dept_id ?? null;
$adminId = $player->admin_id ?? null;
}
if ($deptId !== null && $deptId !== '' && (int) $deptId > 0) {
return (int) $deptId;
}
if ($adminId !== null && $adminId !== '') {
$fromAdmin = self::resolveDeptIdByAdminId($adminId);
if ($fromAdmin !== null && $fromAdmin > 0) {
return $fromAdmin;
}
}
return self::DEFAULT_TEMPLATE_DEPT;
}
/**
* 从请求参数或请求体中解析 dept_id兼容 PUT JSON 仅出现在 body 的情况)
*/
public static function pickRequestDeptId($inputDeptId, array $body = [])
{
if ($inputDeptId !== null && $inputDeptId !== '') {
return $inputDeptId;
}
if (isset($body['dept_id']) && $body['dept_id'] !== '' && $body['dept_id'] !== null) {
return $body['dept_id'];
}
return null;
}
public static function canAccessDept(?array $adminInfo, $recordDeptId, $requestDeptId = null): bool
{
$recordDeptId = self::normalizeRecordDeptId($recordDeptId);
$scopeDeptId = self::getDeptId($adminInfo);
if ($scopeDeptId === null) {
if ($requestDeptId === null || $requestDeptId === '') {
return true;
}
$target = self::resolveConfigDeptId($adminInfo, $requestDeptId);
if (self::isTemplateDeptId($target)) {
return self::isTemplateDeptId($recordDeptId);
}
return $recordDeptId === $target;
}
if ($recordDeptId === self::DEFAULT_TEMPLATE_DEPT && $scopeDeptId > 0) {
return false;
}
return $recordDeptId === $scopeDeptId;
}
/**
* 业务页渠道 ID超管通过请求 dept_id 筛选;未传或 <=0 时不限制
*/
public static function resolveBusinessDeptId(?array $adminInfo, $requestDeptId): ?int
{
$scopeDeptId = self::getDeptId($adminInfo);
if ($scopeDeptId !== null) {
return $scopeDeptId > 0 ? $scopeDeptId : null;
}
if ($requestDeptId === null || $requestDeptId === '') {
return null;
}
$id = (int) $requestDeptId;
return $id > 0 ? $id : null;
}
/**
* 业务数据列表(玩家、记录、工作台等)
*/
public static function applyAdminScope($query, ?array $adminInfo, $requestDeptId = null): void
{
if (self::getDeptId($adminInfo) === null) {
$target = self::resolveBusinessDeptId($adminInfo, $requestDeptId);
if ($target !== null && $target > 0) {
$query->where('dept_id', $target);
}
return;
}
$deptId = self::getDeptId($adminInfo);
if ($deptId <= 0) {
$query->whereRaw('1=0');
return;
}
$query->whereIn('admin_id', $allowedIds);
$query->where('dept_id', $deptId);
}
/**
* 配置类列表:超管按所选渠道/默认模板筛选
*/
public static function applyConfigScope($query, ?array $adminInfo, $requestDeptId = null, string $deptColumn = 'dept_id'): void
{
$targetDeptId = self::resolveConfigDeptId($adminInfo, $requestDeptId);
$scopeDeptId = self::getDeptId($adminInfo);
if ($scopeDeptId !== null && !self::isSuperAdmin($adminInfo)) {
if ($scopeDeptId <= 0) {
$query->whereRaw('1=0');
return;
}
$query->where($deptColumn, $scopeDeptId);
return;
}
if (self::isTemplateDeptId($targetDeptId)) {
$templateId = self::DEFAULT_TEMPLATE_DEPT;
$query->where(function ($q) use ($templateId, $deptColumn) {
$q->where($deptColumn, $templateId)->whereOr($deptColumn, null);
});
return;
}
$query->where($deptColumn, $targetDeptId);
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace app\dice\helper;
use plugin\saiadmin\exception\ApiException;
/**
* 配置类数据按渠道隔离的更新(防止 find(id) 误更新其他渠道同业务主键行)
*/
class ConfigScopeEditHelper
{
/**
* 在查询上附加渠道条件
*/
public static function applyDeptIdWhere($query, int $deptId, string $column = 'dept_id'): void
{
if (AdminScopeHelper::isTemplateDeptId($deptId)) {
$query->where(function ($q) use ($deptId, $column) {
$q->where($column, $deptId);
if (method_exists($q, 'orWhereNull')) {
$q->orWhereNull($column);
} else {
$q->whereOr($column, null);
}
});
return;
}
$query->where($column, $deptId);
}
/**
* 按主键 + 渠道更新配置行
*
* @param Model $model 模型实例(用于取表名、主键)
* @param mixed $primaryKeyValue 列表/表单中的主键值
* @param int $deptId 渠道 ID0=默认模板)
* @param array $data 更新字段
* @param array $forbidden 禁止写入的字段名
*/
/**
* 按主键更新(主键全局唯一表如 dice_lottery_pool_config
* 以库中记录的 dept_id 为准,避免请求未带 dept_id 时误按默认模板 0 查找失败
*/
public static function updateByPkAndDept(
object $model,
$primaryKeyValue,
int $requestDeptId,
array $data,
array $forbidden = ['id', 'dept_id', 'create_time', 'update_time', 'delete_time', 'row_id'],
?array $adminInfo = null,
$rawRequestDeptId = null
): bool {
foreach ($forbidden as $field) {
unset($data[$field]);
}
if ($data === []) {
return true;
}
$pk = self::resolvePk($model);
$record = $model->where($pk, $primaryKeyValue)->find();
if ($record === null) {
throw new ApiException('data not found');
}
$recordDeptId = AdminScopeHelper::normalizeRecordDeptId(
is_array($record) ? ($record['dept_id'] ?? null) : ($record->dept_id ?? null)
);
if ($adminInfo !== null && ! AdminScopeHelper::canAccessDept($adminInfo, $recordDeptId, $rawRequestDeptId)) {
throw new ApiException('no permission to update this record');
}
if ($rawRequestDeptId !== null && $rawRequestDeptId !== '') {
$targetDeptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $rawRequestDeptId);
if ($targetDeptId !== $recordDeptId) {
throw new ApiException('record does not belong to selected channel');
}
}
$query = $model->where($pk, $primaryKeyValue);
self::applyDeptIdWhere($query, $recordDeptId);
$affected = $query->update($data);
return $affected !== false;
}
/**
* dice_reward_config / dice_config业务 id025 等)+ 渠道更新
*/
public static function updateByBusinessIdAndDept(
object $model,
int $businessId,
int $deptId,
array $data,
array $forbidden = ['id', 'dept_id', 'create_time', 'update_time', 'delete_time', 'row_id']
): bool {
foreach ($forbidden as $field) {
unset($data[$field]);
}
if ($data === []) {
return true;
}
$query = $model->where('id', $businessId);
self::applyDeptIdWhere($query, $deptId);
$record = (clone $query)->find();
if ($record === null) {
throw new ApiException('config id=' . $businessId . ' not found for current channel');
}
$affected = $query->update($data);
return $affected !== false;
}
/**
* 列表/读取:按主键 + 渠道取单条(避免 find(pk) 命中其他渠道)
*/
private static function resolvePk(object $model): string
{
if (method_exists($model, 'getPk')) {
return (string) $model->getPk();
}
if (method_exists($model, 'getKeyName')) {
return (string) $model->getKeyName();
}
return 'id';
}
public static function findByPkAndDept(object $model, $primaryKeyValue, int $deptId)
{
$pk = self::resolvePk($model);
$query = $model->where($pk, $primaryKeyValue);
self::applyDeptIdWhere($query, $deptId);
return $query->find();
}
}

View File

@@ -6,13 +6,15 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\ante_config;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\ante_config\DiceAnteConfig;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\basic\DiceBaseLogic;
/**
* 底注配置逻辑层
*/
class DiceAnteConfigLogic extends BaseLogic
class DiceAnteConfigLogic extends DiceBaseLogic
{
public function __construct()
{
@@ -22,22 +24,35 @@ class DiceAnteConfigLogic extends BaseLogic
public function add(array $data): mixed
{
return $this->transaction(function () use ($data) {
$this->applyNameTitleFromMult($data);
$this->normalizeDefaultField($data);
$deptId = AdminScopeHelper::resolveConfigDeptId(null, $data['dept_id'] ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
if ((int) ($data['is_default'] ?? 0) === 1) {
$this->clearOtherDefaults();
$this->clearOtherDefaults(null, $deptId);
}
return parent::add($data);
});
}
public function edit($id, array $data): mixed
public function edit($id, array $data, ?array $adminInfo = null, $requestDeptId = null): mixed
{
return $this->transaction(function () use ($id, $data) {
$pickedDeptId = AdminScopeHelper::pickRequestDeptId($requestDeptId, $data);
$deptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $pickedDeptId);
return $this->transaction(function () use ($id, $data, $deptId, $adminInfo, $pickedDeptId) {
$this->applyNameTitleFromMult($data);
$this->normalizeDefaultField($data);
if ((int) ($data['is_default'] ?? 0) === 1) {
$this->clearOtherDefaults((int) $id);
$this->clearOtherDefaults((int) $id, $deptId);
}
return parent::edit($id, $data);
return ConfigScopeEditHelper::updateByPkAndDept(
$this->model,
$id,
$deptId,
$data,
['id', 'dept_id', 'create_time', 'update_time', 'delete_time', 'row_id'],
$adminInfo,
$pickedDeptId
);
});
}
@@ -79,9 +94,25 @@ class DiceAnteConfigLogic extends BaseLogic
$data['is_default'] = ((int) $data['is_default']) === 1 ? 1 : 0;
}
private function clearOtherDefaults(?int $excludeId = null): void
/** 名称、标题随底注倍率自动设为 xN */
private function applyNameTitleFromMult(array &$data): void
{
if (!array_key_exists('mult', $data)) {
return;
}
$mult = (int) $data['mult'];
if ($mult <= 0) {
return;
}
$label = 'x' . $mult;
$data['name'] = $label;
$data['title'] = $label;
}
private function clearOtherDefaults(?int $excludeId = null, int $deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT): void
{
$query = $this->model->where('is_default', 1);
ConfigScopeEditHelper::applyDeptIdWhere($query, $deptId);
if ($excludeId !== null && $excludeId > 0) {
$query->where('id', '<>', $excludeId);
}

View File

@@ -6,7 +6,9 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\config;
use plugin\saiadmin\basic\eloquent\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\config\DiceConfig;
@@ -14,7 +16,7 @@ use app\dice\model\config\DiceConfig;
/**
* 摇色子配置逻辑层
*/
class DiceConfigLogic extends BaseLogic
class DiceConfigLogic extends DiceBaseLogic
{
/**
* 构造函数
@@ -24,4 +26,13 @@ class DiceConfigLogic extends BaseLogic
$this->model = new DiceConfig();
}
public function edit($id, array $data, ?array $adminInfo = null, $requestDeptId = null): mixed
{
$deptId = AdminScopeHelper::resolveConfigDeptId(
$adminInfo,
AdminScopeHelper::pickRequestDeptId($requestDeptId, $data)
);
return ConfigScopeEditHelper::updateByBusinessIdAndDept($this->model, (int) $id, $deptId, $data);
}
}

View File

@@ -4,16 +4,33 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\game;
use plugin\saiadmin\basic\eloquent\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\game\DiceGame;
/**
* 游戏管理逻辑层
*/
class DiceGameLogic extends BaseLogic
class DiceGameLogic extends DiceBaseLogic
{
public function __construct()
{
$this->model = new DiceGame();
}
public function edit($id, array $data, ?array $adminInfo = null, $requestDeptId = null): mixed
{
$pickedDeptId = AdminScopeHelper::pickRequestDeptId($requestDeptId, $data);
$deptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $pickedDeptId);
return ConfigScopeEditHelper::updateByPkAndDept(
$this->model,
$id,
$deptId,
$data,
['id', 'dept_id', 'create_time', 'update_time', 'delete_time', 'row_id'],
$adminInfo,
$pickedDeptId
);
}
}

View File

@@ -6,16 +6,22 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\lottery_pool_config;
use app\api\cache\UserCache;
use app\api\service\LotteryService;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\model\player\DicePlayer;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use support\think\Cache;
use support\think\Db;
/**
* 色子奖池配置逻辑层
*/
class DiceLotteryPoolConfigLogic extends BaseLogic
class DiceLotteryPoolConfigLogic extends DiceBaseLogic
{
/** Redis 当前彩金池type=0 实例key无则按 type=0 创建 */
private const REDIS_KEY_CURRENT_POOL = 'api:game:lottery_pool:default';
@@ -30,19 +36,155 @@ class DiceLotteryPoolConfigLogic extends BaseLogic
$this->model = new DiceLotteryPoolConfig();
}
/**
* 按渠道隔离更新(主键 id 全局唯一,仍校验 dept_id 防止越权)
*/
public function edit($id, array $data, ?array $adminInfo = null, $requestDeptId = null): mixed
{
$pickedDeptId = AdminScopeHelper::pickRequestDeptId($requestDeptId, $data);
$deptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $pickedDeptId);
$result = ConfigScopeEditHelper::updateByPkAndDept(
$this->model,
$id,
$deptId,
$data,
['id', 'dept_id', 'create_time', 'update_time', 'delete_time', 'row_id'],
$adminInfo,
$pickedDeptId
);
if ($result) {
$pool = DiceLotteryPoolConfig::where('id', $id)->find();
if ($pool && $pool->isPlayerDefaultTemplate()) {
$this->syncPlayersBoundToPlayerDefaultPool($pool);
}
}
return $result;
}
/**
* 修改 playerDefault 后:同步同渠道所有绑定该池(及应跟随 playerDefault 的玩家T1T5 权重,并刷新 Redis
*
* @return int 已同步玩家数量
*/
public function syncPlayersBoundToPlayerDefaultPool(DiceLotteryPoolConfig $pool): int
{
$poolId = (int) ($pool->id ?? 0);
if ($poolId <= 0) {
return 0;
}
$fresh = DiceLotteryPoolConfig::where('id', $poolId)->find();
if (!$fresh || !$fresh->isPlayerDefaultTemplate()) {
return 0;
}
$pool = $fresh;
$weights = $this->extractPoolTierWeights($pool);
$poolDeptId = AdminScopeHelper::normalizeRecordDeptId($pool->dept_id ?? null);
$legacyDefaultPoolId = $this->findDefaultPoolIdForDept($poolDeptId);
$idsMap = [];
$boundPlayers = DicePlayer::where('lottery_config_id', $poolId)->select();
foreach ($boundPlayers as $player) {
$idsMap[(int) $player->id] = true;
}
if ($legacyDefaultPoolId > 0 && $legacyDefaultPoolId !== $poolId) {
$legacyPlayers = DicePlayer::where('lottery_config_id', $legacyDefaultPoolId)->select();
foreach ($legacyPlayers as $player) {
if (AdminScopeHelper::resolvePlayerConfigDeptId($player) === $poolDeptId) {
$idsMap[(int) $player->id] = true;
}
}
}
$unboundPlayers = DicePlayer::where(function ($q) {
$q->where('lottery_config_id', 0)->whereOr('lottery_config_id', null);
})->select();
foreach ($unboundPlayers as $player) {
if (AdminScopeHelper::resolvePlayerConfigDeptId($player) === $poolDeptId) {
$idsMap[(int) $player->id] = true;
}
}
if ($idsMap === []) {
return 0;
}
$ids = array_keys($idsMap);
$update = array_merge($weights, ['lottery_config_id' => $poolId]);
Db::table('dice_player')->whereIn('id', $ids)->update($update);
foreach ($ids as $playerId) {
LotteryService::patchPlayerWeightsCache($playerId, $weights);
LotteryService::invalidatePlayerLotteryCache($playerId);
UserCache::deleteUser($playerId);
}
return count($ids);
}
/**
* @return array{t1_weight:float,t2_weight:float,t3_weight:float,t4_weight:float,t5_weight:float}
*/
private function extractPoolTierWeights(DiceLotteryPoolConfig $pool): array
{
$data = $pool->getData();
return [
't1_weight' => (float) ($data['t1_weight'] ?? 0),
't2_weight' => (float) ($data['t2_weight'] ?? 0),
't3_weight' => (float) ($data['t3_weight'] ?? 0),
't4_weight' => (float) ($data['t4_weight'] ?? 0),
't5_weight' => (float) ($data['t5_weight'] ?? 0),
];
}
private function findDefaultPoolIdForDept(int $deptId): int
{
$query = DiceLotteryPoolConfig::where('name', 'default');
if (AdminScopeHelper::isTemplateDeptId($deptId)) {
$query->where(function ($q) {
$q->where('dept_id', AdminScopeHelper::DEFAULT_TEMPLATE_DEPT)
->whereOr('dept_id', null);
});
} else {
$query->where('dept_id', $deptId);
}
$row = $query->find();
return $row ? (int) $row->id : 0;
}
/**
* 获取当前彩金池type=0+ 杀分权重为 type=1 的只读展示
* profit_amount 每次从 DB 实时读取t1_weightt5_weight 来自 type=1杀分权重不可在弹窗内修改
*
* @return array{id:int,name:string,safety_line:int,kill_enabled:int,t1_weight:int,...,t5_weight:int,profit_amount:float}
*/
public function getCurrentPool(): array
public function getCurrentPool(int $deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT): array
{
$configType0 = DiceLotteryPoolConfig::where('name', 'default')->find();
$query0 = DiceLotteryPoolConfig::where('name', 'default');
if (AdminScopeHelper::isTemplateDeptId($deptId)) {
$query0->where(function ($q) {
$q->where('dept_id', AdminScopeHelper::DEFAULT_TEMPLATE_DEPT)
->whereOr('dept_id', null);
});
} else {
$query0->where('dept_id', $deptId);
}
$configType0 = $query0->find();
if (!$configType0) {
throw new ApiException('No name=default pool config found, please create one first');
}
$configType1 = DiceLotteryPoolConfig::where('name', 'killScore')->find();
$query1 = DiceLotteryPoolConfig::where('name', 'killScore');
if (AdminScopeHelper::isTemplateDeptId($deptId)) {
$query1->where(function ($q) {
$q->where('dept_id', AdminScopeHelper::DEFAULT_TEMPLATE_DEPT)
->whereOr('dept_id', null);
});
} else {
$query1->where('dept_id', $deptId);
}
$configType1 = $query1->find();
$row0 = $configType0->toArray();
$profitAmount = isset($row0['profit_amount']) ? (float) $row0['profit_amount'] : (isset($row0['ev']) ? (float) $row0['ev'] : 0.0);
$pool = [
@@ -66,9 +208,9 @@ class DiceLotteryPoolConfigLogic extends BaseLogic
*
* @param array{safety_line?:int,kill_enabled?:int} $data
*/
public function updateCurrentPool(array $data): void
public function updateCurrentPool(array $data, int $deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT): void
{
$pool = $this->getCurrentPool();
$pool = $this->getCurrentPool($deptId);
$id = (int) $pool['id'];
if (!array_key_exists('safety_line', $data) && !array_key_exists('kill_enabled', $data)) {
return;
@@ -83,17 +225,21 @@ class DiceLotteryPoolConfigLogic extends BaseLogic
if ($update === []) {
return;
}
DiceLotteryPoolConfig::where('id', $id)->update($update);
$query = DiceLotteryPoolConfig::where('id', $id);
ConfigScopeEditHelper::applyDeptIdWhere($query, $deptId);
$query->update($update);
}
/**
* 重置当前彩金池的玩家累计盈利:将 profit_amount 置为 0并刷新 Redis 缓存
*/
public function resetProfitAmount(): void
public function resetProfitAmount(int $deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT): void
{
$pool = $this->getCurrentPool();
$pool = $this->getCurrentPool($deptId);
$id = (int) $pool['id'];
DiceLotteryPoolConfig::where('id', $id)->update(['profit_amount' => 0]);
$query = DiceLotteryPoolConfig::where('id', $id);
ConfigScopeEditHelper::applyDeptIdWhere($query, $deptId);
$query->update(['profit_amount' => 0]);
$pool['profit_amount'] = 0.0;
Cache::set(self::REDIS_KEY_CURRENT_POOL, json_encode($pool), self::EXPIRE);
}

View File

@@ -6,7 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\play_record;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\play_record\DicePlayRecord;
@@ -14,7 +14,7 @@ use app\dice\model\play_record\DicePlayRecord;
/**
* 玩家抽奖记录逻辑层
*/
class DicePlayRecordLogic extends BaseLogic
class DicePlayRecordLogic extends DiceBaseLogic
{
/**
* 构造函数

View File

@@ -6,7 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\play_record_test;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\play_record_test\DicePlayRecordTest;
@@ -14,7 +14,7 @@ use app\dice\model\play_record_test\DicePlayRecordTest;
/**
* 玩家抽奖记录(测试数据)逻辑层
*/
class DicePlayRecordTestLogic extends BaseLogic
class DicePlayRecordTestLogic extends DiceBaseLogic
{
/**
* 构造函数

View File

@@ -6,7 +6,8 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\player;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\player\DicePlayer;
@@ -14,7 +15,7 @@ use app\dice\model\player\DicePlayer;
/**
* 大富翁-玩家逻辑层
*/
class DicePlayerLogic extends BaseLogic
class DicePlayerLogic extends DiceBaseLogic
{
/** 密码加密盐(可与 config 统一) */
private const PASSWORD_SALT = 'dice_player_salt_2024';
@@ -48,9 +49,30 @@ class DicePlayerLogic extends BaseLogic
} else {
unset($data['password']);
}
$data = $this->applyLotteryPoolWeightsToPlayerData($data);
return parent::edit($id, $data);
}
/**
* 已绑定彩金池时:玩家 T1T5 以池配置为准,避免前端提交陈旧权重覆盖同步结果
*/
private function applyLotteryPoolWeightsToPlayerData(array $data): array
{
$configId = isset($data['lottery_config_id']) ? (int) $data['lottery_config_id'] : 0;
if ($configId <= 0) {
return $data;
}
$config = DiceLotteryPoolConfig::find($configId);
if (!$config) {
return $data;
}
$row = $config->getData();
foreach (['t1_weight', 't2_weight', 't3_weight', 't4_weight', 't5_weight'] as $field) {
$data[$field] = $row[$field] ?? 0;
}
return $data;
}
/**
* 密码加密md5(salt . password)
*/

View File

@@ -6,7 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\player_ticket_record;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\player_ticket_record\DicePlayerTicketRecord;
@@ -14,7 +14,7 @@ use app\dice\model\player_ticket_record\DicePlayerTicketRecord;
/**
* 抽奖券获取记录逻辑层
*/
class DicePlayerTicketRecordLogic extends BaseLogic
class DicePlayerTicketRecordLogic extends DiceBaseLogic
{
/**
* 构造函数

View File

@@ -6,7 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\player_wallet_record;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
use app\dice\model\player_wallet_record\DicePlayerWalletRecord;
use app\dice\model\player\DicePlayer;
@@ -15,7 +15,7 @@ use app\api\cache\UserCache;
/**
* 玩家钱包流水逻辑层
*/
class DicePlayerWalletRecordLogic extends BaseLogic
class DicePlayerWalletRecordLogic extends DiceBaseLogic
{
/**
* 构造函数
@@ -83,9 +83,11 @@ class DicePlayerWalletRecordLogic extends BaseLogic
}
$playerAdminId = ($player->admin_id ?? null) ? (int) $player->admin_id : null;
$playerDeptId = ($player->dept_id ?? null) ? (int) $player->dept_id : null;
$record = [
'player_id' => $playerId,
'admin_id' => $playerAdminId,
'dept_id' => $playerDeptId,
'coin' => $type === 3 ? $coin : -$coin,
'type' => $type,
'wallet_before' => $walletBefore,

View File

@@ -4,6 +4,8 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\reward;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\reward\DiceReward;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\exception\ApiException;
@@ -18,6 +20,16 @@ class DiceRewardLogic
private const WEIGHT_MIN = 1;
private const WEIGHT_MAX = 10000;
/** 豹子边点 5/30对照表默认最低权重 */
private const CORNER_BIGWIN_GRIDS = [5, 30];
/** 中间大奖候选点 10/15/20/25对照表默认权重 */
private const MID_BIGWIN_GRIDS = [10, 15, 20, 25];
private const REFERENCE_MID_WEIGHT = 10;
private const REFERENCE_NORMAL_WEIGHT = 100;
/** 档位键 */
private const TIER_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5', 'BIGWIN'];
@@ -29,21 +41,33 @@ class DiceRewardLogic
* @param int $limit
* @return array{total: int, per_page: int, current_page: int, data: array}
*/
public function getListWithConfig(int $direction, array $where, int $page = 1, int $limit = 10): array
{
public function getListWithConfig(
int $direction,
array $where,
int $page = 1,
int $limit = 10,
?array $adminInfo = null,
$requestDeptId = null
): array {
$tier = isset($where['tier']) ? trim((string) $where['tier']) : '';
$orderField = isset($where['orderField']) && $where['orderField'] !== '' ? (string) $where['orderField'] : 'r.tier';
$orderType = isset($where['orderType']) && strtoupper((string) $where['orderType']) === 'DESC' ? 'desc' : 'asc';
$keepIds = $this->resolveDedupedRewardIdsByGrid($direction, $tier, $adminInfo, $requestDeptId);
if ($keepIds === []) {
return [
'total' => 0,
'per_page' => $limit,
'current_page' => $page,
'data' => [],
];
}
$query = DiceReward::alias('r')
->where('r.direction', $direction)
->whereIn('r.id', $keepIds)
->field('r.id,r.tier,r.direction,r.end_index,r.weight,r.grid_number,r.start_index,r.ui_text,r.real_ev,r.remark,r.type,r.create_time,r.update_time')
->order($orderField, $orderType)
->order('r.end_index', 'asc');
if ($tier !== '') {
$query->where('r.tier', $tier);
}
->order('r.grid_number', 'asc');
$paginator = $query->paginate($limit, false, ['page' => $page]);
$arr = $paginator->toArray();
@@ -69,12 +93,47 @@ class DiceRewardLogic
];
}
/**
* 列表去重:每个方向、每个色子点数(5-30)仅保留一条(取 id 最大),避免历史重复数据导致 104 条
* @return int[]
*/
private function resolveDedupedRewardIdsByGrid(
int $direction,
string $tier,
?array $adminInfo,
$requestDeptId
): array {
$dedupeQuery = DiceReward::alias('rd')
->field('MAX(rd.id) AS keep_id')
->where('rd.direction', $direction)
->whereBetween('rd.grid_number', [5, 30]);
if ($adminInfo !== null) {
AdminScopeHelper::applyConfigScope($dedupeQuery, $adminInfo, $requestDeptId, 'rd.dept_id');
}
if ($tier !== '') {
$dedupeQuery->where('rd.tier', $tier);
}
$rows = $dedupeQuery->group('rd.grid_number')->select()->toArray();
$ids = [];
foreach ($rows as $row) {
$id = isset($row['keep_id']) ? (int) $row['keep_id'] : 0;
if ($id > 0) {
$ids[] = $id;
}
}
return $ids;
}
/**
* 按单方向批量更新权重(仅更新当前方向的 weight并刷新缓存
* @param int $direction 0=顺时针 1=逆时针
* @param array<int, array{id: int, weight: int}> $items id 为 end_indexDiceRewardConfig.id
*/
public function batchUpdateWeightsByDirection(int $direction, array $items): void
public function batchUpdateWeightsByDirection(int $direction, array $items, ?int $deptId = null): void
{
if (empty($items)) {
return;
@@ -90,23 +149,36 @@ class DiceRewardLogic
}
$weight = max(self::WEIGHT_MIN, min(self::WEIGHT_MAX, $weight));
$tier = DiceRewardConfig::where('id', $id)->value('tier');
$configQuery = DiceRewardConfig::where('id', $id);
if ($deptId !== null) {
ConfigScopeEditHelper::applyDeptIdWhere($configQuery, $deptId);
}
$tier = $configQuery->value('tier');
if ($tier === null || $tier === '') {
throw new ApiException(\app\api\util\ApiLang::translateParams('配置ID %s 不存在或档位为空', [$id]));
}
$tier = (string) $tier;
$affected = DiceReward::where('tier', $tier)->where('direction', $direction)->where('end_index', $id)->update(['weight' => $weight]);
$rewardQuery = DiceReward::where('tier', $tier)
->where('direction', $direction)
->where('end_index', $id);
if ($deptId !== null) {
ConfigScopeEditHelper::applyDeptIdWhere($rewardQuery, $deptId);
}
$affected = $rewardQuery->update(['weight' => $weight]);
if ($affected === 0) {
$m = new DiceReward();
$m->tier = $tier;
$m->direction = $direction;
$m->end_index = $id;
$m->weight = $weight;
if ($deptId !== null && $deptId > 0) {
$m->dept_id = $deptId;
}
$m->save();
}
}
DiceReward::refreshCache();
DiceReward::refreshCache($deptId);
}
/**
@@ -114,11 +186,11 @@ class DiceRewardLogic
* @param int $direction 0=顺时针 1=逆时针
* @return array<string, array> 键 T1|T2|...|BIGWIN值为该档位下带 weight 的行数组
*/
public function getListGroupedByTierForDirection(int $direction): array
public function getListGroupedByTierForDirection(int $direction, ?int $deptId = null): array
{
$configInstance = DiceRewardConfig::getCachedInstance();
$configInstance = DiceRewardConfig::getCachedInstance($deptId);
$byTier = $configInstance['by_tier'] ?? [];
$rewardInstance = DiceReward::getCachedInstance();
$rewardInstance = DiceReward::getCachedInstance($deptId);
$byTierDirection = $rewardInstance['by_tier_direction'] ?? [];
$result = [];
@@ -153,9 +225,9 @@ class DiceRewardLogic
*
* @return array<string, array{0: array, 1: array}>
*/
public function getListGroupedByTierWithDirection(): array
public function getListGroupedByTierWithDirection(?int $deptId = null): array
{
$rewardInstance = DiceReward::getCachedInstance();
$rewardInstance = DiceReward::getCachedInstance($deptId);
$byTierDirection = $rewardInstance['by_tier_direction'] ?? [];
$result = [];
@@ -185,7 +257,7 @@ class DiceRewardLogic
* @param array<int, array{id: int, weight: int}> $items 每项 id 为 dice_reward 表主键weight 为 1-10000
* @throws ApiException
*/
public function batchUpdateWeights(array $items): void
public function batchUpdateWeights(array $items, ?int $deptId = null): void
{
if (empty($items)) {
return;
@@ -203,13 +275,24 @@ class DiceRewardLogic
}
$weight = isset($item['weight']) ? (int) $item['weight'] : self::WEIGHT_MIN;
$weight = max(self::WEIGHT_MIN, min(self::WEIGHT_MAX, $weight));
$model = DiceReward::find($id);
$query = DiceReward::where('id', $id);
if ($deptId !== null) {
if (AdminScopeHelper::isTemplateDeptId($deptId)) {
$query->where(function ($q) {
$q->where('dept_id', AdminScopeHelper::DEFAULT_TEMPLATE_DEPT)
->whereOr('dept_id', null);
});
} else {
$query->where('dept_id', $deptId);
}
}
$model = $query->find();
if ($model !== null) {
$model->weight = $weight;
$model->save();
}
}
DiceReward::refreshCache();
DiceReward::refreshCache($deptId);
}
/** BIGWIN 权重范围0=0% 中奖10000=100% 中奖grid_number=5/30 固定 100% 不可改 */
@@ -219,9 +302,9 @@ class DiceRewardLogic
* 按 grid_number 获取 BIGWIN 档位权重(取顺时针方向,用于编辑展示)
* 若 DiceReward 无该点数则 5/30 返回 10000其余返回 0
*/
public function getBigwinWeightByGridNumber(int $gridNumber): int
public function getBigwinWeightByGridNumber(int $gridNumber, ?int $deptId = null): int
{
$inst = DiceReward::getCachedInstance();
$inst = DiceReward::getCachedInstance($deptId);
$rows = $inst['by_tier_direction']['BIGWIN'][DiceReward::DIRECTION_CLOCKWISE] ?? [];
foreach ($rows as $row) {
if ((int) ($row['grid_number'] ?? 0) === $gridNumber) {
@@ -235,21 +318,24 @@ class DiceRewardLogic
* 更新 BIGWIN 档位某点数的权重(顺/逆时针同时更新0=0% 中奖10000=100% 中奖
* 表 dice_reward 唯一键为 (direction, grid_number),同一点数同一方向仅一条记录,故先按该键查找再更新,避免重复插入
*/
public function updateBigwinWeight(int $gridNumber, int $weight): void
public function updateBigwinWeight(int $gridNumber, int $weight, ?int $deptId = null): void
{
$weight = min(self::BIGWIN_WEIGHT_MAX, max(0, $weight));
$config = DiceRewardConfig::where('tier', 'BIGWIN')
->where('grid_number', $gridNumber)
->find();
if ($deptId === null) {
$deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
}
$configQuery = DiceRewardConfig::where('tier', 'BIGWIN')->where('grid_number', $gridNumber);
ConfigScopeEditHelper::applyDeptIdWhere($configQuery, $deptId);
$config = $configQuery->find();
if (! $config) {
return;
}
$configArr = $config->toArray();
foreach ([DiceReward::DIRECTION_CLOCKWISE, DiceReward::DIRECTION_COUNTERCLOCKWISE] as $direction) {
// 按唯一键 (direction, grid_number) 查找,存在则更新,不存在则插入
$row = DiceReward::where('direction', $direction)
->where('grid_number', $gridNumber)
->find();
$rowQuery = DiceReward::where('direction', $direction)->where('grid_number', $gridNumber);
ConfigScopeEditHelper::applyDeptIdWhere($rowQuery, $deptId);
$row = $rowQuery->find();
if ($row) {
$row->tier = 'BIGWIN';
$row->weight = $weight > 0 ? $weight : self::WEIGHT_MIN;
@@ -272,10 +358,13 @@ class DiceRewardLogic
$m->remark = (string) ($configArr['remark'] ?? '');
$m->type = $configArr['type'] ?? null;
$m->weight = $weight > 0 ? $weight : self::WEIGHT_MIN;
if (!AdminScopeHelper::isTemplateDeptId($deptId)) {
$m->dept_id = $deptId;
}
$m->save();
}
}
DiceReward::refreshCache();
DiceReward::refreshCache($deptId);
}
/** 盘面格数(用于顺时针/逆时针计算 end_index */
@@ -285,6 +374,94 @@ class DiceRewardLogic
private const GRID_NUMBER_MIN = 5;
private const GRID_NUMBER_MAX = 30;
/**
* 预览:按当前 dice_reward_config 计算将要生成的 dice_reward不写库
* 若当前 dice_reward 与计算结果完全一致,则标记 unchanged=true并返回现有权重导入时将复用旧权重
*
* @return array{unchanged: bool, skipped: int, preview: array<string, array{0: array, 1: array}>}
* @throws ApiException
*/
public function createRewardReferencePreviewFromConfig(?int $deptId = null): array
{
$normalizedDeptId = $deptId;
$list = $this->loadConfigListForReference($normalizedDeptId);
$computed = $this->computeReferenceRowsFromConfigList($list, $normalizedDeptId);
$existing = $this->loadExistingRewardRowsForReference($normalizedDeptId);
$compare = $this->compareReferenceRows($computed['rows'], $existing);
$unchanged = $compare['unchanged'];
$previewRows = [];
foreach ($computed['rows'] as $row) {
$key = $row['direction'] . ':' . $row['grid_number'];
$gridNumber = isset($row['grid_number']) ? (int) $row['grid_number'] : 0;
$weight = $unchanged
? self::WEIGHT_MIN
: $this->defaultReferenceWeightForGrid($gridNumber);
$oldStart = null;
$oldEnd = null;
$oldTier = null;
$oldRemark = null;
$oldWeight = null;
if (isset($existing[$key])) {
$oldStart = isset($existing[$key]['start_index']) ? (int) $existing[$key]['start_index'] : null;
$oldEnd = isset($existing[$key]['end_index']) ? (int) $existing[$key]['end_index'] : null;
$oldTier = isset($existing[$key]['tier']) ? (string) $existing[$key]['tier'] : null;
$oldRemark = isset($existing[$key]['remark']) ? (string) $existing[$key]['remark'] : null;
$oldWeight = isset($existing[$key]['weight']) ? (int) $existing[$key]['weight'] : null;
}
// 映射未变化时:通常复用旧权重;旧权重为 1 时按点数类型补全为新默认建议值
if ($unchanged && $oldWeight !== null) {
$oldWeight = (int) $oldWeight;
$weight = max(self::WEIGHT_MIN, min(self::WEIGHT_MAX, $oldWeight));
if ($weight === self::WEIGHT_MIN) {
$weight = $this->defaultReferenceWeightForGrid($gridNumber);
}
}
$diffChanged = false;
$diffFields = [];
if ($oldStart === null || $oldEnd === null || $oldTier === null) {
$diffChanged = true;
$diffFields[] = 'new';
} else {
if ((int) $oldStart !== (int) ($row['start_index'] ?? 0)) {
$diffChanged = true;
$diffFields[] = 'start_index';
}
if ((int) $oldEnd !== (int) ($row['end_index'] ?? 0)) {
$diffChanged = true;
$diffFields[] = 'end_index';
}
if (trim((string) $oldTier) !== trim((string) ($row['tier'] ?? ''))) {
$diffChanged = true;
$diffFields[] = 'tier';
}
if (trim((string) $oldRemark) !== trim((string) ($row['remark'] ?? ''))) {
$diffChanged = true;
$diffFields[] = 'remark';
}
}
$previewRows[] = array_merge($row, [
'weight' => $weight,
'old_start_index' => $oldStart,
'old_end_index' => $oldEnd,
'old_tier' => $oldTier,
'old_remark' => $oldRemark,
'old_weight' => $oldWeight,
'diff_changed' => $diffChanged,
'diff_fields' => $diffFields,
]);
}
return [
'unchanged' => $unchanged,
'skipped' => $computed['skipped'],
'preview' => $this->groupReferenceRowsByTierWithDirection($previewRows),
];
}
/**
* 创建奖励对照:先清空 dice_reward 表,再按两种方向为点数 5-30 生成记录。
*
@@ -297,9 +474,9 @@ class DiceRewardLogic
* - 奖励档位tier = DiceRewardConfig::where('id', $end_index)->first()->tier
* - 显示uiui_text = DiceRewardConfig::where('id', $end_index)->first()->ui_text
* - 实际中奖real_ev = DiceRewardConfig::where('id', $end_index)->first()->real_ev
* - 备注remark = DiceRewardConfig::where('id', $end_index)->first()->remark
* - 备注remark = 按本条对照档位(推断后 T1-T5的默认备注T1 大奖、T2 小赚…),非落点格盘面 remark 字段
* - 类型type = DiceRewardConfig::where('id', $end_index)->first()->type-2=唯一惩罚,-1=抽水,0=回本,1=再来一次,2=小赚,3=大奖格)
* - weight 默认 1后续在权重编辑弹窗设置
* - weight 默认10/15/20/25 为 105/30 为 1其余为 100
*
* 例如顺时针摇取点数为 5 时start_index = 配置中 grid_number=5 对应格位的 id
* 结束位置 = (起始位置 + grid_number) % 26再取该位置的 config 的 id 作为 end_index。
@@ -309,27 +486,107 @@ class DiceRewardLogic
* @return array{created_clockwise: int, created_counterclockwise: int, updated_clockwise: int, updated_counterclockwise: int, skipped: int}
* @throws ApiException
*/
public function createRewardReferenceFromConfig(): array
public function createRewardReferenceFromConfig(?int $deptId = null): array
{
$list = DiceRewardConfig::order('id', 'asc')->select()->toArray();
if (empty($list)) {
throw new ApiException('Reward config is empty, please maintain dice_reward_config first');
}
$configCount = count($list);
if ($configCount < self::BOARD_SIZE) {
throw new ApiException(
\app\api\util\ApiLang::translateParams(
'奖励配置需覆盖 26 个格位id 0-25 或 1-26当前仅 %s 条,无法完整生成 5-30 共26个点数、顺时针与逆时针的奖励对照',
[$configCount]
)
);
$normalizedDeptId = $deptId;
$list = $this->loadConfigListForReference($normalizedDeptId);
$computed = $this->computeReferenceRowsFromConfigList($list, $normalizedDeptId);
$existing = $this->loadExistingRewardRowsForReference($normalizedDeptId);
$compare = $this->compareReferenceRows($computed['rows'], $existing);
if ($compare['unchanged']) {
return [
'created_clockwise' => 0,
'created_counterclockwise' => 0,
'updated_clockwise' => 0,
'updated_counterclockwise' => 0,
'skipped' => $computed['skipped'],
'unchanged' => true,
];
}
$table = (new DiceReward())->getTable();
Db::execute('DELETE FROM `' . $table . '`');
DiceReward::refreshCache();
if ($normalizedDeptId === null) {
Db::table($table)->whereNull('dept_id')->delete();
} else {
Db::table($table)->where('dept_id', $normalizedDeptId)->delete();
}
// 按 id 排序后,盘面位置 0..25 对应 $list[$pos],避免 config.id 非 0-25/1-26 时取模结果找不到
$createdCw = 0;
$createdCcw = 0;
foreach ($computed['rows'] as $row) {
$m = new DiceReward();
$m->tier = $row['tier'];
$m->direction = (int) $row['direction'];
$m->end_index = (int) $row['end_index'];
$m->weight = $this->defaultReferenceWeightForGrid((int) $row['grid_number']);
$m->grid_number = (int) $row['grid_number'];
$m->start_index = (int) $row['start_index'];
$m->ui_text = (string) ($row['ui_text'] ?? '');
$m->real_ev = $row['real_ev'] ?? null;
$m->remark = (string) ($row['remark'] ?? '');
$m->type = isset($row['type']) ? (int) $row['type'] : 0;
if ($normalizedDeptId !== null) {
$m->dept_id = $normalizedDeptId;
}
$m->save();
if ((int) $row['direction'] === DiceReward::DIRECTION_CLOCKWISE) {
$createdCw++;
} else {
$createdCcw++;
}
}
DiceReward::refreshCache($normalizedDeptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
return [
'created_clockwise' => $createdCw,
'created_counterclockwise' => $createdCcw,
'updated_clockwise' => 0,
'updated_counterclockwise' => 0,
'skipped' => $computed['skipped'],
'unchanged' => false,
];
}
/**
* 读取奖励配置(按 id asc并把模板 dept 转为 null
* @return array<int, array<string, mixed>>
*/
private function loadConfigListForReference(?int &$deptId): array
{
$configQuery = DiceRewardConfig::order('id', 'asc');
if ($deptId === null || $deptId === AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
$templateId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
$configQuery->where(function ($q) use ($templateId) {
$q->where('dept_id', $templateId)->whereOr('dept_id', 'null');
});
$deptId = null;
} else {
$configQuery->where('dept_id', $deptId);
}
$list = $configQuery->select()->toArray();
if (empty($list)) {
throw new ApiException('Reward config is empty, please maintain dice_reward_config first');
}
if (count($list) < self::BOARD_SIZE) {
throw new ApiException(
\app\api\util\ApiLang::translateParams(
'奖励配置需覆盖 26 个格位id 0-25 或 1-26当前仅 %s 条,无法完整生成 5-30 共26个点数、顺时针与逆时针的奖励对照',
[count($list)]
)
);
}
return $list;
}
/**
* 计算 5-30 两个方向的对照行(不含权重)
* @param array<int, array<string, mixed>> $list
* @return array{rows: array<int, array<string, mixed>>, skipped: int}
*/
private function computeReferenceRowsFromConfigList(array $list, ?int $deptId): array
{
// 按 id 排序后,盘面位置 0..25 对应 $list[$pos]
$gridToPosition = [];
foreach ($list as $pos => $row) {
$gn = isset($row['grid_number']) ? (int) $row['grid_number'] : 0;
@@ -338,12 +595,8 @@ class DiceRewardLogic
}
}
$createdCw = 0;
$createdCcw = 0;
$updatedCw = 0;
$updatedCcw = 0;
$rows = [];
$skipped = 0;
for ($gridNumber = self::GRID_NUMBER_MIN; $gridNumber <= self::GRID_NUMBER_MAX; $gridNumber++) {
if (!isset($gridToPosition[$gridNumber])) {
$skipped++;
@@ -358,97 +611,206 @@ class DiceRewardLogic
$configCw = $list[$endPosCw] ?? null;
$configCcw = $list[$endPosCcw] ?? null;
$endIdCw = $configCw !== null && isset($configCw['id']) ? (int) $configCw['id'] : 0;
$endIdCcw = $configCcw !== null && isset($configCcw['id']) ? (int) $configCcw['id'] : 0;
if ($configCw !== null) {
$tier = isset($configCw['tier']) ? trim((string) $configCw['tier']) : '';
if ($tier !== '') {
// 使用对应奖励配置的 weight 作为格子权重(若未配置则退回最小权重)
$weightCw = isset($configCw['weight']) && $configCw['weight'] !== null
? $configCw['weight']
: self::WEIGHT_MIN;
$payloadCw = [
'tier' => $tier,
'weight' => $weightCw,
'grid_number' => $gridNumber,
'start_index' => $startId,
'end_index' => $endIdCw,
'ui_text' => $configCw['ui_text'] ?? '',
'real_ev' => $configCw['real_ev'] ?? null,
'remark' => $configCw['remark'] ?? '',
'type' => isset($configCw['type']) ? (int) $configCw['type'] : 0,
];
$existing = DiceReward::where('direction', DiceReward::DIRECTION_CLOCKWISE)->where('grid_number', $gridNumber)->find();
if ($existing) {
DiceReward::where('id', $existing->id)->update($payloadCw);
$updatedCw++;
} else {
$m = new DiceReward();
$m->tier = $tier;
$m->direction = DiceReward::DIRECTION_CLOCKWISE;
$m->end_index = $endIdCw;
$m->weight = $weightCw;
$m->grid_number = $gridNumber;
$m->start_index = $startId;
$m->ui_text = $configCw['ui_text'] ?? '';
$m->real_ev = $configCw['real_ev'] ?? null;
$m->remark = $configCw['remark'] ?? '';
$m->type = isset($configCw['type']) ? (int) $configCw['type'] : 0;
$m->save();
$createdCw++;
}
$rows[] = $this->buildReferenceRowFromLandingConfig(
$tier,
$configCw,
DiceReward::DIRECTION_CLOCKWISE,
$gridNumber,
$startId
);
}
}
if ($configCcw !== null) {
$tier = isset($configCcw['tier']) ? trim((string) $configCcw['tier']) : '';
if ($tier !== '') {
// 使用对应奖励配置的 weight 作为格子权重(若未配置则退回最小权重)
$weightCcw = isset($configCcw['weight']) && $configCcw['weight'] !== null
? $configCcw['weight']
: self::WEIGHT_MIN;
$payloadCcw = [
'tier' => $tier,
'weight' => $weightCcw,
'grid_number' => $gridNumber,
'start_index' => $startId,
'end_index' => $endIdCcw,
'ui_text' => $configCcw['ui_text'] ?? '',
'real_ev' => $configCcw['real_ev'] ?? null,
'remark' => $configCcw['remark'] ?? '',
'type' => isset($configCcw['type']) ? (int) $configCcw['type'] : 0,
];
$existing = DiceReward::where('direction', DiceReward::DIRECTION_COUNTERCLOCKWISE)->where('grid_number', $gridNumber)->find();
if ($existing) {
DiceReward::where('id', $existing->id)->update($payloadCcw);
$updatedCcw++;
} else {
$m = new DiceReward();
$m->tier = $tier;
$m->direction = DiceReward::DIRECTION_COUNTERCLOCKWISE;
$m->end_index = $endIdCcw;
$m->weight = $weightCcw;
$m->grid_number = $gridNumber;
$m->start_index = $startId;
$m->ui_text = $configCcw['ui_text'] ?? '';
$m->real_ev = $configCcw['real_ev'] ?? null;
$m->remark = $configCcw['remark'] ?? '';
$m->type = isset($configCcw['type']) ? (int) $configCcw['type'] : 0;
$m->save();
$createdCcw++;
}
$rows[] = $this->buildReferenceRowFromLandingConfig(
$tier,
$configCcw,
DiceReward::DIRECTION_COUNTERCLOCKWISE,
$gridNumber,
$startId
);
}
}
}
return ['rows' => $rows, 'skipped' => $skipped];
}
DiceReward::refreshCache();
/**
* 对照表落点行:档位按落点格 real_ev 推断;备注随**本条对照档位**T1 大奖 / T2 小赚 …),
* 与奖励配置页「按结算金额匹配档位备注」一致,不拷贝落点格盘面备注(盘面可保留「大奖格」等细分文案)。
*
* @param array<string, mixed> $landingConfig
* @return array<string, mixed>
*/
private function buildReferenceRowFromLandingConfig(
string $tier,
array $landingConfig,
int $direction,
int $gridNumber,
int $startId
): array {
$realEv = isset($landingConfig['real_ev']) ? (float) $landingConfig['real_ev'] : 0.0;
if ($tier !== 'BIGWIN') {
$inferred = $this->inferTierFromRealEv($realEv);
if ($inferred !== '') {
$tier = $inferred;
}
}
return [
'created_clockwise' => $createdCw,
'created_counterclockwise' => $createdCcw,
'updated_clockwise' => $updatedCw,
'updated_counterclockwise' => $updatedCcw,
'skipped' => $skipped,
'tier' => $tier,
'direction' => $direction,
'weight' => $this->defaultReferenceWeightForGrid($gridNumber),
'grid_number' => $gridNumber,
'start_index' => $startId,
'end_index' => isset($landingConfig['id']) ? (int) $landingConfig['id'] : 0,
'ui_text' => $landingConfig['ui_text'] ?? '',
'real_ev' => $landingConfig['real_ev'] ?? null,
'remark' => $this->defaultRemarkForTier($tier),
'type' => isset($landingConfig['type']) ? (int) $landingConfig['type'] : 0,
];
}
/**
* 按结算金额推断档位(与前端 generateIndexByRules 一致)
*/
private function inferTierFromRealEv(float $realEv): string
{
if ($realEv > 2) {
return 'T1';
}
if ($realEv > 1) {
return 'T2';
}
if ($realEv > 0) {
return 'T3';
}
if ($realEv < 0) {
return 'T4';
}
return 'T5';
}
/**
* 创建奖励对照表时按色子点数和的默认权重
*/
private function defaultReferenceWeightForGrid(int $gridNumber): int
{
if (in_array($gridNumber, self::MID_BIGWIN_GRIDS, true)) {
return self::REFERENCE_MID_WEIGHT;
}
if (in_array($gridNumber, self::CORNER_BIGWIN_GRIDS, true)) {
return self::WEIGHT_MIN;
}
return self::REFERENCE_NORMAL_WEIGHT;
}
/**
* 档位默认备注
*/
private function defaultRemarkForTier(string $tier): string
{
return match ($tier) {
'T1', 'BIGWIN' => '大奖',
'T2' => '小赚',
'T3' => '抽水',
'T4' => '惩罚',
'T5' => '再来一次',
default => '',
};
}
/**
* 读出当前 dice_reward用于对比/复用权重。key = "direction:grid_number"
* @return array<string, array<string, mixed>>
*/
private function loadExistingRewardRowsForReference(?int $deptId): array
{
$query = DiceReward::whereIn('grid_number', range(self::GRID_NUMBER_MIN, self::GRID_NUMBER_MAX))
->whereIn('direction', [DiceReward::DIRECTION_CLOCKWISE, DiceReward::DIRECTION_COUNTERCLOCKWISE]);
if ($deptId === null) {
$query->whereNull('dept_id');
} else {
$query->where('dept_id', $deptId);
}
$rows = $query->select()->toArray();
$map = [];
foreach ($rows as $r) {
$dir = isset($r['direction']) ? (int) $r['direction'] : 0;
$gn = isset($r['grid_number']) ? (int) $r['grid_number'] : 0;
$map[$dir . ':' . $gn] = $r;
}
return $map;
}
/**
* 对比 computed 与 existing 是否完全一致(忽略权重)
* @param array<int, array<string, mixed>> $computedRows
* @param array<string, array<string, mixed>> $existingMap
* @return array{unchanged: bool}
*/
private function compareReferenceRows(array $computedRows, array $existingMap): array
{
if (empty($computedRows)) {
return ['unchanged' => false];
}
foreach ($computedRows as $row) {
$key = $row['direction'] . ':' . $row['grid_number'];
if (!isset($existingMap[$key])) {
return ['unchanged' => false];
}
$ex = $existingMap[$key];
$same =
((int) ($ex['start_index'] ?? 0) === (int) ($row['start_index'] ?? 0)) &&
((int) ($ex['end_index'] ?? 0) === (int) ($row['end_index'] ?? 0)) &&
(trim((string) ($ex['tier'] ?? '')) === trim((string) ($row['tier'] ?? ''))) &&
(trim((string) ($ex['remark'] ?? '')) === trim((string) ($row['remark'] ?? '')));
if (!$same) {
return ['unchanged' => false];
}
}
return ['unchanged' => true];
}
/**
* 将行按 tier -> {0:[],1:[]} 组织,便于前端展示(与 weightRatioList 输出结构一致)
* @param array<int, array<string, mixed>> $rows
* @return array<string, array{0: array, 1: array}>
*/
private function groupReferenceRowsByTierWithDirection(array $rows): array
{
$result = [];
foreach (self::TIER_KEYS as $tier) {
$result[$tier] = [0 => [], 1 => []];
}
foreach ($rows as $r) {
$tier = isset($r['tier']) ? trim((string) $r['tier']) : '';
if ($tier === '' || !isset($result[$tier])) {
continue;
}
$dir = isset($r['direction']) ? (int) $r['direction'] : 0;
$dir = $dir === 1 ? 1 : 0;
$result[$tier][$dir][] = [
'reward_id' => 0,
'id' => isset($r['end_index']) ? (int) $r['end_index'] : 0,
'grid_number' => isset($r['grid_number']) ? (int) $r['grid_number'] : 0,
'ui_text' => (string) ($r['ui_text'] ?? ''),
'real_ev' => $r['real_ev'] ?? 0,
'remark' => (string) ($r['remark'] ?? ''),
'weight' => isset($r['weight']) ? max(self::WEIGHT_MIN, min(self::WEIGHT_MAX, (int) $r['weight'])) : self::WEIGHT_MIN,
'start_index' => isset($r['start_index']) ? (int) $r['start_index'] : 0,
'tier' => (string) ($r['tier'] ?? ''),
'old_start_index' => $r['old_start_index'] ?? null,
'old_end_index' => $r['old_end_index'] ?? null,
'old_tier' => $r['old_tier'] ?? null,
'old_weight' => $r['old_weight'] ?? null,
'diff_changed' => (bool) ($r['diff_changed'] ?? false),
'diff_fields' => $r['diff_fields'] ?? [],
];
}
return $result;
}
}

View File

@@ -6,11 +6,13 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\reward_config;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\logic\reward\DiceRewardLogic;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\reward\DiceRewardConfig;
use app\dice\model\reward_config_record\DiceRewardConfigRecord;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use support\Log;
@@ -19,7 +21,7 @@ use support\Log;
* 奖励配置逻辑层DiceRewardConfig
* weight 1-10000各档位权重和不限制
*/
class DiceRewardConfigLogic extends BaseLogic
class DiceRewardConfigLogic extends DiceBaseLogic
{
/** weight 取值范围 */
private const WEIGHT_MIN = 1;
@@ -36,18 +38,23 @@ class DiceRewardConfigLogic extends BaseLogic
public function add(array $data): mixed
{
$result = parent::add($data);
DiceRewardConfig::refreshCache();
$deptId = AdminScopeHelper::normalizeRecordDeptId($data['dept_id'] ?? null);
DiceRewardConfig::refreshCache($deptId);
return $result;
}
/**
* 修改:保存后刷新缓存BIGWIN 的 weight 直接写入 dice_reward_config 表,抽奖时从 Config 读取
* 修改:按业务 id + 渠道更新;保存后刷新该渠道缓存
*/
public function edit($id, array $data): mixed
public function edit($id, array $data, ?array $adminInfo = null, $requestDeptId = null): mixed
{
$result = parent::edit($id, $data);
DiceRewardConfig::refreshCache();
return $result;
$deptId = AdminScopeHelper::resolveConfigDeptId(
$adminInfo,
AdminScopeHelper::pickRequestDeptId($requestDeptId, $data)
);
ConfigScopeEditHelper::updateByBusinessIdAndDept($this->model, (int) $id, $deptId, $data);
DiceRewardConfig::refreshCache($deptId);
return true;
}
/**
@@ -152,8 +159,9 @@ class DiceRewardConfigLogic extends BaseLogic
/**
* 批量更新奖励索引配置grid_number、ui_text、real_ev、tier、remark不含 weightBIGWIN 权重单独接口)
* @param array $items 每项 [id, grid_number?, ui_text?, real_ev?, tier?, remark?]
* @param int $deptId 渠道 ID0=默认模板)
*/
public function batchUpdate(array $items): void
public function batchUpdate(array $items, int $deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT): void
{
foreach ($items as $row) {
if (! array_key_exists('id', $row) || $row['id'] === null || $row['id'] === '') {
@@ -167,10 +175,18 @@ class DiceRewardConfigLogic extends BaseLogic
}
}
if (! empty($data)) {
parent::edit($id, $data);
$this->updateByBusinessIdAndDept($id, $deptId, $data);
}
}
DiceRewardConfig::refreshCache();
DiceRewardConfig::refreshCache($deptId);
}
/**
* 按业务 id025与渠道更新单条配置
*/
private function updateByBusinessIdAndDept(int $businessId, int $deptId, array $data): void
{
ConfigScopeEditHelper::updateByBusinessIdAndDept($this->model, $businessId, $deptId, $data);
}
/**
@@ -201,8 +217,9 @@ class DiceRewardConfigLogic extends BaseLogic
/**
* 批量更新 BIGWIN 档位权重(仅写 dice_reward_config 表,不操作 dice_reward
* @param array $items 每项 [grid_number => 5-30, weight => 0-10000]
* @param int $deptId 渠道 ID0=默认模板)
*/
public function batchUpdateBigwinWeight(array $items): void
public function batchUpdateBigwinWeight(array $items, int $deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT): void
{
$weightMin = 0;
$weightMax = 10000;
@@ -213,21 +230,33 @@ class DiceRewardConfigLogic extends BaseLogic
continue;
}
$weight = max($weightMin, min($weightMax, $weight));
$this->model->where('tier', 'BIGWIN')
->where('grid_number', $gridNumber)
->update(['weight' => $weight]);
$query = $this->model->where('tier', 'BIGWIN')->where('grid_number', $gridNumber);
if (AdminScopeHelper::isTemplateDeptId($deptId)) {
$query->where(function ($q) {
$q->where('dept_id', AdminScopeHelper::DEFAULT_TEMPLATE_DEPT)
->whereOr('dept_id', null);
});
} else {
$query->where('dept_id', $deptId);
}
$exists = (clone $query)->find();
if ($exists === null) {
throw new ApiException('BIGWIN grid_number=' . $gridNumber . ' not found for current channel');
}
$query->update(['weight' => $weight]);
}
DiceRewardConfig::refreshCache();
DiceRewardConfig::refreshCache($deptId);
}
/**
* 删除后刷新缓存
*/
public function destroy($ids): bool
public function destroy($ids, ?array $adminInfo = null, $requestDeptId = null): bool
{
$deptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $requestDeptId);
$result = parent::destroy($ids);
if ($result) {
DiceRewardConfig::refreshCache();
DiceRewardConfig::refreshCache($deptId);
}
return $result;
}
@@ -403,6 +432,12 @@ class DiceRewardConfigLogic extends BaseLogic
$record->lottery_config_id = $config ? (int) $config->id : null;
$record->result_counts = $counts;
$record->admin_id = $adminId;
if ($adminId > 0) {
$admin = \plugin\saiadmin\app\model\system\SystemUser::find($adminId);
if ($admin && !empty($admin->dept_id)) {
$record->dept_id = $admin->dept_id;
}
}
$record->create_time = date('Y-m-d H:i:s');
$record->save();
$recordId = (int) $record->id;

View File

@@ -4,12 +4,16 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\reward_config_record;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\api\util\ApiLang;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\ante_config\DiceAnteConfig;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\reward\DiceReward;
use app\dice\model\reward\DiceRewardConfig;
use app\dice\model\play_record_test\DicePlayRecordTest;
use app\dice\model\reward_config_record\DiceRewardConfigRecord;
use plugin\saiadmin\basic\think\BaseLogic;
use app\dice\basic\DiceBaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\app\model\system\SystemUser;
@@ -17,7 +21,7 @@ use plugin\saiadmin\app\model\system\SystemUser;
* 奖励配置权重测试记录逻辑层
*
*/
class DiceRewardConfigRecordLogic extends BaseLogic
class DiceRewardConfigRecordLogic extends DiceBaseLogic
{
public function __construct()
{
@@ -74,6 +78,51 @@ class DiceRewardConfigRecordLogic extends BaseLogic
return $map;
}
/**
* 删除权重测试记录,并级联删除 dice_play_record_test 中 reward_config_record_id 关联的明细
* @param mixed $ids
*/
public function destroy($ids): bool
{
return $this->transaction(function () use ($ids) {
$intIds = $this->normalizeDestroyIds($ids);
if ($intIds === []) {
return false;
}
$this->destroyRelatedPlayRecordTests($intIds);
return parent::destroy($intIds);
});
}
/**
* @param mixed $ids
* @return list<int>
*/
private function normalizeDestroyIds($ids): array
{
$idList = is_array($ids) ? $ids : explode(',', (string) $ids);
$intIds = [];
foreach ($idList as $v) {
$iv = (int) $v;
if ($iv > 0) {
$intIds[] = $iv;
}
}
return array_values(array_unique($intIds));
}
/**
* @param list<int> $recordIds dice_reward_config_record.id
*/
private function destroyRelatedPlayRecordTests(array $recordIds): void
{
DicePlayRecordTest::destroy(function ($query) use ($recordIds) {
$query->whereIn('reward_config_record_id', $recordIds);
});
}
/**
* 将测试记录导入DiceReward权重快照、DiceRewardConfigBIGWIN weight、DiceLotteryPoolConfig付费/免费 T1-T5
* @param int $recordId 测试记录 ID
@@ -88,6 +137,7 @@ class DiceRewardConfigRecordLogic extends BaseLogic
throw new ApiException('Test record not found');
}
$record = is_array($record) ? $record : $record->toArray();
$configDeptId = AdminScopeHelper::normalizeRecordDeptId($record['dept_id'] ?? null);
$snapshot = $record['weight_config_snapshot'] ?? null;
if (is_string($snapshot)) {
@@ -112,9 +162,9 @@ class DiceRewardConfigRecordLogic extends BaseLogic
$tier = $tierFromDb !== null ? (string) $tierFromDb : '';
}
// 仅按方向 + 点数更新 DiceReward若存在则更新不存在才插入避免唯一键冲突
$reward = DiceReward::where('direction', $direction)
->where('grid_number', $gridNumber)
->find();
$rewardQuery = DiceReward::where('direction', $direction)->where('grid_number', $gridNumber);
ConfigScopeEditHelper::applyDeptIdWhere($rewardQuery, $configDeptId);
$reward = $rewardQuery->find();
if ($reward) {
$reward->weight = $weight;
// 若快照中有 tier补齐 tier 信息
@@ -130,10 +180,13 @@ class DiceRewardConfigRecordLogic extends BaseLogic
$m->direction = $direction;
$m->grid_number = $gridNumber;
$m->weight = $weight;
if (!AdminScopeHelper::isTemplateDeptId($configDeptId)) {
$m->dept_id = $configDeptId;
}
$m->save();
}
}
DiceReward::refreshCache();
DiceReward::refreshCache($configDeptId);
}
// 使用记录中的 bigwin_weight JSON 将 BIGWIN 概率导入到 DiceRewardConfig
@@ -152,11 +205,11 @@ class DiceRewardConfigRecordLogic extends BaseLogic
if ($weight < 0) {
$weight = 0;
}
DiceRewardConfig::where('tier', 'BIGWIN')
->where('grid_number', $gridNumber)
->update(['weight' => $weight]);
$bigwinQuery = DiceRewardConfig::where('tier', 'BIGWIN')->where('grid_number', $gridNumber);
ConfigScopeEditHelper::applyDeptIdWhere($bigwinQuery, $configDeptId);
$bigwinQuery->update(['weight' => $weight]);
}
DiceRewardConfig::refreshCache();
DiceRewardConfig::refreshCache($configDeptId);
}
$tiers = ['T1', 'T2', 'T3', 'T4', 'T5'];
@@ -225,7 +278,8 @@ class DiceRewardConfigRecordLogic extends BaseLogic
DiceLotteryPoolConfig::where('id', $freeTargetId)->update($update);
}
DiceRewardConfig::refreshCache();
DiceRewardConfig::refreshCache($configDeptId);
DiceReward::refreshCache($configDeptId);
DiceRewardConfig::clearRequestInstance();
}
@@ -237,8 +291,12 @@ class DiceRewardConfigRecordLogic extends BaseLogic
* @return int 记录 ID
* @throws ApiException
*/
public function createWeightTestRecord(array|int $params, mixed $adminIdOrFreeS = null, mixed $freeSOrFreeN = null, mixed $freeN = null): int
{
public function createWeightTestRecord(
array|int $params,
mixed $adminIdOrFreeS = null,
?array $adminInfo = null,
$requestDeptId = null
): int {
$adminId = null;
if (!is_array($params)) {
// 兼容旧版调用createWeightTestRecord(paid_s_count, paid_n_count)
@@ -249,15 +307,15 @@ class DiceRewardConfigRecordLogic extends BaseLogic
} else {
$adminId = $adminIdOrFreeS !== null && $adminIdOrFreeS !== '' ? (int) $adminIdOrFreeS : null;
}
$deptId = $this->resolveWeightTestDeptId(
$adminInfo,
AdminScopeHelper::pickRequestDeptId($requestDeptId, is_array($params) ? $params : []),
is_array($params) ? $params : []
);
$allowed = [100, 500, 1000, 5000];
$ante = isset($params['ante']) ? intval($params['ante']) : 1;
if ($ante <= 0) {
throw new ApiException('ante must be greater than 0');
}
$anteExists = DiceAnteConfig::where('mult', $ante)->count();
if ($anteExists <= 0) {
throw new ApiException('ante not allowed: ' . $ante);
}
$anteRandom = !empty($params['ante_random']);
$ante = $anteRandom ? 0 : $this->resolveWeightTestAnte($params, $deptId);
$lotteryConfigId = isset($params['lottery_config_id']) ? (int) $params['lottery_config_id'] : 0;
$paidConfigId = isset($params['paid_lottery_config_id']) ? (int) $params['paid_lottery_config_id'] : 0;
@@ -272,7 +330,12 @@ class DiceRewardConfigRecordLogic extends BaseLogic
$paidN = isset($params['paid_n_count']) ? (int) $params['paid_n_count'] : 0;
$chainFreeMode = !empty($params['chain_free_mode']);
$killModeEnabled = !empty($params['kill_mode_enabled']);
$testSafetyLine = isset($params['test_safety_line']) ? (int) $params['test_safety_line'] : 5000;
if (array_key_exists('test_safety_line', $params) && $params['test_safety_line'] !== null && $params['test_safety_line'] !== '') {
$testSafetyLine = (int) $params['test_safety_line'];
} else {
$defaultPool = DiceLotteryPoolConfig::findByNameForDept('default', $deptId);
$testSafetyLine = (int) ($defaultPool->safety_line ?? 0);
}
if ($testSafetyLine < 0) {
throw new ApiException('test_safety_line must be greater than or equal to 0');
}
@@ -296,8 +359,8 @@ class DiceRewardConfigRecordLogic extends BaseLogic
$paidTierWeights = null;
$freeTierWeights = null;
// 来自 DiceReward 的当前权重快照(按方向+点数),用于权重测试模拟
$instance = DiceReward::getCachedInstance();
// 来自当前渠道的 DiceReward 权重快照(按方向+点数),用于权重测试模拟
$instance = DiceReward::getCachedInstance($deptId);
$byTierDirection = $instance['by_tier_direction'] ?? [];
foreach ($byTierDirection as $tier => $byDir) {
foreach ($byDir as $dir => $rows) {
@@ -316,7 +379,7 @@ class DiceRewardConfigRecordLogic extends BaseLogic
// BIGWIN 概率快照从 DiceRewardConfig 读取(例如豹子号配置)
// JSON 结构 {"grid_number": weight, ...}
$bigwinWeights = [];
$bigwinConfigs = DiceRewardConfig::getCachedByTier('BIGWIN');
$bigwinConfigs = DiceRewardConfig::getCachedByTier('BIGWIN', $deptId);
foreach ($bigwinConfigs as $cfg) {
$grid = isset($cfg['grid_number']) ? (int) $cfg['grid_number'] : 0;
if ($grid <= 0) {
@@ -327,10 +390,7 @@ class DiceRewardConfigRecordLogic extends BaseLogic
}
if ($paidConfigId > 0) {
$config = DiceLotteryPoolConfig::find($paidConfigId);
if (!$config) {
throw new ApiException('Paid pool config not found');
}
$config = $this->findPoolConfigInDept($paidConfigId, $deptId, 'Paid pool config not found');
$tierWeightsSnapshot['paid'] = [
'T1' => (int) ($config->t1_weight ?? 0),
'T2' => (int) ($config->t2_weight ?? 0),
@@ -359,10 +419,7 @@ class DiceRewardConfigRecordLogic extends BaseLogic
}
if ($freeConfigId > 0) {
$config = DiceLotteryPoolConfig::find($freeConfigId);
if (!$config) {
throw new ApiException('Free pool config not found');
}
$config = $this->findPoolConfigInDept($freeConfigId, $deptId, 'Free pool config not found');
$tierWeightsSnapshot['free'] = [
'T1' => (int) ($config->t1_weight ?? 0),
'T2' => (int) ($config->t2_weight ?? 0),
@@ -400,6 +457,9 @@ class DiceRewardConfigRecordLogic extends BaseLogic
if ($chainFreeMode) {
$tierWeightsSnapshot['chain_free_mode'] = true;
}
if (!empty($params['ante_random'])) {
$tierWeightsSnapshot['ante_random'] = true;
}
$record = new DiceRewardConfigRecord();
$plannedPaidSpins = $paidS + $paidN;
@@ -428,9 +488,109 @@ class DiceRewardConfigRecordLogic extends BaseLogic
$record->bigwin_weight = $bigwinWeights ?: null;
$record->ante = $ante;
$record->admin_id = $adminId;
$record->dept_id = $deptId;
$record->create_time = date('Y-m-d H:i:s');
$record->save();
return (int) $record->id;
}
/**
* 解析一键测试所属渠道:请求 dept_id > 奖池配置 dept_id > 渠道管理员本渠道
*/
private function resolveWeightTestDeptId(?array $adminInfo, $requestDeptId, array $params): int
{
$deptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $requestDeptId);
if (! AdminScopeHelper::isTemplateDeptId($deptId)) {
return $deptId;
}
foreach (['paid_lottery_config_id', 'free_lottery_config_id', 'lottery_config_id'] as $key) {
$poolId = isset($params[$key]) ? (int) $params[$key] : 0;
if ($poolId <= 0) {
continue;
}
$pool = DiceLotteryPoolConfig::find($poolId);
if (! $pool) {
continue;
}
$poolDeptId = AdminScopeHelper::normalizeRecordDeptId($pool->dept_id ?? null);
if (! AdminScopeHelper::isTemplateDeptId($poolDeptId)) {
return $poolDeptId;
}
}
$scopeDeptId = AdminScopeHelper::getDeptId($adminInfo);
if ($scopeDeptId !== null && $scopeDeptId > 0) {
return $scopeDeptId;
}
return $deptId;
}
/**
* 校验奖池配置属于当前渠道
*/
private function findPoolConfigInDept(int $poolId, int $deptId, string $notFoundMsg): DiceLotteryPoolConfig
{
$config = DiceLotteryPoolConfig::find($poolId);
if (!$config) {
throw new ApiException($notFoundMsg);
}
$poolDeptId = AdminScopeHelper::normalizeRecordDeptId($config->dept_id ?? null);
if ($poolDeptId !== $deptId) {
throw new ApiException('POOL_CONFIG_NOT_IN_CHANNEL');
}
return $config;
}
/**
* 解析一键测试底注:优先 ante_config_id否则按 mult + 渠道校验
*/
private function resolveWeightTestAnte(array $params, int $deptId): int
{
if (!empty($params['ante_random'])) {
$anteQuery = DiceAnteConfig::field('id,mult')->order('mult', 'asc');
ConfigScopeEditHelper::applyDeptIdWhere($anteQuery, $deptId);
$rows = $anteQuery->select()->toArray();
if ($rows === []) {
throw new ApiException('No ante config in current channel');
}
$picked = $rows[random_int(0, count($rows) - 1)];
$mult = (int) ($picked['mult'] ?? 0);
if ($mult <= 0) {
throw new ApiException('ANTE_MUST_POSITIVE');
}
return $mult;
}
$anteConfigId = isset($params['ante_config_id']) ? (int) $params['ante_config_id'] : 0;
if ($anteConfigId > 0) {
$config = DiceAnteConfig::find($anteConfigId);
if (! $config) {
throw new ApiException('ANTE_CONFIG_NOT_FOUND');
}
$configDeptId = AdminScopeHelper::normalizeRecordDeptId($config->dept_id ?? null);
if ($configDeptId !== $deptId) {
throw new ApiException('ANTE_CONFIG_NOT_IN_CHANNEL');
}
$mult = (int) ($config->mult ?? 0);
if ($mult <= 0) {
throw new ApiException('ANTE_MUST_POSITIVE');
}
return $mult;
}
$ante = isset($params['ante']) ? (int) $params['ante'] : 0;
if ($ante <= 0) {
throw new ApiException('ANTE_MUST_POSITIVE');
}
$anteQuery = DiceAnteConfig::where('mult', $ante);
ConfigScopeEditHelper::applyDeptIdWhere($anteQuery, $deptId);
if ($anteQuery->count() <= 0) {
throw new ApiException(ApiLang::translateParams('ANTE_NOT_ALLOWED', [$ante]));
}
return $ante;
}
}

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace app\dice\logic\reward_config_record;
use app\api\logic\PlayStartLogic;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\ante_config\DiceAnteConfig;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\play_record_test\DicePlayRecordTest;
use app\dice\model\reward_config_record\DiceRewardConfigRecord;
@@ -14,14 +17,21 @@ use support\think\Db;
/**
* 一键测试权重:单进程后台执行模拟摇色子,写入 dice_play_record_test 并更新 dice_reward_config_record 进度
* 支持测试内杀分:当模拟玩家累计盈利达到安全线后,付费抽奖切换到 killScore
* 抽奖规则与 PlayStartLogic 一致:
* - 付费未杀分按模拟玩家档位权重抽档lottery_config_id 记 default
* - 付费杀分:测试内杀分开启且模拟池盈利 >= test_safety_line 后切 killScore
* - 免费券name=free 奖池(无则 default排除 5/30 豹子
*/
class WeightTestRunner
{
private const BATCH_SIZE = 10;
/** 本次测试所属渠道(与 dice_reward_config_record.dept_id 一致) */
private int $runDeptId = 0;
/** 测试记录写库白名单字段 */
private const PLAY_RECORD_TEST_COLUMNS = [
'reward_config_record_id',
'dept_id',
'admin_id',
'lottery_config_id',
'lottery_type',
@@ -52,7 +62,6 @@ class WeightTestRunner
return;
}
$ante = is_numeric($record->ante ?? null) ? intval($record->ante) : 1;
$paidS = (int) ($record->paid_s_count ?? 0);
$paidN = (int) ($record->paid_n_count ?? 0);
$total = $paidS + $paidN;
@@ -61,10 +70,22 @@ class WeightTestRunner
return;
}
$configType0 = DiceLotteryPoolConfig::where('name', 'default')->find();
$configType1 = DiceLotteryPoolConfig::where('name', 'killScore')->find();
$this->runDeptId = $this->resolveRunDeptId($recordId, $record);
$deptId = $this->runDeptId;
$anteRandom = $this->isAnteRandomMode($record);
$ante = is_numeric($record->ante ?? null) ? intval($record->ante) : 1;
if (!$anteRandom && $ante <= 0) {
$ante = $this->getMinAnteMult($deptId);
}
DiceReward::setRequestDeptId($deptId);
DiceRewardConfig::clearRequestInstance();
$configType0 = DiceLotteryPoolConfig::findByNameForDept('default', $deptId);
$configKill = DiceLotteryPoolConfig::findByNameForDept('killScore', $deptId);
$configFree = DiceLotteryPoolConfig::findByNameForDept('free', $deptId);
if (!$configType0) {
$this->markFailed($recordId, '彩金池配置 name=default 不存在');
$this->markFailed($recordId, '彩金池配置 name=default 不存在(当前渠道)');
return;
}
@@ -79,12 +100,12 @@ class WeightTestRunner
$freePoolConfigId = (int) ($record->free_lottery_config_id ?? 0);
$paidPoolConfig = $paidPoolConfigId > 0 ? DiceLotteryPoolConfig::find($paidPoolConfigId) : $configType0;
if (!$paidPoolConfig) {
if (!$paidPoolConfig || AdminScopeHelper::normalizeRecordDeptId($paidPoolConfig->dept_id ?? null) !== $deptId) {
$paidPoolConfig = $configType0;
}
$freePoolConfig = $freePoolConfigId > 0 ? DiceLotteryPoolConfig::find($freePoolConfigId) : $configType1;
if (!$freePoolConfig) {
$freePoolConfig = $configType0;
$freePoolConfig = $freePoolConfigId > 0 ? DiceLotteryPoolConfig::find($freePoolConfigId) : $configFree;
if (!$freePoolConfig || AdminScopeHelper::normalizeRecordDeptId($freePoolConfig->dept_id ?? null) !== $deptId) {
$freePoolConfig = $configFree ?: $configType0;
}
if ($paidTierWeightsCustom !== null && array_sum($paidTierWeightsCustom) <= 0) {
@@ -106,8 +127,11 @@ class WeightTestRunner
$testSafetyLine = 0;
}
// 测试内“玩家累计盈利”:用于控制付费局是否切换杀分
$playerProfitTotal = 0.0;
// 彩金池累计盈利:从 default.profit_amount 起步并在测试内逐局累加,与杀分判定值一致
$poolProfitTotal = (float) ($configType0->profit_amount ?? 0);
// 付费未杀分时的模拟玩家档位权重(自定义 > 快照 > 兜底奖池)
$paidPlayerWeights = $paidTierWeightsCustom ?? $this->resolveTierWeightsSnapshot($record, 'paid');
$playLogic = new PlayStartLogic();
$resultCounts = [];
@@ -118,18 +142,21 @@ class WeightTestRunner
try {
$this->runChainFreeMode(
$recordId,
$deptId,
$playLogic,
$paidS,
$paidN,
$ante,
$anteRandom,
$configType0,
$paidPoolConfig,
$freePoolConfig,
$configType1,
$paidTierWeightsCustom,
$configKill,
$paidPlayerWeights,
$freeTierWeightsCustom,
$killModeEnabled,
$testSafetyLine,
$playerProfitTotal,
$poolProfitTotal,
$resultCounts,
$tierCounts,
$buffer,
@@ -145,6 +172,9 @@ class WeightTestRunner
} catch (\Throwable $e) {
Log::error('WeightTestRunner exception: ' . $e->getMessage(), ['record_id' => $recordId, 'trace' => $e->getTraceAsString()]);
$this->markFailed($recordId, $e->getMessage());
} finally {
DiceReward::clearRequestInstance();
DiceRewardConfig::clearRequestInstance();
}
}
@@ -153,18 +183,21 @@ class WeightTestRunner
*/
private function runChainFreeMode(
int $recordId,
int $deptId,
PlayStartLogic $playLogic,
int $paidS,
int $paidN,
int $ante,
bool $anteRandom,
$defaultPoolConfig,
$paidPoolConfig,
$freePoolConfig,
$killPoolConfig,
?array $paidTierWeightsCustom,
?array $paidPlayerWeights,
?array $freeTierWeightsCustom,
bool $killModeEnabled,
int $testSafetyLine,
float &$playerProfitTotal,
float &$poolProfitTotal,
array &$resultCounts,
array &$tierCounts,
array &$buffer,
@@ -172,39 +205,61 @@ class WeightTestRunner
): void {
$queue = [];
for ($i = 0; $i < $paidS; $i++) {
$queue[] = ['paid', 0, $ante];
$queue[] = ['paid', 0, $anteRandom ? 0 : $ante];
}
for ($i = 0; $i < $paidN; $i++) {
$queue[] = ['paid', 1, $ante];
$queue[] = ['paid', 1, $anteRandom ? 0 : $ante];
}
$qi = 0;
$lastPaidPlayAnte = 0;
while ($qi < count($queue)) {
$item = $queue[$qi];
$isPaid = $item[0] === 'paid';
$dir = $item[1];
$playAnte = $item[2];
$playAnte = (int) $item[2];
$playAnte = $this->resolvePlayAnteMult(
$deptId,
$playAnte,
$isPaid,
$anteRandom,
$ante,
$lastPaidPlayAnte
);
if ($isPaid) {
$lastPaidPlayAnte = $playAnte;
}
$lotteryType = $isPaid ? 0 : 1;
if ($isPaid) {
$useKillForPaid = $killModeEnabled && $playerProfitTotal >= $testSafetyLine && $killPoolConfig !== null;
$useKillForPaid = $killModeEnabled
&& $poolProfitTotal >= $testSafetyLine
&& $killPoolConfig !== null;
if ($useKillForPaid) {
$cfg = $killPoolConfig;
$customWeights = null;
} else {
$cfg = $paidPoolConfig;
$customWeights = $paidTierWeightsCustom;
// 付费未杀分模拟玩家档位权重lottery_config_id 记 default与 PlayStartLogic 一致)
$cfg = $defaultPoolConfig;
$customWeights = $paidPlayerWeights;
if ($customWeights === null) {
$cfg = $paidPoolConfig;
$customWeights = null;
}
}
} else {
$cfg = $freePoolConfig;
$customWeights = $freeTierWeightsCustom;
}
$row = $playLogic->simulateOnePlay($cfg, $dir, $lotteryType, $playAnte, $customWeights);
$row = $playLogic->simulateOnePlay($cfg, $dir, $lotteryType, $playAnte, $customWeights, $deptId);
// 明细底注必须为 dice_ante_config.mult随机模式每局独立抽取后的值
$row['ante'] = $playAnte;
$winCoin = (float) ($row['win_coin'] ?? 0);
$paidAmount = (float) ($row['paid_amount'] ?? 0);
$playerProfitTotal += $winCoin - $paidAmount;
$perPlayProfit = $isPaid ? ($winCoin - $paidAmount) : $winCoin;
$poolProfitTotal += round($perPlayProfit, 2);
$this->aggregate($row, $resultCounts, $tierCounts);
$buffer[] = $this->rowForInsert($row, $recordId);
$buffer[] = $this->rowForInsert($row, $recordId, $deptId);
$done++;
if (!empty($row['grants_free_ticket'])) {
@@ -217,6 +272,189 @@ class WeightTestRunner
}
}
/**
* 是否启用「底注随机」:每局付费抽奖从当前渠道底注配置中独立抽取 mult
*/
private function isAnteRandomMode(DiceRewardConfigRecord $record): bool
{
$snap = $this->normalizeTierWeightsSnapshot($record->tier_weights_snapshot ?? null);
if (is_array($snap) && !empty($snap['ante_random'])) {
return true;
}
// 创建随机测试时主表 ante 固定为 0快照丢失 ante_random 时仍按随机模式执行
$recordAnte = is_numeric($record->ante ?? null) ? (int) $record->ante : -1;
return $recordAnte === 0;
}
/**
* @param mixed $snap
* @return array<string, mixed>|null
*/
private function normalizeTierWeightsSnapshot($snap): ?array
{
if (is_array($snap)) {
return $snap;
}
if (is_string($snap) && $snap !== '') {
$decoded = json_decode($snap, true);
return is_array($decoded) ? $decoded : null;
}
return null;
}
/**
* 解析本局有效底注dice_ante_config.mult禁止写入 0
*/
private function resolvePlayAnteMult(
int $deptId,
int $queuedAnte,
bool $isPaid,
bool $anteRandom,
int $recordAnte,
int $lastPaidPlayAnte
): int {
if ($isPaid) {
if ($anteRandom) {
return $this->pickRandomAnteMult($deptId);
}
if ($queuedAnte > 0) {
return $queuedAnte;
}
if ($recordAnte > 0) {
return $recordAnte;
}
return $this->getMinAnteMult($deptId);
}
// 免费券T5 链式):与触发付费局同底注,不得为 0
if ($queuedAnte > 0) {
return $queuedAnte;
}
if ($lastPaidPlayAnte > 0) {
return $lastPaidPlayAnte;
}
if ($recordAnte > 0) {
return $recordAnte;
}
return $this->getMinAnteMult($deptId);
}
/**
* 当前渠道底注配置中最小正数 mult
*/
private function getMinAnteMult(int $deptId): int
{
$mults = $this->listPositiveAnteMults($deptId);
return $mults !== [] ? $mults[0] : 1;
}
/**
* @return list<int>
*/
private function listPositiveAnteMults(int $deptId): array
{
$anteQuery = DiceAnteConfig::field('mult')->order('mult', 'asc');
ConfigScopeEditHelper::applyDeptIdWhere($anteQuery, $deptId);
$rows = $anteQuery->select()->toArray();
$mults = [];
foreach ($rows as $row) {
$mult = (int) ($row['mult'] ?? 0);
if ($mult > 0) {
$mults[] = $mult;
}
}
return $mults;
}
/**
* 从当前渠道 dice_ante_config 中随机取一条 mult仅 mult>0
*/
private function pickRandomAnteMult(int $deptId): int
{
$mults = $this->listPositiveAnteMults($deptId);
if ($mults === []) {
return 1;
}
return $mults[random_int(0, count($mults) - 1)];
}
/**
* 从 tier_weights_snapshot 读取付费/免费档位权重快照
*/
private function resolveTierWeightsSnapshot(DiceRewardConfigRecord $record, string $side): ?array
{
$snap = $this->normalizeTierWeightsSnapshot($record->tier_weights_snapshot ?? null);
if ($snap === null) {
return null;
}
$weights = $snap[$side] ?? null;
if (! is_array($weights) || $weights === []) {
return null;
}
return $weights;
}
/**
* 解析本次测试渠道:优先读库字段,避免 ORM 字段缓存未含 dept_id 时读不到
*/
private function resolveRunDeptId(int $recordId, DiceRewardConfigRecord $record): int
{
$recordTable = (new DiceRewardConfigRecord())->getTable();
$fromDb = Db::table($recordTable)->where('id', $recordId)->value('dept_id');
$deptId = AdminScopeHelper::normalizeRecordDeptId($fromDb);
if (! AdminScopeHelper::isTemplateDeptId($deptId)) {
return $deptId;
}
$deptId = AdminScopeHelper::normalizeRecordDeptId($record->dept_id ?? null);
if (! AdminScopeHelper::isTemplateDeptId($deptId)) {
return $deptId;
}
return $this->resolveDeptIdFromRecordPools($record, $deptId);
}
/**
* 历史记录 dept_id=0 时,从关联奖池配置反推并回写
*/
private function resolveDeptIdFromRecordPools(DiceRewardConfigRecord $record, int $fallbackDeptId): int
{
foreach (
[
(int) ($record->paid_lottery_config_id ?? 0),
(int) ($record->free_lottery_config_id ?? 0),
(int) ($record->lottery_config_id ?? 0),
] as $poolId
) {
if ($poolId <= 0) {
continue;
}
$pool = DiceLotteryPoolConfig::find($poolId);
if (! $pool) {
continue;
}
$poolDeptId = AdminScopeHelper::normalizeRecordDeptId($pool->dept_id ?? null);
if (! AdminScopeHelper::isTemplateDeptId($poolDeptId)) {
if (AdminScopeHelper::normalizeRecordDeptId($record->dept_id ?? null) !== $poolDeptId) {
$record->dept_id = $poolDeptId;
$record->save();
}
return $poolDeptId;
}
}
return $fallbackDeptId;
}
private function aggregate(array $row, array &$resultCounts, array &$tierCounts): void
{
$grid = (int) ($row['roll_number_for_count'] ?? $row['roll_number'] ?? 0);
@@ -229,10 +467,14 @@ class WeightTestRunner
}
}
private function rowForInsert(array $row, int $rewardConfigRecordId): array
private function rowForInsert(array $row, int $rewardConfigRecordId, int $deptId): array
{
$bindDeptId = ! AdminScopeHelper::isTemplateDeptId($this->runDeptId)
? $this->runDeptId
: $deptId;
$out = [
'reward_config_record_id' => $rewardConfigRecordId,
'dept_id' => $bindDeptId,
];
$keys = [
'admin_id', 'lottery_config_id', 'lottery_type', 'is_win', 'win_coin',
@@ -245,6 +487,9 @@ class WeightTestRunner
$out[$k] = $row[$k];
}
}
if (array_key_exists('ante', $out) && (int) ($out['ante'] ?? 0) <= 0) {
$out['ante'] = $this->getMinAnteMult($bindDeptId > 0 ? $bindDeptId : $deptId);
}
return $out;
}
@@ -254,7 +499,7 @@ class WeightTestRunner
return;
}
$this->insertBuffer($buffer);
$buffer = [];
array_splice($buffer, 0, count($buffer));
$this->updateProgress($recordId, $done, $resultCounts, $tierCounts, $recordTotalPlayCount);
}
@@ -263,6 +508,7 @@ class WeightTestRunner
if (empty($rows)) {
return;
}
$table = (new DicePlayRecordTest())->getTable();
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
@@ -273,6 +519,9 @@ class WeightTestRunner
$payload[$column] = $row[$column];
}
}
if (! AdminScopeHelper::isTemplateDeptId($this->runDeptId)) {
$payload['dept_id'] = $this->runDeptId;
}
if (!array_key_exists('create_time', $payload) || $payload['create_time'] === null || $payload['create_time'] === '') {
$payload['create_time'] = date('Y-m-d H:i:s');
}
@@ -282,10 +531,26 @@ class WeightTestRunner
if ($payload === []) {
continue;
}
Db::name((new DicePlayRecordTest())->getTable())->insert($payload);
// strict(false):表结构新增 dept_id 后,避免连接层字段缓存未刷新导致插入被丢弃
Db::table($table)->strict(false)->insert($payload);
}
}
/**
* 将本批测试明细 dept_id 与主记录对齐(修复历史 worker 未写入 dept_id 的情况)
*/
private function syncPlayRecordTestDeptId(int $recordId, int $deptId): void
{
if (AdminScopeHelper::isTemplateDeptId($deptId)) {
return;
}
DicePlayRecordTest::where('reward_config_record_id', $recordId)
->where(function ($query) {
$query->whereNull('dept_id')->whereOr('dept_id', AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
})
->update(['dept_id' => $deptId]);
}
private function updateProgress(int $recordId, int $overPlayCount, array $resultCounts, array $tierCounts, ?int $totalPlayCount = null): void
{
$record = DiceRewardConfigRecord::find($recordId);
@@ -308,6 +573,13 @@ class WeightTestRunner
{
$record = DiceRewardConfigRecord::find($recordId);
if ($record) {
$deptId = AdminScopeHelper::normalizeRecordDeptId($record->dept_id ?? null);
if (AdminScopeHelper::isTemplateDeptId($deptId) && ! AdminScopeHelper::isTemplateDeptId($this->runDeptId)) {
$deptId = $this->runDeptId;
$record->dept_id = $deptId;
}
$this->syncPlayRecordTestDeptId($recordId, $deptId);
// 平台盈利通过关联测试记录统计
$platformProfit = DiceRewardConfigRecord::computePlatformProfitFromRelated($recordId);
// 落点统计也通过关联测试记录重新统计,避免模拟过程异常导致为空

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace app\dice\model;
use plugin\saiadmin\basic\think\BaseModel as SaiBaseModel;
/**
* 大富翁模块模型基类:删除均为硬删除(物理删除)
*
* 注意:
* - 不要在此重写实例方法 delete(),否则与 trait/父类的 delete() 相互覆盖,
* 在调用 $this->force()->delete() 时会无限递归force() 返回 $this
* 导致内存爆栈、HTTP 500。
* - 物理删除一律通过静态 destroy() 入口(强制 $force=true完成
* SoftDelete::destroy() 内部会按硬删除分支执行。
*/
abstract class DiceModel extends SaiBaseModel
{
/**
* @param mixed $data
*/
public static function destroy($data, bool $force = true): bool
{
return parent::destroy($data, true);
}
}

View File

@@ -6,7 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\model\ante_config;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\DiceModel;
/**
* 底注配置模型
@@ -19,7 +19,7 @@ use plugin\saiadmin\basic\think\BaseModel;
* @property string $create_time 创建时间
* @property string $update_time 更新时间
*/
class DiceAnteConfig extends BaseModel
class DiceAnteConfig extends DiceModel
{
protected $pk = 'id';

View File

@@ -6,7 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\model\config;
use plugin\saiadmin\basic\eloquent\BaseModel;
use app\dice\model\DiceModel;
/**
* 摇色子配置模型
@@ -23,7 +23,7 @@ use plugin\saiadmin\basic\eloquent\BaseModel;
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DiceConfig extends BaseModel
class DiceConfig extends DiceModel
{
/**
* 数据表主键

View File

@@ -4,14 +4,14 @@
// +----------------------------------------------------------------------
namespace app\dice\model\game;
use plugin\saiadmin\basic\eloquent\BaseModel;
use app\dice\model\DiceModel;
/**
* 游戏管理模型
*
* dice_game 游戏配置表
*/
class DiceGame extends BaseModel
class DiceGame extends DiceModel
{
protected $primaryKey = 'id';

View File

@@ -6,7 +6,9 @@
// +----------------------------------------------------------------------
namespace app\dice\model\lottery_pool_config;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\DiceModel;
/**
* 色子奖池配置模型
@@ -15,7 +17,8 @@ use plugin\saiadmin\basic\think\BaseModel;
*
* @property $id ID
* @property $name 名称
* @property $remark 备注
* @property $remark 奖池名称(后台展示名)
* @property $config_note 配置备注
* @property $safety_line 安全线
* @property $kill_enabled 是否启用杀分0=关闭 1=开启
* @property $create_time 创建时间
@@ -27,8 +30,11 @@ use plugin\saiadmin\basic\think\BaseModel;
* @property $t5_weight T5池权重
* @property $profit_amount 池子累计盈利(每局付费按 win_coin-paid_amount免费按 win_coin 累加;仅展示不可编辑)
*/
class DiceLotteryPoolConfig extends BaseModel
class DiceLotteryPoolConfig extends DiceModel
{
/** 玩家默认彩金池(新玩家关联;付费未杀分时运行时读取该池 T1T5 权重) */
public const NAME_PLAYER_DEFAULT = 'playerDefault';
/**
* 数据表主键
* @var string
@@ -41,12 +47,81 @@ class DiceLotteryPoolConfig extends BaseModel
*/
protected $table = 'dice_lottery_pool_config';
/** 列表/关联 JSON 附带奖池展示名 */
protected $append = ['display_name'];
/**
* 按名称与渠道查找奖池配置(一键测试等场景,避免命中其他渠道同名配置)
*/
public static function findByNameForDept(string $name, int $deptId): ?self
{
$query = (new self())->where('name', $name);
ConfigScopeEditHelper::applyDeptIdWhere($query, AdminScopeHelper::normalizeRecordDeptId($deptId));
return $query->find();
}
/**
* 是否玩家默认模板池name=playerDefault
* 须用 getData()['name']:方法内 $this->name 会命中 ThinkORM 内部属性而非表字段,导致恒为 false
*/
public static function isPlayerDefaultPoolName($name): bool
{
return (string) $name === self::NAME_PLAYER_DEFAULT;
}
/**
* 是否玩家默认模板池(运行时按该池权重抽档,改池配置即对所有关联玩家生效)
*/
public function isPlayerDefaultTemplate(): bool
{
$data = $this->getData();
return self::isPlayerDefaultPoolName($data['name'] ?? '');
}
/**
* 后台展示用奖池名称:优先 remark否则 name
*
* @param array<string, mixed>|self $row
*/
public static function displayLabel($row): string
{
// 禁止对模型实例 toArray()append display_name 会再次触发本方法,导致内存耗尽
if ($row instanceof self) {
$data = $row->getData();
$remark = trim((string) ($data['remark'] ?? ''));
if ($remark !== '') {
return $remark;
}
return trim((string) ($data['name'] ?? ''));
}
$remark = trim((string) ($row['remark'] ?? ''));
if ($remark !== '') {
return $remark;
}
return trim((string) ($row['name'] ?? ''));
}
public function getDisplayNameAttr(): string
{
$data = $this->getData();
$remark = trim((string) ($data['remark'] ?? ''));
if ($remark !== '') {
return $remark;
}
return trim((string) ($data['name'] ?? ''));
}
/**
* 名称 搜索
*/
public function searchNameAttr($query, $value)
{
$query->where('name', 'like', '%'.$value.'%');
$like = '%' . $value . '%';
$query->where(function ($q) use ($like) {
$q->where('name', 'like', $like)
->whereOr('remark', 'like', $like);
});
}
}

View File

@@ -9,7 +9,7 @@ namespace app\dice\model\play_record;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\player\DicePlayer;
use app\dice\model\reward\DiceRewardConfig;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\DiceModel;
use think\model\relation\BelongsTo;
/**
@@ -31,6 +31,7 @@ use think\model\relation\BelongsTo;
* @property $use_coins 消耗平台币(兼容字段:付费局=paid_amount免费局=0
* @property $direction 方向:0=顺时针,1=逆时针
* @property $reward_tier 中奖档位T1,T2,T3,T4,T5,BIGWIN
* @property $remark 备注(如惩罚格余额不足)
* @property $lottery_id 奖池
* @property $start_index 起始索引
* @property $target_index 结束索引
@@ -41,7 +42,7 @@ use think\model\relation\BelongsTo;
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DicePlayRecord extends BaseModel
class DicePlayRecord extends DiceModel
{
/**
* 数据表主键
@@ -87,13 +88,17 @@ class DicePlayRecord extends BaseModel
}
}
/** 按彩金池配置名称模糊diceLotteryPoolConfig.name */
/** 按彩金池奖池名称或内部标识模糊搜索 */
public function searchLotteryConfigNameAttr($query, $value)
{
if ($value === '' || $value === null) {
return;
}
$ids = DiceLotteryPoolConfig::where('name', 'like', '%' . $value . '%')->column('id');
$like = '%' . $value . '%';
$ids = DiceLotteryPoolConfig::where(function ($q) use ($like) {
$q->where('name', 'like', $like)
->whereOr('remark', 'like', $like);
})->column('id');
if (!empty($ids)) {
$query->whereIn('lottery_config_id', $ids);
} else {
@@ -281,4 +286,20 @@ class DicePlayRecord extends BaseModel
$query->where('roll_number', '<=', $value);
}
}
/** 创建时间起始 */
public function searchCreateTimeMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '>=', $value);
}
}
/** 创建时间结束 */
public function searchCreateTimeMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '<=', $value);
}
}
}

View File

@@ -6,7 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\model\play_record_test;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\DiceModel;
use app\dice\model\reward_config_record\DiceRewardConfigRecord;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use think\model\relation\BelongsTo;
@@ -37,7 +37,7 @@ use think\model\relation\BelongsTo;
* @property $admin_id 所属管理员
* @property int|null $reward_config_record_id 关联 DiceRewardConfigRecord.id权重测试记录
*/
class DicePlayRecordTest extends BaseModel
class DicePlayRecordTest extends DiceModel
{
/**
* 数据表主键
@@ -73,6 +73,15 @@ class DicePlayRecordTest extends BaseModel
return $this->belongsTo(DiceRewardConfigRecord::class, 'reward_config_record_id', 'id');
}
/** 彩金池配置 id关联 dice_lottery_pool_config.id */
public function searchLotteryConfigIdAttr($query, $value): void
{
if ($value === '' || $value === null) {
return;
}
$query->where('lottery_config_id', '=', (int) $value);
}
/** 抽奖类型 0=付费 1=免费 */
public function searchLotteryTypeAttr($query, $value)
{

View File

@@ -6,7 +6,8 @@
// +----------------------------------------------------------------------
namespace app\dice\model\player;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\helper\AdminScopeHelper;
use app\dice\model\DiceModel;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
/**
@@ -15,7 +16,8 @@ use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
* dice_player 大富翁-玩家
*
* @property $id ID
* @property $username 用户名
* @property $dept_id 所属渠道ID
* @property $username 用户名(同渠道内唯一)
* @property $phone 手机
* @property $uid uid
* @property $name 昵称
@@ -37,7 +39,7 @@ use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
* @property $update_time 更新时间
* @property $delete_time 删除时间
*/
class DicePlayer extends BaseModel
class DicePlayer extends DiceModel
{
/**
* 数据表主键
@@ -83,26 +85,61 @@ class DicePlayer extends BaseModel
if ($name === null || $name === '') {
$model->setAttr('name', $uid);
}
// 创建玩家时:未指定则自动保存 lottery_config_id 为 DiceLotteryPoolConfig name=default 的 id,没有则为 0
// 创建玩家时:未指定则关联 name=playerDefault玩家默认彩金池,没有则为 0
try {
$lotteryConfigId = $model->getAttr('lottery_config_id');
} catch (\Throwable $e) {
$lotteryConfigId = null;
}
if ($lotteryConfigId === null || $lotteryConfigId === '' || (int) $lotteryConfigId === 0) {
$config = DiceLotteryPoolConfig::where('name', 'default')->find();
$config = self::findPlayerDefaultLotteryConfigForPlayer($model);
$model->setAttr('lottery_config_id', $config ? (int) $config->id : 0);
}
// 彩金池权重默认取 name=default 的奖池配置
// 展示用权重从玩家关联的彩金池复制playerDefault 在抽奖时仍实时读池配置
self::setDefaultWeightsFromLotteryConfig($model);
}
/**
* 从 DiceLotteryPoolConfig name=default 取 t1_weightt5_weight 作为玩家未设置时的默认值
* 按玩家所属渠道查找玩家默认彩金池name=playerDefault
*/
protected static function findPlayerDefaultLotteryConfigForPlayer(DicePlayer $model): ?DiceLotteryPoolConfig
{
try {
$deptId = $model->getAttr('dept_id');
} catch (\Throwable $e) {
$deptId = null;
}
$normalizedDeptId = AdminScopeHelper::resolvePlayerConfigDeptId($model);
if ($deptId !== null && $deptId !== '' && (int) $deptId > 0) {
$normalizedDeptId = (int) $deptId;
}
$config = DiceLotteryPoolConfig::findByNameForDept(
DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT,
$normalizedDeptId
);
if ($config) {
return $config;
}
return DiceLotteryPoolConfig::findByNameForDept('default', $normalizedDeptId);
}
/**
* 从玩家关联彩金池(或 playerDefault / default取 t1_weightt5_weight 作为未设置时的默认值
*/
protected static function setDefaultWeightsFromLotteryConfig(DicePlayer $model): void
{
$config = DiceLotteryPoolConfig::where('name', 'default')->find();
$config = null;
try {
$lotteryConfigId = (int) $model->getAttr('lottery_config_id');
} catch (\Throwable $e) {
$lotteryConfigId = 0;
}
if ($lotteryConfigId > 0) {
$config = DiceLotteryPoolConfig::find($lotteryConfigId);
}
if (!$config) {
$config = self::findPlayerDefaultLotteryConfigForPlayer($model);
}
if (!$config) {
return;
}
@@ -187,6 +224,26 @@ class DicePlayer extends BaseModel
}
}
/**
* 注册时间起始 搜索
*/
public function searchCreateTimeMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '>=', $value);
}
}
/**
* 注册时间结束 搜索
*/
public function searchCreateTimeMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '<=', $value);
}
}
/**
* 关联彩金池配置
*/

View File

@@ -7,7 +7,7 @@
namespace app\dice\model\player_ticket_record;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\DiceModel;
use think\model\relation\BelongsTo;
/**
@@ -27,7 +27,7 @@ use think\model\relation\BelongsTo;
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DicePlayerTicketRecord extends BaseModel
class DicePlayerTicketRecord extends DiceModel
{
/**
* 数据表主键

View File

@@ -7,7 +7,7 @@
namespace app\dice\model\player_wallet_record;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\DiceModel;
use plugin\saiadmin\app\model\system\SystemUser;
use think\model\relation\BelongsTo;
@@ -31,7 +31,7 @@ use think\model\relation\BelongsTo;
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DicePlayerWalletRecord extends BaseModel
class DicePlayerWalletRecord extends DiceModel
{
/**
* 数据表主键

View File

@@ -4,7 +4,9 @@
// +----------------------------------------------------------------------
namespace app\dice\model\reward;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\DiceModel;
use support\think\Cache;
/**
@@ -25,42 +27,70 @@ use support\think\Cache;
* @property $remark 备注(来自config)
* @property $type 奖励类型(来自config)
*/
class DiceReward extends BaseModel
class DiceReward extends DiceModel
{
/** 方向:顺时针 */
public const DIRECTION_CLOCKWISE = 0;
/** 方向:逆时针 */
public const DIRECTION_COUNTERCLOCKWISE = 1;
/** 缓存键:奖励对照实例 */
/** 缓存键前缀:奖励对照实例(按渠道分键) */
private const CACHE_KEY_INSTANCE = 'dice:reward:instance';
private const CACHE_TTL = 86400 * 30;
private static ?array $instance = null;
private static ?int $requestDeptId = null;
protected $table = 'dice_reward';
/** 主键 id 自增,唯一约束 (direction, grid_number) */
protected $pk = 'id';
private static function cacheKeyForDept(int $deptId): string
{
return self::CACHE_KEY_INSTANCE . ':' . $deptId;
}
private static function resolveDeptId(?int $deptId): int
{
if ($deptId !== null) {
return AdminScopeHelper::normalizeRecordDeptId($deptId);
}
if (self::$requestDeptId !== null) {
return self::$requestDeptId;
}
return AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
}
/**
* 请求级设置当前渠道(一键测试 worker 内调用 simulateOnePlay 前设置)
*/
public static function setRequestDeptId(?int $deptId): void
{
self::$requestDeptId = $deptId !== null
? AdminScopeHelper::normalizeRecordDeptId($deptId)
: null;
}
/**
* 获取奖励对照实例(按档位+方向索引,用于抽奖与权重配比)
* 优先从共享缓存读取,保证多进程(如一键测试 worker与数据库一致
* @return array{list: array, by_tier_direction: array}
*/
public static function getCachedInstance(): array
public static function getCachedInstance(?int $deptId = null): array
{
$instance = Cache::get(self::CACHE_KEY_INSTANCE);
$deptId = self::resolveDeptId($deptId);
$cacheKey = self::cacheKeyForDept($deptId);
$instance = Cache::get($cacheKey);
if ($instance !== null && is_array($instance)) {
self::$instance = $instance;
return $instance;
}
if (self::$instance !== null) {
if (self::$instance !== null && self::$requestDeptId === $deptId) {
return self::$instance;
}
self::refreshCache();
$instance = Cache::get(self::CACHE_KEY_INSTANCE);
self::refreshCache($deptId);
$instance = Cache::get($cacheKey);
self::$instance = is_array($instance) ? $instance : self::buildEmptyInstance();
return self::$instance;
}
@@ -69,9 +99,9 @@ class DiceReward extends BaseModel
* 按档位+方向取权重列表(用于抽奖:该档位该方向下 end_index => weight
* @return array<int, int> end_index => weight
*/
public static function getCachedByTierAndDirection(string $tier, int $direction): array
public static function getCachedByTierAndDirection(string $tier, int $direction, ?int $deptId = null): array
{
$inst = self::getCachedInstance();
$inst = self::getCachedInstance($deptId);
$byTierDirection = $inst['by_tier_direction'] ?? [];
$list = $byTierDirection[$tier][$direction] ?? [];
$result = [];
@@ -84,11 +114,14 @@ class DiceReward extends BaseModel
}
/**
* 重新从数据库加载并写入缓存;修改/新增/删除后需调用以实例化
* 按渠道从数据库加载并写入缓存
*/
public static function refreshCache(): void
public static function refreshCache(?int $deptId = null): void
{
$list = (new self())->order('tier')->order('direction')->order('end_index')->select()->toArray();
$deptId = self::resolveDeptId($deptId);
$query = (new self())->order('tier')->order('direction')->order('end_index');
ConfigScopeEditHelper::applyDeptIdWhere($query, $deptId);
$list = $query->select()->toArray();
$byTierDirection = [];
foreach ($list as $row) {
$tier = isset($row['tier']) ? (string) $row['tier'] : '';
@@ -103,11 +136,12 @@ class DiceReward extends BaseModel
$byTierDirection[$tier][$direction][] = $row;
}
}
self::$instance = [
$instance = [
'list' => $list,
'by_tier_direction' => $byTierDirection,
];
Cache::set(self::CACHE_KEY_INSTANCE, self::$instance, self::CACHE_TTL);
self::$instance = $instance;
Cache::set(self::cacheKeyForDept($deptId), $instance, self::CACHE_TTL);
}
private static function buildEmptyInstance(): array
@@ -121,20 +155,29 @@ class DiceReward extends BaseModel
public static function clearRequestInstance(): void
{
self::$instance = null;
self::$requestDeptId = null;
}
private static function refreshCacheForModel($model): void
{
$deptId = AdminScopeHelper::normalizeRecordDeptId(
is_array($model) ? ($model['dept_id'] ?? null) : ($model->dept_id ?? null)
);
self::refreshCache($deptId);
}
public static function onAfterInsert($model): void
{
self::refreshCache();
self::refreshCacheForModel($model);
}
public static function onAfterUpdate($model): void
{
self::refreshCache();
self::refreshCacheForModel($model);
}
public static function onAfterDelete($model): void
{
self::refreshCache();
self::refreshCacheForModel($model);
}
}

View File

@@ -6,8 +6,10 @@
// +----------------------------------------------------------------------
namespace app\dice\model\reward_config;
use app\dice\helper\AdminScopeHelper;
use app\dice\helper\ConfigScopeEditHelper;
use app\dice\model\reward\DiceReward;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\DiceModel;
use support\think\Cache;
/**
@@ -27,7 +29,7 @@ use support\think\Cache;
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DiceRewardConfig extends BaseModel
class DiceRewardConfig extends DiceModel
{
/** 缓存键:彩金池奖励列表实例 */
private const CACHE_KEY_INSTANCE = 'dice:reward_config:instance';
@@ -45,31 +47,35 @@ class DiceRewardConfig extends BaseModel
* 优先从共享缓存读取,保证多进程(如一键测试 worker能拿到最新配置与数据库一致
* @return array{list: array, by_tier: array, by_tier_grid: array, min_real_ev: float}
*/
public static function getCachedInstance(): array
public static function getCachedInstance(?int $deptId = null): array
{
$instance = Cache::get(self::CACHE_KEY_INSTANCE);
if ($deptId === null) {
$deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
}
$cacheKey = self::cacheKeyForDept($deptId);
$instance = Cache::get($cacheKey);
if ($instance !== null && is_array($instance)) {
self::$instance = $instance;
return $instance;
}
if (self::$instance !== null) {
return self::$instance;
}
self::refreshCache();
$instance = Cache::get(self::CACHE_KEY_INSTANCE);
self::$instance = is_array($instance) ? $instance : self::buildEmptyInstance();
return self::$instance;
self::refreshCache($deptId);
$instance = Cache::get($cacheKey);
return is_array($instance) ? $instance : self::buildEmptyInstance();
}
public static function getCachedList(): array
private static function cacheKeyForDept(int $deptId): string
{
$inst = self::getCachedInstance();
return self::CACHE_KEY_INSTANCE . ':' . $deptId;
}
public static function getCachedList(?int $deptId = null): array
{
$inst = self::getCachedInstance($deptId);
return $inst['list'] ?? [];
}
public static function getCachedById(int $id): ?array
public static function getCachedById(int $id, ?int $deptId = null): ?array
{
$list = self::getCachedList();
$list = self::getCachedList($deptId);
foreach ($list as $row) {
if (isset($row['id']) && (int) $row['id'] === $id) {
return $row;
@@ -79,11 +85,16 @@ class DiceRewardConfig extends BaseModel
}
/**
* 重新从数据库加载并写入缓存(按档位+权重抽 grid_number含 by_tier、by_tier_grid
* 按渠道从数据库加载并写入缓存(避免多渠道配置混在同一缓存键
*/
public static function refreshCache(): void
public static function refreshCache(?int $deptId = null): void
{
$list = (new self())->order('id', 'asc')->select()->toArray();
if ($deptId === null) {
$deptId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
}
$query = (new self())->order('id', 'asc');
ConfigScopeEditHelper::applyDeptIdWhere($query, $deptId);
$list = $query->select()->toArray();
$byTier = [];
$byTierGrid = [];
foreach ($list as $row) {
@@ -103,13 +114,16 @@ class DiceRewardConfig extends BaseModel
}
}
$minRealEv = empty($list) ? 0.0 : (float) min(array_column($list, 'real_ev'));
self::$instance = [
$instance = [
'list' => $list,
'by_tier' => $byTier,
'by_tier_grid' => $byTierGrid,
'min_real_ev' => $minRealEv,
];
Cache::set(self::CACHE_KEY_INSTANCE, self::$instance, self::CACHE_TTL);
Cache::set(self::cacheKeyForDept($deptId), $instance, self::CACHE_TTL);
if ($deptId === AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
self::$instance = $instance;
}
}
private static function buildEmptyInstance(): array
@@ -125,9 +139,9 @@ class DiceRewardConfig extends BaseModel
/**
* 按档位+色子点数取一条(用于 BIGWIN
*/
public static function getCachedByTierAndGridNumber(string $tier, int $gridNumber): ?array
public static function getCachedByTierAndGridNumber(string $tier, int $gridNumber, ?int $deptId = null): ?array
{
$inst = self::getCachedInstance();
$inst = self::getCachedInstance($deptId);
$byTierGrid = $inst['by_tier_grid'] ?? [];
$tierData = $byTierGrid[$tier] ?? [];
$row = $tierData[$gridNumber] ?? null;
@@ -143,9 +157,9 @@ class DiceRewardConfig extends BaseModel
/**
* 从缓存按档位取奖励列表(不含权重,仅配置)
*/
public static function getCachedByTier(string $tier): array
public static function getCachedByTier(string $tier, ?int $deptId = null): array
{
$inst = self::getCachedInstance();
$inst = self::getCachedInstance($deptId);
$byTier = $inst['by_tier'] ?? [];
return $byTier[$tier] ?? [];
}
@@ -155,10 +169,10 @@ class DiceRewardConfig extends BaseModel
* @param int $direction 0=顺时针, 1=逆时针
* @return array 每行含 id, grid_number, real_ev, tier, weight 等
*/
public static function getCachedByTierForDirection(string $tier, int $direction): array
public static function getCachedByTierForDirection(string $tier, int $direction, ?int $deptId = null): array
{
$list = self::getCachedByTier($tier);
$weightMap = DiceReward::getCachedByTierAndDirection($tier, $direction);
$list = self::getCachedByTier($tier, $deptId);
$weightMap = DiceReward::getCachedByTierAndDirection($tier, $direction, $deptId);
foreach ($list as $i => $row) {
$id = isset($row['id']) ? (int) $row['id'] : 0;
$list[$i]['weight'] = $weightMap[$id] ?? 1;
@@ -171,19 +185,27 @@ class DiceRewardConfig extends BaseModel
self::$instance = null;
}
private static function refreshCacheForModel($model): void
{
$deptId = AdminScopeHelper::normalizeRecordDeptId(
is_array($model) ? ($model['dept_id'] ?? null) : ($model->dept_id ?? null)
);
self::refreshCache($deptId);
}
public static function onAfterInsert($model): void
{
self::refreshCache();
self::refreshCacheForModel($model);
}
public static function onAfterUpdate($model): void
{
self::refreshCache();
self::refreshCacheForModel($model);
}
public static function onAfterDelete($model): void
{
self::refreshCache();
self::refreshCacheForModel($model);
}
public function searchGridNumberMinAttr($query, $value)

View File

@@ -4,7 +4,7 @@
// +----------------------------------------------------------------------
namespace app\dice\model\reward_config;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\DiceModel;
/**
* 权重配比测试记录模型
@@ -20,7 +20,7 @@ use plugin\saiadmin\basic\think\BaseModel;
* @property int|null $admin_id 执行测试的管理员ID
* @property string|null $create_time 创建时间
*/
class DiceRewardConfigRecord extends BaseModel
class DiceRewardConfigRecord extends DiceModel
{
protected $pk = 'id';

View File

@@ -7,7 +7,7 @@
namespace app\dice\model\reward_config_record;
use app\dice\model\play_record_test\DicePlayRecordTest;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\DiceModel;
use think\model\relation\HasMany;
/**
@@ -43,7 +43,7 @@ use think\model\relation\HasMany;
* @property int|null $admin_id 执行测试的管理员ID
* @property string|null $create_time 创建时间
*/
class DiceRewardConfigRecord extends BaseModel
class DiceRewardConfigRecord extends DiceModel
{
/** 状态:失败 */
public const STATUS_FAIL = -1;

View File

@@ -0,0 +1,640 @@
<?php
declare(strict_types=1);
namespace app\dice\service;
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward\DiceRewardLogic;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\reward\DiceReward;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\exception\ApiException;
use support\think\Db;
/**
* 渠道默认配置复制、补齐与关联删除
* 默认配置dept_id = 0与超管「默认配置模板」一致
*/
class DiceChannelConfigService
{
/** 需 (dept_id, id) 复合唯一的配置表 */
private const COMPOSITE_KEY_TABLES = [
'dice_config',
'dice_reward_config',
];
/** 从默认模板复制的配置表 */
private const CONFIG_TABLES = [
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_game',
];
/** 复制时必须保留主键 id非自增或固定 0-25 */
private const TABLES_KEEP_ID = [
'dice_config',
'dice_reward_config',
];
/** 可关联删除的业务表 */
private const RELATION_TABLES = [
'dice_config' => ['label' => '游戏键值配置', 'group' => 'configs'],
'dice_ante_config' => ['label' => '底注配置', 'group' => 'configs'],
'dice_lottery_pool_config' => ['label' => '彩金池配置', 'group' => 'configs'],
'dice_reward_config' => ['label' => '奖励索引配置', 'group' => 'configs'],
'dice_reward' => ['label' => '中奖概率(奖励对照)', 'group' => 'configs'],
'dice_game' => ['label' => '游戏管理', 'group' => 'configs'],
'dice_player' => ['label' => '玩家', 'group' => 'players'],
'dice_play_record' => ['label' => '抽奖记录', 'group' => 'records'],
'dice_play_record_test' => ['label' => '测试抽奖记录', 'group' => 'records'],
'dice_player_wallet_record' => ['label' => '钱包流水', 'group' => 'records'],
'dice_player_ticket_record' => ['label' => '票券记录', 'group' => 'records'],
'dice_reward_config_record' => ['label' => '权重测试记录', 'group' => 'records'],
];
/** 关联数据删除顺序:先删流水/记录,再删玩家,最后删配置 */
private const DELETE_TABLE_ORDER = [
'dice_play_record',
'dice_play_record_test',
'dice_player_wallet_record',
'dice_player_ticket_record',
'dice_reward_config_record',
'dice_player',
'dice_reward',
'dice_reward_config',
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_game',
];
/**
* 默认模板 dept_id 统一为 0并为固定 id 的配置表建立 (dept_id, id) 唯一约束
*/
public function ensureConfigCompositeKeys(): void
{
foreach (array_merge(self::CONFIG_TABLES, ['dice_reward']) as $table) {
if ($this->tableHasColumn($table, 'dept_id')) {
Db::table($table)->whereNull('dept_id')->update(['dept_id' => AdminScopeHelper::DEFAULT_TEMPLATE_DEPT]);
}
}
foreach (self::COMPOSITE_KEY_TABLES as $table) {
if (!$this->tableHasColumn($table, 'dept_id')) {
continue;
}
if (!$this->tableHasColumn($table, 'row_id')) {
if ($table === 'dice_reward_config') {
Db::execute(
'ALTER TABLE `dice_reward_config`'
. ' MODIFY `id` int(11) NOT NULL COMMENT \'ID\','
. ' ADD COLUMN `row_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT FIRST,'
. ' DROP PRIMARY KEY,'
. ' ADD PRIMARY KEY (`row_id`)'
);
} else {
Db::execute(
'ALTER TABLE `dice_config`'
. ' ADD COLUMN `row_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT FIRST,'
. ' DROP PRIMARY KEY,'
. ' ADD PRIMARY KEY (`row_id`)'
);
}
}
$indexes = Db::query("SHOW INDEX FROM `{$table}` WHERE Key_name = 'uk_dept_config'");
if (empty($indexes)) {
Db::execute("ALTER TABLE `{$table}` ADD UNIQUE KEY `uk_dept_config` (`dept_id`, `id`)");
}
}
$this->ensureDeptScopedUniqueIndexes();
}
/**
* 将全局唯一键改为按渠道 (dept_id, 业务键) 唯一,便于复制默认模板
*/
private function ensureDeptScopedUniqueIndexes(): void
{
if ($this->tableHasColumn('dice_lottery_pool_config', 'dept_id')) {
$old = Db::query("SHOW INDEX FROM `dice_lottery_pool_config` WHERE Key_name = 'dice_lottery_poll_config_unique'");
if (!empty($old)) {
Db::execute('ALTER TABLE `dice_lottery_pool_config` DROP INDEX `dice_lottery_poll_config_unique`');
}
$uk = Db::query("SHOW INDEX FROM `dice_lottery_pool_config` WHERE Key_name = 'uk_dept_name'");
if (empty($uk)) {
Db::execute('ALTER TABLE `dice_lottery_pool_config` ADD UNIQUE KEY `uk_dept_name` (`dept_id`, `name`)');
}
}
if ($this->tableHasColumn('dice_game', 'dept_id')) {
foreach (['uk_dice_game_code', 'uk_dice_game_key'] as $idx) {
$exists = Db::query("SHOW INDEX FROM `dice_game` WHERE Key_name = '{$idx}'");
if (!empty($exists)) {
Db::execute("ALTER TABLE `dice_game` DROP INDEX `{$idx}`");
}
}
$ukCode = Db::query("SHOW INDEX FROM `dice_game` WHERE Key_name = 'uk_dept_game_code'");
if (empty($ukCode)) {
Db::execute('ALTER TABLE `dice_game` ADD UNIQUE KEY `uk_dept_game_code` (`dept_id`, `game_code`)');
}
$ukKey = Db::query("SHOW INDEX FROM `dice_game` WHERE Key_name = 'uk_dept_game_key'");
if (empty($ukKey)) {
Db::execute('ALTER TABLE `dice_game` ADD UNIQUE KEY `uk_dept_game_key` (`dept_id`, `game_key`)');
}
}
if ($this->tableHasColumn('dice_reward', 'dept_id')) {
$old = Db::query("SHOW INDEX FROM `dice_reward` WHERE Key_name = 'uk_direction_grid_number'");
if (!empty($old)) {
Db::execute('ALTER TABLE `dice_reward` DROP INDEX `uk_direction_grid_number`');
}
$uk = Db::query("SHOW INDEX FROM `dice_reward` WHERE Key_name = 'uk_dept_direction_grid'");
if (empty($uk)) {
Db::execute('ALTER TABLE `dice_reward` ADD UNIQUE KEY `uk_dept_direction_grid` (`dept_id`, `direction`, `grid_number`)');
}
}
}
/**
* 将当前无 dept_id 的配置标记为默认模板(仅执行一次迁移)
*/
public function markLegacyConfigAsDefault(): int
{
$this->ensureConfigCompositeKeys();
$total = 0;
foreach (self::CONFIG_TABLES as $table) {
if (!$this->tableHasColumn($table, 'dept_id')) {
continue;
}
$total += $this->countByDept($table, AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
}
return $total;
}
/**
* 为单个渠道从默认模板复制配置(已存在则跳过)
*/
public function copyDefaultConfigToDept(int $deptId): array
{
if ($deptId <= 0) {
throw new ApiException('Invalid channel id');
}
$result = ['dept_id' => $deptId, 'copied' => [], 'skipped' => [], 'merged' => []];
foreach (self::CONFIG_TABLES as $table) {
if (!$this->tableHasColumn($table, 'dept_id')) {
continue;
}
if (in_array($table, self::TABLES_KEEP_ID, true)) {
$merged = $this->syncCompositeIdTableFromDefault($table, $deptId);
if ($merged > 0) {
$result['merged'][$table] = $merged;
} elseif ($this->countByDept($table, $deptId) > 0) {
$result['skipped'][] = $table;
}
continue;
}
if ($this->countByDept($table, $deptId) > 0) {
$result['skipped'][] = $table;
continue;
}
$rows = $this->defaultTemplateRows($table);
if (empty($rows)) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
unset($row['id'], $row['row_id'], $row['create_time'], $row['update_time'], $row['delete_time']);
$row['dept_id'] = $deptId;
Db::table($table)->insert($row);
}
$result['copied'][] = $table;
}
$this->ensureRewardReferenceForDept($deptId);
DiceRewardConfig::refreshCache($deptId);
$this->ensurePlayerDefaultPoolForDept($deptId);
$this->migratePlayersDefaultPoolToPlayerDefault($deptId);
return $result;
}
/**
* 为渠道补齐玩家默认彩金池 name=playerDefault权重复制自 default
*/
public function ensurePlayerDefaultPoolForDept(int $deptId): ?int
{
if (!$this->tableHasColumn('dice_lottery_pool_config', 'dept_id')) {
return null;
}
$exists = Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT)
->count();
if ($exists > 0) {
return (int) Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT)
->value('id');
}
$defaultRow = Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', 'default')
->find();
if (!$defaultRow) {
return null;
}
$defaultRow = (array) $defaultRow;
unset($defaultRow['id'], $defaultRow['row_id'], $defaultRow['create_time'], $defaultRow['update_time'], $defaultRow['delete_time']);
$defaultRow['name'] = DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT;
$defaultRow['remark'] = '默认';
$defaultRow['safety_line'] = 0;
$defaultRow['kill_enabled'] = 0;
$defaultRow['profit_amount'] = 0;
$defaultRow['dept_id'] = $deptId;
return (int) Db::table('dice_lottery_pool_config')->insertGetId($defaultRow);
}
/**
* 将仍关联 name=default 的玩家改为关联 playerDefault杀分逻辑仍用 default 池)
*/
public function migratePlayersDefaultPoolToPlayerDefault(int $deptId): int
{
if (!$this->tableHasColumn('dice_player', 'dept_id')
|| !$this->tableHasColumn('dice_player', 'lottery_config_id')) {
return 0;
}
$playerDefaultId = Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', DiceLotteryPoolConfig::NAME_PLAYER_DEFAULT)
->value('id');
if (!$playerDefaultId) {
return 0;
}
$defaultPoolId = Db::table('dice_lottery_pool_config')
->where('dept_id', $deptId)
->where('name', 'default')
->value('id');
if (!$defaultPoolId) {
return 0;
}
return Db::table('dice_player')
->where('dept_id', $deptId)
->where('lottery_config_id', (int) $defaultPoolId)
->update(['lottery_config_id' => (int) $playerDefaultId]);
}
/**
* 为全部渠道与默认模板补齐 playerDefault 奖池并迁移玩家关联
*/
public function ensurePlayerDefaultPoolsAllChannels(): array
{
$deptIds = [AdminScopeHelper::DEFAULT_TEMPLATE_DEPT];
foreach (SystemDept::column('id') as $id) {
$id = (int) $id;
if ($id > 0) {
$deptIds[] = $id;
}
}
$deptIds = array_values(array_unique($deptIds));
$summary = [];
foreach ($deptIds as $deptId) {
$summary[$deptId] = [
'pool_id' => $this->ensurePlayerDefaultPoolForDept($deptId),
'players_migrated' => $this->migratePlayersDefaultPoolToPlayerDefault($deptId),
];
}
return $summary;
}
/**
* 按业务 id 从默认模板补齐配置dice_config / dice_reward_config
*/
private function syncCompositeIdTableFromDefault(string $table, int $deptId): int
{
$templateRows = $this->defaultTemplateRows($table);
if (empty($templateRows)) {
return 0;
}
$inserted = 0;
foreach ($templateRows as $row) {
$row = (array) $row;
if (!isset($row['id'])) {
continue;
}
$businessId = $row['id'];
$exists = Db::table($table)->where('dept_id', $deptId)->where('id', $businessId)->count();
if ($exists > 0) {
continue;
}
unset($row['row_id'], $row['create_time'], $row['update_time'], $row['delete_time']);
$row['dept_id'] = $deptId;
Db::table($table)->insert($row);
$inserted++;
}
return $inserted;
}
/**
* 渠道已有奖励索引时,自动生成 dice_reward 对照表
*/
public function ensureRewardReferenceForDept(int $deptId): void
{
if ($deptId <= 0 || !$this->tableHasColumn('dice_reward', 'dept_id')) {
return;
}
if ($this->countByDept('dice_reward_config', $deptId) <= 0) {
return;
}
if ($this->countByDept('dice_reward', $deptId) > 0) {
return;
}
$logic = new DiceRewardLogic();
$logic->createRewardReferenceFromConfig($deptId);
}
/**
* 复制默认 dice_reward 到渠道
*/
public function copyDefaultRewardsToDept(int $deptId): void
{
$this->ensureRewardReferenceForDept($deptId);
if (!$this->tableHasColumn('dice_reward', 'dept_id')) {
return;
}
if ($this->countByDept('dice_reward', $deptId) > 0) {
return;
}
if ($this->countByDept('dice_reward_config', $deptId) > 0) {
return;
}
$rows = $this->defaultTemplateRows('dice_reward');
foreach ($rows as $row) {
$row = (array) $row;
unset($row['id'], $row['row_id']);
unset($row['create_time'], $row['update_time'], $row['delete_time']);
$row['dept_id'] = $deptId;
Db::table('dice_reward')->insert($row);
}
}
/**
* 为所有已有渠道补齐缺失配置
*/
public function syncAllChannelsFromDefault(): array
{
$deptIds = SystemDept::column('id');
$summary = [];
foreach ($deptIds as $deptId) {
$deptId = (int) $deptId;
if ($deptId <= 0) {
continue;
}
$summary[$deptId] = $this->copyDefaultConfigToDept($deptId);
}
$summary['_player_default_pools'] = $this->ensurePlayerDefaultPoolsAllChannels();
return $summary;
}
/**
* 修复已删除渠道 ID、无管理员关联的遗留数据归并到首个顶级渠道
*/
public function repairOrphanDeptReferences(): array
{
$validDeptIds = array_map('intval', SystemDept::column('id') ?: []);
if (empty($validDeptIds)) {
return [];
}
$rootDeptId = min($validDeptIds);
$stats = [];
$inList = implode(',', $validDeptIds);
$stats['sa_system_user'] = Db::execute(
"UPDATE sa_system_user SET dept_id = {$rootDeptId}
WHERE dept_id IS NOT NULL AND dept_id > 0 AND dept_id NOT IN ({$inList})"
);
$bizTables = [
'dice_player',
'dice_play_record',
'dice_play_record_test',
'dice_player_wallet_record',
'dice_player_ticket_record',
'dice_reward_config_record',
];
foreach ($bizTables as $table) {
if (!$this->tableHasColumn($table, 'dept_id')) {
continue;
}
$stats[$table . '_invalid_dept'] = Db::execute(
"UPDATE `{$table}` SET dept_id = {$rootDeptId}
WHERE dept_id IS NOT NULL AND dept_id > 0 AND dept_id NOT IN ({$inList})"
);
}
return $stats;
}
/**
* 根据管理员/玩家回填 dept_id
*/
public function backfillDataDeptId(): array
{
$stats = $this->repairOrphanDeptReferences();
if ($this->tableHasColumn('dice_player', 'dept_id') && $this->tableHasColumn('dice_player', 'admin_id')) {
$stats['dice_player'] = Db::execute(
'UPDATE dice_player p INNER JOIN sa_system_user u ON p.admin_id = u.id
SET p.dept_id = u.dept_id WHERE (p.dept_id IS NULL OR p.dept_id = 0) AND u.dept_id IS NOT NULL AND u.dept_id > 0'
);
}
$validDeptIds = SystemDept::column('id') ?: [];
if (!empty($validDeptIds) && $this->tableHasColumn('dice_player', 'dept_id')) {
$rootDeptId = (int) min($validDeptIds);
$stats['dice_player_legacy'] = Db::table('dice_player')
->where(function ($q) {
$q->whereNull('dept_id')->whereOr('dept_id', 0);
})
->update(['dept_id' => $rootDeptId]);
}
$stats = array_merge($stats, $this->backfillRecordDeptIdByPlayer('dice_play_record'));
$stats = array_merge($stats, $this->backfillRecordDeptIdByAdmin('dice_play_record'));
$stats = array_merge($stats, $this->backfillRecordDeptIdByPlayer('dice_player_wallet_record'));
$stats = array_merge($stats, $this->backfillRecordDeptIdByPlayer('dice_player_ticket_record'));
$stats = array_merge($stats, $this->backfillRecordDeptIdByAdmin('dice_play_record_test'));
if (!empty($validDeptIds) && $this->tableHasColumn('dice_play_record_test', 'dept_id')) {
$rootDeptId = (int) min($validDeptIds);
$stats['dice_play_record_test_legacy'] = Db::table('dice_play_record_test')
->where(function ($q) {
$q->whereNull('dept_id')->whereOr('dept_id', 0);
})
->update(['dept_id' => $rootDeptId]);
}
if ($this->tableHasColumn('dice_reward_config_record', 'dept_id')) {
$stats['dice_reward_config_record'] = Db::execute(
'UPDATE dice_reward_config_record r INNER JOIN sa_system_user u ON r.admin_id = u.id
SET r.dept_id = u.dept_id WHERE (r.dept_id IS NULL OR r.dept_id = 0) AND u.dept_id IS NOT NULL AND u.dept_id > 0'
);
}
return $stats;
}
/**
* 删除渠道前关联数据统计
*/
public function getDestroyPreview(array $deptIds): array
{
$items = [];
foreach ($deptIds as $deptId) {
$deptId = (int) $deptId;
if ($deptId <= 0) {
continue;
}
$dept = SystemDept::find($deptId);
$row = [
'dept_id' => $deptId,
'dept_name' => $dept ? $dept->name : '',
'user_count' => SystemUser::where('dept_id', $deptId)->count(),
'relations' => [],
];
foreach (self::RELATION_TABLES as $table => $meta) {
if (!$this->tableHasColumn($table, 'dept_id')) {
continue;
}
$count = $this->countByDept($table, $deptId);
if ($count > 0) {
$row['relations'][] = [
'table' => $table,
'label' => $meta['label'],
'group' => $meta['group'],
'count' => $count,
];
}
}
$items[] = $row;
}
return $items;
}
/**
* 删除渠道及勾选的关联数据
*
* @param array $deleteTables 要删除的表名列表
*/
public function destroyDeptWithRelations(int $deptId, array $deleteTables): void
{
if ($deptId <= 0) {
throw new ApiException('Invalid channel id');
}
$userCount = SystemUser::where('dept_id', $deptId)->count();
if ($userCount > 0) {
throw new ApiException('This channel has users, please delete or transfer them first');
}
$tablesToDelete = $this->sortTablesForDelete($deleteTables);
Db::startTrans();
try {
foreach ($tablesToDelete as $table) {
if (!$this->tableHasColumn($table, 'dept_id')) {
continue;
}
Db::table($table)->where('dept_id', $deptId)->delete();
}
Db::name('sa_system_role_dept')->where('dept_id', $deptId)->delete();
SystemDept::destroy($deptId, true);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw new ApiException('Channel delete failed: ' . $e->getMessage());
}
DiceRewardConfig::refreshCache($deptId);
DiceReward::refreshCache($deptId);
}
/**
* 按依赖顺序排列待删表(勾选顺序无关)
*
* @param array<int, string> $deleteTables
* @return array<int, string>
*/
private function sortTablesForDelete(array $deleteTables): array
{
$allowed = array_keys(self::RELATION_TABLES);
$picked = [];
foreach ($deleteTables as $table) {
if (is_string($table) && in_array($table, $allowed, true)) {
$picked[$table] = true;
}
}
$ordered = [];
foreach (self::DELETE_TABLE_ORDER as $table) {
if (isset($picked[$table])) {
$ordered[] = $table;
unset($picked[$table]);
}
}
foreach (array_keys($picked) as $table) {
$ordered[] = $table;
}
return $ordered;
}
/**
* @return array<int, array<string, mixed>>
*/
private function defaultTemplateRows(string $table): array
{
$templateId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
$rows = Db::table($table)->where('dept_id', $templateId)->select()->toArray();
if (!empty($rows)) {
return $rows;
}
return Db::table($table)->whereNull('dept_id')->select()->toArray();
}
private function backfillRecordDeptIdByPlayer(string $table): array
{
if (!$this->tableHasColumn($table, 'dept_id') || !$this->tableHasColumn($table, 'player_id')) {
return [];
}
return [
$table => Db::execute(
"UPDATE `{$table}` r INNER JOIN dice_player p ON r.player_id = p.id
SET r.dept_id = p.dept_id WHERE (r.dept_id IS NULL OR r.dept_id = 0) AND p.dept_id IS NOT NULL AND p.dept_id > 0"
),
];
}
private function backfillRecordDeptIdByAdmin(string $table): array
{
if (!$this->tableHasColumn($table, 'dept_id') || !$this->tableHasColumn($table, 'admin_id')) {
return [];
}
return [
$table => Db::execute(
"UPDATE `{$table}` r INNER JOIN sa_system_user u ON r.admin_id = u.id
SET r.dept_id = u.dept_id WHERE (r.dept_id IS NULL OR r.dept_id = 0) AND u.dept_id IS NOT NULL AND u.dept_id > 0"
),
];
}
private function countByDept(string $table, ?int $deptId): int
{
$query = Db::table($table);
if ($deptId === null || $deptId === AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
$templateId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
$query->where(function ($q) use ($templateId) {
$q->where('dept_id', $templateId)->whereOr(function ($sub) {
$sub->whereNull('dept_id');
});
});
} else {
$query->where('dept_id', $deptId);
}
return $query->count();
}
private function tableHasColumn(string $table, string $column): bool
{
try {
$fields = Db::getFields($table);
return isset($fields[$column]);
} catch (\Throwable $e) {
return false;
}
}
}

View File

@@ -18,6 +18,8 @@ class DiceLotteryPoolConfigValidate extends BaseValidate
*/
protected $rule = [
'name' => 'require',
'remark' => 'max:200',
'config_note' => 'max:500',
't1_weight' => 'require',
't2_weight' => 'require',
't3_weight' => 'require',
@@ -43,6 +45,8 @@ class DiceLotteryPoolConfigValidate extends BaseValidate
protected $scene = [
'save' => [
'name',
'remark',
'config_note',
't1_weight',
't2_weight',
't3_weight',
@@ -51,6 +55,8 @@ class DiceLotteryPoolConfigValidate extends BaseValidate
],
'update' => [
'name',
'remark',
'config_note',
't1_weight',
't2_weight',
't3_weight',

View File

@@ -6,6 +6,7 @@
// +----------------------------------------------------------------------
namespace app\dice\validate\player;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\basic\BaseValidate;
/**
@@ -17,7 +18,7 @@ class DicePlayerValidate extends BaseValidate
* 定义验证规则
*/
protected $rule = [
'username' => 'require',
'username' => 'require|unique:' . DicePlayer::class . ',username^dept_id',
'name' => 'require',
'phone' => 'require',
'password' => 'require',
@@ -30,6 +31,7 @@ class DicePlayerValidate extends BaseValidate
*/
protected $message = [
'username' => '用户名必须填写',
'username.unique' => 'PLAYER_USERNAME_DEPT_UNIQUE',
'name' => '昵称必须填写',
'phone' => '手机号必须填写',
'password' => '密码必须填写',

View File

@@ -6,11 +6,13 @@ return [
// 登录成功返回的连接地址前缀,如 https://127.0.0.1:6777
'login_url_base' => env('API_LOGIN_URL_BASE', 'https://127.0.0.1:6777'),
// 游戏地址,用于 /api/v1/getGameUrl 返回拼接 token
'game_url' => env('GAME_URL', 'dice-game.h55555game.top'),
'game_url' => env('GAME_URL', 'dice-v3-game.h55555game.top'),
// 按 username 存储的登录会话 Redis key 前缀,用于 token 中间件校验
'session_username_prefix' => env('API_SESSION_USERNAME_PREFIX', 'api:user:session:'),
// 登录会话过期时间(秒),默认 7 天
'session_expire' => (int) env('API_SESSION_EXPIRE', 604800),
// 平台对接请求头 api-key/api/v1/* 必填,与客户端请求头 api-key 一致)
'platform_api_key' => env('API_KEY', ''),
// auth-token 签名密钥(与客户端约定,用于 /api/authToken 的 signature 校验,必填)
'auth_token_secret' => env('API_AUTH_TOKEN_SECRET', ''),
// auth-token 时间戳允许误差(秒),防重放,默认 300 秒

View File

@@ -14,6 +14,7 @@
use Webman\Route;
use app\api\middleware\ApiAccessLogMiddleware;
use app\api\middleware\ApiKeyMiddleware;
use app\api\middleware\AuthTokenMiddleware;
use app\api\middleware\TokenMiddleware;
@@ -22,9 +23,10 @@ Route::group('/api/v1', function () {
Route::any('/authToken', [app\api\controller\v1\AuthTokenController::class, 'index']);
})->middleware([
ApiAccessLogMiddleware::class,
ApiKeyMiddleware::class,
]);
// 平台 v1 接口:需在请求头携带 auth-token
// 平台 v1 接口:需在请求头携带 api-key、auth-token
Route::group('/api/v1', function () {
Route::any('/getGameList', [app\api\controller\v1\GameController::class, 'getGameList']);
Route::any('/getGameHall', [app\api\controller\v1\GameController::class, 'getGameHall']);
@@ -36,6 +38,7 @@ Route::group('/api/v1', function () {
Route::any('/setPlayerWallet', [app\api\controller\v1\GameController::class, 'setPlayerWallet']);
})->middleware([
ApiAccessLogMiddleware::class,
ApiKeyMiddleware::class,
AuthTokenMiddleware::class,
]);

View File

@@ -0,0 +1,84 @@
<?php
/**
* 审计各渠道游戏配置是否已从默认模板实例化
* 用法php server/db/audit_channel_config.php [--fix]
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use app\dice\helper\AdminScopeHelper;
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\model\system\SystemDept;
use support\think\Db;
$fix = in_array('--fix', $argv ?? [], true);
$templateId = AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
$tables = [
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_game',
'dice_reward',
];
$templateCounts = [];
foreach ($tables as $table) {
$templateCounts[$table] = (int) Db::table($table)
->where(function ($q) use ($templateId) {
$q->where('dept_id', $templateId)->whereOr('dept_id', null);
})
->count();
}
$depts = SystemDept::where('id', '>', 0)->column('id');
echo "========== 渠道配置实例化审计 ==========\n";
echo "默认模板 dept_id={$templateId} 行数:\n";
foreach ($templateCounts as $table => $cnt) {
echo " {$table}: {$cnt}\n";
}
echo "\n";
$missing = [];
foreach ($depts as $deptId) {
$deptId = (int) $deptId;
if ($deptId <= 0) {
continue;
}
$issues = [];
foreach ($tables as $table) {
$expected = $templateCounts[$table];
if ($expected <= 0) {
continue;
}
$actual = (int) Db::table($table)->where('dept_id', $deptId)->count();
if ($actual < $expected) {
$issues[] = "{$table}: {$actual}/{$expected}";
}
}
if ($issues !== []) {
$missing[$deptId] = $issues;
echo "渠道 {$deptId} 不完整 → " . implode(', ', $issues) . "\n";
} else {
echo "渠道 {$deptId} OK\n";
}
}
if ($missing === []) {
echo "\n全部渠道配置已实例化。\n";
exit(0);
}
if (!$fix) {
echo "\n存在缺失。执行 php server/db/audit_channel_config.php --fix 可自动补齐。\n";
exit(1);
}
echo "\n开始补齐...\n";
$service = new DiceChannelConfigService();
$summary = $service->syncAllChannelsFromDefault();
foreach ($summary as $deptId => $info) {
$copied = implode(',', $info['copied_tables'] ?? []);
echo "渠道 {$deptId}: 新增表 [{$copied}] 补齐行 " . ($info['merged_rows'] ?? 0) . "\n";
}
echo "补齐完成,请重新运行审计确认。\n";

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use support\think\Db;
$depts = Db::table('sa_system_dept')->column('id');
array_unshift($depts, 0);
foreach ($depts as $d) {
$c = Db::table('dice_reward_config')->where('dept_id', $d)->count();
$r = Db::table('dice_reward')->where('dept_id', $d)->count();
$nonBig = Db::table('dice_reward_config')->where('dept_id', $d)->where('tier', '<>', 'BIGWIN')->count();
echo "dept {$d}: reward_config={$c}, non_bigwin={$nonBig}, reward={$r}\n";
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\ThinkOrm\ThinkOrm::start(null);
use support\think\Db;
foreach (['dice_config', 'dice_ante_config', 'dice_game', 'dice_lottery_pool_config'] as $table) {
echo "{$table} dept1123: " . Db::table($table)->where('dept_id', 1123)->count() . "\n";
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* 查询指定表/视图的类型BASE TABLE / VIEW
*
* 用法(在 server 目录执行):
* php db/debug_check_table_type.php bet_order_view
*/
require_once __DIR__ . '/../vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(dirname(__DIR__) . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
} else {
\Dotenv\Dotenv::createMutable(dirname(__DIR__))->load();
}
}
$table = $argv[1] ?? '';
$table = trim((string) $table);
if ($table === '') {
fwrite(STDERR, "Missing table name.\n");
exit(1);
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
"SELECT TABLE_NAME, TABLE_TYPE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :name"
);
$stmt->execute(['name' => $table]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
echo "NOT_FOUND\t{$table}\n";
exit(0);
}
echo $row['TABLE_NAME'] . "\t" . $row['TABLE_TYPE'] . "\n";

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/**
* 列出当前数据库中所有 *__view_backup 对象,用于排查宝塔备份报错。
*
* 用法(在 server 目录执行):
* php db/debug_list_view_backup_objects.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(dirname(__DIR__) . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
} else {
\Dotenv\Dotenv::createMutable(dirname(__DIR__))->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$sql = "SELECT TABLE_NAME, TABLE_TYPE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE '%\\_\\_view\\_backup'
ORDER BY TABLE_NAME";
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) {
echo "No *__view_backup objects found.\n";
exit(0);
}
foreach ($rows as $row) {
echo $row['TABLE_NAME'] . "\t" . $row['TABLE_TYPE'] . "\n";
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* 列出当前数据库中的 VIEW 及其 DEFINER用于排查宝塔备份“缺少表 xxx__view_backup”。
*
* 用法(在 server 目录执行):
* php db/debug_list_views_and_definers.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(dirname(__DIR__) . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
} else {
\Dotenv\Dotenv::createMutable(dirname(__DIR__))->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$sql = "SELECT TABLE_NAME, DEFINER, SECURITY_TYPE
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME";
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
if (!$rows) {
echo "No views found.\n";
exit(0);
}
foreach ($rows as $row) {
echo $row['TABLE_NAME'] . "\t" . ($row['DEFINER'] ?? '') . "\t" . ($row['SECURITY_TYPE'] ?? '') . "\n";
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use plugin\saiadmin\app\cache\UserInfoCache;
use support\think\Db;
$adminInfo = UserInfoCache::getUserInfo(123);
$logic = new DiceRewardConfigLogic();
$query = $logic->search([]);
AdminScopeHelper::applyConfigScope($query, $adminInfo, 0);
$_GET['limit'] = 200;
$_REQUEST['limit'] = 200;
$result = $logic->getList($query);
echo "limit=200 data count: " . count($result['data'] ?? []) . " total=" . ($result['total'] ?? 0) . "\n";
$bw = 0;
foreach ($result['data'] ?? [] as $row) {
if (($row['tier'] ?? '') === 'BIGWIN') {
$bw++;
echo "BIGWIN id={$row['id']} grid={$row['grid_number']}\n";
}
}
$query3 = $logic->search([]);
AdminScopeHelper::applyConfigScope($query3, $adminInfo, 0);
unset($_GET['limit'], $_REQUEST['limit']);
$result3 = $logic->getList($query3);
echo "default limit data count: " . count($result3['data'] ?? []) . "\n";
$bw3 = 0;
foreach ($result3['data'] ?? [] as $row) {
if (($row['tier'] ?? '') === 'BIGWIN') {
$bw3++;
}
}
echo "default limit BIGWIN: {$bw3}\n";
$_GET['saiType'] = 'all';
$_REQUEST['saiType'] = 'all';
$resultAll = $logic->getList($query);
echo "saiType=all count: " . (is_array($resultAll) ? count($resultAll) : 0) . "\n";
if (is_array($resultAll)) {
$bwAll = 0;
foreach ($resultAll as $row) {
if (($row['tier'] ?? '') === 'BIGWIN') {
$bwAll++;
}
}
echo "saiType=all BIGWIN: {$bwAll}\n";
}
echo "\nAll rows by id for dept 1123:\n";
$allRows = \support\think\Db::table('dice_reward_config')->where('dept_id', 1123)->order('id', 'asc')->select();
foreach ($allRows as $r) {
echo "id={$r['id']} tier={$r['tier']} grid={$r['grid_number']}\n";
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use plugin\saiadmin\app\cache\UserInfoCache;
$adminInfo = UserInfoCache::getUserInfo(123);
$logic = new DiceRewardConfigLogic();
// paginated default
$query = $logic->search([]);
AdminScopeHelper::applyConfigScope($query, $adminInfo, 0);
$page = $logic->getList($query);
echo "default paginate count=" . count($page['data'] ?? []) . " total=" . ($page['total'] ?? 0) . "\n";
// saiType all
$_GET['saiType'] = 'all';
$_REQUEST['saiType'] = 'all';
$query2 = $logic->search([]);
AdminScopeHelper::applyConfigScope($query2, $adminInfo, 0);
$all = $logic->getList($query2);
echo "saiType=all is_array=" . (is_array($all) ? 'yes' : 'no') . " count=" . (is_array($all) ? count($all) : 0) . "\n";
$nonBigwin = 0;
if (is_array($all)) {
foreach ($all as $row) {
if (($row['tier'] ?? '') !== 'BIGWIN') {
$nonBigwin++;
}
}
}
echo "non-BIGWIN rows for index tab: {$nonBigwin}\n";
// simulate dept_id=1123 explicit
unset($_GET['saiType'], $_REQUEST['saiType']);
$_GET['saiType'] = 'all';
$query3 = $logic->search([]);
AdminScopeHelper::applyConfigScope($query3, $adminInfo, 1123);
$all3 = $logic->getList($query3);
echo "dept_id=1123 saiType=all count=" . (is_array($all3) ? count($all3) : 0) . "\n";

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\app\cache\UserInfoCache;
use support\think\Db;
$adminInfo = UserInfoCache::getUserInfo(123);
echo "Raw DB count dept 1123: " . Db::table('dice_reward_config')->where('dept_id', 1123)->whereNull('delete_time')->count() . "\n";
$logic = new DiceRewardConfigLogic();
$query = $logic->search([]);
AdminScopeHelper::applyConfigScope($query, $adminInfo, 1123);
$sql = $query->fetchSql(true)->select();
echo "SQL: {$sql}\n";
$rows = $query->fetchSql(false)->select()->toArray();
echo "Model select count: " . count($rows) . "\n";
$model = new DiceRewardConfig();
$q2 = $model->where('dept_id', 1123)->order('id', 'asc');
$rows2 = $q2->select()->toArray();
echo "Direct model dept 1123: " . count($rows2) . "\n";
$tierCounts = Db::table('dice_reward_config')->where('dept_id', 1123)->group('tier')->column('count(*)', 'tier');
echo "Tier counts: " . json_encode($tierCounts, JSON_UNESCAPED_UNICODE) . "\n";
$idCounts = Db::query('SELECT id, COUNT(*) as c FROM dice_reward_config WHERE dept_id=1123 GROUP BY id HAVING c>1');
echo "Duplicate business ids in dept 1123: " . count($idCounts) . "\n";
if ($idCounts) {
print_r($idCounts);
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\app\cache\UserInfoCache;
use plugin\saiadmin\app\logic\system\SystemUserLogic;
use support\think\Db;
$uid = 123;
$user = Db::table('sa_system_user')->where('id', $uid)->find();
echo "DB user: " . json_encode($user, JSON_UNESCAPED_UNICODE) . "\n";
$logic = new SystemUserLogic();
$info = $logic->getUser($uid);
echo "getUser deptList: " . json_encode($info['deptList'] ?? null, JSON_UNESCAPED_UNICODE) . "\n";
echo "getUser dept_id: " . ($info['dept_id'] ?? 'null') . "\n";
$cached = UserInfoCache::getUserInfo($uid);
echo "cache deptList: " . json_encode($cached['deptList'] ?? null, JSON_UNESCAPED_UNICODE) . "\n";
echo "getDeptId: " . var_export(AdminScopeHelper::getDeptId($cached), true) . "\n";
echo "resolveConfigDeptId(null): " . AdminScopeHelper::resolveConfigDeptId($cached, null) . "\n";
echo "resolveConfigDeptId(0): " . AdminScopeHelper::resolveConfigDeptId($cached, 0) . "\n";
$deptId = 1123;
$all = Db::table('dice_reward_config')->where('dept_id', $deptId)->count();
$bigwin = Db::table('dice_reward_config')->where('dept_id', $deptId)->where('tier', 'BIGWIN')->count();
$reward = Db::table('dice_reward')->where('dept_id', $deptId)->count();
echo "dept {$deptId}: reward_config={$all}, BIGWIN={$bigwin}, dice_reward={$reward}\n";
$sample = Db::table('dice_reward_config')->where('dept_id', $deptId)->limit(3)->select();
echo "sample reward_config: " . json_encode($sample, JSON_UNESCAPED_UNICODE) . "\n";

View File

@@ -0,0 +1,26 @@
-- 渠道扁平化:将子渠道用户归并到顶级渠道,删除子渠道,更新表注释
-- 执行前请备份数据库
-- 1. 表及字段注释改为「渠道」
ALTER TABLE `sa_system_dept` COMMENT = '渠道表';
ALTER TABLE `sa_system_dept`
MODIFY COLUMN `parent_id` bigint(20) UNSIGNED NULL DEFAULT 0 COMMENT '父级ID扁平渠道固定为0',
MODIFY COLUMN `name` varchar(64) NOT NULL COMMENT '渠道名称',
MODIFY COLUMN `code` varchar(64) NULL DEFAULT NULL COMMENT '渠道编码',
MODIFY COLUMN `leader_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '渠道负责人ID';
-- 2. 菜单名称(按实际 id 调整id=5 为渠道管理菜单)
UPDATE `sa_system_menu` SET `name` = '渠道管理' WHERE `id` = 5 OR `name` LIKE '%渠道(部门)%' OR `name` = '部门管理';
-- 3. 将子渠道下的用户 dept_id 提升到顶级渠道(需配合 run_dept_flatten_channels.php 处理多级)
-- 以下为单级子渠道快速迁移parent_id != 0 的直接挂到父级)
UPDATE `sa_system_user` u
INNER JOIN `sa_system_dept` d ON u.dept_id = d.id AND d.parent_id > 0
INNER JOIN `sa_system_dept` p ON d.parent_id = p.id
SET u.dept_id = p.id;
-- 4. 删除所有子渠道parent_id > 0
DELETE FROM `sa_system_dept` WHERE `parent_id` > 0;
-- 5. 剩余渠道统一为顶级
UPDATE `sa_system_dept` SET `parent_id` = 0, `level` = '0';

View File

@@ -0,0 +1,33 @@
-- 抽奖流程图两个顶级外链菜单type=4点击新窗口打开 public/docs/flowcharts/*.html
-- 挂载位置与「后台操作指南」同级parent_id=0紧挨其下方sort 略小,列表按 sort 降序)
SET @now = NOW();
-- 移除旧的「抽奖流程说明」内嵌页菜单(若已安装)
SET @old_flow_menu_id = (
SELECT `id` FROM `sa_system_menu`
WHERE `path` = 'flowcharts' AND `component` = '/plugin/dice/flowcharts/index/index' AND `type` = 2
ORDER BY `id` ASC LIMIT 1
);
DELETE FROM `sa_system_role_menu` WHERE `menu_id` = @old_flow_menu_id;
DELETE FROM `sa_system_menu` WHERE `parent_id` = @old_flow_menu_id AND `type` = 3;
DELETE FROM `sa_system_menu` WHERE `id` = @old_flow_menu_id;
-- 1) 为何最终抽到该奖励
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`icon`,`sort`,`link_url`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT 0, '为何最终抽到该奖励', 'DiceFlowWhyReward', NULL, 4, 'dice_flow_why_reward', '', NULL, 'ri:question-answer-line', 4, '/docs/flowcharts/dice-为何抽到该奖励.html', 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `sa_system_menu`
WHERE `type` = 4 AND `link_url` = '/docs/flowcharts/dice-为何抽到该奖励.html'
);
-- 2) 后台如何配置中奖逻辑
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`icon`,`sort`,`link_url`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT 0, '后台如何配置中奖逻辑', 'DiceFlowAdminConfig', NULL, 4, 'dice_flow_admin_config', '', NULL, 'ri:settings-3-line', 3, '/docs/flowcharts/dice-后台中奖逻辑配置.html', 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `sa_system_menu`
WHERE `type` = 4 AND `link_url` = '/docs/flowcharts/dice-后台中奖逻辑配置.html'
);

View File

@@ -0,0 +1,3 @@
-- dice_play_record 新增备注T4 惩罚余额不足等场景)
ALTER TABLE `dice_play_record`
ADD COLUMN `remark` varchar(255) DEFAULT NULL COMMENT '备注(如惩罚格余额不足)' AFTER `reward_tier`;

View File

@@ -0,0 +1,10 @@
-- dice_player同一渠道内用户名唯一dept_id + username— 数据库根本约束
-- 执行前请备份;若存在重复数据需先清理后再执行
-- 推荐php db/run_dice_player_dept_username_unique.php
-- 移除仅按 username 的普通索引(若不存在可忽略报错)
-- ALTER TABLE `dice_player` DROP INDEX `idx_dice_player_username`;
-- 同一渠道下用户名唯一UNIQUE 为数据库层最终约束)
ALTER TABLE `dice_player`
ADD UNIQUE INDEX `uk_dice_player_dept_username` (`dept_id`, `username`);

View File

@@ -0,0 +1,2 @@
-- 已废弃:请使用 dice_player_dept_username_unique.sql同一渠道 dept_id + username 唯一)
-- 历史脚本保留说明,勿再单独执行本文件

View File

@@ -0,0 +1,23 @@
-- 奖励配置档位结算推荐配置T1-T5 推荐金额、按规则生成)按钮权限
-- 挂载在「奖励配置」菜单type=2slug 与 DiceRewardConfigController Permission 一致
SET @now = NOW();
SET @reward_menu_id = (
SELECT `id` FROM `sa_system_menu`
WHERE `type` = 2
AND (
`path` = 'reward_config'
OR `component` LIKE '%reward_config%'
)
ORDER BY `id` ASC
LIMIT 1
);
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @reward_menu_id, '档位结算推荐配置', '', 'dice:reward_config:index:tierRecommend', 3, '', '', '', 95, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE @reward_menu_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'dice:reward_config:index:tierRecommend' AND `type` = 3
);

View File

@@ -0,0 +1,92 @@
-- 大富翁游戏相关表增加 dept_id关联 sa_system_dept渠道表
ALTER TABLE `dice_ante_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_game`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_lottery_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_lottery_poll_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_lottery_pool_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_play_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_play_record_test`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_player`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
-- 同一渠道内用户名唯一(根本约束,新库初始化时执行;已有库请用 dice_player_dept_username_unique.sql
ALTER TABLE `dice_player`
ADD UNIQUE INDEX `uk_dice_player_dept_username` (`dept_id`, `username`);
ALTER TABLE `dice_player_ticket_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_player_wallet_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_reward`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_reward_config`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
ALTER TABLE `dice_reward_config_record`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
-- 从管理员归属回填玩家 dept_id
UPDATE `dice_player` p
INNER JOIN `sa_system_user` u ON p.admin_id = u.id
SET p.dept_id = u.dept_id
WHERE p.dept_id IS NULL AND u.dept_id IS NOT NULL AND u.dept_id > 0;
UPDATE `dice_play_record` r
INNER JOIN `dice_player` p ON r.player_id = p.id
SET r.dept_id = p.dept_id
WHERE r.dept_id IS NULL AND p.dept_id IS NOT NULL;
UPDATE `dice_play_record_test` r
INNER JOIN `dice_player` p ON r.player_id = p.id
SET r.dept_id = p.dept_id
WHERE r.dept_id IS NULL AND p.dept_id IS NOT NULL;
UPDATE `dice_player_ticket_record` r
INNER JOIN `dice_player` p ON r.player_id = p.id
SET r.dept_id = p.dept_id
WHERE r.dept_id IS NULL AND p.dept_id IS NOT NULL;
UPDATE `dice_player_wallet_record` r
INNER JOIN `dice_player` p ON r.player_id = p.id
SET r.dept_id = p.dept_id
WHERE r.dept_id IS NULL AND p.dept_id IS NOT NULL;
UPDATE `dice_reward_config_record` r
INNER JOIN `sa_system_user` u ON r.admin_id = u.id
SET r.dept_id = u.dept_id
WHERE r.dept_id IS NULL AND u.dept_id IS NOT NULL AND u.dept_id > 0;

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/**
* 解决宝塔面板备份偶发报错:
* “备份文件中缺少表: xxx__view_backup”
*
* 原因通常是:数据库存在 VIEW但宝塔的备份校验按“表”去匹配
* 或其内部会期望存在 xxx__view_backup 之类的“视图备份表”标记。
*
* 本脚本会:
* - 扫描当前库所有 VIEW
* - 为每个 VIEW 创建一个同名的备份表:<view_name>__view_backup
* - 在该表写入 SHOW CREATE VIEW 的结果(若权限不足则写入空串)
*
* 用法(在 server 目录执行):
* php db/fix_bt_backup_view_tables.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(dirname(__DIR__) . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
} else {
\Dotenv\Dotenv::createMutable(dirname(__DIR__))->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$dbName = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
if ($dbName === '' || $user === '') {
fwrite(STDERR, "Missing DB_NAME/DB_USER in .env\n");
exit(1);
}
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$views = $pdo
->query("SELECT TABLE_NAME FROM information_schema.VIEWS WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME")
->fetchAll(PDO::FETCH_COLUMN);
if (!$views) {
echo "No views found; nothing to do.\n";
exit(0);
}
$created = 0;
$updated = 0;
$failed = 0;
foreach ($views as $viewName) {
$viewName = (string) $viewName;
$backupTable = $viewName . '__view_backup';
if (!preg_match('/^[a-zA-Z0-9_]+$/', $backupTable)) {
$failed++;
echo "[skip] invalid name: {$backupTable}\n";
continue;
}
try {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `{$backupTable}` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`view_name` VARCHAR(255) NOT NULL,
`create_sql` LONGTEXT NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_view_name` (`view_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$created++;
} catch (Throwable $e) {
$failed++;
echo "[fail] create table {$backupTable}: " . $e->getMessage() . "\n";
continue;
}
$createSql = '';
try {
$stmt = $pdo->query("SHOW CREATE VIEW `{$viewName}`");
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : false;
if ($row) {
// SHOW CREATE VIEW 返回字段名可能是 "Create View" 或类似
foreach ($row as $k => $v) {
if (is_string($k) && stripos($k, 'create') !== false && is_string($v)) {
$createSql = $v;
break;
}
}
}
} catch (Throwable $e) {
// 权限不足也不阻断,只是留空,保证备份表存在
$createSql = '';
}
try {
$stmt = $pdo->prepare(
"INSERT INTO `{$backupTable}` (`view_name`, `create_sql`, `updated_at`)
VALUES (:view_name, :create_sql, :updated_at)
ON DUPLICATE KEY UPDATE
`create_sql` = VALUES(`create_sql`),
`updated_at` = VALUES(`updated_at`)"
);
$stmt->execute([
'view_name' => $viewName,
'create_sql' => $createSql,
'updated_at' => date('Y-m-d H:i:s'),
]);
$updated++;
echo "[ok] {$viewName} -> {$backupTable}\n";
} catch (Throwable $e) {
$failed++;
echo "[fail] upsert {$backupTable}: " . $e->getMessage() . "\n";
}
}
echo "Done. created={$created}, updated={$updated}, failed={$failed}\n";

View File

@@ -0,0 +1,93 @@
<?php
/**
* 排查 dice_player 删除报错:
* - 表结构
* - 外键引用情况
* - 实际 destroy 流程(不真的删,仅 dry-run 抓异常)
*
* 用法php server/db/inspect_player_destroy.php [<id1>,<id2>,...]
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use support\think\Db;
$config = config('database');
$default = $config['default'];
$conn = $config['connections'][$default];
$dbName = $conn['database'] ?? '';
echo "[DB] {$default} -> {$dbName}\n\n";
echo "--- dice_player columns ---\n";
$cols = Db::query("SHOW FULL COLUMNS FROM `dice_player`");
foreach ($cols as $c) {
echo str_pad((string)$c['Field'], 26) . ' | ' . str_pad((string)$c['Type'], 24) . ' | NULL=' . $c['Null'] . ' | Key=' . $c['Key'] . "\n";
}
echo "\n";
echo "--- referenced by foreign keys (other tables -> dice_player) ---\n";
$fks = Db::query("SELECT TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME = 'dice_player'", [$dbName]);
if (empty($fks)) {
echo " (none)\n";
} else {
foreach ($fks as $f) {
echo " {$f['TABLE_NAME']}.{$f['COLUMN_NAME']} -> {$f['REFERENCED_TABLE_NAME']}.{$f['REFERENCED_COLUMN_NAME']} [{$f['CONSTRAINT_NAME']}]\n";
}
}
echo "\n";
echo "--- top 3 dice_player rows ---\n";
$rows = Db::table('dice_player')->limit(3)->select()->toArray();
foreach ($rows as $r) {
echo "id={$r['id']} dept_id={$r['dept_id']} username={$r['username']} delete_time=" . ($r['delete_time'] ?? 'null') . "\n";
}
echo "\n";
$idsArg = $argv[1] ?? '';
if ($idsArg === '') {
echo "(no ids passed, skip dry-run delete)\n";
return;
}
$ids = array_filter(array_map('intval', explode(',', $idsArg)));
if (empty($ids)) {
echo "(invalid ids)\n";
return;
}
echo "--- dry-run destroy ids: " . implode(',', $ids) . " ---\n";
$beforeAll = Db::query('SELECT id, delete_time FROM dice_player WHERE id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')', $ids);
echo "before (raw): " . count($beforeAll) . " rows\n";
$placeholders = implode(',', array_fill(0, count($ids), '?'));
// Case A: static destroy
try {
Db::startTrans();
$result = \app\dice\model\player\DicePlayer::destroy($ids, true);
$afterAny = Db::query("SELECT COUNT(*) AS c FROM dice_player WHERE id IN ({$placeholders})", $ids);
echo "[static destroy] returned: " . var_export($result, true) . ", remaining raw rows: " . ($afterAny[0]['c'] ?? 'n/a') . "\n";
Db::rollback();
} catch (\Throwable $e) {
Db::rollback();
echo "EXCEPTION (static destroy): " . get_class($e) . ": " . $e->getMessage() . "\n";
}
// Case B: instance ->delete()
try {
Db::startTrans();
$instance = \app\dice\model\player\DicePlayer::find($ids[0]);
if ($instance) {
$r = $instance->delete();
$afterAny = Db::query("SELECT COUNT(*) AS c FROM dice_player WHERE id = ?", [$ids[0]]);
echo "[instance delete] returned: " . var_export($r, true) . ", remaining raw rows for id={$ids[0]}: " . ($afterAny[0]['c'] ?? 'n/a') . "\n";
} else {
echo "[instance delete] no instance found for id={$ids[0]}\n";
}
Db::rollback();
} catch (\Throwable $e) {
Db::rollback();
echo "EXCEPTION (instance delete): " . get_class($e) . ": " . $e->getMessage() . "\n";
}
echo "(both rolled back, no actual delete)\n";

View File

@@ -0,0 +1,35 @@
-- 从所有角色中移除以下运维菜单及其按钮权限
-- /safeguard/dict
-- /safeguard/attachment
-- /safeguard/database
-- /safeguard/server
-- /safeguard/cache
-- /safeguard/email-log
--
-- 推荐执行php db/run_remove_safeguard_ops_role_menus.php
-- 该脚本会按 route 动态匹配菜单及其子权限,并清理菜单缓存
-- 主菜单
DELETE rm FROM `sa_system_role_menu` rm
INNER JOIN `sa_system_menu` m ON rm.menu_id = m.id
WHERE m.component IN (
'/safeguard/dict',
'/safeguard/attachment',
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log'
);
-- 子按钮权限
DELETE rm FROM `sa_system_role_menu` rm
INNER JOIN `sa_system_menu` child ON rm.menu_id = child.id
INNER JOIN `sa_system_menu` parent ON child.parent_id = parent.id
WHERE parent.component IN (
'/safeguard/dict',
'/safeguard/attachment',
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log'
);

View File

@@ -0,0 +1,14 @@
-- 移除岗位Post相关数据表与菜单
-- 执行前请备份数据库
-- 删除岗位子菜单权限id: 41-47
DELETE FROM `sa_system_role_menu` WHERE `menu_id` IN (41, 42, 43, 44, 45, 46, 47);
DELETE FROM `sa_system_menu` WHERE `id` IN (41, 42, 43, 44, 45, 46, 47);
-- 删除岗位管理主菜单id: 7
DELETE FROM `sa_system_role_menu` WHERE `menu_id` = 7;
DELETE FROM `sa_system_menu` WHERE `id` = 7;
-- 删除用户岗位关联表与岗位信息表
DROP TABLE IF EXISTS `sa_system_user_post`;
DROP TABLE IF EXISTS `sa_system_post`;

191
server/db/run_all_init.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
/**
* 一键执行渠道与游戏配置初始化(需已备份数据库)
* 用法:在 server 目录执行 php db/run_all_init.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
} else {
\Dotenv\Dotenv::createMutable(BASE_PATH)->load();
}
}
// 加载配置(排除 route.php避免 CLI 报错)
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemUser;
use support\think\Db;
function cliPdo(): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
return new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
}
function runSqlFile(PDO $pdo, string $path, string $label, bool $alterOnly = false): void
{
echo "\n=== {$label} ===\n";
if (!is_file($path)) {
echo "跳过:文件不存在\n";
return;
}
$sql = file_get_contents($path);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
$ok = 0;
$skip = 0;
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
if ($alterOnly && stripos($statement, 'ALTER TABLE') !== 0) {
continue;
}
try {
$pdo->exec($statement);
$ok++;
} catch (PDOException $e) {
$msg = $e->getMessage();
$isAlter = stripos($statement, 'ALTER TABLE') === 0;
if ($isAlter && (stripos($msg, 'Duplicate column') !== false
|| stripos($msg, 'Duplicate key name') !== false)) {
$skip++;
} elseif (!$isAlter) {
echo " [警告] " . substr($msg, 0, 120) . "\n";
$skip++;
} else {
echo " [错误] {$msg}\n";
throw $e;
}
}
}
echo "完成:成功 {$ok}" . ($skip > 0 ? ",跳过 {$skip} 条(字段已存在)" : '') . "\n";
}
function getRootDeptId(int $deptId): ?int
{
$currentId = $deptId;
$visited = [];
while ($currentId > 0 && !isset($visited[$currentId])) {
$visited[$currentId] = true;
$dept = SystemDept::find($currentId);
if (!$dept) {
return null;
}
$parentId = (int) ($dept->parent_id ?? 0);
if ($parentId === 0) {
return $currentId;
}
$currentId = $parentId;
}
return $currentId > 0 ? $currentId : null;
}
echo "========== 大富翁渠道/配置初始化 ==========\n";
echo '数据库: ' . (getenv('DB_NAME') ?: '') . '@' . (getenv('DB_HOST') ?: '') . "\n";
$pdo = cliPdo();
runSqlFile($pdo, __DIR__ . '/dice_tables_add_dept_id.sql', '1. dice 表增加 dept_id', true);
runSqlFile($pdo, __DIR__ . '/dept_flatten_channels.sql', '2. 渠道扁平化 SQL');
echo "\n=== 3. 渠道扁平化 PHP多级用户归并 + 删除子渠道) ===\n";
Db::transaction(function () {
$users = SystemUser::where('dept_id', '>', 0)->select();
$moved = 0;
foreach ($users as $user) {
$deptId = (int) $user->dept_id;
$rootId = getRootDeptId($deptId);
if ($rootId !== null && $rootId !== $deptId) {
SystemUser::where('id', $user->id)->update(['dept_id' => $rootId]);
echo " 用户 {$user->id}: dept {$deptId} -> {$rootId}\n";
$moved++;
}
}
$childIds = SystemDept::where('parent_id', '>', 0)->column('id');
if (!empty($childIds)) {
SystemDept::destroy($childIds);
echo ' 已删除子渠道: ' . implode(',', $childIds) . "\n";
} else {
echo " 无子渠道需删除\n";
}
SystemDept::where('id', '>', 0)->update(['parent_id' => 0, 'level' => '0']);
echo " 用户归并 {$moved} 人,渠道扁平化完成\n";
});
$service = new DiceChannelConfigService();
echo "\n=== 3.5 配置表复合键与默认模板 dept_id=0 ===\n";
$service->ensureConfigCompositeKeys();
echo " dice_config / dice_reward_config: ok\n";
echo "\n=== 3.6 彩金池配置按渠道唯一dept_id + name ===\n";
try {
$indexes = Db::query("SHOW INDEX FROM `dice_lottery_pool_config` WHERE Key_name = 'dice_lottery_poll_config_unique'");
if (!empty($indexes)) {
Db::execute('ALTER TABLE `dice_lottery_pool_config` DROP INDEX `dice_lottery_poll_config_unique`');
echo " 已移除 name 全局唯一索引\n";
}
$uk = Db::query("SHOW INDEX FROM `dice_lottery_pool_config` WHERE Key_name = 'uk_dept_name'");
if (empty($uk)) {
Db::execute('ALTER TABLE `dice_lottery_pool_config` ADD UNIQUE KEY `uk_dept_name` (`dept_id`, `name`)');
echo " 已添加 uk_dept_name(dept_id, name)\n";
} else {
echo " uk_dept_name 已存在\n";
}
} catch (\Throwable $e) {
echo " 跳过: {$e->getMessage()}\n";
}
echo "\n=== 4. 将现有配置设为默认模板dept_id=0 ===\n";
$tables = [
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_reward',
'dice_game',
];
foreach ($tables as $table) {
try {
Db::table($table)->update(['dept_id' => 0]);
echo " {$table}: ok\n";
} catch (\Throwable $e) {
echo " {$table}: 跳过 ({$e->getMessage()})\n";
}
}
echo "\n=== 5. 为所有渠道从默认模板复制配置 ===\n";
$summary = $service->syncAllChannelsFromDefault();
foreach ($summary as $deptId => $info) {
$copied = implode(',', $info['copied'] ?? []) ?: '无';
$skipped = implode(',', $info['skipped'] ?? []) ?: '无';
echo " 渠道 {$deptId}: 复制 [{$copied}],跳过 [{$skipped}]\n";
}
echo "\n=== 6. 修复无效渠道并回填 dept_id ===\n";
echo " 无效渠道归并: ";
print_r($service->repairOrphanDeptReferences());
echo " 回填: ";
print_r($service->backfillDataDeptId());
$deptCount = SystemDept::count();
echo "\n当前顶级渠道数: {$deptCount}\n";
echo "========== 全部初始化完成 ==========\n";

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\service\DiceChannelConfigService;
$service = new DiceChannelConfigService();
echo "修复无效渠道引用...\n";
print_r($service->repairOrphanDeptReferences());
echo "\n回填 dept_id...\n";
print_r($service->backfillDataDeptId());
echo "完成。\n";

View File

@@ -0,0 +1,46 @@
<?php
/**
* 渠道配置初始化:默认模板 + 为已有渠道补齐配置 + 业务数据 dept_id 回填
* 用法:在 server 目录执行 php db/run_channel_config_init.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
$bootstrap = __DIR__ . '/../support/bootstrap.php';
if (is_file($bootstrap)) {
require_once $bootstrap;
}
use app\dice\service\DiceChannelConfigService;
use support\think\Db;
$service = new DiceChannelConfigService();
echo "1. 将 dept_id=0 的配置归为默认模板dept_id 置空)...\n";
echo " 若需将全部现有配置作为模板,请确认后手动执行 UPDATE ... SET dept_id=NULL\n";
foreach (
[
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_reward',
'dice_game',
] as $table
) {
try {
Db::table($table)->where('dept_id', 0)->update(['dept_id' => null]);
echo " {$table}: ok\n";
} catch (\Throwable $e) {
echo " {$table}: 跳过 ({$e->getMessage()})\n";
}
}
echo "2. 为所有渠道补齐默认配置...\n";
$summary = $service->syncAllChannelsFromDefault();
print_r($summary);
echo "3. 回填玩家及记录 dept_id...\n";
$stats = $service->backfillDataDeptId();
print_r($stats);
echo "完成。\n";

View File

@@ -0,0 +1,55 @@
<?php
/**
* 渠道扁平化迁移脚本(多级子渠道用户归并到顶级渠道后删除子渠道)
* 用法:在 server 目录下执行 php db/run_dept_flatten_channels.php
*/
require_once __DIR__ . '/../vendor/autoload.php';
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemUser;
use support\think\Db;
$bootstrap = __DIR__ . '/../support/bootstrap.php';
if (is_file($bootstrap)) {
require_once $bootstrap;
}
function getRootDeptId(int $deptId): ?int
{
$currentId = $deptId;
$visited = [];
while ($currentId > 0 && !isset($visited[$currentId])) {
$visited[$currentId] = true;
$dept = SystemDept::find($currentId);
if (!$dept) {
return null;
}
$parentId = (int) ($dept->parent_id ?? 0);
if ($parentId === 0) {
return $currentId;
}
$currentId = $parentId;
}
return $currentId > 0 ? $currentId : null;
}
Db::transaction(function () {
$users = SystemUser::where('dept_id', '>', 0)->select();
foreach ($users as $user) {
$deptId = (int) $user->dept_id;
$rootId = getRootDeptId($deptId);
if ($rootId !== null && $rootId !== $deptId) {
SystemUser::where('id', $user->id)->update(['dept_id' => $rootId]);
echo "用户 {$user->id} dept_id {$deptId} -> {$rootId}\n";
}
}
$childIds = SystemDept::where('parent_id', '>', 0)->column('id');
if (!empty($childIds)) {
SystemDept::destroy($childIds);
echo '已删除子渠道: ' . implode(',', $childIds) . "\n";
}
SystemDept::where('id', '>', 0)->update(['parent_id' => 0, 'level' => '0']);
echo "渠道扁平化完成\n";
});

View File

@@ -0,0 +1,99 @@
<?php
/**
* 安装两个抽奖流程图外链菜单,并授权超级管理员
* 用法(在 server 目录): php db/run_dice_flowcharts_menu.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\cache\UserMenuCache;
use support\think\Db;
function runSqlFile(PDO $pdo, string $path, string $label): void
{
echo "\n=== {$label} ===\n";
if (! is_file($path)) {
echo "跳过:文件不存在 {$path}\n";
return;
}
$sql = file_get_contents($path);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
$ok = 0;
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
$pdo->exec($statement);
$ok++;
}
echo "完成:执行 {$ok} 条语句\n";
}
function cliPdo(): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
return new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
}
echo "========== 抽奖流程图外链菜单安装 ==========\n";
echo '数据库: ' . (getenv('DB_NAME') ?: '') . '@' . (getenv('DB_HOST') ?: '') . "\n";
$pdo = cliPdo();
runSqlFile($pdo, __DIR__ . '/dice_flowcharts_menu.sql', '1. 外链菜单');
$menuIds = Db::name('sa_system_menu')
->where('type', 4)
->whereIn('link_url', [
'/docs/flowcharts/dice-为何抽到该奖励.html',
'/docs/flowcharts/dice-后台中奖逻辑配置.html',
])
->column('id');
if ($menuIds === [] || $menuIds === null) {
echo "错误:未找到流程图菜单\n";
exit(1);
}
$menuIds = array_map('intval', $menuIds);
echo "\n流程图菜单 ID: " . implode(', ', $menuIds) . "\n";
echo "\n=== 2. 授权超级管理员角色 ===\n";
$adminRoleIds = Db::name('sa_system_role')
->where('code', 'super_admin')
->column('id');
if ($adminRoleIds === [] || $adminRoleIds === null) {
$adminRoleIds = Db::name('sa_system_role')->where('id', 1)->column('id');
}
foreach ($adminRoleIds as $roleId) {
$roleId = (int) $roleId;
foreach ($menuIds as $menuId) {
$exists = Db::name('sa_system_role_menu')
->where('role_id', $roleId)
->where('menu_id', $menuId)
->count();
if ($exists > 0) {
continue;
}
Db::name('sa_system_role_menu')->insert([
'role_id' => $roleId,
'menu_id' => $menuId,
]);
}
echo "角色 {$roleId} 已关联流程图菜单\n";
}
UserMenuCache::clearMenuCache();
echo "\n已清理菜单缓存。请重新登录后台或刷新页面查看侧边栏。\n";
echo "点击菜单将在新窗口打开 /docs/flowcharts/*.html\n";

View File

@@ -0,0 +1,44 @@
<?php
/**
* 执行 dice_play_record 备注字段迁移
* 用法:在 server 目录执行 php db/run_dice_play_record_add_remark.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
} else {
\Dotenv\Dotenv::createMutable(BASE_PATH)->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$sqlFile = __DIR__ . '/dice_play_record_add_remark.sql';
$sql = file_get_contents($sqlFile);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
echo "执行: dice_play_record_add_remark.sql\n";
echo "数据库: {$db} @ {$host}\n\n";
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
$pdo->exec($statement);
echo "OK\n";
}
echo "\n完成。\n";

View File

@@ -0,0 +1,53 @@
<?php
/**
* 为 dice_player 增加 (dept_id, username) 唯一索引
* 用法:在 server 目录执行 php db/run_dice_player_dept_username_unique.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
} else {
\Dotenv\Dotenv::createMutable(BASE_PATH)->load();
}
}
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use support\think\Db;
echo "检查 (dept_id, username) 重复...\n";
$dupes = Db::query(
'SELECT dept_id, username, COUNT(*) AS c FROM dice_player
WHERE username IS NOT NULL AND username <> \'\'
GROUP BY dept_id, username HAVING c > 1 LIMIT 20'
);
if (!empty($dupes)) {
echo "存在重复记录,请先处理后再执行:\n";
print_r($dupes);
exit(1);
}
$indexes = Db::query("SHOW INDEX FROM `dice_player` WHERE Key_name = 'idx_dice_player_username'");
if (!empty($indexes)) {
Db::execute('ALTER TABLE `dice_player` DROP INDEX `idx_dice_player_username`');
echo "已删除 idx_dice_player_username\n";
}
$uk = Db::query("SHOW INDEX FROM `dice_player` WHERE Key_name = 'uk_dice_player_dept_username'");
if (empty($uk)) {
Db::execute(
'ALTER TABLE `dice_player` ADD UNIQUE INDEX `uk_dice_player_dept_username` (`dept_id`, `username`)'
);
echo "已创建 uk_dice_player_dept_username\n";
} else {
echo "uk_dice_player_dept_username 已存在,跳过\n";
}
echo "完成\n";

View File

@@ -0,0 +1,60 @@
<?php
/**
* 执行档位结算推荐配置菜单权限 SQL
* 用法:在 server 目录执行 php db/run_dice_reward_config_tier_recommend_menu.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
if (method_exists(\Dotenv\Dotenv::class, 'createUnsafeMutable')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
} else {
\Dotenv\Dotenv::createMutable(BASE_PATH)->load();
}
}
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$sqlFile = __DIR__ . '/dice_reward_config_tier_recommend_menu.sql';
$sql = file_get_contents($sqlFile);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
echo "执行: dice_reward_config_tier_recommend_menu.sql\n";
echo "数据库: {$db} @ {$host}\n\n";
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
$pdo->exec($statement);
}
$row = $pdo->query(
"SELECT id, parent_id, name, slug FROM sa_system_menu WHERE slug = 'dice:reward_config:index:tierRecommend' AND type = 3 LIMIT 1"
)->fetch(PDO::FETCH_ASSOC);
if ($row) {
echo "成功:已写入菜单权限\n";
echo " id={$row['id']} parent_id={$row['parent_id']} name={$row['name']} slug={$row['slug']}\n";
} else {
$parent = $pdo->query(
"SELECT id, name, path FROM sa_system_menu WHERE type = 2 AND (path = 'reward_config' OR component LIKE '%reward_config%') ORDER BY id ASC LIMIT 1"
)->fetch(PDO::FETCH_ASSOC);
if ($parent === false) {
echo "失败:未找到「奖励配置」父菜单 (type=2),请先创建 reward_config 菜单\n";
exit(1);
}
echo "警告:权限 slug 未查到(可能已存在但未插入)。父菜单: id={$parent['id']} path={$parent['path']}\n";
exit(1);
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* 为已有渠道补齐缺失的默认配置(不删除已有数据)
* 用法php db/run_fill_missing_channel_config.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
}
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\model\system\SystemDept;
echo "========== 补齐渠道缺失配置 ==========\n";
$service = new DiceChannelConfigService();
$service->ensureConfigCompositeKeys();
$summary = $service->syncAllChannelsFromDefault();
foreach ($summary as $deptId => $info) {
$copied = implode(',', $info['copied'] ?? []) ?: '无';
$merged = empty($info['merged']) ? '无' : json_encode($info['merged'], JSON_UNESCAPED_UNICODE);
$skipped = implode(',', $info['skipped'] ?? []) ?: '无';
echo "渠道 {$deptId}: 新增表 [{$copied}] 补齐行 {$merged} 跳过 [{$skipped}]\n";
}
echo "\n渠道数: " . SystemDept::count() . "\n完成。\n";

View File

@@ -0,0 +1,100 @@
<?php
/**
* 从所有角色中移除以下运维菜单及其按钮权限:
* /safeguard/dict
* /safeguard/attachment
* /safeguard/database
* /safeguard/server
* /safeguard/cache
* /safeguard/email-log
*
* 用法(在 server 目录): php db/run_remove_safeguard_ops_role_menus.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\cache\UserMenuCache;
use support\think\Db;
$targetRoutes = [
'/safeguard/dict',
'/safeguard/attachment',
'/safeguard/database',
'/safeguard/server',
'/safeguard/cache',
'/safeguard/email-log',
];
/**
* @param int[] $parentIds
* @return int[]
*/
function collectChildMenuIds(array $parentIds): array
{
if ($parentIds === []) {
return [];
}
$childIds = Db::name('sa_system_menu')
->whereIn('parent_id', $parentIds)
->column('id');
if ($childIds === []) {
return [];
}
$childIds = array_map('intval', $childIds);
$deeper = collectChildMenuIds($childIds);
return array_values(array_unique(array_merge($childIds, $deeper)));
}
echo "=== remove safeguard ops menus from all roles ===\n";
$rootMenuIds = Db::name('sa_system_menu')
->whereIn('component', $targetRoutes)
->column('id');
$rootMenuIds = array_map('intval', $rootMenuIds ?: []);
$childMenuIds = collectChildMenuIds($rootMenuIds);
$menuIds = array_values(array_unique(array_merge($rootMenuIds, $childMenuIds)));
if ($menuIds === []) {
echo "WARN: no menu matched target routes\n";
exit(0);
}
$menuRows = Db::name('sa_system_menu')
->whereIn('id', $menuIds)
->field('id, parent_id, name, component, slug, type')
->order('id', 'asc')
->select()
->toArray();
echo "Matched menus (" . count($menuRows) . "):\n";
foreach ($menuRows as $row) {
echo sprintf(
" - id=%d parent=%d type=%s component=%s slug=%s name=%s\n",
(int) ($row['id'] ?? 0),
(int) ($row['parent_id'] ?? 0),
(string) ($row['type'] ?? ''),
(string) ($row['component'] ?? ''),
(string) ($row['slug'] ?? ''),
(string) ($row['name'] ?? '')
);
}
$deleted = Db::name('sa_system_role_menu')->whereIn('menu_id', $menuIds)->delete();
echo "Deleted role-menu rows: {$deleted}\n";
$roleCount = Db::name('sa_system_role_menu')
->whereIn('menu_id', $menuIds)
->count();
echo "Remaining role-menu rows for these menus: {$roleCount}\n";
UserMenuCache::clearMenuCache();
echo "Cleared menu cache\n";
echo "Done.\n";

View File

@@ -0,0 +1,74 @@
<?php
/**
* 执行 sa_system_role 渠道隔离迁移,并为已有渠道复制默认角色、映射用户角色
*
* 用法(在 server 目录): php db/run_sa_system_role_dept_id.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\service\SystemRoleChannelService;
use support\think\Db;
function tableHasColumn(string $table, string $column): bool
{
try {
$fields = Db::getFields($table);
return isset($fields[$column]);
} catch (\Throwable $e) {
return false;
}
}
function indexExists(string $table, string $indexName): bool
{
$rows = Db::query("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
return !empty($rows);
}
echo "=== sa_system_role dept_id migration ===\n";
if (!tableHasColumn('sa_system_role', 'dept_id')) {
Db::execute(
"ALTER TABLE `sa_system_role`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属渠道ID0=默认模板' AFTER `id`"
);
echo "OK: ADD COLUMN dept_id\n";
} else {
echo "SKIP: dept_id column exists\n";
}
if (!indexExists('sa_system_role', 'idx_dept_id')) {
Db::execute('ALTER TABLE `sa_system_role` ADD INDEX `idx_dept_id` (`dept_id`)');
echo "OK: ADD INDEX idx_dept_id\n";
} else {
echo "SKIP: idx_dept_id exists\n";
}
Db::execute('UPDATE `sa_system_role` SET `dept_id` = 0 WHERE `id` > 1');
echo "OK: UPDATE template dept_id\n";
if (indexExists('sa_system_role', 'uk_slug')) {
Db::execute('ALTER TABLE `sa_system_role` DROP INDEX `uk_slug`');
echo "OK: DROP INDEX uk_slug\n";
} else {
echo "SKIP: uk_slug not found\n";
}
if (!indexExists('sa_system_role', 'uk_dept_code')) {
Db::execute('ALTER TABLE `sa_system_role` ADD UNIQUE KEY `uk_dept_code` (`dept_id`, `code`)');
echo "OK: ADD UNIQUE uk_dept_code\n";
} else {
echo "SKIP: uk_dept_code exists\n";
}
$service = new SystemRoleChannelService();
$sync = $service->syncAllChannelsFromDefault();
echo "Synced roles for channels: " . json_encode($sync, JSON_UNESCAPED_UNICODE) . "\n";
$mapped = $service->remapUserRolesToChannelRoles();
echo "Remapped user roles: {$mapped}\n";
echo "Done.\n";

View File

@@ -0,0 +1,58 @@
<?php
/**
* 仅执行渠道配置同步(步骤 4-6适用于已完成表结构迁移后
* 用法php db/run_sync_channel_config.php
*/
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
if (class_exists(\Dotenv\Dotenv::class) && is_file(BASE_PATH . '/.env')) {
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
}
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\model\system\SystemDept;
use support\think\Db;
echo "========== 渠道配置同步 ==========\n";
$service = new DiceChannelConfigService();
echo "调整配置表主键与默认模板 dept_id=0...\n";
$service->ensureConfigCompositeKeys();
$tables = ['dice_config', 'dice_ante_config', 'dice_lottery_pool_config', 'dice_reward_config', 'dice_reward', 'dice_game'];
echo "\n清理各渠道不完整配置后重新复制...\n";
$deptIds = SystemDept::column('id');
foreach ($deptIds as $deptId) {
$deptId = (int) $deptId;
if ($deptId <= 0) {
continue;
}
foreach ($tables as $table) {
try {
Db::table($table)->where('dept_id', $deptId)->delete();
} catch (\Throwable $e) {
}
}
}
echo "设为默认模板 (dept_id=0)...\n";
foreach ($tables as $table) {
Db::table($table)->whereNull('dept_id')->update(['dept_id' => 0]);
}
echo "从默认模板复制到各渠道...\n";
$summary = $service->syncAllChannelsFromDefault();
foreach ($summary as $deptId => $info) {
echo "渠道 {$deptId}: 复制 [" . (implode(',', $info['copied'] ?? []) ?: '无') . "] 跳过 [" . (implode(',', $info['skipped'] ?? []) ?: '无') . "]\n";
}
echo "\n回填 dept_id...\n";
print_r($service->backfillDataDeptId());
echo "\n渠道数: " . SystemDept::count() . "\n完成。\n";

View File

@@ -0,0 +1,107 @@
<?php
/**
* 安装「后台操作指南」菜单与权限,并授权给超级管理员角色
* 用法(在 server 目录): php db/run_system_admin_guide_menu.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\cache\UserMenuCache;
use support\think\Db;
function runSqlFile(PDO $pdo, string $path, string $label): void
{
echo "\n=== {$label} ===\n";
if (! is_file($path)) {
echo "跳过:文件不存在 {$path}\n";
return;
}
$sql = file_get_contents($path);
$sql = preg_replace('/--.*$/m', '', $sql);
$parts = array_filter(array_map('trim', explode(';', $sql)));
$ok = 0;
foreach ($parts as $statement) {
if ($statement === '') {
continue;
}
$pdo->exec($statement);
$ok++;
}
echo "完成:执行 {$ok} 条语句\n";
}
function cliPdo(): PDO
{
$host = getenv('DB_HOST') ?: '127.0.0.1';
$port = getenv('DB_PORT') ?: '3306';
$db = getenv('DB_NAME') ?: '';
$user = getenv('DB_USER') ?: '';
$pass = getenv('DB_PASSWORD') ?: '';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset=utf8mb4";
return new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
}
echo "========== 后台操作指南菜单安装 ==========\n";
echo '数据库: ' . (getenv('DB_NAME') ?: '') . '@' . (getenv('DB_HOST') ?: '') . "\n";
$pdo = cliPdo();
runSqlFile($pdo, __DIR__ . '/system_admin_guide_menu.sql', '1. 菜单与按钮权限');
$menuId = (int) Db::name('sa_system_menu')
->where('path', 'admin_guide')
->where('component', '/system/admin_guide/index')
->where('type', 2)
->value('id');
if ($menuId <= 0) {
echo "错误:未找到后台操作指南菜单\n";
exit(1);
}
$buttonIds = Db::name('sa_system_menu')
->where('parent_id', $menuId)
->where('type', 3)
->column('id');
$allMenuIds = array_values(array_unique(array_merge([$menuId], array_map('intval', $buttonIds ?: []))));
echo "\n=== 2. 授权超级管理员角色 ===\n";
$adminRoleIds = Db::name('sa_system_role')
->where('code', 'super_admin')
->column('id');
if ($adminRoleIds === [] || $adminRoleIds === null) {
$adminRoleIds = Db::name('sa_system_role')->where('id', 1)->column('id');
}
$inserted = 0;
foreach ($adminRoleIds as $roleId) {
$roleId = (int) $roleId;
foreach ($allMenuIds as $mid) {
$exists = Db::name('sa_system_role_menu')
->where('role_id', $roleId)
->where('menu_id', $mid)
->count();
if ($exists > 0) {
continue;
}
Db::name('sa_system_role_menu')->insert([
'role_id' => $roleId,
'menu_id' => $mid,
]);
$inserted++;
}
echo " 角色 {$roleId}:新增授权 {$inserted}\n";
}
UserMenuCache::clearMenuCache();
\plugin\saiadmin\app\cache\UserAuthCache::clear();
echo "\n菜单 ID: {$menuId}\n";
echo "按钮权限: " . implode(',', $allMenuIds) . "\n";
echo "已清除菜单缓存,请重新登录后台查看。\n";
echo "========== 安装完成 ==========\n";

View File

@@ -0,0 +1,85 @@
<?php
/**
* 为登录日志、操作日志补充 dept_id 字段并回填历史数据。
*
* 用法(在 server 目录): php db/run_system_log_dept_id.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use support\think\Db;
function systemLogTableHasColumn(string $table, string $column): bool
{
try {
$fields = Db::getFields($table);
return isset($fields[$column]);
} catch (\Throwable $e) {
return false;
}
}
function systemLogIndexExists(string $table, string $indexName): bool
{
$rows = Db::query("SHOW INDEX FROM `{$table}` WHERE Key_name = ?", [$indexName]);
return !empty($rows);
}
function addLogDeptColumn(string $table, string $afterColumn): void
{
if (!systemLogTableHasColumn($table, 'dept_id')) {
Db::execute(
"ALTER TABLE `{$table}`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '所属渠道ID' AFTER `{$afterColumn}`"
);
echo "OK: {$table} ADD COLUMN dept_id\n";
} else {
echo "SKIP: {$table} dept_id column exists\n";
}
if (!systemLogIndexExists($table, 'idx_dept_id')) {
Db::execute("ALTER TABLE `{$table}` ADD INDEX `idx_dept_id` (`dept_id`)");
echo "OK: {$table} ADD INDEX idx_dept_id\n";
} else {
echo "SKIP: {$table} idx_dept_id exists\n";
}
}
echo "=== system log dept_id migration ===\n";
addLogDeptColumn('sa_system_login_log', 'login_time');
addLogDeptColumn('sa_system_oper_log', 'request_data');
$loginByCreator = Db::execute(
"UPDATE `sa_system_login_log` l
INNER JOIN `sa_system_user` u ON u.id = l.created_by
SET l.dept_id = u.dept_id
WHERE (l.dept_id IS NULL OR l.dept_id = 0)
AND u.dept_id IS NOT NULL
AND u.dept_id > 0"
);
echo "OK: login log backfilled by created_by: {$loginByCreator}\n";
$loginByUsername = Db::execute(
"UPDATE `sa_system_login_log` l
INNER JOIN `sa_system_user` u ON u.username = l.username
SET l.dept_id = u.dept_id
WHERE (l.dept_id IS NULL OR l.dept_id = 0)
AND u.dept_id IS NOT NULL
AND u.dept_id > 0"
);
echo "OK: login log backfilled by username: {$loginByUsername}\n";
$operByUsername = Db::execute(
"UPDATE `sa_system_oper_log` o
INNER JOIN `sa_system_user` u ON u.username = o.username
SET o.dept_id = u.dept_id
WHERE (o.dept_id IS NULL OR o.dept_id = 0)
AND u.dept_id IS NOT NULL
AND u.dept_id > 0"
);
echo "OK: oper log backfilled by username: {$operByUsername}\n";
echo "Done.\n";

View File

@@ -0,0 +1,10 @@
-- 角色表按渠道隔离dept_id=0 为默认模板角色,各渠道拥有独立角色副本
ALTER TABLE `sa_system_role`
ADD COLUMN `dept_id` bigint(20) UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属渠道ID0=默认模板' AFTER `id`,
ADD INDEX `idx_dept_id` (`dept_id`);
UPDATE `sa_system_role` SET `dept_id` = 0 WHERE `dept_id` IS NULL OR `id` > 1;
ALTER TABLE `sa_system_role` DROP INDEX `uk_slug`;
ALTER TABLE `sa_system_role` ADD UNIQUE KEY `uk_dept_code` (`dept_id`, `code`);

View File

@@ -0,0 +1,21 @@
<?php
/**
* 为各渠道补齐三个默认代理角色,并清理多余角色(无用户绑定的)
* 用法: php db/sync_channel_default_roles.php
*/
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../support/bootstrap.php';
use plugin\saiadmin\app\service\SystemRoleChannelService;
$service = new SystemRoleChannelService();
echo 'Default role codes: ' . implode(', ', $service->getDefaultChannelRoleCodes()) . "\n";
$sync = $service->syncAllChannelsFromDefault();
echo json_encode($sync, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
$mapped = $service->remapUserRolesToChannelRoles();
echo "Remapped user roles: {$mapped}\n";
echo "Done.\n";

View File

@@ -0,0 +1,39 @@
-- 后台操作指南顶级菜单与权限
-- 说明挂载到顶级菜单parent_id=0内容来源 server/docs/ADMIN_GUIDE.md
SET @now = NOW();
-- 1) 创建后台操作指南顶级菜单type=2parent_id=0
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`icon`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT 0, '后台操作指南', 'AdminGuide', NULL, 2, 'admin_guide', '/system/admin_guide/index', NULL, 'ri:book-read-line', 5, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (
SELECT 1 FROM `sa_system_menu` WHERE `path` = 'admin_guide' AND `component` = '/system/admin_guide/index' AND `type` = 2
);
SET @admin_guide_menu_id = (
SELECT `id` FROM `sa_system_menu`
WHERE `path` = 'admin_guide' AND `component` = '/system/admin_guide/index' AND `type` = 2
ORDER BY `id` ASC LIMIT 1
);
-- 2) 创建按钮权限
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @admin_guide_menu_id, '数据列表', '', 'system:admin_guide:index:index', 3, '', '', '', 100, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'system:admin_guide:index:index' AND `type` = 3);
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @admin_guide_menu_id, '读取', '', 'system:admin_guide:index:read', 3, '', '', '', 100, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'system:admin_guide:index:read' AND `type` = 3);
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @admin_guide_menu_id, '编辑', '', 'system:admin_guide:index:edit', 3, '', '', '', 100, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'system:admin_guide:index:edit' AND `type` = 3);
INSERT INTO `sa_system_menu`
(`parent_id`,`name`,`code`,`slug`,`type`,`path`,`component`,`method`,`sort`,`is_iframe`,`is_keep_alive`,`is_hidden`,`is_fixed_tab`,`is_full_page`,`generate_id`,`generate_key`,`status`,`create_time`,`update_time`)
SELECT @admin_guide_menu_id, '保存', '', 'system:admin_guide:index:save', 3, '', '', '', 100, 2, 2, 2, 2, 2, 0, NULL, 1, @now, @now
WHERE NOT EXISTS (SELECT 1 FROM `sa_system_menu` WHERE `slug` = 'system:admin_guide:index:save' AND `type` = 3);

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\model\reward_config\DiceRewardConfig;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
$deptId = 1123;
$query = (new DiceRewardConfig())->order('id', 'asc');
AdminScopeHelper::applyConfigScope($query, null, $deptId);
$logic = new DiceRewardConfigLogic();
$result = $logic->getList($query);
echo 'keys: ' . implode(',', array_keys($result)) . "\n";
$data = $result['data'] ?? $result['records'] ?? [];
echo 'count: ' . (is_array($data) ? count($data) : 0) . "\n";

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\model\reward_config\DiceRewardConfig;
foreach ([0, 1123] as $deptId) {
$q1 = DiceRewardConfig::where('dept_id', $deptId);
echo "direct dept {$deptId}: " . $q1->count() . "\n";
$q2 = (new DiceRewardConfig())->order('id', 'asc');
AdminScopeHelper::applyConfigScope($q2, ['id' => 1], $deptId);
echo "super admin scoped dept {$deptId}: " . $q2->count() . "\n";
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require_once BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use plugin\saiadmin\app\model\system\SystemDept;
use support\think\Db;
$deptIds = SystemDept::column('id');
echo "渠道数: " . count($deptIds) . " [" . implode(',', $deptIds) . "]\n\n";
$tables = [
'dice_config',
'dice_ante_config',
'dice_lottery_pool_config',
'dice_reward_config',
'dice_reward',
'dice_game',
];
echo "=== 配置按 dept_id 统计 ===\n";
foreach ($tables as $table) {
$rows = Db::query("SELECT dept_id, COUNT(*) AS cnt FROM `{$table}` GROUP BY dept_id ORDER BY dept_id");
echo "{$table}:\n";
foreach ($rows as $r) {
$dept = $r['dept_id'] === null ? 'NULL' : (string) $r['dept_id'];
echo " dept_id={$dept}: {$r['cnt']}\n";
}
}
echo "\n=== 业务数据未回填 dept_id ===\n";
$biz = ['dice_player', 'dice_play_record', 'dice_play_record_test'];
foreach ($biz as $table) {
if (!Db::getFields($table)) {
continue;
}
$nullCnt = Db::table($table)->where(function ($q) {
$q->whereNull('dept_id')->whereOr('dept_id', 0);
})->count();
$total = Db::table($table)->count();
echo "{$table}: 未回填 {$nullCnt} / 总计 {$total}\n";
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
define('BASE_PATH', dirname(__DIR__));
require BASE_PATH . '/vendor/autoload.php';
\Dotenv\Dotenv::createUnsafeMutable(BASE_PATH)->load();
\Webman\Config::load(BASE_PATH . '/config', ['route', 'plugin']);
\Webman\ThinkOrm\ThinkOrm::start(null);
use app\dice\helper\AdminScopeHelper;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use plugin\saiadmin\app\cache\UserInfoCache;
$adminInfo = UserInfoCache::getUserInfo(123);
$logic = new DiceRewardConfigLogic();
$query = $logic->search([]);
AdminScopeHelper::applyConfigScope($query, $adminInfo, 1123);
$data = $query->order('id', 'asc')->select()->toArray();
echo 'controller-style all rows: ' . count($data) . PHP_EOL;
$nonBig = 0;
foreach ($data as $row) {
if (($row['tier'] ?? '') !== 'BIGWIN') {
$nonBig++;
}
}
echo 'index tab rows (non-BIGWIN): ' . $nonBig . PHP_EOL;
echo 'bigwin tab rows: ' . (count($data) - $nonBig) . PHP_EOL;

128
server/docs/ADMIN_GUIDE.md Normal file
View File

@@ -0,0 +1,128 @@
# 大富翁-使用说明指南
## 菜单简单介绍
### 工作台/统计页面:统计数据
![image.png](/docs/picture/guide_01.png)
### 角色管理:对角色的菜单权限设置
按等级设定,等级越低权限越少(不要出现上级角色没有的权限,子角色有)
避免方式:使用子角色创建下级角色,可以避免下级角色比上级角色操作权限更多的问题
![image.png](/docs/picture/guide_02.png)
这里设置角色的菜单以及按钮权限
![image.png](/docs/picture/guide_03.png)
### 彩金池配置:监听彩金池实时变化
可以实时监听彩金池累积金额的变化
![image.png](/docs/picture/guide_04.png)
### 游戏配置:游戏规则和平台币转化比
游戏配置
![image.png](/docs/picture/guide_05.png)
其中游戏玩法为进入游戏的弹窗,和规则介绍(无特殊需求不需要大改)
![image.png](/docs/picture/guide_06.png)
游戏平台币兑换币为进入平台时平台比转化比比如当前设置的为11如果从jk8平台转入100那么获取的游戏币为100如果设置12则获取的平台币为200
### 底注配置:方便玩家快速调整压注倍率
底注配置
![image.png](/docs/picture/guide_07.png)
对应游戏中的其中每次游玩对局基础消耗为1游戏币无法修改底注的设置只是方便玩家快速修改压注金额
![image.png](/docs/picture/guide_08.png)
## 抽奖逻辑
### 判断抽奖档位
当前的抽奖逻辑时按照抽奖档位T1-T5进行抽奖在【玩家管理】菜单中的设置玩家具体的档位权重
![image.png](/docs/picture/guide_09.png)
也可以在【彩金池配置】菜单中设置玩家正常的抽奖档位权重
- 其中正常的档位权重为注册玩家默认绑定的档位权重,并且只有在修改完后,创建的玩家才能绑定最新的正常档位权重
![image.png](/docs/picture/guide_10.png)
- 其中free为杀分权重为如果当前彩金池平台盈利超过设置的安全线则制动走杀分的权重
![image.png](/docs/picture/guide_11.png)
- 剩余的两个权重可以方便快速切换用户的档位抽奖权重
![image.png](/docs/picture/guide_12.png)
![image.png](/docs/picture/guide_13.png)
### 根据档位抽取中奖号码
#### 设置中奖号码地图
在后台设置地图缩影
![image.png](/docs/picture/guide_14.png)
地图的索引参看如下
![image.png](/docs/picture/guide_15.png)
其中地图的索引可以按照需求点击图中的按规则生成
并且规则尽可能符合:结算金额>2 → T12>=结算金额>1 → T21>=结算金额>0 → T30>结算金额 → T4惩罚0=结算金额 → T5再来一次
![image.png](/docs/picture/guide_16.png)
![image.png](/docs/picture/guide_17.png)
#### 创建完地图索引后创建相应的奖励对照表
创建奖励对照表的原因是由于有每个号码的权重不一样豹子号10152025有多重组合方式所以需要设置奖励对照表中的权重配比
![image.png](/docs/picture/guide_18.png)
![image.png](/docs/picture/guide_19.png)
根据抽到的奖励档位抽取号码主要用于设置抽取豹子号的51015202530的权重
![image.png](/docs/picture/guide_20.png)
比如上图中如果不设置色子点数5抽到的概率和其他点数的概率是一样的可能抽7次T1奖励就有1次中豹子号5的可能
由于抽到色子点数和为10152025的色子点数组合有多种所以在抽该这四个点数时还需要单独配置相应的中大奖概率其中豹子号5和30只有一种组合【11111】和【66666】所以不需要配置其中权重拉到最大10000那么中奖概率为100%只要摇到了相应的色子点数和则中奖概率为100%
![image.png](/docs/picture/guide_21.png)
#### 扩展
- 测试设置的中奖概率,根据如下设置可以测试当前设置权重的中奖概率,该测试数据不记录到真实数据系统中
![image.png](/docs/picture/guide_22.png)
![image.png](/docs/picture/guide_23.png)
能够准确的反馈抽中点数的统计
![image.png](/docs/picture/guide_24.png)
如果当前中奖概率符合预期,或则测试多组数据选取一组符合预期的导入到当前的配置中
![image.png](/docs/picture/guide_25.png)
这里可以详情查询到指定测试记录的详情,
![image.png](/docs/picture/guide_26.png)

View File

@@ -26,7 +26,12 @@
- 请求头统一:
- `Content-Type: application/json`
- `Accept: application/json`
- `api-key: {api_key}`**所有 `/api/v1/*` 必传**,与服务端 `.env``API_KEY` 一致)
- `auth-token: {authtoken}`(除 `/api/v1/authToken` 外必传)
- `api-key` 携带方式(任选其一,按优先级读取,先命中即采用):
1. 请求头 `api-key`**推荐**
2. URL 查询参数 `api_key`(或 `api-key`
3. body 表单/JSON 字段 `api_key`(或 `api-key`
- 时间相关参数统一使用 Unix 时间戳(秒)
- 建议所有请求设置超时:连接超时 `3s`,读取超时 `10s`
- 生产环境建议增加调用方 IP 白名单和重试退避机制(避免瞬时重试风暴)
@@ -53,9 +58,9 @@
常见错误码:
- `400` 参数错误
- `401` 未携带 token
- `401` 未携带 `api-key``auth-token``token`
- `402` token 无效或过期
- `403` 签名或鉴权失败
- `403` `api-key` 无效、签名或鉴权失败
- `404` 资源不存在
- `422` 业务错误(如余额不足)
- `500` 服务端异常
@@ -64,11 +69,18 @@
## 4. 鉴权流程(平台级)
平台级凭证分两层:
- **`api-key`**:所有 `/api/v1/*` 接口必传,与服务端 `.env``API_KEY` 一致可通过请求头、query、body 任一方式携带(详见 §2.1)。
- **`auth-token`**:业务接口(除 `/api/v1/authToken` 外)必传,由 `/api/v1/authToken` 颁发。
`/api/v1/*` 接口调用前,先获取 `auth-token`
### 4.1 获取 auth-token
- 路径: `GET /api/v1/authToken`
- Header
- `api-key: {api_key}`(必传,与服务端 `.env``API_KEY` 一致)
- 鉴权参数Query
- `agent_id`:代理标识(商户标识)
- `secret`:双方约定密钥
@@ -113,11 +125,12 @@ const signature = crypto.createHash('md5').update(agentId + secret + time).diges
服务端校验逻辑(关键点):
- `api-key` 缺失即失败(`401`),与 `.env``API_KEY` 不一致即失败(`403`
- `agent_id/secret/time/signature` 任一缺失即失败(`400`
- `secret` 不匹配即失败(`403`
- `time` 超出容差窗口即失败(`403`,默认容差 `300s`
- `signature` 校验失败即失败(`403`
- 校验通过后颁发 `authtoken`,后续请求必须放在 Header `auth-token`
- 校验通过后颁发 `authtoken`,后续请求必须放在 Header `auth-token`(同时仍需带 `api-key`
防重放与时间同步建议:
@@ -140,14 +153,15 @@ const signature = crypto.createHash('md5').update(agentId + secret + time).diges
后续调用 `/api/v1/*` 时,请在 Header 携带:
```text
api-key: {api_key}
auth-token: {authtoken}
```
### 4.2 完整调用链(推荐)
1. 计算 `signature = md5(agent_id + secret + time)`
2. 调用 `GET /api/v1/authToken` 获取 `authtoken`
3. 在 Header 添加 `auth-token: {authtoken}`
2. 调用 `GET /api/v1/authToken`Header 携带 `api-key`获取 `authtoken`
3. 在 Header 添加 `api-key: {api_key}``auth-token: {authtoken}`
4. 调用业务接口(如 `getPlayerInfo``setPlayerWallet``getGameUrl``getPlayerGameRecord``getPlayerWalletRecord``getPlayerTicketRecord`
5. 若返回 `402`,重新获取 `authtoken` 后重试一次
@@ -155,12 +169,13 @@ auth-token: {authtoken}
## 5. 游戏相关接口
以下接口均需 Header: `auth-token`
以下接口均需 Header`api-key` + `auth-token``api-key` 也可放 query/body参见 §2.1
## 5.1 获取游戏列表(已支持)
- 路径: `POST /api/v1/getGameList`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `lang`(可选):`zh`/`en`,默认 `zh`
@@ -240,6 +255,7 @@ auth-token: {authtoken}
- 路径: `POST /api/v1/getGameHall`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `lang`(可选):`zh`/`en`,默认 `zh`
@@ -316,9 +332,11 @@ auth-token: {authtoken}
## 5.3 获取某个游戏地址(已支持)
- 路径: `POST /api/v1/getGameUrl`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `username`(必填):玩家账号(不存在会自动创建)
- `password`(可选):默认 `123456`
- `time`(可选):不传则服务端取当前时间戳
- `lang`(可选):`zh`/`en`,默认 `zh`
@@ -349,6 +367,7 @@ auth-token: {authtoken}
- 路径: `POST /api/v1/getPlayerGameRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `username`(可选):玩家账号;不传则**不按玩家筛选**(返回库内符合条件的记录,请谨慎使用)
@@ -396,7 +415,7 @@ auth-token: {authtoken}
## 7. 钱包相关接口
以下接口均需 Header: `auth-token`
以下接口均需 Header`api-key` + `auth-token``api-key` 也可放 query/body参见 §2.1
### 7.1 查询余额(已支持)
@@ -430,6 +449,7 @@ auth-token: {authtoken}
- 路径: `POST /api/v1/getPlayerWalletRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:
- `username`(可选):玩家账号;不传则**不按玩家筛选**
@@ -445,6 +465,7 @@ auth-token: {authtoken}
- 路径: `POST /api/v1/getPlayerTicketRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body 参数:与 **7.4** 相同(`username``start_create_time``end_create_time``limit`
- 返回说明:
@@ -462,7 +483,8 @@ auth-token: {authtoken}
- `provider``Dicey Fun`
- `provider_code``DF`
- `agent_id``5ef059938ba799aaa845e1c2e8a762bd`
- `secret`:签名密钥(双方约定)
- `secret`:签名密钥(双方约定,对应服务端 `.env``API_AUTH_TOKEN_SECRET`
- `api_key`:所有 `/api/v1/*` 请求必传的 `api-key`(对应服务端 `.env``API_KEY`
- `agent_token``[我来填]`(如需额外业务层 token
- `game_url`:游戏前端域名/地址
- `lobby_url`:大厅地址(可选)
@@ -482,8 +504,8 @@ auth-token: {authtoken}
## 10. 对接时序(建议)
1. 平台分配 `agent_id``secret`
2. 第三方调用 `/api/v1/authToken` 获取 `authtoken`
1. 平台分配 `agent_id``secret``api_key`
2. 第三方调用 `/api/v1/authToken`Header 携带 `api-key`获取 `authtoken`
3. 第三方调用 `/api/v1/getGameHall``/api/v1/getGameList` 获取大厅/游戏信息
4. 第三方调用 `/api/v1/getPlayerInfo`(可选,检查用户与余额)
5. 第三方调用 `/api/v1/setPlayerWallet` 进行额度转入(如有)
@@ -498,7 +520,8 @@ auth-token: {authtoken}
### 11.1 获取 auth-token
```bash
curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={agent_id}&secret={secret}&time={time}&signature={signature}'
curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={agent_id}&secret={secret}&time={time}&signature={signature}' \
--header 'api-key: {api_key}'
```
建议在接入测试时,先本地打印以下值再发请求,便于排查:
@@ -514,6 +537,7 @@ curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameUrl' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -526,6 +550,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameUrl' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"zh"
@@ -537,6 +562,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"en"
@@ -548,6 +574,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameHall' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"zh"
@@ -559,6 +586,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameHall' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/setPlayerWallet' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -571,6 +599,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/setPlayerWallet' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerInfo' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001"
@@ -582,6 +611,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerInfo' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerGameRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -594,6 +624,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerGameRecord
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerWalletRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -606,6 +637,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerWalletReco
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerTicketRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",

View File

@@ -26,7 +26,12 @@
- Unified headers:
- `Content-Type: application/json`
- `Accept: application/json`
- `api-key: {api_key}` (**Required for ALL `/api/v1/*` endpoints**, must match `API_KEY` in server `.env`)
- `auth-token: {authtoken}` (Required for all endpoints except `/api/v1/authToken`)
- `api-key` may be supplied in any of the following ways (read in priority order, first non-empty wins):
1. HTTP header `api-key` (**recommended**)
2. Query string `api_key` (or `api-key`)
3. Body form/JSON field `api_key` (or `api-key`)
- All time-related parameters use Unix timestamps (seconds)
- Recommended timeouts: connect timeout `3s`, read timeout `10s`
- Production recommendation: add caller IP whitelist and retry backoff (to avoid burst retry storms)
@@ -53,9 +58,9 @@ Notes:
Common error codes:
- `400` Invalid parameters
- `401` Missing token
- `401` Missing `api-key`, `auth-token` or `token`
- `402` Token invalid or expired
- `403` Signature or authentication failed
- `403` Invalid `api-key`, signature or authentication failed
- `404` Resource not found
- `422` Business error (e.g., insufficient balance)
- `500` Server exception
@@ -64,11 +69,18 @@ Common error codes:
## 4. Authentication Flow (Platform Level)
Two layers of platform-level credentials:
- **`api-key`**: Required for ALL `/api/v1/*` endpoints, must match `API_KEY` in server `.env`. May be sent in header, query, or body (see §2.1).
- **`auth-token`**: Required for business endpoints (i.e., everything except `/api/v1/authToken`); obtained from `/api/v1/authToken`.
Before calling any `/api/v1/*` endpoint, obtain an `auth-token` first.
### 4.1 Get auth-token
- Path: `GET /api/v1/authToken`
- Header:
- `api-key: {api_key}` (Required, must match `API_KEY` in server `.env`)
- Auth parameters (Query):
- `agent_id`: Agent identifier (merchant identifier)
- `secret`: Shared secret agreed by both parties
@@ -113,11 +125,12 @@ const signature = crypto.createHash('md5').update(agentId + secret + time).diges
Server-side validation logic (key points):
- Missing `api-key` => fail (`401`); `api-key` not equal to `.env` `API_KEY` => fail (`403`)
- Missing any of `agent_id/secret/time/signature` => fail (`400`)
- `secret` mismatch => fail (`403`)
- `time` outside tolerance window => fail (`403`, default tolerance `300s`)
- `signature` mismatch => fail (`403`)
- If validated, the server issues `authtoken`; subsequent requests must include it in the `auth-token` header
- If validated, the server issues `authtoken`; subsequent requests must include it in the `auth-token` header (and still carry `api-key`)
Anti-replay and time sync recommendations:
@@ -137,17 +150,18 @@ Success response example:
}
```
For subsequent calls to `/api/v1/*`, include the following header:
For subsequent calls to `/api/v1/*`, include the following headers:
```text
api-key: {api_key}
auth-token: {authtoken}
```
### 4.2 Full Call Chain (Recommended)
1. Compute `signature = md5(agent_id + secret + time)`
2. Call `GET /api/v1/authToken` to obtain `authtoken`
3. Add header `auth-token: {authtoken}`
2. Call `GET /api/v1/authToken` (Header `api-key`) to obtain `authtoken`
3. Add headers `api-key: {api_key}` and `auth-token: {authtoken}`
4. Call business endpoints (e.g., `getPlayerInfo`, `setPlayerWallet`, `getGameUrl`, `getPlayerGameRecord`, `getPlayerWalletRecord`, `getPlayerTicketRecord`)
5. If `402` is returned, re-fetch `authtoken` and retry once
@@ -155,12 +169,13 @@ auth-token: {authtoken}
## 5. Game APIs
All endpoints below require the `auth-token` header.
All endpoints below require headers `api-key` + `auth-token` (`api-key` may also be sent via query/body, see §2.1).
## 5.1 Get Game List (Supported)
- Path: `POST /api/v1/getGameList`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `lang` (optional): `zh`/`en`, default `zh`
@@ -240,6 +255,7 @@ Success example (`lang=en`):
- Path: `POST /api/v1/getGameHall`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `lang` (optional): `zh`/`en`, default `zh`
@@ -316,9 +332,11 @@ Success example (`lang=en`):
## 5.3 Get Game URL (Supported)
- Path: `POST /api/v1/getGameUrl`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `username` (required): Player username (auto-created if not exists)
- `password` (optional): default `123456`
- `time` (optional): if omitted, server uses current timestamp
- `lang` (optional): `zh`/`en`, default `zh`
@@ -349,6 +367,7 @@ An independent endpoint is provided: `POST /api/v1/getGameList`, supporting both
- Path: `POST /api/v1/getPlayerGameRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `username` (optional): Player username; if omitted, **no player filter** is applied (returns matching rows from the database—use with care)
@@ -396,7 +415,7 @@ This update introduces a game management table and menu to centrally manage basi
## 7. Wallet APIs
All endpoints below require the `auth-token` header.
All endpoints below require headers `api-key` + `auth-token` (`api-key` may also be sent via query/body, see §2.1).
### 7.1 Query Balance (Supported)
@@ -430,6 +449,7 @@ If the integrators wallet flow requires “return lobby URL after transfer”
- Path: `POST /api/v1/getPlayerWalletRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters:
- `username` (optional): Player username; if omitted, **no player filter** is applied
@@ -445,6 +465,7 @@ If the integrators wallet flow requires “return lobby URL after transfer”
- Path: `POST /api/v1/getPlayerTicketRecord`
- Header:
- `api-key: {api_key}`
- `auth-token: {authtoken}`
- Body parameters: Same as **7.4** (`username`, `start_create_time`, `end_create_time`, `limit`)
- Response notes:
@@ -462,7 +483,8 @@ It is recommended to configure the following fields in the integration parameter
- `provider`: `Dicey Fun`
- `provider_code`: `DF`
- `agent_id`: `5ef059938ba799aaa845e1c2e8a762bd`
- `secret`: Signature secret (shared by both parties)
- `secret`: Signature secret (shared by both parties, maps to server `.env` `API_AUTH_TOKEN_SECRET`)
- `api_key`: The `api-key` required by every `/api/v1/*` request (maps to server `.env` `API_KEY`)
- `agent_token`: `[to be filled by us]` (if an additional business-layer token is needed)
- `game_url`: Game frontend domain/URL
- `lobby_url`: Lobby URL (optional)
@@ -482,8 +504,8 @@ It is recommended to configure the following fields in the integration parameter
## 10. Integration Sequence (Recommended)
1. Platform assigns `agent_id` and `secret`
2. Third party calls `/api/v1/authToken` to obtain `authtoken`
1. Platform assigns `agent_id`, `secret` and `api_key`
2. Third party calls `/api/v1/authToken` (with header `api-key`) to obtain `authtoken`
3. Third party calls `/api/v1/getGameHall` or `/api/v1/getGameList` to obtain lobby/game info
4. Third party calls `/api/v1/getPlayerInfo` (optional, check user and balance)
5. Third party calls `/api/v1/setPlayerWallet` to credit in (if applicable)
@@ -498,7 +520,8 @@ It is recommended to configure the following fields in the integration parameter
### 11.1 Get auth-token
```bash
curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={agent_id}&secret={secret}&time={time}&signature={signature}'
curl --location --request GET 'https://{your-domain}/api/v1/authToken?agent_id={agent_id}&secret={secret}&time={time}&signature={signature}' \
--header 'api-key: {api_key}'
```
During integration testing, it is recommended to print the following values locally before sending the request to ease troubleshooting:
@@ -514,6 +537,7 @@ During integration testing, it is recommended to print the following values loca
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameUrl' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -526,6 +550,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameUrl' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"zh"
@@ -537,6 +562,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"en"
@@ -548,6 +574,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameList' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getGameHall' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"lang":"zh"
@@ -559,6 +586,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getGameHall' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/setPlayerWallet' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -571,6 +599,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/setPlayerWallet' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerInfo' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001"
@@ -582,6 +611,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerInfo' \
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerGameRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -594,6 +624,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerGameRecord
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerWalletRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",
@@ -606,6 +637,7 @@ curl --location --request POST 'https://{your-domain}/api/v1/getPlayerWalletReco
```bash
curl --location --request POST 'https://{your-domain}/api/v1/getPlayerTicketRecord' \
--header 'Content-Type: application/json' \
--header 'api-key: {api_key}' \
--header 'auth-token: {authtoken}' \
--data-raw '{
"username":"test_player_001",

View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>为何最终抽到该奖励</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
margin: 0;
padding: 24px 32px 48px;
background: #f5f7fa;
color: #1a1a2e;
line-height: 1.6;
}
header { max-width: 1100px; margin: 0 auto 16px; }
h1 { font-size: 1.5rem; margin: 0 0 8px; font-weight: 600; }
.subtitle { color: #5c6370; font-size: 0.95rem; margin: 0; }
.card {
max-width: 1100px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.copy-hint {
max-width: 1100px;
margin: 12px auto 0;
font-size: 0.88rem;
color: #606266;
}
.copy-box {
max-width: 1100px;
margin: 8px auto 0;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px 14px;
}
.copy-box summary { cursor: pointer; font-size: 0.9rem; color: #409eff; }
.copy-box pre {
margin: 10px 0 0;
font-size: 0.78rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
max-height: 280px;
overflow: auto;
color: #303133;
}
.legend {
max-width: 1100px;
margin: 20px auto 0;
padding: 16px 20px;
background: #fff;
border-radius: 12px;
font-size: 0.9rem;
color: #444;
}
.legend h2 { font-size: 1rem; margin: 0 0 10px; }
.legend ul { margin: 0; padding-left: 1.2em; }
.legend li { margin: 4px 0; }
.mermaid { display: flex; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>为何最终抽到的是这个奖励</h1>
<p class="subtitle">业务说明:一局抽奖从开局到到账的决策顺序(仅用本项目菜单与业务用语)</p>
</header>
<p class="copy-hint">复制方式展开下方「Mermaid 源码」全选复制,粘贴到 ProcessOn / draw.io / 飞书文档等支持 Mermaid 的流程图工具;或直接用浏览器打开本页看图。</p>
<div class="card">
<pre class="mermaid">
flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0
</pre>
</div>
<details class="copy-box">
<summary>Mermaid 源码(可复制,与同目录 .mmd 文件一致)</summary>
<pre id="mermaid-src">flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0</pre>
</details>
<div class="legend">
<h2>读图要点</h2>
<ul>
<li><strong>两步抽签</strong>:先抽档位 T1T5大奖 / 小赚 / 抽水 / 惩罚 / 再来一次),再在该档位 + 方向的多条奖励里按权重抽具体点数与结算金额。</li>
<li><strong>免费局与杀分局</strong>:都用杀分奖池的档位概率;一般不会出豹子大奖,也不会抽到只能组成豹子的点数 5、30。</li>
<li><strong>普通付费局</strong>:彩金池未到杀分条件时,用该玩家在「玩家管理」里的档位权重,才可能按「大奖权重」出豹子。</li>
<li><strong>玩家最终看到</strong>:色子点数、五颗骰子图案、到账平台币(普通奖 + 豹子奖)、是否获得「再来一次」免费券。</li>
</ul>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
flowchart: { curve: 'basis', padding: 16, nodeSpacing: 28, rankSpacing: 40 }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,44 @@
flowchart TD
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
Dir --> Ante[选择底注倍数]
Ante --> Type{本局是否使用免费抽奖券?}
Type -->|是| Free[免费局]
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
Free --> PoolKill[按「杀分奖池」的 T1T5 档位概率抽签]
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
KillCheck -->|是| PoolKill
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1T5 档位概率抽签]
PoolKill --> DrawTier[随机抽出档位 T1T5]
PlayerW --> DrawTier
DrawTier --> PickRow[在「色子奖励权重」中<br/>取该档位 + 本局方向的所有行<br/>按行权重随机一条]
PickRow --> Got[得到:色子点数、结算金额、所属档位、落点格位]
Got --> KillMode{本局是否走杀分档位概率?}
KillMode -->|是| NoLeo[不发放豹子大奖<br/>且不会抽到仅能豹子的点数 5、30]
KillMode -->|否| NormalPath[按普通规则继续]
NoLeo --> DiceShow[生成五颗骰子并结算]
NormalPath --> Leopard{色子点数是否为<br/>5 / 10 / 15 / 20 / 25 / 30?}
Leopard -->|否| NormalWin[五颗骰子点数和 = 该点数<br/>奖金 = 结算金额 × 底注]
Leopard -->|是| LeoRule{点数?}
LeoRule -->|5 或 30| MustBig[必定豹子大奖]
LeoRule -->|10 / 15 / 20 / 25| BigRate[按「奖励配置」页签「大奖权重」<br/>该点数权重决定真豹子或普通展示]
MustBig --> BigPay[豹子奖金 = 大奖结算金额 × 底注<br/>本局不再发该点数的普通奖]
BigRate -->|命中豹子| BigPay
BigRate -->|未中豹子| NonLeo[五颗骰子为非豹子组合<br/>奖金 = 结算金额 × 底注]
NormalWin --> T5Check
NonLeo --> T5Check
BigPay --> EndBig([本局结束:以豹子大奖为准])
DiceShow --> T5Check{档位为 T5 再来一次?}
T5Check -->|是| FreeTicket[赠送 1 次免费抽奖券<br/>下次免费局须相同底注]
T5Check -->|否| EndNormal([本局结束:以普通奖或惩罚为准])
FreeTicket --> EndNormal
style Start fill:#e8f4fc
style EndNormal fill:#e8fce8
style EndBig fill:#fff3e0

View File

@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>后台如何配置中奖逻辑</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
margin: 0;
padding: 24px 32px 48px;
background: #f0f4f8;
color: #1a1a2e;
line-height: 1.55;
}
header { max-width: 1200px; margin: 0 auto 16px; }
h1 { font-size: 1.45rem; margin: 0 0 6px; font-weight: 600; }
.subtitle { color: #5c6370; font-size: 0.92rem; margin: 0; }
.tip {
max-width: 1200px;
margin: 0 auto 16px;
padding: 12px 16px;
background: #fff8e6;
border-left: 4px solid #e6a23c;
border-radius: 6px;
font-size: 0.9rem;
}
.card {
max-width: 1200px;
margin: 0 auto 20px;
background: #fff;
border-radius: 12px;
padding: 24px 20px;
box-shadow: 0 2px 12px rgba(0,0,0,.06);
}
.card h2 { font-size: 1.05rem; margin: 0 0 12px; color: #303133; }
.copy-hint {
max-width: 1200px;
margin: 0 auto 12px;
font-size: 0.88rem;
color: #606266;
}
.copy-box {
max-width: 1200px;
margin: 8px auto 20px;
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px 14px;
}
.copy-box summary { cursor: pointer; font-size: 0.9rem; color: #409eff; }
.copy-box pre {
margin: 10px 0 0;
font-size: 0.76rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
max-height: 320px;
overflow: auto;
}
.steps {
max-width: 1200px;
margin: 0 auto;
display: grid;
gap: 10px;
}
.step {
background: #fff;
border-radius: 10px;
padding: 12px 16px;
border: 1px solid #e4e7ed;
font-size: 0.9rem;
}
.step strong { color: #409eff; }
.mermaid { display: flex; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>后台如何配置中奖逻辑</h1>
<p class="subtitle">按「一局真实抽奖」顺序:每个环节对应左侧菜单与页面按钮(与前台逻辑一致)</p>
</header>
<p class="tip">菜单根目录:<strong>大富翁-色子游戏</strong>。多渠道后台请先选顶部<strong>渠道</strong>,再改该渠道数据。</p>
<p class="copy-hint">复制展开「Mermaid 源码」粘贴到流程图工具;日常维护也可打开同目录 <code>dice-后台中奖逻辑配置.mmd</code></p>
<div class="card">
<h2>主流程图(抽奖环节 → 去哪点哪个按钮)</h2>
<pre class="mermaid">
flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8
</pre>
</div>
<details class="copy-box">
<summary>Mermaid 源码(可复制,与同目录 .mmd 文件一致)</summary>
<pre id="mermaid-src">flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8</pre>
</details>
<div class="card">
<h2>首次搭建推荐顺序(与上图环节对应)</h2>
<pre class="mermaid">
flowchart TD
O([开始配置]) --> R1[奖励配置 · 页签「奖励索引」· 按钮「保存」]
R1 --> R2[奖励配置 · 页签「大奖权重」· 按钮「保存」]
R2 --> R3[奖励配置 · 按钮「创建奖励对照」·「确认导入」]
R3 --> W[色子奖励权重 · 按钮「权重配比」· 按钮「提交」]
W --> P1[彩金池配置 · 行内「编辑」default / killScore ·「提交」]
P1 --> P2[彩金池配置 ·「查看当前彩金池」·「保存安全线」]
P2 --> PL[玩家管理 · 行内「编辑」· 档位权重 ·「提交」]
PL --> T{要仿真?}
T -->|是| Test[色子奖励权重 ·「一键测试权重」·「开始测试」]
Test --> Imp[权重测试记录 ·「查看详情」·「导入到当前配置」·「确认导入」]
T -->|否| Live([上线])
Imp --> Live
style O fill:#e8f4fc
style Live fill:#e8fce8
</pre>
</div>
<div class="steps">
<div class="step"><strong>档位含义</strong>T1 大奖 · T2 小赚 · T3 抽水 · T4 惩罚 · T5 再来一次(由「奖励索引」结算金额规则决定,见页内说明)。</div>
<div class="step"><strong>改「奖励索引」后</strong>:必须再点「创建奖励对照」→「确认导入」,否则抽奖仍用旧对照表。</div>
<div class="step"><strong>核对真实对局</strong>:大富翁-色子游戏 → 玩家抽奖记录(看奖励档位、色子点数、摇色子中奖平台币、中大奖平台币、底注、方向)。</div>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
flowchart: { curve: 'basis', padding: 14, nodeSpacing: 24, rankSpacing: 36 }
});
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
flowchart TD
O([按一局抽奖的真实顺序配置后台]) --> L1
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
L1A --> L2
L2[② 先随机抽出档位 T1T5] --> L2Q{本局类型?}
L2Q -->|免费抽奖券| L2F[概率来源:杀分奖池 killScore]
L2Q -->|付费且彩金池杀分生效| L2F
L2Q -->|付费且未杀分| L2P[概率来源:该玩家档位权重]
L2F --> M2F[大富翁-色子游戏 → 彩金池配置<br/>按钮:行内「编辑」→ 名称 killScore<br/>填写 T1池权重T5池权重 合计 100%<br/>按钮:「提交」]
L2P --> M2P[大富翁-色子游戏 → 玩家管理<br/>按钮:行内「编辑」<br/>填写 T1池权重T5池权重 或 选择「彩金池配置」<br/>按钮:「提交」]
M2F --> L2K
M2P --> L2K
L2K[杀分何时对付费局生效] --> M2K[大富翁-色子游戏 → 彩金池配置<br/>按钮:「查看当前彩金池」<br/>填写「安全线」· 开关「开启杀分」<br/>按钮:「保存安全线」]
M2K --> L3
L3[③ 在档位内随机一条奖励行] --> M3A[须先有盘面金额与档位规则]
M3A --> M3B[大富翁-色子游戏 → 奖励配置<br/>页签「奖励索引」→ 填写结算金额等<br/>按钮:「保存」]
M3B --> M3C[奖励配置 → 按钮「创建奖励对照」<br/>弹窗 → 按钮「确认导入」]
M3C --> M3D[大富翁-色子游戏 → 色子奖励权重<br/>按钮:「权重配比」→ 页签顺时针/逆时针<br/>按 T1T5 填各点数权重 → 按钮「提交」]
M3D --> L4
L4[④ 若抽到豹子点数 5/10/15/20/25/30] --> L4Q{本局是否杀分档位?}
L4Q -->|是| L4N[不触发豹子大奖]
L4Q -->|否| L4Y[可能触发豹子大奖]
L4Y --> M4[大富翁-色子游戏 → 奖励配置<br/>页签「大奖权重」→ 拖动权重滑条<br/>按钮:「保存」<br/>说明:点数 5、30 固定必中10/15/20/25 可调]
L4N --> L5
M4 --> L5
L5[⑤ 验证后上线] --> M5A[色子奖励权重 → 按钮「一键测试权重」<br/>弹窗 → 按钮「开始测试」]
M5A --> M5B[权重测试记录 → 按钮「查看详情」<br/>按钮「导入到当前配置」→「确认导入」]
M5B --> Done([可对玩家开放;用「玩家抽奖记录」核对])
style O fill:#e8f4fc
style Done fill:#e8fce8
style M2F fill:#fdf6ec
style M2P fill:#fdf6ec
style M3D fill:#fde2e2
style M4 fill:#e1f3d8

View File

@@ -27,7 +27,6 @@ class UserInfoCache
'expire' => 60 * 60 * 4,
'dept' => 'saiadmin:user_cache:dept_',
'role' => 'saiadmin:user_cache:role_',
'post' => 'saiadmin:user_cache:post_',
]);
}
@@ -73,11 +72,6 @@ class UserInfoCache
$tags[] = $cache['role'] . $role['id'];
}
}
if (!empty($data['postList'])) {
foreach ($data['postList'] as $post) {
$tags[] = $cache['post'] . $post['id'];
}
}
Cache::tag($tags)->set($cache['prefix'] . $uid, $data, $cache['expire']);
return $data;
}
@@ -125,21 +119,4 @@ class UserInfoCache
return Cache::tag($tags)->clear();
}
/**
* 清理岗位下所有用户缓存
*/
public static function clearUserInfoByPostId($post_id): bool
{
$cache = static::cacheConfig();
if (is_array($post_id)) {
$tags = [];
foreach ($post_id as $id) {
$tags[] = $cache['post'] . $id;
}
} else {
$tags = $cache['post'] . $post_id;
}
return Cache::tag($tags)->clear();
}
}

View File

@@ -0,0 +1,49 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\controller\system;
use plugin\saiadmin\app\logic\system\SystemAdminGuideLogic;
use plugin\saiadmin\basic\BaseController;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 后台操作指南控制器
*/
class SystemAdminGuideController extends BaseController
{
private SystemAdminGuideLogic $guideLogic;
public function __construct()
{
$this->guideLogic = new SystemAdminGuideLogic();
parent::__construct();
}
/**
* 读取后台操作指南 Markdown 内容
*/
#[Permission('后台操作指南读取', 'system:admin_guide:index:read')]
public function read(Request $request): Response
{
$data = $this->guideLogic->read();
return $this->success($data);
}
/**
* 保存后台操作指南 Markdown 内容
*/
#[Permission('后台操作指南保存', 'system:admin_guide:index:save')]
public function save(Request $request): Response
{
$content = $request->post('content', '');
if (! is_string($content)) {
return $this->fail('invalid content');
}
$data = $this->guideLogic->save($content);
return $this->success($data, 'save success');
}
}

View File

@@ -14,7 +14,7 @@ use support\Request;
use support\Response;
/**
* 部门控制器
* 渠道控制器
*/
class SystemDeptController extends BaseController
{
@@ -33,7 +33,7 @@ class SystemDeptController extends BaseController
* @param Request $request
* @return Response
*/
#[Permission('部门数据列表', 'core:dept:index')]
#[Permission('渠道数据列表', 'core:dept:index')]
public function index(Request $request) : Response
{
$where = $request->more([
@@ -50,7 +50,7 @@ class SystemDeptController extends BaseController
* @param Request $request
* @return Response
*/
#[Permission('部门数据读取', 'core:dept:read')]
#[Permission('渠道数据读取', 'core:dept:read')]
public function read(Request $request) : Response
{
$id = $request->input('id', '');
@@ -68,7 +68,7 @@ class SystemDeptController extends BaseController
* @param Request $request
* @return Response
*/
#[Permission('部门数据添加', 'core:dept:save')]
#[Permission('渠道数据添加', 'core:dept:save')]
public function save(Request $request): Response
{
$data = $request->post();
@@ -86,7 +86,7 @@ class SystemDeptController extends BaseController
* @param Request $request
* @return Response
*/
#[Permission('部门数据修改','core:dept:update')]
#[Permission('渠道数据修改','core:dept:update')]
public function update(Request $request): Response
{
$data = $request->post();
@@ -104,23 +104,59 @@ class SystemDeptController extends BaseController
* @param Request $request
* @return Response
*/
#[Permission('部门数据删除','core:dept:destroy')]
#[Permission('渠道数据删除','core:dept:destroy')]
public function destroy(Request $request) : Response
{
$ids = $request->post('ids', '');
// DELETE + JSON body 须用 input()post() 仅表单 POST 有效(与 SystemUserController 一致)
$ids = $request->input('ids', '');
if (empty($ids)) {
return $this->fail('please select data to delete');
}
$deleteTables = $request->input('delete_tables', []);
if (!is_array($deleteTables)) {
$deleteTables = [];
}
$idList = is_array($ids) ? $ids : explode(',', (string) $ids);
if (!empty($deleteTables)) {
foreach ($idList as $deptId) {
$this->logic->destroyWithRelations((int) $deptId, $deleteTables);
}
return $this->success('delete success');
}
$result = $this->logic->destroy($ids);
if ($result) {
return $this->success('delete success');
} else {
return $this->fail('delete failed');
}
return $this->fail('delete failed');
}
/**
* 可操作部门
* 删除渠道前关联数据预览
*/
#[Permission('渠道数据删除', 'core:dept:destroy')]
public function destroyPreview(Request $request): Response
{
$ids = $request->input('ids', '');
if ($ids === '' || $ids === null) {
return $this->fail('please select data');
}
$idList = is_array($ids) ? $ids : explode(',', (string) $ids);
$data = $this->logic->getDestroyPreview($idList);
return $this->success($data);
}
/**
* 为所有渠道补齐默认配置
*/
#[Permission('渠道数据修改', 'core:dept:update')]
public function syncChannelConfigs(Request $request): Response
{
$data = $this->logic->syncAllChannelConfigs();
return $this->success($data, 'sync success');
}
/**
* 可操作渠道
* @param Request $request
* @return Response
*/

View File

@@ -32,6 +32,7 @@ class SystemLogController extends BaseController
['username', ''],
['status', ''],
['ip', ''],
['dept_id', ''],
]);
$logic = new SystemLoginLogLogic();
$query = $logic->search($where);
@@ -71,6 +72,7 @@ class SystemLogController extends BaseController
['service_name', ''],
['router', ''],
['ip', ''],
['dept_id', ''],
]);
$logic = new SystemOperLogLogic();
$logic->init($this->adminInfo);

View File

@@ -1,177 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: sai <1430792918@qq.com>
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\controller\system;
use plugin\saiadmin\basic\BaseController;
use plugin\saiadmin\app\logic\system\SystemPostLogic;
use plugin\saiadmin\app\validate\system\SystemPostValidate;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 岗位信息控制器
*/
class SystemPostController extends BaseController
{
/**
* 构造
*/
public function __construct()
{
$this->logic = new SystemPostLogic();
$this->validate = new SystemPostValidate;
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('岗位数据列表', 'core:post:index')]
public function index(Request $request): Response
{
$where = $request->more([
['name', ''],
['code', ''],
['status', ''],
]);
$query = $this->logic->search($where);
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('岗位数据读取', 'core:post:read')]
public function read(Request $request): Response
{
$id = $request->input('id', '');
$model = $this->logic->read($id);
if ($model) {
$data = is_array($model) ? $model : $model->toArray();
return $this->success($data);
} else {
return $this->fail('not found');
}
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('岗位数据添加', 'core:post:save')]
public function save(Request $request): Response
{
$data = $request->post();
$this->validate('save', $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
} else {
return $this->fail('add failed');
}
}
/**
* 更新数据
* @param Request $request
* @return Response
*/
#[Permission('岗位数据修改', 'core:post:update')]
public function update(Request $request): Response
{
$data = $request->post();
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
if ($result) {
return $this->success('update success');
} else {
return $this->fail('update failed');
}
}
/**
* 删除数据
* @param Request $request
* @return Response
*/
#[Permission('岗位数据删除', 'core:post:destroy')]
public function destroy(Request $request): Response
{
$ids = $request->post('ids', '');
if (empty($ids)) {
return $this->fail('please select data to delete');
}
$result = $this->logic->destroy($ids);
if ($result) {
return $this->success('delete success');
} else {
return $this->fail('delete failed');
}
}
/**
* 导入数据
* @param Request $request
* @return Response
*/
#[Permission('岗位数据导入', 'core:post:import')]
public function import(Request $request): Response
{
$file = current($request->file());
if (!$file || !$file->isValid()) {
return $this->fail('uploaded file not found');
}
$this->logic->import($file);
return $this->success('import success');
}
/**
* 导出数据
* @param Request $request
* @return Response
*/
#[Permission('岗位数据导出', 'core:post:export')]
public function export(Request $request): Response
{
$where = $request->more([
['name', ''],
['code', ''],
['status', ''],
]);
return $this->logic->export($where);
}
/**
* 下载导入模板
* @return Response
*/
public function downloadTemplate(): Response
{
$file_name = "template.xlsx";
return downloadFile($file_name);
}
/**
* 可操作岗位
* @param Request $request
* @return Response
*/
public function accessPost(Request $request): Response
{
$where = ['status' => 1];
$data = $this->logic->accessPost($where);
return $this->success($data);
}
}

View File

@@ -6,10 +6,8 @@
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\controller\system;
use plugin\saiadmin\app\model\system\SystemUserRole;
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\basic\BaseController;
use plugin\saiadmin\app\cache\UserInfoCache;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\app\validate\system\SystemRoleValidate;
use plugin\saiadmin\app\logic\system\SystemRoleLogic;
use plugin\saiadmin\service\Permission;
@@ -17,13 +15,10 @@ use support\Request;
use support\Response;
/**
* 角色控制器
* 角色控制器(按渠道隔离)
*/
class SystemRoleController extends BaseController
{
/**
* 构造
*/
public function __construct()
{
$this->logic = new SystemRoleLogic();
@@ -31,11 +26,6 @@ class SystemRoleController extends BaseController
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('角色数据列表', 'core:role:index')]
public function index(Request $request): Response
{
@@ -44,19 +34,14 @@ class SystemRoleController extends BaseController
['code', ''],
['status', ''],
]);
$query = $this->logic->search($where);
$levelArr = array_column($this->adminInfo['roleList'], 'level');
$maxLevel = max($levelArr);
$query->where('level', '<', $maxLevel);
$data = $this->logic->getList($query);
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
$request->input('dept_id'),
$request->all()
);
$data = $this->logic->indexList($where, $requestDeptId);
return $this->success($data);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('角色数据读取', 'core:role:read')]
public function read(Request $request): Response
{
@@ -64,53 +49,49 @@ class SystemRoleController extends BaseController
$model = $this->logic->read($id);
if ($model) {
$data = is_array($model) ? $model : $model->toArray();
$role = $this->logic->model->find($id);
if ($role) {
$this->logic->assertRoleWritable($role);
}
return $this->success($data);
} else {
return $this->fail('not found');
}
return $this->fail('not found');
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('角色数据添加', 'core:role:save')]
public function save(Request $request): Response
{
$data = $request->post();
$data['dept_id'] = $this->logic->resolveRequestDeptId(
AdminScopeHelper::pickRequestDeptId($data['dept_id'] ?? null, $data)
);
$this->validate('save', $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('add success');
} else {
return $this->fail('add failed');
}
return $this->fail('add failed');
}
/**
* 更新数据
* @param Request $request
* @return Response
*/
#[Permission('角色数据修改', 'core:role:update')]
public function update(Request $request): Response
{
$data = $request->post();
$role = $this->logic->model->find($data['id'] ?? 0);
if ($role) {
$this->logic->assertRoleWritable($role);
if (!isset($data['dept_id']) || $data['dept_id'] === '' || $data['dept_id'] === null) {
$data['dept_id'] = $role->dept_id;
}
}
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
if ($result) {
return $this->success('update success');
} else {
return $this->fail('update failed');
}
return $this->fail('update failed');
}
/**
* 删除数据
* @param Request $request
* @return Response
*/
#[Permission('角色数据删除', 'core:role:destroy')]
public function destroy(Request $request): Response
{
@@ -121,16 +102,10 @@ class SystemRoleController extends BaseController
$result = $this->logic->destroy($ids);
if ($result) {
return $this->success('delete success');
} else {
return $this->fail('delete failed');
}
return $this->fail('delete failed');
}
/**
* 根据角色获取菜单
* @param Request $request
* @return Response
*/
#[Permission('角色数据列表', 'core:role:index')]
public function getMenuByRole(Request $request): Response
{
@@ -139,11 +114,6 @@ class SystemRoleController extends BaseController
return $this->success($data);
}
/**
* 菜单权限
* @param Request $request
* @return Response
*/
#[Permission('角色菜单权限', 'core:role:menu')]
public function menuPermission(Request $request): Response
{
@@ -153,16 +123,14 @@ class SystemRoleController extends BaseController
return $this->success('operation success');
}
/**
* 可操作角色
* @param Request $request
* @return Response
*/
public function accessRole(Request $request): Response
{
$where = ['status' => 1];
$data = $this->logic->accessRole($where);
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
$request->input('dept_id'),
$request->all()
);
$data = $this->logic->accessRole($where, $requestDeptId);
return $this->success($data);
}
}

View File

@@ -182,7 +182,6 @@ class SystemUserController extends BaseController
{
$data = $request->post();
unset($data['deptList']);
unset($data['postList']);
unset($data['roleList']);
$result = $this->logic->updateInfo($this->adminId, $data);
if ($result) {

View File

@@ -9,6 +9,7 @@ namespace plugin\saiadmin\app\event;
use plugin\saiadmin\app\cache\ReflectionCache;
use plugin\saiadmin\app\model\system\SystemLoginLog;
use plugin\saiadmin\app\model\system\SystemOperLog;
use plugin\saiadmin\app\model\system\SystemUser as SystemUserModel;
class SystemUser
{
@@ -32,6 +33,10 @@ class SystemUser
if (isset($item['admin_id'])) {
$data['created_by'] = $item['admin_id'];
$data['updated_by'] = $item['admin_id'];
$deptId = SystemUserModel::where('id', $item['admin_id'])->value('dept_id');
if ($deptId !== null && $deptId !== '' && $deptId > 0) {
$data['dept_id'] = $deptId;
}
}
SystemLoginLog::create($data);
}
@@ -49,6 +54,9 @@ class SystemUser
return false;
}
$info = getCurrentInfo();
if (!$info) {
return false;
}
$ip = $request->getRealIp();
$module = $request->plugin;
$rule = trim($request->uri());
@@ -60,6 +68,14 @@ class SystemUser
$data['ip'] = $ip;
$data['ip_location'] = self::getIpLocation($ip);
$data['request_data'] = $this->filterParams($request->all());
if (isset($info['dept_id']) && $info['dept_id'] > 0) {
$data['dept_id'] = $info['dept_id'];
} elseif (isset($info['id'])) {
$deptId = SystemUserModel::where('id', $info['id'])->value('dept_id');
if ($deptId !== null && $deptId !== '' && $deptId > 0) {
$data['dept_id'] = $deptId;
}
}
SystemOperLog::create($data);
return true;
}

View File

@@ -0,0 +1,72 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\logic\system;
use plugin\saiadmin\exception\ApiException;
/**
* 后台操作指南逻辑(读写 server/docs/ADMIN_GUIDE.md
*/
class SystemAdminGuideLogic
{
private const GUIDE_FILENAME = 'ADMIN_GUIDE.md';
/**
* 获取指南 Markdown 文件绝对路径
*/
public function getFilePath(): string
{
return base_path() . DIRECTORY_SEPARATOR . 'docs' . DIRECTORY_SEPARATOR . self::GUIDE_FILENAME;
}
/**
* 读取指南内容
* @return array{content: string, file_path: string, update_time: string|null}
*/
public function read(): array
{
$filePath = $this->getFilePath();
if (! is_file($filePath)) {
throw new ApiException('admin guide file not found');
}
$content = file_get_contents($filePath);
if ($content === false) {
throw new ApiException('failed to read admin guide file');
}
return [
'content' => $content,
'file_path' => 'docs/' . self::GUIDE_FILENAME,
'update_time' => date('Y-m-d H:i:s', filemtime($filePath)),
];
}
/**
* 保存指南内容到 Markdown 文件
* @param string $content
* @return array{content: string, file_path: string, update_time: string}
*/
public function save(string $content): array
{
$filePath = $this->getFilePath();
$dir = dirname($filePath);
if (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir)) {
throw new ApiException('failed to create docs directory');
}
$result = file_put_contents($filePath, $content, LOCK_EX);
if ($result === false) {
throw new ApiException('failed to save admin guide file');
}
clearstatcache(true, $filePath);
return [
'content' => $content,
'file_path' => 'docs/' . self::GUIDE_FILENAME,
'update_time' => date('Y-m-d H:i:s', filemtime($filePath)),
];
}
}

View File

@@ -2,129 +2,123 @@
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: sai <1430792918@qq.com>
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\logic\system;
use app\dice\service\DiceChannelConfigService;
use plugin\saiadmin\app\service\SystemRoleChannelService;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\utils\Helper;
use plugin\saiadmin\utils\Arr;
/**
* 部门逻辑层
* 渠道逻辑层(表 sa_system_dept
*/
class SystemDeptLogic extends BaseLogic
{
/**
* 构造函数
*/
public function __construct()
{
$this->model = new SystemDept();
}
/**
* 添加数据
*/
public function add($data): mixed
{
$data = $this->handleData($data);
$this->model->save($data);
return $this->model->getKey();
$deptId = (int) $this->model->getKey();
if ($deptId > 0) {
(new DiceChannelConfigService())->copyDefaultConfigToDept($deptId);
(new SystemRoleChannelService())->copyDefaultRolesToDept($deptId, false);
}
return $deptId;
}
/**
* 修改数据
*/
public function edit($id, $data): mixed
{
$oldLevel = $data['level'] . $id . ',';
$data = $this->handleData($data);
if ($data['parent_id'] == $id) {
throw new ApiException('Parent department cannot be the same as current department');
}
if (in_array($id, explode(',', $data['level']))) {
throw new ApiException('Cannot set parent department to a child of current department');
}
$newLevel = $data['level'] . $id . ',';
$deptIds = $this->model->where('level', 'like', $oldLevel . '%')->column('id');
return $this->transaction(function () use ($deptIds, $oldLevel, $newLevel, $data, $id) {
$this->model->whereIn('id', $deptIds)->exp('level', "REPLACE(level, '$oldLevel', '$newLevel')")->update([]);
return $this->model->update($data, ['id' => $id]);
});
return $this->model->update($data, ['id' => $id]);
}
/**
* 数据删除
*/
public function destroy($ids): bool
{
$num = $this->model->where('parent_id', 'in', $ids)->count();
if ($num > 0) {
throw new ApiException('This department has sub-departments, please delete them first');
} else {
$count = SystemUser::where('dept_id', 'in', $ids)->count();
if ($count > 0) {
throw new ApiException('This department has users, please delete or transfer them first');
}
return $this->model->destroy($ids);
$count = SystemUser::where('dept_id', 'in', $ids)->count();
if ($count > 0) {
throw new ApiException('This channel has users, please delete or transfer them first');
}
return $this->model->destroy($ids);
}
/**
* 数据处理
* 带关联选项删除渠道
*/
public function destroyWithRelations(int $deptId, array $deleteTables): bool
{
(new SystemRoleChannelService())->deleteRolesByDept($deptId);
(new DiceChannelConfigService())->destroyDeptWithRelations($deptId, $deleteTables);
return true;
}
public function getDestroyPreview(array $deptIds): array
{
return (new DiceChannelConfigService())->getDestroyPreview($deptIds);
}
public function syncAllChannelConfigs(): array
{
$config = (new DiceChannelConfigService())->syncAllChannelsFromDefault();
$roles = (new SystemRoleChannelService())->syncAllChannelsFromDefault();
return ['config' => $config, 'roles' => $roles];
}
protected function handleData($data)
{
// 处理上级部门
if (empty($data['parent_id']) || $data['parent_id'] == 0) {
$data['level'] = '0';
$data['parent_id'] = 0;
} else {
$parentMenu = SystemDept::findOrEmpty($data['parent_id']);
$data['level'] = $parentMenu['level'] . $parentMenu['id'] . ',';
}
$data['level'] = '0';
$data['parent_id'] = 0;
return $data;
}
/**
* 数据树形化
* @param array $where
* @return array
*/
public function tree(array $where = []): array
{
$query = $this->search($where);
$request = request();
if ($request && $request->input('tree', 'false') === 'true') {
$query->field('id, id as value, name as label, parent_id');
}
$query->order('sort', 'desc');
$query->with(['leader']);
$data = $this->getAll($query);
return Helper::makeTree($data);
return $this->getAll($query);
}
/**
* 可操作部门
* @param array $where
* @return array
*/
public function accessDept(array $where = []): array
{
$query = $this->search($where);
// 超级管理员(id=1)可查看全部部门,普通管理员按部门权限过滤
if (isset($this->adminInfo['id']) && $this->adminInfo['id'] > 1) {
$query->auth($this->adminInfo['deptList'] ?? []);
$deptId = $this->resolveAccessibleDeptId();
if ($deptId > 0) {
$query->where('id', $deptId);
} else {
return [];
}
}
$query->field('id, id as value, name as label, parent_id');
$query->field('id, id as value, name as label');
$query->order('sort', 'desc');
$data = $this->getAll($query);
return Helper::makeTree($data);
return $this->getAll($query);
}
/**
* 当前管理员可操作的渠道 IDdeptList 缺失时回退 dept_id
*/
public function resolveAccessibleDeptId(?array $adminInfo = null): int
{
$adminInfo = $adminInfo ?? $this->adminInfo ?? [];
if (empty($adminInfo['id']) || (int) $adminInfo['id'] <= 1) {
return 0;
}
$deptList = $adminInfo['deptList'] ?? [];
if (is_array($deptList) && isset($deptList['id']) && (int) $deptList['id'] > 0) {
return (int) $deptList['id'];
}
$deptId = $adminInfo['dept_id'] ?? null;
if ($deptId !== null && $deptId !== '' && (int) $deptId > 0) {
return (int) $deptId;
}
return 0;
}
}

View File

@@ -1,95 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: sai <1430792918@qq.com>
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\logic\system;
use plugin\saiadmin\app\model\system\SystemPost;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\service\OpenSpoutWriter;
use OpenSpout\Reader\XLSX\Reader;
/**
* 岗位管理逻辑层
*/
class SystemPostLogic extends BaseLogic
{
/**
* 构造函数
*/
public function __construct()
{
$this->model = new SystemPost();
}
/**
* 可操作岗位
* @param array $where
* @return array
*/
public function accessPost(array $where = []): array
{
$query = $this->search($where);
$query->field('id, id as value, name as label, name, code');
return $this->getAll($query);
}
/**
* 导入数据
*/
public function import($file)
{
$path = $this->getImport($file);
$reader = new Reader();
try {
$reader->open($path);
$data = [];
foreach ($reader->getSheetIterator() as $sheet) {
$isHeader = true;
foreach ($sheet->getRowIterator() as $row) {
if ($isHeader) {
$isHeader = false;
continue;
}
$cells = $row->getCells();
$data[] = [
'name' => $cells[0]->getValue(),
'code' => $cells[1]->getValue(),
'sort' => $cells[2]->getValue(),
'status' => $cells[3]->getValue(),
];
}
}
$this->saveAll($data);
} catch (\Exception $e) {
throw new ApiException('Import file error, please upload correct xlsx file');
}
}
/**
* 导出数据
*/
public function export($where = [])
{
$query = $this->search($where)->field('id,name,code,sort,status,create_time');
$data = $this->getAll($query);
$file_name = '岗位数据.xlsx';
$header = ['编号', '岗位名称', '岗位标识', '排序', '状态', '创建时间'];
$filter = [
'status' => [
['value' => 1, 'label' => '正常'],
['value' => 2, 'label' => '禁用']
]
];
$writer = new OpenSpoutWriter($file_name);
$writer->setWidth([15, 15, 20, 15, 15, 25]);
$writer->setHeader($header);
$writer->setData($data, null, $filter);
$file_path = $writer->returnFile();
return response()->download($file_path, urlencode($file_name));
}
}

View File

@@ -6,121 +6,136 @@
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\logic\system;
use app\dice\helper\AdminScopeHelper;
use plugin\saiadmin\app\cache\UserMenuCache;
use plugin\saiadmin\app\model\system\SystemRole;
use plugin\saiadmin\app\service\SystemRoleChannelService;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use support\think\Cache;
use support\think\Db;
/**
* 角色逻辑层
* 角色逻辑层(按渠道 dept_id 隔离)
*/
class SystemRoleLogic extends BaseLogic
{
/**
* 构造函数
*/
protected string $orderField = 'level';
protected string $orderType = 'desc';
public function __construct()
{
$this->model = new SystemRole();
}
/**
* 添加数据
* 分页列表(按渠道过滤)
*/
public function indexList(array $where, $requestDeptId = null): array
{
$query = $this->search($where);
$this->applyDeptScope($query, $requestDeptId);
$levelArr = array_column($this->adminInfo['roleList'] ?? [], 'level');
if (!empty($levelArr)) {
$maxLevel = max($levelArr);
$query->where('level', '<', $maxLevel);
}
$query->where('id', '<>', SystemRoleChannelService::SUPER_ADMIN_ROLE_ID);
return $this->getList($query);
}
public function add($data): bool
{
$data = $this->handleData($data);
$deptId = AdminScopeHelper::normalizeRecordDeptId($data['dept_id'] ?? null);
$data['dept_id'] = $deptId;
$this->assertCodeUniqueInDept($data['code'] ?? '', $deptId, null);
return $this->model->save($data);
}
/**
* 修改数据
*/
public function edit($id, $data): bool
{
$model = $this->model->findOrEmpty($id);
if ($model->isEmpty()) {
throw new ApiException('Data not found');
}
$this->assertRoleWritable($model);
$data = $this->handleData($data);
$deptId = AdminScopeHelper::normalizeRecordDeptId($model->dept_id ?? $data['dept_id'] ?? null);
$data['dept_id'] = $deptId;
$this->assertCodeUniqueInDept($data['code'] ?? '', $deptId, (int) $id);
return $model->save($data);
}
/**
* 删除数据
*/
public function destroy($ids): bool
{
// 越权保护
$levelArr = array_column($this->adminInfo['roleList'], 'level');
$maxLevel = max($levelArr);
$levelArr = array_column($this->adminInfo['roleList'] ?? [], 'level');
$maxLevel = !empty($levelArr) ? max($levelArr) : 100;
$num = SystemRole::where('level', '>=', $maxLevel)->whereIn('id', $ids)->count();
if ($num > 0) {
throw new ApiException('Cannot operate roles with higher level than current account');
} else {
return $this->model->destroy($ids);
$idList = is_array($ids) ? $ids : explode(',', (string) $ids);
foreach ($idList as $roleId) {
$roleId = (int) $roleId;
if ($roleId === SystemRoleChannelService::SUPER_ADMIN_ROLE_ID) {
throw new ApiException('Cannot delete super admin role');
}
$role = $this->model->find($roleId);
if (!$role) {
continue;
}
$this->assertRoleWritable($role);
if ((int) ($role->level ?? 0) >= $maxLevel) {
throw new ApiException('Cannot operate roles with higher level than current account');
}
}
return $this->model->destroy($ids);
}
/**
* 数据处理
*/
protected function handleData($data)
{
// 越权保护
$levelArr = array_column($this->adminInfo['roleList'], 'level');
$maxLevel = max($levelArr);
if ($data['level'] >= $maxLevel) {
throw new ApiException('Cannot operate roles with higher level than current account');
$levelArr = array_column($this->adminInfo['roleList'] ?? [], 'level');
if (!empty($levelArr)) {
$maxLevel = max($levelArr);
if (($data['level'] ?? 0) >= $maxLevel) {
throw new ApiException('Cannot operate roles with higher level than current account');
}
}
return $data;
}
/**
* 可操作角色
* @param array $where
* @return array
*/
public function accessRole(array $where = []): array
public function accessRole(array $where = [], $requestDeptId = null): array
{
$query = $this->search($where);
// 越权保护
$levelArr = array_column($this->adminInfo['roleList'], 'level');
$maxLevel = max($levelArr);
$query->where('level', '<', $maxLevel);
$query->order('sort', 'desc');
$this->applyDeptScope($query, $requestDeptId);
$levelArr = array_column($this->adminInfo['roleList'] ?? [], 'level');
if (!empty($levelArr)) {
$maxLevel = max($levelArr);
$query->where('level', '<', $maxLevel);
}
$query->where('id', '<>', SystemRoleChannelService::SUPER_ADMIN_ROLE_ID);
return $this->getAll($query);
}
/**
* 根据角色数组获取菜单
* @param $ids
* @return array
*/
public function getMenuIdsByRoleIds($ids): array
{
if (empty($ids))
if (empty($ids)) {
return [];
}
return $this->model->where('id', 'in', $ids)->with([
'menus' => function ($query) {
$query->where('status', 1)->order('sort', 'desc');
}
])->select()->toArray();
}
/**
* 根据角色获取菜单
* @param $id
* @return array
*/
public function getMenuByRole($id): array
{
$role = $this->model->findOrEmpty($id);
if ($role->isEmpty()) {
throw new ApiException('Data not found');
}
$this->assertRoleWritable($role);
$menus = $role->menus ?: [];
return [
'id' => $id,
@@ -128,14 +143,14 @@ class SystemRoleLogic extends BaseLogic
];
}
/**
* 保存菜单权限
* @param $id
* @param $menu_ids
* @return mixed
*/
public function saveMenuPermission($id, $menu_ids): mixed
{
$role = $this->model->findOrEmpty($id);
if ($role->isEmpty()) {
throw new ApiException('Data not found');
}
$this->assertRoleWritable($role);
return $this->transaction(function () use ($id, $menu_ids) {
$role = $this->model->findOrEmpty($id);
if ($role) {
@@ -147,10 +162,90 @@ class SystemRoleLogic extends BaseLogic
}
$cache = config('plugin.saiadmin.saithink.button_cache');
$tag = $cache['role'] . $id;
Cache::tag($tag)->clear(); // 清理权限缓存-角色TAG
UserMenuCache::clearMenuCache(); // 清理菜单缓存
Cache::tag($tag)->clear();
UserMenuCache::clearMenuCache();
return true;
});
}
/**
* 解析并校验当前请求应操作的渠道 ID
*/
public function resolveRequestDeptId($requestDeptId): int
{
if ((int) ($this->adminInfo['id'] ?? 0) === 1) {
return AdminScopeHelper::resolveConfigDeptId($this->adminInfo, $requestDeptId);
}
$deptLogic = new SystemDeptLogic();
$deptLogic->init($this->adminInfo);
return $deptLogic->resolveAccessibleDeptId();
}
/**
* 列表/下拉按渠道过滤
*/
protected function applyDeptScope($query, $requestDeptId = null): void
{
if (!$this->tableHasDeptIdColumn()) {
return;
}
if ((int) ($this->adminInfo['id'] ?? 0) === 1) {
$deptId = AdminScopeHelper::resolveConfigDeptId($this->adminInfo, $requestDeptId);
$query->where('dept_id', $deptId);
return;
}
$deptLogic = new SystemDeptLogic();
$deptLogic->init($this->adminInfo);
$deptId = $deptLogic->resolveAccessibleDeptId();
if ($deptId > 0) {
$query->where('dept_id', $deptId);
}
}
/**
* 校验角色属于当前可操作渠道
*/
public function assertRoleWritable($role): void
{
if (!$this->tableHasDeptIdColumn()) {
return;
}
$roleDeptId = AdminScopeHelper::normalizeRecordDeptId($role->dept_id ?? null);
if ((int) ($role->id ?? 0) === SystemRoleChannelService::SUPER_ADMIN_ROLE_ID) {
throw new ApiException('Cannot operate super admin role');
}
if ((int) ($this->adminInfo['id'] ?? 0) === 1) {
return;
}
$deptLogic = new SystemDeptLogic();
$deptLogic->init($this->adminInfo);
$scopeDeptId = $deptLogic->resolveAccessibleDeptId();
if ($scopeDeptId > 0 && $roleDeptId !== $scopeDeptId) {
throw new ApiException('No permission to operate this channel role');
}
}
protected function assertCodeUniqueInDept(string $code, int $deptId, ?int $excludeId): void
{
if ($code === '') {
return;
}
$query = SystemRole::where('code', $code)->where('dept_id', $deptId);
if ($excludeId !== null && $excludeId > 0) {
$query->where('id', '<>', $excludeId);
}
if ($query->count() > 0) {
throw new ApiException('Role code already exists in this channel');
}
}
protected function tableHasDeptIdColumn(): bool
{
try {
$fields = Db::getFields((new SystemRole())->getTable());
return isset($fields['dept_id']);
} catch (\Throwable $e) {
return false;
}
}
}

View File

@@ -40,9 +40,16 @@ class SystemUserLogic extends BaseLogic
{
$query = $this->search($where);
$query->with(['depts']);
// 超级管理员(id=1)可查看全部用户,普通管理员按部门权限过滤
// 超级管理员(id=1)可查看全部用户,渠道管理员按本渠道过滤
if (isset($this->adminInfo['id']) && $this->adminInfo['id'] > 1) {
$query->auth($this->adminInfo['deptList'] ?? []);
$deptLogic = new SystemDeptLogic();
$deptLogic->init($this->adminInfo);
$deptId = $deptLogic->resolveAccessibleDeptId();
if ($deptId > 0) {
$query->where('dept_id', $deptId);
} else {
$query->auth($this->adminInfo['deptList'] ?? []);
}
}
return $this->getList($query);
}
@@ -69,8 +76,13 @@ class SystemUserLogic extends BaseLogic
$admin = $this->model->findOrEmpty($id);
$data = $admin->hidden(['password'])->toArray();
$data['roleList'] = $admin->roles->toArray() ?: [];
$data['postList'] = $admin->posts->toArray() ?: [];
$data['deptList'] = $admin->depts ? $admin->depts->toArray() : [];
if (empty($data['deptList']) && ! empty($admin->dept_id)) {
$dept = SystemDept::find($admin->dept_id);
if ($dept && ! $dept->isEmpty()) {
$data['deptList'] = $dept->toArray();
}
}
return $data;
}
@@ -101,7 +113,6 @@ class SystemUserLogic extends BaseLogic
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
return $this->transaction(function () use ($data) {
$role_ids = $data['role_ids'] ?? [];
$post_ids = $data['post_ids'] ?? [];
if ($this->adminInfo['id'] > 1) {
// 部门保护
if (!$this->deptProtect($this->adminInfo['deptList'], $data['dept_id'])) {
@@ -114,11 +125,7 @@ class SystemUserLogic extends BaseLogic
}
$user = SystemUser::create($data);
$user->roles()->detach();
$user->posts()->detach();
$user->roles()->saveAll($role_ids);
if (!empty($post_ids)) {
$user->posts()->save($post_ids);
}
return $user;
});
}
@@ -134,7 +141,6 @@ class SystemUserLogic extends BaseLogic
unset($data['password']);
return $this->transaction(function () use ($data, $id) {
$role_ids = $data['role_ids'] ?? [];
$post_ids = $data['post_ids'] ?? [];
// 超级管理员可修改任意用户,普通管理员仅可修改当前部门和子部门的用户
$query = $this->model->where('id', $id);
if (isset($this->adminInfo['id']) && $this->adminInfo['id'] > 1) {
@@ -157,11 +163,7 @@ class SystemUserLogic extends BaseLogic
$result = parent::edit($id, $data);
if ($result) {
$user->roles()->detach();
$user->posts()->detach();
$user->roles()->saveAll($role_ids);
if (!empty($post_ids)) {
$user->posts()->save($post_ids);
}
UserInfoCache::clearUserInfo($id);
UserAuthCache::clearUserAuth($id);
UserMenuCache::clearUserMenu($id);

View File

@@ -9,15 +9,15 @@ namespace plugin\saiadmin\app\model\system;
use plugin\saiadmin\basic\think\BaseModel;
/**
* 部门模型
* 渠道模型
*
* sa_system_dept 部门
* sa_system_dept 渠道
*
* @property $id 编号
* @property $parent_id 父级ID0为根节点
* @property $name 部门名称
* @property $code 部门编码
* @property $leader_id 部门负责人ID
* @property $parent_id 父级ID扁平渠道固定为0
* @property $name 渠道名称
* @property $code 渠道编码
* @property $leader_id 渠道负责人ID
* @property $level 祖级列表,格式: 0,1,5,
* @property $sort 排序,数字越小越靠前
* @property $status 状态: 1启用, 0禁用
@@ -38,24 +38,21 @@ class SystemDept extends BaseModel
protected $table = 'sa_system_dept';
/**
* 权限范围
* 权限范围(扁平渠道,仅本渠道)
*/
public function scopeAuth($query, $value)
{
if (!empty($value) && isset($value['id'])) {
$deptIds = [$value['id']];
$level = $value['level'] ?? '';
if ($level !== '' && $level !== null) {
$deptLevel = $level . $value['id'] . ',';
$ids = static::whereLike('level', $deptLevel . '%')->column('id');
$deptIds = array_merge($deptIds, $ids);
}
$query->whereIn('id', $deptIds);
if (is_array($value) && isset($value['id']) && (int) $value['id'] > 0) {
$query->where('id', $value['id']);
return;
}
if (is_numeric($value) && (int) $value > 0) {
$query->where('id', (int) $value);
}
}
/**
* 部门领导
* 渠道负责人
*/
public function leader()
{

View File

@@ -23,6 +23,7 @@ use plugin\saiadmin\basic\think\BaseModel;
* @property $status 登录状态
* @property $message 提示消息
* @property $login_time 登录时间
* @property $dept_id 所属渠道
* @property $remark 备注
* @property $created_by 创建者
* @property $updated_by 更新者
@@ -47,4 +48,14 @@ class SystemLoginLog extends BaseModel
$query->whereTime('login_time', 'between', $value);
}
/**
* 渠道搜索
*/
public function searchDeptIdAttr($query, $value): void
{
if ($value !== '' && $value !== null && $value > 0) {
$query->where('dept_id', '=', $value);
}
}
}

View File

@@ -23,6 +23,7 @@ use plugin\saiadmin\basic\think\BaseModel;
* @property $ip 请求IP地址
* @property $ip_location IP所属地
* @property $request_data 请求数据
* @property $dept_id 所属渠道
* @property $remark 备注
* @property $created_by 创建者
* @property $updated_by 更新者
@@ -39,4 +40,14 @@ class SystemOperLog extends BaseModel
protected $table = 'sa_system_oper_log';
/**
* 渠道搜索
*/
public function searchDeptIdAttr($query, $value): void
{
if ($value !== '' && $value !== null && $value > 0) {
$query->where('dept_id', '=', $value);
}
}
}

View File

@@ -1,37 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: sai <1430792918@qq.com>
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\model\system;
use plugin\saiadmin\basic\think\BaseModel;
/**
* 岗位模型
*
* sa_system_post 岗位信息表
*
* @property $id 主键
* @property $name 岗位名称
* @property $code 岗位代码
* @property $sort 排序
* @property $status 状态
* @property $remark 备注
* @property $created_by 创建者
* @property $updated_by 更新者
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class SystemPost extends BaseModel
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
protected $table = 'sa_system_post';
}

View File

@@ -14,6 +14,7 @@ use plugin\saiadmin\basic\think\BaseModel;
* sa_system_role 角色表
*
* @property $id
* @property int $dept_id 所属渠道ID0=默认模板
* @property $name 角色名称
* @property $code 角色标识,如: hr_manager
* @property $level 角色级别:用于行政控制,不可操作级别大于自己的角色
@@ -41,6 +42,14 @@ class SystemRole extends BaseModel
*/
protected $table = 'sa_system_role';
/** 按渠道筛选 */
public function searchDeptIdAttr($query, $value): void
{
if ($value !== '' && $value !== null) {
$query->where('dept_id', '=', $value);
}
}
/**
* 权限范围
*/

View File

@@ -24,7 +24,7 @@ use plugin\saiadmin\basic\think\BaseModel;
* @property $phone 手机号
* @property $signed 个性签名
* @property $dashboard 工作台
* @property $dept_id 主归属部门
* @property $dept_id 主归属渠道
* @property $is_super 是否超级管理员: 1是
* @property $status 状态: 1启用, 2禁用
* @property $remark 备注
@@ -82,16 +82,12 @@ class SystemUser extends BaseModel
}
/**
* 权限范围 - 过滤部门用户
* 权限范围 - 过滤同渠道用户
*/
public function scopeAuth($query, $value)
{
if (!empty($value)) {
$deptIds = [$value['id']];
$deptLevel = $value['level'] . $value['id'] . ',';
$dept_ids = SystemDept::whereLike('level', $deptLevel . '%')->column('id');
$deptIds = array_merge($deptIds, $dept_ids);
$query->whereIn('dept_id', $deptIds);
if (!empty($value) && isset($value['id'])) {
$query->where('dept_id', $value['id']);
}
}
@@ -104,15 +100,7 @@ class SystemUser extends BaseModel
}
/**
* 通过中间表关联岗位
*/
public function posts()
{
return $this->belongsToMany(SystemPost::class, SystemUserPost::class, 'post_id', 'user_id');
}
/**
* 通过中间表关联部门
* 关联渠道
*/
public function depts()
{

View File

@@ -1,25 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: sai <1430792918@qq.com>
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\model\system;
use think\model\Pivot;
/**
* 用户岗位关联模型
*
* sa_system_user_post 用户与岗位关联表
*
* @property $id 主键
* @property $user_id 用户主键
* @property $post_id 岗位主键
*/
class SystemUserPost extends Pivot
{
protected $pk = 'id';
protected $table = 'sa_system_user_post';
}

View File

@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace plugin\saiadmin\app\service;
use plugin\saiadmin\app\model\system\SystemDept;
use plugin\saiadmin\app\model\system\SystemRole;
use plugin\saiadmin\exception\ApiException;
use support\think\Db;
/**
* 渠道角色:从默认模板复制指定角色、同步与删除
*/
class SystemRoleChannelService
{
/** 全局超级管理员角色,不参与渠道复制 */
public const SUPER_ADMIN_ROLE_ID = 1;
/**
* 为渠道从默认模板复制三个代理角色(缺失则补齐,不整包跳过)
*/
public function copyDefaultRolesToDept(int $deptId, bool $pruneExtra = false): array
{
if ($deptId <= 0) {
throw new ApiException('Invalid channel id');
}
if (!$this->tableHasColumn('sa_system_role', 'dept_id')) {
return ['dept_id' => $deptId, 'copied' => 0, 'skipped' => 0, 'pruned' => 0, 'message' => 'dept_id column missing'];
}
$templates = $this->defaultTemplateRoles();
if (empty($templates)) {
return ['dept_id' => $deptId, 'copied' => 0, 'skipped' => 0, 'pruned' => 0, 'message' => 'no template roles'];
}
$copied = 0;
$skipped = 0;
foreach ($templates as $template) {
$template = (array) $template;
$templateId = (int) ($template['id'] ?? 0);
$code = (string) ($template['code'] ?? '');
if ($templateId <= 0 || $code === '') {
continue;
}
if ($this->roleExists($deptId, $code)) {
$skipped++;
continue;
}
$newId = $this->insertRoleFromTemplate($template, $deptId);
if ($newId > 0) {
$this->copyRoleMenus($templateId, $newId);
$copied++;
}
}
$pruned = 0;
if ($pruneExtra) {
$pruned = $this->pruneExtraChannelRoles($deptId);
}
return ['dept_id' => $deptId, 'copied' => $copied, 'skipped' => $skipped, 'pruned' => $pruned];
}
/**
* 为所有已启用渠道补齐三个默认角色,并移除多余历史角色
*/
public function syncAllChannelsFromDefault(): array
{
$deptIds = SystemDept::where('status', 1)->where('id', '>', 0)->column('id');
$result = [];
foreach ($deptIds as $deptId) {
$result[(int) $deptId] = $this->copyDefaultRolesToDept((int) $deptId, true);
}
return $result;
}
/**
* 删除渠道下全部角色及菜单关联
*/
public function deleteRolesByDept(int $deptId): int
{
if ($deptId <= 0) {
return 0;
}
$roleIds = SystemRole::where('dept_id', $deptId)->column('id');
if (empty($roleIds)) {
return 0;
}
Db::name('sa_system_user_role')->whereIn('role_id', $roleIds)->delete();
Db::name('sa_system_role_menu')->whereIn('role_id', $roleIds)->delete();
Db::name('sa_system_role_dept')->whereIn('role_id', $roleIds)->delete();
return SystemRole::destroy($roleIds);
}
/**
* 将用户已绑定的模板角色映射到其渠道对应角色(仅三个默认 code
*/
public function remapUserRolesToChannelRoles(): int
{
if (!$this->tableHasColumn('sa_system_role', 'dept_id')) {
return 0;
}
$codes = $this->getDefaultChannelRoleCodes();
if (empty($codes)) {
return 0;
}
$codeList = "'" . implode("','", array_map('addslashes', $codes)) . "'";
return Db::execute(
'UPDATE `sa_system_user_role` ur
INNER JOIN `sa_system_user` u ON ur.user_id = u.id
INNER JOIN `sa_system_role` r_old ON ur.role_id = r_old.id
INNER JOIN `sa_system_role` r_new ON r_new.dept_id = u.dept_id AND r_new.code = r_old.code
SET ur.role_id = r_new.id
WHERE u.dept_id > 0
AND r_old.dept_id = 0
AND r_old.code IN (' . $codeList . ')
AND r_old.id <> ' . self::SUPER_ADMIN_ROLE_ID
);
}
/**
* @return string[]
*/
public function getDefaultChannelRoleCodes(): array
{
$codes = config('plugin.saiadmin.saithink.channel_default_role_codes', []);
if (!is_array($codes) || $codes === []) {
return ['yijidaili', 'erjidaili', 'sanjidaili'];
}
$out = [];
foreach ($codes as $code) {
$code = trim((string) $code);
if ($code !== '') {
$out[] = $code;
}
}
return $out;
}
/**
* 删除渠道下不在默认三个 code 内、且未被用户绑定的角色
*/
public function pruneExtraChannelRoles(int $deptId): int
{
if ($deptId <= 0) {
return 0;
}
$allowed = $this->getDefaultChannelRoleCodes();
if (empty($allowed)) {
return 0;
}
$query = SystemRole::where('dept_id', $deptId)->whereNotIn('code', $allowed);
$roleIds = $query->column('id');
if (empty($roleIds)) {
return 0;
}
$usedIds = Db::name('sa_system_user_role')->whereIn('role_id', $roleIds)->column('role_id');
$usedMap = array_flip($usedIds ?: []);
$pruned = 0;
foreach ($roleIds as $roleId) {
if (isset($usedMap[$roleId])) {
continue;
}
Db::name('sa_system_role_menu')->where('role_id', $roleId)->delete();
Db::name('sa_system_role_dept')->where('role_id', $roleId)->delete();
SystemRole::destroy($roleId);
$pruned++;
}
return $pruned;
}
/**
* @return array<int, array<string, mixed>>
*/
private function defaultTemplateRoles(): array
{
$codes = $this->getDefaultChannelRoleCodes();
if (empty($codes)) {
return [];
}
$query = Db::table('sa_system_role')
->where('id', '<>', self::SUPER_ADMIN_ROLE_ID)
->whereIn('code', $codes);
if ($this->tableHasColumn('sa_system_role', 'dept_id')) {
$query->where('dept_id', 0);
}
$rows = $query->select()->toArray();
if (empty($rows)) {
return [];
}
if (count($rows) === count($codes)) {
return $this->sortRolesByLevelDesc($rows);
}
// 按配置 code 补齐,缺失的 code 跳过,最终按角色级别从大到小排序
$byCode = [];
foreach ($rows as $row) {
$byCode[(string) ($row['code'] ?? '')] = $row;
}
$ordered = [];
foreach ($codes as $code) {
if (isset($byCode[$code])) {
$ordered[] = $byCode[$code];
}
}
return $this->sortRolesByLevelDesc($ordered);
}
/**
* 按角色级别从大到小排序(同级别按 sort 降序)
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function sortRolesByLevelDesc(array $rows): array
{
usort($rows, static function (array $a, array $b): int {
$levelCompare = (int) ($b['level'] ?? 0) <=> (int) ($a['level'] ?? 0);
if ($levelCompare !== 0) {
return $levelCompare;
}
return (int) ($b['sort'] ?? 0) <=> (int) ($a['sort'] ?? 0);
});
return $rows;
}
private function insertRoleFromTemplate(array $template, int $deptId): int
{
unset(
$template['id'],
$template['create_time'],
$template['update_time'],
$template['delete_time']
);
$template['dept_id'] = $deptId;
$now = date('Y-m-d H:i:s');
if (!isset($template['create_time'])) {
$template['create_time'] = $now;
}
if (!isset($template['update_time'])) {
$template['update_time'] = $now;
}
return (int) Db::table('sa_system_role')->insertGetId($template);
}
private function copyRoleMenus(int $fromRoleId, int $toRoleId): void
{
$menuIds = Db::name('sa_system_role_menu')->where('role_id', $fromRoleId)->column('menu_id');
if (empty($menuIds)) {
return;
}
$rows = [];
foreach ($menuIds as $menuId) {
$rows[] = ['role_id' => $toRoleId, 'menu_id' => $menuId];
}
Db::name('sa_system_role_menu')->limit(100)->insertAll($rows);
}
private function roleExists(int $deptId, string $code): bool
{
return SystemRole::where('dept_id', $deptId)->where('code', $code)->count() > 0;
}
private function tableHasColumn(string $table, string $column): bool
{
try {
$fields = Db::getFields($table);
return isset($fields[$column]);
} catch (\Throwable $e) {
return false;
}
}
}

View File

@@ -9,7 +9,7 @@ namespace plugin\saiadmin\app\validate\system;
use plugin\saiadmin\basic\BaseValidate;
/**
* 部门验证器
* 渠道验证器
*/
class SystemDeptValidate extends BaseValidate
{
@@ -25,7 +25,7 @@ class SystemDeptValidate extends BaseValidate
* 定义错误信息
*/
protected $message = [
'name' => '部门名称必须填写',
'name' => '渠道名称必须填写',
'status' => '状态必须填写',
];

View File

@@ -1,50 +0,0 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: sai <1430792918@qq.com>
// +----------------------------------------------------------------------
namespace plugin\saiadmin\app\validate\system;
use plugin\saiadmin\basic\BaseValidate;
/**
* 用户角色验证器
*/
class SystemPostValidate extends BaseValidate
{
/**
* 定义验证规则
*/
protected $rule = [
'name' => 'require',
'code' => 'require',
'status' => 'require',
];
/**
* 定义错误信息
*/
protected $message = [
'name' => '岗位名称必须填写',
'code' => '岗位标识必须填写',
'status' => '状态必须填写',
];
/**
* 定义场景
*/
protected $scene = [
'save' => [
'name',
'code',
'status',
],
'update' => [
'name',
'code',
'status',
],
];
}

View File

@@ -19,7 +19,7 @@ class SystemRoleValidate extends BaseValidate
*/
protected $rule = [
'name' => 'require|max:16',
'code' => 'require|alphaDash|unique:' . SystemRole::class,
'code' => 'require|alphaDash|unique:' . SystemRole::class . ',code^dept_id',
'status' => 'require',
];

View File

@@ -6,6 +6,7 @@
// +----------------------------------------------------------------------
namespace plugin\saiadmin\basic;
use app\api\util\ApiLang;
use support\Request;
use support\Response;
@@ -36,6 +37,7 @@ class OpenController
if (is_string($data)) {
$msg = $data;
}
$msg = ApiLang::translate($msg, request());
return json(['code' => 200, 'message' => $msg, 'data' => $data], $option);
}
@@ -47,6 +49,7 @@ class OpenController
*/
public function fail(string $msg = 'fail', int $code = 400): Response
{
$msg = ApiLang::translate($msg, request());
return json(['code' => $code, 'message' => $msg]);
}

View File

@@ -12,10 +12,18 @@ use plugin\saiadmin\basic\contracts\ModelInterface;
/**
* ThinkORM 模型基类
*
* 全局策略:所有删除一律为硬删除(物理删除)。
* - 保留 SoftDelete trait 仅是为了兼容历史字段(如 delete_time与查询作用域
* 实际删除方法delete/destroy均通过 trait 别名重写为强制 force=true。
* - 项目中不使用 withTrashed/onlyTrashed/restore() 等软删除恢复接口。
*/
class BaseModel extends Model implements ModelInterface
{
use SoftDelete;
use SoftDelete {
delete as protected softDeleteCascadeOriginal;
destroy as protected softDeleteDestroyOriginal;
}
/**
* 删除时间字段
@@ -99,6 +107,25 @@ class BaseModel extends Model implements ModelInterface
}
}
/**
* 删除记录(静态入口):一律强制硬删除(物理删除)。
* @param mixed $data 主键、闭包或条件
* @param bool $force 兼容签名,内部一律按 true 处理
*/
public static function destroy($data, bool $force = true): bool
{
return static::softDeleteDestroyOriginal($data, true);
}
/**
* 删除记录(实例方法):一律强制硬删除。
*/
public function delete(): bool
{
$this->force(true);
return $this->softDeleteCascadeOriginal();
}
/**
* 新增前事件:自动写入 create_time有后台登录信息时写入 created_by
* @param Model $model

View File

@@ -23,6 +23,7 @@ Route::group('/core', function () {
Route::get('/dice/dashboard/rechargeBarChart', [\app\dice\controller\DiceDashboardController::class, 'rechargeBarChart']);
Route::get('/dice/dashboard/walletRecordList', [\app\dice\controller\DiceDashboardController::class, 'walletRecordList']);
Route::get('/dice/dashboard/newPlayerList', [\app\dice\controller\DiceDashboardController::class, 'newPlayerList']);
Route::get('/dice/dashboard/playRecordList', [\app\dice\controller\DiceDashboardController::class, 'playRecordList']);
Route::get('/system/clearAllCache', [plugin\saiadmin\app\controller\SystemController::class, 'clearAllCache']);
Route::get("/system/getResourceCategory", [plugin\saiadmin\app\controller\SystemController::class, 'getResourceCategory']);
@@ -49,14 +50,11 @@ Route::group('/core', function () {
Route::get("/role/getMenuByRole", [\plugin\saiadmin\app\controller\system\SystemRoleController::class, 'getMenuByRole']);
Route::post("/role/menuPermission", [\plugin\saiadmin\app\controller\system\SystemRoleController::class, 'menuPermission']);
// 部门管理
// 渠道管理
fastRoute("dept", \plugin\saiadmin\app\controller\system\SystemDeptController::class);
Route::get("/dept/accessDept", [\plugin\saiadmin\app\controller\system\SystemDeptController::class, 'accessDept']);
// 岗位管理
fastRoute('post', \plugin\saiadmin\app\controller\system\SystemPostController::class);
Route::get("/post/accessPost", [\plugin\saiadmin\app\controller\system\SystemPostController::class, 'accessPost']);
Route::post("/post/downloadTemplate", [plugin\saiadmin\app\controller\system\SystemPostController::class, 'downloadTemplate']);
Route::get("/dept/destroyPreview", [\plugin\saiadmin\app\controller\system\SystemDeptController::class, 'destroyPreview']);
Route::post("/dept/syncChannelConfigs", [\plugin\saiadmin\app\controller\system\SystemDeptController::class, 'syncChannelConfigs']);
// 菜单管理
fastRoute('menu', \plugin\saiadmin\app\controller\system\SystemMenuController::class);
@@ -83,6 +81,10 @@ Route::group('/core', function () {
Route::delete("/logs/deleteOperLog", [\plugin\saiadmin\app\controller\system\SystemLogController::class, 'deleteOperLog']);
fastRoute("email", \plugin\saiadmin\app\controller\system\SystemMailController::class);
// 后台操作指南
Route::get("/adminGuide/read", [\plugin\saiadmin\app\controller\system\SystemAdminGuideController::class, 'read']);
Route::post("/adminGuide/save", [\plugin\saiadmin\app\controller\system\SystemAdminGuideController::class, 'save']);
// 服务管理
Route::get("/server/monitor", [\plugin\saiadmin\app\controller\system\SystemServerController::class, 'monitor']);
Route::get("/server/cache", [\plugin\saiadmin\app\controller\system\SystemServerController::class, 'cache']);
@@ -94,6 +96,7 @@ Route::group('/core', function () {
Route::put('/dice/player/DicePlayer/updateStatus', [\app\dice\controller\player\DicePlayerController::class, 'updateStatus']);
Route::get('/dice/player/DicePlayer/getLotteryConfigOptions', [\app\dice\controller\player\DicePlayerController::class, 'getLotteryConfigOptions']);
Route::get('/dice/player/DicePlayer/getSystemUserOptions', [\app\dice\controller\player\DicePlayerController::class, 'getSystemUserOptions']);
Route::get('/dice/player/DicePlayer/getSystemUserTreeOptions', [\app\dice\controller\player\DicePlayerController::class, 'getSystemUserTreeOptions']);
Route::get('/dice/player/DicePlayer/getGameUrl', [\app\dice\controller\player\DicePlayerController::class, 'getGameUrl']);
fastRoute('dice/play_record/DicePlayRecord', \app\dice\controller\play_record\DicePlayRecordController::class);
Route::get('/dice/play_record/DicePlayRecord/getPlayerOptions', [\app\dice\controller\play_record\DicePlayRecordController::class, 'getPlayerOptions']);
@@ -116,10 +119,13 @@ Route::group('/core', function () {
Route::post('/dice/reward_config/DiceRewardConfig/batchUpdateWeights', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'batchUpdateWeights']);
Route::post('/dice/reward_config/DiceRewardConfig/saveBigwinWeightsByGrid', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'saveBigwinWeightsByGrid']);
Route::post('/dice/reward_config/DiceRewardConfig/batchUpdate', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'batchUpdate']);
Route::post('/dice/reward_config/DiceRewardConfig/generateIndexByRules', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'generateIndexByRules']);
Route::post('/dice/reward_config/DiceRewardConfig/createRewardReference', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'createRewardReference']);
Route::post('/dice/reward_config/DiceRewardConfig/createRewardReferencePreview', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'createRewardReferencePreview']);
Route::post('/dice/reward_config/DiceRewardConfig/runWeightTest', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'runWeightTest']);
fastRoute('dice/game/DiceGame', \app\dice\controller\game\DiceGameController::class);
fastRoute('dice/ante_config/DiceAnteConfig', \app\dice\controller\ante_config\DiceAnteConfigController::class);
Route::get('/dice/ante_config/DiceAnteConfig/getOptions', [\app\dice\controller\ante_config\DiceAnteConfigController::class, 'getOptions']);
fastRoute('dice/lottery_pool_config/DiceLotteryPoolConfig', \app\dice\controller\lottery_pool_config\DiceLotteryPoolConfigController::class);
Route::get('/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions', [\app\dice\controller\lottery_pool_config\DiceLotteryPoolConfigController::class, 'getOptions']);
Route::get('/dice/lottery_pool_config/DiceLotteryPoolConfig/getCurrentPool', [\app\dice\controller\lottery_pool_config\DiceLotteryPoolConfigController::class, 'getCurrentPool']);

View File

@@ -33,7 +33,6 @@ return [
'expire' => 60 * 60 * 4,
'dept' => 'saiadmin:user_cache:dept_',
'role' => 'saiadmin:user_cache:role_',
'post' => 'saiadmin:user_cache:post_',
],
// 用户权限缓存
@@ -73,4 +72,13 @@ return [
'attr' => 'saiadmin:reflection_cache:attr_',
],
/**
* 新建渠道时从默认模板复制的角色 code须存在于 dept_id=0 的模板角色)
*/
'channel_default_role_codes' => [
'yijidaili',
'erjidaili',
'sanjidaili',
],
];

View File

@@ -391,7 +391,6 @@ INSERT INTO `sa_system_menu` VALUES (3, 0, '系统管理', 'System', NULL, 1, '/
INSERT INTO `sa_system_menu` VALUES (4, 3, '用户管理', 'User', NULL, 2, 'user', '/system/user', NULL, 'ri:user-line', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (5, 3, '部门管理', 'Dept', NULL, 2, 'dept', '/system/dept', NULL, 'ri:node-tree', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (6, 3, '角色管理', 'Role', NULL, 2, 'role', '/system/role', NULL, 'ri:admin-line', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (7, 3, '岗位管理', 'Post', '', 2, 'post', '/system/post', NULL, 'ri:signpost-line', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (8, 3, '菜单管理', 'Menu', NULL, 2, 'menu', '/system/menu', NULL, 'ri:menu-line', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (10, 0, '运维管理', 'Safeguard', NULL, 1, '/safeguard', '', NULL, 'ri:shield-check-line', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (11, 10, '缓存管理', 'Cache', '', 2, 'cache', '/safeguard/cache', NULL, 'ri:keyboard-box-line', 80, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
@@ -423,13 +422,6 @@ INSERT INTO `sa_system_menu` VALUES (36, 6, '修改', '', 'core:role:update', 3,
INSERT INTO `sa_system_menu` VALUES (37, 6, '读取', '', 'core:role:read', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (38, 6, '删除', '', 'core:role:destroy', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (39, 6, '菜单权限', '', 'core:role:menu', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (41, 7, '数据列表', '', 'core:post:index', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (42, 7, '添加', '', 'core:post:save', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (43, 7, '修改', '', 'core:post:update', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (44, 7, '读取', '', 'core:post:read', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (45, 7, '删除', '', 'core:post:destroy', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (46, 7, '导入', '', 'core:post:import', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (47, 7, '导出', '', 'core:post:export', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (48, 8, '数据列表', '', 'core:menu:index', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (49, 8, '读取', '', 'core:menu:read', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (50, 8, '添加', '', 'core:menu:save', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
@@ -501,31 +493,6 @@ CREATE TABLE `sa_system_oper_log` (
-- Records of sa_system_oper_log
-- ----------------------------
-- ----------------------------
-- Table structure for sa_system_post
-- ----------------------------
DROP TABLE IF EXISTS `sa_system_post`;
CREATE TABLE `sa_system_post` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(50) NULL DEFAULT NULL COMMENT '岗位名称',
`code` varchar(100) NULL DEFAULT NULL COMMENT '岗位代码',
`sort` smallint(5) UNSIGNED NULL DEFAULT 0 COMMENT '排序',
`status` smallint(6) NULL DEFAULT 1 COMMENT '状态 (1正常 2停用)',
`remark` varchar(255) NULL DEFAULT NULL COMMENT '备注',
`created_by` int(11) NULL DEFAULT NULL COMMENT '创建者',
`updated_by` int(11) NULL DEFAULT NULL COMMENT '更新者',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',
`delete_time` datetime(0) NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 87 COMMENT = '岗位信息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sa_system_post
-- ----------------------------
INSERT INTO `sa_system_post` VALUES (1, '司机岗', 'driver', 100, 1, '', 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_post` VALUES (2, '保安岗', 'security', 100, 1, '', 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
-- ----------------------------
-- Table structure for sa_system_role
-- ----------------------------
@@ -635,23 +602,6 @@ INSERT INTO `sa_system_user` VALUES (10, 'timi_boss', '$2y$10$sY/4StKVV.N/8Ock8J
INSERT INTO `sa_system_user` VALUES (100, 'dev_wang', '$2y$10$sY/4StKVV.N/8Ock8J8kdeIOK4jS4tAUoYjkzvB8Tzy0fLh.wA2KS', '王程序员', NULL, 'https://image.saithink.top/saiadmin/avatar.jpg', NULL, '15888888888', NULL, 'work', 1111, 0, 1, NULL, NULL, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_user` VALUES (101, 'dev_li', '$2y$10$sY/4StKVV.N/8Ock8J8kdeIOK4jS4tAUoYjkzvB8Tzy0fLh.wA2KS', '李策划', NULL, 'https://image.saithink.top/saiadmin/avatar.jpg', NULL, '15888888888', NULL, 'work', 1111, 0, 1, NULL, NULL, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
-- ----------------------------
-- Table structure for sa_system_user_post
-- ----------------------------
DROP TABLE IF EXISTS `sa_system_user_post`;
CREATE TABLE `sa_system_user_post` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint(20) UNSIGNED NOT NULL COMMENT '用户主键',
`post_id` bigint(20) UNSIGNED NOT NULL COMMENT '岗位主键',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id`(`user_id`) USING BTREE,
INDEX `idx_post_id`(`post_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '用户与岗位关联表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sa_system_user_post
-- ----------------------------
-- ----------------------------
-- Table structure for sa_system_user_role
-- ----------------------------

View File

@@ -380,7 +380,6 @@ INSERT INTO `sa_system_menu` VALUES (3, 0, '系统管理', 'System', NULL, 1, '/
INSERT INTO `sa_system_menu` VALUES (4, 3, '用户管理', 'User', NULL, 2, 'user', '/system/user', NULL, 'ri:user-line', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (5, 3, '部门管理', 'Dept', NULL, 2, 'dept', '/system/dept', NULL, 'ri:node-tree', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (6, 3, '角色管理', 'Role', NULL, 2, 'role', '/system/role', NULL, 'ri:admin-line', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (7, 3, '岗位管理', 'Post', '', 2, 'post', '/system/post', NULL, 'ri:signpost-line', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (8, 3, '菜单管理', 'Menu', NULL, 2, 'menu', '/system/menu', NULL, 'ri:menu-line', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (10, 0, '运维管理', 'Safeguard', NULL, 1, '/safeguard', '', NULL, 'ri:shield-check-line', 100, NULL, 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (11, 10, '缓存管理', 'Cache', '', 2, 'cache', '/safeguard/cache', NULL, 'ri:keyboard-box-line', 80, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
@@ -412,13 +411,6 @@ INSERT INTO `sa_system_menu` VALUES (36, 6, '修改', '', 'core:role:update', 3,
INSERT INTO `sa_system_menu` VALUES (37, 6, '读取', '', 'core:role:read', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (38, 6, '删除', '', 'core:role:destroy', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (39, 6, '菜单权限', '', 'core:role:menu', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (41, 7, '数据列表', '', 'core:post:index', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (42, 7, '添加', '', 'core:post:save', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (43, 7, '修改', '', 'core:post:update', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (44, 7, '读取', '', 'core:post:read', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (45, 7, '删除', '', 'core:post:destroy', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (46, 7, '导入', '', 'core:post:import', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (47, 7, '导出', '', 'core:post:export', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (48, 8, '数据列表', '', 'core:menu:index', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (49, 8, '读取', '', 'core:menu:read', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
INSERT INTO `sa_system_menu` VALUES (50, 8, '添加', '', 'core:menu:save', 3, '', '', NULL, '', 100, '', 2, 2, 2, 2, 2, 0, NULL, 1, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
@@ -490,25 +482,6 @@ CREATE TABLE `sa_system_oper_log` (
-- Records of sa_system_oper_log
-- ----------------------------
-- ----------------------------
-- Table structure for sa_system_post
-- ----------------------------
DROP TABLE IF EXISTS `sa_system_post`;
CREATE TABLE `sa_system_post` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(50) NULL DEFAULT NULL COMMENT '岗位名称',
`code` varchar(100) NULL DEFAULT NULL COMMENT '岗位代码',
`sort` smallint(5) UNSIGNED NULL DEFAULT 0 COMMENT '排序',
`status` smallint(6) NULL DEFAULT 1 COMMENT '状态 (1正常 2停用)',
`remark` varchar(255) NULL DEFAULT NULL COMMENT '备注',
`created_by` int(11) NULL DEFAULT NULL COMMENT '创建者',
`updated_by` int(11) NULL DEFAULT NULL COMMENT '更新者',
`create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` datetime(0) NULL DEFAULT NULL COMMENT '修改时间',
`delete_time` datetime(0) NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 87 COMMENT = '岗位信息表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for sa_system_role
-- ----------------------------
@@ -606,23 +579,6 @@ CREATE TABLE `sa_system_user` (
-- ----------------------------
INSERT INTO `sa_system_user` VALUES (1, 'admin', '$2y$10$wnixh48uDnaW/6D9EygDd.OHJK0vQY/4nHaTjMKBCVDBP2NiTatqS', '祭道之上', '2', 'https://image.saithink.top/saiadmin/avatar.jpg', 'saiadmin@admin.com', '15888888888', 'SaiAdmin是兼具设计美学与高效开发的后台系统!', 'statistics', 1, 1, 1, NULL, NULL, NULL, 1, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00', NULL);
-- ----------------------------
-- Table structure for sa_system_user_post
-- ----------------------------
DROP TABLE IF EXISTS `sa_system_user_post`;
CREATE TABLE `sa_system_user_post` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint(20) UNSIGNED NOT NULL COMMENT '用户主键',
`post_id` bigint(20) UNSIGNED NOT NULL COMMENT '岗位主键',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_user_id`(`user_id`) USING BTREE,
INDEX `idx_post_id`(`post_id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 COMMENT = '用户与岗位关联表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of sa_system_user_post
-- ----------------------------
-- ----------------------------
-- Table structure for sa_system_user_role
-- ----------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
return [
'ACCOUNT_DISABLED' => 'Account is disabled and cannot log in',
'API_AUTH_TOKEN_SECRET is not configured' => 'API_AUTH_TOKEN_SECRET is not configured',
'API_KEY is not configured' => 'API_KEY is not configured',
'Please provide api-key' => 'Please provide api-key',
'Invalid api-key' => 'Invalid api-key',
'AUTH_TOKEN_EXPIRED' => 'auth-token expired',
'AUTH_TOKEN_FORMAT_INVALID' => 'auth-token format invalid',
'AUTH_TOKEN_INVALID' => 'auth-token invalid',
@@ -254,6 +257,7 @@ return [
'This category has sub-categories, please delete them first' => 'This category has sub-categories, please delete them first',
'This department has sub-departments, please delete them first' => 'This department has sub-departments, please delete them first',
'This department has users, please delete or transfer them first' => 'This department has users, please delete or transfer them first',
'This channel has users, please delete or transfer them first' => 'This channel has users, please delete or transfer them first',
'This dict code already exists' => 'This dict code already exists',
'This menu has sub-menus, please delete them first' => 'This menu has sub-menus, please delete them first',
'Timestamp expired or invalid, please sync time' => 'Timestamp expired or invalid, please sync time',

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
return [
'ACCOUNT_DISABLED' => '账号已被禁用,无法登录',
'API_AUTH_TOKEN_SECRET is not configured' => '服务端未配置 API_AUTH_TOKEN_SECRET',
'API_KEY is not configured' => '服务端未配置 API_KEY',
'Please provide api-key' => '请携带 api-key',
'Invalid api-key' => 'api-key 无效',
'AUTH_TOKEN_EXPIRED' => 'auth-token 已过期',
'AUTH_TOKEN_FORMAT_INVALID' => 'auth-token 格式无效',
'AUTH_TOKEN_INVALID' => 'auth-token 无效',
@@ -254,6 +257,7 @@ return [
'This category has sub-categories, please delete them first' => '该部门下存在子分类,请先删除子分类',
'This department has sub-departments, please delete them first' => '该部门下存在子部门,请先删除子部门',
'This department has users, please delete or transfer them first' => '该部门下存在用户,请先删除或者转移用户',
'This channel has users, please delete or transfer them first' => '该渠道下存在用户,请先删除或者转移用户',
'This dict code already exists' => '该字典标识已存在',
'This menu has sub-menus, please delete them first' => '该菜单下存在子菜单,请先删除子菜单',
'Timestamp expired or invalid, please sync time' => '时间戳已过期或无效,请同步时间',

View File

@@ -11,7 +11,7 @@ declare(strict_types=1);
$options = getopt('', ['agent_id:', 'secret:', 'time::']);
$agentId = $options['agent_id'] ?? '5ef059938ba799aaa845e1c2e8a762bd';
$agentId = $options['agent_id'] ?? '76dc611d6ebaafc66cc0879c71b5db5c';
$secret = $options['secret'] ?? 'xF75oK91TQj13s0UmNIr1NBWMWGfflNO';
$time = $options['time'] ?? (string) time();