Compare commits
38 Commits
a5e2f2fdf7
...
master-v3
| Author | SHA1 | Date | |
|---|---|---|---|
| b20a1d276f | |||
| 5e54859a30 | |||
| 226675c6bd | |||
| e7a4a47329 | |||
| 99949c4c3c | |||
| 6ef3663957 | |||
| f8969097d7 | |||
| 0cc81299f0 | |||
| 8a4a268526 | |||
| 16a59c28d4 | |||
| eb9ade6d16 | |||
| 8c6c122dc2 | |||
| dfb37dd33a | |||
| 5d316ef7d6 | |||
| 58a4b229a8 | |||
| 0c4da1540d | |||
| 1980ff4af0 | |||
| 5eb0ac24cd | |||
| 307a942b8e | |||
| 51105dd1e0 | |||
| 136c18e413 | |||
| 9fb98dee3f | |||
| 37b0ee134e | |||
| c0d5258aee | |||
| 13dacc8fdd | |||
| 79c84c198a | |||
| 3f97905ffa | |||
| d77e390fa3 | |||
| 906539995d | |||
| 18944f0d48 | |||
| 90abab14a3 | |||
| a4c8f623be | |||
| e0b303c5d4 | |||
| 77db2357ba | |||
| cde5a851e5 | |||
| 9a43e1d8f2 | |||
| 37c0035bfc | |||
| 5628af683f |
41
API对接文档.md
@@ -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 | 服务器内部错误 | 服务端异常或配置缺失 |
|
||||
|
||||
@@ -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/` | 性能、权重测试、出点分析等专项说明(按需阅读)。 |
|
||||
|
||||
**与玩法直接相关的玩家接口示例**:
|
||||
|
||||
@@ -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",
|
||||
|
||||
192
saiadmin-artd/public/docs/flowcharts/dice-为何抽到该奖励.html
Normal 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[按「杀分奖池」的 T1~T5 档位概率抽签]
|
||||
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
|
||||
KillCheck -->|是| PoolKill
|
||||
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1~T5 档位概率抽签]
|
||||
|
||||
PoolKill --> DrawTier[随机抽出档位 T1~T5]
|
||||
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[按「杀分奖池」的 T1~T5 档位概率抽签]
|
||||
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
|
||||
KillCheck -->|是| PoolKill
|
||||
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1~T5 档位概率抽签]
|
||||
|
||||
PoolKill --> DrawTier[随机抽出档位 T1~T5]
|
||||
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>:先抽档位 T1~T5(大奖 / 小赚 / 抽水 / 惩罚 / 再来一次),再在该档位 + 方向的多条奖励里按权重抽具体点数与结算金额。</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>
|
||||
44
saiadmin-artd/public/docs/flowcharts/dice-为何抽到该奖励.mmd
Normal file
@@ -0,0 +1,44 @@
|
||||
flowchart TD
|
||||
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
|
||||
Dir --> Ante[选择底注倍数]
|
||||
Ante --> Type{本局是否使用免费抽奖券?}
|
||||
|
||||
Type -->|是| Free[免费局]
|
||||
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
|
||||
|
||||
Free --> PoolKill[按「杀分奖池」的 T1~T5 档位概率抽签]
|
||||
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
|
||||
KillCheck -->|是| PoolKill
|
||||
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1~T5 档位概率抽签]
|
||||
|
||||
PoolKill --> DrawTier[随机抽出档位 T1~T5]
|
||||
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
|
||||
219
saiadmin-artd/public/docs/flowcharts/dice-后台中奖逻辑配置.html
Normal 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[② 先随机抽出档位 T1~T5] --> 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/>按 T1~T5 填各点数权重 → 按钮「提交」]
|
||||
|
||||
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[② 先随机抽出档位 T1~T5] --> 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/>按 T1~T5 填各点数权重 → 按钮「提交」]
|
||||
|
||||
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>
|
||||
42
saiadmin-artd/public/docs/flowcharts/dice-后台中奖逻辑配置.mmd
Normal file
@@ -0,0 +1,42 @@
|
||||
flowchart TD
|
||||
O([按一局抽奖的真实顺序配置后台]) --> L1
|
||||
|
||||
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
|
||||
L1A --> L2
|
||||
|
||||
L2[② 先随机抽出档位 T1~T5] --> 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/>按 T1~T5 填各点数权重 → 按钮「提交」]
|
||||
|
||||
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
|
||||
26
saiadmin-artd/scripts/sync-dice-flowcharts.mjs
Normal 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)')
|
||||
26
saiadmin-artd/src/api/system/admin_guide.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-grow min-w-0 min-h-0">
|
||||
<div v-if="selectedDeptLabel" class="channel-banner mb-3 text-sm text-g-500">
|
||||
{{ bannerLabel }}:<b>{{ selectedDeptLabel }}</b>
|
||||
<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 />
|
||||
@@ -66,10 +66,10 @@
|
||||
treeData,
|
||||
selectedDeptId,
|
||||
loadingChannels,
|
||||
selectedDeptLabel,
|
||||
handleChannelClick,
|
||||
provideScope,
|
||||
isConfigScope,
|
||||
isAllChannelScope,
|
||||
showDefaultTemplate
|
||||
} = useChannelDeptScope()
|
||||
|
||||
@@ -82,11 +82,17 @@
|
||||
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: defaultLabel } : 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')
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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 { isConfigChannelRoute, isRoleChannelRoute, isSuperAdminUser } from '@/utils/channelLayout'
|
||||
import {
|
||||
isAllChannelScopeRoute,
|
||||
isConfigChannelRoute,
|
||||
isNoChannelLayoutRoute,
|
||||
isRoleChannelRoute,
|
||||
isSuperAdminUser
|
||||
} from '@/utils/channelLayout'
|
||||
|
||||
export interface ChannelTreeNode {
|
||||
id: number
|
||||
@@ -21,18 +28,42 @@ export interface ChannelDeptScopeContext {
|
||||
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 {
|
||||
return inject(CHANNEL_DEPT_SCOPE_KEY, null)
|
||||
// 仅在组件 setup 同步路径下调用 inject() 才可靠;
|
||||
// 否则(异步回调、setInterval、事件处理器等)退化为读取模块级激活上下文,
|
||||
// 避免渠道切换后 getCurrentPool/withChannelDeptParams 等仍按超管自身部门发请求。
|
||||
if (getCurrentInstance()) {
|
||||
const ctx = inject(CHANNEL_DEPT_SCOPE_KEY, null)
|
||||
if (ctx) {
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
return activeChannelCtx
|
||||
}
|
||||
|
||||
/** 超管全局渠道栏:创建并 provide 渠道上下文 */
|
||||
@@ -45,6 +76,7 @@ export function useChannelDeptScope() {
|
||||
|
||||
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())
|
||||
@@ -67,6 +99,9 @@ export function useChannelDeptScope() {
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -83,7 +118,7 @@ export function useChannelDeptScope() {
|
||||
label: String(item.label ?? item.name ?? item.id)
|
||||
}))
|
||||
if (isSuperAdmin.value) {
|
||||
if (showDefaultTemplate.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
|
||||
@@ -120,6 +155,7 @@ export function useChannelDeptScope() {
|
||||
selectedDeptLabel,
|
||||
deptQueryParams,
|
||||
isConfigScope,
|
||||
isAllChannelScope,
|
||||
showDefaultTemplate
|
||||
}
|
||||
|
||||
@@ -147,6 +183,11 @@ export function bindChannelDeptToSearchParams(
|
||||
}
|
||||
|
||||
const apply = (deptId: number) => {
|
||||
if (channel.isAllChannelScope.value && deptId <= 0) {
|
||||
delete searchParams.dept_id
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
if (!channel.showDefaultTemplate.value && deptId <= 0) {
|
||||
return
|
||||
}
|
||||
@@ -174,6 +215,10 @@ export function useChannelDeptReload(loadFn: () => void | Promise<void>) {
|
||||
watch(
|
||||
() => channel.selectedDeptId.value,
|
||||
(deptId) => {
|
||||
if (channel.isAllChannelScope.value && deptId <= 0) {
|
||||
void loadFn()
|
||||
return
|
||||
}
|
||||
if (!channel.showDefaultTemplate.value && deptId <= 0) {
|
||||
return
|
||||
}
|
||||
@@ -186,8 +231,15 @@ export function useChannelDeptReload(loadFn: () => void | Promise<void>) {
|
||||
/** 请求参数:业务页附带 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 {}
|
||||
}
|
||||
@@ -209,18 +261,25 @@ export function getChannelDeptRequestParams(): { dept_id?: number } {
|
||||
return {}
|
||||
}
|
||||
|
||||
/** 保存/更新时附带 dept_id(优先渠道栏选中值,其次表单/行数据中的 dept_id) */
|
||||
/** 保存/更新时附带 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 }
|
||||
}
|
||||
const rowDeptId = payload.dept_id
|
||||
if (rowDeptId !== undefined && rowDeptId !== null && rowDeptId !== '') {
|
||||
const num = Number(rowDeptId)
|
||||
if (num > 0) {
|
||||
return { ...payload, dept_id: num }
|
||||
}
|
||||
if (hasRowDept && Number.isFinite(rowDeptNum) && rowDeptNum > 0) {
|
||||
return { ...payload, dept_id: rowDeptNum }
|
||||
}
|
||||
const channel = useInjectedChannelDept()
|
||||
if (channel && channel.selectedDeptId.value > 0) {
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"listTitle": "Channels",
|
||||
"defaultTemplate": "Default template",
|
||||
"defaultRoleTemplate": "Default role template",
|
||||
"allChannels": "All",
|
||||
"currentConfig": "Current config",
|
||||
"currentChannel": "Current channel",
|
||||
"currentRole": "Current roles"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,20 +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).",
|
||||
"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",
|
||||
"killWeightNote": "(Kill weights from pool config type=1; edit in list.)",
|
||||
"btnResetProfit": "Reset Player Total Profit",
|
||||
"btnSaveSafetyLine": "Save Safety Line",
|
||||
"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",
|
||||
@@ -55,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",
|
||||
|
||||
@@ -57,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)",
|
||||
@@ -84,6 +85,7 @@
|
||||
"rollArray": "Roll Array",
|
||||
"rollNumber": "Roll Number",
|
||||
"rewardTier": "Reward Tier",
|
||||
"remark": "Remark",
|
||||
"createTime": "Create Time",
|
||||
"updateTime": "Update Time"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"placeholderLotteryPool": "Leave empty for custom weights below, or select pool",
|
||||
"currentConfig": "Current Config",
|
||||
"configLabelName": "Name",
|
||||
"configLabelPoolName": "Pool name",
|
||||
"configLabelCode": "Code",
|
||||
"configLabelType": "Type",
|
||||
"configLabelWeights": "T1–T5 Weights",
|
||||
"configLabelRemark": "Remark",
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
"emptyTier": "No data for this tier",
|
||||
"sumLineDual": "Tier weight sum (clockwise): {cw}; counter-clockwise: {ccw} (each row 1–10000, ratio draw within tier, sum not limited)",
|
||||
"sumLineSingle": "Tier weight sum: {sum} (each row 1–10000, 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,33 +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 T1–T3."
|
||||
},
|
||||
"weightRatio": {
|
||||
"title": "Weight Ratio",
|
||||
"globalTip": "Configure dice_reward weights: first by direction (clockwise / counter-clockwise), then by tier (T1–T5); each row weight 1–10000, ratio draw within tier.",
|
||||
"globalTip": "Configure dice_reward weights: first by direction (clockwise / counter-clockwise), then by tier (T1–T5); each row weight 1–10000, ratio draw within tier for dice points (T4/T5 use the same logic as T1–T3 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: T1–T5 below apply when a free draw runs (tier roll; combined with dice_reward row weights).",
|
||||
"sectionFreeAfterPlayAgain": "Free draws (play again)",
|
||||
"tierProbHintFreeChain": "Custom tiers: T1–T5 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",
|
||||
"placeholderAnte": "Select ante config",
|
||||
"placeholderPaidPool": "Leave empty for custom tier odds below (default: default)",
|
||||
"placeholderFreePool": "Leave empty for custom tier odds below (default: killScore)",
|
||||
"anteRandomOption": "Random (each paid spin picks independently from channel ante configs)",
|
||||
"placeholderPaidPool": "Leave empty to set T1–T5 weights manually",
|
||||
"placeholderFreePool": "Leave empty to set T1–T5 weights manually",
|
||||
"selectedPoolHint": "Selected pool: {name}",
|
||||
"tierProbHint": "Custom tier odds (T1–T5), each 0–100%, sum of five must not exceed 100%",
|
||||
"tierFieldLabel": "Tier {tier} (%)",
|
||||
"tierSumError": "Current sum of five tiers is {sum}%, cannot exceed 100%",
|
||||
|
||||
@@ -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 (5–30) 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 (5–30), 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 0–25; each row’s grid_number is 5–30 and unique.\n• Roll D (5–30): 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 row’s “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, T1–T4 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 0–25; each row’s grid_number is 5–30 and unique.\n• Roll D (5–30): 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 row’s “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. T1–T4 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)",
|
||||
|
||||
@@ -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 (T1–T5, used in test)",
|
||||
"sectionFreeTier": "Free draw tier odds (T1–T5, used in test)",
|
||||
|
||||
27
saiadmin-artd/src/locales/langs/en/system/admin_guide.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
"listTitle": "渠道列表",
|
||||
"defaultTemplate": "默认配置模板",
|
||||
"defaultRoleTemplate": "默认角色模板",
|
||||
"allChannels": "全部",
|
||||
"currentConfig": "当前配置",
|
||||
"currentChannel": "当前渠道",
|
||||
"currentRole": "当前角色范围"
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"labelIsDefault": "默认底注",
|
||||
"placeholderName": "请输入名称",
|
||||
"placeholderTitle": "请输入标题",
|
||||
"placeholderNameAuto": "随底注倍率自动生成,如 x5",
|
||||
"placeholderTitleAuto": "随底注倍率自动生成,如 x5",
|
||||
"ruleNameRequired": "请输入名称",
|
||||
"ruleTitleRequired": "请输入标题",
|
||||
"ruleMultRequired": "请输入底注倍率",
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
"dialogTitleEdit": "编辑色子奖池配置",
|
||||
"placeholderName": "请输入名称",
|
||||
"placeholderRemark": "请输入备注",
|
||||
"placeholderPoolName": "请输入奖池名称,如:正常池",
|
||||
"placeholderConfigNote": "选填,用于说明该奖池用途或规则",
|
||||
"poolType": "奖池类型",
|
||||
"poolName": "奖池名称",
|
||||
"placeholderPoolType": "请选择奖池类型",
|
||||
"poolTypeNormal": "正常",
|
||||
"poolTypeKill": "强制杀猪",
|
||||
"poolTypeFree": "免费",
|
||||
"poolTypeKill": "强制杀分",
|
||||
"poolTypeT1": "T1高倍率",
|
||||
"safetyLine": "安全线",
|
||||
"t1Weight": "T1池权重(%)",
|
||||
@@ -21,20 +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 权重抽取档位(杀分)。",
|
||||
"profitCalcHint": "累计在 name=default(正常)奖池上:付费每局 += win_coin − paid_amount(ante×1);免费每局 += win_coin。用于与安全线比较,判定付费抽奖是否切换杀分。弹窗打开期间每 2 秒自动刷新。",
|
||||
"tierRuleTitle": "付费抽奖档位规则",
|
||||
"tierRuleContent": "比较对象为 default 奖池的 profit_amount(非单个玩家盈利)。当 profit_amount 低于安全线或未开启杀分时,付费按玩家 T*_weight 抽档;当 profit_amount 高于或等于安全线且已开启杀分时,付费按 killScore 奖池抽档。免费抽奖始终按本渠道 name=free 奖池权重(无 free 时回退 default),与安全线无关。",
|
||||
"enableKillScore": "开启杀分",
|
||||
"killScoreWeights": "杀分权重",
|
||||
"killWeightNote": "(杀分权重来自奖池配置,请在列表中编辑对应记录)",
|
||||
"btnResetProfit": "重置玩家累计盈利",
|
||||
"btnSaveSafetyLine": "保存安全线",
|
||||
"killScoreWeights": "杀分权重(killScore)",
|
||||
"killWeightNote": "杀分权重请在列表中编辑 name=killScore(强制杀分)记录;本弹窗仅配置 default 奖池的安全线与杀分开关。",
|
||||
"btnResetProfit": "重置彩金池累计盈利",
|
||||
"btnSaveSafetyLine": "保存安全线与杀分开关",
|
||||
"safetyLineDefaultOnlyHint": "仅 name=default(正常)奖池的安全线参与杀分判定;其它奖池类型请勿在此配置安全线。",
|
||||
"safetyLineNotUsedReadonly": "当前奖池类型不参与杀分判定,安全线仅对「正常(default)」奖池生效,请通过「查看当前彩金池」或编辑正常行修改。",
|
||||
"ruleSafetyLineRequired": "请输入安全线",
|
||||
"msgGetPoolFailed": "获取彩金池失败",
|
||||
"msgSaveSuccess": "保存成功",
|
||||
"msgResetProfitSuccess": "玩家累计盈利已重置为 0",
|
||||
"msgResetProfitSuccess": "彩金池累计盈利已重置为 0",
|
||||
"msgResetFailed": "重置失败",
|
||||
"ruleNameRequired": "名称必需填写",
|
||||
"rulePoolTypeRequired": "请选择奖池类型",
|
||||
@@ -52,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池权重",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"rollNumber": "摇取点数和",
|
||||
"rewardTier": "中奖档位",
|
||||
"rewardConfig": "奖励配置",
|
||||
"createTime": "创建时间",
|
||||
"usernameFuzzy": "用户名模糊",
|
||||
"nameFuzzy": "名称模糊",
|
||||
"uiTextFuzzy": "前端显示文本模糊",
|
||||
@@ -84,6 +85,7 @@
|
||||
"rollArray": "摇取点数",
|
||||
"rollNumber": "摇取点数和",
|
||||
"rewardTier": "中奖档位",
|
||||
"remark": "备注",
|
||||
"createTime": "创建时间",
|
||||
"updateTime": "更新时间"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"platformTotalProfit": "平台总盈利"
|
||||
},
|
||||
"search": {
|
||||
"lotteryPoolConfig": "彩金池配置",
|
||||
"placeholderLotteryPool": "请选择彩金池(可搜索 name)",
|
||||
"rewardConfigRecordId": "测试记录ID",
|
||||
"drawType": "抽奖类型",
|
||||
"direction": "方向",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"placeholderLotteryPool": "留空则使用下方自定义权重,或选择彩金池",
|
||||
"currentConfig": "当前配置",
|
||||
"configLabelName": "名称",
|
||||
"configLabelPoolName": "奖池名称",
|
||||
"configLabelCode": "内部标识",
|
||||
"configLabelType": "类型",
|
||||
"configLabelWeights": "T1~T5 权重",
|
||||
"configLabelRemark": "备注",
|
||||
|
||||
@@ -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,33 +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": "当使用自定义档位时:以下为「免费抽奖」时 T1~T5 的档位概率(仅在有免费局时参与摇档,与 dice_reward 格子权重共同决定结果)。",
|
||||
"sectionFreeAfterPlayAgain": "免费抽奖(再来一次)",
|
||||
"tierProbHintFreeChain": "自定义档位时:免费局 T1~T5 档位概率(与 dice_reward 格子权重共同决定结果)。",
|
||||
"stepPaid": "付费抽奖券",
|
||||
"stepFree": "免费抽奖券",
|
||||
"labelLotteryTypePaid": "测试数据档位类型",
|
||||
"labelLotteryTypeFree": "测试数据档位类型",
|
||||
"labelLotteryTypePaid": "付费档位奖池",
|
||||
"labelLotteryTypeFree": "免费档位奖池",
|
||||
"labelAnte": "底注",
|
||||
"placeholderAnte": "请选择底注配置",
|
||||
"placeholderPaidPool": "不选则下方自定义档位概率(默认 default)",
|
||||
"placeholderFreePool": "不选则下方自定义档位概率(默认 killScore)",
|
||||
"anteRandomOption": "随机(每局付费抽奖从当前渠道底注配置中独立抽取)",
|
||||
"placeholderPaidPool": "不选则下方手动设定 T1–T5 档位权重",
|
||||
"placeholderFreePool": "不选则下方手动设定 T1–T5 档位权重",
|
||||
"selectedPoolHint": "已选奖池:{name}",
|
||||
"tierProbHint": "自定义档位概率(T1~T5),每档 0-100%,五档之和不能超过 100%",
|
||||
"tierFieldLabel": "档位 {tier}(%)",
|
||||
"tierSumError": "当前五档之和为 {sum}%,不能超过 100%",
|
||||
|
||||
@@ -8,7 +8,19 @@
|
||||
"tabIndex": "奖励索引",
|
||||
"tabBigwin": "大奖权重",
|
||||
"tipIndex": "色子点数须在 5~30 之间且本表内不重复。",
|
||||
"tipBigwin": "从左至右:中大奖点数(不可改)、显示信息、实际中奖、备注、权重(0~10000)。点数 5、30 权重固定 100%。本表单独立提交,仅提交大奖权重。",
|
||||
"tierRecommendRules": "【结算金额与档位】【大奖】T1:结算金额>2;【小赚】T2:2>=结算金额>1;【抽水】T3:1>=结算金额>0;【惩罚】T4:0>结算金额;【再来一次】T5:0=结算金额。下方可为各档位填写推荐结算金额;表格中「所属档位」随结算金额自动计算,不可手动修改。",
|
||||
"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 默认 10;5/30 默认 1);确认导入后可再到「奖励对照」页面调整权重。",
|
||||
"createRefPreviewSkipped": "有 {n} 个点数在当前奖励索引中缺失,已跳过生成(请先补齐 5~30 共 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 升序为位置 0~25;每条配置的 grid_number 为 5~30 且不重复。\n• 摇取点数 D(5~30):起点为「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 → T4;0 < 结算金额 < 100 → T3;100 < 结算金额 < 200 → T2;200 < 结算金额 → T1;T5「再来一次」结算金额=0。下方可为各档位填写统一的 结算金额 标准,生成时写入配置;细则可稍后在表格中再改。\n\n【本弹窗输入】\n条数:T1/T4/T5「固定」;T2「不少于」——顺时针与逆时针的加权条数(每条摇取结果计一次)须分别满足所填数值;T1、T4 与 T5 分开填写。\n结算金额 标准:同档位各格使用同一数值。生成时 T1~T4 的 显示文本 / 显示文本(英文) = 结算金额;T5 固定为「再来一次」/「Once again」。备注仍区分完美回本/小赚等。",
|
||||
"ruleGenerateRules": "【生成逻辑(与创建奖励对照一致)】\n• 盘面 26 格按 id 升序为位置 0~25;每条配置的 grid_number 为 5~30 且不重复。\n• 摇取点数 D(5~30):起点为「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;【小赚】T2:2>=金额>1;【抽水】T3:1>=金额>0;【惩罚】T4:0>金额;【再来一次】T5:0=金额。下方可为各档位填写推荐结算金额标准,生成时写入配置。\n\n【本弹窗输入】\n条数:T1/T4/T5「固定」;T2「不少于」——顺时针与逆时针的加权条数(每条摇取结果计一次)须分别满足所填数值;T1、T4 与 T5 分开填写。\n结算金额 标准:同档位各格使用同一数值。生成时 T1~T4 的显示文本 = 结算金额;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 固定条数(顺/逆)",
|
||||
|
||||
@@ -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,测试时使用)",
|
||||
|
||||
27
saiadmin-artd/src/locales/langs/zh/system/admin_guide.json
Normal 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": "请先点击编辑后再保存"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,23 @@ const BUILTIN_CHANNEL_LAYOUT_PATHS = [
|
||||
'/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
|
||||
@@ -28,6 +45,20 @@ export function isRoleChannelRoute(route: Pick<RouteLocationNormalized, 'path' |
|
||||
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
|
||||
@@ -35,10 +66,10 @@ export function shouldWrapSuperAdminChannelLayout(route: RouteLocationNormalized
|
||||
if (route.meta?.isFullPage) {
|
||||
return false
|
||||
}
|
||||
if (route.meta?.noChannelLayout === true) {
|
||||
const path = route.path
|
||||
if (isNoChannelLayoutRoute(route)) {
|
||||
return false
|
||||
}
|
||||
const path = route.path
|
||||
for (let i = 0; i < BUILTIN_CHANNEL_LAYOUT_PATHS.length; i++) {
|
||||
if (path.startsWith(BUILTIN_CHANNEL_LAYOUT_PATHS[i])) {
|
||||
return false
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
19
saiadmin-artd/src/utils/navigation/resolveAppAssetUrl.ts
Normal 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}`
|
||||
}
|
||||
@@ -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">
|
||||
@@ -87,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) => {
|
||||
@@ -99,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()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,32 +45,14 @@ export default {
|
||||
* 获取 DiceLotteryPoolConfig 列表数据,含 id、name、t1_weight~t5_weight,用于一键测试权重档位类型下拉
|
||||
* name 映射:default=原 type=0,killScore=原 type=1,up=原 type=2
|
||||
*/
|
||||
async getOptions(params?: Record<string, unknown>): 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',
|
||||
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))
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import request from '@/utils/http'
|
||||
import { normalizeLotteryPoolOption, type LotteryPoolOption } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
|
||||
|
||||
/**
|
||||
* 玩家抽奖记录 API接口
|
||||
@@ -59,11 +60,13 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
/** 获取彩金池配置选项(id、name) */
|
||||
getLotteryConfigOptions(params?: Record<string, unknown>) {
|
||||
return request.get<{ id: number; name: string }[]>({
|
||||
/** 获取彩金池配置选项(含奖池名称) */
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import request from '@/utils/http'
|
||||
import { normalizeLotteryPoolOption, type LotteryPoolOption } from '@/views/plugin/dice/utils/lotteryPoolDisplay'
|
||||
|
||||
/**
|
||||
* 大富翁-玩家 API接口
|
||||
@@ -84,16 +85,15 @@ export default {
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取彩金池配置选项(DiceLotteryPoolConfig.id、name),供 lottery_config_id 下拉使用
|
||||
* @returns [ { id, name } ]
|
||||
* 获取彩金池配置选项,供 lottery_config_id 下拉使用(含奖池名称 display_name)
|
||||
*/
|
||||
async getLotteryConfigOptions(params?: Record<string, unknown>): 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',
|
||||
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))
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,6 +76,27 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 按规则生成并保存奖励索引(需 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 权重配比:按档位分组获取配置列表
|
||||
*/
|
||||
@@ -126,5 +147,15 @@ export default {
|
||||
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 || {}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,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">
|
||||
@@ -81,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() || '-'
|
||||
}
|
||||
|
||||
// 权重列带 %
|
||||
@@ -96,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,
|
||||
@@ -114,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',
|
||||
|
||||
@@ -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,6 +41,7 @@
|
||||
: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="$t('page.form.enableKillScore')">
|
||||
<el-switch v-model="formData.kill_enabled" :active-value="1" :inactive-value="0" />
|
||||
@@ -239,6 +240,8 @@
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
// 切换渠道后再打开弹窗时,先清掉旧池数据,避免视觉残留
|
||||
pool.value = null
|
||||
loadPool().then(() => startPolling())
|
||||
} else {
|
||||
stopPolling()
|
||||
|
||||
@@ -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/up;name 在新增时填写,编辑时禁用 -->
|
||||
<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 />
|
||||
@@ -111,17 +128,36 @@
|
||||
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
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始数据(权重为数字便于输入与校验)
|
||||
@@ -131,6 +167,7 @@
|
||||
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,
|
||||
@@ -224,11 +261,15 @@
|
||||
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(submitData)
|
||||
await api.save(payload)
|
||||
ElMessage.success(t('page.form.msgAddSuccess'))
|
||||
} else {
|
||||
await api.update(submitData)
|
||||
await api.update(payload)
|
||||
ElMessage.success(t('page.form.msgUpdateSuccess'))
|
||||
}
|
||||
emit('success')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="art-full-height">
|
||||
<!-- 搜索面板 -->
|
||||
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
|
||||
<TableSearch v-model="searchForm" @search="handleSearch" @reset="handleResetSearch" />
|
||||
|
||||
<ElCard class="art-table-card" shadow="never">
|
||||
<!-- 表格头部 -->
|
||||
@@ -143,6 +143,7 @@
|
||||
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'
|
||||
const { t } = useI18n()
|
||||
|
||||
// 搜索表单
|
||||
@@ -157,9 +158,40 @@
|
||||
roll_number_max: undefined,
|
||||
reward_ui_text: undefined,
|
||||
reward_tier: undefined,
|
||||
direction: undefined
|
||||
direction: undefined,
|
||||
create_time: undefined
|
||||
})
|
||||
|
||||
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 +201,19 @@
|
||||
return res
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = (params: Record<string, any>) => {
|
||||
Object.assign(searchParams, params)
|
||||
applySearchParams(params)
|
||||
getData()
|
||||
}
|
||||
|
||||
const handleResetSearch = () => {
|
||||
searchForm.value.create_time = undefined
|
||||
resetSearchParams()
|
||||
}
|
||||
|
||||
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 +256,7 @@
|
||||
core: {
|
||||
apiFn: listApi,
|
||||
apiParams: { limit: 100 },
|
||||
excludeParams: ['create_time'],
|
||||
columnsFactory: () => [
|
||||
// { type: 'selection' },
|
||||
{ prop: 'id', label: 'page.table.id', width: 80 },
|
||||
@@ -253,6 +289,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 },
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<el-option
|
||||
v-for="item in lotteryConfigOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:label="lotteryPoolOptionLabel(item)"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -180,6 +180,10 @@
|
||||
<script setup lang="ts">
|
||||
import api from '../../../api/play_record/index'
|
||||
import { getChannelDeptRequestParams } from '@/composables/useChannelDeptScope'
|
||||
import {
|
||||
lotteryPoolOptionLabel,
|
||||
type LotteryPoolOption
|
||||
} from '@/views/plugin/dice/utils/lotteryPoolDisplay'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
@@ -209,7 +213,7 @@
|
||||
})
|
||||
|
||||
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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copy } = useClipboard()
|
||||
@@ -141,10 +142,13 @@
|
||||
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 {
|
||||
|
||||
@@ -88,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>
|
||||
@@ -97,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>
|
||||
@@ -183,8 +183,16 @@
|
||||
|
||||
<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'
|
||||
@@ -272,7 +280,7 @@
|
||||
const formData = reactive({ ...initialFormData })
|
||||
|
||||
/** 彩金池配置下拉选项(DiceLotteryConfig id、name) */
|
||||
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
const lotteryConfigOptions = ref<LotteryPoolOption[]>([])
|
||||
/** 彩金池选项加载中 */
|
||||
const lotteryConfigLoading = ref(false)
|
||||
/** 后台管理员下拉选项(SystemUser) */
|
||||
@@ -298,7 +306,21 @@
|
||||
/** 管理员选项加载中 */
|
||||
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 ?? '')
|
||||
@@ -314,6 +336,15 @@
|
||||
return v == null || v === 0
|
||||
}
|
||||
|
||||
/** 将彩金池配置的 T1–T5 写入表单(绑定彩金池时展示与提交均以池为准) */
|
||||
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
|
||||
@@ -323,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
|
||||
}
|
||||
@@ -337,6 +367,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentLotteryConfig.value,
|
||||
(cfg) => {
|
||||
if (cfg && !isLotteryConfigEmpty()) {
|
||||
applyPoolWeightsToForm(cfg)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
@@ -352,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
|
||||
@@ -486,9 +524,12 @@
|
||||
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
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<el-option
|
||||
v-for="item in lotteryConfigOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:label="lotteryPoolOptionLabel(item)"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -65,6 +65,7 @@
|
||||
|
||||
<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 +78,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 () => {
|
||||
|
||||
@@ -78,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()
|
||||
@@ -132,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' }
|
||||
]
|
||||
|
||||
@@ -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="
|
||||
@@ -361,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
|
||||
@@ -465,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 })
|
||||
@@ -530,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;
|
||||
|
||||
@@ -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,
|
||||
@@ -433,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
|
||||
@@ -506,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 })
|
||||
}
|
||||
}
|
||||
@@ -567,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;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:title="$t('page.weightTest.title')"
|
||||
width="920px"
|
||||
width="960px"
|
||||
top="4vh"
|
||||
class="weight-test-dialog"
|
||||
:close-on-click-modal="false"
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<ElForm :model="form" label-width="108px" class="weight-test-form">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="8">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.weightTest.labelAnte')" prop="ante_config_id" required>
|
||||
<ElSelect
|
||||
v-model="form.ante_config_id"
|
||||
@@ -29,6 +29,10 @@
|
||||
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"
|
||||
@@ -38,130 +42,167 @@
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="8">
|
||||
<ElFormItem :label="$t('page.weightTest.labelKillModeEnabled')" prop="kill_mode_enabled">
|
||||
<ElSwitch v-model="form.kill_mode_enabled" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="8">
|
||||
<ElFormItem :label="$t('page.weightTest.labelTestSafetyLine')" prop="test_safety_line">
|
||||
</ElRow>
|
||||
|
||||
<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"
|
||||
:disabled="!form.kill_mode_enabled"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
class="kill-mode-field-input"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</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>
|
||||
<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="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>
|
||||
<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>
|
||||
<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="'n-' + c" :label="String(c)" :value="c" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElCol>
|
||||
|
||||
<ElCol :span="12">
|
||||
<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="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 :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>
|
||||
</ElForm>
|
||||
@@ -185,7 +226,7 @@
|
||||
<script setup lang="ts">
|
||||
import api from '../../../api/reward/index'
|
||||
import anteConfigApi from '../../../api/ante_config/index'
|
||||
import lotteryPoolApi from '../../../api/lottery_pool_config/index'
|
||||
import lotteryPoolApi, { type LotteryPoolConfigOption } from '../../../api/lottery_pool_config/index'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
@@ -193,6 +234,13 @@
|
||||
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 可能失效) */
|
||||
@@ -222,16 +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 }>>([])
|
||||
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
|
||||
)
|
||||
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() {
|
||||
@@ -296,6 +344,15 @@
|
||||
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) {
|
||||
@@ -321,20 +378,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
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(resolveDeptParams())
|
||||
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) {
|
||||
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
|
||||
}
|
||||
@@ -346,7 +418,6 @@
|
||||
function buildPayload() {
|
||||
const payload: Record<string, unknown> = {
|
||||
ante: form.ante,
|
||||
ante_config_id: form.ante_config_id,
|
||||
paid_s_count: form.paid_s_count,
|
||||
paid_n_count: form.paid_n_count,
|
||||
free_s_count: 0,
|
||||
@@ -355,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 {
|
||||
@@ -369,12 +445,15 @@
|
||||
}
|
||||
|
||||
function validateForm(): boolean {
|
||||
if (form.ante_config_id == null || form.ante_config_id <= 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
|
||||
}
|
||||
syncAnteFromSelect()
|
||||
if (form.ante == null || form.ante <= 0) {
|
||||
if (!isRandomAnte) {
|
||||
syncAnteFromSelect()
|
||||
}
|
||||
if (!isRandomAnte && (form.ante == null || form.ante <= 0)) {
|
||||
ElMessage.warning(t('page.weightTest.warnAnte'))
|
||||
return false
|
||||
}
|
||||
@@ -437,6 +516,7 @@
|
||||
if (v) {
|
||||
void loadAnteOptions()
|
||||
void loadLotteryOptions()
|
||||
void loadDefaultPoolInfo()
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
@@ -448,6 +528,7 @@
|
||||
if (visible.value) {
|
||||
void loadAnteOptions()
|
||||
void loadLotteryOptions()
|
||||
void loadDefaultPoolInfo()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -478,6 +559,91 @@
|
||||
: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);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -491,6 +657,9 @@
|
||||
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 {
|
||||
|
||||
@@ -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,6 +459,12 @@
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<CreateRewardReferencePreviewDialog
|
||||
v-model="createRewardPreviewVisible"
|
||||
:dept-id="filterDeptId"
|
||||
@success="loadIndexList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -453,20 +476,29 @@
|
||||
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)
|
||||
@@ -510,18 +542,101 @@
|
||||
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 一致:0~25 */
|
||||
const REWARD_INDEX_MIN = 0
|
||||
const REWARD_INDEX_MAX = 25
|
||||
@@ -564,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 {
|
||||
@@ -590,43 +748,7 @@
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
createRewardLoading.value = true
|
||||
try {
|
||||
const res: any = await api.createRewardReference({ dept_id: filterDeptId.value as number })
|
||||
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
|
||||
})
|
||||
}
|
||||
ElMessage.success(msg)
|
||||
loadIndexList()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message ?? t('page.configPage.createRefFail'))
|
||||
} finally {
|
||||
createRewardLoading.value = false
|
||||
}
|
||||
createRewardPreviewVisible.value = true
|
||||
}
|
||||
|
||||
function extractIndexList(res: unknown): Record<string, unknown>[] {
|
||||
@@ -651,6 +773,11 @@
|
||||
.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 }))
|
||||
})
|
||||
@@ -745,6 +872,10 @@
|
||||
}
|
||||
|
||||
function openRuleGenerateDialog() {
|
||||
if (!canTierRecommend.value) {
|
||||
return
|
||||
}
|
||||
syncRuleGenFromTierRecommend()
|
||||
ruleGenerateDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -781,6 +912,9 @@
|
||||
}
|
||||
|
||||
async function handleRuleGenerateApply() {
|
||||
if (!canTierRecommend.value) {
|
||||
return
|
||||
}
|
||||
const grids = extractGrids26()
|
||||
if (grids === null) {
|
||||
ElMessage.warning(t('page.configPage.ruleGenNeedFullGrid'))
|
||||
@@ -790,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) {
|
||||
@@ -850,7 +985,7 @@
|
||||
remark: r.remark
|
||||
})
|
||||
)
|
||||
await api.batchUpdate(indexPayload, { dept_id: filterDeptId.value })
|
||||
await api.generateIndexByRules(indexPayload, { dept_id: filterDeptId.value })
|
||||
ElMessage.success(
|
||||
t('page.configPage.ruleGenSuccess', {
|
||||
cwT1: sc.cw.T1,
|
||||
@@ -888,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) => ({
|
||||
@@ -957,7 +1095,7 @@
|
||||
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, { dept_id: filterDeptId.value })
|
||||
@@ -1058,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
549
saiadmin-artd/src/views/system/admin_guide/index.vue
Normal 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 ``
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -106,8 +106,15 @@
|
||||
ElMessage.success('删除成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 +28,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
|
||||
|
||||
@@ -92,15 +92,11 @@ 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();
|
||||
}
|
||||
@@ -114,7 +110,8 @@ class GameController extends BaseController
|
||||
|
||||
try {
|
||||
$logic = new UserLogic();
|
||||
$result = $logic->loginByUsername($username, $password, $lang, 0.0, $time, $adminId, $adminIdsInTopDept, $deptId);
|
||||
// 平台 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);
|
||||
}
|
||||
@@ -148,7 +145,18 @@ class GameController extends BaseController
|
||||
return $this->success($cached);
|
||||
}
|
||||
|
||||
$player = DicePlayer::field(self::PLAYER_INFO_DB_FIELDS)->where('username', $username)->where('dept_id', $deptId)->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::PARAMS_ERROR);
|
||||
}
|
||||
|
||||
@@ -48,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',
|
||||
|
||||
@@ -50,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 = '惩罚格奖励:玩家余额不足,已扣尽钱包剩余余额';
|
||||
|
||||
/**
|
||||
* 执行一局游戏
|
||||
@@ -116,7 +118,8 @@ class PlayStartLogic
|
||||
}
|
||||
|
||||
$configType0 = DiceLotteryPoolConfig::where('name', 'default')->where('dept_id', $configDeptId)->find();
|
||||
$configType1 = DiceLotteryPoolConfig::where('name', 'killScore')->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)');
|
||||
}
|
||||
@@ -124,41 +127,40 @@ 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', $configDeptId);
|
||||
$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_amount(default 奖池)
|
||||
$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($configDeptId);
|
||||
@@ -203,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);
|
||||
@@ -246,7 +248,7 @@ class PlayStartLogic
|
||||
// 中 BIGWIN 豹子:不走原奖励流程,不记录原奖励,不触发 T5 再来一次,仅发放豹子奖金
|
||||
$rewardWinCoin = 0.0;
|
||||
$realEv = 0.0;
|
||||
$isTierT5 = false;
|
||||
$isTierPlayAgain = false;
|
||||
} else {
|
||||
$rollArray = $this->generateNonSuperWinRollArrayWithSum($rollNumber);
|
||||
}
|
||||
@@ -265,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 ?? '');
|
||||
@@ -291,11 +297,33 @@ class PlayStartLogic
|
||||
$startIndex,
|
||||
$targetIndex,
|
||||
$rollArray,
|
||||
$isTierT5,
|
||||
$isTierPlayAgain,
|
||||
$tier,
|
||||
&$record
|
||||
&$record,
|
||||
&$settledWinCoin
|
||||
) {
|
||||
$rewardTier = ($isWin === 1 && $superWinCoin > 0) ? 'BIGWIN' : (string) ($tier ?? '');
|
||||
|
||||
$p = DicePlayer::find($playerId);
|
||||
if (!$p) {
|
||||
throw new \RuntimeException('玩家不存在');
|
||||
}
|
||||
$coinBefore = (float) $p->coin;
|
||||
// 开始前先扣付费金额,再加中奖金额(免费抽奖 paid_amount=0)
|
||||
$coinAfter = round($coinBefore - $paidAmount + $winCoin, 2);
|
||||
$recordWinCoin = $winCoin;
|
||||
$recordRewardWinCoin = $rewardWinCoin;
|
||||
$playRecordRemark = null;
|
||||
// T4 惩罚:扣完购券费用后若不足以支付全额惩罚,则扣尽钱包剩余余额并记录备注
|
||||
if ($rewardTier === 'T4' && $coinAfter < 0) {
|
||||
$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,
|
||||
@@ -305,9 +333,9 @@ class PlayStartLogic
|
||||
'ante' => $ante,
|
||||
'paid_amount' => $paidAmount,
|
||||
'is_win' => $isWin,
|
||||
'win_coin' => $winCoin,
|
||||
'win_coin' => $recordWinCoin,
|
||||
'super_win_coin' => $superWinCoin,
|
||||
'reward_win_coin' => $rewardWinCoin,
|
||||
'reward_win_coin' => $recordRewardWinCoin,
|
||||
'direction' => $direction,
|
||||
'reward_tier' => $rewardTier,
|
||||
'start_index' => $startIndex,
|
||||
@@ -315,19 +343,9 @@ class PlayStartLogic
|
||||
'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 = DicePlayer::find($playerId);
|
||||
if (!$p) {
|
||||
throw new \RuntimeException('玩家不存在');
|
||||
}
|
||||
$coinBefore = (float) $p->coin;
|
||||
// 开始前先扣付费金额,再加中奖金额(免费抽奖 paid_amount=0)
|
||||
$coinAfter = round($coinBefore - $paidAmount + $winCoin, 2);
|
||||
// T4 惩罚兜底:扣完购券费用后若余额不足以承受本次惩罚(导致为负),统一按“余额不足”提示
|
||||
if ($rewardTier === 'T4' && $coinAfter < 0) {
|
||||
throw new ApiException('余额不足');
|
||||
}
|
||||
$p->coin = $coinAfter;
|
||||
// 免费抽奖消耗:优先消耗 free_ticket.count,耗尽则清空 free_ticket;否则兼容旧 free_ticket_count
|
||||
if ($ticketType === self::LOTTERY_TYPE_FREE) {
|
||||
@@ -375,7 +393,7 @@ class PlayStartLogic
|
||||
// 若本局中奖档位为 T5,则额外赠送 1 次免费抽奖次数:
|
||||
// - 新结构:写入 free_ticket(ante=本局注数,count+1)
|
||||
// - 兼容旧结构:free_ticket_count +1
|
||||
if ($isTierT5) {
|
||||
if ($isTierPlayAgain) {
|
||||
$ft = $p->free_ticket ?? null;
|
||||
$ftAnte = null;
|
||||
$ftCount = 0;
|
||||
@@ -429,7 +447,7 @@ class PlayStartLogic
|
||||
// 彩金池累计盈利累加在 name=default 彩金池上:
|
||||
// 付费:每局按「本局赢取平台币 win_coin - 抽奖费用 paid_amount(ante*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([
|
||||
@@ -459,12 +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,
|
||||
@@ -654,6 +675,7 @@ class PlayStartLogic
|
||||
*/
|
||||
public function simulateOnePlay($config, int $direction, int $lotteryType = 0, int $ante = 1, ?array $customTierWeights = null, ?int $configDeptId = null): array
|
||||
{
|
||||
$useKillMode = false;
|
||||
$rewardInstance = DiceReward::getCachedInstance($configDeptId);
|
||||
$byTierDirection = $rewardInstance['by_tier_direction'] ?? [];
|
||||
$maxTierRetry = 10;
|
||||
@@ -776,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,54 @@ class UserLogic
|
||||
return array_map('intval', $adminIds ?: [(int) $admin->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户名查找玩家;不存在则创建并绑定渠道/管理员(供 getPlayerInfo 等接口)
|
||||
*
|
||||
* @param int|null $adminId 关联后台管理员 ID(sa_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');
|
||||
}
|
||||
|
||||
$query = DicePlayer::where('username', $username);
|
||||
if ($deptId !== null && $deptId > 0) {
|
||||
$query->where('dept_id', $deptId);
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录(JSON:username, password, lang, coin, time)
|
||||
* 存在则校验密码并更新 coin(累加);不存在则创建用户并写入 coin。
|
||||
@@ -76,7 +124,7 @@ class UserLogic
|
||||
* @param int|null $adminId 创建新用户时关联的后台管理员ID(sa_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, ?int $deptId = 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 === '') {
|
||||
@@ -95,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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
64
server/app/api/middleware/ApiKeyMiddleware.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -38,19 +38,23 @@ class DiceLotteryPoolConfigController extends BaseController
|
||||
#[Permission('色子奖池配置列表', 'dice:lottery_pool_config:index:index')]
|
||||
public function getOptions(Request $request): Response
|
||||
{
|
||||
$query = DiceLotteryPoolConfig::field('id,name,t1_weight,t2_weight,t3_weight,t4_weight,t5_weight')
|
||||
$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);
|
||||
|
||||
@@ -51,6 +51,8 @@ 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, $request->input('dept_id'));
|
||||
@@ -92,7 +94,7 @@ class DicePlayRecordController extends BaseController
|
||||
#[Permission('玩家抽奖记录列表', 'dice:play_record:index:index')]
|
||||
public function getLotteryConfigOptions(Request $request): Response
|
||||
{
|
||||
$query = DiceLotteryPoolConfig::field('id,name')->order('id', 'asc');
|
||||
$query = DiceLotteryPoolConfig::field('id,name,remark')->order('id', 'asc');
|
||||
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
|
||||
$request->input('dept_id'),
|
||||
$request->all()
|
||||
@@ -100,7 +102,13 @@ class DicePlayRecordController extends BaseController
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ class DicePlayRecordTestController extends BaseController
|
||||
{
|
||||
$where = $request->more([
|
||||
['reward_config_record_id', ''],
|
||||
['lottery_config_id', ''],
|
||||
['lottery_type', ''],
|
||||
['direction', ''],
|
||||
['is_win', ''],
|
||||
|
||||
@@ -43,7 +43,7 @@ class DicePlayerController extends BaseController
|
||||
#[Permission('玩家列表', 'dice:player:index:index')]
|
||||
public function getLotteryConfigOptions(Request $request): Response
|
||||
{
|
||||
$query = DiceLotteryPoolConfig::field('id,name')->order('id', 'asc');
|
||||
$query = DiceLotteryPoolConfig::field('id,name,remark')->order('id', 'asc');
|
||||
$requestDeptId = AdminScopeHelper::pickRequestDeptId(
|
||||
$request->input('dept_id'),
|
||||
$request->all()
|
||||
@@ -51,7 +51,13 @@ class DicePlayerController extends BaseController
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -96,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
|
||||
@@ -116,6 +116,7 @@ class DiceRewardController extends BaseController
|
||||
'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);
|
||||
|
||||
@@ -127,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)) {
|
||||
@@ -244,6 +263,23 @@ class DiceRewardConfigController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建奖励对照(预览):不写入 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 概率
|
||||
|
||||
@@ -24,6 +24,7 @@ class DiceAnteConfigLogic extends DiceBaseLogic
|
||||
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) {
|
||||
@@ -38,6 +39,7 @@ class DiceAnteConfigLogic extends DiceBaseLogic
|
||||
$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, $deptId);
|
||||
@@ -92,6 +94,21 @@ class DiceAnteConfigLogic extends DiceBaseLogic
|
||||
$data['is_default'] = ((int) $data['is_default']) === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
/** 名称、标题随底注倍率自动设为 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);
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
// +----------------------------------------------------------------------
|
||||
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 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;
|
||||
|
||||
/**
|
||||
* 色子奖池配置逻辑层
|
||||
@@ -39,7 +43,7 @@ class DiceLotteryPoolConfigLogic extends DiceBaseLogic
|
||||
{
|
||||
$pickedDeptId = AdminScopeHelper::pickRequestDeptId($requestDeptId, $data);
|
||||
$deptId = AdminScopeHelper::resolveConfigDeptId($adminInfo, $pickedDeptId);
|
||||
return ConfigScopeEditHelper::updateByPkAndDept(
|
||||
$result = ConfigScopeEditHelper::updateByPkAndDept(
|
||||
$this->model,
|
||||
$id,
|
||||
$deptId,
|
||||
@@ -48,6 +52,106 @@ class DiceLotteryPoolConfigLogic extends DiceBaseLogic
|
||||
$adminInfo,
|
||||
$pickedDeptId
|
||||
);
|
||||
if ($result) {
|
||||
$pool = DiceLotteryPoolConfig::where('id', $id)->find();
|
||||
if ($pool && $pool->isPlayerDefaultTemplate()) {
|
||||
$this->syncPlayersBoundToPlayerDefaultPool($pool);
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改 playerDefault 后:同步同渠道所有绑定该池(及应跟随 playerDefault 的玩家)T1–T5 权重,并刷新 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
namespace app\dice\logic\player;
|
||||
|
||||
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;
|
||||
@@ -48,9 +49,30 @@ class DicePlayerLogic extends DiceBaseLogic
|
||||
} else {
|
||||
unset($data['password']);
|
||||
}
|
||||
$data = $this->applyLotteryPoolWeightsToPlayerData($data);
|
||||
return parent::edit($id, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已绑定彩金池时:玩家 T1–T5 以池配置为准,避免前端提交陈旧权重覆盖同步结果
|
||||
*/
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -20,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'];
|
||||
|
||||
@@ -43,19 +53,21 @@ class DiceRewardLogic
|
||||
$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 ($adminInfo !== null) {
|
||||
AdminScopeHelper::applyConfigScope($query, $adminInfo, $requestDeptId, 'r.dept_id');
|
||||
}
|
||||
|
||||
if ($tier !== '') {
|
||||
$query->where('r.tier', $tier);
|
||||
}
|
||||
->order('r.grid_number', 'asc');
|
||||
|
||||
$paginator = $query->paginate($limit, false, ['page' => $page]);
|
||||
$arr = $paginator->toArray();
|
||||
@@ -81,6 +93,41 @@ 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=逆时针
|
||||
@@ -327,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 生成记录。
|
||||
*
|
||||
@@ -339,9 +474,9 @@ class DiceRewardLogic
|
||||
* - 奖励档位:tier = DiceRewardConfig::where('id', $end_index)->first()->tier
|
||||
* - 显示ui:ui_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 为 10,5/30 为 1,其余为 100
|
||||
*
|
||||
* 例如顺时针摇取点数为 5 时:start_index = 配置中 grid_number=5 对应格位的 id,
|
||||
* 结束位置 = (起始位置 + grid_number) % 26,再取该位置的 config 的 id 作为 end_index。
|
||||
@@ -352,10 +487,76 @@ class DiceRewardLogic
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function createRewardReferenceFromConfig(?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);
|
||||
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();
|
||||
if ($normalizedDeptId === null) {
|
||||
Db::table($table)->whereNull('dept_id')->delete();
|
||||
} else {
|
||||
Db::table($table)->where('dept_id', $normalizedDeptId)->delete();
|
||||
}
|
||||
|
||||
$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 === \app\dice\helper\AdminScopeHelper::DEFAULT_TEMPLATE_DEPT) {
|
||||
$templateId = \app\dice\helper\AdminScopeHelper::DEFAULT_TEMPLATE_DEPT;
|
||||
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');
|
||||
});
|
||||
@@ -367,25 +568,25 @@ class DiceRewardLogic
|
||||
if (empty($list)) {
|
||||
throw new ApiException('Reward config is empty, please maintain dice_reward_config first');
|
||||
}
|
||||
$configCount = count($list);
|
||||
if ($configCount < self::BOARD_SIZE) {
|
||||
if (count($list) < self::BOARD_SIZE) {
|
||||
throw new ApiException(
|
||||
\app\api\util\ApiLang::translateParams(
|
||||
'奖励配置需覆盖 26 个格位(id 0-25 或 1-26),当前仅 %s 条,无法完整生成 5-30 共26个点数、顺时针与逆时针的奖励对照',
|
||||
[$configCount]
|
||||
[count($list)]
|
||||
)
|
||||
);
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
$table = (new DiceReward())->getTable();
|
||||
if ($deptId === null) {
|
||||
Db::table($table)->whereNull('dept_id')->delete();
|
||||
} else {
|
||||
Db::table($table)->where('dept_id', $deptId)->delete();
|
||||
}
|
||||
DiceReward::refreshCache($deptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
|
||||
|
||||
// 按 id 排序后,盘面位置 0..25 对应 $list[$pos],避免 config.id 非 0-25/1-26 时取模结果找不到
|
||||
/**
|
||||
* 计算 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;
|
||||
@@ -394,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++;
|
||||
@@ -414,115 +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,
|
||||
];
|
||||
$existingQuery = DiceReward::where('direction', DiceReward::DIRECTION_CLOCKWISE)->where('grid_number', $gridNumber);
|
||||
if ($deptId === null) {
|
||||
$existingQuery->whereNull('dept_id');
|
||||
} else {
|
||||
$existingQuery->where('dept_id', $deptId);
|
||||
}
|
||||
$existing = $existingQuery->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;
|
||||
if ($deptId !== null) {
|
||||
$m->dept_id = $deptId;
|
||||
}
|
||||
$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,
|
||||
];
|
||||
$existingQuery = DiceReward::where('direction', DiceReward::DIRECTION_COUNTERCLOCKWISE)->where('grid_number', $gridNumber);
|
||||
if ($deptId === null) {
|
||||
$existingQuery->whereNull('dept_id');
|
||||
} else {
|
||||
$existingQuery->where('dept_id', $deptId);
|
||||
}
|
||||
$existing = $existingQuery->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;
|
||||
if ($deptId !== null) {
|
||||
$m->dept_id = $deptId;
|
||||
}
|
||||
$m->save();
|
||||
$createdCcw++;
|
||||
}
|
||||
$rows[] = $this->buildReferenceRowFromLandingConfig(
|
||||
$tier,
|
||||
$configCcw,
|
||||
DiceReward::DIRECTION_COUNTERCLOCKWISE,
|
||||
$gridNumber,
|
||||
$startId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['rows' => $rows, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
DiceReward::refreshCache($deptId ?? AdminScopeHelper::DEFAULT_TEMPLATE_DEPT);
|
||||
/**
|
||||
* 对照表落点行:档位按落点格 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 app\dice\basic\DiceBaseLogic;
|
||||
use plugin\saiadmin\exception\ApiException;
|
||||
@@ -77,6 +78,51 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
|
||||
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(权重快照)、DiceRewardConfig(BIGWIN weight)、DiceLotteryPoolConfig(付费/免费 T1-T5)
|
||||
* @param int $recordId 测试记录 ID
|
||||
@@ -268,7 +314,8 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
|
||||
is_array($params) ? $params : []
|
||||
);
|
||||
$allowed = [100, 500, 1000, 5000];
|
||||
$ante = $this->resolveWeightTestAnte($params, $deptId);
|
||||
$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;
|
||||
@@ -283,7 +330,12 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
|
||||
$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');
|
||||
}
|
||||
@@ -405,6 +457,9 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
|
||||
if ($chainFreeMode) {
|
||||
$tierWeightsSnapshot['chain_free_mode'] = true;
|
||||
}
|
||||
if (!empty($params['ante_random'])) {
|
||||
$tierWeightsSnapshot['ante_random'] = true;
|
||||
}
|
||||
|
||||
$record = new DiceRewardConfigRecord();
|
||||
$plannedPaidSpins = $paidS + $paidN;
|
||||
@@ -494,6 +549,21 @@ class DiceRewardConfigRecordLogic extends DiceBaseLogic
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,8 @@ 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;
|
||||
@@ -15,7 +17,10 @@ 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
|
||||
{
|
||||
@@ -57,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;
|
||||
@@ -68,11 +72,18 @@ class WeightTestRunner
|
||||
|
||||
$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);
|
||||
$configType1 = DiceLotteryPoolConfig::findByNameForDept('killScore', $deptId);
|
||||
$configKill = DiceLotteryPoolConfig::findByNameForDept('killScore', $deptId);
|
||||
$configFree = DiceLotteryPoolConfig::findByNameForDept('free', $deptId);
|
||||
if (!$configType0) {
|
||||
$this->markFailed($recordId, '彩金池配置 name=default 不存在(当前渠道)');
|
||||
return;
|
||||
@@ -92,9 +103,9 @@ class WeightTestRunner
|
||||
if (!$paidPoolConfig || AdminScopeHelper::normalizeRecordDeptId($paidPoolConfig->dept_id ?? null) !== $deptId) {
|
||||
$paidPoolConfig = $configType0;
|
||||
}
|
||||
$freePoolConfig = $freePoolConfigId > 0 ? DiceLotteryPoolConfig::find($freePoolConfigId) : $configType1;
|
||||
$freePoolConfig = $freePoolConfigId > 0 ? DiceLotteryPoolConfig::find($freePoolConfigId) : $configFree;
|
||||
if (!$freePoolConfig || AdminScopeHelper::normalizeRecordDeptId($freePoolConfig->dept_id ?? null) !== $deptId) {
|
||||
$freePoolConfig = $configType1 ?: $configType0;
|
||||
$freePoolConfig = $configFree ?: $configType0;
|
||||
}
|
||||
|
||||
if ($paidTierWeightsCustom !== null && array_sum($paidTierWeightsCustom) <= 0) {
|
||||
@@ -116,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 = [];
|
||||
@@ -133,14 +147,16 @@ class WeightTestRunner
|
||||
$paidS,
|
||||
$paidN,
|
||||
$ante,
|
||||
$anteRandom,
|
||||
$configType0,
|
||||
$paidPoolConfig,
|
||||
$freePoolConfig,
|
||||
$configType1,
|
||||
$paidTierWeightsCustom,
|
||||
$configKill,
|
||||
$paidPlayerWeights,
|
||||
$freeTierWeightsCustom,
|
||||
$killModeEnabled,
|
||||
$testSafetyLine,
|
||||
$playerProfitTotal,
|
||||
$poolProfitTotal,
|
||||
$resultCounts,
|
||||
$tierCounts,
|
||||
$buffer,
|
||||
@@ -172,14 +188,16 @@ class WeightTestRunner
|
||||
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,
|
||||
@@ -187,27 +205,46 @@ 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;
|
||||
@@ -215,9 +252,12 @@ class WeightTestRunner
|
||||
}
|
||||
|
||||
$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, $deptId);
|
||||
$done++;
|
||||
@@ -232,6 +272,137 @@ 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 时读不到
|
||||
*/
|
||||
@@ -316,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ use app\dice\model\DiceModel;
|
||||
*
|
||||
* @property $id ID
|
||||
* @property $name 名称
|
||||
* @property $remark 备注
|
||||
* @property $remark 奖池名称(后台展示名)
|
||||
* @property $config_note 配置备注
|
||||
* @property $safety_line 安全线
|
||||
* @property $kill_enabled 是否启用杀分:0=关闭 1=开启
|
||||
* @property $create_time 创建时间
|
||||
@@ -31,6 +32,9 @@ use app\dice\model\DiceModel;
|
||||
*/
|
||||
class DiceLotteryPoolConfig extends DiceModel
|
||||
{
|
||||
/** 玩家默认彩金池(新玩家关联;付费未杀分时运行时读取该池 T1–T5 权重) */
|
||||
public const NAME_PLAYER_DEFAULT = 'playerDefault';
|
||||
|
||||
/**
|
||||
* 数据表主键
|
||||
* @var string
|
||||
@@ -43,6 +47,9 @@ class DiceLotteryPoolConfig extends DiceModel
|
||||
*/
|
||||
protected $table = 'dice_lottery_pool_config';
|
||||
|
||||
/** 列表/关联 JSON 附带奖池展示名 */
|
||||
protected $append = ['display_name'];
|
||||
|
||||
/**
|
||||
* 按名称与渠道查找奖池配置(一键测试等场景,避免命中其他渠道同名配置)
|
||||
*/
|
||||
@@ -53,12 +60,68 @@ class DiceLotteryPoolConfig extends DiceModel
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 结束索引
|
||||
@@ -87,13 +88,17 @@ class DicePlayRecord extends DiceModel
|
||||
}
|
||||
}
|
||||
|
||||
/** 按彩金池配置名称模糊(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 DiceModel
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,15 @@ class DicePlayRecordTest extends DiceModel
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// +----------------------------------------------------------------------
|
||||
namespace app\dice\model\player;
|
||||
|
||||
use app\dice\helper\AdminScopeHelper;
|
||||
use app\dice\model\DiceModel;
|
||||
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
|
||||
|
||||
@@ -84,26 +85,61 @@ class DicePlayer extends DiceModel
|
||||
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_weight~t5_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_weight~t5_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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -54,6 +55,22 @@ class DiceChannelConfigService
|
||||
'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) 唯一约束
|
||||
*/
|
||||
@@ -193,9 +210,99 @@ class DiceChannelConfigService
|
||||
}
|
||||
$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)
|
||||
*/
|
||||
@@ -281,6 +388,7 @@ class DiceChannelConfigService
|
||||
}
|
||||
$summary[$deptId] = $this->copyDefaultConfigToDept($deptId);
|
||||
}
|
||||
$summary['_player_default_pools'] = $this->ensurePlayerDefaultPoolsAllChannels();
|
||||
return $summary;
|
||||
}
|
||||
|
||||
@@ -417,21 +525,54 @@ class DiceChannelConfigService
|
||||
if ($userCount > 0) {
|
||||
throw new ApiException('This channel has users, please delete or transfer them first');
|
||||
}
|
||||
$allowed = array_keys(self::RELATION_TABLES);
|
||||
foreach ($deleteTables as $table) {
|
||||
if (!in_array($table, $allowed, true)) {
|
||||
continue;
|
||||
$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();
|
||||
}
|
||||
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());
|
||||
}
|
||||
SystemDept::destroy($deptId, true);
|
||||
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>>
|
||||
*/
|
||||
@@ -477,7 +618,9 @@ class DiceChannelConfigService
|
||||
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('dept_id', 'null');
|
||||
$q->where('dept_id', $templateId)->whereOr(function ($sub) {
|
||||
$sub->whereNull('dept_id');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
$query->where('dept_id', $deptId);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -11,6 +11,8 @@ return [
|
||||
'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 秒
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
84
server/db/audit_channel_config.php
Normal 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";
|
||||
16
server/db/check_reward_config.php
Normal 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";
|
||||
}
|
||||
11
server/db/count_config_tables.php
Normal 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";
|
||||
}
|
||||
52
server/db/debug_check_table_type.php
Normal 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";
|
||||
|
||||
47
server/db/debug_list_view_backup_objects.php
Normal 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";
|
||||
}
|
||||
|
||||
46
server/db/debug_list_views_and_definers.php
Normal 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";
|
||||
}
|
||||
|
||||
61
server/db/debug_reward_api_user123.php
Normal 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";
|
||||
}
|
||||
46
server/db/debug_reward_index_user123.php
Normal 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";
|
||||
39
server/db/debug_reward_query_detail.php
Normal 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);
|
||||
}
|
||||
36
server/db/debug_user123_reward.php
Normal 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";
|
||||
26
server/db/dept_flatten_channels.sql
Normal 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';
|
||||
33
server/db/dice_flowcharts_menu.sql
Normal 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'
|
||||
);
|
||||
3
server/db/dice_play_record_add_remark.sql
Normal 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`;
|
||||
10
server/db/dice_player_dept_username_unique.sql
Normal 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`);
|
||||
2
server/db/dice_player_username_index.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 已废弃:请使用 dice_player_dept_username_unique.sql(同一渠道 dept_id + username 唯一)
|
||||
-- 历史脚本保留说明,勿再单独执行本文件
|
||||
23
server/db/dice_reward_config_tier_recommend_menu.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- 奖励配置:档位结算推荐配置(T1-T5 推荐金额、按规则生成)按钮权限
|
||||
-- 挂载在「奖励配置」菜单(type=2)下;slug 与 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
|
||||
);
|
||||
92
server/db/dice_tables_add_dept_id.sql
Normal 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;
|
||||
127
server/db/fix_bt_backup_view_tables.php
Normal 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";
|
||||
|
||||
93
server/db/inspect_player_destroy.php
Normal 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";
|
||||
35
server/db/remove_safeguard_ops_role_menus.sql
Normal 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'
|
||||
);
|
||||
14
server/db/remove_system_post.sql
Normal 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
@@ -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";
|
||||
16
server/db/run_backfill_dept_id.php
Normal 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";
|
||||
46
server/db/run_channel_config_init.php
Normal 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";
|
||||
55
server/db/run_dept_flatten_channels.php
Normal 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";
|
||||
});
|
||||
99
server/db/run_dice_flowcharts_menu.php
Normal 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";
|
||||
44
server/db/run_dice_play_record_add_remark.php
Normal 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";
|
||||
53
server/db/run_dice_player_dept_username_unique.php
Normal 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";
|
||||
60
server/db/run_dice_reward_config_tier_recommend_menu.php
Normal 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);
|
||||
}
|
||||
33
server/db/run_fill_missing_channel_config.php
Normal 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";
|
||||
100
server/db/run_remove_safeguard_ops_role_menus.php
Normal 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";
|
||||
74
server/db/run_sa_system_role_dept_id.php
Normal 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 '所属渠道ID,0=默认模板' 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";
|
||||
58
server/db/run_sync_channel_config.php
Normal 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";
|
||||
107
server/db/run_system_admin_guide_menu.php
Normal 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";
|
||||
85
server/db/run_system_log_dept_id.php
Normal 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";
|
||||
10
server/db/sa_system_role_add_dept_id.sql
Normal 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 '所属渠道ID,0=默认模板' 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`);
|
||||
21
server/db/sync_channel_default_roles.php
Normal 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";
|
||||
39
server/db/system_admin_guide_menu.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- 后台操作指南顶级菜单与权限
|
||||
-- 说明:挂载到顶级菜单(parent_id=0),内容来源 server/docs/ADMIN_GUIDE.md
|
||||
|
||||
SET @now = NOW();
|
||||
|
||||
-- 1) 创建后台操作指南顶级菜单(type=2,parent_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);
|
||||
20
server/db/test_reward_config_list.php
Normal 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";
|
||||
18
server/db/test_reward_config_scope.php
Normal 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";
|
||||
}
|
||||
45
server/db/verify_channel_init.php
Normal 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";
|
||||
}
|
||||
26
server/db/verify_reward_index_all.php
Normal 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
@@ -0,0 +1,128 @@
|
||||
# 大富翁-使用说明指南
|
||||
|
||||
## 菜单简单介绍
|
||||
|
||||
### 工作台/统计页面:统计数据
|
||||
|
||||

|
||||
|
||||
### 角色管理:对角色的菜单权限设置
|
||||
|
||||
按等级设定,等级越低权限越少(不要出现上级角色没有的权限,子角色有)
|
||||
避免方式:使用子角色创建下级角色,可以避免下级角色比上级角色操作权限更多的问题
|
||||
|
||||

|
||||
|
||||
这里设置角色的菜单以及按钮权限
|
||||
|
||||

|
||||
|
||||
### 彩金池配置:监听彩金池实时变化
|
||||
|
||||
可以实时监听彩金池累积金额的变化
|
||||
|
||||

|
||||
|
||||
### 游戏配置:游戏规则和平台币转化比
|
||||
|
||||
游戏配置
|
||||
|
||||

|
||||
|
||||
其中游戏玩法为进入游戏的弹窗,和规则介绍(无特殊需求不需要大改)
|
||||
|
||||

|
||||
|
||||
游戏平台币兑换币:为进入平台时平台比转化比,比如,当前设置的为1:1,如果从jk8平台转入100,那么获取的游戏币为100,如果设置1:2则获取的平台币为200
|
||||
|
||||
### 底注配置:方便玩家快速调整压注倍率
|
||||
|
||||
底注配置
|
||||
|
||||

|
||||
|
||||
对应游戏中的,其中每次游玩对局基础消耗为1游戏币(无法修改),底注的设置只是方便玩家快速修改压注金额
|
||||
|
||||

|
||||
|
||||
## 抽奖逻辑
|
||||
|
||||
### 判断抽奖档位
|
||||
|
||||
当前的抽奖逻辑时,按照抽奖档位(T1-T5)进行抽奖,在【玩家管理】菜单中的设置玩家具体的档位权重
|
||||
|
||||

|
||||
|
||||
也可以在【彩金池配置】菜单中设置玩家正常的抽奖档位权重
|
||||
|
||||
- 其中正常的档位权重为注册玩家默认绑定的档位权重,并且只有在修改完后,创建的玩家才能绑定最新的正常档位权重
|
||||
|
||||

|
||||
|
||||
- 其中free为杀分权重为如果当前彩金池(平台)盈利超过设置的安全线则制动走杀分的权重
|
||||
|
||||

|
||||
|
||||
- 剩余的两个权重可以方便快速切换用户的档位抽奖权重
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 根据档位抽取中奖号码
|
||||
|
||||
#### 设置中奖号码地图
|
||||
|
||||
在后台设置地图缩影
|
||||
|
||||

|
||||
|
||||
地图的索引参看如下
|
||||
|
||||

|
||||
|
||||
其中地图的索引可以按照需求点击图中的按规则生成
|
||||
|
||||
并且规则尽可能符合:结算金额>2 → T1;2>=结算金额>1 → T2;1>=结算金额>0 → T3;0>结算金额 → T4(惩罚);0=结算金额 → T5(再来一次)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### 创建完地图索引后创建相应的奖励对照表
|
||||
|
||||
创建奖励对照表的原因是由于有每个号码的权重不一样,豹子号10,15,20,25有多重组合方式,所以需要设置奖励对照表中的权重配比
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
根据抽到的奖励档位,抽取号码(主要用于设置抽取豹子号的5,10,15,20,25,30的权重)
|
||||
|
||||

|
||||
|
||||
比如上图中如果不设置,色子点数5抽到的概率和其他点数的概率是一样的,可能抽7次T1奖励,就有1次中豹子号5的可能
|
||||
|
||||
由于抽到色子点数和为10,15,20,25的色子点数组合有多种,所以在抽该这四个点数时还需要单独配置相应的中大奖概率(其中豹子号5和30只有一种组合【1,1,1,1,1】和【6,6,6,6,6】,所以不需要配置),其中权重拉到最大10000,那么中奖概率为100%(只要摇到了相应的色子点数和则中奖概率为100%)
|
||||
|
||||

|
||||
|
||||
#### 扩展
|
||||
|
||||
- 测试设置的中奖概率,根据如下设置可以测试当前设置权重的中奖概率,该测试数据不记录到真实数据系统中
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
能够准确的反馈抽中点数的统计
|
||||
|
||||

|
||||
|
||||
如果当前中奖概率符合预期,或则测试多组数据选取一组符合预期的导入到当前的配置中
|
||||
|
||||

|
||||
|
||||
这里可以详情查询到指定测试记录的详情,
|
||||
|
||||

|
||||
@@ -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",
|
||||
|
||||
@@ -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 integrator’s 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 integrator’s 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",
|
||||
|
||||
192
server/docs/flowcharts/dice-为何抽到该奖励.html
Normal 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[按「杀分奖池」的 T1~T5 档位概率抽签]
|
||||
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
|
||||
KillCheck -->|是| PoolKill
|
||||
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1~T5 档位概率抽签]
|
||||
|
||||
PoolKill --> DrawTier[随机抽出档位 T1~T5]
|
||||
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[按「杀分奖池」的 T1~T5 档位概率抽签]
|
||||
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
|
||||
KillCheck -->|是| PoolKill
|
||||
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1~T5 档位概率抽签]
|
||||
|
||||
PoolKill --> DrawTier[随机抽出档位 T1~T5]
|
||||
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>:先抽档位 T1~T5(大奖 / 小赚 / 抽水 / 惩罚 / 再来一次),再在该档位 + 方向的多条奖励里按权重抽具体点数与结算金额。</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>
|
||||
44
server/docs/flowcharts/dice-为何抽到该奖励.mmd
Normal file
@@ -0,0 +1,44 @@
|
||||
flowchart TD
|
||||
Start([玩家开始一局抽奖]) --> Dir[选择方向:顺时针 或 逆时针]
|
||||
Dir --> Ante[选择底注倍数]
|
||||
Ante --> Type{本局是否使用免费抽奖券?}
|
||||
|
||||
Type -->|是| Free[免费局]
|
||||
Type -->|否且平台币足够| Paid[付费局:扣除底注对应平台币]
|
||||
|
||||
Free --> PoolKill[按「杀分奖池」的 T1~T5 档位概率抽签]
|
||||
Paid --> KillCheck{彩金池已开启杀分<br/>且彩金池累计盈利 ≥ 安全线?}
|
||||
KillCheck -->|是| PoolKill
|
||||
KillCheck -->|否| PlayerW[按该玩家在「玩家管理」<br/>配置的 T1~T5 档位概率抽签]
|
||||
|
||||
PoolKill --> DrawTier[随机抽出档位 T1~T5]
|
||||
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
|
||||
219
server/docs/flowcharts/dice-后台中奖逻辑配置.html
Normal 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[② 先随机抽出档位 T1~T5] --> 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/>按 T1~T5 填各点数权重 → 按钮「提交」]
|
||||
|
||||
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[② 先随机抽出档位 T1~T5] --> 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/>按 T1~T5 填各点数权重 → 按钮「提交」]
|
||||
|
||||
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>
|
||||
42
server/docs/flowcharts/dice-后台中奖逻辑配置.mmd
Normal file
@@ -0,0 +1,42 @@
|
||||
flowchart TD
|
||||
O([按一局抽奖的真实顺序配置后台]) --> L1
|
||||
|
||||
L1[① 玩家选方向 + 底注] --> L1A[可选:大富翁-色子游戏 → 底注配置<br/>按钮:新增 / 行内编辑 → 提交]
|
||||
L1A --> L2
|
||||
|
||||
L2[② 先随机抽出档位 T1~T5] --> 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/>按 T1~T5 填各点数权重 → 按钮「提交」]
|
||||
|
||||
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
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -107,11 +107,12 @@ class SystemDeptController extends BaseController
|
||||
#[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->post('delete_tables', []);
|
||||
$deleteTables = $request->input('delete_tables', []);
|
||||
if (!is_array($deleteTables)) {
|
||||
$deleteTables = [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ use support\think\Db;
|
||||
*/
|
||||
class SystemRoleLogic extends BaseLogic
|
||||
{
|
||||
protected string $orderField = 'level';
|
||||
|
||||
protected string $orderType = 'desc';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->model = new SystemRole();
|
||||
@@ -110,7 +114,6 @@ class SystemRoleLogic extends BaseLogic
|
||||
$query->where('level', '<', $maxLevel);
|
||||
}
|
||||
$query->where('id', '<>', SystemRoleChannelService::SUPER_ADMIN_ROLE_ID);
|
||||
$query->order('sort', 'desc');
|
||||
return $this->getAll($query);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -189,12 +189,16 @@ class SystemRoleChannelService
|
||||
if ($this->tableHasColumn('sa_system_role', 'dept_id')) {
|
||||
$query->where('dept_id', 0);
|
||||
}
|
||||
$rows = $query->order('sort', 'desc')->select()->toArray();
|
||||
if (count($rows) === count($codes)) {
|
||||
return $rows;
|
||||
$rows = $query->select()->toArray();
|
||||
if (empty($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 按配置顺序返回,缺失的 code 跳过
|
||||
if (count($rows) === count($codes)) {
|
||||
return $this->sortRolesByLevelDesc($rows);
|
||||
}
|
||||
|
||||
// 按配置 code 补齐,缺失的 code 跳过,最终按角色级别从大到小排序
|
||||
$byCode = [];
|
||||
foreach ($rows as $row) {
|
||||
$byCode[(string) ($row['code'] ?? '')] = $row;
|
||||
@@ -205,7 +209,25 @@ class SystemRoleChannelService
|
||||
$ordered[] = $byCode[$code];
|
||||
}
|
||||
}
|
||||
return $ordered;
|
||||
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
|
||||
|
||||
@@ -81,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']);
|
||||
@@ -115,7 +119,9 @@ 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);
|
||||
|
||||
BIN
server/public/docs/picture/guide_01.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
server/public/docs/picture/guide_02.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
server/public/docs/picture/guide_03.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
server/public/docs/picture/guide_04.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
server/public/docs/picture/guide_05.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
server/public/docs/picture/guide_06.png
Normal file
|
After Width: | Height: | Size: 671 KiB |
BIN
server/public/docs/picture/guide_07.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
server/public/docs/picture/guide_08.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
server/public/docs/picture/guide_09.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
server/public/docs/picture/guide_10.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
server/public/docs/picture/guide_11.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
server/public/docs/picture/guide_12.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
server/public/docs/picture/guide_13.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
server/public/docs/picture/guide_14.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
server/public/docs/picture/guide_15.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
server/public/docs/picture/guide_16.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
server/public/docs/picture/guide_17.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
server/public/docs/picture/guide_18.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
server/public/docs/picture/guide_19.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
server/public/docs/picture/guide_20.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
server/public/docs/picture/guide_21.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
server/public/docs/picture/guide_22.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
server/public/docs/picture/guide_23.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
server/public/docs/picture/guide_24.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
server/public/docs/picture/guide_25.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
server/public/docs/picture/guide_26.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
@@ -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',
|
||||
|
||||
@@ -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 无效',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||