55 Commits

Author SHA1 Message Date
545a818094 API接口-初版 2026-04-16 17:38:21 +08:00
7b39a2a505 将端口号8787改为7979 2026-04-16 17:12:28 +08:00
015d1e4d5b 后台游戏对局实时显示-优化 2026-04-16 16:36:57 +08:00
c7149e7058 [游戏管理]游戏实时对局 2026-04-16 15:10:12 +08:00
15fdd3ba57 创建模型验证器,验证数据正确性 2026-04-16 14:52:13 +08:00
7aaa66d2dc [游戏管理]用户管理-修复500报错 2026-04-16 14:32:56 +08:00
a9a66063b4 [运营]用户阅读记录 2026-04-16 14:32:40 +08:00
6c491bac81 [运营]公告 2026-04-16 14:32:35 +08:00
54f460a242 [渠道管理]-优化样式,新增手动结算 2026-04-16 13:39:08 +08:00
5bf948e309 优化样式 2026-04-16 11:17:01 +08:00
fb16321b7e 执行文档 2026-04-15 18:06:15 +08:00
7e695d32cf [代理与结算]代理结算周期 2026-04-15 18:06:04 +08:00
080ead200b [代理与结算]代理佣金记录 2026-04-15 18:05:56 +08:00
0b0c0ee04a [游戏管理]游戏配置 2026-04-15 17:46:34 +08:00
518642ddb0 [游戏管理]36字花字典-来自游戏配置扩展 2026-04-15 17:46:30 +08:00
569f9e7749 [游戏管理]玩家钱包流水 2026-04-15 17:46:26 +08:00
f88afbcadb [订单管理]提现订单 2026-04-15 17:46:22 +08:00
92545067d2 [游戏管理]压注订单 2026-04-15 17:46:18 +08:00
6bca594769 [订单管理]充值订单 2026-04-15 17:46:14 +08:00
56df105af6 [游戏管理]用户管理-优化表单样式 2026-04-15 17:46:04 +08:00
c213b2d9e9 [渠道管理] 2026-04-15 17:46:00 +08:00
6573f59531 [游戏管理]游戏对局-创建游戏对局service 2026-04-15 14:21:59 +08:00
e2097450eb [游戏管理]游戏配置 2026-04-15 14:21:27 +08:00
b6e5f09479 [游戏管理]36字花字典-来自游戏配置扩展 2026-04-15 14:21:21 +08:00
c9f0154174 [游戏管理]用户管理-优化表单样式 2026-04-15 13:50:39 +08:00
a0bcc73503 [游戏管理]压注订单 2026-04-15 13:44:55 +08:00
14b9920667 [游戏管理]游戏对局 2026-04-15 13:44:50 +08:00
ba80e7c392 [游戏管理]玩家钱包流水 2026-04-15 11:50:14 +08:00
9d06c7a226 [游戏管理]用户管理-优化 2026-04-15 11:27:14 +08:00
c01e6430db [会员管理]-移除 2026-04-15 10:22:33 +08:00
979751f719 [权限管理]角色组管理 2026-04-15 10:19:45 +08:00
8cc28096c4 [渠道管理] 2026-04-15 10:19:41 +08:00
7b002c9410 [游戏管理]用户管理 2026-04-15 10:19:30 +08:00
66c14eacb3 数据库原版文件 2026-04-15 10:03:54 +08:00
36c344731e 环境配置文件 2026-04-15 10:03:28 +08:00
6af87681b7 说明文档 2026-04-15 10:03:21 +08:00
a410098b17 配置文件修改 2026-04-14 13:57:02 +08:00
8a1287d8ed 1.修复矿建鉴权报错
2.优化登录跳转接口
3.优化登录跳转接口
4.修复CURD生成代码模块表不加前缀访问返回404问题
5.系统级报错***优化报错Fatal error: Type of app\common\library\token\TokenExpirationException::$message
2026-04-13 16:48:37 +08:00
2d14527da8 MySQL数据 2026-04-01 17:12:50 +08:00
2e6e5105cf 游戏-用户管理-优化表格和表单样式 2026-04-01 16:37:16 +08:00
7fc9470f45 游戏-用户管理-优化表格和表单样式 2026-04-01 16:37:03 +08:00
3438c711f0 移除冗余代码 2026-04-01 16:36:45 +08:00
2ee78b3239 游戏-渠道管理-优化渠道和管理员添加方式 2026-04-01 16:16:06 +08:00
d8bcc4f4c4 游戏-用户管理-优化password和uuid保存 2026-04-01 15:59:57 +08:00
165689bccf 游戏-渠道管理 2026-04-01 15:51:33 +08:00
1d350ddb68 游戏-用户管理 2026-04-01 15:51:25 +08:00
0429cf62c4 优化CURD代码生成关联 2026-04-01 15:27:23 +08:00
effde58a53 修复CURD代码生成的关联报错 2026-04-01 14:41:41 +08:00
2bb589c4e5 修复CURD代码生成模块-生成的代码报错500 2026-04-01 14:30:18 +08:00
9058fa29fb 优化安装数据库 2026-04-01 14:08:45 +08:00
dc51b63e5e 修复安装安装时报错 2026-03-21 17:44:52 +08:00
e7dfb238e3 修复安装安装时报错 2026-03-21 17:39:43 +08:00
f4233e751e 修复安装数据库报错 2026-03-21 15:48:48 +08:00
f0d1d46457 buildadmin-webman项目初始化v1完整版 2026-03-21 14:49:47 +08:00
3502c299d1 优化项目部署 2026-03-21 14:33:14 +08:00
228 changed files with 15220 additions and 3133 deletions

View File

@@ -12,8 +12,8 @@ LANG_DEFAULT_LANG = zh-cn
DATABASE_DRIVER = mysql DATABASE_DRIVER = mysql
DATABASE_TYPE = mysql DATABASE_TYPE = mysql
DATABASE_HOSTNAME = 127.0.0.1 DATABASE_HOSTNAME = 127.0.0.1
DATABASE_DATABASE = buildadmin-webman DATABASE_DATABASE = buildadmin-webman-game-number
DATABASE_USERNAME = buildadmin-webman DATABASE_USERNAME = buildadmin-webman-game-number
DATABASE_PASSWORD = 123456 DATABASE_PASSWORD = 123456
DATABASE_HOSTPORT = 3306 DATABASE_HOSTPORT = 3306
DATABASE_CHARSET = utf8mb4 DATABASE_CHARSET = utf8mb4
@@ -21,3 +21,6 @@ DATABASE_PREFIX =
# 缓存config/cache.php # 缓存config/cache.php
CACHE_DRIVER = file CACHE_DRIVER = file
# 移动端接口鉴权(/api/v1/authToken
AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a

View File

@@ -161,7 +161,7 @@ php webman migrate
**开发环境需同时启动后端与前端:** **开发环境需同时启动后端与前端:**
1. **启动后端API 服务,端口 8787** 1. **启动后端API 服务,端口 7979**
**Linux / Mac** **Linux / Mac**
```bash ```bash
@@ -180,18 +180,42 @@ php webman migrate
``` ```
3. **访问地址:** 3. **访问地址:**
- 安装向导http://localhost:1818/install/ - 安装向导http://localhost:1818/install/
- 前台地址http://localhost:1818/index.html/#/ - 前台地址http://localhost:1818/index.html/#/
- 后台地址http://localhost:1818/index.html/#/admin - 后台地址http://localhost:1818/index.html/#/admin
> 注意:前端通过 Vite 代理将 `/api`、`/admin`、`/install` 转发到后端 8787 端口,请勿直接访问 8787 端口的前端页面,否则可能出现 404。 > 注意:前端通过 Vite 代理将 `/api`、`/admin`、`/install` 转发到后端 7979 端口,请勿直接访问 8787 端口的前端页面,否则可能出现 404。
### 5.6 生产环境 Nginx反向代理 Webman
部署到服务器时,若使用 **Nginx** 作为站点入口,需将请求转发到本机 **Webman** 进程(默认监听端口与 `config/process.php` 中 `listen` 一致,一般为 `7979`,反代目标使用 `127.0.0.1:8787`)。
在站点 **`server { }`** 块中可增加如下写法:**先由 Nginx 根据 `root` 判断是否存在对应静态文件;不存在则转发到 Webman**`root` 建议指向项目 `public` 目录)。
```nginx
location ^~ / {
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
if (!-f $request_filename) {
proxy_pass http://127.0.0.1:7979;
}
}
```
修改配置后执行 `nginx -t` 校验,再重载 Nginx并确保 Webman 已启动(如 `php start.php start -d`)。
若前端与接口为**不同域名**(跨域),除反代外还需保证 **HTTPS 证书与域名一致**,以及后端 **CORS / 预检OPTIONS** 与前端请求头(如 `think-lang`、`server` 等)配置一致,否则浏览器会报跨域相关错误。
--- ---
## 六、路由说明 ## 六、路由说明
- **后台 API**`/admin/{module}.{Controller}/{action}` - **后台 API**`/admin/{module}.{Controller}/{action}`
- 示例:`/admin/mall.Player/index` → `app\admin\controller\mall\Player::index` - 示例:`/admin/mall.Player/index` → `app\admin\controller\mall\Player::index`
- **前台 API**`/api/...` - **前台 API**`/api/...`
- **安装**`/api/Install/...` - **安装**`/api/Install/...`

View File

@@ -0,0 +1,839 @@
<?php
namespace app\admin\controller;
use Throwable;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 渠道管理
*/
class Channel extends Backend
{
/**
* 预览接口与手动结算共用「手动结算」按钮权限(避免额外菜单节点)
*/
protected array $noNeedPermission = ['manualSettlePreview'];
/**
* Channel模型对象
* @var object|null
* @phpstan-var \app\common\model\Channel|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'user_count', 'profit_amount', 'create_time', 'update_time'];
protected array $withJoinTable = ['adminGroup', 'admin'];
protected string|array $quickSearchField = ['id', 'code', 'name'];
protected bool $modelSceneValidate = true;
private array $currentChannelIds = [];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\Channel();
$this->currentChannelIds = $this->getCurrentChannelIds();
return null;
}
/**
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
*/
public function adminTree(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$query = Db::name('channel')
->field(['id', 'name', 'admin_group_id'])
->order('id', 'asc');
if (!$this->auth->isSuperAdmin()) {
$query = $query->where('id', 'in', $this->currentChannelIds ?: [0]);
}
$channels = $query->select()->toArray();
$groupChildrenCache = [];
$getGroupChildren = function ($groupId) use (&$getGroupChildren, &$groupChildrenCache) {
if ($groupId === null || $groupId === '') return [];
if (array_key_exists($groupId, $groupChildrenCache)) return $groupChildrenCache[$groupId];
$children = Db::name('admin_group')
->where('pid', $groupId)
->where('status', 1)
->column('id');
$all = [];
foreach ($children as $cid) {
$all[] = $cid;
foreach ($getGroupChildren($cid) as $cc) {
$all[] = $cc;
}
}
$groupChildrenCache[$groupId] = $all;
return $all;
};
$tree = [];
foreach ($channels as $ch) {
$groupId = $ch['admin_group_id'] ?? null;
$groupIds = [];
if ($groupId !== null && $groupId !== '') {
$groupIds[] = $groupId;
foreach ($getGroupChildren($groupId) as $gid) {
$groupIds[] = $gid;
}
}
$adminIds = [];
if ($groupIds) {
$adminIds = Db::name('admin_group_access')
->where('group_id', 'in', array_unique($groupIds))
->column('uid');
}
$adminIds = array_values(array_unique($adminIds));
$admins = [];
if ($adminIds) {
$admins = Db::name('admin')
->field(['id', 'username'])
->where('id', 'in', $adminIds)
->order('id', 'asc')
->select()
->toArray();
}
$children = [];
foreach ($admins as $a) {
$children[] = [
'value' => (string) $a['id'],
'label' => $a['username'],
'channel_id' => $ch['id'],
'is_leaf' => true,
];
}
$tree[] = [
'value' => 'channel_' . $ch['id'],
'label' => $ch['name'],
'disabled' => true,
'children' => $children,
];
}
return $this->success('', [
'list' => $tree,
]);
}
/**
* 添加重写管理员只选顶级组admin_group_id 后端自动写入)
* @throws Throwable
*/
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$data = $this->normalizeAgentModeFields($data);
$bizErr = $this->validateAndNormalizeBusinessFields($data);
if ($bizErr !== null) {
return $this->error($bizErr);
}
unset($data['invite_code']);
$adminId = $data['admin_id'] ?? null;
if ($adminId === null || $adminId === '') {
return $this->error(__('Parameter %s can not be empty', ['admin_id']));
}
if (array_key_exists('admin_group_id', $data)) {
unset($data['admin_group_id']);
}
$topGroupId = Db::name('admin_group_access')
->alias('aga')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('aga.uid', $adminId)
->where('ag.pid', 0)
->value('ag.id');
if ($topGroupId === null || $topGroupId === '') {
return $this->error(__('Record not found'));
}
$data['admin_group_id'] = $topGroupId;
if (!$this->auth->isSuperAdmin()) {
$data['admin_id'] = $this->auth->id;
}
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* 编辑重写管理员只选顶级组admin_group_id 后端自动写入)
* @throws Throwable
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array($row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$data = $this->normalizeAgentModeFields($data);
$bizErr = $this->validateAndNormalizeBusinessFields($data);
if ($bizErr !== null) {
return $this->error($bizErr);
}
unset($data['invite_code']);
if (array_key_exists('admin_group_id', $data)) {
unset($data['admin_group_id']);
}
$nextAdminId = array_key_exists('admin_id', $data) ? $data['admin_id'] : ($row['admin_id'] ?? null);
if ($nextAdminId !== null && $nextAdminId !== '') {
$topGroupId = Db::name('admin_group_access')
->alias('aga')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('aga.uid', $nextAdminId)
->where('ag.pid', 0)
->value('ag.id');
if ($topGroupId === null || $topGroupId === '') {
return $this->error(__('Record not found'));
}
$data['admin_group_id'] = $topGroupId;
}
if (!$this->auth->isSuperAdmin()) {
$data['admin_id'] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row
]);
}
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
if (!$this->auth->isSuperAdmin()) {
$where[] = [$alias['channel'] . '.id', 'in', $this->currentChannelIds ?: [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['adminGroup' => ['name'], 'admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 手动结算预览:区间=上次结算周期结束~当前时间;金额来自已结算注单汇总(服务端计算)
*/
public function manualSettlePreview(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/manualSettle')) {
return $this->error(__('You have no permission'));
}
$id = (int) ($request->get('id', 0));
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$payload = $this->buildManualSettlePayload($row->toArray());
if (is_string($payload)) {
return $this->error($payload);
}
return $this->success('', $payload);
}
/**
* 手动结算(渠道维度):仅接收备注;周期与金额全部由服务端按注单汇总计算,并写入结算周期与佣金记录
*/
public function manualSettle(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$id = (int) ($request->post('id', $request->get('id', 0)));
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$remark = (string) $request->post('remark', '');
$payload = $this->buildManualSettlePayload($row->toArray());
if (is_string($payload)) {
return $this->error($payload);
}
$settlementNo = $payload['settlement_no'];
if (Db::name('agent_settlement_period')->where('settlement_no', $settlementNo)->value('id')) {
return $this->error('结算单号已存在,请稍后重试');
}
$adminId = $row['admin_id'] ?? null;
if ($adminId === null || $adminId === '' || (int) $adminId <= 0) {
return $this->error('渠道未绑定代理管理员,无法生成佣金记录');
}
$now = time();
Db::startTrans();
try {
$periodId = (int) Db::name('agent_settlement_period')->insertGetId([
'settlement_no' => $settlementNo,
'period_start_at' => $payload['period_start_ts'],
'period_end_at' => $payload['period_end_ts'],
'total_bet_amount' => $payload['total_bet_amount'],
'total_payout_amount' => $payload['total_payout_amount'],
'platform_profit_amount' => $payload['platform_profit_amount'],
'status' => 2,
'remark' => trim($remark) !== '' ? $remark : ('手动结算-渠道#' . $row['id'] . '-' . $row['name']),
'create_time' => $now,
'update_time' => $now,
]);
Db::name('agent_commission_record')->insert([
'settlement_period_id' => $periodId,
'channel_id' => (int) $row['id'],
'admin_id' => (int) $adminId,
'commission_rate' => $payload['commission_rate'],
'calc_base_amount' => $payload['calc_base_amount'],
'commission_amount' => $payload['commission_amount'],
'status' => 0,
'settled_at' => null,
'remark' => trim($remark) !== '' ? $remark : ('手动结算佣金-CH' . $row['id']),
'create_time' => $now,
'update_time' => $now,
]);
Db::name('channel')->where('id', $row['id'])->update([
'update_time' => $now,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('手动结算已完成,已生成结算周期与佣金记录');
}
/**
* @return array|string 成功返回预览数据数组,失败返回错误文案
*/
private function buildManualSettlePayload(array $row): array|string
{
$channelId = (int) ($row['id'] ?? 0);
if ($channelId <= 0) {
return '渠道数据异常';
}
$endTs = time();
$lastEnd = $this->getLastSettlementEndForChannel($channelId);
$channelCreateTs = (int) ($row['create_time'] ?? 0);
if ($lastEnd === null) {
$periodStartTs = $channelCreateTs > 0 ? $channelCreateTs : 0;
} else {
$periodStartTs = (int) $lastEnd;
}
if ($periodStartTs >= $endTs) {
return '结算区间无效(开始时间不早于当前)';
}
$stats = $this->aggregateBetOrderForChannel($channelId, $periodStartTs, $lastEnd !== null, $endTs);
$totalBet = $stats['total_bet'];
$totalPayout = $stats['total_payout'];
$profit = bcsub($totalBet, $totalPayout, 4);
$mode = (string) ($row['agent_mode'] ?? 'turnover');
$commission = $this->computeCommissionAmounts($row, $totalBet, $profit, $mode);
if (is_string($commission)) {
return $commission;
}
$settlementNo = $this->generateAgentSettlementNo('M', $channelId, $endTs);
return [
'settlement_no' => $settlementNo,
'period_start_ts' => $periodStartTs,
'period_end_ts' => $endTs,
'period_start_at' => date('Y-m-d H:i:s', $periodStartTs),
'period_end_at' => date('Y-m-d H:i:s', $endTs),
'total_bet_amount' => $totalBet,
'total_payout_amount' => $totalPayout,
'platform_profit_amount' => $profit,
'commission_rate' => $commission['commission_rate'],
'calc_base_amount' => $commission['calc_base_amount'],
'commission_amount' => $commission['commission_amount'],
'agent_mode' => $mode,
];
}
/**
* 生成代理结算周期单号:仅大写字母与数字、无分隔符;首字符 M=手动结算A=自动结算(定时任务等复用)
*/
private function generateAgentSettlementNo(string $sourceFlag, int $channelId, int $endTs): string
{
$flag = strtoupper(trim($sourceFlag));
if ($flag !== 'M' && $flag !== 'A') {
$flag = 'M';
}
$channelPart = str_pad((string) max(0, $channelId), 6, '0', STR_PAD_LEFT);
$timePart = str_pad((string) max(0, $endTs), 10, '0', STR_PAD_LEFT);
$base = $flag . $channelPart . $timePart;
for ($i = 0; $i < 8; $i++) {
$randPart = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
$no = $base . $randPart;
if (!Db::name('agent_settlement_period')->where('settlement_no', $no)->value('id')) {
return $no;
}
}
return $base . strtoupper(substr(bin2hex(random_bytes(8)), 0, 16));
}
private function getLastSettlementEndForChannel(int $channelId): ?int
{
$row = Db::name('agent_commission_record')
->alias('acr')
->join('agent_settlement_period asp', 'acr.settlement_period_id = asp.id')
->where('acr.channel_id', $channelId)
->field('MAX(asp.period_end_at) AS m')
->find();
if (!$row) {
return null;
}
$m = $row['m'] ?? null;
if ($m === null || $m === '') {
return null;
}
return (int) $m;
}
/**
* @return array{total_bet:string,total_payout:string}
*/
private function aggregateBetOrderForChannel(int $channelId, int $periodStartTs, bool $hasPriorSettlement, int $endTs): array
{
$query = Db::name('bet_order')
->where('channel_id', $channelId)
->where('status', 2)
->where('create_time', '<=', $endTs);
if ($hasPriorSettlement) {
$query->where('create_time', '>', $periodStartTs);
} else {
$query->where('create_time', '>=', $periodStartTs);
}
$row = $query->field('SUM(total_amount) AS tb, SUM(win_amount) AS tw, SUM(jackpot_extra_amount) AS tj')->find();
$tb = $row && $row['tb'] !== null && $row['tb'] !== '' ? (string) $row['tb'] : '0.0000';
$tw = $row && $row['tw'] !== null && $row['tw'] !== '' ? (string) $row['tw'] : '0.0000';
$tj = $row && $row['tj'] !== null && $row['tj'] !== '' ? (string) $row['tj'] : '0.0000';
$totalPayout = bcadd($tw, $tj, 4);
return [
'total_bet' => number_format((float) $tb, 4, '.', ''),
'total_payout' => number_format((float) $totalPayout, 4, '.', ''),
];
}
/**
* @return array{commission_rate:string,calc_base_amount:string,commission_amount:string}|string
*/
private function computeCommissionAmounts(array $row, string $totalBet, string $platformProfit, string $mode): array|string
{
if ($mode === 'turnover') {
$ratePercent = $row['turnover_share_rate'] ?? null;
if ($ratePercent === null || $ratePercent === '') {
return '普通返水代理未配置返水分红比例';
}
$rateDec = bcdiv((string) $ratePercent, '100', 6);
$amount = bcmul($totalBet, $rateDec, 4);
return [
'commission_rate' => $rateDec,
'calc_base_amount' => $totalBet,
'commission_amount' => $amount,
];
}
if ($mode === 'affiliate') {
$fee = $row['affiliate_fee_rate'] ?? null;
$rulesRaw = $row['affiliate_ladder_rules'] ?? null;
if ($fee === null || $fee === '') {
return '联营代理未配置成本扣除比例';
}
$rules = $this->normalizeLadderRulesForSettlement($rulesRaw);
if ($rules === []) {
return '联营阶梯规则无效或为空';
}
if (bccomp($platformProfit, '0', 4) <= 0) {
return [
'commission_rate' => '0.000000',
'calc_base_amount' => '0.0000',
'commission_amount' => '0.0000',
];
}
$afterFee = bcmul($platformProfit, bcsub('1', (string) $fee, 8), 4);
if (bccomp($afterFee, '0', 4) <= 0) {
return [
'commission_rate' => '0.000000',
'calc_base_amount' => '0.0000',
'commission_amount' => '0.0000',
];
}
$playerLoss = $platformProfit;
$share = $this->pickAffiliateShareRateFromLadder($rules, $playerLoss);
$rateDec = number_format($share, 6, '.', '');
$amount = bcmul($afterFee, $rateDec, 4);
return [
'commission_rate' => $rateDec,
'calc_base_amount' => $afterFee,
'commission_amount' => $amount,
];
}
return '未知的代理模式';
}
/**
* @return array<int, array{minLoss: string, shareRate: string}>
*/
private function normalizeLadderRulesForSettlement(mixed $rulesRaw): array
{
if ($rulesRaw === null || $rulesRaw === '') {
return [];
}
if (is_string($rulesRaw)) {
$decoded = json_decode($rulesRaw, true);
$rulesRaw = is_array($decoded) ? $decoded : [];
}
if (!is_array($rulesRaw)) {
return [];
}
$out = [];
foreach ($rulesRaw as $rule) {
if (!is_array($rule)) {
continue;
}
$minLoss = $rule['minLoss'] ?? ($rule['min_loss'] ?? null);
$shareRate = $rule['shareRate'] ?? ($rule['share_rate'] ?? null);
if ($minLoss === null || $shareRate === null || !is_numeric((string) $minLoss) || !is_numeric((string) $shareRate)) {
continue;
}
$out[] = [
'minLoss' => number_format((float) $minLoss, 4, '.', ''),
'shareRate' => number_format((float) $shareRate, 6, '.', ''),
];
}
usort($out, function ($a, $b) {
return bccomp($a['minLoss'], $b['minLoss'], 4);
});
return $out;
}
/**
* @param array<int, array{minLoss: string, shareRate: string}> $rules
*/
private function pickAffiliateShareRateFromLadder(array $rules, string $playerLoss): float
{
$chosen = (float) $rules[0]['shareRate'];
foreach ($rules as $rule) {
if (bccomp($playerLoss, $rule['minLoss'], 4) >= 0) {
$chosen = (float) $rule['shareRate'];
}
}
return $chosen;
}
private function getCurrentChannelIds(): array
{
if ($this->auth->isSuperAdmin()) {
return Db::name('channel')->column('id');
}
$admin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
private function normalizeAgentModeFields(array $data): array
{
$mode = $data['agent_mode'] ?? null;
if (empty($data['settle_cycle'])) {
$data['settle_cycle'] = 'weekly';
}
if (empty($data['settle_weekday'])) {
$data['settle_weekday'] = 1;
}
if (empty($data['settle_time'])) {
$data['settle_time'] = '02:00:00';
}
if ($mode === 'turnover') {
$data['affiliate_share_rate'] = null;
$data['affiliate_fee_rate'] = null;
$data['carryover_balance'] = 0;
$data['affiliate_contract_no'] = null;
$data['affiliate_contract_name'] = null;
$data['affiliate_ladder_rules'] = null;
$data['affiliate_effective_start_at'] = null;
$data['affiliate_effective_end_at'] = null;
return $data;
}
if ($mode === 'affiliate') {
$data['turnover_share_rate'] = null;
}
return $data;
}
private function validateAndNormalizeBusinessFields(array &$data): ?string
{
$cycle = isset($data['settle_cycle']) ? trim((string) $data['settle_cycle']) : 'weekly';
if (!in_array($cycle, ['daily', 'weekly', 'monthly'], true)) {
return '结算周期不合法';
}
$data['settle_cycle'] = $cycle;
$settleTime = isset($data['settle_time']) ? trim((string) $data['settle_time']) : '02:00:00';
if (!preg_match('/^\d{2}:\d{2}:\d{2}$/', $settleTime)) {
return '结算时间格式不正确HH:mm:ss';
}
$data['settle_time'] = $settleTime;
if ($cycle === 'weekly') {
$weekday = isset($data['settle_weekday']) ? (int) $data['settle_weekday'] : 1;
if ($weekday < 1 || $weekday > 7) {
return '周结必须选择周一到周日';
}
$data['settle_weekday'] = $weekday;
} else {
$data['settle_weekday'] = 1;
}
if ($cycle === 'monthly') {
$monthday = isset($data['settle_monthday']) ? (int) $data['settle_monthday'] : 1;
if ($monthday < 1 || $monthday > 31) {
return '月结日期必须在1到31之间';
}
$data['settle_monthday'] = $monthday;
} else {
$data['settle_monthday'] = 1;
}
$mode = isset($data['agent_mode']) ? (string) $data['agent_mode'] : '';
if ($mode === 'turnover') {
if (isset($data['turnover_share_rate']) && $data['turnover_share_rate'] !== '' && $data['turnover_share_rate'] !== null) {
$num = (float) $data['turnover_share_rate'];
if ($num < 0 || $num > 100) {
return '返水分红比例必须在0到100之间';
}
}
return null;
}
if ($mode === 'affiliate') {
foreach (['affiliate_share_rate' => '联营占成比例', 'affiliate_fee_rate' => '联营成本扣除比例'] as $field => $label) {
if (!isset($data[$field]) || $data[$field] === '' || $data[$field] === null) {
return $label . '不能为空';
}
$num = (float) $data[$field];
if ($num < 0 || $num > 1) {
return $label . '必须在0到1之间';
}
}
$ladderErr = $this->validateLadderRulesField($data);
if ($ladderErr !== null) {
return $ladderErr;
}
}
return null;
}
private function validateLadderRulesField(array &$data): ?string
{
$rulesRaw = $data['affiliate_ladder_rules'] ?? null;
if ($rulesRaw === null || $rulesRaw === '') {
return '联营阶梯规则不能为空';
}
if (is_string($rulesRaw)) {
$decoded = json_decode($rulesRaw, true);
if (!is_array($decoded)) {
return '联营阶梯规则必须是有效JSON数组';
}
$rulesRaw = $decoded;
}
if (!is_array($rulesRaw) || $rulesRaw === []) {
return '联营阶梯规则至少需要一条';
}
$normalized = [];
$prevMinLoss = null;
foreach ($rulesRaw as $idx => $rule) {
if (!is_array($rule)) {
return '联营阶梯规则第' . ($idx + 1) . '行格式错误';
}
$minLoss = $rule['minLoss'] ?? ($rule['min_loss'] ?? null);
$shareRate = $rule['shareRate'] ?? ($rule['share_rate'] ?? null);
if ($minLoss === null || $minLoss === '' || !is_numeric((string) $minLoss)) {
return '联营阶梯规则第' . ($idx + 1) . '行起始客损格式错误';
}
if ($shareRate === null || $shareRate === '' || !is_numeric((string) $shareRate)) {
return '联营阶梯规则第' . ($idx + 1) . '行占成比例格式错误';
}
$minLossNum = (float) $minLoss;
$shareRateNum = (float) $shareRate;
if ($minLossNum < 0) {
return '联营阶梯规则第' . ($idx + 1) . '行起始客损不能为负';
}
if ($shareRateNum < 0 || $shareRateNum > 1) {
return '联营阶梯规则第' . ($idx + 1) . '行占成比例必须在0到1之间';
}
if ($prevMinLoss !== null && $minLossNum <= $prevMinLoss) {
return '联营阶梯规则需按起始客损递增';
}
$prevMinLoss = $minLossNum;
$normalized[] = [
'minLoss' => number_format($minLossNum, 4, '.', ''),
'shareRate' => number_format($shareRateNum, 6, '.', ''),
];
}
$data['affiliate_ladder_rules'] = $normalized;
return null;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace app\admin\controller\agent;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 代理佣金记录
*/
class CommissionRecord extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'remark'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['settlementPeriod', 'channel', 'admin'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\AgentCommissionRecord();
return null;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace app\admin\controller\agent;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 代理结算周期
*/
class SettlementPeriod extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'settlement_no', 'remark'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\AgentSettlementPeriod();
return null;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\admin\controller\auth; namespace app\admin\controller\auth;
use ba\Random;
use Throwable; use Throwable;
use support\think\Db; use support\think\Db;
use support\validation\Validator; use support\validation\Validator;
@@ -21,7 +22,7 @@ class Admin extends Backend
protected array|string $quickSearchField = ['username', 'nickname']; protected array|string $quickSearchField = ['username', 'nickname'];
protected string|int|bool $dataLimit = 'allAuthAndOthers'; protected string|int|bool $dataLimit = 'parent';
protected string $dataLimitField = 'id'; protected string $dataLimitField = 'id';
@@ -42,18 +43,109 @@ class Admin extends Backend
} }
list($where, $alias, $limit, $order) = $this->queryBuilder(); list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model $query = $this->model
->withoutField('login_failure,password,salt') ->withoutField('login_failure,password,salt')
->withJoin($this->withJoinTable, $this->withJoinType) ->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias) ->alias($alias)
->where($where) ->where($where);
// 仅返回“顶级角色组(pid=0)”下的管理员(用于远程下拉等场景)
$topGroup = $request->get('top_group') ?? $request->post('top_group');
if ($topGroup === '1' || $topGroup === 1 || $topGroup === true) {
$query = $query
->join('admin_group_access aga', $alias['admin'] . '.id = aga.uid')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('ag.pid', 0)
->distinct(true);
}
$res = $query
->order($order) ->order($order)
->paginate($limit); ->paginate($limit);
$items = $res->items();
$topGroupUids = $this->getTopGroupUserMap(array_column($items, 'id'));
foreach ($items as &$item) {
$id = $item['id'] ?? null;
if ($id === 1 || isset($topGroupUids[$id])) {
$item['commission_rate'] = null;
}
}
unset($item);
return $this->success('', [
'list' => $items,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 远程下拉(重写:支持 top_group=1 仅返回顶级组管理员)
*/
protected function _select(): Response
{
if (empty($this->model)) {
return $this->success('', [
'list' => [],
'total' => 0,
]);
}
$pk = $this->model->getPk();
$fields = [$pk];
$quickSearchArr = is_array($this->quickSearchField) ? $this->quickSearchField : explode(',', (string) $this->quickSearchField);
foreach ($quickSearchArr as $f) {
$f = trim((string) $f);
if ($f === '') continue;
$f = str_contains($f, '.') ? substr($f, strrpos($f, '.') + 1) : $f;
if ($f !== '' && !in_array($f, $fields, true)) {
$fields[] = $f;
}
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$modelTable = strtolower($this->model->getTable());
$mainAlias = ($alias[$modelTable] ?? $modelTable) . '.';
// 联表时避免字段歧义:主表字段统一 select 为 "admin.xxx as xxx"
$selectFields = [];
foreach ($fields as $f) {
$f = trim((string) $f);
if ($f === '') continue;
$selectFields[] = $mainAlias . $f . ' as ' . $f;
}
// 联表时避免排序字段歧义:无前缀的字段默认加主表前缀
$qualifiedOrder = [];
if (is_array($order)) {
foreach ($order as $k => $v) {
$k = (string) $k;
$qualifiedOrder[str_contains($k, '.') ? $k : ($mainAlias . $k)] = $v;
}
}
$query = $this->model
->field($selectFields)
->alias($alias)
->where($where);
$topGroup = $this->request ? ($this->request->get('top_group') ?? $this->request->post('top_group')) : null;
if ($topGroup === '1' || $topGroup === 1 || $topGroup === true) {
$query = $query
->join('admin_group_access aga', $mainAlias . 'id = aga.uid')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('ag.pid', 0)
->distinct(true);
}
$res = $query
->order($qualifiedOrder ?: $order)
->paginate($limit);
return $this->success('', [ return $this->success('', [
'list' => $res->items(), 'list' => $res->items(),
'total' => $res->total(), 'total' => $res->total(),
'remark' => get_route_remark(),
]); ]);
} }
@@ -67,6 +159,10 @@ class Admin extends Backend
if (!$data) { if (!$data) {
return $this->error(__('Parameter %s can not be empty', [''])); return $this->error(__('Parameter %s can not be empty', ['']));
} }
$data = $this->normalizeSingleGroup($data);
if (!$this->hasSingleGroup($data['group_arr'] ?? null)) {
return $this->error('请选择且仅选择一个角色组');
}
if ($this->modelValidate) { if ($this->modelValidate) {
try { try {
@@ -90,6 +186,25 @@ class Admin extends Backend
$passwd = $data['password'] ?? ''; $passwd = $data['password'] ?? '';
$data = $this->excludeFields($data); $data = $this->excludeFields($data);
$creatorChannelId = $this->getCreatorChannelId();
if (!$this->auth->isSuperAdmin()) {
if ($creatorChannelId === null || $creatorChannelId === '') {
return $this->error(__('You have no permission'));
}
$data['channel_id'] = $creatorChannelId;
$data['parent_admin_id'] = $this->auth->id;
}
$data['invite_code'] = $this->generateUniqueInviteCode();
$requireCommissionRate = $this->requireCommissionRate($data['group_arr'] ?? []);
if ($requireCommissionRate) {
if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) {
return $this->error(__('Please enter a valid commission rate for non-top role group'));
}
$commissionRes = $this->validateAdminCommissionByGroups($data['group_arr'] ?? [], floatval((string)$data['commission_rate']));
if ($commissionRes !== null) return $commissionRes;
} else {
$data['commission_rate'] = null;
}
$result = false; $result = false;
if (!empty($data['group_arr'])) { if (!empty($data['group_arr'])) {
$authRes = $this->checkGroupAuth($data['group_arr']); $authRes = $this->checkGroupAuth($data['group_arr']);
@@ -148,6 +263,37 @@ class Admin extends Backend
if (!$data) { if (!$data) {
return $this->error(__('Parameter %s can not be empty', [''])); return $this->error(__('Parameter %s can not be empty', ['']));
} }
$data = $this->normalizeSingleGroup($data);
if (!$this->hasSingleGroup($data['group_arr'] ?? null)) {
return $this->error('请选择且仅选择一个角色组');
}
// 未提交分红比例时,若角色组未变更则沿用数据库原值(避免表单单项 number 校验把空串判错)
$postedGroups = array_map('intval', $data['group_arr'] ?? []);
$rowGroups = array_map('intval', $row->group_arr ?? []);
sort($postedGroups);
sort($rowGroups);
$sameGroups = $postedGroups === $rowGroups;
$postedCommission = $data['commission_rate'] ?? null;
if (($postedCommission === null || $postedCommission === '') && $sameGroups && $this->isValidCommissionRate($row['commission_rate'] ?? null)) {
$data['commission_rate'] = $row['commission_rate'];
}
// 当前管理员编辑自身时,不允许修改角色组和分红比
if ((int)$this->auth->id === (int)$id) {
$postedGroups = $data['group_arr'] ?? [];
if (!is_array($postedGroups)) {
$postedGroups = [];
}
$originGroups = $row->group_arr ?? [];
sort($postedGroups);
sort($originGroups);
$postedRate = $data['commission_rate'] ?? null;
$originRate = $row['commission_rate'] ?? null;
if ($postedGroups !== $originGroups || (string)$postedRate !== (string)$originRate) {
return $this->error(__('You cannot modify your own management group!'));
}
}
if ($this->modelValidate) { if ($this->modelValidate) {
try { try {
@@ -194,15 +340,29 @@ class Admin extends Backend
if ($authRes !== null) return $authRes; if ($authRes !== null) return $authRes;
} }
Db::name('admin_group_access')
->where('uid', $id)
->delete();
$data = $this->excludeFields($data); $data = $this->excludeFields($data);
unset($data['invite_code']);
$creatorChannelId = $this->getCreatorChannelId();
if (!$this->auth->isSuperAdmin() && $creatorChannelId !== null && $creatorChannelId !== '') {
$data['channel_id'] = $creatorChannelId;
}
$requireCommissionRate = $this->requireCommissionRate($data['group_arr'] ?? []);
if ($requireCommissionRate) {
if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) {
return $this->error(__('Please enter a valid commission rate for non-top role group'));
}
$commissionRes = $this->validateAdminCommissionByGroups($data['group_arr'] ?? [], floatval((string)$data['commission_rate']), intval((string)$id));
if ($commissionRes !== null) return $commissionRes;
} else {
$data['commission_rate'] = null;
}
$result = false; $result = false;
$this->model->startTrans(); $this->model->startTrans();
try { try {
$result = $row->save($data); $result = $row->save($data);
Db::name('admin_group_access')
->where('uid', $id)
->delete();
if ($groupAccess) { if ($groupAccess) {
Db::name('admin_group_access')->insertAll($groupAccess); Db::name('admin_group_access')->insertAll($groupAccess);
} }
@@ -275,7 +435,7 @@ class Admin extends Backend
if ($this->auth->isSuperAdmin()) { if ($this->auth->isSuperAdmin()) {
return null; return null;
} }
$authGroups = $this->auth->getAllAuthGroups('allAuthAndOthers'); $authGroups = $this->getManageableGroupIds();
foreach ($groups as $group) { foreach ($groups as $group) {
if (!in_array($group, $authGroups)) { if (!in_array($group, $authGroups)) {
return $this->error(__('You have no permission to add an administrator to this group!')); return $this->error(__('You have no permission to add an administrator to this group!'));
@@ -283,4 +443,132 @@ class Admin extends Backend
} }
return null; return null;
} }
private function getManageableGroupIds(): array
{
if ($this->auth->isSuperAdmin()) {
return Db::name('admin_group')->where('status', 1)->column('id');
}
$own = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$children = $this->auth->getAdminChildGroups();
return array_values(array_unique(array_merge($own, $children)));
}
private function getCreatorChannelId(): mixed
{
$currentAdmin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
if ($currentAdmin && !empty($currentAdmin['channel_id'])) {
return $currentAdmin['channel_id'];
}
$channelId = Db::name('channel')
->where('admin_id', $this->auth->id)
->value('id');
return $channelId ?: null;
}
private function generateUniqueInviteCode(): string
{
$tries = 0;
do {
$tries++;
$code = strtoupper(substr(Random::uuid(), 0, 8));
$exists = Db::name('admin')->where('invite_code', $code)->find();
} while ($exists && $tries < 20);
return $code;
}
private function requireCommissionRate(array $groupIds): bool
{
if (!$groupIds) {
return false;
}
$count = Db::name('admin_group')
->where('id', 'in', $groupIds)
->where('pid', '<>', 0)
->count();
return $count > 0;
}
private function isValidCommissionRate(mixed $value): bool
{
if ($value === null || $value === '') {
return false;
}
$rate = trim((string)$value);
if (!preg_match('/^(100(\.00?)?|[0-9]{1,2}(\.[0-9]{1,2})?)$/', $rate)) {
return false;
}
return true;
}
private function validateAdminCommissionByGroups(array $groupIds, float $currentRate, ?int $excludeAdminId = null): ?Response
{
if (!$groupIds) {
return null;
}
$groups = Db::name('admin_group')
->where('id', 'in', $groupIds)
->where('pid', '<>', 0)
->column('name', 'id');
foreach ($groups as $groupId => $groupName) {
$query = Db::name('admin_group_access')->alias('aga')
->join('admin a', 'aga.uid = a.id')
->where('aga.group_id', intval((string)$groupId));
if ($excludeAdminId !== null) {
$query = $query->where('a.id', '<>', $excludeAdminId);
}
$sum = (float)$query->sum('a.commission_rate');
$remaining = 100 - $sum;
if ($currentRate > $remaining + 0.000001) {
$exceed = $currentRate - $remaining;
return $this->error(sprintf('角色组[%s]分红比例总和不能超过100%%,当前剩余 %.2f%%,本次超出 %.2f%%', $groupName, max(0, $remaining), $exceed));
}
}
return null;
}
private function getTopGroupUserMap(array $userIds): array
{
if (!$userIds) {
return [];
}
$uids = Db::name('admin_group_access')->alias('aga')
->join('admin_group ag', 'aga.group_id = ag.id')
->where('aga.uid', 'in', $userIds)
->where('ag.pid', 0)
->column('aga.uid');
$map = [];
foreach ($uids as $uid) {
$map[$uid] = true;
}
return $map;
}
private function normalizeSingleGroup(array $data): array
{
if (!array_key_exists('group_arr', $data)) {
return $data;
}
$group = $data['group_arr'];
if (is_array($group)) {
$data['group_arr'] = array_values(array_filter($group, static function ($item) {
return $item !== null && $item !== '';
}));
return $data;
}
if ($group === null || $group === '') {
$data['group_arr'] = [];
return $data;
}
$data['group_arr'] = [$group];
return $data;
}
private function hasSingleGroup(mixed $groups): bool
{
return is_array($groups) && count($groups) === 1;
}
} }

View File

@@ -34,6 +34,7 @@ class Group extends Backend
protected bool $assembleTree = true; protected bool $assembleTree = true;
protected array $adminGroups = []; protected array $adminGroups = [];
protected array $manageableGroupIds = [];
protected function initController(Request $request): ?Response protected function initController(Request $request): ?Response
{ {
@@ -48,6 +49,7 @@ class Group extends Backend
$this->assembleTree = $isTree && !$this->initValue; $this->assembleTree = $isTree && !$this->initValue;
$this->adminGroups = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id'); $this->adminGroups = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$this->manageableGroupIds = $this->getManageableGroupIds();
return null; return null;
} }
@@ -79,6 +81,23 @@ class Group extends Backend
} }
$data = $this->excludeFields($data); $data = $this->excludeFields($data);
$pid = $data['pid'] ?? 0;
$pidInt = intval((string)$pid);
if (!$this->auth->isSuperAdmin() && $pidInt !== 0 && !in_array($pidInt, $this->manageableGroupIds, true)) {
return $this->error(__('You have no permission'));
}
$shouldHandleCommissionRate = true;
if ($shouldHandleCommissionRate) {
if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) {
return $this->error(__('Please enter the correct field', ['commission_rate']));
}
if ($pidInt !== 0) {
$commissionRes = $this->validateSiblingCommissionRate($pidInt, floatval((string)$data['commission_rate']));
if ($commissionRes !== null) return $commissionRes;
}
} else {
$data['commission_rate'] = 0;
}
$rulesRes = $this->handleRules($data); $rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes; if ($rulesRes instanceof Response) return $rulesRes;
@@ -141,6 +160,23 @@ class Group extends Backend
} }
$data = $this->excludeFields($data); $data = $this->excludeFields($data);
$pid = $data['pid'] ?? $row['pid'] ?? 0;
$pidInt = intval((string)$pid);
if (!$this->auth->isSuperAdmin() && $pidInt !== 0 && !in_array($pidInt, $this->manageableGroupIds, true)) {
return $this->error(__('You have no permission'));
}
$shouldHandleCommissionRate = true;
if ($shouldHandleCommissionRate) {
if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) {
return $this->error(__('Please enter the correct field', ['commission_rate']));
}
if ($pidInt !== 0) {
$commissionRes = $this->validateSiblingCommissionRate($pidInt, floatval((string)$data['commission_rate']), intval((string)$row['id']));
if ($commissionRes !== null) return $commissionRes;
}
} else {
$data['commission_rate'] = 0;
}
$rulesRes = $this->handleRules($data); $rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes; if ($rulesRes instanceof Response) return $rulesRes;
@@ -308,11 +344,12 @@ class Group extends Backend
} }
if (!$this->auth->isSuperAdmin()) { if (!$this->auth->isSuperAdmin()) {
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, $where); // 仅本人所在角色组 + 其下级子组getManageableGroupIds不包含上级/同级。无父节点在结果集时assembleChild 将该节点作为树根展示,符合「只看我这条线」
if (!$absoluteAuth) { $authGroups = $this->manageableGroupIds;
$authGroups = array_merge($this->adminGroups, $authGroups); if ($absoluteAuth) {
$authGroups = array_values(array_diff($authGroups, $this->adminGroups));
} }
$where[] = ['id', 'in', $authGroups]; $where[] = ['id', 'in', $authGroups ?: [0]];
} }
$data = $this->model->where($where)->select()->toArray(); $data = $this->model->where($where)->select()->toArray();
@@ -337,10 +374,48 @@ class Group extends Backend
private function checkAuth($groupId): ?Response private function checkAuth($groupId): ?Response
{ {
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, []); $authGroups = $this->manageableGroupIds;
if (!$this->auth->isSuperAdmin() && !in_array($groupId, $authGroups)) { if (!$this->auth->isSuperAdmin() && !in_array(intval((string)$groupId), $authGroups, true)) {
return $this->error(__($this->authMethod == 'allAuth' ? 'You need to have all permissions of this group to operate this group~' : 'You need to have all the permissions of the group and have additional permissions before you can operate the group~')); return $this->error(__($this->authMethod == 'allAuth' ? 'You need to have all permissions of this group to operate this group~' : 'You need to have all the permissions of the group and have additional permissions before you can operate the group~'));
} }
return null; return null;
} }
private function getManageableGroupIds(): array
{
if ($this->auth->isSuperAdmin()) {
return AdminGroup::where('status', 1)->column('id');
}
$own = array_map('intval', $this->adminGroups);
$children = array_map('intval', $this->auth->getAdminChildGroups());
return array_values(array_unique(array_merge($own, $children)));
}
private function isValidCommissionRate(mixed $value): bool
{
if ($value === null || $value === '') {
return false;
}
$rate = trim((string)$value);
if (!preg_match('/^(100(\.00?)?|[0-9]{1,2}(\.[0-9]{1,2})?)$/', $rate)) {
return false;
}
return true;
}
private function validateSiblingCommissionRate(int $pid, float $currentRate, ?int $excludeId = null): ?Response
{
$query = Db::name('admin_group')->where('pid', $pid);
if ($excludeId !== null) {
$query = $query->where('id', '<>', $excludeId);
}
$sum = (float)$query->sum('commission_rate');
$remaining = 100 - $sum;
if ($currentRate > $remaining + 0.000001) {
$exceed = $currentRate - $remaining;
return $this->error(sprintf('同一父级角色组分红比例总和不能超过100%%,当前父级剩余 %.2f%%,本次超出 %.2f%%', max(0, $remaining), $exceed));
}
return null;
}
} }

View File

@@ -0,0 +1,66 @@
<?php
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏配置game_config
*/
class GameConfig extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'config_key', 'remark'];
protected string|array $defaultSortField = ['id' => 'asc'];
protected string|array $orderGuarantee = ['id' => 'asc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameConfig();
return null;
}
/**
* 列表:排除独立表单维护的 36 字花字典
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '') {
$where[] = [$mainShort . '.config_key', '<>', ZiHuaDictionaryLib::CONFIG_KEY];
}
$res = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 36 字花字典独立编辑(仅 game_config.zi_hua_36_dictionary
*/
class ZiHuaDictionary extends Backend
{
protected bool $modelValidate = false;
protected array $noNeedPermission = ['index', 'save'];
private function hasNodePermission(WebmanRequest $request, string $action): bool
{
if (!$this->auth) {
return false;
}
$controllerPath = get_controller_path($request);
if (!$controllerPath) {
return false;
}
$paths = [];
$paths[] = $controllerPath . '/' . $action;
$parts = explode('/', $controllerPath);
foreach ($parts as &$part) {
if (str_contains($part, '_')) {
$part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part))));
}
}
$paths[] = implode('/', $parts) . '/' . $action;
foreach (array_values(array_unique($paths)) as $path) {
if ($this->auth->check($path)) {
return true;
}
}
return false;
}
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
/**
* GET读取 36 条POST 不支持
*/
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'index')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'GET') {
return $this->error(__('Parameter error'));
}
$row = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
$items = ZiHuaDictionaryLib::parseFromConfigValue($row['config_value'] ?? null);
return $this->success('', [
'items' => $items,
'categories' => ZiHuaDictionaryLib::CATEGORIES,
]);
}
/**
* 保存 JSON 数组(仅 value_type=json
*/
public function save(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'save')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$payload = $request->post();
if (!is_array($payload)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$items = $payload['items'] ?? null;
if (!is_array($items)) {
return $this->error('items 必须为数组');
}
try {
$clean = ZiHuaDictionaryLib::prepareItemsForSave($items);
$json = ZiHuaDictionaryLib::encodeForDb($clean);
} catch (InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$now = time();
try {
$exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => ZiHuaDictionaryLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '36字花字典 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Saved successfully'));
}
}

View File

@@ -581,6 +581,13 @@ class Crud extends Backend
private function parseModelMethods($field, &$modelData): void private function parseModelMethods($field, &$modelData): void
{ {
// MySQL bigint/int 时间戳字段:显式声明为 integer避免 ThinkORM 自动时间戳写入 'now' 字符串
if (in_array($field['name'] ?? '', ['create_time', 'update_time', 'createtime', 'updatetime'], true)
&& in_array($field['type'] ?? '', ['int', 'bigint'], true)
) {
$modelData['fieldType'][$field['name']] = 'integer';
}
if (($field['designType'] ?? '') == 'array') { if (($field['designType'] ?? '') == 'array') {
$modelData['fieldType'][$field['name']] = 'json'; $modelData['fieldType'][$field['name']] = 'json';
} elseif (!in_array($field['name'], ['create_time', 'update_time', 'updatetime', 'createtime']) } elseif (!in_array($field['name'], ['create_time', 'update_time', 'updatetime', 'createtime'])

View File

@@ -0,0 +1,66 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏配置game_config
*/
class Config extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'config_key', 'remark'];
protected string|array $defaultSortField = ['id' => 'asc'];
protected string|array $orderGuarantee = ['id' => 'asc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameConfig();
return null;
}
/**
* 列表:排除独立表单维护的 36 字花字典
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '') {
$where[] = [$mainShort . '.config_key', '<>', ZiHuaDictionaryLib::CONFIG_KEY];
}
$res = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\service\GameLiveService;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏实时对局
*/
class Live extends Backend
{
protected ?object $model = null;
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
protected function _index(): Response
{
$recordIdRaw = $this->request ? $this->request->get('record_id') : null;
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
return $this->success('', GameLiveService::buildSnapshot($recordId));
}
public function snapshot(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$recordIdRaw = $request->get('record_id');
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
return $this->success('', GameLiveService::buildSnapshot($recordId));
}
public function pushConfig(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$ws = (string) config('plugin.webman.push.app.websocket');
$ws = str_replace('websocket://', 'ws://', $ws);
$ws = str_replace('0.0.0.0', '127.0.0.1', $ws);
return $this->success('', [
'url' => $ws,
'app_key' => (string) config('plugin.webman.push.app.app_key'),
'channel' => 'game-live',
'event' => 'bet-updated',
]);
}
public function calculate(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$recordIdRaw = $request->post('record_id');
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
$manualRaw = $request->post('manual_number');
$manualNumber = is_numeric((string) $manualRaw) ? (int) $manualRaw : null;
$res = GameLiveService::calculateResult($recordId, $manualNumber);
if (!($res['ok'] ?? false)) {
return $this->error((string) ($res['msg'] ?? '计算失败'));
}
return $this->success((string) $res['msg'], $res);
}
public function draw(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$recordIdRaw = $request->post('record_id');
$recordId = is_numeric((string) $recordIdRaw) ? (int) $recordIdRaw : null;
$manualRaw = $request->post('manual_number');
$manualNumber = is_numeric((string) $manualRaw) ? (int) $manualRaw : null;
$res = GameLiveService::drawResult($recordId, $manualNumber);
if (!($res['ok'] ?? false)) {
return $this->error((string) ($res['msg'] ?? '开奖失败'));
}
return $this->success((string) $res['msg'], $res);
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\service\GameRecordService;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏对局(期号)
*/
class Period extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'period_no'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameRecord();
return null;
}
/**
* 读取 / 保存:自动创建下一期、手动创建下一期 开关(存 game_config
*/
public function periodSettings(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$method = $request->method();
if ($method === 'GET') {
return $this->success('', GameRecordService::getRecordSettings());
}
if ($method === 'POST') {
$data = $request->post();
if (!is_array($data)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
try {
GameRecordService::saveRecordSettings($data);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Saved successfully'));
}
return $this->error(__('Parameter error'));
}
/**
* 手动创建下一期(受开关与「存在进行中期号」约束)
*/
public function createNextManual(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$result = GameRecordService::createNextRecordForManual();
if ($result['ok']) {
return $this->success($result['msg'], ['period_no' => $result['period_no'] ?? '']);
}
return $this->error($result['msg']);
}
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!is_array($data)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
if (!isset($data['period_start_at']) || $data['period_start_at'] === '' || $data['period_start_at'] === null) {
$data['period_start_at'] = time();
}
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏对局记录
*/
class Record extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'period_no'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameRecord();
return null;
}
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('游戏对局记录由系统自动生成,禁止后台手工新增');
}
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() === 'POST') {
return $this->error('游戏对局记录不可编辑');
}
$pk = $this->model->getPk();
$id = $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
return $this->success('', ['row' => $row]);
}
public function del(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('游戏对局记录不可删除');
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 36 字花字典独立编辑(仅 game_config.zi_hua_36_dictionary
*/
class ZiHuaDictionary extends Backend
{
protected bool $modelValidate = false;
protected array $noNeedPermission = ['index', 'save'];
private function hasNodePermission(WebmanRequest $request, string $action): bool
{
if (!$this->auth) {
return false;
}
$controllerPath = get_controller_path($request);
if (!$controllerPath) {
return false;
}
return $this->auth->check($controllerPath . '/' . $action);
}
protected function initController(WebmanRequest $request): ?Response
{
return null;
}
/**
* GET读取 36 条POST 不支持
*/
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'index')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'GET') {
return $this->error(__('Parameter error'));
}
$row = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
$items = ZiHuaDictionaryLib::parseFromConfigValue($row['config_value'] ?? null);
return $this->success('', [
'items' => $items,
'categories' => ZiHuaDictionaryLib::CATEGORIES,
]);
}
/**
* 保存 JSON 数组(仅 value_type=json
*/
public function save(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->hasNodePermission($request, 'save')) {
return $this->error(__('You have no permission'), [], 401);
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$payload = $request->post();
if (!is_array($payload)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$items = $payload['items'] ?? null;
if (!is_array($items)) {
return $this->error('items 必须为数组');
}
try {
$clean = ZiHuaDictionaryLib::prepareItemsForSave($items);
$json = ZiHuaDictionaryLib::encodeForDb($clean);
} catch (InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$now = time();
try {
$exists = Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', ZiHuaDictionaryLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => ZiHuaDictionaryLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '36字花字典 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Saved successfully'));
}
}

View File

@@ -1,149 +0,0 @@
<?php
namespace app\admin\controller\mall;
use app\common\controller\Backend;
use support\Response;
use Throwable;
use Webman\Http\Request;
/**
* 积分商城用户
*/
class Player extends Backend
{
/**
* Player模型对象
* @var object|null
* @phpstan-var \app\admin\model\mall\Player|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time', 'password'];
protected string|array $quickSearchField = ['id'];
/** 列表不返回密码字段 */
protected string|array $indexField = ['id', 'username', 'create_time', 'update_time', 'score'];
public function initialize(): void
{
parent::initialize();
$this->model = new \app\admin\model\mall\Player();
}
/**
* 添加(重写以支持密码加密)
*/
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response instanceof Response) {
return $response;
}
if ($request->method() !== 'POST') {
$this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
$passwd = $data['password'] ?? '';
if (empty($passwd)) {
$this->error(__('Parameter %s can not be empty', [__('Password')]));
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
if ($result !== false && $passwd) {
$this->model->resetPassword((int) $this->model->id, $passwd);
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
$result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
/**
* 编辑(重写以支持编辑时密码可选)
*/
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response instanceof Response) {
return $response;
}
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
$this->error(__('Record not found'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
$this->error(__('Parameter %s can not be empty', ['']));
}
if (!empty($data['password'])) {
$this->model->resetPassword((int) $row->id, $data['password']);
}
$data = $this->applyInputFilter($data);
$data = $this->excludeFields($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$validate->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
$this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
unset($row['password']);
$row['password'] = '';
$this->success('', ['row' => $row]);
}
/**
* 若需重写查看、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -0,0 +1,33 @@
<?php
namespace app\admin\controller\operation;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 运营公告
*/
class OperationNotice extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
protected string|array $quickSearchField = ['id', 'title'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\OperationNotice();
return null;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace app\admin\controller\operation;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 用户公告阅读记录
*/
class UserNoticeRead extends Backend
{
protected ?object $model = null;
protected string|array $preExcludeFields = ['id', 'create_time'];
protected string|array $quickSearchField = ['id', 'user_id', 'notice_id'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'operationNotice'];
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\UserNoticeRead();
return null;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace app\admin\controller\order;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 压注订单(业务下单写入,后台以查询为主)
*/
class BetOrder extends Backend
{
protected ?object $model = null;
protected bool $modelValidate = false;
protected string|array $quickSearchField = ['id', 'period_no', 'idempotency_key'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel', 'gameRecord'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\BetOrder();
return null;
}
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('注单由游戏接口生成,禁止后台手工新增');
}
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('注单不可编辑');
}
public function del(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('注单不可删除');
}
public function sortable(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('不支持排序');
}
/**
* 渠道管理员仅看本渠道注单
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'gameRecord' => ['period_no', 'status'],
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* @return int[]
*/
private function getScopedChannelIdsForFilter(): array
{
if (!$this->auth) {
return [0];
}
if ($this->auth->isSuperAdmin()) {
return [];
}
$admin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace app\admin\controller\order;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 充值订单
*/
class DepositOrder extends Backend
{
protected ?object $model = null;
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected string|array $quickSearchField = ['id', 'order_no', 'pay_channel', 'remark'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\DepositOrder();
return null;
}
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* @return int[]
*/
private function getScopedChannelIdsForFilter(): array
{
if (!$this->auth) {
return [0];
}
if ($this->auth->isSuperAdmin()) {
return [];
}
$admin = Db::name('admin')->field(['id', 'channel_id'])->where('id', $this->auth->id)->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace app\admin\controller\order;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 提现订单
*/
class WithdrawOrder extends Backend
{
protected ?object $model = null;
protected bool $modelValidate = true;
protected bool $modelSceneValidate = true;
protected string|array $quickSearchField = ['id', 'order_no', 'remark'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel', 'reviewAdmin'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\WithdrawOrder();
return null;
}
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'reviewAdmin' => ['username'],
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* @return int[]
*/
private function getScopedChannelIdsForFilter(): array
{
if (!$this->auth) {
return [0];
}
if ($this->auth->isSuperAdmin()) {
return [];
}
$admin = Db::name('admin')->field(['id', 'channel_id'])->where('id', $this->auth->id)->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace app\admin\controller\record;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 玩家钱包流水(只读列表,数据由业务入账写入本表)
*/
class UserWalletRecord extends Backend
{
protected ?object $model = null;
protected bool $modelValidate = false;
protected string|array $quickSearchField = ['id', 'biz_type', 'ref_type', 'remark', 'idempotency_key'];
protected string|array $defaultSortField = ['id' => 'desc'];
protected string|array $orderGuarantee = ['id' => 'desc'];
protected array $withJoinTable = ['user', 'channel', 'operatorAdmin'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\UserWalletRecord();
return null;
}
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('钱包流水仅允许业务入账,禁止后台手工新增');
}
public function edit(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('钱包流水不可编辑');
}
public function del(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('钱包流水不可删除');
}
public function sortable(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->error('不支持排序');
}
/**
* 列表渠道管理员仅看本渠道流水channel_id 快照)
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '' && $this->auth && !$this->auth->isSuperAdmin()) {
$channelIds = $this->getScopedChannelIdsForFilter();
$where[] = [$mainShort . '.channel_id', 'in', $channelIds !== [] ? $channelIds : [0]];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'operatorAdmin' => ['username'],
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 非超管:与渠道管理一致,仅本账号相关渠道
*
* @return int[]
*/
private function getScopedChannelIdsForFilter(): array
{
if (!$this->auth) {
return [0];
}
if ($this->auth->isSuperAdmin()) {
return [];
}
$admin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
$byAdmin = Db::name('channel')->where('admin_id', $this->auth->id)->column('id');
return array_values(array_unique(array_merge($ids, $byAdmin)));
}
}

View File

@@ -1,150 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use Throwable;
use app\admin\model\UserRule;
use app\admin\model\UserGroup;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class Group extends Backend
{
protected ?object $model = null;
protected array|string $preExcludeFields = ['update_time', 'create_time'];
protected array|string $quickSearchField = 'name';
protected function initController(Request $request): ?Response
{
$this->model = new UserGroup();
return null;
}
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
list($where, $alias, $limit, $order) = $this->queryBuilder();
$data = $this->model
->alias($alias)
->where($where)
->order($order)
->limit(9999)
->select()
->toArray();
return $this->success('', [
'options' => $data,
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('add')->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk);
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->excludeFields($data);
$data = $this->handleRules($data);
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
$validate->scene('edit')->check(array_merge($data, [$pk => $row[$pk]]));
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated'));
}
$pidArr = UserRule::field('pid')->distinct(true)->where('id', 'in', $row->rules)->select()->toArray();
$rules = $row->rules ? explode(',', $row->rules) : [];
foreach ($pidArr as $item) {
$ruKey = array_search($item['pid'], $rules);
if ($ruKey !== false) {
unset($rules[$ruKey]);
}
}
$rowData = $row->toArray();
$rowData['rules'] = array_values($rules);
return $this->success('', ['row' => $rowData]);
}
private function handleRules(array $data): array
{
if (isset($data['rules']) && is_array($data['rules']) && $data['rules']) {
$rules = UserRule::select();
$super = true;
foreach ($rules as $rule) {
if (!in_array($rule['id'], $data['rules'])) {
$super = false;
break;
}
}
$data['rules'] = $super ? '*' : implode(',', $data['rules']);
} else {
unset($data['rules']);
}
return $data;
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use app\admin\model\User;
use app\admin\model\UserMoneyLog;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class MoneyLog extends Backend
{
protected ?object $model = null;
protected array $withJoinTable = ['user'];
protected array|string $preExcludeFields = ['create_time'];
protected array|string $quickSearchField = ['user.username', 'user.nickname'];
protected function initController(Request $request): ?Response
{
$this->model = new UserMoneyLog();
return null;
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
return $this->_add();
}
$userId = $request->get('userId', $request->post('userId', 0));
$user = User::where('id', $userId)->find();
if (!$user) {
return $this->error(__("The user can't find it"));
}
return $this->success('', ['user' => $user]);
}
}

View File

@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use ba\Tree;
use app\common\controller\Backend;
use app\admin\model\UserRule;
use Webman\Http\Request;
use support\Response;
class Rule extends Backend
{
protected ?object $model = null;
protected Tree $tree;
protected array $initValue = [];
protected bool $assembleTree = true;
protected string $keyword = '';
protected array|string $preExcludeFields = ['create_time', 'update_time'];
protected array|string $defaultSortField = ['weigh' => 'desc'];
protected array|string $quickSearchField = 'title';
protected function initController(Request $request): ?Response
{
$this->model = new UserRule();
$this->tree = Tree::instance();
$isTree = filter_var($request->get('isTree', $request->post('isTree', true)), FILTER_VALIDATE_BOOLEAN);
$initValue = $request->get('initValue') ?? $request->post('initValue') ?? [];
$this->initValue = is_array($initValue) ? array_filter($initValue) : [];
$this->keyword = $request->get('quickSearch', $request->post('quickSearch', ''));
$this->assembleTree = $isTree && !$this->initValue;
return null;
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
list($where, , , ) = $this->queryBuilder();
$list = $this->model->where($where)->order($this->defaultSortField)->select();
if ($this->assembleTree) {
$list = $this->tree->assembleChild($list->toArray());
} else {
$list = $list->toArray();
}
return $this->success('', [
'list' => $list,
'remark' => get_route_remark(),
]);
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_add();
}
public function edit(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_edit();
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
return $this->_del();
}
public function select(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
list($where, , , ) = $this->queryBuilder();
$list = $this->model->where($where)->order($this->defaultSortField)->select();
if ($this->assembleTree) {
$list = $this->tree->assembleChild($list->toArray());
} else {
$list = $list->toArray();
}
return $this->success('', ['list' => $list]);
}
}

View File

@@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use app\admin\model\User;
use app\admin\model\UserScoreLog;
use app\common\controller\Backend;
use Webman\Http\Request;
use support\Response;
class ScoreLog extends Backend
{
protected ?object $model = null;
protected array $withJoinTable = ['user'];
protected array|string $preExcludeFields = ['create_time'];
protected array|string $quickSearchField = ['user.username', 'user.nickname'];
protected function initController(Request $request): ?Response
{
$this->model = new UserScoreLog();
return null;
}
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($request->method() === 'POST') {
return $this->_add();
}
$userId = $request->get('userId', $request->post('userId', 0));
$user = User::where('id', $userId)->find();
if (!$user) {
return $this->error(__("The user can't find it"));
}
return $this->success('', ['user' => $user]);
}
}

View File

@@ -1,101 +1,107 @@
<?php <?php
declare(strict_types=1);
namespace app\admin\controller\user; namespace app\admin\controller\user;
use Throwable; use Throwable;
use app\common\controller\Backend; use app\common\controller\Backend;
use app\admin\model\User as UserModel; use support\think\Db;
use Webman\Http\Request;
use support\Response; use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 用户管理
*/
class User extends Backend class User extends Backend
{ {
/**
* User模型对象
* @var object|null
* @phpstan-var \app\common\model\User|null
*/
protected ?object $model = null; protected ?object $model = null;
protected array $withJoinTable = ['userGroup']; protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time'];
protected array|string $preExcludeFields = ['last_login_time', 'login_failure', 'password', 'salt'];
protected array|string $quickSearchField = ['username', 'nickname', 'id'];
protected function initController(Request $request): ?Response protected array $withJoinTable = ['channel', 'admin'];
protected string|array $quickSearchField = ['id', 'username', 'phone'];
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{ {
$this->model = new UserModel(); $this->model = new \app\common\model\User();
return null; return null;
} }
public function index(Request $request): Response /**
* 添加重写password 使用 Admin 同款加密uuid 由 username+channel_id 生成)
* @throws Throwable
*/
protected function _add(): Response
{ {
$response = $this->initializeBackend($request); if ($this->request && $this->request->method() === 'POST') {
if ($response !== null) return $response; $data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if ($request->get('select') || $request->post('select')) { $data = $this->applyInputFilter($data);
return $this->select($request); $data = $this->excludeFields($data);
}
list($where, $alias, $limit, $order) = $this->queryBuilder(); $password = $data['password'] ?? null;
$res = $this->model if (!is_string($password) || trim($password) === '') {
->withoutField('password,salt') return $this->error(__('Parameter %s can not be empty', ['password']));
->withJoin($this->withJoinTable, $this->withJoinType) }
->alias($alias) $data['password'] = hash_password($password);
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [ $username = $data['username'] ?? '';
'list' => $res->items(), $channelId = $data['channel_id'] ?? null;
'total' => $res->total(), if (!is_string($username) || trim($username) === '' || $channelId === null || $channelId === '') {
'remark' => get_route_remark(), return $this->error(__('Parameter %s can not be empty', ['username/channel_id']));
]); }
} $data['uuid'] = md5(trim($username) . '|' . $channelId);
public function add(Request $request): Response if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
{ $data[$this->dataLimitField] = $this->auth->id;
$response = $this->initializeBackend($request); }
if ($response !== null) return $response;
if ($request->method() !== 'POST') { $result = false;
return $this->error(__('Parameter error')); $this->model->startTrans();
} try {
if ($this->modelValidate) {
$data = $request->post(); $validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (!$data) { if (class_exists($validate)) {
return $this->error(__('Parameter %s can not be empty', [''])); $validate = new $validate();
} if ($this->modelSceneValidate) {
$validate->scene('add');
$passwd = $data['password'] ?? ''; }
$data = $this->excludeFields($data); $validate->check($data);
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) {
$validate = new $validateClass();
if ($this->modelSceneValidate) $validate->scene('add');
$validate->check($data);
} }
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
} }
$result = $this->model->save($data); if ($result !== false) {
$this->model->commit(); return $this->success(__('Added successfully'));
if ($passwd) {
$this->model->resetPassword($this->model->id, $passwd);
} }
} catch (Throwable $e) { return $this->error(__('No rows were added'));
$this->model->rollback();
return $this->error($e->getMessage());
} }
return $result !== false ? $this->success(__('Added successfully')) : $this->error(__('No rows were added'));
return $this->error(__('Parameter error'));
} }
public function edit(Request $request): Response /**
* 编辑重写password 使用 Admin 同款加密uuid 由 username+channel_id 生成)
* @throws Throwable
*/
protected function _edit(): Response
{ {
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$pk = $this->model->getPk(); $pk = $this->model->getPk();
$id = $request->post($pk) ?? $request->get($pk); $id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id); $row = $this->model->find($id);
if (!$row) { if (!$row) {
return $this->error(__('Record not found')); return $this->error(__('Record not found'));
@@ -106,23 +112,48 @@ class User extends Backend
return $this->error(__('You have no permission')); return $this->error(__('You have no permission'));
} }
if ($request->method() === 'POST') { if ($this->request && $this->request->method() === 'POST') {
$data = $request->post(); $data = $this->request->post();
if (!$data) { if (!$data) {
return $this->error(__('Parameter %s can not be empty', [''])); return $this->error(__('Parameter %s can not be empty', ['']));
} }
if (!empty($data['password'])) {
$this->model->resetPassword($row->id, $data['password']); $data = $this->applyInputFilter($data);
}
$data = $this->excludeFields($data); $data = $this->excludeFields($data);
if (array_key_exists('password', $data)) {
$password = $data['password'];
if (!is_string($password) || trim($password) === '') {
unset($data['password']);
} else {
$data['password'] = hash_password($password);
}
}
$nextUsername = array_key_exists('username', $data) ? $data['username'] : $row['username'];
$nextChannelId = null;
if (array_key_exists('channel_id', $data)) {
$nextChannelId = $data['channel_id'];
} else {
$nextChannelId = $row['channel_id'] ?? null;
}
if (is_string($nextUsername) && trim($nextUsername) !== '' && $nextChannelId !== null && $nextChannelId !== '') {
$data['uuid'] = md5(trim($nextUsername) . '|' . $nextChannelId);
}
$result = false; $result = false;
$this->model->startTrans(); $this->model->startTrans();
try { try {
if ($this->modelValidate) { if ($this->modelValidate) {
$validateClass = str_replace("\\model\\", "\\validate\\", get_class($this->model)); $validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validateClass)) { if (class_exists($validate)) {
$validate = new $validateClass(); $validate = new $validate();
$validate->scene('edit')->check(array_merge($data, [$pk => $row[$pk]])); if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
} }
} }
$result = $row->save($data); $result = $row->save($data);
@@ -131,31 +162,182 @@ class User extends Backend
$this->model->rollback(); $this->model->rollback();
return $this->error($e->getMessage()); return $this->error($e->getMessage());
} }
return $result !== false ? $this->success(__('Update successful')) : $this->error(__('No rows updated')); if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
} }
unset($row['password'], $row['salt']); // GET: 返回编辑数据时,剔除敏感字段
$row['password'] = ''; unset($row['password'], $row['salt'], $row['token'], $row['refresh_token']);
return $this->success('', ['row' => $row]); return $this->success('', [
'row' => $row
]);
} }
public function select(Request $request): Response /**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{ {
$response = $this->initializeBackend($request); // 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($response !== null) return $response; if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
/**
* 1. withJoin 不可使用 alias 方法设置表别名,别名将自动使用关联模型名称(小写下划线命名规则)
* 2. 以下的别名设置了主表别名,同时便于拼接查询参数等
* 3. paginate 数据集可使用链式操作 each(function($item, $key) {}) 遍历处理
*/
list($where, $alias, $limit, $order) = $this->queryBuilder(); list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model $res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType) ->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['channel' => ['name'], 'admin' => ['username']])
->alias($alias) ->alias($alias)
->where($where) ->where($where)
->order($order) ->order($order)
->paginate($limit); ->paginate($limit);
return $this->success('', [ return $this->success('', [
'list' => $res->items(), 'list' => $res->items(),
'total' => $res->total(), 'total' => $res->total(),
'remark' => get_route_remark(),
]); ]);
} }
}
/**
* 角色组 → 管理员树(仅当前账号可管理的角色组及其下管理员;用于游戏用户归属)
* 同一管理员若属于多个组,只挂在 id 最小的所属组下,避免树中重复 value
*/
public function adminScopeTree(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$groupIds = $this->getManageableAdminGroupIds();
if ($groupIds === []) {
return $this->success('', ['list' => []]);
}
$groups = Db::name('admin_group')
->where('id', 'in', $groupIds)
->where('status', 1)
->field(['id', 'pid', 'name'])
->order('id', 'asc')
->select()
->toArray();
$accessRows = Db::name('admin_group_access')->alias('aga')
->join('admin a', 'aga.uid = a.id')
->where('aga.group_id', 'in', $groupIds)
->field(['a.id', 'a.username', 'a.channel_id', 'a.invite_code', 'aga.group_id'])
->select()
->toArray();
$adminPrimary = [];
foreach ($accessRows as $row) {
$uid = intval((string)$row['id']);
$gid = intval((string)$row['group_id']);
if (!isset($adminPrimary[$uid]) || $gid < $adminPrimary[$uid]['gid']) {
$adminPrimary[$uid] = [
'gid' => $gid,
'user' => $row,
];
}
}
$adminsByGroup = [];
foreach ($adminPrimary as $item) {
$row = $item['user'];
$gid = $item['gid'];
$invite = $row['invite_code'] ?? '';
$invite = is_string($invite) ? $invite : '';
$channelId = $row['channel_id'] ?? null;
$adminsByGroup[$gid][] = [
'value' => (string)$row['id'],
'label' => (string)$row['username'],
'is_leaf' => true,
'channel_id' => $channelId === null || $channelId === '' ? null : intval((string)$channelId),
'invite_code' => $invite,
];
}
$groupMap = [];
foreach ($groups as $g) {
$groupMap[intval((string)$g['id'])] = $g;
}
$childGroupIdsByPid = [];
foreach ($groups as $g) {
$id = intval((string)$g['id']);
$pid = intval((string)($g['pid'] ?? 0));
$childGroupIdsByPid[$pid][] = $id;
}
$buildNode = null;
$buildNode = function (int $groupId) use (&$buildNode, $groupMap, $childGroupIdsByPid, $adminsByGroup): array {
if (!isset($groupMap[$groupId])) {
return [];
}
$g = $groupMap[$groupId];
$children = [];
foreach ($childGroupIdsByPid[$groupId] ?? [] as $childId) {
$children[] = $buildNode($childId);
}
foreach ($adminsByGroup[$groupId] ?? [] as $leaf) {
$children[] = $leaf;
}
return [
'value' => 'group_' . $groupId,
'label' => (string)$g['name'],
'disabled' => true,
'children' => $children,
];
};
$groupIdSet = array_fill_keys(array_keys($groupMap), true);
$roots = [];
foreach ($groups as $g) {
$id = intval((string)$g['id']);
$pid = intval((string)($g['pid'] ?? 0));
if ($pid === 0 || !isset($groupIdSet[$pid])) {
$roots[] = $id;
}
}
$roots = array_values(array_unique($roots));
sort($roots);
$tree = [];
foreach ($roots as $rid) {
$tree[] = $buildNode($rid);
}
return $this->success('', [
'list' => $tree,
]);
}
/**
* @return int[]
*/
private function getManageableAdminGroupIds(): array
{
if ($this->auth->isSuperAdmin()) {
return Db::name('admin_group')->where('status', 1)->column('id');
}
$own = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$own = array_map('intval', $own);
$children = array_map('intval', $this->auth->getAdminChildGroups());
return array_values(array_unique(array_merge($own, $children)));
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -94,5 +94,6 @@ return [
'No rows were restore' => 'No rows were restored', 'No rows were restore' => 'No rows were restored',
'%d records and files have been deleted' => '%d records and files have been deleted', '%d records and files have been deleted' => '%d records and files have been deleted',
'Please input correct username' => 'Please enter the correct username', 'Please input correct username' => 'Please enter the correct username',
'Please enter a valid commission rate for non-top role group' => 'Non-top role groups require a commission rate between 0 and 100 (%)',
'Group Name Arr' => 'Group Name Arr', 'Group Name Arr' => 'Group Name Arr',
]; ];

View File

@@ -113,5 +113,6 @@ return [
'No rows were restore' => '未恢复任何行', 'No rows were restore' => '未恢复任何行',
'%d records and files have been deleted' => '已删除%d条记录和文件', '%d records and files have been deleted' => '已删除%d条记录和文件',
'Please input correct username' => '请输入正确的用户名', 'Please input correct username' => '请输入正确的用户名',
'Please enter a valid commission rate for non-top role group' => '非顶级角色组须填写 0100 的分红比例(%)',
'Group Name Arr' => '分组名称数组', 'Group Name Arr' => '分组名称数组',
]; ];

View File

@@ -802,8 +802,8 @@ class Helper
$indexVueData['defaultItems'] = self::getJsonFromArray($indexVueData['defaultItems'] ?? []); $indexVueData['defaultItems'] = self::getJsonFromArray($indexVueData['defaultItems'] ?? []);
$indexVueData['tableColumn'] = self::buildTableColumn($indexVueData['tableColumn'] ?? []); $indexVueData['tableColumn'] = self::buildTableColumn($indexVueData['tableColumn'] ?? []);
$indexVueData['dblClickNotEditColumn'] = self::buildSimpleArray($indexVueData['dblClickNotEditColumn'] ?? ['undefined']); $indexVueData['dblClickNotEditColumn'] = self::buildSimpleArray($indexVueData['dblClickNotEditColumn'] ?? ['undefined']);
$controllerFile['path'][] = $controllerFile['originalLastName']; $urlSegments = array_merge($controllerFile['path'], [$controllerFile['originalLastName']]);
$indexVueData['controllerUrl'] = '\'/admin/' . ($controllerFile['path'] ? implode('.', $controllerFile['path']) : '') . '/\''; $indexVueData['controllerUrl'] = '\'/admin/' . ($urlSegments ? implode('.', array_map('strtolower', $urlSegments)) : '') . '/\'';
$indexVueData['componentName'] = ($webViewsDir['path'] ? implode('/', $webViewsDir['path']) . '/' : '') . $webViewsDir['originalLastName']; $indexVueData['componentName'] = ($webViewsDir['path'] ? implode('/', $webViewsDir['path']) . '/' : '') . $webViewsDir['originalLastName'];
$indexVueContent = self::assembleStub('html/index', $indexVueData); $indexVueContent = self::assembleStub('html/index', $indexVueData);
self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'index.vue', $indexVueContent); self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'index.vue', $indexVueContent);

View File

@@ -3,6 +3,8 @@
namespace {%namespace%}; namespace {%namespace%};
{%use%} {%use%}
use app\common\controller\Backend; use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/** /**
* {%tableComment%} * {%tableComment%}

View File

@@ -3,11 +3,11 @@
* 查看 * 查看
* @throws Throwable * @throws Throwable
*/ */
public function index(): void protected function _index(): Response
{ {
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index // 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
if ($this->request->param('select')) { if ($this->request && $this->request->get('select')) {
$this->select(); return $this->select($this->request);
} }
/** /**
@@ -18,13 +18,14 @@
list($where, $alias, $limit, $order) = $this->queryBuilder(); list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model $res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType) ->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
{%relationVisibleFields%} {%relationVisibleFields%}
->alias($alias) ->alias($alias)
->where($where) ->where($where)
->order($order) ->order($order)
->paginate($limit); ->paginate($limit);
$this->success('', [ return $this->success('', [
'list' => $res->items(), 'list' => $res->items(),
'total' => $res->total(), 'total' => $res->total(),
'remark' => get_route_remark(), 'remark' => get_route_remark(),

View File

@@ -1,6 +1,6 @@
public function initialize(): void protected function initController(WebmanRequest $request): ?Response
{ {
parent::initialize();
$this->model = new \{%modelNamespace%}\{%modelName%}();{%filterRule%} $this->model = new \{%modelNamespace%}\{%modelName%}();{%filterRule%}
return null;
} }

View File

@@ -50,6 +50,7 @@ trait Backend
$res = $this->model $res = $this->model
->field($this->indexField) ->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType) ->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->alias($alias) ->alias($alias)
->where($where) ->where($where)
->order($order) ->order($order)
@@ -301,7 +302,40 @@ trait Backend
/** /**
* 加载为 select(远程下拉选择框)数据,子类可覆盖 * 加载为 select(远程下拉选择框)数据,子类可覆盖
*/ */
protected function _select(): void protected function _select(): Response
{ {
if (empty($this->model)) {
return $this->success('', [
'list' => [],
'total' => 0,
]);
}
$pk = $this->model->getPk();
// 远程下拉只要求包含主键与可显示字段;这里尽量返回主键 + quickSearch 字段,避免全量字段带来性能问题
$fields = [$pk];
$quickSearchArr = is_array($this->quickSearchField) ? $this->quickSearchField : explode(',', (string) $this->quickSearchField);
foreach ($quickSearchArr as $f) {
$f = trim((string) $f);
if ($f === '') continue;
$f = str_contains($f, '.') ? substr($f, strrpos($f, '.') + 1) : $f;
if ($f !== '' && !in_array($f, $fields, true)) {
$fields[] = $f;
}
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->field($fields)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
} }
} }

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
use think\model\relation\BelongsTo;
class User extends Model
{
use TimestampInteger;
protected string $table = 'user';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
{
return full_url($value ?? '', false, config('buildadmin.default_avatar'));
}
public function setAvatarAttr($value): string
{
return $value === full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
}
public function getMoneyAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function userGroup(): BelongsTo
{
return $this->belongsTo(UserGroup::class, 'group_id');
}
public function resetPassword(int|string $uid, string $newPassword): int
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
}

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
class UserGroup extends Model
{
use TimestampInteger;
protected string $table = 'user_group';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
}

View File

@@ -1,72 +0,0 @@
<?php
namespace app\admin\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
use think\model\relation\BelongsTo;
class UserMoneyLog extends Model
{
use TimestampInteger;
protected string $table = 'user_money_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public static function onBeforeInsert($model): void
{
$user = User::where('id', $model->user_id)->lock(true)->find();
if (!$user) {
throw new \Exception(__("The user can't find it"));
}
if (!$model->memo) {
throw new \Exception(__("Change note cannot be blank"));
}
$model->before = $user->money;
$user->money += $model->money;
$user->save();
$model->after = $user->money;
}
public static function onBeforeDelete(): bool
{
return false;
}
public function getMoneyAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function getBeforeAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setBeforeAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function getAfterAttr($value): string
{
return bcdiv((string) $value, '100', 2);
}
public function setAfterAttr($value): string
{
return bcmul((string) $value, '100', 2);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace app\admin\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
use think\model\relation\BelongsTo;
class UserScoreLog extends Model
{
use TimestampInteger;
protected string $table = 'user_score_log';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected bool $updateTime = false;
public static function onBeforeInsert($model): void
{
$user = User::where('id', $model->user_id)->lock(true)->find();
if (!$user) {
throw new \Exception(__("The user can't find it"));
}
if (!$model->memo) {
throw new \Exception(__("Change note cannot be blank"));
}
$model->before = $user->score;
$user->score += $model->score;
$user->save();
$model->after = $user->score;
}
public static function onBeforeDelete(): bool
{
return false;
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace app\admin\model\mall;
use app\common\model\traits\TimestampInteger;
use support\think\Model;
/**
* Player
*/
class Player extends Model
{
use TimestampInteger;
// 表名
protected $name = 'mall_player';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
/**
* 重置密码
*/
public function resetPassword(int $id, string $newPassword): bool
{
return $this->where(['id' => $id])->update(['password' => hash_password($newPassword)]) !== false;
}
}

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class UserMoneyLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'money' => 'require',
'memo' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['user_id', 'money', 'memo'],
'edit' => ['user_id', 'money', 'memo'],
];
public function __construct()
{
$this->field = [
'user_id' => __('user_id'),
'money' => __('money'),
'memo' => __('memo'),
];
parent::__construct();
}
}

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\validate;
use think\Validate;
class UserScoreLog extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require',
'score' => 'require',
'memo' => 'require',
];
protected $message = [];
protected $field = [];
protected $scene = [
'add' => ['user_id', 'score', 'memo'],
'edit' => ['user_id', 'score', 'memo'],
];
public function __construct()
{
$this->field = [
'user_id' => __('user_id'),
'score' => __('score'),
'memo' => __('memo'),
];
parent::__construct();
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace app\admin\validate\mall;
use think\Validate;
class Player extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
];
/**
* 提示消息
*/
protected $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
];
}

View File

@@ -10,6 +10,7 @@ use app\common\facade\Token;
use app\common\model\UserScoreLog; use app\common\model\UserScoreLog;
use app\common\model\UserMoneyLog; use app\common\model\UserMoneyLog;
use app\common\controller\Frontend; use app\common\controller\Frontend;
use app\common\facade\Token as TokenFacade;
use support\validation\Validator; use support\validation\Validator;
use support\validation\ValidationException; use support\validation\ValidationException;
use Webman\Http\Request; use Webman\Http\Request;
@@ -20,6 +21,51 @@ class Account extends Frontend
protected array $noNeedLogin = ['retrievePassword']; protected array $noNeedLogin = ['retrievePassword'];
protected array $noNeedPermission = ['verification', 'changeBind']; protected array $noNeedPermission = ['verification', 'changeBind'];
public function userProfile(Request $request): Response
{
$response = $this->initializeFrontend($request);
if ($response !== null) {
return $response;
}
$authToken = trim((string) $request->header('auth-token', ''));
if ($authToken === '') {
return $this->mobileResult(1101, 'Missing auth-token');
}
$tokenData = TokenFacade::get($authToken);
$type = $tokenData['type'] ?? '';
$expireTime = $tokenData['expire_time'] ?? 0;
if ($type !== 'auth-token' || !is_numeric($expireTime) || $expireTime < time()) {
return $this->mobileResult(1101, 'auth-token is invalid or expired');
}
$user = $this->auth->getUser();
$payload = [
'code' => 1,
'message' => __('ok'),
'data' => [
'id' => $user->id,
'username' => $user->username,
'head_image' => $user->avatar ?? '',
'coin' => $user->coin,
'current_streak' => $user->current_streak ?? 0,
'channel_id' => $user->channel_id,
'risk_flags' => $user->risk_flags ?? 0,
],
];
return \response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
}
private function mobileResult(int $code, string $message, array $data = []): Response
{
$payload = [
'code' => $code,
'message' => __($message),
'data' => $data,
];
return \response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
}
public function overview(Request $request): Response public function overview(Request $request): Response
{ {
$response = $this->initializeFrontend($request); $response = $this->initializeFrontend($request);

138
app/api/controller/Auth.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\facade\Token;
use app\common\library\Auth as UserAuth;
use app\common\model\User;
use ba\Random;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
class Auth extends MobileBase
{
protected array $noNeedLogin = ['userRegister', 'userLogin', 'tokenRefresh'];
public function userRegister(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$account = trim((string) $request->post('account', ''));
$accountType = trim((string) $request->post('account_type', ''));
$password = (string) $request->post('password', '');
$inviteCode = trim((string) $request->post('invite_code', ''));
if ($account === '' || $accountType === '' || $password === '') {
return $this->mobileError(1001, 'Missing parameters');
}
if ($accountType !== 'phone' && $accountType !== 'email') {
return $this->mobileError(1003, 'Invalid parameter value');
}
$username = $account;
$mobile = '';
$email = '';
if ($accountType === 'phone') {
$mobile = $account;
}
if ($accountType === 'email') {
$email = $account;
}
$extend = [];
if ($inviteCode !== '') {
$inviterAdmin = Db::name('admin')->field(['id', 'channel_id'])->where('invite_code', $inviteCode)->find();
if (!$inviterAdmin) {
return $this->mobileError(2002, 'Invite code does not exist');
}
$extend['register_invite_code'] = $inviteCode;
$extend['admin_id'] = $inviterAdmin['id'];
$extend['channel_id'] = $inviterAdmin['channel_id'] ?? null;
}
$registered = $this->auth->register($username, $password, $mobile, $email, 1, $extend);
if (!$registered) {
return $this->mobileError(2000, (string) $this->auth->getError());
}
$loggedIn = $this->auth->login($username, $password, true);
if (!$loggedIn) {
return $this->mobileError(2000, 'Registered successfully but login failed');
}
$userInfo = $this->auth->getUserInfo();
return $this->mobileSuccess([
'user_id' => $userInfo['id'] ?? null,
'access_token' => $userInfo['token'] ?? '',
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
'profile' => [
'username' => $userInfo['username'] ?? '',
'coin' => $userInfo['coin'] ?? '0.0000',
'channel_id' => $userInfo['channel_id'] ?? null,
],
]);
}
public function userLogin(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$account = trim((string) $request->post('account', ''));
$password = (string) $request->post('password', '');
if ($account === '' || $password === '') {
return $this->mobileError(1001, 'Missing parameters');
}
$ok = $this->auth->login($account, $password, true);
if (!$ok) {
return $this->mobileError(1101, 'Incorrect account or password');
}
$userInfo = $this->auth->getUserInfo();
return $this->mobileSuccess([
'access_token' => $userInfo['token'] ?? '',
'refresh_token' => $userInfo['refresh_token'] ?? '',
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
'user' => [
'id' => $userInfo['id'] ?? null,
'username' => $userInfo['username'] ?? '',
'coin' => $userInfo['coin'] ?? '0.0000',
'risk_flags' => $userInfo['risk_flags'] ?? 0,
],
]);
}
public function tokenRefresh(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$refreshToken = trim((string) $request->post('refresh_token', ''));
if ($refreshToken === '') {
return $this->mobileError(1001, 'Missing parameters');
}
$tokenData = Token::get($refreshToken);
if (!$tokenData || $tokenData['type'] !== UserAuth::TOKEN_TYPE . '-refresh' || $tokenData['expire_time'] < time()) {
return $this->mobileError(1101, 'Login status has expired');
}
$newToken = Random::uuid();
Token::set($newToken, UserAuth::TOKEN_TYPE, $tokenData['user_id'], config('buildadmin.user_token_keep_time', 259200));
return $this->mobileSuccess([
'access_token' => $newToken,
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
]);
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\model\DepositOrder;
use app\common\model\WithdrawOrder;
use Webman\Http\Request;
use support\Response;
class Finance extends MobileBase
{
public function depositCreate(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$payAmountFiat = (string) $request->post('pay_amount_fiat', '');
$fiatCurrency = trim((string) $request->post('fiat_currency', ''));
$channel = trim((string) $request->post('channel', ''));
$idempotencyKey = trim((string) $request->post('idempotency_key', ''));
if ($payAmountFiat === '' || $fiatCurrency === '' || $channel === '' || $idempotencyKey === '') {
return $this->mobileError(1001, 'Missing parameters');
}
$orderNo = 'DP' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
$coinAmount = $payAmountFiat;
DepositOrder::create([
'order_no' => $orderNo,
'user_id' => $this->auth->id,
'fiat_currency' => $fiatCurrency,
'fiat_amount' => $payAmountFiat,
'fx_rate' => '1.00000000',
'coin_amount' => $coinAmount,
'gateway' => $channel,
'status' => 0,
'create_time' => time(),
'update_time' => time(),
]);
return $this->mobileSuccess([
'order_no' => $orderNo,
'coin_amount' => $coinAmount,
'pay_url' => '',
'status' => 'pending',
]);
}
public function depositDetail(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$orderNo = trim((string) $request->get('order_no', ''));
if ($orderNo === '') {
return $this->mobileError(1001, 'Missing parameters');
}
$order = DepositOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find();
if (!$order) {
return $this->mobileError(2003, 'Order does not exist');
}
return $this->mobileSuccess([
'order_no' => $order->order_no,
'status' => $this->mapDepositStatus($order->status),
'coin_amount' => $order->coin_amount,
'create_time' => $order->create_time,
'finish_time' => $order->paid_at,
]);
}
public function withdrawCreate(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$withdrawCoin = (string) $request->post('withdraw_coin', '');
$receiveAccount = trim((string) $request->post('receive_account', ''));
$receiveType = trim((string) $request->post('receive_type', ''));
$idempotencyKey = trim((string) $request->post('idempotency_key', ''));
if ($withdrawCoin === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === '') {
return $this->mobileError(1001, 'Missing parameters');
}
$user = $this->auth->getUser();
if (bccomp((string) $user->coin, $withdrawCoin, 4) < 0) {
return $this->mobileError(2001, 'Insufficient balance');
}
$orderNo = 'WD' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
$feeCoin = bcmul($withdrawCoin, '0.005', 4);
$actualArrivalCoin = bcsub($withdrawCoin, $feeCoin, 4);
WithdrawOrder::create([
'order_no' => $orderNo,
'user_id' => $user->id,
'apply_amount' => $withdrawCoin,
'fee_amount' => $feeCoin,
'actual_amount' => $actualArrivalCoin,
'fiat_currency' => '',
'need_audit' => 1,
'audit_status' => 0,
'reject_reason' => '',
'create_time' => time(),
'update_time' => time(),
]);
return $this->mobileSuccess([
'order_no' => $orderNo,
'status' => 'pending_review',
'fee_coin' => $feeCoin,
'actual_arrival_coin' => $actualArrivalCoin,
'risk_review_required' => true,
]);
}
public function withdrawDetail(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$orderNo = trim((string) $request->get('order_no', ''));
if ($orderNo === '') {
return $this->mobileError(1001, 'Missing parameters');
}
$order = WithdrawOrder::where('order_no', $orderNo)->where('user_id', $this->auth->id)->find();
if (!$order) {
return $this->mobileError(2003, 'Order does not exist');
}
return $this->mobileSuccess([
'order_no' => $order->order_no,
'status' => $this->mapWithdrawStatus($order->audit_status),
'withdraw_coin' => $order->apply_amount,
'fee_coin' => $order->fee_amount,
'reject_reason' => $order->reject_reason === '' ? null : $order->reject_reason,
'create_time' => $order->create_time,
]);
}
private function mapDepositStatus($status): string
{
if ($this->intValue($status) === 1) {
return 'paid';
}
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
return 'failed';
}
return 'pending';
}
private function mapWithdrawStatus($auditStatus): string
{
if ($this->intValue($auditStatus) === 1) {
return 'approved';
}
if ($this->intValue($auditStatus) === 2) {
return 'rejected';
}
return 'pending_review';
}
private function intValue($value): int
{
$result = filter_var($value, FILTER_VALIDATE_INT);
if ($result === false) {
return 0;
}
return $result;
}
}

320
app/api/controller/Game.php Normal file
View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\library\game\ZiHuaDictionary;
use app\common\model\BetOrder;
use app\common\model\GameConfig;
use app\common\model\GameRecord;
use app\common\model\UserWalletRecord;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
class Game extends MobileBase
{
protected array $noNeedLogin = ['dictionaryList', 'periodHistory'];
public function lobbyInit(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$period = GameRecord::order('id', 'desc')->find();
$now = time();
$startAt = $period ? $this->intValue($period->period_start_at) : $now;
$lockAt = $startAt + 20;
$openAt = $startAt + 22;
$countdown = $period ? max(0, ($startAt + 30) - $now) : 0;
$dictionaryConfig = GameConfig::where('config_key', ZiHuaDictionary::CONFIG_KEY)->find();
$dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig?->config_value ?? null);
$items = [];
foreach ($dictionaryItems as $row) {
$items[] = [
'number' => $row['no'],
'name' => $row['name'],
'category' => $row['category'],
'icon' => '',
];
}
$user = $this->auth->getUser();
return $this->mobileSuccess([
'server_time' => $now,
'period' => [
'period_no' => $period->period_no ?? '',
'status' => $this->mapPeriodStatus($period->status ?? null),
'countdown' => $countdown,
'lock_at' => $lockAt,
'open_at' => $openAt,
],
'bet_config' => [
'max_select_count' => $this->intValue($this->getConfigValue('max_select_count', '5')),
'chips' => ['1.0000', '5.0000', '10.0000', '25.0000', '50.0000', '100.0000'],
'single_number_max_bet' => $this->getConfigValue('single_number_max_bet', '500.0000'),
],
'dictionary' => $items,
'user_snapshot' => [
'coin' => $user->coin,
'current_streak' => $user->current_streak ?? 0,
],
]);
}
public function dictionaryList(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$dictionaryConfig = GameConfig::where('config_key', ZiHuaDictionary::CONFIG_KEY)->find();
$dictionaryItems = ZiHuaDictionary::parseFromConfigValue($dictionaryConfig?->config_value ?? null);
$items = [];
foreach ($dictionaryItems as $row) {
$items[] = [
'number' => $row['no'],
'name' => $row['name'],
'category' => $row['category'],
'icon' => '',
];
}
return $this->mobileSuccess([
'version' => (string) ($dictionaryConfig->update_time ?? '1'),
'items' => $items,
]);
}
public function periodHistory(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$limit = $this->intValue($request->get('limit', 30));
if ($limit < 1) {
$limit = 30;
}
$list = GameRecord::whereNotNull('result_number')->order('id', 'desc')->limit($limit)->select();
$rows = [];
foreach ($list as $item) {
$rows[] = [
'period_no' => $item->period_no,
'result_number' => $item->result_number,
'open_time' => $item->update_time,
];
}
return $this->mobileSuccess(['list' => $rows]);
}
public function periodCurrent(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$period = GameRecord::order('id', 'desc')->find();
if (!$period) {
return $this->mobileError(2002, 'Game period does not exist');
}
$now = time();
$startAt = $this->intValue($period->period_start_at);
return $this->mobileSuccess([
'period_id' => $period->id,
'period_no' => $period->period_no,
'status' => $this->mapPeriodStatus($period->status),
'countdown' => max(0, ($startAt + 30) - $now),
'bet_close_in' => max(0, ($startAt + 20) - $now),
'result_number' => $period->result_number,
]);
}
public function betPlace(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$periodNo = trim((string) $request->post('period_no', ''));
$numbers = $request->post('numbers', []);
$betAmount = (string) $request->post('bet_amount', '');
$idempotencyKey = trim((string) $request->post('idempotency_key', ''));
if ($periodNo === '' || !is_array($numbers) || $betAmount === '' || $idempotencyKey === '') {
return $this->mobileError(1001, 'Missing parameters');
}
if (count($numbers) < 1) {
return $this->mobileError(1003, 'Invalid parameter value');
}
$period = GameRecord::where('period_no', $periodNo)->find();
if (!$period) {
return $this->mobileError(2002, 'Game period does not exist');
}
if ($this->intValue($period->status) !== 0) {
return $this->mobileError(3002, 'Betting is closed');
}
$user = $this->auth->getUser();
$pickCount = count($numbers);
$totalAmount = bcmul($betAmount, (string) $pickCount, 4);
if (bccomp((string) $user->coin, $totalAmount, 4) < 0) {
return $this->mobileError(2001, 'Insufficient balance');
}
$exists = BetOrder::where('idempotency_key', $idempotencyKey)->find();
if ($exists) {
return $this->mobileError(3003, 'Duplicate request');
}
Db::startTrans();
try {
$before = (string) $user->coin;
$after = bcsub($before, $totalAmount, 4);
UserWalletRecord::create([
'user_id' => $user->id,
'channel_id' => $user->channel_id,
'biz_type' => 'bet',
'direction' => 2,
'amount' => $totalAmount,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'bet_order',
'remark' => '移动端下注',
'create_time' => time(),
]);
Db::name('user')->where('id', $user->id)->update(['coin' => $after, 'update_time' => time()]);
$orderNo = 'BO' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
BetOrder::create([
'period_id' => $period->id,
'period_no' => $period->period_no,
'user_id' => $user->id,
'channel_id' => $user->channel_id,
'pick_numbers' => $numbers,
'unit_amount' => $betAmount,
'pick_count' => $pickCount,
'total_amount' => $totalAmount,
'streak_at_bet' => $user->current_streak ?? 0,
'is_auto' => 0,
'status' => 1,
'idempotency_key' => $idempotencyKey,
'create_time' => time(),
'update_time' => time(),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->mobileError(5000, 'System is busy, please try again later', ['detail' => $e->getMessage()]);
}
return $this->mobileSuccess([
'order_no' => $orderNo,
'period_no' => $period->period_no,
'status' => 'accepted',
'locked_balance' => '0.0000',
'balance_after' => $after,
'current_streak' => $user->current_streak ?? 0,
]);
}
public function betRebet(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
return $this->mobileError(3001, 'Current process does not allow this operation');
}
public function autoBetCreate(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
return $this->mobileError(3001, 'Current process does not allow this operation');
}
public function autoBetStop(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
return $this->mobileError(3001, 'Current process does not allow this operation');
}
public function betMyOrders(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$page = $this->intValue($request->get('page', 1));
$pageSize = $this->intValue($request->get('page_size', 20));
$paginate = BetOrder::where('user_id', $this->auth->id)->order('id', 'desc')->paginate([
'page' => $page,
'list_rows' => $pageSize,
]);
$rows = [];
foreach ($paginate->items() as $item) {
$rows[] = [
'order_no' => (string) $item->id,
'period_no' => $item->period_no,
'numbers' => $item->pick_numbers ?? [],
'bet_amount' => $item->unit_amount,
'total_amount' => $item->total_amount,
'result_number' => null,
'win_amount' => $item->win_amount,
'status' => (string) $item->status,
'create_time' => $item->create_time,
];
}
return $this->mobileSuccess([
'list' => $rows,
'pagination' => [
'page' => $paginate->currentPage(),
'page_size' => $paginate->listRows(),
'total' => $paginate->total(),
],
]);
}
private function mapPeriodStatus($status): string
{
if ($this->intValue($status) === 0) {
return 'betting';
}
if ($this->intValue($status) === 1) {
return 'locked';
}
if ($this->intValue($status) === 2 || $this->intValue($status) === 3) {
return 'settling';
}
return 'finished';
}
private function getConfigValue(string $key, string $default): string
{
$value = GameConfig::where('config_key', $key)->value('config_value');
if ($value === null || $value === '') {
return $default;
}
return (string) $value;
}
private function intValue($value): int
{
$result = filter_var($value, FILTER_VALIDATE_INT);
if ($result === false) {
return 0;
}
return $result;
}
}

View File

@@ -11,7 +11,7 @@ use ba\Terminal;
use ba\Filesystem; use ba\Filesystem;
use app\common\controller\Api; use app\common\controller\Api;
use app\admin\model\Admin as AdminModel; use app\admin\model\Admin as AdminModel;
use app\admin\model\User as UserModel; use app\process\Monitor;
use support\Response; use support\Response;
use Webman\Http\Request; use Webman\Http\Request;
use Phinx\Config\Config as PhinxConfig; use Phinx\Config\Config as PhinxConfig;
@@ -428,6 +428,20 @@ class Install extends Api
]); ]);
} }
// Windows 下 php windows.php 会每秒检测监控目录;写入 config/.env 等会触发 taskkill 整进程,导致 POST 被中断ERR_CONNECTION_RESET
Monitor::pause();
try {
return $this->baseConfigPost($request, $envOk, $rootPath, $migrateCommand);
} finally {
Monitor::resume();
}
}
/**
* 系统基础配置 POST写入配置并执行迁移
*/
private function baseConfigPost(Request $request, bool $envOk, string $rootPath, string $migrateCommand): Response
{
$connectData = $databaseParam = $request->only(['hostname', 'username', 'password', 'hostport', 'database', 'prefix']); $connectData = $databaseParam = $request->only(['hostname', 'username', 'password', 'hostport', 'database', 'prefix']);
// 数据库配置测试 // 数据库配置测试
@@ -455,6 +469,9 @@ class Install extends Api
return "\$env('database.{$key}', '" . addslashes($value) . "')"; return "\$env('database.{$key}', '" . addslashes($value) . "')";
}; };
$dbConfigText = preg_replace_callback("/\\\$env\('database\.(hostname|database|username|password|hostport|prefix)',\s*'[^']*'\)/", $callback, $dbConfigContent); $dbConfigText = preg_replace_callback("/\\\$env\('database\.(hostname|database|username|password|hostport|prefix)',\s*'[^']*'\)/", $callback, $dbConfigContent);
if ($dbConfigText === null) {
return $this->error(__('Failed to update database config file:%s', ['config/' . self::$dbConfigFileName]));
}
$result = @file_put_contents($dbConfigFile, $dbConfigText); $result = @file_put_contents($dbConfigFile, $dbConfigText);
if (!$result) { if (!$result) {
return $this->error(__('File has no write permission:%s', ['config/' . self::$dbConfigFileName])); return $this->error(__('File has no write permission:%s', ['config/' . self::$dbConfigFileName]));
@@ -609,10 +626,6 @@ class Install extends Api
$adminModel->resetPassword($defaultAdmin->id, $param['adminpassword']); $adminModel->resetPassword($defaultAdmin->id, $param['adminpassword']);
} }
// 默认用户密码修改
$user = new UserModel();
$user->resetPassword(1, Random::build());
// 修改站点名称 // 修改站点名称
if (class_exists(\app\admin\model\Config::class)) { if (class_exists(\app\admin\model\Config::class)) {
\app\admin\model\Config::where('name', 'site_name')->update([ \app\admin\model\Config::where('name', 'site_name')->update([
@@ -650,19 +663,20 @@ class Install extends Api
/** /**
* 获取安装完成后的访问地址(根据请求来源区分 API 与前端开发模式) * 获取安装完成后的访问地址(根据请求来源区分 API 与前端开发模式)
* - 通过 API 访问(8787index.html#/admin、index.html#/ * - 通过 API 访问(7979index#/admin、index#/(无 index 与 # 之间的斜杠)
* - 通过前端开发服务访问1818/#/admin、/#/ * - 通过前端开发服务访问1818/#/admin、/#/
*/ */
public function accessUrls(Request $request): Response public function accessUrls(Request $request): Response
{ {
$this->setRequest($request); $this->setRequest($request);
$host = $request->header('host', '127.0.0.1:8787'); $host = $request->header('host', '127.0.0.1:7979');
$port = '8787'; $port = '7979';
if (str_contains($host, ':')) { if (str_contains($host, ':')) {
$port = substr($host, strrpos($host, ':') + 1); $port = substr($host, strrpos($host, ':') + 1);
} }
$scheme = $request->header('x-forwarded-proto', 'http'); $scheme = $request->header('x-forwarded-proto', 'http');
$base = rtrim($scheme . '://' . $host, '/'); $basePath = $request instanceof \support\Request ? $request->publicBasePath() : '';
$base = rtrim($scheme . '://' . $host, '/') . $basePath;
if ($port === '1818') { if ($port === '1818') {
$adminUrl = $base . '/#/admin'; $adminUrl = $base . '/#/admin';

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\controller\Frontend;
use app\common\facade\Token;
use support\Response;
use Webman\Http\Request;
use function response;
abstract class MobileBase extends Frontend
{
protected array $noNeedPermission = ['*'];
protected array $noNeedAuthToken = [];
/**
* 移动端统一初始化:
* - 校验请求头 auth-token
* - 再走会员中心 Frontend 初始化(登录态/权限等)
*/
protected function initializeMobile(Request $request): ?Response
{
$this->setRequest($request);
$path = trim($request->path(), '/');
$parts = explode('/', $path);
$action = $parts[array_key_last($parts)] ?? '';
$needAuthToken = !action_in_arr($this->noNeedAuthToken, $action);
if ($needAuthToken) {
$authToken = trim((string) $request->header('auth-token', ''));
if ($authToken === '') {
return $this->mobileError(1101, 'Missing auth-token');
}
$tokenData = Token::get($authToken);
$type = $tokenData['type'] ?? '';
$expireTime = $tokenData['expire_time'] ?? 0;
if ($type !== 'auth-token' || !is_numeric($expireTime) || $expireTime < time()) {
return $this->mobileError(1101, 'auth-token is invalid or expired');
}
}
return $this->initializeFrontend($request);
}
protected function mobileSuccess(array $data = [], string $message = 'ok'): Response
{
if ($message === '') {
$message = __('ok');
} else {
$message = __($message);
}
$payload = [
'code' => 1,
'message' => $message,
'data' => $data,
];
return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
}
protected function mobileError(int $code, string $message, array $data = []): Response
{
$payload = [
'code' => $code,
'message' => __($message),
'data' => $data,
];
return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\model\OperationNotice;
use app\common\model\UserNoticeRead;
use Webman\Http\Request;
use support\Response;
class Notice extends MobileBase
{
public function noticeList(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$page = $this->intValue($request->get('page', 1), 1);
$pageSize = $this->intValue($request->get('page_size', 20), 20);
$paginate = OperationNotice::where('status', 1)->order('id', 'desc')->paginate([
'page' => $page,
'list_rows' => $pageSize,
]);
$noticeIds = [];
foreach ($paginate->items() as $row) {
$noticeIds[] = $row->id;
}
$readRows = [];
if ($noticeIds !== []) {
$readRows = UserNoticeRead::where('user_id', $this->auth->id)->whereIn('notice_id', $noticeIds)->column('notice_id');
}
$readMap = array_flip($readRows);
$list = [];
foreach ($paginate->items() as $row) {
$list[] = [
'notice_id' => $row->id,
'title' => $row->title,
'notice_type' => $this->intValue($row->notice_type, 0) === 1 ? 'popout' : 'silent',
'is_read' => isset($readMap[$row->id]),
'publish_time' => $row->publish_at,
];
}
return $this->mobileSuccess(['list' => $list]);
}
public function noticeDetail(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$id = $this->intValue($request->get('id', 0), 0);
if ($id < 1) {
return $this->mobileError(1001, 'Missing parameters');
}
$notice = OperationNotice::where('id', $id)->where('status', 1)->find();
if (!$notice) {
return $this->mobileError(2004, 'Notice does not exist');
}
return $this->mobileSuccess([
'notice_id' => $notice->id,
'title' => $notice->title,
'content' => $notice->content,
'notice_type' => $this->intValue($notice->notice_type, 0) === 1 ? 'popout' : 'silent',
'must_confirm' => $this->intValue($notice->notice_type, 0) === 1,
'publish_time' => $notice->publish_at,
]);
}
public function noticeConfirm(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$noticeId = $this->intValue($request->post('notice_id', 0), 0);
if ($noticeId < 1) {
return $this->mobileError(1001, 'Missing parameters');
}
$notice = OperationNotice::where('id', $noticeId)->where('status', 1)->find();
if (!$notice) {
return $this->mobileError(2004, 'Notice does not exist');
}
$exists = UserNoticeRead::where('user_id', $this->auth->id)->where('notice_id', $noticeId)->find();
$now = time();
if ($exists) {
$exists->save([
'confirmed' => 1,
'read_at' => $now,
]);
} else {
UserNoticeRead::create([
'user_id' => $this->auth->id,
'notice_id' => $noticeId,
'confirmed' => 1,
'read_at' => $now,
'create_time' => $now,
]);
}
return $this->mobileSuccess([
'notice_id' => $noticeId,
'confirmed' => true,
'confirm_time' => $now,
]);
}
private function intValue($value, int $default): int
{
$result = filter_var($value, FILTER_VALIDATE_INT);
if ($result === false) {
return $default;
}
return $result;
}
}

View File

@@ -6,6 +6,7 @@ use ba\Captcha;
use ba\ClickCaptcha; use ba\ClickCaptcha;
use app\common\controller\Frontend; use app\common\controller\Frontend;
use app\common\facade\Token; use app\common\facade\Token;
use support\think\Db;
use support\validation\Validator; use support\validation\Validator;
use support\validation\ValidationException; use support\validation\ValidationException;
use Webman\Http\Request; use Webman\Http\Request;
@@ -46,6 +47,7 @@ class User extends Frontend
'captchaId' => $params['captchaId'] ?? '', 'captchaId' => $params['captchaId'] ?? '',
'captchaInfo' => $params['captchaInfo'] ?? '', 'captchaInfo' => $params['captchaInfo'] ?? '',
'registerType' => $params['registerType'] ?? '', 'registerType' => $params['registerType'] ?? '',
'invite_code' => $params['invite_code'] ?? '',
]); ]);
if (!in_array($params['tab'], ['login', 'register'])) { if (!in_array($params['tab'], ['login', 'register'])) {
@@ -72,7 +74,20 @@ class User extends Frontend
if (!$captchaObj->check($params['captcha'], $params[$params['registerType']] . 'user_register')) { if (!$captchaObj->check($params['captcha'], $params[$params['registerType']] . 'user_register')) {
return $this->error(__('Please enter the correct verification code')); return $this->error(__('Please enter the correct verification code'));
} }
$res = $this->auth->register($params['username'], $params['password'], $params['mobile'], $params['email']); $extend = [];
if (!empty($params['invite_code'])) {
$inviterAdmin = Db::name('admin')
->field(['id', 'channel_id'])
->where('invite_code', $params['invite_code'])
->find();
if (!$inviterAdmin) {
return $this->error(__('Parameter error'));
}
$extend['register_invite_code'] = $params['invite_code'];
$extend['inviter_admin_id'] = $inviterAdmin['id'];
$extend['channel_id'] = $inviterAdmin['channel_id'] ?? null;
}
$res = $this->auth->register($params['username'], $params['password'], $params['mobile'], $params['email'], 1, $extend);
} }
if ($res === true) { if ($res === true) {
@@ -117,6 +132,7 @@ class User extends Frontend
'email' => 'required_if:registerType,email|email|unique:user,email', 'email' => 'required_if:registerType,email|email|unique:user,email',
'mobile' => 'required_if:registerType,mobile|regex:/^1[3-9]\d{9}$/|unique:user,mobile', 'mobile' => 'required_if:registerType,mobile|regex:/^1[3-9]\d{9}$/|unique:user,mobile',
'captcha' => 'required|string', 'captcha' => 'required|string',
'invite_code' => 'nullable|string|max:64',
], ],
[ [
'username.regex' => __('Please input correct username'), 'username.regex' => __('Please input correct username'),
@@ -129,6 +145,7 @@ class User extends Frontend
'password' => __('Password'), 'password' => __('Password'),
'captcha' => __('captcha'), 'captcha' => __('captcha'),
'registerType' => __('Register type'), 'registerType' => __('Register type'),
'invite_code' => __('Invite code'),
] ]
]; ];
} }

85
app/api/controller/V1.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\controller\Api;
use app\common\facade\Token;
use ba\Random;
use Webman\Http\Request;
use support\Response;
use function response;
class V1 extends Api
{
public function authToken(Request $request): Response
{
$responseInit = $this->initializeApi($request);
if ($responseInit !== null) {
return $responseInit;
}
$secret = trim((string) $request->get('secret', ''));
$timestampRaw = $request->get('timestamp', '');
$deviceId = trim((string) $request->get('device_id', ''));
$signature = trim((string) $request->get('signature', ''));
if ($secret === '' || $timestampRaw === '' || $deviceId === '' || $signature === '') {
return $this->mobileResult(1001, 'Missing parameters');
}
$serverSecret = (string) env('AUTH_TOKEN_SECRET', '');
if ($serverSecret === '' || !hash_equals($serverSecret, $secret)) {
return $this->mobileResult(1103, 'Invalid secret');
}
$timestamp = filter_var($timestampRaw, FILTER_VALIDATE_INT);
if ($timestamp === false) {
return $this->mobileResult(1002, 'Invalid parameter format');
}
$now = time();
$skew = abs($now - $timestamp);
if ($skew > 300) {
return $this->mobileResult(3001, 'Invalid timestamp');
}
$params = [
'device_id' => $deviceId,
'secret' => $secret,
'timestamp' => (string) $timestamp,
];
ksort($params);
$pairs = [];
foreach ($params as $k => $v) {
$pairs[] = $k . '=' . $v;
}
$plain = implode('&', $pairs);
$expected = strtoupper(md5($plain));
if (!hash_equals($expected, $signature)) {
return $this->mobileResult(1103, 'Invalid signature');
}
$token = Random::uuid();
$expire = 60 * 60 * 24;
Token::set($token, 'auth-token', 0, $expire);
return $this->mobileResult(1, 'ok', [
'auth_token' => $token,
'expires_in' => $expire,
'server_time' => $now,
]);
}
private function mobileResult(int $code, string $message, array $data = []): Response
{
$payload = [
'code' => $code,
'message' => __($message),
'data' => $data,
];
return response(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 200, ['Content-Type' => 'application/json']);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\model\UserWalletRecord;
use Webman\Http\Request;
use support\Response;
class Wallet extends MobileBase
{
public function balanceSummary(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$user = $this->auth->getUser();
return $this->mobileSuccess([
'coin_balance' => $user->coin,
'frozen_balance' => '0.0000',
'total_deposit_coin' => $user->total_deposit_coin ?? '0.0000',
'total_valid_bet_coin' => $user->total_valid_bet_coin ?? '0.0000',
'withdrawable_balance' => $user->coin,
]);
}
public function recordList(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$type = trim((string) $request->get('type', 'all'));
$page = $this->intValue($request->get('page', 1), 1);
$pageSize = $this->intValue($request->get('page_size', 20), 20);
$query = UserWalletRecord::where('user_id', $this->auth->id)->order('id', 'desc');
if ($type !== '' && $type !== 'all') {
$query->where('biz_type', $type);
}
$paginate = $query->paginate([
'page' => $page,
'list_rows' => $pageSize,
]);
$list = [];
foreach ($paginate->items() as $row) {
$list[] = [
'record_id' => $row->id,
'biz_type' => $row->biz_type,
'direction' => $row->direction,
'amount' => $row->amount,
'balance_before' => $row->balance_before,
'balance_after' => $row->balance_after,
'ref_type' => $row->ref_type,
'ref_id' => $row->ref_id,
'create_time' => $row->create_time,
];
}
return $this->mobileSuccess([
'list' => $list,
'pagination' => [
'page' => $paginate->currentPage(),
'page_size' => $paginate->listRows(),
'total' => $paginate->total(),
],
]);
}
private function intValue($value, int $default): int
{
$result = filter_var($value, FILTER_VALIDATE_INT);
if ($result === false) {
return $default;
}
return $result;
}
}

View File

@@ -12,6 +12,27 @@ return [
'Please login first' => 'Please login first', 'Please login first' => 'Please login first',
'You have no permission' => 'No permission to operate', 'You have no permission' => 'No permission to operate',
'Captcha error' => 'Captcha error!', 'Captcha error' => 'Captcha error!',
'ok' => 'ok',
'Missing parameters' => 'Missing parameters',
'Invalid parameter format' => 'Invalid parameter format',
'Invalid parameter value' => 'Invalid parameter value',
'Missing auth-token' => 'Missing auth-token',
'auth-token is invalid or expired' => 'auth-token is invalid or expired',
'Invalid secret' => 'Invalid secret',
'Invalid signature' => 'Invalid signature',
'Invalid timestamp' => 'Invalid timestamp',
'Invite code does not exist' => 'Invite code does not exist',
'Registered successfully but login failed' => 'Registered successfully but login failed',
'Incorrect account or password' => 'Incorrect account or password',
'Login status has expired' => 'Login status has expired',
'Game period does not exist' => 'Game period does not exist',
'Betting is closed' => 'Betting is closed',
'Insufficient balance' => 'Insufficient balance',
'Duplicate request' => 'Duplicate request',
'System is busy, please try again later' => 'System is busy, please try again later',
'Current process does not allow this operation' => 'Current process does not allow this operation',
'Order does not exist' => 'Order does not exist',
'Notice does not exist' => 'Notice does not exist',
// Member center account // Member center account
'Data updated successfully~' => 'Data updated successfully~', 'Data updated successfully~' => 'Data updated successfully~',
'Password has been changed~' => 'Password has been changed~', 'Password has been changed~' => 'Password has been changed~',

View File

@@ -44,6 +44,27 @@ return [
'Parameter error' => '参数错误!', 'Parameter error' => '参数错误!',
'Token expiration' => '登录态过期,请重新登录!', 'Token expiration' => '登录态过期,请重新登录!',
'Captcha error' => '验证码错误!', 'Captcha error' => '验证码错误!',
'ok' => '成功',
'Missing parameters' => '参数缺失',
'Invalid parameter format' => '参数格式错误',
'Invalid parameter value' => '参数取值非法',
'Missing auth-token' => '缺少 auth-token',
'auth-token is invalid or expired' => 'auth-token 无效或已过期',
'Invalid secret' => '密钥无效',
'Invalid signature' => '签名错误',
'Invalid timestamp' => '时间戳无效',
'Invite code does not exist' => '邀请码不存在',
'Registered successfully but login failed' => '注册成功但登录失败',
'Incorrect account or password' => '账号或密码错误',
'Login status has expired' => '登录状态已过期',
'Game period does not exist' => '对局不存在',
'Betting is closed' => '已封盘,禁止下注',
'Insufficient balance' => '余额不足',
'Duplicate request' => '重复请求(幂等冲突)',
'System is busy, please try again later' => '系统繁忙,请稍后重试',
'Current process does not allow this operation' => '当前流程不允许该操作',
'Order does not exist' => '订单不存在',
'Notice does not exist' => '公告不存在',
// 会员中心 account // 会员中心 account
'Data updated successfully~' => '资料更新成功~', 'Data updated successfully~' => '资料更新成功~',
'Password has been changed~' => '密码已修改~', 'Password has been changed~' => '密码已修改~',

View File

@@ -142,14 +142,26 @@ class Backend extends Api
if ($needLogin) { if ($needLogin) {
if (!$this->auth->isLogin()) { if (!$this->auth->isLogin()) {
if ($request->method() === 'GET' && !$this->expectsApiJsonResponse($request)) {
$location = $this->adminSpaLoginUrl($request);
return redirect($location);
}
// 必须使用 HTTP 200 返回 JSON若用 HTTP 303axios 会跟随重定向,拿不到 JSON前端无法跳转登录
return $this->error(__('Please login first'), [ return $this->error(__('Please login first'), [
'type' => Auth::NEED_LOGIN, 'type' => Auth::NEED_LOGIN,
], 0, ['statusCode' => Auth::LOGIN_RESPONSE_CODE]); ], 0);
} }
if ($needPermission) { if ($needPermission) {
$controllerPath = $this->getControllerPath($request); $controllerPath = $this->getControllerPath($request);
$routePath = $controllerPath . '/' . $action; $routePaths = $this->buildPermissionRoutePaths($controllerPath, $action);
if (!$this->auth->check($routePath)) { $pass = false;
foreach ($routePaths as $routePath) {
if ($this->auth->check($routePath)) {
$pass = true;
break;
}
}
if (!$pass) {
return $this->error(__('You have no permission'), [], 401); return $this->error(__('You have no permission'), [], 401);
} }
} }
@@ -167,6 +179,37 @@ class Backend extends Api
return null; return null;
} }
/**
* 是否应按 API 返回 JSON前端 axios 会带 server: true纯浏览器地址栏访问多为 HTML Accept
*/
protected function expectsApiJsonResponse(WebmanRequest $request): bool
{
$server = $request->header('server', '');
if ($server === 'true' || $server === '1') {
return true;
}
if (strtolower($request->header('x-requested-with', '')) === 'xmlhttprequest') {
return true;
}
$accept = strtolower($request->header('accept', ''));
if (str_contains($accept, 'application/json')) {
return true;
}
// 浏览器地址栏/点击链接触发的主文档请求,优先 302 到前端登录(避免误判为 API
if (strtolower((string) $request->header('sec-fetch-mode', '')) === 'navigate') {
return false;
}
return false;
}
/**
* 后台 Vue 为 hash 路由时的登录页(相对路径,与 web/src/router 一致)
*/
protected function adminSpaLoginUrl(WebmanRequest $request): string
{
return '/#/admin/login';
}
/** /**
* 子类可覆盖,用于初始化 model 等(替代原 initialize * 子类可覆盖,用于初始化 model 等(替代原 initialize
* @return Response|null 需直接返回时返回 Response否则 null * @return Response|null 需直接返回时返回 Response否则 null
@@ -214,9 +257,8 @@ class Backend extends Api
public function select(WebmanRequest $request): Response public function select(WebmanRequest $request): Response
{ {
$response = $this->initializeBackend($request); $response = $this->initializeBackend($request);
if ($response !== null) return $response; if ($response instanceof Response) return $response;
$this->_select(); return $this->_select();
return $this->success();
} }
/** /**
@@ -432,4 +474,26 @@ class Backend extends Api
{ {
return get_controller_path($request); return get_controller_path($request);
} }
/**
* 构造权限节点候选:兼容 snake_case 与 camelCase 节点名
*
* @return string[]
*/
protected function buildPermissionRoutePaths(string $controllerPath, string $action): array
{
$paths = [];
$paths[] = $controllerPath . '/' . $action;
$parts = explode('/', $controllerPath);
foreach ($parts as &$part) {
if (str_contains($part, '_')) {
$part = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $part))));
}
}
$camelControllerPath = implode('/', $parts);
$paths[] = $camelControllerPath . '/' . $action;
return array_values(array_unique($paths));
}
} }

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace app\common\library\game;
use InvalidArgumentException;
/**
* 36 字花字典game_config.zi_hua_36_dictionary仅存 JSON 数组
* category 使用英文zodiac|beast|fowl|vermin|divine
*/
final class ZiHuaDictionary
{
public const CONFIG_KEY = 'zi_hua_36_dictionary';
/** 与表单下拉一致,顺序即展示顺序 */
public const CATEGORIES = ['zodiac', 'beast', 'fowl', 'vermin', 'divine'];
private const LEGACY_CATEGORY_MAP = [
'生肖' => 'zodiac',
'猛兽' => 'beast',
'飞禽' => 'fowl',
'虫蛇' => 'vermin',
'神兽' => 'divine',
'zodiac' => 'zodiac',
'beast' => 'beast',
'fowl' => 'fowl',
'vermin' => 'vermin',
'divine' => 'divine',
];
/**
* 默认 36 条(与 PRD 一致category 为英文)
*
* @return list<array{no: int, name: string, category: string}>
*/
public static function defaultItems(): array
{
return [
['no' => 1, 'name' => '鼠', 'category' => 'zodiac'],
['no' => 2, 'name' => '牛', 'category' => 'zodiac'],
['no' => 3, 'name' => '虎', 'category' => 'zodiac'],
['no' => 4, 'name' => '兔', 'category' => 'zodiac'],
['no' => 5, 'name' => '龙', 'category' => 'zodiac'],
['no' => 6, 'name' => '蛇', 'category' => 'zodiac'],
['no' => 7, 'name' => '马', 'category' => 'zodiac'],
['no' => 8, 'name' => '羊', 'category' => 'zodiac'],
['no' => 9, 'name' => '猴', 'category' => 'zodiac'],
['no' => 10, 'name' => '鸡', 'category' => 'zodiac'],
['no' => 11, 'name' => '狗', 'category' => 'zodiac'],
['no' => 12, 'name' => '猪', 'category' => 'zodiac'],
['no' => 13, 'name' => '狮', 'category' => 'beast'],
['no' => 14, 'name' => '象', 'category' => 'beast'],
['no' => 15, 'name' => '鹿', 'category' => 'beast'],
['no' => 16, 'name' => '熊', 'category' => 'beast'],
['no' => 17, 'name' => '狼', 'category' => 'beast'],
['no' => 18, 'name' => '豹', 'category' => 'beast'],
['no' => 19, 'name' => '鹰', 'category' => 'fowl'],
['no' => 20, 'name' => '鹤', 'category' => 'fowl'],
['no' => 21, 'name' => '孔雀', 'category' => 'fowl'],
['no' => 22, 'name' => '鸳鸯', 'category' => 'fowl'],
['no' => 23, 'name' => '鸵鸟', 'category' => 'fowl'],
['no' => 24, 'name' => '天鹅', 'category' => 'fowl'],
['no' => 25, 'name' => '蟾蜍', 'category' => 'vermin'],
['no' => 26, 'name' => '蜘蛛', 'category' => 'vermin'],
['no' => 27, 'name' => '蝙蝠', 'category' => 'vermin'],
['no' => 28, 'name' => '蜈蚣', 'category' => 'vermin'],
['no' => 29, 'name' => '蝎', 'category' => 'vermin'],
['no' => 30, 'name' => '蛇蜥', 'category' => 'vermin'],
['no' => 31, 'name' => '麒麟', 'category' => 'divine'],
['no' => 32, 'name' => '凤凰', 'category' => 'divine'],
['no' => 33, 'name' => '青龙', 'category' => 'divine'],
['no' => 34, 'name' => '白虎', 'category' => 'divine'],
['no' => 35, 'name' => '朱雀', 'category' => 'divine'],
['no' => 36, 'name' => '玄武', 'category' => 'divine'],
];
}
/**
* @return list<array{no: int, name: string, category: string}>
*/
public static function parseFromConfigValue(?string $configValue): array
{
if ($configValue === null || trim($configValue) === '') {
return self::defaultItems();
}
$decoded = json_decode($configValue, true);
if (!is_array($decoded)) {
return self::defaultItems();
}
if (isset($decoded['items']) && is_array($decoded['items'])) {
return self::normalizeItemsList($decoded['items']);
}
if ($decoded !== [] && array_is_list($decoded)) {
return self::normalizeItemsList($decoded);
}
return self::defaultItems();
}
/**
* @param list<mixed> $items
* @return list<array{no: int, name: string, category: string}>
*/
public static function normalizeItemsList(array $items): array
{
$defaults = self::defaultItems();
$defaultByNo = [];
foreach ($defaults as $row) {
$defaultByNo[$row['no']] = $row;
}
$out = [];
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$no = $row['no'] ?? null;
if (!is_numeric($no)) {
continue;
}
$n = intval($no, 10);
if ($n < 1 || $n > 36) {
continue;
}
$name = isset($row['name']) && is_string($row['name']) ? trim($row['name']) : '';
if ($name === '') {
$name = $defaultByNo[$n]['name'] ?? '';
}
$catRaw = $row['category'] ?? 'zodiac';
$catStr = is_string($catRaw) ? trim($catRaw) : '';
$category = self::LEGACY_CATEGORY_MAP[$catStr] ?? (in_array($catStr, self::CATEGORIES, true) ? $catStr : 'zodiac');
$out[] = ['no' => $n, 'name' => $name, 'category' => $category];
}
if (count($out) !== 36) {
return self::defaultItems();
}
usort($out, static fn (array $a, array $b): int => $a['no'] <=> $b['no']);
$seen = [];
foreach ($out as $row) {
if (isset($seen[$row['no']])) {
return self::defaultItems();
}
$seen[$row['no']] = true;
}
for ($i = 1; $i <= 36; $i++) {
if (!isset($seen[$i])) {
return self::defaultItems();
}
}
return $out;
}
/**
* 校验 POST 数据并输出入库用的 36 条no 升序)
*
* @param list<array{no?: mixed, name?: mixed, category?: mixed}> $items
*
* @return list<array{no: int, name: string, category: string}>
*
* @throws InvalidArgumentException
*/
public static function prepareItemsForSave(array $items): array
{
if (count($items) !== 36) {
throw new InvalidArgumentException('必须恰好 36 条');
}
$nos = [];
$out = [];
foreach ($items as $idx => $row) {
if (!is_array($row)) {
throw new InvalidArgumentException('第 ' . ($idx + 1) . ' 行格式错误');
}
$no = $row['no'] ?? null;
if (!is_numeric($no)) {
throw new InvalidArgumentException('编号必须为数字');
}
$n = intval($no, 10);
if ($n < 1 || $n > 36) {
throw new InvalidArgumentException('编号必须在 136');
}
if (isset($nos[$n])) {
throw new InvalidArgumentException('编号重复:' . $n);
}
$nos[$n] = true;
$name = $row['name'] ?? '';
if (!is_string($name) || trim($name) === '') {
throw new InvalidArgumentException('编号 ' . $n . ' 名称不能为空');
}
$cat = $row['category'] ?? '';
$catTrim = is_string($cat) ? trim($cat) : '';
if (!in_array($catTrim, self::CATEGORIES, true)) {
throw new InvalidArgumentException('编号 ' . $n . ' 类型无效');
}
$out[] = ['no' => $n, 'name' => trim($name), 'category' => $catTrim];
}
for ($i = 1; $i <= 36; $i++) {
if (!isset($nos[$i])) {
throw new InvalidArgumentException('缺少编号:' . $i);
}
}
usort($out, static fn (array $a, array $b): int => $a['no'] <=> $b['no']);
return $out;
}
/**
* @param list<array{no: int, name: string, category: string}> $items
*/
public static function encodeForDb(array $items): string
{
usort($items, static fn (array $a, array $b): int => $a['no'] <=> $b['no']);
$encoded = json_encode($items, JSON_UNESCAPED_UNICODE);
if ($encoded === false) {
throw new InvalidArgumentException('JSON 编码失败');
}
return $encoded;
}
}

View File

@@ -11,12 +11,15 @@ use Exception;
*/ */
class TokenExpirationException extends Exception class TokenExpirationException extends Exception
{ {
protected array $data = [];
public function __construct( public function __construct(
protected string $message = '', string $message = '',
protected int $code = 409, int $code = 409,
protected array $data = [], array $data = [],
?\Throwable $previous = null ?\Throwable $previous = null
) { ) {
$this->data = $data;
parent::__construct($message, $code, $previous); parent::__construct($message, $code, $previous);
} }

View File

@@ -30,7 +30,7 @@ class AllowCrossDomain implements MiddlewareInterface
'Access-Control-Allow-Credentials' => 'true', 'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => '1800', 'Access-Control-Max-Age' => '1800',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang', 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang, server',
]; ];
$origin = $request->header('origin'); $origin = $request->header('origin');
if (is_array($origin)) { if (is_array($origin)) {

View File

@@ -25,12 +25,20 @@ class LoadLangPack implements MiddlewareInterface
protected function loadLang(Request $request): void protected function loadLang(Request $request): void
{ {
// 优先从请求头 think-lang 获取前端选择的语言(与前端 axios 发送的 header 对应) // 优先从请求头 lang / think-lang 获取前端选择的语言
// 安装页等未发送 think-lang 时,回退到 Accept-Language 或配置默认值 // 支持lang=en / lang=zh / think-lang=en / think-lang=zh-cn
$headerLang = $request->header('think-lang'); // 未发送时回退到 Accept-Language 或配置默认值
$headerLang = $request->header('lang', '');
if ($headerLang === '') {
$headerLang = $request->header('think-lang', '');
}
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']); $allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
if ($headerLang && in_array(str_replace('_', '-', strtolower($headerLang)), $allowLangList)) { $normalizedHeaderLang = str_replace('_', '-', strtolower($headerLang));
$langSet = str_replace('_', '-', strtolower($headerLang)); if ($normalizedHeaderLang === 'zh') {
$normalizedHeaderLang = 'zh-cn';
}
if ($headerLang && in_array($normalizedHeaderLang, $allowLangList)) {
$langSet = $normalizedHeaderLang;
} else { } else {
$acceptLang = $request->header('accept-language', ''); $acceptLang = $request->header('accept-language', '');
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) { if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {

View File

@@ -0,0 +1,38 @@
<?php
namespace app\common\model;
use support\think\Model;
class AgentCommissionRecord extends Model
{
protected $name = 'agent_commission_record';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'settled_at' => 'integer',
'commission_rate' => 'string',
'calc_base_amount' => 'string',
'commission_amount' => 'string',
'status' => 'integer',
];
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
public function settlementPeriod(): \think\model\relation\BelongsTo
{
return $this->belongsTo(AgentSettlementPeriod::class, 'settlement_period_id', 'id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace app\common\model;
use support\think\Model;
class AgentSettlementPeriod extends Model
{
protected $name = 'agent_settlement_period';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'period_start_at' => 'integer',
'period_end_at' => 'integer',
'total_bet_amount' => 'string',
'total_payout_amount' => 'string',
'platform_profit_amount' => 'string',
'status' => 'integer',
];
}

View File

@@ -0,0 +1,53 @@
<?php
namespace app\common\model;
use app\common\service\GameLiveService;
use support\think\Model;
use Throwable;
class BetOrder extends Model
{
protected $name = 'bet_order';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'pick_numbers' => 'json',
'unit_amount' => 'string',
'total_amount' => 'string',
'win_amount' => 'string',
'jackpot_extra_amount' => 'string',
'status' => 'integer',
'pick_count' => 'integer',
'streak_at_bet' => 'integer',
'is_auto' => 'integer',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
protected static function onAfterInsert($model): void
{
try {
$periodId = isset($model['period_id']) ? (int) $model['period_id'] : null;
GameLiveService::publishSnapshot($periodId);
} catch (Throwable) {
}
}
public function gameRecord(): \think\model\relation\BelongsTo
{
return $this->belongsTo(GameRecord::class, 'period_id', 'id');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* Channel
*/
class Channel extends Model
{
// 表名
protected $name = 'channel';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'affiliate_effective_start_at' => 'integer',
'affiliate_effective_end_at' => 'integer',
'settle_weekday' => 'integer',
'settle_monthday' => 'integer',
];
public function getprofitAmountAttr($value): ?float
{
return is_null($value) ? null : (float)$value;
}
public function adminGroup(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\AdminGroup::class, 'admin_group_id', 'id');
}
public function admin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace app\common\model;
use support\think\Model;
class DepositOrder extends Model
{
protected $name = 'deposit_order';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'pay_time' => 'integer',
'amount' => 'string',
'status' => 'integer',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* 游戏/平台动态参数KV
*/
class GameConfig extends Model
{
protected $name = 'game_config';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
}

View File

@@ -0,0 +1,43 @@
<?php
namespace app\common\model;
use support\think\Model;
class GamePeriod extends Model
{
protected $name = 'game_record';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'period_start_at' => 'integer',
'status' => 'integer',
'draw_mode' => 'integer',
'preset_number' => 'integer',
'result_number' => 'integer',
];
/**
* 后台表单 datetime 可能提交字符串,入库前转为 Unix 时间戳
* ThinkPHP 修改器签名为 (mixed $value, array $data)
*/
public function setPeriodStartAtAttr($value, $data = [])
{
if ($value === null || $value === '') {
return 0;
}
if (is_int($value)) {
return $value;
}
if (is_string($value)) {
$t = strtotime($value);
if ($t !== false) {
return $t;
}
}
return 0;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace app\common\model;
use support\think\Model;
class GameRecord extends Model
{
protected $name = 'game_record';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'period_start_at' => 'integer',
'status' => 'integer',
'draw_mode' => 'integer',
'preset_number' => 'integer',
'result_number' => 'integer',
];
public function setPeriodStartAtAttr($value, $data = [])
{
if ($value === null || $value === '') {
return 0;
}
if (is_int($value)) {
return $value;
}
if (is_string($value)) {
$t = strtotime($value);
if ($t !== false) {
return $t;
}
}
return 0;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace app\common\model;
use support\think\Model;
class OperationNotice extends Model
{
protected $name = 'operation_notice';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'publish_at' => 'integer',
'notice_type' => 'integer',
'status' => 'integer',
];
public function setPublishAtAttr($value)
{
if ($value === null || $value === '') {
return 0;
}
if (is_int($value)) {
return $value;
}
if (is_string($value)) {
if (ctype_digit($value)) {
return (int) $value;
}
$ts = strtotime($value);
if ($ts !== false) {
return $ts;
}
}
return 0;
}
}

View File

@@ -2,42 +2,34 @@
namespace app\common\model; namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use support\think\Model; use support\think\Model;
/** /**
* 会员公共模型 * 用户主表
*/ */
class User extends Model class User extends Model
{ {
use TimestampInteger; protected $name = 'user';
protected string $table = 'user'; protected $autoWriteTimestamp = true;
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
public function getAvatarAttr($value): string protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'coin' => 'string',
'total_deposit_coin' => 'string',
'total_valid_bet_coin' => 'string',
'risk_flags' => 'integer',
'current_streak' => 'integer',
];
public function channel(): \think\model\relation\BelongsTo
{ {
return full_url($value, false, config('buildadmin.default_avatar')); return $this->belongsTo(Channel::class, 'channel_id', 'id');
} }
public function setAvatarAttr($value): string public function admin(): \think\model\relation\BelongsTo
{ {
return $value == full_url('', false, config('buildadmin.default_avatar')) ? '' : $value; return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
public function resetPassword($uid, $newPassword)
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
}
public function getMoneyAttr($value): string
{
return bcdiv((string)$value, '100', 2);
}
public function setMoneyAttr($value): string
{
return bcmul((string)$value, '100', 2);
} }
} }

View File

@@ -0,0 +1,28 @@
<?php
namespace app\common\model;
use support\think\Model;
class UserNoticeRead extends Model
{
protected $name = 'user_notice_read';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'read_at' => 'integer',
'confirmed' => 'integer',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function operationNotice(): \think\model\relation\BelongsTo
{
return $this->belongsTo(OperationNotice::class, 'notice_id', 'id');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* 用户钱包流水(原 game_user_wallet_record
*/
class UserWalletRecord extends Model
{
protected $name = 'user_wallet_record';
protected $autoWriteTimestamp = false;
protected $createTime = 'create_time';
protected $updateTime = false;
protected $type = [
'create_time' => 'integer',
'amount' => 'string',
'balance_before' => 'string',
'balance_after' => 'string',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function operatorAdmin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'operator_admin_id', 'id');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace app\common\model;
use support\think\Model;
class WithdrawOrder extends Model
{
protected $name = 'withdraw_order';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'review_time' => 'integer',
'amount' => 'string',
'fee' => 'string',
'actual_amount' => 'string',
'status' => 'integer',
];
public function user(): \think\model\relation\BelongsTo
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
public function channel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function reviewAdmin(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\admin\model\Admin::class, 'review_admin_id', 'id');
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
use Webman\Push\Api;
final class GameLiveService
{
private const BASE_ODDS = 33;
private const CHANNEL = 'game-live';
private const EVENT = 'bet-updated';
private const KEY_PERIOD_SECONDS = 'period_seconds';
private const KEY_BET_SECONDS = 'bet_seconds';
private const KEY_PICK_MAX_NUMBER_COUNT = 'pick_max_number_count';
public static function buildSnapshot(?int $recordId = null): array
{
$record = self::resolveRecord($recordId);
if (!$record) {
return [
'record' => null,
'bets' => [],
'candidate_numbers' => [],
'ai_default_number' => null,
'calc_number' => null,
'period_seconds' => self::getConfigInt(self::KEY_PERIOD_SECONDS, 30),
'bet_seconds' => self::getConfigInt(self::KEY_BET_SECONDS, 20),
'pick_max_number_count' => self::getPickMaxNumberCount(),
'remaining_seconds' => 0,
'bet_remaining_seconds' => 0,
'can_calculate' => false,
'can_draw' => false,
'server_time' => time(),
];
}
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
$pickMax = self::getPickMaxNumberCount();
$elapsed = max(0, time() - (int) $record['period_start_at']);
$remaining = max(0, $periodSeconds - $elapsed);
$betRemaining = max(0, $betSeconds - $elapsed);
$bets = Db::name('bet_order')
->where('period_id', (int) $record['id'])
->order('id', 'desc')
->limit(200)
->select()
->toArray();
$candidates = [];
$bestNumber = null;
$bestLoss = null;
$status = (int) $record['status'];
$canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
if ($canCalculate) {
for ($n = 1; $n <= $pickMax; $n++) {
$loss = self::estimateLossForNumber($bets, $n);
$candidates[] = [
'number' => $n,
'estimated_loss' => $loss,
];
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
$bestLoss = $loss;
$bestNumber = $n;
}
}
}
return [
'record' => $record,
'bets' => array_map(static function (array $row): array {
return [
'id' => (int) $row['id'],
'user_id' => (int) $row['user_id'],
'period_no' => (string) $row['period_no'],
'pick_numbers' => $row['pick_numbers'],
'unit_amount' => (string) $row['unit_amount'],
'total_amount' => (string) $row['total_amount'],
'streak_at_bet' => (int) $row['streak_at_bet'],
'create_time' => (int) $row['create_time'],
];
}, $bets),
'candidate_numbers' => $candidates,
'ai_default_number' => $bestNumber,
'calc_number' => $bestNumber,
'period_seconds' => $periodSeconds,
'bet_seconds' => $betSeconds,
'pick_max_number_count' => $pickMax,
'remaining_seconds' => $remaining,
'bet_remaining_seconds' => $betRemaining,
'can_calculate' => $canCalculate,
'can_draw' => $canCalculate,
'server_time' => time(),
];
}
public static function calculateResult(?int $recordId, ?int $manualNumber = null): array
{
$record = self::resolveRecord($recordId);
if (!$record) {
return ['ok' => false, 'msg' => '未找到进行中的对局'];
}
if (!in_array((int) $record['status'], [0, 1], true)) {
return ['ok' => false, 'msg' => '当前对局状态不可计算'];
}
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
$elapsed = max(0, time() - (int) $record['period_start_at']);
if ($elapsed < $betSeconds) {
return ['ok' => false, 'msg' => '下注开放时长未结束,暂不可计算'];
}
if ((int) $record['status'] === 0) {
Db::name('game_record')->where('id', (int) $record['id'])->update([
'status' => 1,
'update_time' => time(),
]);
$record['status'] = 1;
}
$pickMax = self::getPickMaxNumberCount();
if ($manualNumber !== null && ($manualNumber < 1 || $manualNumber > $pickMax)) {
return ['ok' => false, 'msg' => '手动开奖号码超出允许范围'];
}
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
$candidates = [];
$bestNumber = null;
$bestLoss = null;
for ($n = 1; $n <= $pickMax; $n++) {
$loss = self::estimateLossForNumber($bets, $n);
$candidates[] = ['number' => $n, 'estimated_loss' => $loss];
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
$bestLoss = $loss;
$bestNumber = $n;
}
}
$finalNumber = $manualNumber ?? $bestNumber;
$finalLoss = '0.0000';
if ($finalNumber !== null) {
$finalLoss = self::estimateLossForNumber($bets, $finalNumber);
}
return [
'ok' => true,
'msg' => '计算完成',
'record' => $record,
'period_seconds' => $periodSeconds,
'bet_seconds' => $betSeconds,
'pick_max_number_count' => $pickMax,
'candidate_numbers' => $candidates,
'ai_default_number' => $bestNumber,
'final_number' => $finalNumber,
'final_estimated_loss' => $finalLoss,
];
}
public static function drawResult(?int $recordId, ?int $manualNumber = null): array
{
$calc = self::calculateResult($recordId, $manualNumber);
if (!($calc['ok'] ?? false)) {
return $calc;
}
$record = $calc['record'];
$finalNumber = (int) $calc['final_number'];
$now = time();
Db::startTrans();
try {
Db::name('game_record')->where('id', (int) $record['id'])->update([
'status' => 4,
'result_number' => $finalNumber,
'draw_mode' => $manualNumber === null ? 0 : 1,
'update_time' => $now,
]);
GameRecordService::createNextRecordAfterDraw();
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return ['ok' => false, 'msg' => $e->getMessage()];
}
self::publishSnapshot(null);
return [
'ok' => true,
'msg' => '开奖完成',
'result_number' => $finalNumber,
'estimated_loss' => $calc['final_estimated_loss'],
];
}
public static function tickAutoDraw(): void
{
$record = self::resolveRecord(null);
if (!$record || !in_array((int) $record['status'], [0, 1], true)) {
return;
}
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
$elapsed = max(0, time() - (int) $record['period_start_at']);
if ($elapsed >= $betSeconds && (int) $record['status'] === 0) {
Db::name('game_record')->where('id', (int) $record['id'])->update([
'status' => 1,
'update_time' => time(),
]);
$record['status'] = 1;
}
if ($elapsed < $periodSeconds) {
return;
}
self::drawResult((int) $record['id'], null);
}
public static function publishSnapshot(?int $recordId = null): void
{
try {
$payload = self::buildSnapshot($recordId);
$api = new Api(
str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')),
(string) config('plugin.webman.push.app.app_key'),
(string) config('plugin.webman.push.app.app_secret')
);
$api->trigger(self::CHANNEL, self::EVENT, $payload);
} catch (Throwable) {
}
}
private static function resolveRecord(?int $recordId): ?array
{
if ($recordId !== null && $recordId > 0) {
$row = Db::name('game_record')->where('id', $recordId)->find();
if ($row) {
return $row;
}
}
return Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
}
private static function getConfigInt(string $key, int $default): int
{
$row = Db::name('game_config')->where('config_key', $key)->find();
if (!$row) {
return $default;
}
$v = $row['config_value'] ?? null;
if ($v === null || $v === '') {
return $default;
}
if (!is_numeric((string) $v)) {
return $default;
}
return (int) $v;
}
private static function getPickMaxNumberCount(): int
{
$max = self::getConfigInt(self::KEY_PICK_MAX_NUMBER_COUNT, 36);
if ($max < 1) {
return 1;
}
if ($max > 36) {
return 36;
}
return $max;
}
private static function estimateLossForNumber(array $bets, int $number): string
{
$payout = '0.0000';
foreach ($bets as $bet) {
$pickNumbers = $bet['pick_numbers'];
if (is_string($pickNumbers)) {
$decoded = json_decode($pickNumbers, true);
$pickNumbers = is_array($decoded) ? $decoded : [];
}
if (!is_array($pickNumbers)) {
$pickNumbers = [];
}
if (!in_array($number, array_map('intval', $pickNumbers), true)) {
continue;
}
$unit = (string) ($bet['unit_amount'] ?? '0');
$streak = (int) ($bet['streak_at_bet'] ?? 0);
$odds = (string) (($streak + 1) * self::BASE_ODDS);
$orderPayout = bcmul($unit, $odds, 4);
$payout = bcadd($payout, $orderPayout, 4);
}
return $payout;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 兼容层:保留旧类名,内部转发到 GameRecordService
*/
final class GamePeriodService
{
public const KEY_AUTO_CREATE = GameRecordService::KEY_AUTO_CREATE;
public const KEY_MANUAL_CREATE = GameRecordService::KEY_MANUAL_CREATE;
public static function getConfigBool(string $key): bool
{
return GameRecordService::getConfigBool($key);
}
public static function getPeriodSettings(): array
{
return GameRecordService::getRecordSettings();
}
public static function savePeriodSettings(array $data): void
{
GameRecordService::saveRecordSettings($data);
}
public static function hasActivePeriod(): bool
{
return GameRecordService::hasActiveRecord();
}
public static function tickAutoCreate(): void
{
GameRecordService::tickAutoCreate();
}
public static function createNextPeriodForManual(): array
{
return GameRecordService::createNextRecordForManual();
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
final class GameRecordService
{
public const KEY_AUTO_CREATE = 'period_auto_create_enabled';
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
private const ACTIVE_STATUSES = [0, 1, 2, 3];
public static function getConfigBool(string $key): bool
{
$row = Db::name('game_config')->where('config_key', $key)->find();
if (!$row) {
return false;
}
$v = $row['config_value'] ?? '';
return $v === '1' || $v === 1;
}
public static function getRecordSettings(): array
{
return [
'period_auto_create_enabled' => self::getConfigBool(self::KEY_AUTO_CREATE) ? 1 : 0,
'period_manual_create_enabled' => self::getConfigBool(self::KEY_MANUAL_CREATE) ? 1 : 0,
];
}
public static function saveRecordSettings(array $data): void
{
$now = time();
$auto = self::truthyConfigInput($data['period_auto_create_enabled'] ?? null) ? '1' : '0';
$manual = self::truthyConfigInput($data['period_manual_create_enabled'] ?? null) ? '1' : '0';
self::upsertConfig(self::KEY_AUTO_CREATE, $auto, 'int', '是否允许定时任务自动创建下一局(全局仅一局)', $now);
self::upsertConfig(self::KEY_MANUAL_CREATE, $manual, 'int', '是否允许后台手动创建下一局', $now);
}
public static function hasActiveRecord(): bool
{
$count = Db::name('game_record')->whereIn('status', self::ACTIVE_STATUSES)->count();
return $count > 0;
}
public static function tickAutoCreate(): void
{
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
if (self::hasActiveRecord()) {
return;
}
try {
self::createNextRecordRow();
} catch (Throwable) {
}
}
public static function createNextRecordForManual(): array
{
if (!self::getConfigBool(self::KEY_MANUAL_CREATE)) {
return ['ok' => false, 'msg' => '未开启「手动创建下一局」开关'];
}
if (self::hasActiveRecord()) {
return ['ok' => false, 'msg' => '存在未结束对局,无法新建'];
}
try {
$periodNo = self::createNextRecordRow();
return ['ok' => true, 'msg' => '已创建新对局', 'period_no' => $periodNo];
} catch (Throwable $e) {
return ['ok' => false, 'msg' => $e->getMessage()];
}
}
public static function createNextRecordAfterDraw(): ?string
{
if (self::hasActiveRecord()) {
return null;
}
return self::createNextRecordRow();
}
private static function createNextRecordRow(): string
{
$periodNo = self::generatePeriodNo();
$now = time();
Db::name('game_record')->insert([
'period_no' => $periodNo,
'period_start_at' => $now,
'status' => 0,
'draw_mode' => 0,
'void_reason' => '',
'create_time' => $now,
'update_time' => $now,
]);
return $periodNo;
}
private static function generatePeriodNo(): string
{
return date('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
}
private static function truthyConfigInput(mixed $v): bool
{
return $v === 1 || $v === '1' || $v === true;
}
private static function upsertConfig(string $key, string $value, string $valueType, string $remark, int $now): void
{
$exists = Db::name('game_config')->where('config_key', $key)->find();
if ($exists) {
Db::name('game_config')->where('config_key', $key)->update([
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'update_time' => $now,
]);
return;
}
Db::name('game_config')->insert([
'config_key' => $key,
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'create_time' => $now,
'update_time' => $now,
]);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class AgentCommissionRecord extends Validate
{
protected $failException = true;
protected $rule = [
'settlement_period_id' => 'require|integer|gt:0',
'channel_id' => 'require|integer|gt:0',
'admin_id' => 'require|integer|gt:0',
'commission_rate' => 'require|float|egt:0',
'calc_base_amount' => 'require|float',
'commission_amount' => 'require|float',
'status' => 'in:0,1,2',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['settlement_period_id', 'channel_id', 'admin_id', 'commission_rate', 'calc_base_amount', 'commission_amount', 'status', 'remark'],
'edit' => ['commission_rate', 'calc_base_amount', 'commission_amount', 'status', 'remark'],
];
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class AgentSettlementPeriod extends Validate
{
protected $failException = true;
protected $rule = [
'settlement_no' => 'require|max:64|unique:agent_settlement_period',
'period_start_at' => 'require|integer|egt:0',
'period_end_at' => 'require|integer|egt:0',
'status' => 'in:0,1,2',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['settlement_no', 'period_start_at', 'period_end_at', 'status', 'remark'],
'edit' => ['period_start_at', 'period_end_at', 'status', 'remark'],
];
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class Channel extends Validate
{
protected $failException = true;
protected $rule = [
'code' => 'require|max:255|unique:channel',
'name' => 'require|max:255',
'agent_mode' => 'require|in:turnover,affiliate',
'status' => 'in:0,1',
'admin_id' => 'require|integer|gt:0',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['code', 'name', 'agent_mode', 'status', 'admin_id', 'remark'],
'edit' => ['name', 'agent_mode', 'status', 'admin_id', 'remark'],
];
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class DepositOrder extends Validate
{
protected $failException = true;
protected $rule = [
'order_no' => 'require|max:64|unique:deposit_order',
'user_id' => 'require|integer|gt:0',
'status' => 'require|in:0,1,2,3',
];
protected $scene = [
'add' => ['order_no', 'user_id', 'status'],
'edit' => ['status'],
];
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class GameConfig extends Validate
{
protected $failException = true;
protected $rule = [
'config_key' => 'require|max:64|unique:game_config',
'value_type' => 'require|in:string,int,decimal,json',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['config_key', 'value_type', 'remark'],
'edit' => ['value_type', 'remark'],
];
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class GamePeriod extends Validate
{
protected $failException = true;
protected $rule = [
'period_no' => 'require|max:64|unique:game_record',
'period_start_at' => 'integer',
'status' => 'require|in:0,1,2,3,4,5',
'draw_mode' => 'in:0,1',
'preset_number' => 'between:1,36',
'result_number' => 'between:1,36',
];
protected $scene = [
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
];
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class GameRecord extends Validate
{
protected $failException = true;
protected $rule = [
'period_no' => 'require|max:64|unique:game_record',
'period_start_at' => 'integer',
'status' => 'require|in:0,1,2,3,4,5',
'draw_mode' => 'in:0,1',
'preset_number' => 'between:1,36',
'result_number' => 'between:1,36',
];
protected $scene = [
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
];
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class OperationNotice extends Validate
{
protected $failException = true;
protected $rule = [
'title' => 'require|max:255',
'notice_type' => 'require|in:0,1',
'status' => 'require|in:0,1',
'publish_at' => 'require|checkPublishAt',
];
protected $scene = [
'add' => ['title', 'notice_type', 'status', 'publish_at'],
'edit' => ['title', 'notice_type', 'status', 'publish_at'],
];
protected function checkPublishAt($value): bool
{
if (is_int($value)) {
return $value >= 0;
}
if (is_string($value)) {
if ($value === '') {
return false;
}
if (ctype_digit($value)) {
return true;
}
return strtotime($value) !== false;
}
return false;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class User extends Validate
{
protected $failException = true;
protected $rule = [
'username' => 'require|max:255',
'password' => 'require|min:6|max:255',
'phone' => 'max:32',
'email' => 'email',
'channel_id' => 'require|integer|gt:0',
'status' => 'in:0,1',
];
protected $scene = [
'add' => ['username', 'password', 'phone', 'email', 'channel_id', 'status'],
'edit' => ['username', 'password', 'phone', 'email', 'channel_id', 'status'],
];
public function sceneEdit(): static
{
return $this->only(['username', 'password', 'phone', 'email', 'channel_id', 'status'])
->remove('password', 'require');
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class UserNoticeRead extends Validate
{
protected $failException = true;
protected $rule = [
'user_id' => 'require|integer|gt:0',
'notice_id' => 'require|integer|gt:0',
'confirmed' => 'in:0,1',
'read_at' => 'integer',
];
protected $scene = [
'add' => ['user_id', 'notice_id', 'confirmed', 'read_at'],
'edit' => ['user_id', 'notice_id', 'confirmed', 'read_at'],
];
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
class WithdrawOrder extends Validate
{
protected $failException = true;
protected $rule = [
'order_no' => 'require|max:64|unique:withdraw_order',
'user_id' => 'require|integer|gt:0',
'amount' => 'float|egt:0',
'status' => 'require|in:0,1,2,3',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['order_no', 'user_id', 'amount', 'status', 'remark'],
'edit' => ['amount', 'status', 'remark'],
];
}

View File

@@ -170,7 +170,7 @@ if (!function_exists('get_controller_path')) {
* 从 Request 或路由获取控制器路径(等价于 ThinkPHP controllerPath * 从 Request 或路由获取控制器路径(等价于 ThinkPHP controllerPath
* 优先从 $request->controllerWebman 路由匹配时设置)解析,否则从 path 解析 * 优先从 $request->controllerWebman 路由匹配时设置)解析,否则从 path 解析
* @param \Webman\Http\Request|null $request * @param \Webman\Http\Request|null $request
* @return string|null 如 auth/admin、user/user * @return string|null 如 auth/admin
*/ */
function get_controller_path($request = null): ?string function get_controller_path($request = null): ?string
{ {
@@ -204,7 +204,24 @@ if (!function_exists('get_controller_path')) {
if (count($parts) < 2) { if (count($parts) < 2) {
return $parts[0] ?? null; return $parts[0] ?? null;
} }
return implode('/', array_slice($parts, 1, -1)) ?: $parts[1]; $segments = array_slice($parts, 1, -1);
if ($segments === []) {
return $parts[1] ?? null;
}
// ThinkPHP 风格段 game.Config -> game/config与 $request->controller 解析结果一致(否则权限节点对不上)
$normalized = [];
foreach ($segments as $seg) {
if (str_contains($seg, '.')) {
$dotPos = strpos($seg, '.');
$mod = substr($seg, 0, $dotPos);
$ctrl = substr($seg, $dotPos + 1);
$normalized[] = strtolower($mod);
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $ctrl));
} else {
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $seg));
}
}
return implode('/', $normalized);
} }
} }

View File

@@ -0,0 +1,20 @@
<?php
namespace app\process;
use app\common\service\GameLiveService;
use Workerman\Timer;
/**
* 实时对局:按单局时长自动开奖
*/
class GameLiveTicker
{
public function onWorkerStart(): void
{
Timer::add(1, static function (): void {
GameLiveService::tickAutoDraw();
GameLiveService::publishSnapshot(null);
});
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace app\process;
use app\common\service\GameRecordService;
use Workerman\Timer;
/**
* 定时检查:开启自动创建且无进行中期号时插入新期(全局仅一局)
*/
class GamePeriodAutoTicker
{
public function onWorkerStart(): void
{
Timer::add(15, static function (): void {
GameRecordService::tickAutoCreate();
});
}
}

View File

@@ -3,12 +3,12 @@
namespace app\process; namespace app\process;
use Webman\App; use Webman\App;
use Webman\Http\Response;
class Http extends App class Http extends App
{ {
/** /**
* 在父类处理前拦截 OPTIONS 预检,直接返回 CORS 头(避免预检未命中路由时无 CORS * 在父类处理前拦截 OPTIONS 预检,直接返回 CORS 头(避免预检未命中路由时无 CORS
* 必须与 AllowCrossDomain::optionsResponse 一致,否则会覆盖中间件里对 Allow-Headers如 server的配置
*/ */
public function onMessage($connection, $request): void public function onMessage($connection, $request): void
{ {
@@ -18,19 +18,8 @@ class Http extends App
$path = is_string($path) ? trim($path, '/') : ''; $path = is_string($path) ? trim($path, '/') : '';
$isApiOrAdmin = $path !== '' && (str_starts_with($path, 'api') || str_starts_with($path, 'admin')); $isApiOrAdmin = $path !== '' && (str_starts_with($path, 'api') || str_starts_with($path, 'admin'));
if ($isApiOrAdmin) { if ($isApiOrAdmin) {
$origin = $request->header('origin'); $response = \app\common\middleware\AllowCrossDomain::optionsResponse($request);
$origin = is_array($origin) ? ($origin[0] ?? '') : (is_string($origin) ? trim($origin) : ''); $connection->send($response);
if ($origin === '') {
$origin = '*';
}
$headers = [
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Max-Age' => '1800',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, think-lang',
];
$connection->send(new Response(204, $headers, ''));
return; return;
} }
} }

View File

@@ -39,8 +39,10 @@
"robmorgan/phinx": "^0.15", "robmorgan/phinx": "^0.15",
"nelexa/zip": "^4.0.0", "nelexa/zip": "^4.0.0",
"voku/anti-xss": "^4.1", "voku/anti-xss": "^4.1",
"topthink/think-validate": "^3.0" "topthink/think-validate": "^3.0",
}, "ext-bcmath": "*",
"webman/push": "^1.1"
},
"suggest": { "suggest": {
"ext-event": "For better performance. " "ext-event": "For better performance. "
}, },

View File

@@ -7,9 +7,9 @@ return [
// 允许跨域访问的域名(* 表示任意;开发可用 *,生产建议填具体域名) // 允许跨域访问的域名(* 表示任意;开发可用 *,生产建议填具体域名)
'cors_request_domain' => '*', 'cors_request_domain' => '*',
// 是否开启会员登录验证码 // 是否开启会员登录验证码
'user_login_captcha' => true, 'user_login_captcha' => false,
// 是否开启管理员登录验证码 // 是否开启管理员登录验证码
'admin_login_captcha' => true, 'admin_login_captcha' => false,
// 会员登录失败可重试次数,false则无限 // 会员登录失败可重试次数,false则无限
'user_login_retry' => 10, 'user_login_retry' => 10,
// 管理员登录失败可重试次数,false则无限 // 管理员登录失败可重试次数,false则无限

View File

@@ -0,0 +1,10 @@
<?php
return [
'enable' => true,
'websocket' => 'websocket://0.0.0.0:3131',
'api' => 'http://0.0.0.0:3232',
'app_key' => '6d0af5971ad191f2dc8a500885cb79c7',
'app_secret' => 'c457f0be89cd48d481b37f16c0a97f5f',
'channel_hook' => 'http://127.0.0.1:7979/plugin/webman/push/hook',
'auth' => '/plugin/webman/push/auth'
];

View File

@@ -0,0 +1,21 @@
<?php
use Webman\Push\Server;
return [
'server' => [
'handler' => Server::class,
'listen' => config('plugin.webman.push.app.websocket'),
'count' => 1, // 必须是1
'reloadable' => false, // 执行reload不重启
'constructor' => [
'api_listen' => config('plugin.webman.push.app.api'),
'app_info' => [
config('plugin.webman.push.app.app_key') => [
'channel_hook' => config('plugin.webman.push.app.channel_hook'),
'app_secret' => config('plugin.webman.push.app.app_secret'),
],
]
]
]
];

View File

@@ -0,0 +1,87 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Request;
use Webman\Route;
use Webman\Push\Api;
/**
* 推送js客户端文件
*/
Route::get('/plugin/webman/push/push.js', function (Request $request) {
return response()->file(base_path().'/vendor/webman/push/src/push.js');
});
/**
* 私有频道鉴权这里应该使用session辨别当前用户身份然后确定该用户是否有权限监听channel_name
*/
Route::post(config('plugin.webman.push.app.auth'), function (Request $request) {
$pusher = new Api(str_replace('0.0.0.0', '127.0.0.1', config('plugin.webman.push.app.api')), config('plugin.webman.push.app.app_key'), config('plugin.webman.push.app.app_secret'));
$channel_name = $request->post('channel_name');
$session = $request->session();
// 这里应该通过session和channel_name判断当前用户是否有权限监听channel_name
$has_authority = true;
if ($has_authority) {
return response($pusher->socketAuth($channel_name, $request->post('socket_id')));
} else {
return response('Forbidden', 403);
}
});
/**
* 当频道上线以及下线时触发的回调
* 频道上线:是指某个频道从没有连接在线到有连接在线的事件
* 频道下线:是指某个频道的所有连接都断开触发的事件
*/
Route::post(parse_url(config('plugin.webman.push.app.channel_hook'), PHP_URL_PATH), function (Request $request) {
// 没有x-pusher-signature头视为伪造请求
if (!$webhook_signature = $request->header('x-pusher-signature')) {
return response('401 Not authenticated', 401);
}
$body = $request->rawBody();
// 计算签名,$app_secret 是双方使用的密钥,是保密的,外部无从得知
$expected_signature = hash_hmac('sha256', $body, config('plugin.webman.push.app.app_secret'), false);
// 安全校验如果签名不一致可能是伪造的请求返回401状态码
if ($webhook_signature !== $expected_signature) {
return response('401 Not authenticated', 401);
}
// 这里存储这上线 下线的channel数据
$payload = json_decode($body, true);
$channels_online = $channels_offline = [];
foreach ($payload['events'] as $event) {
if ($event['name'] === 'channel_added') {
$channels_online[] = $event['channel'];
} else if ($event['name'] === 'channel_removed') {
$channels_offline[] = $event['channel'];
}
}
// 业务根据需要处理上下线的channel例如将在线状态写入数据库通知其它channel等
// 上线的所有channel
echo 'online channels: ' . implode(',', $channels_online) . "\n";
// 下线的所有channel
echo 'offline channels: ' . implode(',', $channels_offline) . "\n";
return 'OK';
});

View File

@@ -21,7 +21,7 @@ global $argv;
return [ return [
'webman' => [ 'webman' => [
'handler' => Http::class, 'handler' => Http::class,
'listen' => 'http://0.0.0.0:8787', 'listen' => 'http://0.0.0.0:7979',
'count' => cpu_count() * 4, 'count' => cpu_count() * 4,
'user' => '', 'user' => '',
'group' => '', 'group' => '',
@@ -35,6 +35,18 @@ return [
'publicPath' => public_path() 'publicPath' => public_path()
] ]
], ],
// 游戏期号:自动创建下一期(依赖 game_config.period_auto_create_enabled
'gamePeriodAutoTicker' => [
'handler' => app\process\GamePeriodAutoTicker::class,
'count' => 1,
'reloadable' => false,
],
'gameLiveTicker' => [
'handler' => app\process\GameLiveTicker::class,
'count' => 1,
'reloadable' => false,
],
// File update detection and automatic reload // File update detection and automatic reload
'monitor' => [ 'monitor' => [
'handler' => app\process\Monitor::class, 'handler' => app\process\Monitor::class,

View File

@@ -68,6 +68,9 @@ Route::get('/install/index', function () use ($installLockFileForInstall, $insta
// api/index // api/index
Route::get('/api/index/index', [\app\api\controller\Index::class, 'index']); Route::get('/api/index/index', [\app\api\controller\Index::class, 'index']);
// api/v1
Route::get('/api/v1/authToken', [\app\api\controller\V1::class, 'authToken']);
// api/userGET 获取配置POST 登录/注册) // api/userGET 获取配置POST 登录/注册)
Route::add(['GET', 'POST'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']); Route::add(['GET', 'POST'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']);
Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']); Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']);
@@ -108,6 +111,35 @@ Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class
// api/ems // api/ems
Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']); Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']);
// ==================== 移动端游戏接口36字花 ====================
Route::post('/api/auth/userRegister', [\app\api\controller\Auth::class, 'userRegister']);
Route::post('/api/auth/userLogin', [\app\api\controller\Auth::class, 'userLogin']);
Route::post('/api/auth/tokenRefresh', [\app\api\controller\Auth::class, 'tokenRefresh']);
Route::get('/api/account/userProfile', [\app\api\controller\Account::class, 'userProfile']);
Route::get('/api/game/lobbyInit', [\app\api\controller\Game::class, 'lobbyInit']);
Route::get('/api/game/dictionaryList', [\app\api\controller\Game::class, 'dictionaryList']);
Route::get('/api/game/periodHistory', [\app\api\controller\Game::class, 'periodHistory']);
Route::get('/api/game/periodCurrent', [\app\api\controller\Game::class, 'periodCurrent']);
Route::post('/api/game/betPlace', [\app\api\controller\Game::class, 'betPlace']);
Route::post('/api/game/betRebet', [\app\api\controller\Game::class, 'betRebet']);
Route::post('/api/game/autoBetCreate', [\app\api\controller\Game::class, 'autoBetCreate']);
Route::post('/api/game/autoBetStop', [\app\api\controller\Game::class, 'autoBetStop']);
Route::get('/api/game/betMyOrders', [\app\api\controller\Game::class, 'betMyOrders']);
Route::get('/api/wallet/balanceSummary', [\app\api\controller\Wallet::class, 'balanceSummary']);
Route::get('/api/wallet/recordList', [\app\api\controller\Wallet::class, 'recordList']);
Route::post('/api/finance/depositCreate', [\app\api\controller\Finance::class, 'depositCreate']);
Route::get('/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']);
Route::post('/api/finance/withdrawCreate', [\app\api\controller\Finance::class, 'withdrawCreate']);
Route::get('/api/finance/withdrawDetail', [\app\api\controller\Finance::class, 'withdrawDetail']);
Route::get('/api/notice/noticeList', [\app\api\controller\Notice::class, 'noticeList']);
Route::get('/api/notice/noticeDetail', [\app\api\controller\Notice::class, 'noticeDetail']);
Route::post('/api/notice/noticeConfirm', [\app\api\controller\Notice::class, 'noticeConfirm']);
// ==================== Admin 路由 ==================== // ==================== Admin 路由 ====================
// Admin 多为 JSON API前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容 // Admin 多为 JSON API前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容
@@ -172,29 +204,6 @@ Route::get('/admin/auth/rule/select', [\app\admin\controller\auth\Rule::class, '
// admin/auth/adminLog // admin/auth/adminLog
Route::get('/admin/auth/adminLog/index', [\app\admin\controller\auth\AdminLog::class, 'index']); Route::get('/admin/auth/adminLog/index', [\app\admin\controller\auth\AdminLog::class, 'index']);
// admin/user/user
Route::get('/admin/user/user/index', [\app\admin\controller\user\User::class, 'index']);
Route::post('/admin/user/user/add', [\app\admin\controller\user\User::class, 'add']);
Route::post('/admin/user/user/edit', [\app\admin\controller\user\User::class, 'edit']);
Route::get('/admin/user/user/select', [\app\admin\controller\user\User::class, 'select']);
// admin/user/group
Route::post('/admin/user/group/add', [\app\admin\controller\user\Group::class, 'add']);
Route::post('/admin/user/group/edit', [\app\admin\controller\user\Group::class, 'edit']);
// admin/user/rule
Route::get('/admin/user/rule/index', [\app\admin\controller\user\Rule::class, 'index']);
Route::post('/admin/user/rule/add', [\app\admin\controller\user\Rule::class, 'add']);
Route::post('/admin/user/rule/edit', [\app\admin\controller\user\Rule::class, 'edit']);
Route::post('/admin/user/rule/del', [\app\admin\controller\user\Rule::class, 'del']);
Route::get('/admin/user/rule/select', [\app\admin\controller\user\Rule::class, 'select']);
// admin/user/scoreLog
Route::post('/admin/user/scoreLog/add', [\app\admin\controller\user\ScoreLog::class, 'add']);
// admin/user/moneyLog
Route::post('/admin/user/moneyLog/add', [\app\admin\controller\user\MoneyLog::class, 'add']);
// admin/routine/config // admin/routine/config
Route::get('/admin/routine/config/index', [\app\admin\controller\routine\Config::class, 'index']); Route::get('/admin/routine/config/index', [\app\admin\controller\routine\Config::class, 'index']);
Route::post('/admin/routine/config/edit', [\app\admin\controller\routine\Config::class, 'edit']); Route::post('/admin/routine/config/edit', [\app\admin\controller\routine\Config::class, 'edit']);
@@ -245,6 +254,34 @@ Route::get('/admin/security/dataRecycleLog/index', [\app\admin\controller\securi
Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']); Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']);
Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']); Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']);
// ==================== CRUD 生成的根级控制器(/admin/item/index 或 /admin/Item/index无子目录、无点号 ====================
// 显式路由在上,此处作为兜底;与 /admin/module.controller/action 互补
Route::add(
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
'/admin/{controller:[a-zA-Z][a-zA-Z0-9]*}/{action}',
function (\Webman\Http\Request $request, string $controller, string $action) {
$class = '\\app\\admin\\controller\\' . ucfirst(strtolower($controller));
if (!class_exists($class)) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
}
if (!method_exists($class, $action)) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
}
$request->controller = $class;
try {
$instance = new $class();
return $instance->$action($request);
} catch (\Throwable $e) {
return new Response(500, ['Content-Type' => 'application/json'], json_encode([
'code' => 0,
'msg' => $e->getMessage(),
'time' => time(),
'data' => null,
], JSON_UNESCAPED_UNICODE));
}
}
);
// ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action ==================== // ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action ====================
// 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用 // 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用
Route::add( Route::add(

View File

@@ -0,0 +1,411 @@
-- =====================================================================
-- 「36字花」数据库 DDL V1
-- 字符集utf8mb4 | 引擎InnoDB | 金额DECIMAL(18,4)(游戏币)
-- 执行前请全库备份。若库中已存在同名列/表,请按需删减对应语句后执行。
-- =====================================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ---------------------------------------------------------------------
-- 一、新渠道表channel替代 game_channel
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `channel`;
CREATE TABLE `channel` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`top_admin_id` int(10) unsigned DEFAULT NULL COMMENT '顶级代理管理员IDadmin.id',
`code` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道标识',
`invite_code` varchar(64) DEFAULT NULL COMMENT '渠道主邀请码',
`name` varchar(255) NOT NULL DEFAULT '' COMMENT '渠道名',
`agent_mode` varchar(32) NOT NULL DEFAULT 'turnover' COMMENT '代理模式turnover=普通刷水代理 affiliate=联营代理',
`user_count` int(10) DEFAULT NULL COMMENT '用户数',
`profit_amount` decimal(18,4) DEFAULT NULL COMMENT '渠道当期利润快照(游戏币)',
`total_profit_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '渠道累计总利润(游戏币)',
`commission_pool_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '可分红资金池(游戏币)',
`turnover_share_rate` decimal(9,6) DEFAULT NULL COMMENT '普通刷水代理分红比例(按流水分桶后再乘该比例)',
`affiliate_share_rate` decimal(9,6) DEFAULT NULL COMMENT '联营代理占成比例',
`affiliate_fee_rate` decimal(9,6) DEFAULT NULL COMMENT '联营代理成本扣除比例',
`carryover_balance` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '联营负结转余额(可负)',
`status` tinyint(1) unsigned NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=启用',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`admin_id` int(10) unsigned DEFAULT NULL COMMENT '创建管理员',
`admin_group_id` int(10) unsigned DEFAULT NULL COMMENT '渠道角色组IDadmin_group.id',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
UNIQUE KEY `uk_invite_code` (`invite_code`),
KEY `idx_agent_mode` (`agent_mode`),
KEY `idx_top_admin_id` (`top_admin_id`),
KEY `idx_admin_group_id` (`admin_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='渠道表替代game_channel';
-- 可选数据迁移(仅当历史存在 game_channel 且字段兼容时执行)
-- INSERT INTO `channel` (`id`,`code`,`name`,`user_count`,`profit_amount`,`status`,`remark`,`admin_id`,`admin_group_id`,`create_time`,`update_time`)
-- SELECT `id`,`code`,`name`,`user_count`,CAST(`profit_amount` AS DECIMAL(18,4)),`status`,`remark`,`admin_id`,`admin_group_id`,`create_time`,`update_time`
-- FROM `game_channel`;
-- ---------------------------------------------------------------------
-- 二、既有表适配game_userC 端用户)
-- ---------------------------------------------------------------------
ALTER TABLE `game_user`
MODIFY COLUMN `coin` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '游戏币余额',
ADD COLUMN `email` varchar(255) NOT NULL DEFAULT '' COMMENT '邮箱,可与手机号二选一注册' AFTER `phone`,
ADD COLUMN `channel_id` int(10) unsigned DEFAULT NULL COMMENT '所属渠道IDchannel.id' AFTER `game_channel_id`,
ADD COLUMN `register_invite_code` varchar(64) NOT NULL DEFAULT '' COMMENT '注册时使用的邀请码快照' AFTER `channel_id`,
ADD COLUMN `total_deposit_coin` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '累计充值入账游戏币(提现流水校验分子/分母用)' AFTER `register_invite_code`,
ADD COLUMN `total_valid_bet_coin` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '累计有效投注游戏币' AFTER `total_deposit_coin`,
ADD COLUMN `risk_flags` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '风控位标记:可按位表示禁止登录/下注/提现等' AFTER `total_valid_bet_coin`,
ADD COLUMN `current_streak` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '当前连胜场次DB兜底权威状态可放Redis' AFTER `risk_flags`,
ADD COLUMN `last_bet_period_no` varchar(64) NOT NULL DEFAULT '' COMMENT '上一笔有效下注所在期号,用于连胜连续期校验' AFTER `current_streak`;
ALTER TABLE `game_user`
ADD KEY `idx_channel_id` (`channel_id`),
ADD KEY `idx_register_invite_code` (`register_invite_code`),
ADD KEY `idx_last_bet_period_no` (`last_bet_period_no`);
-- ---------------------------------------------------------------------
-- 三、既有表适配admin子代理管理
-- ---------------------------------------------------------------------
ALTER TABLE `admin`
ADD COLUMN `parent_admin_id` int(11) unsigned DEFAULT NULL COMMENT '上级代理管理员ID顶级为空' AFTER `id`,
ADD COLUMN `channel_id` int(10) unsigned DEFAULT NULL COMMENT '所属渠道IDchannel.id' AFTER `parent_admin_id`,
ADD COLUMN `invite_code` varchar(64) DEFAULT NULL COMMENT '子代理邀请码(用于发展玩家)' AFTER `mobile`,
ADD COLUMN `agent_role` varchar(32) NOT NULL DEFAULT 'sub_agent' COMMENT 'agent_admin|sub_agent|staff均无开奖权限' AFTER `status`;
ALTER TABLE `admin`
ADD UNIQUE KEY `uk_invite_code` (`invite_code`),
ADD KEY `idx_parent_admin_id` (`parent_admin_id`),
ADD KEY `idx_channel_id` (`channel_id`);
-- ---------------------------------------------------------------------
-- 四、既有表适配admin_group角色组按渠道隔离
-- ---------------------------------------------------------------------
ALTER TABLE `admin_group`
ADD COLUMN `channel_id` int(10) unsigned DEFAULT NULL COMMENT '所属渠道IDchannel.idNULL表示系统级角色组' AFTER `pid`,
ADD COLUMN `commission_rate` decimal(6,2) NOT NULL DEFAULT 0.00 COMMENT '角色组分红比例(百分比)' AFTER `channel_id`;
ALTER TABLE `admin_group`
ADD KEY `idx_channel_id` (`channel_id`);
-- ---------------------------------------------------------------------
-- 五、系统参数KV禁止业务写死
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_config`;
CREATE TABLE `game_config` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
`config_key` varchar(64) NOT NULL DEFAULT '' COMMENT '参数键,全局唯一',
`config_value` text COMMENT '参数值可为JSON',
`value_type` varchar(16) NOT NULL DEFAULT 'string' COMMENT 'string|int|decimal|json',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '说明',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_config_key` (`config_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='游戏/平台动态参数';
-- ---------------------------------------------------------------------
-- 四、期号与状态机
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_period`;
CREATE TABLE `game_period` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_no` varchar(64) NOT NULL DEFAULT '' COMMENT '业务期号,唯一,如 20260414-123045',
`period_start_at` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '本期开始时间戳',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=下注开放 1=已封盘 2=算票中 3=派彩中 4=已结束 5=已作废',
`draw_mode` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=自动AI 1=手动预设',
`preset_number` tinyint(3) unsigned DEFAULT NULL COMMENT '手动模式预设开奖号码 1-36',
`result_number` tinyint(3) unsigned DEFAULT NULL COMMENT '实际开奖号码 1-36',
`void_reason` varchar(255) NOT NULL DEFAULT '' COMMENT '作废原因',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_period_no` (`period_no`),
KEY `idx_status_start` (`status`,`period_start_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='游戏期号与状态';
-- ---------------------------------------------------------------------
-- 五、注单
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_bet_order`;
CREATE TABLE `game_bet_order` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '关联 game_period.id',
`period_no` varchar(64) NOT NULL DEFAULT '' COMMENT '冗余期号,便于对账与查询',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`channel_id` int(10) unsigned DEFAULT NULL COMMENT '下单时归属渠道',
`pick_numbers` json DEFAULT NULL COMMENT '选中号码 JSON 数组,如 [1,8,16]',
`unit_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '单号金额(各号一致)',
`pick_count` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '选中号码个数',
`total_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '本单总金额',
`streak_at_bet` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '下注时连胜次数(用于派彩倍率)',
`is_auto` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=手动 1=托管',
`win_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '常规派彩金额(含连赢倍率后)',
`jackpot_extra_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT 'Jackpot 额外派彩',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '1=已扣款待开奖 2=已结算 3=已作废已退款',
`idempotency_key` varchar(64) DEFAULT NULL COMMENT '幂等键防重复下单未使用则为NULL允许多条NULL',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_idempotency` (`idempotency_key`),
KEY `idx_period_user` (`period_id`,`user_id`),
KEY `idx_user_ctime` (`user_id`,`create_time`),
KEY `idx_period_no` (`period_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='游戏注单';
-- ---------------------------------------------------------------------
-- 六、自动托管
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_bet_auto`;
CREATE TABLE `game_bet_auto` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`remaining_rounds` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '剩余托管局数',
`pick_numbers` json DEFAULT NULL COMMENT '选号 JSON',
`unit_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '单号金额',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=停止 1=运行中',
`next_trigger_before` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '下次允许自动下注前的时间戳上界配合0-10秒抖动',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='自动托管任务';
-- ---------------------------------------------------------------------
-- 七、用户钱包流水账
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `user_wallet_ledger`;
CREATE TABLE `user_wallet_ledger` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`biz_type` varchar(32) NOT NULL DEFAULT '' COMMENT '业务类型deposit|bet|payout|withdraw_freeze|withdraw|fee|manual|void_refund 等',
`direction` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '1=入金 2=出金',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '变动额,恒正',
`balance_after` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '变动后余额',
`ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '关联类型period|bet_order|deposit_order|withdraw_order',
`ref_id` bigint(20) unsigned DEFAULT NULL COMMENT '关联主键',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_ctime` (`user_id`,`create_time`),
KEY `idx_ref` (`ref_type`,`ref_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户游戏币账本流水';
-- ---------------------------------------------------------------------
-- 八、充值订单
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `deposit_order`;
CREATE TABLE `deposit_order` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '内部订单号',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`fiat_currency` varchar(16) NOT NULL DEFAULT '' COMMENT '法币币种代码',
`fiat_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '法币金额',
`fx_rate` decimal(18,8) NOT NULL DEFAULT 0.00000000 COMMENT '成交汇率',
`coin_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '入账游戏币',
`gateway` varchar(32) NOT NULL DEFAULT '' COMMENT '支付网关标识',
`gateway_trade_no` varchar(128) NOT NULL DEFAULT '' COMMENT '第三方流水号',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=待支付 1=成功 2=失败 3=关闭',
`paid_at` bigint(16) unsigned DEFAULT NULL COMMENT '支付成功时间',
`notify_payload` mediumtext COMMENT '回调原文存档',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_ctime` (`user_id`,`create_time`),
KEY `idx_gateway_trade` (`gateway`,`gateway_trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='充值订单';
-- ---------------------------------------------------------------------
-- 九、提现订单
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `withdraw_order`;
CREATE TABLE `withdraw_order` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_no` varchar(64) NOT NULL DEFAULT '' COMMENT '内部订单号',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`apply_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '申请提现游戏币',
`fee_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '手续费游戏币',
`actual_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '应出账游戏币(扣费后)',
`fiat_currency` varchar(16) NOT NULL DEFAULT '' COMMENT '出款法币币种',
`need_audit` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=免审 1=人工审核',
`audit_status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=待审 1=通过 2=驳回',
`from_jackpot` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=否 1=Jackpot相关拦截',
`reject_reason` varchar(500) NOT NULL DEFAULT '' COMMENT '驳回原因',
`paid_at` bigint(16) unsigned DEFAULT NULL COMMENT '出款完成时间',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_ctime` (`user_id`,`create_time`),
KEY `idx_audit` (`need_audit`,`audit_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='提现订单';
-- ---------------------------------------------------------------------
-- 十、代理钱包(每渠道/代理线一条)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_agent_wallet`;
CREATE TABLE `game_agent_wallet` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'channel.id一对一',
`balance` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '可用佣金余额',
`frozen` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '冻结',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_channel` (`channel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='代理佣金钱包';
-- ---------------------------------------------------------------------
-- 十一、代理钱包流水
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `game_agent_wallet_ledger`;
CREATE TABLE `game_agent_wallet_ledger` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'channel.id',
`biz_type` varchar(32) NOT NULL DEFAULT '' COMMENT 'commission_payout|withdraw|manual 等',
`direction` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '1=入金 2=出金',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '变动额,恒正',
`balance_after` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '变动后余额',
`ref_type` varchar(32) NOT NULL DEFAULT '' COMMENT '关联类型',
`ref_id` bigint(20) unsigned DEFAULT NULL COMMENT '关联主键',
`remark` varchar(500) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_channel_ctime` (`channel_id`,`create_time`),
KEY `idx_ref` (`ref_type`,`ref_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='代理钱包流水';
-- ---------------------------------------------------------------------
-- 十二、代理结算周期(大盘快照)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `agent_settlement_period`;
CREATE TABLE `agent_settlement_period` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_type` varchar(16) NOT NULL DEFAULT '' COMMENT 'daily|weekly|monthly',
`period_key` varchar(32) NOT NULL DEFAULT '' COMMENT '周期唯一键,如 2026-W16',
`platform_ggr` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '平台当期总客损/毛利(可负)',
`platform_turnover` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '全网有效投注流水',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=未结算 1=已计算 2=已发放',
`start_at` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '周期开始时间戳',
`end_at` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '周期结束时间戳',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_period` (`period_type`,`period_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='代理结算周期';
-- ---------------------------------------------------------------------
-- 十三、代理佣金明细(级差按行记录)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `agent_commission_record`;
CREATE TABLE `agent_commission_record` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`settlement_period_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT 'agent_settlement_period.id',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '获得佣金的渠道',
`commission_mode` varchar(16) NOT NULL DEFAULT 'turnover' COMMENT 'turnover=流水占比 affiliate=联营',
`line_turnover` decimal(18,4) DEFAULT NULL COMMENT '该线当期流水turnover 模式用)',
`line_turnover_ratio` decimal(18,8) DEFAULT NULL COMMENT '占全网比例',
`pool_amount` decimal(18,4) DEFAULT NULL COMMENT '该线分桶基数',
`rate_snapshot` decimal(9,6) DEFAULT NULL COMMENT '当期有效拨比/级差快照',
`commission_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '本笔应发佣金',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=待入账 1=已入账',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_period_channel` (`settlement_period_id`,`channel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='代理佣金明细';
-- ---------------------------------------------------------------------
-- 十四、联营契约
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `affiliate_contract`;
CREATE TABLE `affiliate_contract` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '联营代理 channel.id',
`title` varchar(128) NOT NULL DEFAULT '' COMMENT '契约标题',
`share_rate` decimal(9,6) NOT NULL DEFAULT 0.000000 COMMENT '客损占成比例',
`admin_fee_rate` decimal(9,6) NOT NULL DEFAULT 0.000000 COMMENT '平台双降费等成本比例快照',
`tier_json` json DEFAULT NULL COMMENT '阶梯条件 JSON可选',
`effective_from` bigint(16) unsigned NOT NULL DEFAULT 0 COMMENT '生效时间',
`effective_to` bigint(16) unsigned DEFAULT NULL COMMENT '失效时间,空为长期',
`status` tinyint(3) unsigned NOT NULL DEFAULT 1 COMMENT '0=停用 1=有效',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_channel_time` (`channel_id`,`effective_from`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='联营契约';
-- ---------------------------------------------------------------------
-- 十五、联营负结转(按代理线 + 周期)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `affiliate_carryover`;
CREATE TABLE `affiliate_carryover` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`settlement_period_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT 'agent_settlement_period.id',
`channel_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '联营代理线',
`opening_carryover` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '期初结转(常为负)',
`period_net_ggr` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '当期辖区净客损',
`period_net_after_fee` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '扣费后净利润基数',
`commission_amount` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '本期应发联营佣金',
`closing_carryover` decimal(18,4) NOT NULL DEFAULT 0.0000 COMMENT '期末结转至下期',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_period_channel` (`settlement_period_id`,`channel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='联营负结转';
-- ---------------------------------------------------------------------
-- 十六、运营公告
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `operation_notice`;
CREATE TABLE `operation_notice` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
`content` mediumtext COMMENT '正文,可富文本',
`notice_type` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=静默信箱 1=强弹窗',
`status` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0=草稿 1=发布',
`publish_at` bigint(16) unsigned DEFAULT NULL COMMENT '发布时间',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(16) unsigned DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY (`id`),
KEY `idx_type_status` (`notice_type`,`status`,`publish_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='运营公告';
-- ---------------------------------------------------------------------
-- 十七、用户公告已读/确认
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `user_notice_read`;
CREATE TABLE `user_notice_read` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`notice_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'operation_notice.id',
`read_at` bigint(16) unsigned DEFAULT NULL COMMENT '阅读时间',
`confirmed` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '强弹窗是否已勾选确认 0/1',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_notice` (`user_id`,`notice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户公告阅读与确认';
-- ---------------------------------------------------------------------
-- 十八、站内信(与公告分离的个性化通知)
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `user_site_message`;
CREATE TABLE `user_site_message` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT 'game_user.id',
`title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
`body` mediumtext COMMENT '内容',
`category` varchar(32) NOT NULL DEFAULT 'system' COMMENT '分类',
`read_at` bigint(16) unsigned DEFAULT NULL COMMENT '已读时间,空未读',
`create_time` bigint(16) unsigned DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_read` (`user_id`,`read_at`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='用户站内信';
SET FOREIGN_KEY_CHECKS = 1;
-- =====================================================================
-- 可选game_config 初始键(按需导入,值由运营最终确定)
-- INSERT INTO game_config (config_key, config_value, value_type, remark, create_time, update_time) VALUES
-- ('period_seconds','30','int','单局总时长秒', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
-- ('bet_seconds','20','int','下注开放秒数', UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- =====================================================================

View File

@@ -227,6 +227,8 @@ class Install extends AbstractMigration
->addColumn('extend', 'string', ['limit' => 255, 'default' => '', 'comment' => '扩展属性', 'null' => false]) ->addColumn('extend', 'string', ['limit' => 255, 'default' => '', 'comment' => '扩展属性', 'null' => false])
->addColumn('allow_del', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '允许删除:0=否,1=是', 'null' => false]) ->addColumn('allow_del', 'integer', ['signed' => false, 'limit' => MysqlAdapter::INT_TINY, 'default' => 0, 'comment' => '允许删除:0=否,1=是', 'null' => false])
->addColumn('weigh', 'integer', ['comment' => '权重', 'default' => 0, 'null' => false]) ->addColumn('weigh', 'integer', ['comment' => '权重', 'default' => 0, 'null' => false])
->addColumn('update_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '更新时间'])
->addColumn('create_time', 'biginteger', ['limit' => 16, 'signed' => false, 'null' => true, 'default' => null, 'comment' => '创建时间'])
->addIndex(['name'], [ ->addIndex(['name'], [
'unique' => true, 'unique' => true,
]) ])

View File

@@ -17,8 +17,6 @@ class InstallData extends AbstractMigration
$this->menuRule(); $this->menuRule();
$this->securityDataRecycle(); $this->securityDataRecycle();
$this->securitySensitiveData(); $this->securitySensitiveData();
$this->user();
$this->userGroup();
$this->userRule(); $this->userRule();
} }
@@ -231,8 +229,17 @@ class InstallData extends AbstractMigration
public function menuRule(): void public function menuRule(): void
{ {
if (!$this->hasTable('menu_rule')) return; // Install 迁移在已存在 admin_rule旧版表名时会跳过创建 menu_rule此处需与之一致
$table = $this->table('menu_rule'); $ruleTable = null;
if ($this->hasTable('menu_rule')) {
$ruleTable = 'menu_rule';
} elseif ($this->hasTable('admin_rule')) {
$ruleTable = 'admin_rule';
}
if ($ruleTable === null) {
return;
}
$table = $this->table($ruleTable);
$rows = [ $rows = [
[ [
'id' => '1', 'id' => '1',
@@ -447,245 +454,6 @@ class InstallData extends AbstractMigration
'updatetime' => $this->nowTime, 'updatetime' => $this->nowTime,
'createtime' => $this->nowTime, 'createtime' => $this->nowTime,
], ],
[
'id' => '21',
'type' => 'menu_dir',
'title' => '会员管理',
'name' => 'user',
'path' => 'user',
'icon' => 'fa fa-drivers-license',
'weigh' => '95',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '22',
'pid' => '21',
'type' => 'menu',
'title' => '会员管理',
'name' => 'user/user',
'path' => 'user/user',
'icon' => 'fa fa-user',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/user/index.vue',
'keepalive' => '1',
'weigh' => '94',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '23',
'pid' => '22',
'type' => 'button',
'title' => '查看',
'name' => 'user/user/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '24',
'pid' => '22',
'type' => 'button',
'title' => '添加',
'name' => 'user/user/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '25',
'pid' => '22',
'type' => 'button',
'title' => '编辑',
'name' => 'user/user/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '26',
'pid' => '22',
'type' => 'button',
'title' => '删除',
'name' => 'user/user/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '27',
'pid' => '21',
'type' => 'menu',
'title' => '会员分组管理',
'name' => 'user/group',
'path' => 'user/group',
'icon' => 'fa fa-group',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/group/index.vue',
'keepalive' => '1',
'weigh' => '93',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '28',
'pid' => '27',
'type' => 'button',
'title' => '查看',
'name' => 'user/group/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '29',
'pid' => '27',
'type' => 'button',
'title' => '添加',
'name' => 'user/group/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '30',
'pid' => '27',
'type' => 'button',
'title' => '编辑',
'name' => 'user/group/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '31',
'pid' => '27',
'type' => 'button',
'title' => '删除',
'name' => 'user/group/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '32',
'pid' => '21',
'type' => 'menu',
'title' => '会员规则管理',
'name' => 'user/rule',
'path' => 'user/rule',
'icon' => 'fa fa-th-list',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/rule/index.vue',
'keepalive' => '1',
'weigh' => '92',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '33',
'pid' => '32',
'type' => 'button',
'title' => '查看',
'name' => 'user/rule/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '34',
'pid' => '32',
'type' => 'button',
'title' => '添加',
'name' => 'user/rule/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '35',
'pid' => '32',
'type' => 'button',
'title' => '编辑',
'name' => 'user/rule/edit',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '36',
'pid' => '32',
'type' => 'button',
'title' => '删除',
'name' => 'user/rule/del',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '37',
'pid' => '32',
'type' => 'button',
'title' => '快速排序',
'name' => 'user/rule/sortable',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '38',
'pid' => '21',
'type' => 'menu',
'title' => '会员余额管理',
'name' => 'user/moneyLog',
'path' => 'user/moneyLog',
'icon' => 'el-icon-Money',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/moneyLog/index.vue',
'keepalive' => '1',
'weigh' => '91',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '39',
'pid' => '38',
'type' => 'button',
'title' => '查看',
'name' => 'user/moneyLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '40',
'pid' => '38',
'type' => 'button',
'title' => '添加',
'name' => 'user/moneyLog/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '41',
'pid' => '21',
'type' => 'menu',
'title' => '会员积分管理',
'name' => 'user/scoreLog',
'path' => 'user/scoreLog',
'icon' => 'el-icon-Discount',
'menu_type' => 'tab',
'component' => '/src/views/backend/user/scoreLog/index.vue',
'keepalive' => '1',
'weigh' => '90',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '42',
'pid' => '41',
'type' => 'button',
'title' => '查看',
'name' => 'user/scoreLog/index',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[
'id' => '43',
'pid' => '41',
'type' => 'button',
'title' => '添加',
'name' => 'user/scoreLog/add',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[ [
'id' => '44', 'id' => '44',
'type' => 'menu_dir', 'type' => 'menu_dir',
@@ -1155,7 +923,7 @@ class InstallData extends AbstractMigration
'createtime' => $this->nowTime, 'createtime' => $this->nowTime,
], ],
]; ];
$exist = Db::name('menu_rule')->where('id', 1)->value('id'); $exist = Db::name($ruleTable)->where('id', 1)->value('id');
if (!$exist) { if (!$exist) {
$table->insert($rows)->saveData(); $table->insert($rows)->saveData();
} }
@@ -1205,16 +973,6 @@ class InstallData extends AbstractMigration
'updatetime' => $this->nowTime, 'updatetime' => $this->nowTime,
'createtime' => $this->nowTime, 'createtime' => $this->nowTime,
], ],
[
'id' => 5,
'name' => '会员',
'controller' => 'user/User.php',
'controller_as' => 'user/user',
'data_table' => 'user',
'primary_key' => 'id',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[ [
'id' => 6, 'id' => 6,
'name' => '数据回收规则', 'name' => '数据回收规则',
@@ -1248,18 +1006,6 @@ class InstallData extends AbstractMigration
'updatetime' => $this->nowTime, 'updatetime' => $this->nowTime,
'createtime' => $this->nowTime, 'createtime' => $this->nowTime,
], ],
[
'id' => 2,
'name' => '会员数据',
'controller' => 'user/User.php',
'controller_as' => 'user/user',
'data_table' => 'user',
'primary_key' => 'id',
'data_fields' => '{"username":"用户名","mobile":"手机号","password":"密码","status":"状态","email":"邮箱地址"}',
'status' => '1',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
],
[ [
'id' => 3, 'id' => 3,
'name' => '管理员权限', 'name' => '管理员权限',
@@ -1279,49 +1025,6 @@ class InstallData extends AbstractMigration
} }
} }
public function user(): void
{
$table = $this->table('user');
$rows = [
[
'id' => 1,
'group_id' => 1,
'username' => 'user',
'nickname' => 'User',
'email' => '18888888888@qq.com',
'mobile' => '18888888888',
'gender' => '2',
'birthday' => date('Y-m-d'),
'status' => 'enable',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
]
];
$exist = Db::name('user')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function userGroup(): void
{
$table = $this->table('user_group');
$rows = [
[
'id' => 1,
'name' => '默认分组',
'rules' => '*',
'status' => '1',
'updatetime' => $this->nowTime,
'createtime' => $this->nowTime,
]
];
$exist = Db::name('user_group')->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
}
public function userRule(): void public function userRule(): void
{ {
$table = $this->table('user_rule'); $table = $this->table('user_rule');

View File

@@ -0,0 +1,55 @@
<?php
/**
* config 表在初始 install 中未含 create_time/update_time而 Config 模型开启自动时间戳,
* Version205 等迁移使用 Model::save() 会生成对 update_time 的 UPDATE导致 1054 错误。
* 本迁移在 Version205 之前执行,补齐字段。
*/
use Phinx\Migration\AbstractMigration;
class AddConfigTimestamps extends AbstractMigration
{
public function up(): void
{
if (!$this->hasTable('config')) {
return;
}
$config = $this->table('config');
if (!$config->hasColumn('update_time')) {
$config->addColumn('update_time', 'biginteger', [
'limit' => 16,
'signed' => false,
'null' => true,
'default' => null,
'comment' => '更新时间',
'after' => 'weigh',
])->save();
}
$config = $this->table('config');
if (!$config->hasColumn('create_time')) {
$config->addColumn('create_time', 'biginteger', [
'limit' => 16,
'signed' => false,
'null' => true,
'default' => null,
'comment' => '创建时间',
'after' => 'update_time',
])->save();
}
}
public function down(): void
{
if (!$this->hasTable('config')) {
return;
}
$config = $this->table('config');
if ($config->hasColumn('create_time')) {
$config->removeColumn('create_time')->save();
}
$config = $this->table('config');
if ($config->hasColumn('update_time')) {
$config->removeColumn('update_time')->save();
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,5 +7,5 @@ services:
volumes: volumes:
- "./:/app" - "./:/app"
ports: ports:
- "8787:8787" - "7979:7979"
command: ["php", "start.php", "start" ] command: ["php", "start.php", "start" ]

View File

@@ -0,0 +1,429 @@
# 「36字花」数据库与实施计划
**文档版本**V1.10
**依据**《36字花 PRD》《业务流程说明书》《后端系统规格书》及现有表 `user`
**目标**:明确分阶段落地步骤、需新建表、两表适配方向与可执行验证清单。
**DDL 脚本(可执行)**[database/dfw_36zihua_schema_v1.sql](../database/dfw_36zihua_schema_v1.sql)
内含:新建 `channel` 表,及 `user` / `admin` / `admin_group``ALTER` 语句,以及新建表完整 `CREATE TABLE`(字段级 `COMMENT` 即字段清单)。
---
## 一、现状与表定位
| 表名 | 当前角色 | 目标角色 |
|------|----------|----------|
| `channel` | 新渠道主表 | **顶级代理渠道分红配置表**:由超管创建,维护分红方式、分红金额、总利润等渠道参数 |
| `user` | 用户coin、channel_id 等) | **C 端玩家主表**:余额与精度、归属代理、流水与风控字段 |
| `admin` | 后台管理员 | **子代理承载表**:顶级代理下级代理放在 `admin`,通过 `invite_code` 管理客户,统一无开奖控制权 |
| `admin_group` | 角色组 | **按渠道隔离**:角色组归属 `channel`,渠道仅管理本渠道角色组 |
### 1.1 全局对局与渠道职责(重要)
与「多渠道」并存的一条硬规则:**对局与开奖全局唯一**,渠道**不**切分牌桌、**不**各自开奖。
| 维度 | 口径 |
|------|------|
| **期号 / 对局** | **全平台共用一套** `game_period`(及全网唯一的当前 `period_no`、同一套状态机)。不存在「每个渠道一局」或「每渠道独立期号表」。 |
| **开奖结果** | **全渠道玩家共享同一期、同一 `result_number`**。所有玩家(无论来自哪个 `channel_id`)压的是**同一场**;避免用户感知「不同入口有不同局、可被渠道操纵」。 |
| **渠道的用途** | `channel``user.channel_id`、注单上的 **`channel_id` 快照**(若有)仅用于 **代理归属、分润、风控与后台数据范围****不**用于生成多套并行对局或独立开奖。 |
| **Redis / 接口** | 当前期倒计时、封盘、算票、开奖号码等 **热状态全局一份**;不得在业务上按渠道维护多套「当前期」或多套开奖结果。 |
> **与 DDL 的对应**`game_period` **不设** `channel_id``bet_order.channel_id` 为下单时用户归属快照,不改变「全场一局」语义。详见 §11.5。
---
## 二、分阶段执行计划与「先核心、后细节」顺序
**原则**:先打通 **账号 / 渠道 / 余额与流水 / 最小可演示闭环**,再叠 **玩法细则、自动开奖调度、公告与站内信等展示类能力**。避免一上来就做全量 DDL 与全部后台菜单,导致联调面过大。
### 2.1 总览(与下文 P0P4 对应)
| 阶段 | 目标 | 主要内容 |
|------|------|----------|
| **P0** | 数据地基 | 新建 `channel` 并适配 `user``admin``admin_group`;建立账本类最小表集(金额精度统一) |
| **P1** | 游戏核心 | 期号、注单、开奖、系统配置Redis 状态机 + MQ 异步落库 |
| **P2** | 资金与风控 | 充提订单、流水账、提现审核、Jackpot / 大额拦截 |
| **P3** | 代理与结算 | 流水占比分桶、级差、联营占成与负结转(表结构 + 跑批骨架) |
| **P4** | 运营与审计 | 公告(含 Pop-out、站内信、后台 RBAC 与操作审计 |
### 2.2 建议先做的:第一批建表(核心路径)
按依赖顺序执行 DDL或 Phinx**优先保证下列表/变更已落地**,其余表可分期补建:
| 顺序 | 类型 | 对象 | 说明 |
|------|------|------|------|
| 1 | 新建 | **`channel`** | 渠道与顶级代理参数;替代历史 `game_channel` 方向 |
| 2 | 适配 | **`user``admin``admin_group`** | `coin` 精度、`channel_id`、邀请码与风控等(见 §四~§六) |
| 3 | 新建 | **`game_config`** | 全局可调参数(局时长、下注窗口等),避免写死 |
| 4 | 新建 | **`user_wallet_record`** | 玩家钱包流水,与余额变更同事务、可审计 |
| 5 | 新建 | **`game_period`**、**`bet_order`** | 最小「一期号 + 注单」闭环;先有表再写状态机与接口 |
**第一批可暂缓的表(归入后续迭代)**`game_bet_auto`(自动托管)、`operation_notice` / `user_notice_read` / `user_site_message`(纯展示与触达)、`game_agent_wallet` 及结算域各表(代理分佣与联营跑批)。
### 2.3 建议先做的:第一批后台菜单(与核心能力对齐)
在 BuildAdmin **`admin_rule`** 中,**优先保证**与下列能力相关的菜单与按钮权限可配置(具体 `component` 路径以仓库为准):
| 优先级 | 菜单能力 | 目的 |
|--------|----------|------|
| 高 | **渠道**、**游戏用户**、**角色组 / 管理员**(含渠道与邀请码相关字段) | 搭好「谁管哪条线、玩家归谁」 |
| 高 | **用户管理 / 用户钱包流水** | 覆盖玩家主数据与账务对账 |
| 高 | **游戏对局 / 压注订单** | 核心玩法与对账入口 |
| 中 | **游戏配置 / 36字花字典** | 运营参数与字典维护 |
| 中 | **充值订单 / 提现订单** | 充提流程管理与审核 |
| 低 | **公告 / 站内信**、**代理结算** 等 | 属 **P3P4**,核心跑通后再接 |
### 2.4 明确放到「细节阶段」的能力(勿阻塞第一期)
下列项依赖 **P1 状态机、定时任务、前端 C 端页面** 较完整后再做,避免与「账号 + 余额 + 最小下注」抢工期:
- **玩法细则**选号上限、连胜倍率、Jackpot 条件等在 `game_config` 与代码中逐步细化即可。
- **自动开奖 / 调度**依赖期号服务、Redis 与可选MQ表结构可先建**调度与算票逻辑后迭代**。
- **游戏公告、强弹窗、站内信展示**:表可后建;**后台编辑 + C 端展示** 属于运营体验增强,放在核心闭环之后。
### 2.5 最小可验收里程碑(建议)
1. **P0 完成**:能创建渠道与子代理管理员,玩家归属正确,`coin``user_wallet_record` 手工或脚本入账可对上。
2. **P1 骨架完成**:能创建期号、写入注单、产生钱包流水(即使开奖仍为**手动或脚本模拟**)。
3. 再推进 **自动流程、充提、公告展示****P3 结算**
### 2.6 本次重构迁移顺序(已执行口径)
1. 新建订单表:`deposit_order``withdraw_order`(带 `order_no` 唯一索引、`user_id/status/create_time` 常用索引)。
2. 硬重命名:`game_user -> user``game_bet_order -> bet_order`
3. 重建菜单与权限目录:`user``order``game``config``record`
4. 新增菜单节点:
- `user/user`
- `order/betOrder``order/depositOrder``order/withdrawOrder`
- `game/period`
- `config/gameConfig``config/ziHuaDictionary`
- `record/userWalletRecord`
5. 清理收口:删除迁移过程中的冗余权限别名规则,仅保留最终模块节点(`user/order/game/config/record`)。
---
## 三、`channel` 设计(替代 `game_channel`
### 3.1 建议保留字段
`id``code`(可作渠道码)、`invite_code``name``status``remark``admin_id``admin_group_id``create_time``update_time`
### 3.2 建议新增或调整(按你确认口径)
| 方向 | 说明 |
|------|------|
| 顶级代理归属 | `top_admin_id`:关联到顶级代理管理员 `admin.id` |
| 代理模式参数 | `agent_mode``turnover` 普通刷水 / `affiliate` 联营)、`turnover_share_rate``affiliate_share_rate``affiliate_fee_rate``carryover_balance` |
| 渠道经营指标 | `profit_amount`(精度升级)、`total_profit_amount``commission_pool_amount` |
| 邀请码参数 | 渠道不生成邀请码,邀请码由管理员自动生成并用于注册归属 |
| 代理钱包 | 余额可字段扩展或独立 `game_agent_wallet`(推荐独立表,便于对账) |
### 3.3 注意
`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。除超管外,渠道管理仅可查看当前管理员归属渠道。
---
## 四、`user` 适配(用户表)
### 4.1 建议保留字段
`id``username` / `phone``password``uuid``head_image``channel_id`(归属渠道)、`status``create_time``update_time`
### 4.2 建议新增或调整
| 方向 | 说明 |
|------|------|
| 金额精度 | `coin` 调整为 **`decimal(18,4)`**(与规格书一致,禁止用浮点存币) |
| 注册与归属 | `invite_code` 或注册时解析 URL 与 `channel.code` 绑定 |
| 提现门槛 | `total_deposit_coin``total_valid_bet_coin` 或等价的「待完成流水」字段(全项目统一一种口径) |
| 风控 | 禁止登录 / 禁止下注 / 禁止提现等,可用位标记或独立布尔字段 |
| 连胜持久化 | 可选 `current_streak``last_bet_period_id`;高频仍以 **Redis** 为准DB 作兜底同步 |
| 邮箱 | PRD 支持手机或邮箱注册时,可增加可空字段 `email`(是否唯一索引按产品定) |
---
## 五、`admin` 适配(子代理承载)
| 方向 | 说明 |
|------|------|
| 层级关系 | `parent_admin_id` 表示上级代理 |
| 渠道归属 | `channel_id` 表示该子代理属于哪个顶级渠道 |
| 邀请管理 | `invite_code` 自动生成且全局唯一,用于发展玩家并做归属绑定 |
| 角色标识 | `agent_role`(如 `agent_admin` / `sub_agent` / `staff` |
| 分红设置 | `commission_rate`百分比0-100最多两位小数管理员角色组改为单选同一角色组下管理员分红比例总和不超过100% |
| 开奖权限 | 不在数据层开放给渠道/子代理;开奖权限仅由超管 RBAC 控制 |
### 5.1 角色组与管理员分红计算口径
- 角色组分红:`当前角色分红 = 渠道设置获取分红 × (1 - 上级角色分红比例) × 当前角色分红比例`
- 管理员分红:`当前管理员分红 = 当前角色分红 × 当前管理员分红比例`
- 校验约束:
- 同一父级下角色组分红比例总和 `<= 100%`
- 同一角色组下管理员分红比例总和 `<= 100%`
---
## 六、`admin_group` 适配(角色组仅归属渠道)
| 方向 | 说明 |
|------|------|
| 渠道归属 | 增加 `channel_id` 字段,角色组只属于一个渠道 |
| 管理边界 | 渠道管理员只能增删改查本渠道 `admin_group` |
| 系统角色组 | `channel_id` 可为空表示系统级(仅超管可见) |
---
## 七、需新建的核心表
表名可按项目命名规范微调,须小写加下划线。
### 7.1 游戏引擎
**对局范围**:与 §1.1 一致——**全平台一套期号**,不按渠道分桌;详见 `game_period``channel_id` 的 DDL。
| 表名 | 用途 |
|------|------|
| `game_period`(或 `game_issue` | **全局**期号、状态机阶段、开奖号码、自动/手动、作废标记(**非**每渠道一局) |
| `bet_order` | 注单:关联全局 `period_id`;可选 `channel_id` 为用户归属快照(分润/查询),**非**独立牌桌 |
| `game_bet_auto`(可选) | 自动托管:剩余局数、选号快照、启停状态(仍挂在全局期号下) |
### 7.2 系统配置
| 表名 | 用途 |
|------|------|
| `game_config`(或统一 `system_config_kv` | 局时长、下注时长、选号上限、赔率步长、Jackpot 参数、RTP 放水、提现倍数与手续费等(须 DB 可配,禁止写死) |
### 7.3 资金与账本(强烈建议)
| 表名 | 用途 |
|------|------|
| **`user_wallet_record`**(原草案名 `user_wallet_ledger`,以 DDL 为准) | **玩家游戏币钱包流水**:充值、提现(含冻结/解冻)、平台划入划出、管理员加扣币、下注、派彩、手续费、作废退款、调账等;金额 **`decimal(18,4)`****只增不改**;与 `user.coin` 变更须同事务 + 条件更新 |
| `deposit_order` | 第三方充值、法币金额、汇率、游戏币到账、回调状态 |
| `withdraw_order` | 提现申请、手续费、审核状态、Jackpot / 大额标记 |
### 7.4 代理与结算
| 表名 | 用途 |
|------|------|
| `agent_settlement_period` | 结算周期、大盘盈亏快照 |
| `agent_commission_record` | 流水占比分桶结果、级差拆分、发放状态 |
| `affiliate_contract`(可选) | 联营契约、占成比例、阶梯条件 |
| `affiliate_carryover`(或与佣金记录合并设计) | 联营负结转余额与流水 |
### 7.5 运营
| 表名 | 用途 |
|------|------|
| `operation_notice` | 公告类型(静默 / Pop-out、内容 |
| `user_notice_read` | 用户已读 / 强弹窗已确认 |
### 7.6 审计(按需)
若现有 `admin_log` 不足以覆盖财务与风控操作,可增补结构化审计表。
### 7.7 冷热分离(中后期)
历史注单与流水检索迁移至 **ElasticSearch 或 ClickHouse**,主库保留近期热数据与归档策略。
---
## 八、验证步骤
### 8.1 数据库与模型
1. DDL 执行后检查:金额字段均为 **`decimal(18,4)`**`channel``user``admin``admin_group`、期号等关联字段有合理索引。
2. 创建测试渠道(顶级)→ 创建子代理 `admin`(设置 `parent_admin_id``channel_id``invite_code`)→ 创建用户并挂载 `channel_id`,链路与后台筛选正常。
3. 扣款更新采用「**条件更新**」语义(如 `WHERE balance >= 扣款额`),压测或单测验证**不出现负余额**。
### 8.1.1 菜单与权限回归(本次新增)
1. 使用超管登录,确认五大目录 `user/order/game/config/record` 可见。
2. 使用普通角色登录,逐项验证按钮权限(`index/add/edit/del/save`)是否与授权一致。
3. 抽查重点页面:
- `user/user`
- `order/depositOrder``order/withdrawOrder``order/betOrder`
- `game/period`
- `config/gameConfig``config/ziHuaDictionary`
- `record/userWalletRecord`
4. 验证 `config/ziHuaDictionary` 的读取与保存权限均命中,且仅授权角色可操作。
### 8.2 业务规则(对照 PRD / 业务流程)
| 场景 | 验证要点 |
|------|----------|
| 注册与邀请 | 玩家使用子代理邀请码注册时,先命中 `admin.invite_code`,再落到对应 `channel_id` |
| 子代理权限 | 渠道与子代理账号均无开奖权限;仅超管角色可访问开奖控制接口 |
| 30 秒节奏 | 020s 可下注20s 后拒绝;约 22s 后可查开奖结果 |
| 连胜与跳局 | 连续期号未下注则连胜清零;连赢时本轮总下注不超过上轮总下注 |
| Jackpot | 连胜达阈值且单局下注不低于配置最小额才触发额外大奖 |
| 提现 | 有效投注 ≥ 总充值×配置倍数;最低提现与 0.5% 手续费;大额 / Jackpot 进审核 |
| 灾难恢复 | 模拟期卡在「计算中」,启动退本流程:期作废、本金退回账本 |
### 8.3 代理与结算(逻辑就绪后)
1. 构造多代理树与多用户下注,跑一期结算脚本或单元测试纯算法。
2. **大盘亏损**:本期代理分佣为 0。
3. **大盘盈利**:流水占比分桶 + 级差,各级金额之和与线总包一致(可对照文档数值验算)。
4. **联营**:客损为负产生负结转;下期盈利先抵扣再分佣。
### 8.4 非功能
- Redis当前期注单池、倒计时、近 30 期开奖缓存。
- MQ派彩异步消费**幂等**(如 `period_id` + 用户 + 业务单号)。
- API下注、提现等核心接口**限流**生效。
---
## 九、风险与依赖
1. **子代理数据源**:子代理统一在 `admin`,避免在 `channel` 重复建树造成双主数据源。
2. **现有 `profit_amount`decimal(5,2)**:与游戏币 **18,4** 精度不一致,演进时改为 `decimal(18,4)` 或迁移至结算域,避免对账误差。
3. 文档要求 **AI 算票在 Redis 内完成**,主库避免结算期同步锁表;账本与注单以异步一致性与幂等为准。
4. **全局对局一致性**:任何需求(多租户展示、渠道后台)均不得引入「按渠道独立期号/独立开奖」;若出现产品歧义,以 **§1.1** 为准,避免公平性质疑与客诉。
---
## 十、相关文档索引
| 文档 | 说明 |
|------|------|
| `docs/36字花prd.md` | 玩法、代理双轨、风控与性能要求 |
| `docs/业务流程.md` | C 端、代理、联营、总控、灾难恢复流程 |
| `docs/后端.md` | 状态机、API、后台模块、安全与精度 |
| `docs/CRUD生成逻辑说明.md` | BuildAdmin CRUD 路径与表名规范 |
| `database/dfw_36zihua_schema_v1.sql` | 具体 DDL字段、索引、注释 |
### 10.1 新建表一览(字段以 SQL 为准)
| 序号 | 表名 | 说明 |
|------|------|------|
| 1 | `game_config` | 动态参数 KV |
| 2 | `game_period` | 期号与状态机 |
| 3 | `bet_order` | 注单 |
| 4 | `game_bet_auto` | 自动托管 |
| 5 | `user_wallet_record` | 玩家游戏币钱包流水(替代旧名 `user_wallet_ledger` |
| 6 | `deposit_order` | 充值订单 |
| 7 | `withdraw_order` | 提现订单 |
| 8 | `game_agent_wallet` | 代理钱包 |
| 9 | `game_agent_wallet_ledger` | 代理钱包流水 |
| 10 | `agent_settlement_period` | 结算周期与大盘快照 |
| 11 | `agent_commission_record` | 代理佣金明细 |
| 12 | `affiliate_contract` | 联营契约 |
| 13 | `affiliate_carryover` | 联营负结转 |
| 14 | `operation_notice` | 运营公告 |
| 15 | `user_notice_read` | 公告已读/确认 |
| 16 | `user_site_message` | 站内信 |
**既有表变更**:新建 `channel` 替代 `game_channel``admin` 增加子代理字段(`parent_admin_id``channel_id``invite_code``agent_role`)用于代理邀请链路管理;`admin_group` 增加 `channel_id` 实现角色组按渠道隔离;`game_user` 已硬重命名为 `user``game_bet_order` 已硬重命名为 `bet_order``user_wallet_record``game_config` 保持现名。
---
**变更记录**
| 版本 | 日期 | 说明 |
|------|------|------|
| V1.0 | 2026-04-14 | 初稿:表适配、新建表清单、验证步骤 |
| V1.1 | 2026-04-14 | 增加可执行 DDL 脚本路径与新建表一览 |
| V1.2 | 2026-04-14 | 修正口径:`game_channel` 仅顶级分红参数,子代理迁移至 `admin` |
| V1.3 | 2026-04-14 | 收敛权限:仅超管可开奖,渠道/子代理仅拉用户 |
| V1.4 | 2026-04-14 | 新建 `channel` 替代 `game_channel``admin_group` 按渠道隔离 |
| V1.5 | 2026-04-15 | 落地 `game_user_wallet_record` 玩家钱包流水表;文档增补字段说明附录;与 DDL 统一命名 |
| V1.6 | 2026-04-15 | 增加「先核心后细节」实施顺序:先建表/菜单清单、可暂缓表与细节阶段能力;修正章节编号(验证步骤改为第八章) |
| V1.7 | 2026-04-15 | 明确「多渠道、单场对局」:全平台共用 `game_period` 与同一开奖;渠道仅归属/分润;附录 `game_period`/`bet_order` 注释对齐 |
| V1.8 | 2026-04-15 | §7.1 游戏引擎表与 §1.1 对齐:显式说明全局期号、`bet_order.channel_id` 仅为归属快照 |
| V1.9 | 2026-04-15 | 落地模块化重构:`game_user->user``game_bet_order->bet_order`;新增 `deposit_order`/`withdraw_order`;后台菜单重组为 `user/order/game/config/record` 五大目录 |
| V1.10 | 2026-04-15 | 完成表名与权限规则收口:`user_wallet_record``game_config` 保持不变;移除冗余 snake alias 菜单规则,文档口径与线上结构一致 |
---
## 十一、附录:主要表字段说明(与 `database/dfw_36zihua_schema_v1.sql` 同步)
以下为各表**字段含义与使用要点**,便于评审与联调;**以仓库内 DDL 为最终口径**。
### 11.1 `channel`(渠道 / 顶级代理分红参数)
| 字段 | 作用 |
|------|------|
| `id` | 主键 |
| `code` | 渠道标识,业务唯一 |
| `invite_code` | 渠道侧邀请码(与 PRD「邀请码由管理员生成」并存时以产品定义为准 |
| `name` | 渠道名称 |
| `agent_mode` | `turnover` 普通刷水 / `affiliate` 联营,决定分红与契约字段使用方式 |
| `profit_amount` / `total_profit_amount` / `commission_pool_amount` | 经营与分红池快照类金额,**decimal(18,4)** |
| `turnover_share_rate` / `affiliate_share_rate` / `affiliate_fee_rate` | 分红与联营费率类参数 |
| `carryover_balance` | 联营负结转余额(可负) |
| `top_admin_id` | 顶级代理管理员,关联 `admin.id` |
| `admin_id` / `admin_group_id` | 创建人、渠道绑定的角色组 |
| `status` / `remark` / `create_time` / `update_time` | 状态、备注、时间戳 |
### 11.2 `user`C 端玩家)
| 字段 | 作用 |
|------|------|
| `coin` | 当前游戏币余额,**decimal(18,4)**,更新须条件更新防负余额 |
| `channel_id` | 归属渠道;历史 `game_channel_id` 若仍存在,迁移期注意双写/对照 |
| `register_invite_code` | 注册时邀请码快照,用于审计与归属 |
| `total_deposit_coin` / `total_valid_bet_coin` | 累计充值入账、累计有效投注;提现流水倍数校验用 |
| `risk_flags` | 风控位(如禁止登录/下注/提现,按位定义) |
| `current_streak` / `last_bet_period_no` | 连胜与期号兜底;高频仍以 Redis 为准 |
| `admin_id` | 归属子代理管理员 |
| 其余 | 账号、头像、状态、时间戳等见 DDL |
### 11.3 `admin` / `admin_group`(子代理与角色组)
| 表/字段 | 作用 |
|---------|------|
| `admin.parent_admin_id` | 子代理上下级 |
| `admin.channel_id` | 所属渠道 |
| `admin.invite_code` | 子代理邀请码,注册归属 |
| `admin.agent_role` | 角色类型(均无开奖权) |
| `admin_group.channel_id` | 角色组归属渠道;`NULL` 可为系统级(仅超管) |
| `admin_group.commission_rate` | 角色组分红比例(百分比) |
### 11.4 `game_config`(动态参数)
| 字段 | 作用 |
|------|------|
| `config_key` | 全局唯一键 |
| `config_value` / `value_type` | 参数值及类型(含 JSON |
| `remark` | 说明,禁止业务写死应读此表 |
### 11.5 `game_period` / `bet_order` / `game_bet_auto`
| 表 | 要点 |
|----|------|
| `game_period` | **全平台唯一**期号:`period_no` 全局唯一;`status` 状态机;开奖结果与作废原因。**无 `channel_id` 字段**:对局不按渠道拆分。 |
| `bet_order` | 注单关联**全局** `period_id``channel_id`(若有)为**用户/归属快照**,便于分润与查询,**不表示**独立牌桌或独立开奖。`idempotency_key` 幂等;金额 **18,4**`win_amount` / `jackpot_extra_amount` |
| `game_bet_auto` | 托管剩余局数、选号快照、抖动时间等;仍挂在**全局**期号下 |
### 11.6 `user_wallet_record`(玩家钱包流水)
| 字段 | 作用 |
|------|------|
| `user_id` | 玩家 `user.id` |
| `channel_id` | **账务发生时**渠道快照,便于按渠道查询与对账;入账逻辑应写入 |
| `biz_type` | 业务类型:`deposit``withdraw``platform_in``platform_out``admin_credit``admin_deduct``bet``payout``fee``void_refund``adjust` 等(字符串枚举,与代码常量一致) |
| `direction` | `1` 入金 / `2` 出金;与 `amount` 恒正配合使用 |
| `amount` | 变动额,**恒正** |
| `balance_before` / `balance_after` | 变动前后余额,对账与审计 |
| `ref_type` / `ref_id` | 关联订单或业务主键(如 `deposit_order``bet_order` |
| `idempotency_key` | 幂等键,防重复记账(唯一索引,允许多 `NULL` |
| `operator_admin_id` | 人工加扣币时的操作者 `admin.id` |
| `remark` | 说明 |
| `create_time` | 流水时间;**本表不设 `update_time`,禁止 UPDATE** |
### 11.7 `deposit_order` / `withdraw_order`
| 表 | 要点 |
|----|------|
| `deposit_order` | 法币、`fx_rate``coin_amount`、网关单号、回调存档 |
| `withdraw_order` | 申请额、手续费、审核与 Jackpot 标记、驳回原因 |
### 11.8 代理钱包与结算域(`game_agent_wallet`、`game_agent_wallet_ledger`、`agent_settlement_period`、`agent_commission_record`、`affiliate_*`
见 DDL 注释:代理余额与流水、结算周期大盘快照、佣金行、联营契约与负结转;金额均为 **decimal(18,4)**,与玩家账本区分职责。
### 11.9 运营与消息(`operation_notice`、`user_notice_read`、`user_site_message`
公告类型(含强弹)、已读确认;站内信与公告分离存储,详见各表 `COMMENT`

164
docs/36字花prd.md Normal file
View File

@@ -0,0 +1,164 @@
# 《"36字花" 数字竞猜游戏功能需求与详细流程规格说明书 (PRD)》
**文档版本**V 1.0 (最终定稿版)
**项目定位**高频30秒/期)、高爆率、多国本地化数字竞猜平台
## 一、 系统全局架构与底层规范
### 1.1 多币种与游戏币体系(统一结算层)
为支持多国市场无缝拓展,系统底层不直接使用法币(如 RM、IDR进行业务流转
* **充值兑换**:用户使用本地法币充值,资金到账后,系统根据实时/固定汇率自动转化为平台统一的**游戏币Coins/Credits**。
* **前端展示**:所有下注、余额、开奖金额均显示为统一的“游戏币图标 + 数值”(如 💎 100
* **提现结汇**:提现时,系统将游戏币按提现国的当前汇率反向换算为本地法币打款。
* **技术优势**代理分佣、AI算票引擎完全剥离汇率波动仅对游戏币进行纯数学计算极大降低系统复杂度。
### 1.2 分布式站点架构
系统拆分为三个独立的前端与一个核心后端服务:
1. **用户前端 (User Portal)**H5/Web响应式面向玩家游玩。
2. **代理后台 (Agent Portal)**:面向总代理/子代理的数据与分佣端。
3. **总控后台 (Admin Portal)**:面向平台运营方的全局管控端。
### 1.3 域名与防封安全架构
* 采用 **方案A反向代理 + CDN 隐藏源站) + 方案D域名轮换池 Domain Pool** 的组合方案。确保源站IP完全隐藏入口域名被封时可秒级切换备用域名。
---
## 二、 核心玩法与游戏机制
### 2.1 36字花字典对照表
系统包含36个字号分为生肖、猛兽、飞禽、虫蛇、神兽五大类。前端需展示“编号+名称+专属插画”。
| 编号 | 名称 | 编号 | 名称 | 编号 | 名称 |
| :---: | :---: | :---: | :---: | :---: | :---: |
| 01 | 鼠 | 02 | 牛 | 03 | 虎 |
| 04 | 兔 | 05 | 龙 | 06 | 蛇 |
| 07 | 马 | 08 | 羊 | 09 | 猴 |
| 10 | 鸡 | 11 | 狗 | 12 | 猪 |
| 13 | 狮 | 14 | 象 | 15 | 鹿 |
| 16 | 熊 | 17 | 狼 | 18 | 豹 |
| 19 | 鹰 | 20 | 鹤 | 21 | 孔雀 |
| 22 | 鸳鸯 | 23 | 鸵鸟 | 24 | 天鹅 |
| 25 | 蟾蜍 | 26 | 蜘蛛 | 27 | 蝙蝠 |
| 28 | 蜈蚣 | 29 | 蝎 | 30 | 蛇蜥 |
| 31 | 麒麟 | 32 | 凤凰 | 33 | 青龙 |
| 34 | 白虎 | 35 | 朱雀 | 36 | 玄武 |
其中 01-12 为十二生肖13-18 为猛兽类19-24 为飞禽类25-30 为虫蛇类31-36 为神兽类。
### 2.2 游戏节奏30秒循环制
每局精确耗时30秒分为两个绝对阶段
* **【常态阶段 / 下注期】 (0-20秒)**
* 倒计时 20 秒启动,接收玩家下注。
* **UI表现**:前端界面的 36 个格子上的高亮光圈,呈现**持续不断、不规则、快速的随机跳动**,营造强烈的赌场氛围。
* **【封盘开奖期】 (20-30秒)**
* **第 20.0 秒**:准时锁盘。在此之后到达服务器的任何延迟请求(如因网络卡顿),直接**拒单退回(提示:已封盘,下注失败)**。
* **第 20-22 秒AI算票**AI 引擎瞬间完成全网盈亏计算并得出结果。
* **第 22-25 秒(开奖动画与异步派彩)**:后端在第 22.0 秒得出结果后,立刻将派彩结算指令打入 MQ 异步执行。同时前端高亮光圈加速跳动配合紧张心跳音效。在3秒内不规则快速跳动后“啪”地一声**定格**在最终中奖号码上。
* **第 25-30 秒(结算展示)**:前端通过轮询/推送接收到余额更新通知,开始播放中奖者全屏/半屏的**金币喷洒动画(伴随爆金音效)**,弹出醒目提示【恭喜获得 💎 XXX】。
### 2.3 下注交互与限制
* **便捷筹码**:提供 💎1, 5, 10, 25, 50, 100 的快捷筹码按钮。
* **下注限制**:默认每场最多选 **5个数字**(后台可配)。单局内所选的每个数字,下注金额必须一致。
* **单注上限**后台须配置“单号最高下注额Max Bet Limit如 💎500以对冲超级连赢带来的数学风险。
* **快捷操作**
* **重复上一注 (Re-bet)**:一键复制上一局的选号和金额并提交。
* **自动托管 (Auto-Spin)**:允许设定连续下注 N 局。采用**服务端执行、逐局扣款**机制。开启后,服务器将在每局的 **00:00 - 00:10 秒之间(引入随机抖动,防止并发风暴)**平滑地为所有托管玩家自动扣款下注,直至局数完成或余额不足。
---
## 三、 连续中奖 (Streak Bonus) & 大奖 (Jackpot) 规则
### 3.1 赔率累加机制
* 基础赔率为 **1:33**
* 玩家连续中奖时赔率累加第1场 1:33连中2场 1:66连中3场 1:99... 依此类推。
* 只要某场未中奖(中断),连续记录清零,赔率重置为 1:33。
* **【跳局处理规则】**:出于提高活跃度和平台流水考虑,**连胜必须基于系统的“连续期号”判断**。若玩家在连胜期间,主动跳过了某一局(未下注),则连胜状态**直接强制清零**。
### 3.2 连赢期间的“换注限制”
为了防止玩家利用连赢的高赔率恶意“洗盘梭哈”:
* **下注金额锁定**:若第 N 轮中奖,则第 N+1 轮的**总下注金额上限**严格锁定为第 N 轮的实际总下注额。上轮总共下了💎100本轮最多只能下💎100若本轮只下了💎10并中奖下一轮上限将降为💎10
* **选号策略自由**:在总金额不超限的前提下,**允许玩家改变选字的数量**。例如上一轮花💎100买了5个字本轮可以花💎100全押在1个字上。
### 3.3 Jackpot 百万大奖触发
* 当玩家连续中奖达到 **10 场** 时触发。
* **触发门槛与限红**为防止利用极小注额套取大奖系统设定【Jackpot资格最小下注额】如单注连续 ≥ 💎50。若连胜中有任何一局未达大奖基准入场额连胜仅享受常规多倍赔率**不触发额外 Jackpot 大奖**。
* **派发逻辑(额外叠加)**达标玩家正常获得第10场应得的常规连赢派彩系统将在此基础上**额外发放 Jackpot 大奖(如 💎100万**。
* 触发后连续中奖场次重置为0。
---
## 四、 AI算票风控与开奖系统
### 4.1 自动开奖引擎(利润最大化 + 随机放水)
* **第一优先级**AI在封盘后遍历36个数字的全局赔付包含当前所有人的连胜倍率计算优先开出让平台**总赔付最少(利润最大)**的数字。
* **RTP 全局风控放水池**
* 系统累积平台抽水利润的固定比例进入“放水池”。
* 当开出某个高赔付(如触发别人连胜或大奖)造成的亏损 **小于** 当前放水池余额时AI 有一定概率后台可配如15%)主动开出该数字,制造真实大奖作为营销案例。
### 4.2 手动开奖机制
* 管理员在后台一键切换至“手动模式”。
* 为了避免 10 秒开奖期的操作延误,管理员必须在**下注倒计时期间前20秒内**提前选定本局必开数字。20秒锁盘后系统直接开出预设号码。
### 4.3 灾难恢复 (Disaster Recovery)
* 若系统在某局运行中途如第25秒遭遇断电或宕机崩溃。
* 服务器重启后的首要动作是自动执行 **“作废退本 (Void & Refund)”** 脚本:该期残局作废,退回所有玩家本局投注的本金,杜绝暗箱操作嫌疑。
---
## 五、 代理体系:普通刷水代理与联营合营代理双轨制 (Agent & Affiliate System)
为了适应不同渠道的推广需求,系统代理账号不开放前台自助注册(必须由后台人工创建或通过专属代理邀请码裂变),并支持**两条平行的代理分佣路线**
### 5.1 普通刷水代理(以流水分桶为导向)
这是一种不承担玩家赢钱风险的基础代理模式。
1. **大盘盈亏判断**:周期结算时,优先计算全平台总盈亏。若全平台整体亏损或平局,则该周期内所有此类代理不分佣。
2. **计算流水占比**:若全平台盈利,则按某代理整条线下注流水占全平台总流水的百分比,分配资金池。(*应分红 = 平台总盈利 × 该线下注流水占比%*)。
3. **无负结转优势**:即便该代理名下的玩家赢了钱,只要全平台大盘是盈利的,其依然可按流水占比分佣。
### 5.2 多级拨比机制
在上述“分桶利润分配”算法中系统支持无限线代理树的极差分账。例如GM 给出的盘口总分红给到顶部总代的是 80%,当这个总代拿手上的分红比例拨给下级时,若他给 70%,则这位下级能拿单线利润基数的 70%,总代由于极差卡段,自己这条线只能拿到 10%80%-70%)的差额利润。这个级差裂变能一直往下分直到没有 % 为止。
### 5.3 联营代理模式 (Affiliate Agent / 客损占成分成)
这是专为有强大客源且愿意与平台**“共担风险、共享高利润”**的高级代理设计的模式(即行业标准的**联营 / 占成代理**)。
* **角色定位**:联营代理脱离于普通流水池,直接对其名下所有会员的总客损(净输赢)负责。
* **净亏损分润**:结算时,系统统计该联营代理辖区内的【玩家总输赢】,扣除平台抽水与通道费后得出【净客损】。联营代理按配置的**占成比例**(如 50%)直接分走这笔客损。
* **负结转 (Negative Carryover)**:这是联营核心机制。如果该联营代理名下的玩家在当期大赢,导致结算时客损为负(代理亏钱),该亏损数字将“结转”至该代理的下期账单中。在后续周期里,必须先用玩家的输额填平该负结转账单后,代理才能恢复分红提现。
---
## 六、 用户与财务风控流程
### 6.1 用户交互与注册
* 只需手机号/邮箱+密码注册,**无需 KYC 实名认证**。
* **路子图 (走势图)**:前端界面底部提供类似百家乐大路/珠盘路的走势图。以数字区分:单数使用**红色图标**Odd双数使用**蓝色图标**Even仅保留最近30期。
### 6.2 平台公告与运营触达流程
为保证由于赔率调整、节日活动或临时维护等重要信息能 100% 触达玩家,前端具备两级触达机制:
* **静默收件箱 (Silent Mailbox)**:常规活动或系统通知直接派发至用户的【公告信箱/站内信】UI 仅显示红点提示,不中断玩家游戏体验。
* **强阻断强制公告 (Pop-out Notification)**:具有最高优先级。运营在后台下发此级公告后,无论玩家在任何界面、或者新用户首次登录,系统立即在屏幕中央弹出该公告弹窗。玩家**必须手动勾选“已阅读并同意”**后才可关闭弹窗并继续游戏下注。
### 6.3 财务风控与提现
* **提现门槛**:用户必须完成历史总充值额 **1倍流水** 的有效下注方可提现,防止恶意洗钱。
* **提现限制**:最低提现 💎100系统自动扣除 0.5% 手续费。
* **人工审核强阻断**:任何触发 **100万 Jackpot** 或达到系统设定的超大额提现标准的资金,不会直接进入可用余额,而是进入“冻结/审核中”状态。必须由总后台管理员核查 IP 与下注流水,点击【通过】后方可打款。
### 6.4 历史数据展示限制
* 前端的下注记录、开奖记录仅展示**最近 1 个月**的数据。
* 历史更久远的数据前端做隐藏处理,减少数据库连表查询压力。
---
## 七、 技术性能要求规范
由于“30秒/期”属超高频高并发场景,系统架构必须满足以下规范:
1. **冷热数据分离架构**
* 必须引入 **Redis** 内存数据库用于存放最新30期开奖记录、当前进行中的下注队列、以及玩家当前的连赢Streak状态。确保 AI 引擎 2 秒内完成算票。
* 引入 **ElasticSearch 或 ClickHouse** 存储海量历史注单与资金流水,与交易主库隔离开来。
2. **异步派彩机制**
* 开奖后的批量资金入账、连胜进度更新、以及站内信发送,必须采用 **消息队列 (Kafka / RabbitMQ)** 异步削峰处理。严禁在 10 秒结算期内使用数据库同步强锁表,防止并发雪崩。
3. **API 频控 (Rate Limiting)**
* 用户端及代理端所有核心 API如下注、提现申请必须配置严格的频率限制防止恶意脚本撞库或刷单。

View File

@@ -2,7 +2,7 @@
# 将 server_name 和 root 改为实际值后,放入 nginx 的 conf.d 或 sites-available # 将 server_name 和 root 改为实际值后,放入 nginx 的 conf.d 或 sites-available
upstream webman { upstream webman {
server 127.0.0.1:8787; server 127.0.0.1:7979;
keepalive 10240; keepalive 10240;
} }

168
docs/业务流程.md Normal file
View File

@@ -0,0 +1,168 @@
# 🚀《"36字花" 全链路业务流程说明书 (Business Flow Document)》
**文档版本**V 1.0 (最终定稿版)
**适用对象**后端开发工程师、前端开发工程师、QA 测试工程师、系统运营人员
---
## 模块一C端用户生命周期与游戏流程 (User Flow)
### 1.1 新用户注册与登入流程 (Onboarding Flow)
1. **获取链接**用户通过点击代理的专属推广链接URL携带代理专属 Invite Code进入注册页。
2. **极简注册**:用户输入 手机号/邮箱 + 密码 进行注册(**系统不要求上传身份证进行 KYC 实名认证**)。
3. **绑定归属**:系统自动将该新用户挂载到对应代理的下线树状图中。
4. **强阻断公告触达**
* 用户首次登录成功,进入主界面前。
* 系统弹出【强制公告信箱】(展示平台规则、近期活动)。
* 必须勾选 `[ √ ] 我已阅读并同意`,否则进入游戏的按钮为灰色不可点击状态。
* 勾选并点击后,正式进入游戏主大厅。
### 1.2 资金充值转换流程 (Deposit & Exchange Flow)
1. **发起充值**:用户点击顶部 `[+] 充值`,选择本地法币(如 RM和金额例如充值 RM 100
2. **网关支付**:跳转至第三方 Payment Gateway 完成支付。
3. **回调与转换**
* 第三方网关向系统发送成功回调。
* 系统底层执行**汇率转换**(如 RM 1 = 💎 1将法币转化为平台统一的【游戏币】。
4. **资金入账**:用户界面余额更新为 💎 100并产生一条充值流水记录。系统记录该用户的“需完成流水额度”即 💎 1001倍提现门槛
### 1.3 🌟 核心游戏交互流程单局30秒标准生命周期
这是系统最高频运转的流程每30秒循环一次
* **阶段 A下注期 (00:00 - 00:20)**
1. 用户在 36宫格 挑选心仪的数字(上限 5 个)。
2. 用户在下方选择筹码面额(如 💎 10。**系统强校验**:所有已选数字将被统一设置为 💎 10。
3. **确认防呆**:用户点击右下角发光的 `[ ✅ 确定下注 ]` 按钮。
4. **资金扣除**:系统校验余额是否充足,若充足则实时扣除余额,并在选中的格子上打上“锁定”印章。此时注单正式生效写入数据库。
* **阶段 B封盘与算票期 (00:20 - 00:22)**
1. 第 20.0 秒准时封盘。界面变灰,提示 `[停止下注]`
2. 系统拒收任何晚于此时间的下注请求。
3. **AI 算票执行**AI 瞬间遍历 36 个数字的全局赔付表,结合 RTP 放水池概率,选定本期开奖数字(详见 3.1 节)。
* **阶段 C开奖与结算期 (00:22 - 00:30)**
1. **异步派彩 (00:22)**:后端得出结果立刻扔进消息队列更新数据、派发奖金、计算连胜次数(主动跳局未下注的玩家也会被清零连胜)。
2. **前端动画 (00:22 - 00:25)**:前端同步收到倒计时状态与定格数字,播放 3 秒紧张的快速跳动转盘动画。
3. **结算展示 (00:25 - 00:30)**:弹出定格的开奖结果。前端轮询接收最新的资金状态更新,播放金币爆破与余额暴增动画。
### 1.4 特殊托管流程:自动下注 (Auto-Spin Flow)
1. 用户在前端设定:托管 50 局,每局选 【08羊16熊】每注 💎 10。点击启动。
2. 前端界面进入暗色“锁定态”。
3. **服务端接管**:用户的请求下发给后端。此后每一局的前段(**引入 00:00 - 00:10 秒的平滑随机分布,防服务器高并发崩溃**),服务器自动在后台帮用户扣款并生成注单。
4. **中断条件**:当达到 50 局、或用户中途点击 `[ 🛑 停止 ]`、或用户余额不足以支付单局总注时,托管脚本自动终止。
### 1.5 资金提现与风控流程 (Withdrawal Flow)
1. **发起提现**:用户点击提现,输入金额(最低限制 💎 100
2. **前置系统校验**
* 余额是否充足?
* **流水校验**:该用户的历史总有效投注额,是否达到了历史总充值额的 1 倍?(未达标则驳回并提示)。
3. **扣除手续费**:系统自动扣除 0.5% 的手续费(例如提现 100实际申请出金金额为 99.5)。
4. **大额/安全拦截**
* 若提现金额超过后台设定的免审阈值,或该资金来源于 Jackpot 百万大奖。系统自动将提现单挂起,状态为 `[待人工审核]`
5. **财务打款**后台管理员审核通过后调用网关接口按实时汇率将游戏币转换回当地法币RM打入用户银行卡。
6. **成功入账**:流转完成,前端发送提现成功站内信。
---
## 模块二:代理端业务与分佣流程 (Agent Flow)
由于采用防刷机制,系统**不支持** C端用户自助升级为代理。
### 2.1 代理开户与层级搭建流程
1. **创设总代**:平台最高管理员在 Admin 后台,创建一个【总代 Master Agent】账号并设定 GM 抽成比例(如平台截留 20%,总代享有 80% 的分红权)。
2. **总代拓客**:总代登录 Agent Portal生成自己的推广链接发给散户或者生成【子代专属邀请码】发展下级代理。
3. **分配比例**:总代在给子代开户时,将自己手中 80% 的分红权,划拨一部分(如 70%)给子代,自己赚取 10% 的级差。
### 2.2 🌟 核心分佣结算流程(流水占比分桶模式)
系统设定为每周一凌晨(或每日)自动执行结算脚本:
1. **大盘盈亏判断**:系统计算在此周期内的平台实际盈利(总下注扣除总派彩)。
* *分支 A*:假如是亏损,**终结流程**,所有代理本期没有分佣。
* *分支 B*:假如实际盈利为 RM 2,000系统内记为 💎 2,000进入下一步。
2. **计算单线流水占比 (分桶)**
* 统计所有单线的流水占全网总下注金额的百分比。例如总下注金额为 RM 10,000。
* 【Line B】本期下注 RM 2,000计算得占比为 **20%**
3. **拨出单线应得红利基数**
* Line B 的路线应分红基数 = 2,000 (实际盈利) × 20% (该线占比) = 💎 400。
4. **结合 GM 盘口计算实际到手金额**
* 假设我们 GM 开发者给出的盘口分红比例是 **80%**
* Line B 整线实际到手金额 = 400 × 80% = 💎 320。
5. **层层级差分配 (无限下延)**
* 当 Line B 总代拿手上的分红拨给旗下的子代(譬如给 70%)。
* 该子代拿到的佣金 = 400 (基数) × 70% = 💎 280。
* Line B 总代自己这条线只能吃到级差倒扣 (80% - 70% = 10%)400 × 10% = 💎 40。
* *(注280 + 40 = 320刚好核对总线实际到手金额多级代理一直按此“抽水级差”相减最后分到无差价为止。)*
6. **发放佣金**:计算完毕后,佣金分别直接发放到各级代理的 Agent 钱包,代理可随时提现。
---
## 模块三:联营代理佣金结算大流 (Affiliate Commission Flow)
系统支持高级的“联营代理(占成代理)”独立结算。联营代理不按流水比例切蛋糕,而是直接就其辖区玩家的**客损净利**与平台进行按比例分成结算。
### 3.1 🌟 客损占成与负结转计算逻辑
系统每月(或单周)触发联营账单结算脚本:
1. **计算总客损 (GGR)**
* 统计【联营代理 A】整条线下所有散户和子代的总输赢额。
* 例如:玩家总输款 150,000玩家总赢款 50,000。该代理辖区 GGR (总客损) = 100,000。
2. **扣除平台双降费 (Admin Fees)**
* 平台需扣除运营杂费包括充提通道手续费、API算票系统服务费等假设固定扣除 GGR 的 15%)。
* 真实净利润 = 100,000 - 15,000 = 💎 85,000。
3. **正盈利分成拨付**
* 根据总后台签署的“联营契约”,代理 A 满足阶梯条件,享有 **55% 的客损占成**
* 联营代理 A 本期佣金 = 85,000 × 55% = 💎 46,750。系统将其下发至代理钱包卡。
4. **负结转机制 (Negative Carryover)**
* *异常分支*:若周期内玩家鸿运当头,该代理辖区 GGR = **负 50,000**(代理大亏)。
* 系统不会直接要求代理补款,而是将这 -50,000 计入代理的**负结转账单 (Carryover Balance)**。
* **下期抵扣**:下个结算周期,若该区重新盈利 80,000必须先刨去上期的 -50,000 亏空窟窿,只能用剩下的 30,000 作为基数来进行分成。以此保证平台与联营代理的绝对风险共担。
---
## 模块四:总控后台运营与管理流程 (Admin Flow)
### 4.1 🌟 开奖管控流程(自动 vs 手动)
管理员在后台【开奖控制台】进行全局调控:
* **【自动开奖模式】 (默认运转流)**
1. 系统在封盘后第20秒AI 引擎接管。
2. AI 遍历 36 个格子的平台赔付金额PnL
3. **风控池判定**:检查 RTP 放水池余额。如果开出最高连赢赔付大奖的亏损 放水池余额AI 以 15%(参数可调)的概率主动把开奖结果设定为该大奖号码;否则,强制开出平台赔付最少的安全号码。
* **【手动开奖模式】 (特殊干预流)**
1. 管理员开启手动模式。
2. **强制前置选择**:管理员必须在**下注倒计时的 20 秒内**,在后台面板点击选中一个必开的动物并保存。
3. 20秒一到系统直接抓取管理员预设的号码作为本期开奖结果。
4. *异常兜底与强制重置*:若管理员开启了手动模式,但 20 秒内忘了点选号码。系统为防宕机,该期自动由 AI 引擎接管兜底出号。**(重要)同时系统会自动将全局模式强制切回并锁定至【自动模式】**,直至管理员再次手动开启,防范持续缺位引发的事故。
### 4.2 大额提现与异常账号审计流程
1. **触发警报**:某玩家触发了 10连胜获得 100万 Jackpot或单笔提现超过 💎 50,000。
2. **资金冻结**:该笔提现进入后台 `[Risk Audit / 风控审核]` 列表。
3. **人工溯源**:风控专员点击该玩家详情,查看:
* **IP 与设备指纹**:是否与其他多账号重合(防对刷)。
* **投注路径**:是否每局固定在最后 0.1 秒压秒下注(防接口脚本外挂)。
4. **处理决策**
* 若无异常:点击 `[通过 Pass]`,财务打款。
* 若实锤作弊:点击 `[驳回并冻结 Reject & Freeze]`,账号封禁,资金不予出款。
### 4.3 平台公告与运营触达流程
1. 运营在后台新建一条公告内容(如“本周末狂欢,基础赔率提升至 1:35”
2. **选择触达方式**
* *静默发布*:仅收纳进用户的 `[公告信箱]`
* *强弹窗发布*:设定为 `[Pop-out]`
3. **用户端生效**:前端检测到有新的 Pop-out 级别的公告未读,立即在用户当前界面弹出。用户必须勾选同意后才可继续游戏,保证 100% 触达率。
---
## 模块五:极端异常与灾难恢复流程 (Disaster Recovery Flow)
### 5.1 开奖期服务器宕机补偿流 (Void & Refund)
这是对平台信誉极其重要的保护流程:
1. **灾难发生**:某局倒计时走到 00:22玩家已全额扣款系统正在算票机房突然断电或遭遇极端 DDoS 攻击,服务器彻底宕机。
2. **工程师重启**30 分钟后,服务器恢复上线。
3. **自检与重置机制**
* 后端主程序启动时的**第一件事**,是去数据库自检上一局的状态。
* 发现状态为 `[计算中/未结算]`,触发**熔断补偿脚本**。
4. **执行退本与安抚**
* 系统将该期直接标记为 **`[已作废 VOID]`**。
* 将该局所有已被扣除下注资金的用户本金,**100% 原路退回**其可用余额。
* 系统通过站内信群发:*“尊敬的用户,因极端的网络波动,期号 20260413-1240 发生数据异常,该期已作废,您的下注本金已全额退回,给您带来的不便敬请谅解。”*
5. **恢复循环**:处理完残局后,系统开启全新的 30 秒倒计时,游戏继续运转。
---

145
docs/后端.md Normal file
View File

@@ -0,0 +1,145 @@
# 💻《"36字花" 后端系统开发与后台模块详细规格书》
**适用对象**:后端开发工程师、数据库架构师、系统管理员
## 一、 系统架构与技术栈规范 (Architecture & Tech Stack)
1. **核心开发语言/框架**:由于极高的并发读写,推荐使用并发性能优越的语言。
2. **数据冷热分离架构**
* **热数据 (Redis)**:存放**当前局的倒计时状态**、**当前局全网注单池**、**玩家连胜状态 (Streak)**、**30期开奖历史**。AI算票必须全程在 Redis 内存中计算,确保 2 秒内出结果。
* **业务主库 (MySQL / PostgreSQL)**:存放用户资产、核心账本、代理树状图、系统配置。注单状态确认后异步落库。
* **冷数据查询 (ElasticSearch / ClickHouse)**:超海量注单流水(每月上亿条)的存储与分页查询,减轻主库压力。
3. **异步消息队列 (MQ)**:引入 RabbitMQ 或 Kafka。开奖结果一出立刻将派彩结算、连胜状态更新、站内信发放等操作扔进 MQ 异步执行,**绝对禁止在结算期同步锁库写数据**。
4. **事务安全性**:下注扣款动作必须是**强原子性操作 (Atomic)**,结合 Redis 分布式锁杜绝并发下注导致的余额超扣Negative Balance
---
## 二、 核心驱动引擎30秒状态机与AI算票 (Core Engine)
后端必须启动一个绝对精准的守护进程Daemon来维持 30 秒一局的生命周期。
### 2.1 状态机切换逻辑 (State Machine)
* `STATUS_OPEN (0-20s)`:接收 `Bet_API` 请求。若有自动托管任务,在 0-10s 内通过 Jitter 平滑随机执行注单,防并发风暴。
* `STATUS_LOCKED (20.0s)`:严格切断请求接入,开始计算。
* `STATUS_CALCULATING (20-22s)`:调用 AI 算票引擎。
* `STATUS_PAYOUT (22-25s)`:第 22.0 秒立刻将派彩和站内信任务扔入 MQ 异步执行。同时下发结果给前端开始播放倒计时定格动画。
* `STATUS_FINISHED (25-30s)`:本期收尾,创建下一期期号,循环。
* **灾难兜底器**:系统启动时自检,若发现有卡在 `CALCULATING` 的历史期号,立即执行 `VOID_AND_REFUND`(全额退本)脚本。
### 2.2 AI 算票引擎算法 (后台执行逻辑)
1. **汇集注单**:抓取本期 Redis 池中所有有效注单。
2. **提取连赢系数**关联下注玩家当前的连赢次数0次=33倍1次=66倍2次=99倍...)。
3. **遍历计算 (PnL Loop)**
* `For i = 1 to 36:`
* 计算如果开出 `i`平台需赔付的总金额含触发Jackpot的额外100万
4. **RTP 风控池判定**
* 拉取当前“全局放水池”余额。
* 如果有某个结果导致平台亏损,但亏损额 < 放水池余额AI通过 `Math.random()` 以配置概率如15%)选择该结果。
5. **最终决断**:若未触发放水,则 `Return Min(PnL_Array)`(返回让平台赔付最少的数字)。若有多个最小值,随机取其一。
---
## 三、 🌟 核心重点:总控后台 (Admin Portal) 功能模块清单
为了让运营团队能够全方位掌控平台,后端需要为前端/后台管理系统提供以下极为详尽的 API 和模块支持:
### 3.1 📊 数据看板台 (Dashboard)
运营人员登录后台第一眼看到的数据宏观监控:
* **实时在线**:当前 WebSocket 在线连接数。
* **宏观财务 (今日/本周/本月)**:全网总充值额、总提现额、全网总下注额 (Turnover)、**平台实际客损盈亏 (GGR / PnL)**。
* **风控预警屏**滚动显示大额中奖提示单笔赢取超过X万、异常频繁提现提示。
### 3.2 🎲 游戏与开奖控制台 (Game & Draw Control)
* **模式切换开关**`[自动 AI 模式]``[手动控制模式]` 实时切换。
* **实时注单监控**:在下注的 0-20 秒内,实时展示当前 36 个格子的注金分布热力图,并实时预估每个格子的 PnL。
* **手动开奖干预框**:当开启手动模式时,提供一个允许在前 20 秒内指定开奖号码的输入框。
* **历史开奖纠错与作废**:允许超管针对历史某期执行“撤回作废 (Void)”指令,系统自动扣除已派彩金额(慎用功能)。
### 3.3 👥 用户 CRM 与风控管理 (User & Risk Management)
* **玩家列表与画像**包含玩家ID、归属代理、注册时间、当前余额、总充提差、历史总输赢。
* **玩家详情穿透 (Deep Trace)**
* 查看该玩家详细下注流水、充提流水。
* 查看登录设备指纹、登录 IP 历史(支持同一 IP 多账号关联查询)。
* **风控操作箱**
* **冻结/解冻账户**(禁止登录 / 禁止下注 / 禁止提现)。
* **人工加减币 (Manual Adjustment)**:用于处理客诉补偿或线下充值,必须记录操作理由和操作人。
* 清零连胜进度(异常账号干预)。
### 3.4 💰 财务与出入金审核中枢 (Finance Center)
* **充值明细表**:所有充值订单状态(成功/失败/掉单补单)。
* **提现审核队列 (Withdrawal Audit)**
* 列出待审核单据。**后端必须在此处提供“有效流水达标检测”标签**该玩家充值1000当前已打流水1500流水完成率 150%,亮绿灯)。
* 支持 `[一键通过并打款]``[驳回请求并退回余额]` 操作。
* **Jackpot 提现专项通道**:针对大额中奖资金的专属审核队列,必须由超管权限放行。
* **提现手续费收益报表**:单独统计系统抽取的 0.5% 手续费总收入。
### 3.5 🏢 代理中枢与佣金结算系统 (Agent System)
* **创建总代账号**:由于前端不支持散户变代理,只能由管理员在此处点击 `[新增总代 Master Agent]`,生成专属邀请链接。
* **代理树状图 (Tree View)**:可视化查看整个多级代理层级及人数。
* **佣金结算看板 (Commission Settlement)**
* 后端按周/月跑批处理脚本Cron Job
* 展示全平台周期总盈利(大盘盈亏)。
* 列出各代理线的流水占比%、应得分红、GM盘口扣除利润、实际下放分红。
* 提供 `[一键发佣]` 按钮,将佣金批量转入各代理的 Agent 钱包。
### 3.6 🤝 联营契约与合营代理管控大厅 (Affiliate Management)
设计为与普通流水分佣系统并行的**客损占成代理模块**方案:
* **联营契约(合营契约)线上配置**平台超管可与高级代理在线签署“联营契约”。支持动态阶梯的占成比例如当月新增活跃≥10人且总客损>1万美元时该代客损占成提高至45%)。
* **独立负结转账单明细 (Agent Statement)**
* 后端按月或周生成带有“负盈利结转”规则的专业级合营账单。
* 账表包含项目:辖区总输赢、当期大促红利扣除、当期通道成本扣除、**历史负结转累计抵扣**、当期应结佣金。
* **佣金与负盈利全自动跑批**:结算周期末由 Cron Job 自动判断,若是正佣金则划入代理钱包;若是负佣金(玩家赢),则记入该代理名下的 Carryover Balance累计负结转账本供下期抵扣。
* **独立的财务出金通道**:联营代理大额提取高额占成分红时,有专门供高级财务总监审批的安全通道,与普通玩家或刷水代理区分开。
### 3.7 ⚙️ 系统超级参数引擎 (System Configuration)
这是系统最核心的动态配置中心,**后端必须将以下参数做成 DB 可配置,严禁在代码里写死**
* **时间参数**单局时长默认30、下注时长默认20
* **下注参数**最高选字数默认5可调为6或7、最低下注额默认1、**单注最高限额**如500风控核心、前端快捷筹码面额配置。
* **赔率参数**基础赔率默认33、连续中奖递增步长默认33
* **大奖参数**Jackpot 触发连胜阈值默认10、Jackpot 奖励固定金额默认1,000,000、**Jackpot资格最小下注额默认50**。
* **风控参数**提现流水倍数要求默认1.0倍、提现手续费比例默认0.005、AI RTP 放水抽水比例。
* **代理参数**:全局 GM 盘口截留比例默认20%)、**联营合营基础成本双降费比例默认15%**。
### 3.8 📢 运营中心 (Operations)
* **公告信箱管理**:富文本编辑器。支持设置公告类型为 `[普通静默]``[强阻断 Pop-out]`,其中 Pop-out 要求客户端弹出并强制确认。
* **前端走马灯/跑马灯**:管理首页顶部滚动的文字通知。
### 3.9 🔒 权限与操作日志 (Admin RBAC & Logs)
* **角色控制 (Role-Based Access Control)**:设置超级管理员、财务审核员、普通客服。
* **管理员操作审计 (Audit Trail)**记录所有后台操作的日志谁、在什么时间、把赔率从33改成了35或给谁手动加了钱防范内部作恶。
---
## 四、 C端前端核心 API 接口要求 (User API Requirements)
除了基础的登录注册,后端需要重点保证以下几个接口的性能与安全:
1. **`POST /api/user/register` (用户注册)**
* 接受手机/邮箱、密码、以及 `invite_code`(选传)。如果带有 `invite_code`,自动挂载到对应代理名下;否则为直客。
2. **`GET /api/game/roadmap_history` (历史路子图拉取)**
* 前端初始化首屏时调用,返回最近 30 期的开奖记录数组(含期号、中奖数字、赔率),用于渲染屏幕底部大路/红蓝球走势图。
3. **`GET /api/game/current_status` (轮询/长连接)**
* 前端每秒请求或通过 WebSocket 接收。返回当前期号、倒计时剩余秒数、当前开奖状态。
4. **`POST /api/game/place_bet` (核心下注接口)**
* **入参**:期号、选中的动物编号数组 `[01, 02, 15]`、单注金额 `10`
* **后端强校验逻辑**
1. 验证时间:是否超过第 20 秒?是则返回 403 封盘报错。
2. 验证数量:数组长度是否 ≤ 系统配置的【最高选字数】?
3. 验证连赢上限:该玩家若处于连赢态,本次下注总额(如 30是否 ≤ 上局总下注额?
4. 验证余额:余额是否 ≥ 30
* 校验全过,开启 DB 事务,扣钱,写注单,提交。
5. **`POST /api/game/auto_spin` (启动/停止自动托管)**
* 后端接收托管指令后,写入守护进程列队。由服务器在后台**引入 Jitter(0-10s随机制打散)** 帮玩家调用 `place_bet`
6. **`GET /api/user/pnl_history` (历史记录过滤)**
* 后端仅返回最近 1 个月的数据(通过 SQL 时间戳硬性过滤),防止扫库查询。
---
## 五、 后端安全与反黑客建议 (Security & Anti-Fraud)
1. **接口限流 (Rate Limiting)**`place_bet` 接口必须限制单个用户的请求频率(如 1秒内最多2次防止外挂脚本通过并发漏洞击穿余额。
2. **汇率与金额精度**:底层数据库所有金钱字段(游戏币)**必须使用 `DECIMAL(18,4)` 或将其放大 10000 倍用整型 (`INT64`) 存储**。绝不允许使用浮点数Float/Double防止精度丢失导致对账不平。
3. **负资产阻断**:扣款 SQL 必须带上条件判断:`UPDATE user SET balance = balance - 100 WHERE id = 1 AND balance >= 100;` 确保高并发下余额不会被扣成负数。

View File

@@ -13,11 +13,7 @@ if (!defined('BASE_PATH')) {
require $baseDir . '/vendor/autoload.php'; require $baseDir . '/vendor/autoload.php';
if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) { if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) { Dotenv\Dotenv::createMutable($baseDir)->load();
Dotenv\Dotenv::createUnsafeImmutable($baseDir)->load();
} else {
Dotenv\Dotenv::createMutable($baseDir)->load();
}
} }
if (!function_exists('env')) { if (!function_exists('env')) {
@@ -42,8 +38,27 @@ if (!function_exists('env')) {
require $baseDir . '/vendor/workerman/webman-framework/src/support/helpers.php'; require $baseDir . '/vendor/workerman/webman-framework/src/support/helpers.php';
require $baseDir . '/app/functions.php'; require $baseDir . '/app/functions.php';
Webman\Config::load($baseDir . '/config', ['route', 'middleware', 'process', 'server', 'static']); use Webman\Config;
$thinkorm = config('thinkorm', []);
Config::clear();
Config::load($baseDir . '/config', ['route', 'middleware', 'process', 'server', 'static']);
// 与 Webman\ThinkOrm\ThinkOrm::start() 一致,并与 phinx.php 中 $thinkorm 来源一致
$thinkorm = array_replace_recursive(config('thinkorm', []), config('think-orm', []));
if (!empty($thinkorm)) { if (!empty($thinkorm)) {
support\think\Db::setConfig($thinkorm); support\think\Db::setConfig($thinkorm);
// Webman DbManager 使用连接池且忽略 force安装向导在同进程内迁移时若不清理会沿用旧前缀连接
// 导致 Phinx 已建带前缀表而 Db::name() 仍查无前缀表(如 menu_rule 不存在)。
if (class_exists(\Webman\ThinkOrm\DbManager::class)) {
$ref = new \ReflectionClass(\Webman\ThinkOrm\DbManager::class);
if ($ref->hasProperty('pools')) {
$poolsProp = $ref->getProperty('pools');
$poolsProp->setAccessible(true);
$poolsProp->setValue(null, []);
}
}
if (class_exists(\Webman\Context::class)) {
foreach (array_keys($thinkorm['connections'] ?? []) as $connName) {
\Webman\Context::set('think-orm.connections.' . $connName, null);
}
}
} }

View File

@@ -1,7 +1,7 @@
<?php <?php
/** /**
* Phinx 数据库迁移配置 * Phinx 数据库迁移配置
* 从 config/thinkorm.php 读取数据库连接,用于 php vendor/bin/phinx migrate * 与 Webman ThinkOrm 引导一致Config 加载后合并 thinkorm + think-ormphinx migrate 使用
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -10,11 +10,8 @@ $baseDir = __DIR__;
require $baseDir . '/vendor/autoload.php'; require $baseDir . '/vendor/autoload.php';
if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) { if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) { // 必须用 MutableWebman Worker 已加载过时Immutable 不会覆盖 $_ENV会导致 Phinx 与 Db::name() 前缀/库名不一致
Dotenv\Dotenv::createUnsafeImmutable($baseDir)->load(); Dotenv\Dotenv::createMutable($baseDir)->load();
} else {
Dotenv\Dotenv::createMutable($baseDir)->load();
}
} }
if (!function_exists('env')) { if (!function_exists('env')) {
@@ -36,7 +33,18 @@ if (!function_exists('env')) {
} }
} }
$thinkorm = require $baseDir . '/config/thinkorm.php'; if (!defined('BASE_PATH')) {
define('BASE_PATH', $baseDir);
}
require $baseDir . '/vendor/workerman/webman-framework/src/support/helpers.php';
require $baseDir . '/app/functions.php';
use Webman\Config;
Config::clear();
Config::load($baseDir . '/config', ['route', 'middleware', 'process', 'server', 'static']);
$thinkorm = array_replace_recursive(config('thinkorm', []), config('think-orm', []));
$conn = $thinkorm['connections'][$thinkorm['default'] ?? 'mysql'] ?? []; $conn = $thinkorm['connections'][$thinkorm['default'] ?? 'mysql'] ?? [];
$prefix = $conn['prefix'] ?? ''; $prefix = $conn['prefix'] ?? '';

File diff suppressed because one or more lines are too long

View File

@@ -1,54 +1,163 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/install/favicon.ico" /> <link rel="icon" href="/install/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BuildAdmin-安装</title> <title>BuildAdmin-安装</title>
<script> <script>
(function(){ (function(){
var urls = { adminUrl: '', frontUrl: '' }; var urls = { adminUrl: '', frontUrl: '' };
fetch('/api/install/accessUrls').then(function(r){return r.json();}).then(function(res){ fetch('/api/install/accessUrls').then(function(r){return r.json();}).then(function(res){
if (res && res.data) { urls.adminUrl = res.data.adminUrl || ''; urls.frontUrl = res.data.frontUrl || ''; } if (res && res.data) { urls.adminUrl = res.data.adminUrl || ''; urls.frontUrl = res.data.frontUrl || ''; }
}).catch(function(){}); }).catch(function(){});
function applyUrls() { function ensureQuickPanel() {
if (!urls.adminUrl && !urls.frontUrl) return; if (!urls.adminUrl && !urls.frontUrl) return;
document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){ if (document.getElementById('__ba_install_quick_urls__')) return;
var v = (inp.value || '').trim(); var wrap = document.createElement('div');
if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 0) && v.indexOf('#/') >= 0) { wrap.id = '__ba_install_quick_urls__';
inp.value = urls.adminUrl; wrap.style.position = 'fixed';
inp.dispatchEvent(new Event('input', { bubbles: true })); wrap.style.right = '16px';
} wrap.style.bottom = '16px';
}); wrap.style.zIndex = '99999';
document.querySelectorAll('a[href*="#/admin"]').forEach(function(a){ if (urls.adminUrl) a.href = urls.adminUrl; }); wrap.style.maxWidth = '560px';
document.querySelectorAll('a[href*="#/"]').forEach(function(a){ wrap.style.fontFamily = 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"';
if (urls.frontUrl && a.href.indexOf('#/admin') < 0) a.href = urls.frontUrl; wrap.innerHTML =
}); '<div style="background:rgba(255,255,255,.96);border:1px solid rgba(0,0,0,.08);box-shadow:0 8px 24px rgba(0,0,0,.12);border-radius:12px;overflow:hidden">' +
} '<div style="padding:10px 12px;border-bottom:1px solid rgba(0,0,0,.06);display:flex;gap:8px;align-items:center;justify-content:space-between">' +
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); }); '<div style="font-weight:600;color:#111">安装完成快捷入口</div>' +
else setInterval(applyUrls, 800); '<button type="button" aria-label="close" style="border:0;background:transparent;cursor:pointer;font-size:16px;line-height:16px;color:#666;padding:4px 6px">×</button>' +
})(); '</div>' +
(function(){ '<div style="padding:12px;display:flex;flex-direction:column;gap:10px">' +
function closeMigrateModal() { '<div>' +
if (!document.body) return; '<div style="font-size:12px;color:#666;margin-bottom:6px">后台地址</div>' +
var txt = document.body.innerText || document.body.textContent || ''; '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">' +
if (txt.indexOf('数据表迁移失败') < 0 && txt.indexOf('数据表自动迁移失败') < 0) return; '<a data-k="admin" target="_blank" rel="noreferrer" style="color:#1677ff;text-decoration:none;word-break:break-all"></a>' +
var btns = document.body.querySelectorAll('button, [role="button"], .el-button'); '<button data-copy="admin" type="button" style="border:1px solid rgba(0,0,0,.12);background:#fff;border-radius:8px;padding:6px 10px;cursor:pointer">复制</button>' +
for (var i = 0; i < btns.length; i++) { '</div>' +
var b = btns[i]; '</div>' +
if (b.textContent && b.textContent.indexOf('继续安装') >= 0) { b.click(); return; } '<div>' +
'<div style="font-size:12px;color:#666;margin-bottom:6px">前台地址</div>' +
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">' +
'<a data-k="front" target="_blank" rel="noreferrer" style="color:#1677ff;text-decoration:none;word-break:break-all"></a>' +
'<button data-copy="front" type="button" style="border:1px solid rgba(0,0,0,.12);background:#fff;border-radius:8px;padding:6px 10px;cursor:pointer">复制</button>' +
'</div>' +
'</div>' +
'<div data-msg style="font-size:12px;color:#52c41a;min-height:16px"></div>' +
'</div>' +
'</div>';
document.body.appendChild(wrap);
var closeBtn = wrap.querySelector('button[aria-label="close"]');
if (closeBtn) closeBtn.addEventListener('click', function(){ wrap.remove(); });
function setLink(which, val) {
var a = wrap.querySelector('a[data-k="' + which + '"]');
if (!a) return;
a.textContent = val || '';
a.href = val || 'javascript:void(0)';
}
setLink('admin', urls.adminUrl);
setLink('front', urls.frontUrl);
function showMsg(text, ok) {
var el = wrap.querySelector('[data-msg]');
if (!el) return;
el.style.color = ok ? '#52c41a' : '#ff4d4f';
el.textContent = text;
window.clearTimeout(el.__t);
el.__t = window.setTimeout(function(){ el.textContent = ''; }, 1800);
}
function copyText(text) {
if (!text) return Promise.reject(new Error('empty'));
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function(resolve, reject){
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
ok ? resolve() : reject(new Error('copy failed'));
} catch (e) {
reject(e);
}
});
}
wrap.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.getAttribute) return;
var which = t.getAttribute('data-copy');
if (!which) return;
var text = which === 'admin' ? urls.adminUrl : urls.frontUrl;
copyText(text).then(function(){
showMsg('已复制:' + text, true);
}).catch(function(){
showMsg('复制失败,请手动复制', false);
});
});
} }
} function applyUrls() {
var obs = new MutationObserver(closeMigrateModal); if (!urls.adminUrl && !urls.frontUrl) return;
function start() { if (document.body) { obs.observe(document.body, { childList: true, subtree: true }); closeMigrateModal(); } } document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){
if (document.body) start(); else document.addEventListener('DOMContentLoaded', start); var v = (inp.value || '').trim();
setInterval(closeMigrateModal, 150); if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 0 || v.indexOf('/index#') >= 0) && v.indexOf('#/') >= 0) {
inp.value = urls.adminUrl;
inp.dispatchEvent(new Event('input', { bubbles: true }));
}
});
document.querySelectorAll('a[href*="#/admin"]').forEach(function(a){ if (urls.adminUrl) a.href = urls.adminUrl; });
document.querySelectorAll('a[href*="#/"]').forEach(function(a){
if (urls.frontUrl && a.href.indexOf('#/admin') < 0) a.href = urls.frontUrl;
});
// index.html/#/ 会被当成路径 /index.html/ 导致 webman 404统一为 index.html#/
document.querySelectorAll('a[href*="index.html/#"]').forEach(function(a){
a.href = a.href.replace(/index\.html\/#\//g, 'index.html#/');
});
// 打包的 index.js 完成页用 protocol+host 拼 adminUrl会漏掉 /index.php 等前缀;用接口结果覆盖展示与点击
if (urls.adminUrl) {
document.querySelectorAll('.admin-url').forEach(function(el){
el.textContent = urls.adminUrl;
el.style.cursor = 'pointer';
el.onclick = function(ev){
ev.preventDefault();
ev.stopPropagation();
window.open(urls.adminUrl, '_blank', 'noreferrer');
};
});
}
ensureQuickPanel();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); });
else setInterval(applyUrls, 800);
})(); })();
</script> (function(){
<script type="module" crossorigin src="/install/assets/index.js"></script> function closeMigrateModal() {
<link rel="stylesheet" crossorigin href="/install/assets/index.css"> if (!document.body) return;
</head> var txt = document.body.innerText || document.body.textContent || '';
<body> if (txt.indexOf('数据表迁移失败') < 0 && txt.indexOf('数据表自动迁移失败') < 0) return;
<div id="app"></div> var btns = document.body.querySelectorAll('button, [role="button"], .el-button');
</body> for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.textContent && b.textContent.indexOf('继续安装') >= 0) { b.click(); return; }
}
}
var obs = new MutationObserver(closeMigrateModal);
function start() { if (document.body) { obs.observe(document.body, { childList: true, subtree: true }); closeMigrateModal(); } }
if (document.body) start(); else document.addEventListener('DOMContentLoaded', start);
setInterval(closeMigrateModal, 150);
})();
</script>
<script type="module" crossorigin src="/install/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/install/assets/index.css">
</head>
<body>
<div id="app"></div>
</body>
</html> </html>

View File

@@ -0,0 +1,36 @@
<?php
/**
* 执行方法
* php scripts/generate_auth_signature.php
* php scripts/generate_auth_signature.php 设备码 密钥 时间戳
* php scripts/generate_auth_signature.php 1 564d14asdasd113e46542asd6das1a2a 1776331077
*/
declare(strict_types=1);
$deviceId = $argv[1] ?? '1';
$secret = $argv[2] ?? ((string) getenv('AUTH_TOKEN_SECRET') ?: '564d14asdasd113e46542asd6das1a2a');
$timestamp = $argv[3] ?? (string) time();
$params = [
'device_id' => (string) $deviceId,
'secret' => (string) $secret,
'timestamp' => (string) $timestamp,
];
ksort($params);
$pairs = [];
foreach ($params as $key => $value) {
$pairs[] = $key . '=' . $value;
}
$plain = implode('&', $pairs);
$signature = strtoupper(md5($plain));
echo 'device_id: ' . $params['device_id'] . PHP_EOL;
echo 'secret: ' . $params['secret'] . PHP_EOL;
echo 'timestamp: ' . $params['timestamp'] . PHP_EOL;
echo 'signature: ' . $signature . PHP_EOL;
echo 'url: /api/v1/authToken?secret=' . rawurlencode($params['secret']) . '&timestamp=' . rawurlencode($params['timestamp']) . '&device_id=' . rawurlencode($params['device_id']) . '&signature=' . rawurlencode($signature) . PHP_EOL;

View File

@@ -35,6 +35,18 @@ class Request extends \Webman\Http\Request
return $path; return $path;
} }
/**
* 入口为 /index.php/... 时返回 /index.php用于拼接后台/前台完整 URL与路由 path() 剥离规则对应)
*/
public function publicBasePath(): string
{
$path = parent::path();
if (str_starts_with($path, '/index.php')) {
return '/index.php';
}
return '';
}
/** /**
* 获取请求参数(兼容 ThinkPHP param合并 get/postpost 优先) * 获取请求参数(兼容 ThinkPHP param合并 get/postpost 优先)
* @param string|null $name 参数名null 返回全部 * @param string|null $name 参数名null 返回全部

View File

@@ -4,5 +4,5 @@ ENV = 'development'
# base路径 # base路径
VITE_BASE_PATH = './' VITE_BASE_PATH = './'
# 本地环境接口地址 - 用空字符串走同源,由 vite 代理到 8787,避免 CORS # 本地环境接口地址 - 用空字符串走同源,由 vite 代理到 7979,避免 CORS
VITE_AXIOS_BASE_URL = '' VITE_AXIOS_BASE_URL = ''

View File

@@ -1,8 +0,0 @@
import createAxios from '/@/utils/axios'
export function getUserRules() {
return createAxios({
url: '/admin/user.Rule/index',
method: 'get',
})
}

View File

@@ -1,13 +0,0 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/user.MoneyLog/'
export function add(userId: string) {
return createAxios({
url: url + 'add',
method: 'get',
params: {
userId: userId,
},
})
}

View File

@@ -1,13 +0,0 @@
import createAxios from '/@/utils/axios'
export const url = '/admin/user.ScoreLog/'
export function add(userId: string) {
return createAxios({
url: url + 'add',
method: 'get',
params: {
userId: userId,
},
})
}

View File

@@ -2,6 +2,7 @@
import { isArray, isString } from 'lodash-es' import { isArray, isString } from 'lodash-es'
import type { PropType, VNode } from 'vue' import type { PropType, VNode } from 'vue'
import { computed, createVNode, defineComponent, reactive, resolveComponent } from 'vue' import { computed, createVNode, defineComponent, reactive, resolveComponent } from 'vue'
import { dayjs } from 'element-plus'
import { getArea } from '/@/api/common' import { getArea } from '/@/api/common'
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput' import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
import { inputTypes } from '/@/components/baInput' import { inputTypes } from '/@/components/baInput'
@@ -40,6 +41,29 @@ export default defineComponent({
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
setup(props, { emit, slots }) { setup(props, { emit, slots }) {
const normalizeDateTimeValue = (value: unknown, format: string) => {
if (value === null || value === undefined || value === '') {
return value
}
if (typeof value === 'number' && Number.isFinite(value)) {
const ms = value > 9999999999 ? value : value * 1000
const d = dayjs(ms)
return d.isValid() ? d.format(format) : value
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (/^\d{10,13}$/.test(trimmed)) {
const num = Number(trimmed)
if (Number.isFinite(num)) {
const ms = trimmed.length === 13 ? num : num * 1000
const d = dayjs(ms)
return d.isValid() ? d.format(format) : value
}
}
}
return value
}
// 合并 props.attr 和 props.data // 合并 props.attr 和 props.data
const attrs = computed(() => { const attrs = computed(() => {
return { ...props.attr, ...props.data } return { ...props.attr, ...props.data }
@@ -186,6 +210,7 @@ export default defineComponent({
valueFormat = 'YYYY' valueFormat = 'YYYY'
break break
} }
const valueComputed = computed(() => normalizeDateTimeValue(props.modelValue, valueFormat))
return () => return () =>
createVNode( createVNode(
resolveComponent('el-date-picker'), resolveComponent('el-date-picker'),
@@ -194,7 +219,7 @@ export default defineComponent({
type: props.type, type: props.type,
'value-format': valueFormat, 'value-format': valueFormat,
...attrs.value, ...attrs.value,
modelValue: props.modelValue, modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate, 'onUpdate:modelValue': onValueUpdate,
}, },
slots slots
@@ -300,7 +325,7 @@ export default defineComponent({
'year', 'year',
() => { () => {
return () => { return () => {
const valueComputed = computed(() => (!props.modelValue ? null : '' + props.modelValue)) const valueComputed = computed(() => normalizeDateTimeValue(props.modelValue, 'YYYY'))
return createVNode( return createVNode(
resolveComponent('el-date-picker'), resolveComponent('el-date-picker'),
{ {

View File

@@ -8,7 +8,5 @@ import { adminBaseRoutePath } from '/@/router/static/adminBase'
export default { export default {
'/': ['./frontend/${lang}/index.ts'], '/': ['./frontend/${lang}/index.ts'],
[adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'], [adminBaseRoutePath + '/moduleStore']: ['./backend/${lang}/module.ts'],
[adminBaseRoutePath + '/user/rule']: ['./backend/${lang}/auth/rule.ts'],
[adminBaseRoutePath + '/user/scoreLog']: ['./backend/${lang}/user/moneyLog.ts'],
[adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'], [adminBaseRoutePath + '/crud/crud']: ['./backend/${lang}/crud/log.ts', './backend/${lang}/crud/state.ts'],
} }

View File

@@ -0,0 +1,22 @@
export default {
'quick Search Fields': 'ID/Settlement period ID/Remark',
id: 'ID',
settlement_period_id: 'Settlement period',
settlement_period_no: 'Settlement no.',
channel_id: 'Channel',
channel_name: 'Channel',
admin_id: 'Agent admin',
admin_username: 'Agent username',
commission_rate: 'Commission rate',
calc_base_amount: 'Calculation base amount',
commission_amount: 'Commission amount',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Paid',
'status 2': 'Reverted',
settled_at: 'Settled at',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
}

View File

@@ -0,0 +1,19 @@
export default {
'quick Search Fields': 'ID/Settlement No./Remark',
id: 'ID',
settlement_no: 'Settlement No.',
period_start_at: 'Period start',
period_end_at: 'Period end',
total_bet_amount: 'Total bet amount',
total_payout_amount: 'Total payout amount',
platform_profit_amount: 'Platform profit',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Processing',
'status 2': 'Completed',
'status 3': 'Closed',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
}

View File

@@ -5,6 +5,13 @@ export default {
avatar: 'Avatar', avatar: 'Avatar',
email: 'Email', email: 'Email',
mobile: 'Mobile Number', mobile: 'Mobile Number',
invite_code: 'Invite code',
commission_rate: 'Commission rate(%)',
commission_rate_desc_title: 'Admin commission notes',
commission_rate_desc_1: 'Admin commission means this admin allocation ratio inside assigned group.',
commission_rate_desc_2: 'Current admin commission = current group commission × current admin commission rate.',
commission_rate_desc_3: 'Within same group, total admin commission rate cannot exceed 100%; exceed and remaining are returned on validation.',
'Please select exactly one group': 'Please select exactly one group',
'Last login': 'Last login', 'Last login': 'Last login',
Password: 'Password', Password: 'Password',
'Please leave blank if not modified': 'Please leave blank if you do not modify.', 'Please leave blank if not modified': 'Please leave blank if you do not modify.',

View File

@@ -1,6 +1,11 @@
export default { export default {
GroupName: 'Group Name', GroupName: 'Group Name',
'Group name': 'Group Name', 'Group name': 'Group Name',
commission_rate: 'Commission rate (%)',
commission_rate_desc_title: 'Group commission notes',
commission_rate_desc_1: 'The total group commission rate under the same parent cannot exceed 100%.',
commission_rate_desc_2: 'Current group commission = channel commission × (1 - parent group commission rate) × current group commission rate.',
commission_rate_desc_3: 'If exceeded, the system returns both exceeded value and remaining quota under current parent.',
jurisdiction: 'Permissions', jurisdiction: 'Permissions',
'Parent group': 'Superior group', 'Parent group': 'Superior group',
'The parent group cannot be the group itself': 'The parent group cannot be the group itself', 'The parent group cannot be the group itself': 'The parent group cannot be the group itself',

View File

@@ -0,0 +1,78 @@
export default {
id: 'id',
code: 'code',
invite_code: 'invite_code',
name: 'name',
agent_mode: 'agent_mode',
'agent_mode turnover': 'turnover',
'agent_mode affiliate': 'affiliate',
agent_mode_title_turnover: 'Turnover Agent Mode',
agent_mode_desc_turnover_1: 'Settlement: commissions apply only when platform-wide PnL is positive.',
agent_mode_desc_turnover_2: 'Formula: bucket base by turnover × turnover share rate.',
agent_mode_desc_turnover_3: 'Fields: fill turnover share rate only; affiliate fields are cleared automatically.',
agent_mode_title_affiliate: 'Affiliate Agent Mode',
agent_mode_desc_affiliate_1: 'Settlement: based on net player loss within this affiliate line.',
agent_mode_desc_affiliate_2: 'Formula: net loss after costs × affiliate share rate.',
agent_mode_desc_affiliate_3: 'Fields: supports fee deduction rate and carryover balance; turnover rate is cleared automatically.',
turnover_share_rate: 'turnover_share_rate',
affiliate_share_rate: 'affiliate_share_rate',
affiliate_fee_rate: 'affiliate_fee_rate',
affiliate_contract_no: 'affiliate_contract_no',
affiliate_contract_name: 'affiliate_contract_name',
settle_cycle: 'settlement_cycle',
settle_cycle_placeholder: 'Select settlement cycle (daily/weekly day/monthly date)',
'settle_cycle daily': 'daily',
'settle_cycle weekly': 'weekly',
'settle_cycle monthly': 'monthly',
settle_weekday: 'settlement_weekday',
settle_time: 'settlement_time',
'weekday 1': 'Mon',
'weekday 2': 'Tue',
'weekday 3': 'Wed',
'weekday 4': 'Thu',
'weekday 5': 'Fri',
'weekday 6': 'Sat',
'weekday 7': 'Sun',
affiliate_effective_start_at: 'affiliate_effective_start_at',
affiliate_effective_end_at: 'affiliate_effective_end_at',
affiliate_ladder_rules: 'affiliate_ladder_rules',
affiliate_ladder_rules_placeholder: 'Input JSON, e.g. [{\"minLoss\":\"0.0000\",\"shareRate\":\"0.200000\"}]',
ladder_min_loss: 'min loss',
ladder_share_rate: 'share rate',
ladder_rule_required: 'At least one ladder rule is required',
ladder_min_loss_invalid: 'Ladder min loss must be a number >= 0',
ladder_share_rate_invalid: 'Ladder share rate must be between 0 and 1',
ladder_min_loss_order_invalid: 'Ladder rules must be sorted by min loss ascending',
settle_day_daily: 'Every day',
day_suffix: 'd',
carryover_balance: 'carryover_balance',
user_count: 'user_count',
profit_amount: 'profit_amount',
total_profit_amount: 'total_profit_amount',
commission_pool_amount: 'commission_pool_amount',
status: 'status',
'status 0': 'status 0',
'status 1': 'status 1',
remark: 'remark',
admin_group_id: 'admin_group_id',
admingroup__name: 'name',
admin_id: 'admin_id',
admin_tree_tip: 'Admins are grouped by channel. Pick a leaf under a channel name. One channel maps to one admin.',
manual_settle: 'Manual settle',
manual_settle_confirm: 'Confirm trigger manual settlement for this channel?',
manual_settle_settlement_no: 'Settlement No.',
manual_settle_period_start: 'Period start',
manual_settle_period_end: 'Period end (now)',
manual_settle_total_bet: 'Total bet (settled bets)',
manual_settle_total_payout: 'Total payout',
manual_settle_platform_profit: 'Platform PnL',
manual_settle_commission_rate: 'Commission rate (decimal)',
manual_settle_calc_base: 'Settlement base',
manual_settle_commission_amount: 'Commission amount',
manual_settle_remark: 'Remark',
admin_id_placeholder: 'Select a channel admin account',
admin__username: 'username',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id,code,name',
}

View File

@@ -0,0 +1,3 @@
import gameConfig from '../game/config'
export default gameConfig

View File

@@ -0,0 +1,3 @@
import dict from '../game/ziHuaDictionary'
export default dict

View File

@@ -0,0 +1,29 @@
export default {
'quick Search Fields': 'ID / Period / Idempotency',
id: 'ID',
period_id: 'Period ID',
period_no: 'Period No.',
user_id: 'User ID',
channel_id: 'Channel ID',
pick_numbers: 'Picks',
unit_amount: 'Unit amount',
pick_count: 'Pick count',
total_amount: 'Total',
streak_at_bet: 'Streak at bet',
is_auto: 'Auto',
'is_auto 0': 'Manual',
'is_auto 1': 'Auto bet',
win_amount: 'Payout',
jackpot_extra_amount: 'Jackpot extra',
status: 'Status',
'status 1': 'Pending draw',
'status 2': 'Settled',
'status 3': 'Refunded',
idempotency_key: 'Idempotency key',
create_time: 'Created',
update_time: 'Updated',
gameRecord_period_no: 'Round (relation)',
gameRecord_status: 'Round status',
user_username: 'Username',
channel_name: 'Channel',
}

View File

@@ -0,0 +1,14 @@
export default {
'quick Search Fields': 'ID / Key / Remark',
id: 'ID',
config_key: 'Config key',
config_value: 'Value',
value_type: 'Value type',
'value_type string': 'String',
'value_type int': 'Integer',
'value_type decimal': 'Decimal',
'value_type json': 'JSON',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
}

View File

@@ -0,0 +1,23 @@
export default {
tip: 'Listen to pushed bet stream in real time and show the AI default number (minimum estimated platform loss).',
current_record: 'Current round',
ai_default_number: 'AI default number',
countdown: 'Countdown',
bet_countdown: 'Bet left',
draw_countdown: 'Draw left',
btn_calc: 'Calculate PnL',
btn_draw: 'Draw now',
calc_result_number: 'Calculated number',
calc_estimated_loss: 'Estimated payout',
push_connected: 'Push connected, realtime updates running',
push_disconnected: 'Push disconnected, please check service status',
candidate_title: 'Candidate payout estimates',
number: 'Number',
estimated_loss: 'Estimated payout',
bet_stream_title: 'Realtime bet stream',
bet_id: 'Bet ID',
user_id: 'Player ID',
pick_numbers: 'Pick numbers',
unit_amount: 'Unit amount',
streak_at_bet: 'Streak at bet',
}

View File

@@ -0,0 +1,28 @@
export default {
'quick Search Fields': 'Period No. / ID',
id: 'ID',
period_no: 'Period No.',
period_start_at: 'Start time',
status: 'Status',
'status 0': 'Betting open',
'status 1': 'Closed',
'status 2': 'Settling',
'status 3': 'Paying',
'status 4': 'Ended',
'status 5': 'Void',
draw_mode: 'Draw mode',
'draw_mode 0': 'Auto AI',
'draw_mode 1': 'Manual preset',
preset_number: 'Preset number',
result_number: 'Result number',
void_reason: 'Void reason',
create_time: 'Created',
update_time: 'Updated',
section_auto: 'Auto draw & new period',
auto_create_label: 'Allow auto-create next period',
auto_create_tip: 'When enabled, a background ticker inserts a new period if none is in progress',
manual_create_label: 'Allow manual create next period',
manual_create_tip: 'When enabled, the button below can create the next period',
btn_create_next: 'Create next period (manual)',
saving: 'Saving…',
}

View File

@@ -0,0 +1,27 @@
export default {
'quick Search Fields': 'Round No. / ID',
id: 'ID',
period_no: 'Round No.',
period_start_at: 'Start time',
status: 'Status',
'status 0': 'Betting open',
'status 1': 'Closed',
'status 2': 'Settling',
'status 3': 'Paying',
'status 4': 'Ended',
'status 5': 'Void',
draw_mode: 'Draw mode',
'draw_mode 0': 'Auto AI',
'draw_mode 1': 'Manual preset',
preset_number: 'Preset number',
result_number: 'Result number',
void_reason: 'Void reason',
create_time: 'Created',
update_time: 'Updated',
section_auto: 'Auto draw & new round',
auto_create_label: 'Allow auto-create next round',
auto_create_tip: 'When enabled, ticker inserts next round if no active one exists',
manual_create_label: 'Allow manual create next round',
manual_create_tip: 'When enabled, button below can create next round manually',
btn_create_next: 'Create next round (manual)',
}

View File

@@ -0,0 +1,45 @@
export default {
id: 'ID',
username: 'Username',
password: 'Password',
uuid: 'UUID',
phone: 'Phone',
email: 'Email',
email_placeholder: 'Optional (register with phone or email)',
head_image: 'Avatar',
remark: 'Remark',
coin: 'Coin balance',
coin_placeholder: 'decimal(18,4)',
total_deposit_coin: 'Total deposit (coin)',
total_valid_bet_coin: 'Total valid bet (coin)',
risk_flags: 'Risk',
risk_none: 'None',
risk_no_login: 'No login',
risk_no_bet: 'No bet',
risk_no_withdraw: 'No withdraw',
current_streak: 'Win streak',
last_bet_period_no: 'Last bet period',
last_bet_period_no_placeholder: 'DB fallback for streak',
register_invite_code: 'Invite code (snapshot)',
register_invite_code_placeholder: 'Invite code at registration',
status: 'Status',
'status 0': 'Disabled',
'status 1': 'Enabled',
section_admin_attribution: 'Administrator',
admin_affiliation: 'Assigned admin',
admin_affiliation_placeholder: 'Role group tree — only admins in your scope',
register_invite_code_auto_placeholder: 'Filled from selected admin invite code',
channel_id: 'Channel',
channel__name: 'Channel',
admin_id: 'Admin',
admin__username: 'Admin',
create_time: 'Created',
update_time: 'Updated',
'quick Search Fields': 'ID, username, phone, email, invite code',
section_basic: 'Account',
section_register: 'Registration',
section_finance: 'Balance & turnover',
section_risk: 'Risk control',
section_streak: 'Streak (fallback)',
section_other: 'Other',
}

View File

@@ -0,0 +1,33 @@
export default {
id: 'Record ID',
user_id: 'User ID',
'user__username': 'Username',
'channel__name': 'Channel',
biz_type: 'Biz type',
direction: 'Direction',
amount: 'Amount',
balance_before: 'Balance before',
balance_after: 'Balance after',
ref_type: 'Ref type',
ref_id: 'Ref ID',
idempotency_key: 'Idempotency key',
'operator_admin__username': 'Operator',
remark: 'Remark',
create_time: 'Created at',
'quick Search Fields': 'ID, biz type, ref type, remark, idempotency key',
'direction in': 'Credit',
'direction out': 'Debit',
'biz deposit': 'Deposit',
'biz withdraw': 'Withdraw',
'biz withdraw_freeze': 'Withdraw freeze',
'biz withdraw_unfreeze': 'Withdraw unfreeze',
'biz platform_in': 'Platform credit',
'biz platform_out': 'Platform debit',
'biz admin_credit': 'Admin credit',
'biz admin_deduct': 'Admin debit',
'biz bet': 'Bet',
'biz payout': 'Payout',
'biz fee': 'Fee',
'biz void_refund': 'Void refund',
'biz adjust': 'Adjustment',
}

View File

@@ -0,0 +1,16 @@
export default {
title: '36 Zi Hua dictionary',
desc: 'Maintain the display names and grouping of the 36 entries. Changes affect player selection display, related filters/statistics, and admin mapping references. Please review before saving.',
no: 'No.',
name: 'Name',
category: 'Category',
btn_save: 'Save',
category_label: {
zodiac: 'Zodiac',
beast: 'Beast',
fowl: 'Fowl',
vermin: 'Vermin',
divine: 'Divine',
},
}

View File

@@ -1,9 +0,0 @@
export default {
id: 'id',
username: 'username',
password: 'password',
create_time: 'create_time',
update_time: 'update_time',
score: 'score',
quickSearchFields: 'id',
}

View File

@@ -0,0 +1,15 @@
export default {
'quick Search Fields': 'ID/Title',
id: 'ID',
title: 'Title',
content: 'Content',
notice_type: 'Notice type',
'notice_type 0': 'Inbox (silent)',
'notice_type 1': 'Pop-up',
status: 'Status',
'status 0': 'Draft',
'status 1': 'Published',
publish_at: 'Publish time',
create_time: 'Created',
update_time: 'Updated',
}

View File

@@ -0,0 +1,13 @@
export default {
'quick Search Fields': 'ID/User ID/Notice ID',
id: 'ID',
user_id: 'User',
notice_id: 'Notice',
notice_title: 'Notice title',
username: 'Username',
read_at: 'Read at',
confirmed: 'Confirmed',
'confirmed 0': 'No',
'confirmed 1': 'Yes',
create_time: 'Created',
}

View File

@@ -0,0 +1,35 @@
export default {
'quick Search Fields': 'ID / Period / Idempotency',
id: 'ID',
period_id: 'Period ID',
period_no: 'Period No.',
user_id: 'User ID',
channel_id: 'Channel ID',
pick_numbers: 'Picks',
unit_amount: 'Unit amount',
pick_count: 'Pick count',
total_amount: 'Total',
streak_at_bet: 'Streak at bet',
is_auto: 'Auto',
'is_auto 0': 'Manual',
'is_auto 1': 'Auto bet',
win_amount: 'Payout',
jackpot_extra_amount: 'Jackpot extra',
status: 'Status',
'status 1': 'Pending draw',
'status 2': 'Settled',
'status 3': 'Refunded',
idempotency_key: 'Idempotency key',
create_time: 'Created',
update_time: 'Updated',
gameRecord_period_no: 'Round (relation)',
gameRecord_status: 'Round status',
'gameRecord_status 0': 'Open for betting',
'gameRecord_status 1': 'Closed',
'gameRecord_status 2': 'Settling tickets',
'gameRecord_status 3': 'Paying out',
'gameRecord_status 4': 'Finished',
'gameRecord_status 5': 'Voided',
user_username: 'Username',
channel_name: 'Channel',
}

View File

@@ -0,0 +1,20 @@
export default {
'quick Search Fields': 'Order No./User ID/Pay channel',
id: 'ID',
order_no: 'Order No.',
user_id: 'User ID',
channel_id: 'Channel ID',
amount: 'Amount',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Success',
'status 2': 'Failed',
'status 3': 'Canceled',
pay_channel: 'Pay channel',
pay_time: 'Pay time',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
user_username: 'Username',
channel_name: 'Channel',
}

View File

@@ -0,0 +1,23 @@
export default {
'quick Search Fields': 'Order No./User ID',
id: 'ID',
order_no: 'Order No.',
user_id: 'User ID',
channel_id: 'Channel ID',
amount: 'Apply amount',
fee: 'Fee',
actual_amount: 'Actual amount',
status: 'Status',
'status 0': 'Pending review',
'status 1': 'Approved',
'status 2': 'Rejected',
'status 3': 'Paid',
review_admin_id: 'Reviewer',
review_time: 'Review time',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
user_username: 'Username',
channel_name: 'Channel',
review_admin_username: 'Reviewer',
}

View File

@@ -0,0 +1,33 @@
export default {
id: 'Record ID',
user_id: 'User ID',
'user__username': 'Username',
'channel__name': 'Channel',
biz_type: 'Biz type',
direction: 'Direction',
amount: 'Amount',
balance_before: 'Balance before',
balance_after: 'Balance after',
ref_type: 'Ref type',
ref_id: 'Ref ID',
idempotency_key: 'Idempotency key',
'operator_admin__username': 'Operator',
remark: 'Remark',
create_time: 'Created at',
'quick Search Fields': 'ID, biz type, ref type, remark, idempotency key',
'direction in': 'Credit',
'direction out': 'Debit',
'biz deposit': 'Deposit',
'biz withdraw': 'Withdraw',
'biz withdraw_freeze': 'Withdraw freeze',
'biz withdraw_unfreeze': 'Withdraw unfreeze',
'biz platform_in': 'Platform credit',
'biz platform_out': 'Platform debit',
'biz admin_credit': 'Admin credit',
'biz admin_deduct': 'Admin debit',
'biz bet': 'Bet',
'biz payout': 'Payout',
'biz fee': 'Fee',
'biz void_refund': 'Void refund',
'biz adjust': 'Adjustment',
}

View File

@@ -1,5 +0,0 @@
export default {
GroupName: 'Group name',
'Group name': 'Group name',
jurisdiction: 'Permissions',
}

View File

@@ -1,16 +0,0 @@
export default {
'User name': 'Username',
'User nickname': 'User nickname',
balance: 'Balance',
'User ID': 'User ID',
'Change balance': 'Change balance',
'Before change': 'Before the change',
'After change': 'After the change',
remarks: 'Remark',
'Current balance': 'Current balance',
'Change amount': 'Change amount',
'Please enter the balance change amount': 'Please enter the balance change amount.',
'Balance after change': 'Balance after change',
'Please enter change remarks / description': 'Please enter change remarks/description',
User: 'User',
}

View File

@@ -1,26 +0,0 @@
export default {
'Normal routing': 'Normal routing',
'Member center menu contents': 'Member center menu directory ',
'Member center menu items': 'Member Center menu items',
'Top bar menu items': 'Top bar menu items',
'Page button': 'Page button',
'Top bar user dropdown': 'Top bar user dropdown',
'Type route tips': 'Automatically register as a front-end route',
'Type menu_dir tips': 'Automatically register routes and serve as menu directory of member center This item cannot jump',
'Type menu tips': 'Automatically register routes and serve as menu items in member centers',
'Type nav tips': 'Routes are automatically registered as menu items in the top bar of the site',
'Type button tips': 'Automatic registration as a permission node, can be quickly verified by v-auth',
'Type nav_user_menu tips': 'Automatically register routes and serve as a dropdown menu for top bar members',
'English name': 'English name',
'Web side routing path': 'Web side routing path',
no_login_valid: 'no login valid',
'no_login_valid 0': 'no',
'no_login_valid 1': 'yes',
'no_login_valid tips': 'Tourists do not have membership groups Use this option to set whether the current rules are valid for tourists (visible)',
'For example, if you add account/overview as a route only':
'Please start with /src for web side component paths, such as: /src/views/frontend/index.vue',
'Web side component path, please start with /src, such as: /src/views/frontend/index':
"For example, if you add 'account/overview' as a route only, then you can additionally add 'account/overview', 'account/overview/:a' and 'account/overview/:b/:C' as menus only.",
'Component path tips':
'This item is mandatory within a WEB project; otherwise, it cannot be accessed. However, when it is used as a menu within a Nuxt project, there is no need to fill in this item',
}

View File

@@ -1,8 +0,0 @@
export default {
integral: 'Integral',
'Change points': 'Change points',
'Current points': 'Current points',
'Please enter the change amount of points': 'Please enter the change amount of points',
'Points after change': 'Points after change',
'Please enter change remarks / description': 'Please enter change remarks/description',
}

View File

@@ -1,22 +1,45 @@
export default { export default {
'User name': 'Username', id: 'ID',
nickname: 'Nickname', username: 'Username',
group: 'Group',
avatar: 'Avatar',
Gender: 'Gender',
male: 'Male',
female: 'Female',
mobile: 'Mobile Number',
'Last login IP': 'Last login IP',
'Last login': 'Last login',
email: 'Email',
birthday: 'Birthday',
balance: 'Balance',
'Adjustment balance': 'Adjust balance',
integral: 'Integral',
'Adjust integral': 'Adjust integral',
password: 'Password', password: 'Password',
'Please leave blank if not modified': 'Please leave blank if you do not modify', uuid: 'UUID',
'Personal signature': 'Personal signature', phone: 'Phone',
'Login account': 'Login account name', email: 'Email',
email_placeholder: 'Optional (register with phone or email)',
head_image: 'Avatar',
remark: 'Remark',
coin: 'Coin balance',
coin_placeholder: 'decimal(18,4)',
total_deposit_coin: 'Total deposit (coin)',
total_valid_bet_coin: 'Total valid bet (coin)',
risk_flags: 'Risk',
risk_none: 'None',
risk_no_login: 'No login',
risk_no_bet: 'No bet',
risk_no_withdraw: 'No withdraw',
current_streak: 'Win streak',
last_bet_period_no: 'Last bet period',
last_bet_period_no_placeholder: 'DB fallback for streak',
register_invite_code: 'Invite code (snapshot)',
register_invite_code_placeholder: 'Invite code at registration',
status: 'Status',
'status 0': 'Disabled',
'status 1': 'Enabled',
section_admin_attribution: 'Administrator',
admin_affiliation: 'Assigned admin',
admin_affiliation_placeholder: 'Role group tree — only admins in your scope',
register_invite_code_auto_placeholder: 'Filled from selected admin invite code',
channel_id: 'Channel',
channel__name: 'Channel',
admin_id: 'Admin',
admin__username: 'Admin',
create_time: 'Created',
update_time: 'Updated',
'quick Search Fields': 'ID, username, phone, email, invite code',
section_basic: 'Account',
section_register: 'Registration',
section_finance: 'Balance & turnover',
section_risk: 'Risk control',
section_streak: 'Streak (fallback)',
section_other: 'Other',
} }

View File

@@ -0,0 +1,22 @@
export default {
'quick Search Fields': 'ID/结算周期ID/备注',
id: 'ID',
settlement_period_id: '结算周期',
settlement_period_no: '结算周期号',
channel_id: '渠道',
channel_name: '渠道名称',
admin_id: '代理管理员',
admin_username: '代理账号',
commission_rate: '佣金比例',
calc_base_amount: '结算基数',
commission_amount: '佣金金额',
status: '状态',
'status 0': '待发放',
'status 1': '已发放',
'status 2': '已回退',
settled_at: '发放时间',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -0,0 +1,19 @@
export default {
'quick Search Fields': 'ID/结算周期号/备注',
id: 'ID',
settlement_no: '结算周期号',
period_start_at: '周期开始',
period_end_at: '周期结束',
total_bet_amount: '总投注额',
total_payout_amount: '总派彩额',
platform_profit_amount: '平台盈亏',
status: '状态',
'status 0': '待结算',
'status 1': '结算中',
'status 2': '已完成',
'status 3': '已关闭',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -5,6 +5,13 @@ export default {
avatar: '头像', avatar: '头像',
email: '电子邮箱', email: '电子邮箱',
mobile: '手机号', mobile: '手机号',
invite_code: '邀请码',
commission_rate: '分红比(%)',
commission_rate_desc_title: '管理员分红说明',
commission_rate_desc_1: '管理员分红用于该管理员在所属角色组内的分配比例。',
commission_rate_desc_2: '当前管理员分红=当前角色分红×当前管理员分红比例。',
commission_rate_desc_3: '同一角色组内管理员分红比例总和不能超过100%;超额会提示超出值与剩余额度。',
'Please select exactly one group': '请选择且仅选择一个角色组',
'Last login': '最后登录', 'Last login': '最后登录',
Password: '密码', Password: '密码',
'Please leave blank if not modified': '不修改请留空', 'Please leave blank if not modified': '不修改请留空',

View File

@@ -1,6 +1,11 @@
export default { export default {
GroupName: '组名', GroupName: '组名',
'Group name': '组别名称', 'Group name': '组别名称',
commission_rate: '分红比例(%)',
commission_rate_desc_title: '角色组分红说明',
commission_rate_desc_1: '同一父级下角色组分红比例总和不能超过100%。',
commission_rate_desc_2: '当前角色分红=渠道设置获取分红×(1-上级角色分红比例)×当前角色分红比例。',
commission_rate_desc_3: '提交超额时,系统会提示超出值与当前父级剩余额度。',
jurisdiction: '权限', jurisdiction: '权限',
'Parent group': '上级分组', 'Parent group': '上级分组',
'The parent group cannot be the group itself': '上级分组不能是分组本身', 'The parent group cannot be the group itself': '上级分组不能是分组本身',

View File

@@ -0,0 +1,78 @@
export default {
id: 'ID',
code: '渠道标识',
invite_code: '渠道邀请码',
name: '渠道名',
agent_mode: '代理模式',
'agent_mode turnover': '普通返水代理',
'agent_mode affiliate': '联营代理',
agent_mode_title_turnover: '普通返水代理(按流水分桶)',
agent_mode_desc_turnover_1: '结算口径仅在平台大盘盈利时参与分红大盘亏损或持平时当期分红为0。',
agent_mode_desc_turnover_2: '计算方式:渠道分桶基数 × 返水分红比例turnover_share_rate。',
agent_mode_desc_turnover_3: '字段说明:仅需填写返水分红比例;切换到该模式会自动清空联营相关字段。',
agent_mode_title_affiliate: '联营代理(客损占成)',
agent_mode_desc_affiliate_1: '结算口径:按该渠道辖区净客损结算,支持联营占成分账。',
agent_mode_desc_affiliate_2: '计算方式:净客损扣除成本后 × 联营占成比例affiliate_share_rate。',
agent_mode_desc_affiliate_3: '字段说明:可配置联营成本扣除比例与负结转余额;切换到该模式会自动清空返水比例。',
turnover_share_rate: '返水分红比例',
affiliate_share_rate: '联营占成比例',
affiliate_fee_rate: '联营成本扣除比例',
affiliate_contract_no: '联营契约编号',
affiliate_contract_name: '联营契约名称',
settle_cycle: '结算周期',
settle_cycle_placeholder: '请选择结算周期(可细化到周几/几号)',
'settle_cycle daily': '日结',
'settle_cycle weekly': '周结',
'settle_cycle monthly': '月结',
settle_weekday: '结算周几',
settle_time: '结算时间',
'weekday 1': '周一',
'weekday 2': '周二',
'weekday 3': '周三',
'weekday 4': '周四',
'weekday 5': '周五',
'weekday 6': '周六',
'weekday 7': '周日',
affiliate_effective_start_at: '联营生效开始',
affiliate_effective_end_at: '联营生效结束',
affiliate_ladder_rules: '联营阶梯规则',
affiliate_ladder_rules_placeholder: '请输入 JSON例如 [{\"minLoss\":\"0.0000\",\"shareRate\":\"0.200000\"}]',
ladder_min_loss: '起始客损',
ladder_share_rate: '占成比例',
ladder_rule_required: '联营阶梯规则至少需要一条',
ladder_min_loss_invalid: '联营阶梯规则起始客损必须为大于等于0的数字',
ladder_share_rate_invalid: '联营阶梯规则占成比例必须在0到1之间',
ladder_min_loss_order_invalid: '联营阶梯规则需按起始客损递增',
settle_day_daily: '每天',
day_suffix: '号',
carryover_balance: '联营负结转余额',
user_count: '用户数',
profit_amount: '当期利润',
total_profit_amount: '累计总利润',
commission_pool_amount: '可分红资金池',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
remark: '备注',
admin_group_id: '管理角色组',
admingroup__name: '组名',
admin_id: '管理员',
admin_tree_tip: '按渠道分组展示可关联的管理员账号,请在「渠道名称」下选择具体管理员(叶子节点)。渠道负责人仅对应一名管理员。',
manual_settle: '手动结算',
manual_settle_confirm: '确认触发当前渠道手动结算?',
manual_settle_settlement_no: '结算单号',
manual_settle_period_start: '周期开始',
manual_settle_period_end: '周期结束(当前时间)',
manual_settle_total_bet: '总投注额(已结算注单)',
manual_settle_total_payout: '总派彩额',
manual_settle_platform_profit: '平台盈亏',
manual_settle_commission_rate: '佣金比例(小数)',
manual_settle_calc_base: '结算基数',
manual_settle_commission_amount: '佣金金额',
manual_settle_remark: '备注',
admin_id_placeholder: '请选择渠道下的管理员账号',
admin__username: '用户名',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、渠道标识、渠道名',
}

View File

@@ -0,0 +1,3 @@
import gameConfig from '../game/config'
export default gameConfig

View File

@@ -0,0 +1,3 @@
import dict from '../game/ziHuaDictionary'
export default dict

View File

@@ -0,0 +1,29 @@
export default {
'quick Search Fields': 'ID/期号/幂等键',
id: 'ID',
period_id: '对局ID',
period_no: '期号',
user_id: '用户ID',
channel_id: '渠道ID',
pick_numbers: '选号',
unit_amount: '单号金额',
pick_count: '选号个数',
total_amount: '总金额',
streak_at_bet: '下注时连胜',
is_auto: '托管',
'is_auto 0': '手动',
'is_auto 1': '托管',
win_amount: '派彩',
jackpot_extra_amount: 'Jackpot',
status: '状态',
'status 1': '待开奖',
'status 2': '已结算',
'status 3': '已退款',
idempotency_key: '幂等键',
create_time: '创建时间',
update_time: '更新时间',
gameRecord_period_no: '对局期号',
gameRecord_status: '期状态',
user_username: '用户名',
channel_name: '渠道',
}

View File

@@ -0,0 +1,14 @@
export default {
'quick Search Fields': 'ID/参数键/说明',
id: 'ID',
config_key: '参数键',
config_value: '参数值',
value_type: '值类型',
'value_type string': '字符串',
'value_type int': '整数',
'value_type decimal': '小数',
'value_type json': 'JSON',
remark: '说明',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -0,0 +1,23 @@
export default {
tip: '实时监听页面推送的压注记录并展示AI默认最优开奖号码平台预估亏损最少',
current_record: '当前对局',
ai_default_number: 'AI默认开奖号码',
countdown: '倒计时',
bet_countdown: '下注剩余',
draw_countdown: '开奖剩余',
btn_calc: '计算法盈亏',
btn_draw: '开奖',
calc_result_number: '计算开奖号码',
calc_estimated_loss: '计算预估赔付',
push_connected: '推送服务已连接,页面数据实时更新中',
push_disconnected: '推送服务连接中断,请检查服务是否启动',
candidate_title: '候选号码赔付预估',
number: '号码',
estimated_loss: '预估赔付',
bet_stream_title: '实时压注记录',
bet_id: '注单ID',
user_id: '玩家ID',
pick_numbers: '压注号码',
unit_amount: '单号金额',
streak_at_bet: '下注时连胜',
}

View File

@@ -0,0 +1,28 @@
export default {
'quick Search Fields': '期号/ID',
id: 'ID',
period_no: '期号',
period_start_at: '开始时间',
status: '状态',
'status 0': '下注开放',
'status 1': '已封盘',
'status 2': '算票中',
'status 3': '派彩中',
'status 4': '已结束',
'status 5': '已作废',
draw_mode: '开奖方式',
'draw_mode 0': '自动AI',
'draw_mode 1': '手动预设',
preset_number: '预设号码',
result_number: '开奖号码',
void_reason: '作废原因',
create_time: '创建时间',
update_time: '更新时间',
section_auto: '自动开奖与新建期',
auto_create_label: '允许自动创建下一期',
auto_create_tip: '开启后由后台定时任务在无进行中期号时自动插入新期',
manual_create_label: '允许手动创建下一期',
manual_create_tip: '开启后可在本页使用「手动创建下一期」按钮',
btn_create_next: '手动创建下一期',
saving: '保存中…',
}

View File

@@ -0,0 +1,27 @@
export default {
'quick Search Fields': '局号/ID',
id: 'ID',
period_no: '局号',
period_start_at: '开始时间',
status: '状态',
'status 0': '下注开放',
'status 1': '已封盘',
'status 2': '算票中',
'status 3': '派彩中',
'status 4': '已结束',
'status 5': '已作废',
draw_mode: '开奖方式',
'draw_mode 0': '自动AI',
'draw_mode 1': '手动预设',
preset_number: '预设号码',
result_number: '开奖号码',
void_reason: '作废原因',
create_time: '创建时间',
update_time: '更新时间',
section_auto: '自动开奖与新建对局',
auto_create_label: '允许自动创建下一局',
auto_create_tip: '开启后由后台定时任务在无进行中对局时自动插入新局',
manual_create_label: '允许手动创建下一局',
manual_create_tip: '开启后可在本页使用「手动创建下一局」按钮',
btn_create_next: '手动创建下一局',
}

View File

@@ -0,0 +1,45 @@
export default {
id: 'ID',
username: '用户名',
password: '密码',
uuid: '用户唯一标识',
phone: '手机号',
email: '邮箱',
email_placeholder: '可选,与手机号二选一注册时填写',
head_image: '头像',
remark: '备注',
coin: '游戏币余额',
coin_placeholder: 'decimal(18,4),禁止业务用浮点存库',
total_deposit_coin: '累计充值(币)',
total_valid_bet_coin: '累计有效投注(币)',
risk_flags: '风控',
risk_none: '无限制',
risk_no_login: '禁止登录',
risk_no_bet: '禁止下注',
risk_no_withdraw: '禁止提现',
current_streak: '当前连胜',
last_bet_period_no: '最近下注期号',
last_bet_period_no_placeholder: '连胜兜底同步用,可与 Redis 对照',
register_invite_code: '注册邀请码快照',
register_invite_code_placeholder: '注册时绑定渠道/代理邀请码',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
section_admin_attribution: '管理员归属',
admin_affiliation: '归属管理员',
admin_affiliation_placeholder: '按角色组展开,仅展示您可管理范围内的管理员',
register_invite_code_auto_placeholder: '随所选管理员邀请码自动带出',
channel_id: '所属渠道',
channel__name: '渠道名',
admin_id: '归属管理员',
admin__username: '管理员',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、用户名、手机号、邮箱、邀请码',
section_basic: '账号信息',
section_register: '注册与邀请',
section_finance: '资金与流水',
section_risk: '风控',
section_streak: '连胜(兜底)',
section_other: '其他',
}

View File

@@ -0,0 +1,33 @@
export default {
id: '流水ID',
user_id: '用户ID',
'user__username': '用户名',
'channel__name': '渠道',
biz_type: '业务类型',
direction: '方向',
amount: '变动额',
balance_before: '变动前余额',
balance_after: '变动后余额',
ref_type: '关联类型',
ref_id: '关联ID',
idempotency_key: '幂等键',
'operator_admin__username': '操作管理员',
remark: '备注',
create_time: '创建时间',
'quick Search Fields': 'ID、业务类型、关联类型、备注、幂等键',
'direction in': '入金',
'direction out': '出金',
'biz deposit': '充值入账',
'biz withdraw': '提现出账',
'biz withdraw_freeze': '提现冻结',
'biz withdraw_unfreeze': '提现解冻',
'biz platform_in': '平台划入',
'biz platform_out': '平台划出',
'biz admin_credit': '管理员加币',
'biz admin_deduct': '管理员扣币',
'biz bet': '下注扣款',
'biz payout': '派彩',
'biz fee': '手续费',
'biz void_refund': '作废退款',
'biz adjust': '其他调账',
}

View File

@@ -0,0 +1,16 @@
export default {
title: '36字花字典',
desc: '用于维护 36 字花的展示名称与归类。修改后会影响前台选号展示、相关筛选统计及后台对照信息,请确认无误后再保存。',
no: '编号',
name: '名称',
category: '类型',
btn_save: '保存',
category_label: {
zodiac: '生肖',
beast: '猛兽',
fowl: '飞禽',
vermin: '虫蛇',
divine: '神兽',
},
}

View File

@@ -1,9 +0,0 @@
export default {
id: 'ID',
username: '用户名',
password: '密码',
create_time: '创建时间',
update_time: '修改时间',
score: '积分',
quickSearchFields: 'ID',
}

View File

@@ -0,0 +1,15 @@
export default {
'quick Search Fields': 'ID/标题',
id: 'ID',
title: '标题',
content: '正文',
notice_type: '公告类型',
'notice_type 0': '静默信箱',
'notice_type 1': '强弹窗',
status: '状态',
'status 0': '草稿',
'status 1': '发布',
publish_at: '发布时间',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -0,0 +1,13 @@
export default {
'quick Search Fields': 'ID/用户ID/公告ID',
id: 'ID',
user_id: '用户',
notice_id: '公告',
notice_title: '公告标题',
username: '用户名',
read_at: '阅读时间',
confirmed: '已确认',
'confirmed 0': '未确认',
'confirmed 1': '已确认',
create_time: '创建时间',
}

View File

@@ -0,0 +1,35 @@
export default {
'quick Search Fields': 'ID/期号/幂等键',
id: 'ID',
period_id: '对局ID',
period_no: '期号',
user_id: '用户ID',
channel_id: '渠道ID',
pick_numbers: '选号',
unit_amount: '单号金额',
pick_count: '选号个数',
total_amount: '总金额',
streak_at_bet: '下注时连胜',
is_auto: '托管',
'is_auto 0': '手动',
'is_auto 1': '托管',
win_amount: '派彩',
jackpot_extra_amount: 'Jackpot',
status: '状态',
'status 1': '待开奖',
'status 2': '已结算',
'status 3': '已退款',
idempotency_key: '幂等键',
create_time: '创建时间',
update_time: '更新时间',
gameRecord_period_no: '对局期号',
gameRecord_status: '期状态',
'gameRecord_status 0': '下注开放',
'gameRecord_status 1': '已封盘',
'gameRecord_status 2': '算票中',
'gameRecord_status 3': '派彩中',
'gameRecord_status 4': '已结束',
'gameRecord_status 5': '已作废',
user_username: '用户名',
channel_name: '渠道',
}

View File

@@ -0,0 +1,20 @@
export default {
'quick Search Fields': '订单号/用户ID/支付通道',
id: 'ID',
order_no: '订单号',
user_id: '用户ID',
user_username: '用户名',
channel_id: '渠道ID',
channel_name: '渠道',
amount: '金额',
status: '状态',
'status 0': '待处理',
'status 1': '成功',
'status 2': '失败',
'status 3': '已取消',
pay_channel: '支付通道',
pay_time: '支付时间',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
}

View File

@@ -0,0 +1,23 @@
export default {
'quick Search Fields': '订单号/用户ID',
id: 'ID',
order_no: '订单号',
user_id: '用户ID',
channel_id: '渠道ID',
amount: '申请金额',
fee: '手续费',
actual_amount: '实际到账',
status: '状态',
'status 0': '待审核',
'status 1': '已通过',
'status 2': '已拒绝',
'status 3': '已打款',
review_admin_id: '审核管理员',
review_time: '审核时间',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
user_username: '用户名',
channel_name: '渠道',
review_admin_username: '审核人',
}

View File

@@ -0,0 +1,33 @@
export default {
id: '流水ID',
user_id: '用户ID',
'user__username': '用户名',
'channel__name': '渠道',
biz_type: '业务类型',
direction: '方向',
amount: '变动额',
balance_before: '变动前余额',
balance_after: '变动后余额',
ref_type: '关联类型',
ref_id: '关联ID',
idempotency_key: '幂等键',
'operator_admin__username': '操作管理员',
remark: '备注',
create_time: '创建时间',
'quick Search Fields': 'ID、业务类型、关联类型、备注、幂等键',
'direction in': '入金',
'direction out': '出金',
'biz deposit': '充值入账',
'biz withdraw': '提现出账',
'biz withdraw_freeze': '提现冻结',
'biz withdraw_unfreeze': '提现解冻',
'biz platform_in': '平台划入',
'biz platform_out': '平台划出',
'biz admin_credit': '管理员加币',
'biz admin_deduct': '管理员扣币',
'biz bet': '下注扣款',
'biz payout': '派彩',
'biz fee': '手续费',
'biz void_refund': '作废退款',
'biz adjust': '其他调账',
}

View File

@@ -1,5 +0,0 @@
export default {
GroupName: '组名',
'Group name': '组别名称',
jurisdiction: '权限',
}

View File

@@ -1,16 +0,0 @@
export default {
'User name': '用户名',
'User nickname': '用户昵称',
balance: '余额',
'User ID': '用户ID',
'Change balance': '变更余额',
'Before change': '变更前',
'After change': '变更后',
remarks: '备注',
'Current balance': '当前余额',
'Change amount': '变动数额',
'Please enter the balance change amount': '请输入余额变更数额',
'Balance after change': '变更后余额',
'Please enter change remarks / description': '请输入变更备注/说明',
User: '用户',
}

View File

@@ -1,24 +0,0 @@
export default {
'Normal routing': '普通路由',
'Member center menu contents': '会员中心菜单目录',
'Member center menu items': '会员中心菜单项',
'Top bar menu items': '顶栏菜单项',
'Page button': '页面按钮',
'Top bar user dropdown': '顶栏会员菜单下拉项',
'Type route tips': '自动注册为前端路由',
'Type menu_dir tips': '自动注册路由,并作为会员中心的菜单目录,此项本身不可跳转',
'Type menu tips': '自动注册路由,并作为会员中心的菜单项目',
'Type nav tips': '自动注册路由,并作为站点顶栏的菜单项目',
'Type button tips': '自动注册为权限节点,可通过 v-auth 快速验权',
'Type nav_user_menu tips': '自动注册路由,并作为顶栏会员菜单下拉项',
'English name': '英文名称',
'Web side routing path': 'WEB 端路由路径vue-router 的 path',
no_login_valid: '未登录有效',
'no_login_valid 0': '游客无效',
'no_login_valid 1': '游客有效',
'no_login_valid tips': '游客没有会员分组,通过本选项设置当前规则是否对游客有效(可见)',
'For example, if you add account/overview as a route only': 'WEB 端组件路径,请以 /src 开头,如:/src/views/frontend/index.vue',
'Web side component path, please start with /src, such as: /src/views/frontend/index':
'比如将 `account/overview` 只添加为路由,那么可以另外将 `account/overview`、`account/overview/:a`、`account/overview/:b/:c` 只添加为菜单',
'Component path tips': '组件路径在 WEB 工程内是必填的,否则无法访问,但作为 Nuxt 工程内的菜单时,无需填写此项,请根据菜单使用场景填写',
}

View File

@@ -1,8 +0,0 @@
export default {
integral: '积分',
'Change points': '变更积分',
'Current points': '当前积分',
'Please enter the change amount of points': '请输入积分变更数额',
'Points after change': '变更后积分',
'Please enter change remarks / description': '请输入变更备注/说明',
}

View File

@@ -1,22 +1,45 @@
export default { export default {
'User name': '用户名', id: 'ID',
nickname: '昵称', username: '用户名',
group: '分组',
avatar: '头像',
Gender: '性别',
male: '男',
female: '女',
mobile: '手机号',
'Last login IP': '最后登录IP',
'Last login': '最后登录',
email: '电子邮箱',
birthday: '生日',
balance: '余额',
'Adjustment balance': '调整余额',
integral: '积分',
'Adjust integral': '调整积分',
password: '密码', password: '密码',
'Please leave blank if not modified': '不修改请留空', uuid: '用户唯一标识',
'Personal signature': '个性签名', phone: '手机号',
'Login account': '登录账户名', email: '邮箱',
email_placeholder: '可选,与手机号二选一注册时填写',
head_image: '头像',
remark: '备注',
coin: '游戏币余额',
coin_placeholder: 'decimal(18,4),禁止业务用浮点存库',
total_deposit_coin: '累计充值(币)',
total_valid_bet_coin: '累计有效投注(币)',
risk_flags: '风控',
risk_none: '无限制',
risk_no_login: '禁止登录',
risk_no_bet: '禁止下注',
risk_no_withdraw: '禁止提现',
current_streak: '当前连胜',
last_bet_period_no: '最近下注期号',
last_bet_period_no_placeholder: '连胜兜底同步用,可与 Redis 对照',
register_invite_code: '注册邀请码快照',
register_invite_code_placeholder: '注册时绑定渠道/代理邀请码',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
section_admin_attribution: '管理员归属',
admin_affiliation: '归属管理员',
admin_affiliation_placeholder: '按角色组展开,仅展示您可管理范围内的管理员',
register_invite_code_auto_placeholder: '随所选管理员邀请码自动带出',
channel_id: '所属渠道',
channel__name: '渠道名',
admin_id: '归属管理员',
admin__username: '管理员',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、用户名、手机号、邮箱、邀请码',
section_basic: '账号信息',
section_register: '注册与邀请',
section_finance: '资金与流水',
section_risk: '风控',
section_streak: '连胜(兜底)',
section_other: '其他',
} }

View File

@@ -37,6 +37,12 @@ router.beforeEach((to, from, next) => {
// 按需动态加载页面的语言包-start // 按需动态加载页面的语言包-start
let loadPath: string[] = [] let loadPath: string[] = []
const toCamelPath = (path: string): string => {
return path
.split('/')
.map((seg) => seg.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase()))
.join('/')
}
const config = useConfig() const config = useConfig()
if (to.path in langAutoLoadMap) { if (to.path in langAutoLoadMap) {
loadPath.push(...langAutoLoadMap[to.path as keyof typeof langAutoLoadMap]) loadPath.push(...langAutoLoadMap[to.path as keyof typeof langAutoLoadMap])
@@ -47,7 +53,10 @@ router.beforeEach((to, from, next) => {
// 去除 path 中的 /admin // 去除 path 中的 /admin
const adminPath = to.path.slice(to.path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length) const adminPath = to.path.slice(to.path.indexOf(adminBaseRoutePath) + adminBaseRoutePath.length)
if (adminPath) loadPath.push(prefix + adminPath + '.ts') if (adminPath) {
loadPath.push(prefix + adminPath + '.ts')
loadPath.push(prefix + toCamelPath(adminPath) + '.ts')
}
} else { } else {
prefix = './frontend/' + config.lang.defaultLang prefix = './frontend/' + config.lang.defaultLang
loadPath.push(prefix + to.path + '.ts') loadPath.push(prefix + to.path + '.ts')
@@ -55,7 +64,9 @@ router.beforeEach((to, from, next) => {
// 根据路由 name 加载的语言包 // 根据路由 name 加载的语言包
if (to.name) { if (to.name) {
loadPath.push(prefix + '/' + to.name.toString() + '.ts') const routeName = to.name.toString()
loadPath.push(prefix + '/' + routeName + '.ts')
loadPath.push(prefix + '/' + toCamelPath(routeName) + '.ts')
} }
if (!window.loadLangHandle.publicMessageLoaded) window.loadLangHandle.publicMessageLoaded = [] if (!window.loadLangHandle.publicMessageLoaded) window.loadLangHandle.publicMessageLoaded = []

View File

@@ -100,6 +100,16 @@ export const useTerminal = defineStore(
} }
function addTask(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) { function addTask(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
const duplicatePending = state.taskList.some(
(item) =>
item.command === command &&
(item.status === taskStatus.Waiting ||
item.status === taskStatus.Connecting ||
item.status === taskStatus.Executing)
)
if (duplicatePending) {
return
}
if (!state.show) toggleDot(true) if (!state.show) toggleDot(true)
state.taskList = state.taskList.concat({ state.taskList = state.taskList.concat({
uuid: uuid(), uuid: uuid(),

View File

@@ -1,6 +1,7 @@
import type { AxiosRequestConfig, Method } from 'axios' import type { AxiosRequestConfig, Method } from 'axios'
import axios from 'axios' import axios from 'axios'
import { ElLoading, ElNotification, type LoadingOptions } from 'element-plus' import { ElLoading, ElNotification, type LoadingOptions } from 'element-plus'
import { nextTick } from 'vue'
import { refreshToken } from '/@/api/common' import { refreshToken } from '/@/api/common'
import { i18n } from '/@/lang/index' import { i18n } from '/@/lang/index'
import router from '/@/router/index' import router from '/@/router/index'
@@ -20,6 +21,12 @@ const loadingInstance: LoadingInstance = {
count: 0, count: 0,
} }
/** 请求是否后台 /admin/ 接口(不依赖当前路由,避免 loading 等场景误判为前台) */
function isAdminBackendRequest(config: AxiosRequestConfig): boolean {
const u = `${config.baseURL ?? ''}${config.url ?? ''}`
return /\/admin\//i.test(u)
}
/** /**
* 根据运行环境获取基础请求URL * 根据运行环境获取基础请求URL
*/ */
@@ -112,6 +119,22 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
if (response.config.responseType == 'json') { if (response.config.responseType == 'json') {
if (response.data && response.data.code !== 1) { if (response.data && response.data.code !== 1) {
const needLogin = response.data.data && typeof response.data.data === 'object' && response.data.data.type === 'need login'
if (needLogin) {
const isAdminAppFlag = isAdminApp() || isAdminBackendRequest(response.config)
if (isAdminAppFlag) {
adminInfo.removeToken()
} else {
userInfo.removeToken()
}
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
if (router.currentRoute.value.name !== loginRouteName) {
nextTick(() => {
void router.replace({ name: loginRouteName })
})
}
return Promise.reject(response.data)
}
if (response.data.code == 409) { if (response.data.code == 409) {
if (!window.tokenRefreshing) { if (!window.tokenRefreshing) {
window.tokenRefreshing = true window.tokenRefreshing = true

View File

@@ -0,0 +1,157 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('agent.commissionRecord.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'agent/commissionRecord',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/agent.CommissionRecord/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('agent.commissionRecord.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('agent.commissionRecord.settlement_period_id'),
prop: 'settlement_period_id',
align: 'center',
width: 130,
operator: 'RANGE',
show: false,
},
{
label: t('agent.commissionRecord.settlement_period_no'),
prop: 'settlementPeriod.settlement_no',
align: 'center',
minWidth: 170,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('agent.commissionRecord.channel_id'), prop: 'channel_id', align: 'center', width: 100, operator: 'RANGE', show: false },
{
label: t('agent.commissionRecord.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('agent.commissionRecord.admin_id'), prop: 'admin_id', align: 'center', width: 100, operator: 'RANGE', show: false },
{
label: t('agent.commissionRecord.admin_username'),
prop: 'admin.username',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('agent.commissionRecord.commission_rate'), prop: 'commission_rate', align: 'center', minWidth: 110, operator: 'RANGE' },
{ label: t('agent.commissionRecord.calc_base_amount'), prop: 'calc_base_amount', align: 'center', minWidth: 120, operator: 'RANGE' },
{ label: t('agent.commissionRecord.commission_amount'), prop: 'commission_amount', align: 'center', minWidth: 120, operator: 'RANGE' },
{
label: t('agent.commissionRecord.status'),
prop: 'status',
align: 'center',
width: 100,
effect: 'dark',
custom: { 0: 'info', 1: 'warning', 2: 'success' },
operator: 'eq',
render: 'tag',
replaceValue: {
'0': t('agent.commissionRecord.status 0'),
'1': t('agent.commissionRecord.status 1'),
'2': t('agent.commissionRecord.status 2'),
},
},
{
label: t('agent.commissionRecord.settled_at'),
prop: 'settled_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('agent.commissionRecord.remark'),
prop: 'remark',
align: 'center',
minWidth: 160,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('agent.commissionRecord.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('agent.commissionRecord.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { status: 0, commission_rate: '0.0000' },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,86 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem
:label="t('agent.commissionRecord.settlement_period_id')"
type="remoteSelect"
v-model="baTable.form.items!.settlement_period_id"
prop="settlement_period_id"
:key="'sp-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'settlement_no',
remoteUrl: '/admin/agent.SettlementPeriod/index',
placeholder: t('Click select'),
}"
/>
<FormItem
:label="t('agent.commissionRecord.channel_id')"
type="remoteSelect"
v-model="baTable.form.items!.channel_id"
prop="channel_id"
:key="'ch-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'name',
remoteUrl: '/admin/channel/index',
placeholder: t('Click select'),
}"
/>
<FormItem
:label="t('agent.commissionRecord.admin_id')"
type="remoteSelect"
v-model="baTable.form.items!.admin_id"
prop="admin_id"
:key="'adm-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'username',
remoteUrl: '/admin/auth/admin/index',
placeholder: t('Click select'),
}"
/>
<FormItem :label="t('agent.commissionRecord.commission_rate')" type="number" v-model="baTable.form.items!.commission_rate" prop="commission_rate" :input-attr="{ min: 0, precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.commissionRecord.calc_base_amount')" type="number" v-model="baTable.form.items!.calc_base_amount" prop="calc_base_amount" :input-attr="{ min: 0, precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.commissionRecord.commission_amount')" type="number" v-model="baTable.form.items!.commission_amount" prop="commission_amount" :input-attr="{ precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.commissionRecord.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('agent.commissionRecord.status 0'), '1': t('agent.commissionRecord.status 1'), '2': t('agent.commissionRecord.status 2') } }" />
<FormItem :label="t('agent.commissionRecord.settled_at')" type="datetime" v-model="baTable.form.items!.settled_at" prop="settled_at" />
<FormItem :label="t('agent.commissionRecord.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
settlement_period_id: [{ required: true, message: t('Please input field', { field: t('agent.commissionRecord.settlement_period_id') }) }],
channel_id: [{ required: true, message: t('Please input field', { field: t('agent.commissionRecord.channel_id') }) }],
admin_id: [{ required: true, message: t('Please input field', { field: t('agent.commissionRecord.admin_id') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('agent.settlementPeriod.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'agent/settlementPeriod',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/agent.SettlementPeriod/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('agent.settlementPeriod.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('agent.settlementPeriod.settlement_no'),
prop: 'settlement_no',
align: 'center',
minWidth: 160,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('agent.settlementPeriod.period_start_at'),
prop: 'period_start_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('agent.settlementPeriod.period_end_at'),
prop: 'period_end_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('agent.settlementPeriod.total_bet_amount'), prop: 'total_bet_amount', align: 'center', operator: 'RANGE', minWidth: 120 },
{
label: t('agent.settlementPeriod.total_payout_amount'),
prop: 'total_payout_amount',
align: 'center',
operator: 'RANGE',
minWidth: 120,
},
{
label: t('agent.settlementPeriod.platform_profit_amount'),
prop: 'platform_profit_amount',
align: 'center',
operator: 'RANGE',
minWidth: 120,
},
{
label: t('agent.settlementPeriod.status'),
prop: 'status',
align: 'center',
width: 100,
effect: 'dark',
custom: { 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' },
operator: 'eq',
render: 'tag',
replaceValue: {
'0': t('agent.settlementPeriod.status 0'),
'1': t('agent.settlementPeriod.status 1'),
'2': t('agent.settlementPeriod.status 2'),
'3': t('agent.settlementPeriod.status 3'),
},
},
{
label: t('agent.settlementPeriod.remark'),
prop: 'remark',
align: 'center',
minWidth: 160,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('agent.settlementPeriod.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('agent.settlementPeriod.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { status: 0 },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,48 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem :label="t('agent.settlementPeriod.settlement_no')" type="string" v-model="baTable.form.items!.settlement_no" prop="settlement_no" />
<FormItem :label="t('agent.settlementPeriod.period_start_at')" type="datetime" v-model="baTable.form.items!.period_start_at" prop="period_start_at" />
<FormItem :label="t('agent.settlementPeriod.period_end_at')" type="datetime" v-model="baTable.form.items!.period_end_at" prop="period_end_at" />
<FormItem :label="t('agent.settlementPeriod.total_bet_amount')" type="number" v-model="baTable.form.items!.total_bet_amount" prop="total_bet_amount" :input-attr="{ min: 0, precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.settlementPeriod.total_payout_amount')" type="number" v-model="baTable.form.items!.total_payout_amount" prop="total_payout_amount" :input-attr="{ min: 0, precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.settlementPeriod.platform_profit_amount')" type="number" v-model="baTable.form.items!.platform_profit_amount" prop="platform_profit_amount" :input-attr="{ precision: 4, step: 0.0001 }" />
<FormItem :label="t('agent.settlementPeriod.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('agent.settlementPeriod.status 0'), '1': t('agent.settlementPeriod.status 1'), '2': t('agent.settlementPeriod.status 2'), '3': t('agent.settlementPeriod.status 3') } }" />
<FormItem :label="t('agent.settlementPeriod.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
settlement_no: [{ required: true, message: t('Please input field', { field: t('agent.settlementPeriod.settlement_no') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -40,6 +40,13 @@ optButtons[1].display = (row) => {
return row.id != adminInfo.id return row.id != adminInfo.id
} }
const formatRatePercent = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '--'
const num = Number(cellValue)
if (Number.isNaN(num)) return '--'
return `${num.toFixed(2)}%`
}
const baTable = new baTableClass( const baTable = new baTableClass(
new baTableApi('/admin/auth.Admin/'), new baTableApi('/admin/auth.Admin/'),
{ {
@@ -48,7 +55,23 @@ const baTable = new baTableClass(
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 }, { label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, { label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, { label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', operator: false, render: 'tags' }, {
label: t('auth.admin.group'),
prop: 'group_name_arr',
align: 'center',
minWidth: 150,
operator: false,
render: 'tags',
},
{ label: t('auth.admin.invite_code'), prop: 'invite_code', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('auth.admin.commission_rate'),
prop: 'commission_rate',
align: 'center',
minWidth: 90,
operator: 'RANGE',
formatter: formatRatePercent,
},
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false }, { label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, { label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, { label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
@@ -67,16 +90,18 @@ const baTable = new baTableClass(
prop: 'status', prop: 'status',
align: 'center', align: 'center',
render: 'tag', render: 'tag',
effect: 'dark',
custom: { disable: 'danger', enable: 'success' }, custom: { disable: 'danger', enable: 'success' },
replaceValue: { disable: t('Disable'), enable: t('Enable') }, replaceValue: { disable: t('Disable'), enable: t('Enable') },
}, },
{ {
label: t('Operate'), label: t('Operate'),
align: 'center', align: 'center',
width: '100', minWidth: '80',
render: 'buttons', render: 'buttons',
buttons: optButtons, buttons: optButtons,
operator: false, operator: false,
fixed: 'right',
}, },
], ],
dblClickNotEditColumn: [undefined, 'status'], dblClickNotEditColumn: [undefined, 'status'],

View File

@@ -43,18 +43,43 @@
/> />
<FormItem <FormItem
:label="t('auth.admin.group')" :label="t('auth.admin.group')"
v-model="baTable.form.items!.group_arr" v-model="singleGroupValue"
prop="group_arr" prop="group_arr"
type="remoteSelect" type="remoteSelect"
:key="'group-' + baTable.form.items!.id" :key="'group-' + baTable.form.items!.id"
:input-attr="{ :input-attr="{
multiple: true, multiple: false,
disabled: adminInfo.id == baTable.form.items!.id,
params: { isTree: true, absoluteAuth: adminInfo.id == baTable.form.items!.id ? 0 : 1 }, params: { isTree: true, absoluteAuth: adminInfo.id == baTable.form.items!.id ? 0 : 1 },
field: 'name', field: 'name',
remoteUrl: '/admin/auth.Group/index', remoteUrl: '/admin/auth.Group/index',
placeholder: t('Click select'), placeholder: t('Click select'),
}" }"
/> />
<FormItem
:label="t('auth.admin.commission_rate')"
v-model="baTable.form.items!.commission_rate"
type="number"
prop="commission_rate"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100, disabled: shouldDisableCommissionRate() }"
:placeholder="t('Please input field', { field: t('auth.admin.commission_rate') })"
/>
<el-alert class="commission-rate-alert" :title="t('auth.admin.commission_rate_desc_title')" type="info" :closable="false" show-icon>
<template #default>
<ul class="commission-rate-desc-list">
<li>{{ t('auth.admin.commission_rate_desc_1') }}</li>
<li>{{ t('auth.admin.commission_rate_desc_2') }}</li>
<li>{{ t('auth.admin.commission_rate_desc_3') }}</li>
</ul>
</template>
</el-alert>
<FormItem
v-if="baTable.form.operate == 'Edit'"
:label="t('auth.admin.invite_code')"
v-model="baTable.form.items!.invite_code"
type="string"
:input-attr="{ readonly: true, disabled: true }"
/>
<FormItem :label="t('auth.admin.avatar')" type="image" v-model="baTable.form.items!.avatar" /> <FormItem :label="t('auth.admin.avatar')" type="image" v-model="baTable.form.items!.avatar" />
<FormItem <FormItem
:label="t('auth.admin.email')" :label="t('auth.admin.email')"
@@ -115,7 +140,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue' import { computed, reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable' import type baTableClass from '/@/utils/baTable'
import { regularPassword, buildValidatorData } from '/@/utils/validate' import { regularPassword, buildValidatorData } from '/@/utils/validate'
@@ -131,10 +156,69 @@ const baTable = inject('baTable') as baTableClass
const { t } = useI18n() const { t } = useI18n()
/** 解析管理员分红比例:与后端 isValidCommissionRate 一致地拒绝非法字符,避免 Number('30,00') 等为 NaN 导致误报 */
function parseAdminCommissionRateInput(raw: unknown): { kind: 'empty' } | { kind: 'invalid' } | { kind: 'ok'; value: number } {
if (raw === null || raw === undefined || raw === '') {
return { kind: 'empty' }
}
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) {
return { kind: 'invalid' }
}
return { kind: 'ok', value: raw }
}
const s = String(raw).trim()
if (s === '') {
return { kind: 'empty' }
}
const normalized = s.replace(',', '.')
const n = parseFloat(normalized)
if (!Number.isFinite(n)) {
return { kind: 'invalid' }
}
return { kind: 'ok', value: n }
}
const shouldDisableCommissionRate = () => {
return adminInfo.id == baTable.form.items!.id
}
const singleGroupValue = computed({
get: () => {
const group = baTable.form.items?.group_arr
if (Array.isArray(group)) {
return group.length > 0 ? group[0] : ''
}
return group ?? ''
},
set: (value) => {
if (!baTable.form.items) {
return
}
baTable.form.items.group_arr = value === '' || value === null || value === undefined ? [] : [value]
},
})
const rules: Partial<Record<string, FormItemRule[]>> = reactive({ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })], username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })], nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],
group_arr: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('auth.admin.group') }) })], group_arr: [
{
required: true,
validator: (_rule: any, val: unknown, callback: Function) => {
if (Array.isArray(val)) {
if (val.length !== 1) {
return callback(new Error(t('auth.admin.Please select exactly one group')))
}
return callback()
}
if (val === null || val === undefined || val === '') {
return callback(new Error(t('Please select field', { field: t('auth.admin.group') })))
}
return callback()
},
trigger: 'change',
},
],
email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })], email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })], mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],
password: [ password: [
@@ -157,6 +241,26 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
trigger: 'blur', trigger: 'blur',
}, },
], ],
commission_rate: [
{
validator: (_rule: unknown, val: unknown, callback: (e?: Error) => void) => {
const parsed = parseAdminCommissionRateInput(val)
if (parsed.kind === 'empty') {
return callback()
}
if (parsed.kind === 'invalid') {
return callback(new Error(t('Please enter the correct field', { field: t('auth.admin.commission_rate') })))
}
const n = parsed.value
const rounded = Math.round(n * 100) / 100
if (rounded < -0.000001 || rounded > 100.000001) {
return callback(new Error(t('Please enter the correct field', { field: t('auth.admin.commission_rate') })))
}
return callback()
},
trigger: ['blur', 'change'],
},
],
}) })
watch( watch(
@@ -181,6 +285,14 @@ watch(
width: 110px; width: 110px;
height: 110px; height: 110px;
} }
.commission-rate-alert {
margin-bottom: 12px;
}
.commission-rate-desc-list {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.6;
}
.avatar-uploader:hover { .avatar-uploader:hover {
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
} }

View File

@@ -90,7 +90,17 @@ const baTable = new baTableClass(new baTableApi('/admin/auth.AdminLog/'), {
showOverflowTooltip: true, showOverflowTooltip: true,
render: 'url', render: 'url',
}, },
{ label: t('auth.adminLog.ip'), prop: 'ip', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tag' }, {
label: t('auth.adminLog.ip'),
prop: 'ip',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
customRenderAttr: {
tag: () => ({ type: 'info' }),
},
},
{ {
label: t('auth.adminLog.useragent'), label: t('auth.adminLog.useragent'),
prop: 'useragent', prop: 'useragent',
@@ -111,10 +121,11 @@ const baTable = new baTableClass(new baTableApi('/admin/auth.AdminLog/'), {
{ {
label: t('Operate'), label: t('Operate'),
align: 'center', align: 'center',
width: '100', minWidth: '80',
render: 'buttons', render: 'buttons',
buttons: optButtons, buttons: optButtons,
operator: false, operator: false,
fixed: 'right',
}, },
], ],
dblClickNotEditColumn: [undefined], dblClickNotEditColumn: [undefined],

View File

@@ -54,19 +54,33 @@ const baTable: baTableClass = new baTableClass(
dblClickNotEditColumn: [undefined], dblClickNotEditColumn: [undefined],
column: [ column: [
{ type: 'selection', align: 'center' }, { type: 'selection', align: 'center' },
{ label: t('auth.group.Group name'), prop: 'name', align: 'left', width: '200' }, {
label: t('auth.group.Group name'),
prop: 'name',
align: 'left',
minWidth: '180',
},
{ label: t('auth.group.commission_rate'), prop: 'commission_rate', align: 'center', formatter: formatRatePercent },
{ label: t('auth.group.jurisdiction'), prop: 'rules', align: 'center' }, { label: t('auth.group.jurisdiction'), prop: 'rules', align: 'center' },
{ {
label: t('State'), label: t('State'),
prop: 'status', prop: 'status',
align: 'center', align: 'center',
render: 'tag', render: 'tag',
effect: 'dark',
custom: { 0: 'danger', 1: 'success' }, custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('Enable') }, replaceValue: { 0: t('Disable'), 1: t('Enable') },
}, },
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' }, { label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' }, { label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Operate'), align: 'center', width: '130', render: 'buttons', buttons: defaultOptButtons(['edit', 'delete']) }, {
label: t('Operate'),
align: 'center',
width: '80',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
fixed: 'right',
},
], ],
}, },
{ {
@@ -165,6 +179,13 @@ const menuRuleTreeUpdate = () => {
provide('baTable', baTable) provide('baTable', baTable)
function formatRatePercent(row: anyObj, _column: any, cellValue: number | string | null) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '0%'
}
return `${cellValue}%`
}
onMounted(() => { onMounted(() => {
baTable.table.ref = tableRef.value baTable.table.ref = tableRef.value
baTable.mount() baTable.mount()

View File

@@ -49,6 +49,23 @@
:placeholder="t('Please input field', { field: t('auth.group.Group name') })" :placeholder="t('Please input field', { field: t('auth.group.Group name') })"
></el-input> ></el-input>
</el-form-item> </el-form-item>
<FormItem
:label="t('auth.group.commission_rate')"
v-model="baTable.form.items!.commission_rate"
type="number"
prop="commission_rate"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100, disabled: shouldDisableCommissionRate() }"
:placeholder="t('Please input field', { field: t('auth.group.commission_rate') })"
/>
<el-alert class="commission-rate-alert" :title="t('auth.group.commission_rate_desc_title')" type="info" :closable="false" show-icon>
<template #default>
<ul class="commission-rate-desc-list">
<li>{{ t('auth.group.commission_rate_desc_1') }}</li>
<li>{{ t('auth.group.commission_rate_desc_2') }}</li>
<li>{{ t('auth.group.commission_rate_desc_3') }}</li>
</ul>
</template>
</el-alert>
<el-form-item prop="auth" :label="t('auth.group.jurisdiction')"> <el-form-item prop="auth" :label="t('auth.group.jurisdiction')">
<el-tree <el-tree
ref="treeRef" ref="treeRef"
@@ -94,16 +111,40 @@ import type { ElTree, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate' import { buildValidatorData } from '/@/utils/validate'
import type Node from 'element-plus/es/components/tree/src/model/node' import type Node from 'element-plus/es/components/tree/src/model/node'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
const config = useConfig() const config = useConfig()
const adminInfo = useAdminInfo()
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef') const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass const baTable = inject('baTable') as baTableClass
const { t } = useI18n() const { t } = useI18n()
const shouldDisableCommissionRate = () => {
return false
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('auth.group.Group name') })], name: [buildValidatorData({ name: 'required', title: t('auth.group.Group name') })],
commission_rate: [
{
required: true,
validator: (_rule: any, val: number | string, callback: Function) => {
if (shouldDisableCommissionRate()) {
return callback()
}
const strVal = String(val ?? '').trim()
if (!strVal) {
return callback(new Error(t('Please input field', { field: t('auth.group.commission_rate') })))
}
if (!/^(100(\.00?)?|[0-9]{1,2}(\.[0-9]{1,2})?)$/.test(strVal)) {
return callback(new Error(t('auth.admin.Commission rate must be between 0 and 100 with up to 2 decimals')))
}
return callback()
},
trigger: 'blur',
},
],
auth: [ auth: [
{ {
required: true, required: true,
@@ -153,6 +194,16 @@ defineExpose({
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.commission-rate-alert {
margin-bottom: 12px;
}
.commission-rate-desc-list {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.6;
}
:deep(.penultimate-node) { :deep(.penultimate-node) {
.el-tree-node__children { .el-tree-node__children {
padding-left: 60px; padding-left: 60px;

View File

@@ -68,9 +68,10 @@ const baTable = new baTableClass(
{ {
label: t('Operate'), label: t('Operate'),
align: 'center', align: 'center',
width: '130', width: '120',
render: 'buttons', render: 'buttons',
buttons: defaultOptButtons(), buttons: defaultOptButtons(),
fixed: 'right',
}, },
], ],
dragSortLimitField: 'pid', dragSortLimitField: 'pid',

View File

@@ -0,0 +1,467 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('channel.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="manualSettle.visible" @close="closeManualSettleDialog">
<template #header>
<div class="title">{{ t('channel.manual_settle') }}</div>
</template>
<div v-loading="manualSettle.previewLoading" class="manual-settle-dialog-body">
<el-form :model="manualSettle.form" label-width="140px">
<el-form-item :label="t('channel.manual_settle_settlement_no')">
<el-input v-model="manualSettle.form.settlement_no" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_period_start')">
<el-input v-model="manualSettle.form.period_start_at" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_period_end')">
<el-input v-model="manualSettle.form.period_end_at" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_total_bet')">
<el-input v-model="manualSettle.form.total_bet_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_total_payout')">
<el-input v-model="manualSettle.form.total_payout_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_platform_profit')">
<el-input v-model="manualSettle.form.platform_profit_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_commission_rate')">
<el-input v-model="manualSettle.form.commission_rate" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_calc_base')">
<el-input v-model="manualSettle.form.calc_base_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_commission_amount')">
<el-input v-model="manualSettle.form.commission_amount" readonly />
</el-form-item>
<el-form-item :label="t('channel.manual_settle_remark')">
<el-input v-model="manualSettle.form.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="closeManualSettleDialog">{{ t('Cancel') }}</el-button>
<el-button type="primary" :disabled="manualSettle.previewLoading" :loading="manualSettle.loading" @click="submitManualSettle">
{{ t('Save') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { auth } from '/@/utils/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import createAxios from '/@/utils/axios'
defineOptions({
name: 'channel',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
let optButtons: OptButton[] = [
{
render: 'tipButton',
name: 'manualSettle',
title: 'channel.manual_settle',
text: '',
type: 'warning',
icon: 'el-icon-Clock',
class: 'table-row-manual-settle',
disabledTip: false,
display: () => auth('manualSettle'),
click: (row: TableRow) => {
void openManualSettleDialog(row)
},
},
]
optButtons = optButtons.concat(defaultOptButtons(['edit', 'delete']))
const formatRatePercent = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
const num = Number(cellValue)
if (Number.isNaN(num)) return '-'
return `${num.toFixed(2)}%`
}
const formatAmountInt = (_row: any, _column: any, cellValue: number | string | null) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
const num = Number(cellValue)
if (Number.isNaN(num)) return '-'
return `${num}`
}
const formatSettleDay = (row: anyObj) => {
if (row.settle_cycle === 'weekly') {
return t(`channel.weekday ${row.settle_weekday ?? 1}`)
}
if (row.settle_cycle === 'monthly') {
return `${row.settle_monthday ?? 1}${t('channel.day_suffix')}`
}
return t('channel.settle_day_daily')
}
const manualSettle = reactive({
visible: false,
loading: false,
previewLoading: false,
channelId: 0,
form: {
settlement_no: '',
period_start_at: '',
period_end_at: '',
total_bet_amount: '',
total_payout_amount: '',
platform_profit_amount: '',
commission_rate: '',
calc_base_amount: '',
commission_amount: '',
remark: '',
},
})
const resetManualSettleForm = () => {
manualSettle.form.settlement_no = ''
manualSettle.form.period_start_at = ''
manualSettle.form.period_end_at = ''
manualSettle.form.total_bet_amount = ''
manualSettle.form.total_payout_amount = ''
manualSettle.form.platform_profit_amount = ''
manualSettle.form.commission_rate = ''
manualSettle.form.calc_base_amount = ''
manualSettle.form.commission_amount = ''
manualSettle.form.remark = ''
}
const closeManualSettleDialog = () => {
manualSettle.visible = false
resetManualSettleForm()
}
const openManualSettleDialog = async (row: TableRow) => {
manualSettle.channelId = row.id
resetManualSettleForm()
manualSettle.visible = true
manualSettle.previewLoading = true
try {
const res = await createAxios(
{
url: '/admin/channel/manualSettlePreview',
method: 'get',
params: { id: row.id },
},
{ showErrorMessage: true }
)
if (res.code !== 1 || !res.data) {
manualSettle.visible = false
return
}
const d = res.data as anyObj
manualSettle.form.settlement_no = d.settlement_no ?? ''
manualSettle.form.period_start_at = d.period_start_at ?? ''
manualSettle.form.period_end_at = d.period_end_at ?? ''
manualSettle.form.total_bet_amount = d.total_bet_amount ?? ''
manualSettle.form.total_payout_amount = d.total_payout_amount ?? ''
manualSettle.form.platform_profit_amount = d.platform_profit_amount ?? ''
manualSettle.form.commission_rate = d.commission_rate ?? ''
manualSettle.form.calc_base_amount = d.calc_base_amount ?? ''
manualSettle.form.commission_amount = d.commission_amount ?? ''
manualSettle.form.remark = `${t('channel.manual_settle')}-CH${row.id}`
} catch {
manualSettle.visible = false
} finally {
manualSettle.previewLoading = false
}
}
const submitManualSettle = async () => {
if (!manualSettle.channelId) return
manualSettle.loading = true
try {
await createAxios(
{
url: '/admin/channel/manualSettle',
method: 'post',
data: {
id: manualSettle.channelId,
remark: manualSettle.form.remark,
},
},
{ showSuccessMessage: true }
)
closeManualSettleDialog()
baTable.onTableHeaderAction('refresh', { event: 'manual-settle' })
} finally {
manualSettle.loading = false
}
}
const baTable = new baTableClass(
new baTableApi('/admin/channel/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('channel.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('channel.code'),
prop: 'code',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.name'),
prop: 'name',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('channel.agent_mode'),
prop: 'agent_mode',
align: 'center',
minWidth: 120,
operator: 'eq',
sortable: false,
render: 'tag',
custom: {
turnover: 'primary',
affiliate: 'success',
},
replaceValue: {
turnover: t('channel.agent_mode turnover'),
affiliate: t('channel.agent_mode affiliate'),
},
},
{
label: t('channel.turnover_share_rate'),
prop: 'turnover_share_rate',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.affiliate_share_rate'),
prop: 'affiliate_share_rate',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.affiliate_fee_rate'),
prop: 'affiliate_fee_rate',
align: 'center',
minWidth: 140,
sortable: false,
operator: 'RANGE',
formatter: formatRatePercent,
},
{
label: t('channel.carryover_balance'),
prop: 'carryover_balance',
align: 'center',
minWidth: 130,
sortable: false,
operator: 'RANGE',
formatter: formatAmountInt,
},
{
label: t('channel.affiliate_contract_no'),
prop: 'affiliate_contract_no',
align: 'center',
minWidth: 140,
sortable: false,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('channel.settle_cycle'),
prop: 'settle_cycle',
align: 'center',
width: 110,
operator: 'eq',
sortable: false,
render: 'tag',
custom: { daily: 'info', weekly: 'primary', monthly: 'success' },
replaceValue: {
daily: t('channel.settle_cycle daily'),
weekly: t('channel.settle_cycle weekly'),
monthly: t('channel.settle_cycle monthly'),
},
},
{
label: t('channel.settle_weekday'),
prop: 'settle_day',
align: 'center',
width: 110,
operator: false,
sortable: false,
formatter: (row: anyObj) => formatSettleDay(row),
},
{
label: t('channel.settle_time'),
prop: 'settle_time',
align: 'center',
width: 100,
operator: 'LIKE',
sortable: false,
},
{
label: t('channel.affiliate_effective_start_at'),
prop: 'affiliate_effective_start_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('channel.affiliate_effective_end_at'),
prop: 'affiliate_effective_end_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('channel.user_count'),
prop: 'user_count',
align: 'center',
sortable: false,
operator: 'RANGE',
},
{
label: t('channel.profit_amount'),
prop: 'profit_amount',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatAmountInt,
},
{
label: t('channel.total_profit_amount'),
prop: 'total_profit_amount',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatAmountInt,
},
{
label: t('channel.commission_pool_amount'),
prop: 'commission_pool_amount',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatAmountInt,
},
{
label: t('channel.status'),
prop: 'status',
align: 'center',
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('channel.status 0'), '1': t('channel.status 1') },
},
{
label: t('channel.admingroup__name'),
prop: 'adminGroup.name',
align: 'center',
minWidth: 110,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('channel.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 90,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('channel.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('channel.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 120, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
status: '1',
agent_mode: 'turnover',
settle_cycle: 'weekly',
settle_weekday: 1,
settle_monthday: 1,
settle_time: '02:00:00',
},
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,578 @@
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('channel.code')"
type="string"
v-model="baTable.form.items!.code"
prop="code"
:placeholder="t('Please input field', { field: t('channel.code') })"
/>
<FormItem
:label="t('channel.name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('Please input field', { field: t('channel.name') })"
/>
<FormItem
:label="t('channel.agent_mode')"
type="radio"
v-model="baTable.form.items!.agent_mode"
prop="agent_mode"
:input-attr="{ content: { turnover: t('channel.agent_mode turnover'), affiliate: t('channel.agent_mode affiliate') } }"
/>
<el-alert
:title="currentAgentMode === 'turnover' ? t('channel.agent_mode_title_turnover') : t('channel.agent_mode_title_affiliate')"
type="info"
show-icon
:closable="false"
class="agent-mode-alert"
>
<template #default>
<ul class="agent-mode-desc-list">
<li v-for="item in currentAgentModeDescList" :key="item">{{ item }}</li>
</ul>
</template>
</el-alert>
<FormItem
v-if="currentAgentMode === 'turnover'"
:label="t('channel.turnover_share_rate')"
type="number"
v-model="baTable.form.items!.turnover_share_rate"
prop="turnover_share_rate"
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100 }"
:placeholder="`${t('Please input field', { field: t('channel.turnover_share_rate') })} (例如 30.5)`"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_contract_no')"
type="string"
v-model="baTable.form.items!.affiliate_contract_no"
prop="affiliate_contract_no"
:placeholder="t('Please input field', { field: t('channel.affiliate_contract_no') })"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_contract_name')"
type="string"
v-model="baTable.form.items!.affiliate_contract_name"
prop="affiliate_contract_name"
:placeholder="t('Please input field', { field: t('channel.affiliate_contract_name') })"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_share_rate')"
type="number"
v-model="baTable.form.items!.affiliate_share_rate"
prop="affiliate_share_rate"
:input-attr="{ step: 0.000001, precision: 6, min: 0, max: 1 }"
:placeholder="`${t('Please input field', { field: t('channel.affiliate_share_rate') })} (例如 0.240000)`"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_fee_rate')"
type="number"
v-model="baTable.form.items!.affiliate_fee_rate"
prop="affiliate_fee_rate"
:input-attr="{ step: 0.000001, precision: 6, min: 0, max: 1 }"
:placeholder="`${t('Please input field', { field: t('channel.affiliate_fee_rate') })} (例如 0.070000)`"
/>
<el-form-item :label="t('channel.settle_cycle')" prop="settle_plan">
<el-tree-select
v-model="settlePlanValue"
class="w100"
clearable
filterable
:data="settlePlanTree"
:props="settlePlanTreeProps"
:render-after-expand="false"
:placeholder="t('channel.settle_cycle_placeholder')"
@change="onSettlePlanChange"
/>
</el-form-item>
<FormItem :label="t('channel.settle_time')" type="time" v-model="baTable.form.items!.settle_time" prop="settle_time" />
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_effective_start_at')"
type="datetime"
v-model="baTable.form.items!.affiliate_effective_start_at"
prop="affiliate_effective_start_at"
/>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.affiliate_effective_end_at')"
type="datetime"
v-model="baTable.form.items!.affiliate_effective_end_at"
prop="affiliate_effective_end_at"
/>
<el-form-item v-if="currentAgentMode === 'affiliate'" :label="t('channel.affiliate_ladder_rules')" prop="affiliate_ladder_rules">
<div class="ladder-rule-box">
<div class="ladder-rule-row ladder-rule-head">
<span>{{ t('channel.ladder_min_loss') }}</span>
<span>{{ t('channel.ladder_share_rate') }}</span>
<span>{{ t('Operate') }}</span>
</div>
<div v-for="(item, idx) in ladderRuleList" :key="idx" class="ladder-rule-row">
<el-input-number v-model="item.minLoss" class="w100" :precision="4" :step="0.0001" :min="0" />
<el-input-number v-model="item.shareRate" class="w100" :precision="6" :step="0.000001" :min="0" :max="1" />
<el-button type="danger" link @click="removeLadderRule(idx)">{{ t('Delete') }}</el-button>
</div>
<el-button type="primary" link @click="addLadderRule">{{ t('Add') }}</el-button>
</div>
</el-form-item>
<FormItem
v-if="currentAgentMode === 'affiliate'"
:label="t('channel.carryover_balance')"
type="number"
v-model="baTable.form.items!.carryover_balance"
prop="carryover_balance"
:input-attr="{ step: 0.0001, precision: 4 }"
:placeholder="t('Please input field', { field: t('channel.carryover_balance') })"
/>
<FormItem
:label="t('channel.status')"
type="switch"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('channel.status 0'), '1': t('channel.status 1') } }"
/>
<FormItem
:label="t('channel.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('channel.remark') })"
/>
<el-alert type="info" :closable="false" show-icon class="channel-admin-tree-tip">
{{ t('channel.admin_tree_tip') }}
</el-alert>
<el-form-item :label="t('channel.admin_id')" prop="admin_id">
<el-tree-select
v-model="baTable.form.items!.admin_id"
class="w100"
clearable
filterable
:data="channelAdminTree"
:props="channelAdminTreeProps"
:render-after-expand="false"
:placeholder="t('channel.admin_id_placeholder')"
/>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
import { computed, inject, nextTick, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import createAxios from '/@/utils/axios'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
type ChannelAdminTreeNode = {
value: string
label: string
disabled?: boolean
children?: ChannelAdminTreeNode[]
channel_id?: number
is_leaf?: boolean
}
const channelAdminTree = ref<ChannelAdminTreeNode[]>([])
const channelAdminTreeProps = {
value: 'value',
label: 'label',
children: 'children',
disabled: 'disabled',
}
type LadderRuleRow = { minLoss: number; shareRate: number }
const ladderRuleList = ref<LadderRuleRow[]>([])
const settlePlanTree = computed(() => [
{ value: 'daily', label: t('channel.settle_cycle daily'), children: [] },
{
value: 'weekly',
label: t('channel.settle_cycle weekly'),
disabled: true,
children: [1, 2, 3, 4, 5, 6, 7].map((n) => ({ value: `weekly:${n}`, label: t(`channel.weekday ${n}`), is_leaf: true })),
},
{
value: 'monthly',
label: t('channel.settle_cycle monthly'),
disabled: true,
children: Array.from({ length: 31 }).map((_, i) => {
const day = i + 1
return { value: `monthly:${day}`, label: `${day}${t('channel.day_suffix')}`, is_leaf: true }
}),
},
])
const settlePlanTreeProps = { value: 'value', label: 'label', children: 'children', disabled: 'disabled' }
const settlePlanValue = ref<string | null>(null)
const syncSettlePlanToItems = () => {
const items = baTable.form.items
if (!items) return
;(items as any).settle_plan = settlePlanValue.value ?? ''
}
const syncSettlePlanFromItems = () => {
const items = baTable.form.items
if (!items) return
const cycle = items.settle_cycle || 'weekly'
if (cycle === 'daily') {
settlePlanValue.value = 'daily'
syncSettlePlanToItems()
return
}
if (cycle === 'monthly') {
settlePlanValue.value = `monthly:${items.settle_monthday || 1}`
syncSettlePlanToItems()
return
}
settlePlanValue.value = `weekly:${items.settle_weekday || 1}`
syncSettlePlanToItems()
}
watch(settlePlanValue, (val) => {
if (!baTable.form.items) return
if (!val) {
syncSettlePlanToItems()
return
}
if (val === 'daily') {
baTable.form.items.settle_cycle = 'daily'
baTable.form.items.settle_weekday = 1
baTable.form.items.settle_monthday = 1
return
}
if (val.startsWith('weekly:')) {
const day = parseInt(val.split(':')[1] || '1', 10)
baTable.form.items.settle_cycle = 'weekly'
baTable.form.items.settle_weekday = Number.isNaN(day) ? 1 : day
baTable.form.items.settle_monthday = 1
return
}
if (val.startsWith('monthly:')) {
const day = parseInt(val.split(':')[1] || '1', 10)
baTable.form.items.settle_cycle = 'monthly'
baTable.form.items.settle_monthday = Number.isNaN(day) ? 1 : day
baTable.form.items.settle_weekday = 1
}
syncSettlePlanToItems()
})
const onSettlePlanChange = () => {
nextTick(() => {
formRef.value?.validateField('settle_plan')
})
}
const loadChannelAdminTree = async () => {
const res = await createAxios({
url: '/admin/channel/adminTree',
method: 'get',
})
channelAdminTree.value = (res.data?.list ?? []) as ChannelAdminTreeNode[]
}
const currentAgentMode = computed(() => baTable.form.items?.agent_mode ?? 'turnover')
const currentAgentModeDescList = computed(() => {
if (currentAgentMode.value === 'turnover') {
return [t('channel.agent_mode_desc_turnover_1'), t('channel.agent_mode_desc_turnover_2'), t('channel.agent_mode_desc_turnover_3')]
}
return [t('channel.agent_mode_desc_affiliate_1'), t('channel.agent_mode_desc_affiliate_2'), t('channel.agent_mode_desc_affiliate_3')]
})
const normalizeLadderRulesToText = (val: unknown): string | null => {
if (val === null || val === undefined || val === '') {
return null
}
if (typeof val === 'string') {
return val
}
try {
return JSON.stringify(val, null, 2)
} catch {
return String(val)
}
}
const parseLadderRulesText = (val: unknown): unknown => {
if (val === null || val === undefined) {
return null
}
if (typeof val !== 'string') {
return val
}
const text = val.trim()
if (!text) {
return null
}
try {
return JSON.parse(text)
} catch {
return text
}
}
const syncLadderRuleRowsFromItems = () => {
const items = baTable.form.items
if (!items) return
const raw = parseLadderRulesText(items.affiliate_ladder_rules)
if (!Array.isArray(raw)) {
ladderRuleList.value = []
return
}
ladderRuleList.value = raw
.map((r: any) => ({
minLoss: Number(r.minLoss ?? r.min_loss ?? 0),
shareRate: Number(r.shareRate ?? r.share_rate ?? 0),
}))
.filter((r: LadderRuleRow) => Number.isFinite(r.minLoss) && Number.isFinite(r.shareRate))
}
const addLadderRule = () => {
ladderRuleList.value.push({ minLoss: 0, shareRate: 0 })
}
const removeLadderRule = (idx: number) => {
ladderRuleList.value.splice(idx, 1)
}
onMounted(() => {
loadChannelAdminTree()
syncSettlePlanFromItems()
})
watch(
() => baTable.form.operate,
(op) => {
if (op === 'Add' || op === 'Edit') {
loadChannelAdminTree()
}
}
)
watch(
() => [baTable.form.operate, baTable.form.items?.id] as const,
() => {
const items = baTable.form.items
if (!items || baTable.form.operate !== 'Edit') {
return
}
if (items.admin_id !== undefined && items.admin_id !== null && items.admin_id !== '') {
items.admin_id = String(items.admin_id) as any
}
items.affiliate_ladder_rules = normalizeLadderRulesToText(items.affiliate_ladder_rules) as any
syncSettlePlanFromItems()
syncLadderRuleRowsFromItems()
}
)
watch(
() => baTable.form.items?.agent_mode,
(mode) => {
if (!baTable.form.items) {
return
}
if (mode === 'turnover') {
baTable.form.items.affiliate_share_rate = null
baTable.form.items.affiliate_fee_rate = null
baTable.form.items.carryover_balance = 0
baTable.form.items.affiliate_contract_no = null
baTable.form.items.affiliate_contract_name = null
baTable.form.items.affiliate_ladder_rules = null
baTable.form.items.affiliate_effective_start_at = null
baTable.form.items.affiliate_effective_end_at = null
ladderRuleList.value = []
return
}
if (mode === 'affiliate') {
baTable.form.items.turnover_share_rate = null
if (!baTable.form.items.settle_cycle) {
baTable.form.items.settle_cycle = 'weekly'
}
syncLadderRuleRowsFromItems()
}
syncSettlePlanFromItems()
}
)
const turnoverRateValidator = (label: string) => {
return (_rule: any, value: string | number | null, callback: (error?: Error) => void) => {
if (currentAgentMode.value !== 'turnover') {
callback()
return
}
if (value === null || value === undefined || value === '') {
callback()
return
}
const str = String(value).trim()
if (!/^\d+(\.\d{1,6})?$/.test(str)) {
callback(new Error(`${label}仅支持最多六位小数`))
return
}
const num = Number(str)
if (Number.isNaN(num) || num < 0 || num > 100) {
callback(new Error(`${label}必须在0到100之间`))
return
}
callback()
}
}
const settlePlanValidator = (_rule: unknown, _value: unknown, callback: (error?: Error) => void) => {
const v = settlePlanValue.value
if (v !== null && v !== undefined && v !== '') {
callback()
return
}
callback(new Error(t('Please input field', { field: t('channel.settle_cycle') })))
}
const affiliateRateValidator = (label: string) => {
return (_rule: any, value: string | number | null, callback: (error?: Error) => void) => {
if (currentAgentMode.value !== 'affiliate') {
callback()
return
}
if (value === null || value === undefined || value === '') {
callback()
return
}
const str = String(value).trim()
if (!/^\d+(\.\d{1,6})?$/.test(str)) {
callback(new Error(`${label}仅支持最多六位小数`))
return
}
const num = Number(str)
if (Number.isNaN(num) || num < 0 || num > 1) {
callback(new Error(`${label}必须在0到1之间`))
return
}
callback()
}
}
baTable.before.onSubmit = ({ items }) => {
if (!items) {
return
}
if (Object.prototype.hasOwnProperty.call(items, 'settle_plan')) {
delete (items as any).settle_plan
}
if (items.agent_mode === 'affiliate') {
if (ladderRuleList.value.length === 0) {
ElMessage.error(t('channel.ladder_rule_required'))
return false
}
const sorted = [...ladderRuleList.value].sort((a, b) => a.minLoss - b.minLoss)
for (let i = 0; i < sorted.length; i++) {
const row = sorted[i]
if (!Number.isFinite(row.minLoss) || row.minLoss < 0) {
ElMessage.error(t('channel.ladder_min_loss_invalid'))
return false
}
if (!Number.isFinite(row.shareRate) || row.shareRate < 0 || row.shareRate > 1) {
ElMessage.error(t('channel.ladder_share_rate_invalid'))
return false
}
if (i > 0 && row.minLoss <= sorted[i - 1].minLoss) {
ElMessage.error(t('channel.ladder_min_loss_order_invalid'))
return false
}
}
items.affiliate_ladder_rules = sorted.map((r) => ({
minLoss: Number(r.minLoss).toFixed(4),
shareRate: Number(r.shareRate).toFixed(6),
}))
} else {
items.affiliate_ladder_rules = null
}
items.settle_cycle = items.settle_cycle || 'weekly'
items.settle_weekday = items.settle_weekday || 1
items.settle_monthday = items.settle_monthday || 1
items.settle_time = items.settle_time || '02:00:00'
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
code: [buildValidatorData({ name: 'required', title: t('channel.code') })],
name: [buildValidatorData({ name: 'required', title: t('channel.name') })],
agent_mode: [buildValidatorData({ name: 'required', title: t('channel.agent_mode') })],
turnover_share_rate: [{ validator: turnoverRateValidator(t('channel.turnover_share_rate')), trigger: 'blur' }],
affiliate_share_rate: [{ validator: affiliateRateValidator(t('channel.affiliate_share_rate')), trigger: 'blur' }],
affiliate_fee_rate: [{ validator: affiliateRateValidator(t('channel.affiliate_fee_rate')), trigger: 'blur' }],
settle_plan: [{ validator: settlePlanValidator, trigger: ['change', 'blur'] }],
settle_time: [buildValidatorData({ name: 'required', title: t('channel.settle_time') })],
affiliate_effective_start_at: [buildValidatorData({ name: 'required', title: t('channel.affiliate_effective_start_at') })],
carryover_balance: [buildValidatorData({ name: 'number', title: t('channel.carryover_balance') })],
admin_id: [buildValidatorData({ name: 'required', title: t('channel.admin_id') })],
})
</script>
<style scoped lang="scss">
.agent-mode-alert {
margin-bottom: 12px;
}
.agent-mode-desc-list {
margin: 6px 0 0;
padding-left: 18px;
line-height: 1.6;
}
.channel-admin-tree-tip {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('config.gameConfig.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'config/gameConfig',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/config.GameConfig/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('config.gameConfig.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('config.gameConfig.config_key'),
prop: 'config_key',
align: 'center',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('config.gameConfig.config_value'),
prop: 'config_value',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('config.gameConfig.value_type'),
prop: 'value_type',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
custom: {
string: 'primary',
int: 'success',
decimal: 'warning',
json: 'info',
},
replaceValue: {
string: t('config.gameConfig.value_type string'),
int: t('config.gameConfig.value_type int'),
decimal: t('config.gameConfig.value_type decimal'),
json: t('config.gameConfig.value_type json'),
},
},
{
label: t('config.gameConfig.remark'),
prop: 'remark',
align: 'center',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('config.gameConfig.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('config.gameConfig.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { value_type: 'string', remark: '' },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,107 @@
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('config.gameConfig.config_key')"
type="string"
v-model="baTable.form.items!.config_key"
prop="config_key"
:placeholder="t('Please input field', { field: t('config.gameConfig.config_key') })"
:input-attr="{ disabled: baTable.form.operate === 'Edit' }"
/>
<FormItem
:label="t('config.gameConfig.config_value')"
type="textarea"
v-model="baTable.form.items!.config_value"
prop="config_value"
:input-attr="{ rows: 5 }"
:placeholder="t('Please input field', { field: t('config.gameConfig.config_value') })"
/>
<FormItem
:label="t('config.gameConfig.value_type')"
type="radio"
v-model="baTable.form.items!.value_type"
prop="value_type"
:input-attr="{
content: {
string: t('config.gameConfig.value_type string'),
int: t('config.gameConfig.value_type int'),
decimal: t('config.gameConfig.value_type decimal'),
json: t('config.gameConfig.value_type json'),
},
}"
/>
<FormItem
:label="t('config.gameConfig.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 2 }"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
config_key: [buildRequired('config.gameConfig.config_key')],
value_type: [buildRequired('config.gameConfig.value_type')],
})
function buildRequired(langKey: string): FormItemRule {
return {
required: true,
message: t('Please input field', { field: t(langKey) }),
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="default-main ba-table-box zi-hua-dict-page">
<el-alert type="info" :closable="false" show-icon>
{{ t('config.ziHuaDictionary.desc') }}
</el-alert>
<div class="toolbar">
<el-button type="primary" :loading="saving" :disabled="loading" @click="onSave">
{{ t('config.ziHuaDictionary.btn_save') }}
</el-button>
</div>
<el-table v-loading="loading" border stripe :data="items" row-key="no" max-height="640">
<el-table-column prop="no" :label="t('config.ziHuaDictionary.no')" width="72" align="center" />
<el-table-column :label="t('config.ziHuaDictionary.name')" min-width="120">
<template #default="{ row }">
<el-input v-model="row.name" maxlength="32" show-word-limit />
</template>
</el-table-column>
<el-table-column :label="t('config.ziHuaDictionary.category')" min-width="160">
<template #default="{ row }">
<el-select v-model="row.category" style="width: 100%">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
defineOptions({
name: 'config/ziHuaDictionary',
})
const { t } = useI18n()
type Item = { no: number; name: string; category: string }
const loading = ref(false)
const saving = ref(false)
const items = ref<Item[]>([])
const categories = ref<string[]>(['zodiac', 'beast', 'fowl', 'vermin', 'divine'])
function categoryLabel(cat: string): string {
return t('config.ziHuaDictionary.category_label.' + cat)
}
async function load() {
loading.value = true
try {
const res = await createAxios({
url: '/admin/config.ZiHuaDictionary/index',
method: 'get',
})
if (res.code === 1 && res.data) {
const list = res.data.items as Item[]
items.value = Array.isArray(list) ? list : []
if (Array.isArray(res.data.categories) && res.data.categories.length) {
categories.value = res.data.categories as string[]
}
}
} finally {
loading.value = false
}
}
async function onSave() {
if (!auth('save')) {
return
}
saving.value = true
try {
await createAxios({
url: '/admin/config.ZiHuaDictionary/save',
method: 'post',
data: { items: items.value },
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.zi-hua-dict-page {
.toolbar {
margin: 12px 0;
}
}
</style>

View File

@@ -385,13 +385,14 @@ const baTable = new baTableClass(
align: 'center', align: 'center',
render: 'tag', render: 'tag',
sortable: false, sortable: false,
effect: 'dark',
replaceValue: { replaceValue: {
delete: t('crud.log.status delete'), delete: t('crud.log.status delete'),
success: t('crud.log.status success'), success: t('crud.log.status success'),
error: t('crud.log.status error'), error: t('crud.log.status error'),
start: t('crud.log.status start'), start: t('crud.log.status start'),
}, },
custom: { delete: 'danger', success: 'success', error: 'warning', start: '' }, custom: { delete: 'danger', success: 'success', error: 'warning', start: 'info' },
}, },
{ {
label: t('crud.log.create_time'), label: t('crud.log.create_time'),

View File

@@ -2,19 +2,13 @@
<div class="default-main ba-table-box"> <div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon /> <el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<!-- 自定义按钮请使用插槽甚至公共搜索也可以使用具名插槽渲染参见文档 -->
<TableHeader <TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']" :buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('mall.player.quickSearchFields') })" :quick-search-placeholder="t('Quick search placeholder', { fields: t('game.config.quick Search Fields') })"
></TableHeader> ></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table> <Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm /> <PopupForm />
</div> </div>
</template> </template>
@@ -30,60 +24,95 @@ import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable' import baTableClass from '/@/utils/baTable'
defineOptions({ defineOptions({
name: 'mall/player', name: 'game/config',
}) })
const { t } = useI18n() const { t } = useI18n()
const tableRef = useTemplateRef('tableRef') const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete']) const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass( const baTable = new baTableClass(
new baTableApi('/admin/mall.Player/'), new baTableApi('/admin/game.Config/'),
{ {
pk: 'id', pk: 'id',
column: [ column: [
{ type: 'selection', align: 'center', operator: false }, { type: 'selection', align: 'center', operator: false },
{ label: t('mall.player.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' }, { label: t('game.config.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{ {
label: t('mall.player.username'), label: t('game.config.config_key'),
prop: 'username', prop: 'config_key',
align: 'center', align: 'center',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'), operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE', operator: 'LIKE',
showOverflowTooltip: true,
}, },
{ {
label: t('mall.player.create_time'), label: t('game.config.config_value'),
prop: 'config_value',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.config.value_type'),
prop: 'value_type',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
custom: {
string: 'primary',
int: 'success',
decimal: 'warning',
json: 'info',
},
replaceValue: {
string: t('game.config.value_type string'),
int: t('game.config.value_type int'),
decimal: t('game.config.value_type decimal'),
json: t('game.config.value_type json'),
},
},
{
label: t('game.config.remark'),
prop: 'remark',
align: 'center',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.config.create_time'),
prop: 'create_time', prop: 'create_time',
align: 'center', align: 'center',
render: 'datetime', render: 'datetime',
operator: 'RANGE', operator: 'RANGE',
comSearchRender: 'datetime', comSearchRender: 'datetime',
sortable: 'custom', sortable: 'custom',
width: 160, width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss', timeFormat: 'yyyy-mm-dd hh:MM:ss',
}, },
{ {
label: t('mall.player.update_time'), label: t('game.config.update_time'),
prop: 'update_time', prop: 'update_time',
align: 'center', align: 'center',
render: 'datetime', render: 'datetime',
operator: 'RANGE', operator: 'RANGE',
comSearchRender: 'datetime', comSearchRender: 'datetime',
sortable: 'custom', sortable: 'custom',
width: 160, width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss', timeFormat: 'yyyy-mm-dd hh:MM:ss',
}, },
{ label: t('mall.player.score'), prop: 'score', align: 'center', sortable: false, operator: 'RANGE' }, { label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
], ],
dblClickNotEditColumn: [undefined], dblClickNotEditColumn: [undefined],
}, },
{ {
defaultItems: {}, defaultItems: { value_type: 'string', remark: '' },
} }
) )

View File

@@ -1,7 +1,4 @@
<template> <template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog <el-dialog
class="ba-operate-dialog" class="ba-operate-dialog"
:close-on-click-modal="false" :close-on-click-modal="false"
@@ -30,26 +27,41 @@
:rules="rules" :rules="rules"
> >
<FormItem <FormItem
:label="t('mall.player.username')" :label="t('game.config.config_key')"
type="string" type="string"
v-model="baTable.form.items!.username" v-model="baTable.form.items!.config_key"
prop="username" prop="config_key"
:placeholder="t('Please input field', { field: t('mall.player.username') })" :placeholder="t('Please input field', { field: t('game.config.config_key') })"
:input-attr="{ disabled: baTable.form.operate === 'Edit' }"
/> />
<FormItem <FormItem
:label="t('mall.player.password')" :label="t('game.config.config_value')"
type="password" type="textarea"
v-model="baTable.form.items!.password" v-model="baTable.form.items!.config_value"
prop="password" prop="config_value"
:placeholder="t('Please input field', { field: t('mall.player.password') })" :input-attr="{ rows: 5 }"
:placeholder="t('Please input field', { field: t('game.config.config_value') })"
/> />
<FormItem <FormItem
:label="t('mall.player.score')" :label="t('game.config.value_type')"
type="number" type="radio"
v-model="baTable.form.items!.score" v-model="baTable.form.items!.value_type"
prop="score" prop="value_type"
:input-attr="{ step: 1 }" :input-attr="{
:placeholder="t('Please input field', { field: t('mall.player.score') })" content: {
string: t('game.config.value_type string'),
int: t('game.config.value_type int'),
decimal: t('game.config.value_type decimal'),
json: t('game.config.value_type json'),
},
}"
/>
<FormItem
:label="t('game.config.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 2 }"
/> />
</el-form> </el-form>
</div> </div>
@@ -72,38 +84,23 @@ import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue' import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable' import type baTableClass from '/@/utils/baTable'
import { buildValidatorData, regularPassword } from '/@/utils/validate'
const config = useConfig() const config = useConfig()
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass const baTable = inject('baTable') as baTableClass
const { t } = useI18n() const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('mall.player.username') })], config_key: [buildRequired('game.config.config_key')],
password: [ value_type: [buildRequired('game.config.value_type')],
{
validator: (_rule: unknown, val: string, callback: (error?: Error) => void) => {
if (baTable.form.operate === 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('mall.player.password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
score: [buildValidatorData({ name: 'number', title: t('mall.player.score') })],
}) })
function buildRequired(langKey: string): FormItemRule {
return {
required: true,
message: t('Please input field', { field: t(langKey) }),
}
}
</script> </script>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -0,0 +1,326 @@
<template>
<div class="default-main">
<el-alert type="info" :title="t('game.live.tip')" show-icon class="mb-12" />
<el-alert :type="pushConnected ? 'success' : 'error'" :title="pushConnected ? t('game.live.push_connected') : t('game.live.push_disconnected')" show-icon class="mb-12" />
<el-card shadow="never" class="mb-12">
<div class="header-row">
<div>
<div>{{ t('game.live.current_record') }}: {{ snapshot.record?.period_no || '-' }}</div>
<div>{{ t('game.live.ai_default_number') }}: {{ snapshot.ai_default_number ?? '-' }}</div>
<div>{{ t('game.live.countdown') }}: {{ countdownText }}</div>
</div>
<div class="header-actions">
<el-input-number v-model="manualNumber" :min="1" :max="36" :step="1" />
<el-button :loading="calcLoading" :disabled="!snapshot.can_calculate" @click="onCalculate">
{{ t('game.live.btn_calc') }}
</el-button>
<el-button type="primary" :loading="drawLoading" :disabled="!snapshot.can_draw" @click="onDraw">
{{ t('game.live.btn_draw') }}
</el-button>
<el-button :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
</div>
</div>
<div class="result-row">
<span>{{ t('game.live.calc_result_number') }}: {{ calcResultNumber ?? '-' }}</span>
<span>{{ t('game.live.calc_estimated_loss') }}: {{ calcEstimatedLoss }}</span>
</div>
</el-card>
<el-row :gutter="12">
<el-col :span="12">
<el-card shadow="never">
<template #header>{{ t('game.live.candidate_title') }}</template>
<el-table :data="snapshot.candidate_numbers" height="420">
<el-table-column prop="number" :label="t('game.live.number')" width="100" />
<el-table-column prop="estimated_loss" :label="t('game.live.estimated_loss')" />
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>{{ t('game.live.bet_stream_title') }}</template>
<el-table :data="snapshot.bets" height="420">
<el-table-column prop="id" :label="t('game.live.bet_id')" width="90" />
<el-table-column prop="user_id" :label="t('game.live.user_id')" width="90" />
<el-table-column prop="pick_numbers" :label="t('game.live.pick_numbers')">
<template #default="scope">
{{ formatPicks(scope.row.pick_numbers) }}
</template>
</el-table-column>
<el-table-column prop="unit_amount" :label="t('game.live.unit_amount')" width="120" />
<el-table-column prop="streak_at_bet" :label="t('game.live.streak_at_bet')" width="90" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import createAxios from '/@/utils/axios'
interface Snapshot {
record: anyObj | null
bets: anyObj[]
candidate_numbers: anyObj[]
ai_default_number: number | null
period_seconds?: number
bet_seconds?: number
pick_max_number_count?: number
remaining_seconds?: number
bet_remaining_seconds?: number
can_calculate?: boolean
can_draw?: boolean
}
const { t } = useI18n()
const loading = ref(false)
const pushConnected = ref(false)
const lastPushAt = ref(0)
const snapshot = reactive<Snapshot>({
record: null,
bets: [],
candidate_numbers: [],
ai_default_number: null,
period_seconds: 30,
bet_seconds: 20,
pick_max_number_count: 36,
remaining_seconds: 0,
bet_remaining_seconds: 0,
can_calculate: false,
can_draw: false,
})
const calcLoading = ref(false)
const drawLoading = ref(false)
const manualNumber = ref<number | null>(1)
const calcResultNumber = ref<number | null>(null)
const calcEstimatedLoss = ref<string>('0.0000')
let pushClient: any = null
let pushChannel: any = null
let pollTimer: number | null = null
let pushWatchdogTimer: number | null = null
function formatPicks(v: unknown): string {
if (Array.isArray(v)) return JSON.stringify(v)
if (typeof v === 'string') return v
return '-'
}
async function loadSnapshot() {
loading.value = true
try {
const res = await createAxios({ url: '/admin/game.Live/snapshot', method: 'get', showCodeMessage: false })
if (res.code === 1 && res.data) {
snapshot.record = res.data.record
snapshot.bets = res.data.bets || []
snapshot.candidate_numbers = res.data.candidate_numbers || []
snapshot.ai_default_number = res.data.ai_default_number
snapshot.period_seconds = res.data.period_seconds ?? 30
snapshot.bet_seconds = res.data.bet_seconds ?? 20
snapshot.pick_max_number_count = 36
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
snapshot.bet_remaining_seconds = res.data.bet_remaining_seconds ?? 0
snapshot.can_calculate = !!res.data.can_calculate
snapshot.can_draw = !!res.data.can_draw
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > 36) manualNumber.value = 1
}
} finally {
loading.value = false
}
}
async function initPush() {
const cfgRes = await createAxios({ url: '/admin/game.Live/pushConfig', method: 'get', showCodeMessage: false })
if (cfgRes.code !== 1 || !cfgRes.data) {
pushConnected.value = false
return
}
const { url, app_key, channel, event } = cfgRes.data
try {
await loadPushJs()
} catch {
pushConnected.value = false
startPolling()
return
}
const PushCtor = (window as any).Push
if (!PushCtor) {
pushConnected.value = false
startPolling()
return
}
try {
pushClient = new PushCtor({ url, app_key })
pushChannel = pushClient.subscribe(channel)
pushConnected.value = false
startPushWatchdog()
stopPolling()
pushChannel.on(event, (payload: anyObj) => {
lastPushAt.value = Date.now()
pushConnected.value = true
snapshot.record = payload.record || null
snapshot.bets = payload.bets || []
snapshot.candidate_numbers = payload.candidate_numbers || []
snapshot.ai_default_number = payload.ai_default_number ?? null
snapshot.period_seconds = payload.period_seconds ?? 30
snapshot.bet_seconds = payload.bet_seconds ?? 20
snapshot.pick_max_number_count = 36
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
snapshot.bet_remaining_seconds = payload.bet_remaining_seconds ?? 0
snapshot.can_calculate = !!payload.can_calculate
snapshot.can_draw = !!payload.can_draw
})
} catch {
pushConnected.value = false
startPolling()
}
}
async function loadPushJs() {
if ((window as any).Push) {
return
}
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = '/plugin/webman/push/push.js'
script.onload = () => resolve()
script.onerror = () => reject(new Error('load push.js failed'))
document.head.appendChild(script)
})
}
async function onCalculate() {
if (!snapshot.record) return
calcLoading.value = true
try {
const res = await createAxios({
url: '/admin/game.Live/calculate',
method: 'post',
data: {
record_id: snapshot.record.id,
manual_number: manualNumber.value,
},
showSuccessMessage: true,
})
if (res.code === 1 && res.data) {
snapshot.candidate_numbers = res.data.candidate_numbers || []
snapshot.ai_default_number = res.data.ai_default_number ?? null
calcResultNumber.value = res.data.final_number ?? null
calcEstimatedLoss.value = String(res.data.final_estimated_loss ?? '0.0000')
}
} finally {
calcLoading.value = false
}
}
async function onDraw() {
if (!snapshot.record) return
drawLoading.value = true
try {
await createAxios({
url: '/admin/game.Live/draw',
method: 'post',
data: {
record_id: snapshot.record.id,
manual_number: manualNumber.value,
},
showSuccessMessage: true,
})
await loadSnapshot()
} finally {
drawLoading.value = false
}
}
const countdownText = computed(() => {
const total = snapshot.remaining_seconds ?? 0
const bet = snapshot.bet_remaining_seconds ?? 0
return `${t('game.live.bet_countdown')} ${bet}s / ${t('game.live.draw_countdown')} ${total}s`
})
onMounted(async () => {
await loadSnapshot()
try {
await initPush()
} catch {
pushConnected.value = false
startPolling()
}
})
onUnmounted(() => {
try {
if (pushClient && typeof pushClient.disconnect === 'function') {
pushClient.disconnect()
}
} catch {
// ignore
}
stopPolling()
stopPushWatchdog()
})
function startPolling() {
if (pollTimer !== null) {
return
}
pollTimer = window.setInterval(() => {
void loadSnapshot()
}, 2000)
}
function stopPolling() {
if (pollTimer !== null) {
window.clearInterval(pollTimer)
pollTimer = null
}
}
function startPushWatchdog() {
if (pushWatchdogTimer !== null) {
return
}
pushWatchdogTimer = window.setInterval(() => {
const state = pushClient && pushClient.connection ? String(pushClient.connection.state || '') : ''
const stateConnected = state === 'connected' || state === 'connecting'
const hasRecentPush = lastPushAt.value > 0 && Date.now() - lastPushAt.value <= 6000
if (!stateConnected || !hasRecentPush) {
pushConnected.value = false
}
}, 1000)
}
function stopPushWatchdog() {
if (pushWatchdogTimer !== null) {
window.clearInterval(pushWatchdogTimer)
pushWatchdogTimer = null
}
}
</script>
<style scoped lang="scss">
.mb-12 {
margin-bottom: 12px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.result-row {
margin-top: 12px;
display: flex;
gap: 16px;
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<el-card v-if="canSettings" class="period-settings-card" shadow="never">
<template #header>
<span>{{ t('game.period.section_auto') }}</span>
</template>
<div v-loading="settingsLoading" class="period-settings-body">
<div class="period-setting-row">
<span class="period-setting-label">{{ t('game.period.auto_create_label') }}</span>
<el-switch v-model="autoCreate" :disabled="settingsSaving" @change="onSwitchChange" />
<span class="period-setting-tip">{{ t('game.period.auto_create_tip') }}</span>
</div>
<div class="period-setting-row">
<span class="period-setting-label">{{ t('game.period.manual_create_label') }}</span>
<el-switch v-model="manualCreate" :disabled="settingsSaving" @change="onSwitchChange" />
<span class="period-setting-tip">{{ t('game.period.manual_create_tip') }}</span>
</div>
<div v-if="canManual" class="period-setting-actions">
<el-button type="primary" :loading="createLoading" @click="onCreateNextManual">
{{ t('game.period.btn_create_next') }}
</el-button>
</div>
</div>
</el-card>
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.period.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, provide, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import createAxios from '/@/utils/axios'
import baTableClass from '/@/utils/baTable'
import { auth } from '/@/utils/common'
defineOptions({
name: 'game/period',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const settingsLoading = ref(false)
const settingsSaving = ref(false)
const createLoading = ref(false)
const autoCreate = ref(false)
const manualCreate = ref(false)
/** 避免初次拉取开关值时触发保存 */
const settingsReady = ref(false)
const canSettings = computed(() => auth('periodSettings'))
const canManual = computed(() => auth('createNextManual'))
const baTable = new baTableClass(
new baTableApi('/admin/game.Period/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.period.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.period.period_no'),
prop: 'period_no',
align: 'center',
minWidth: 180,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
},
{
label: t('game.period.period_start_at'),
prop: 'period_start_at',
align: 'center',
width: 170,
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('game.period.status'),
prop: 'status',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'success',
'1': 'warning',
'2': 'info',
'3': 'primary',
'4': 'warning',
'5': 'danger',
},
replaceValue: {
'0': t('game.period.status 0'),
'1': t('game.period.status 1'),
'2': t('game.period.status 2'),
'3': t('game.period.status 3'),
'4': t('game.period.status 4'),
'5': t('game.period.status 5'),
},
},
{
label: t('game.period.draw_mode'),
prop: 'draw_mode',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
custom: {
'0': 'info',
'1': 'warning',
},
replaceValue: {
'0': t('game.period.draw_mode 0'),
'1': t('game.period.draw_mode 1'),
},
},
{
label: t('game.period.preset_number'),
prop: 'preset_number',
align: 'center',
width: 100,
operator: 'RANGE',
},
{
label: t('game.period.result_number'),
prop: 'result_number',
align: 'center',
width: 100,
operator: 'RANGE',
},
{
label: t('game.period.void_reason'),
prop: 'void_reason',
align: 'center',
minWidth: 140,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.period.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('game.period.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: 0,
draw_mode: 0,
void_reason: '',
},
}
)
provide('baTable', baTable)
async function loadPeriodSettings() {
if (!canSettings.value) {
return
}
settingsLoading.value = true
try {
const res = await createAxios({
url: '/admin/game.Period/periodSettings',
method: 'get',
showCodeMessage: false,
})
if (res.code === 1 && res.data) {
autoCreate.value = res.data.period_auto_create_enabled === 1
manualCreate.value = res.data.period_manual_create_enabled === 1
settingsReady.value = true
}
} catch {
// 无权限或接口异常时不打断列表
} finally {
settingsLoading.value = false
}
}
async function onSaveSettings() {
if (!canSettings.value) {
return
}
settingsSaving.value = true
try {
await createAxios({
url: '/admin/game.Period/periodSettings',
method: 'post',
data: {
period_auto_create_enabled: autoCreate.value ? 1 : 0,
period_manual_create_enabled: manualCreate.value ? 1 : 0,
},
showSuccessMessage: true,
})
} finally {
settingsSaving.value = false
}
}
function onSwitchChange() {
if (!settingsReady.value) {
return
}
void onSaveSettings()
}
async function onCreateNextManual() {
if (!canManual.value) {
return
}
createLoading.value = true
try {
await createAxios({
url: '/admin/game.Period/createNextManual',
method: 'post',
showSuccessMessage: true,
})
await baTable.getData()
} finally {
createLoading.value = false
}
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
void loadPeriodSettings()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss">
.period-settings-card {
margin-bottom: 12px;
}
.period-settings-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.period-setting-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.period-setting-label {
min-width: 160px;
font-weight: 500;
}
.period-setting-tip {
color: var(--el-text-color-secondary);
font-size: 13px;
flex: 1;
min-width: 200px;
}
.period-setting-actions {
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
v-if="!baTable.form.loading"
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<FormItem
:label="t('game.period.period_no')"
type="string"
v-model="baTable.form.items!.period_no"
prop="period_no"
:placeholder="t('Please input field', { field: t('game.period.period_no') })"
/>
<FormItem
:label="t('game.period.period_start_at')"
type="datetime"
v-model="baTable.form.items!.period_start_at"
prop="period_start_at"
:placeholder="t('Please select field', { field: t('game.period.period_start_at') })"
/>
<FormItem
:label="t('game.period.status')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{
content: {
'0': t('game.period.status 0'),
'1': t('game.period.status 1'),
'2': t('game.period.status 2'),
'3': t('game.period.status 3'),
'4': t('game.period.status 4'),
'5': t('game.period.status 5'),
},
}"
/>
<FormItem
:label="t('game.period.draw_mode')"
type="radio"
v-model="baTable.form.items!.draw_mode"
prop="draw_mode"
:input-attr="{
content: {
'0': t('game.period.draw_mode 0'),
'1': t('game.period.draw_mode 1'),
},
}"
/>
<FormItem
:label="t('game.period.preset_number')"
type="number"
v-model="baTable.form.items!.preset_number"
prop="preset_number"
:input-attr="{ step: 1, min: 1, max: 36 }"
/>
<FormItem
:label="t('game.period.result_number')"
type="number"
v-model="baTable.form.items!.result_number"
prop="result_number"
:input-attr="{ step: 1, min: 1, max: 36 }"
/>
<FormItem
:label="t('game.period.void_reason')"
type="textarea"
v-model="baTable.form.items!.void_reason"
prop="void_reason"
:input-attr="{ rows: 3 }"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
period_no: [buildRequired()],
})
function buildRequired(): FormItemRule {
return {
required: true,
message: t('Please input field', { field: t('game.period.period_no') }),
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,89 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.record.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'game/record',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit'])
const baTable = new baTableClass(
new baTableApi('/admin/game.Record/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.record.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{ label: t('game.record.period_no'), prop: 'period_no', align: 'center', minWidth: 180, operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
{ label: t('game.record.period_start_at'), prop: 'period_start_at', align: 'center', width: 170, render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{
label: t('game.record.status'),
prop: 'status',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { '0': 'success', '1': 'warning', '2': 'info', '3': 'primary', '4': 'warning', '5': 'danger' },
replaceValue: { '0': t('game.record.status 0'), '1': t('game.record.status 1'), '2': t('game.record.status 2'), '3': t('game.record.status 3'), '4': t('game.record.status 4'), '5': t('game.record.status 5') },
},
{
label: t('game.record.draw_mode'),
prop: 'draw_mode',
align: 'center',
width: 110,
operator: 'eq',
render: 'tag',
custom: { '0': 'info', '1': 'warning' },
replaceValue: { '0': t('game.record.draw_mode 0'), '1': t('game.record.draw_mode 1') },
},
{ label: t('game.record.preset_number'), prop: 'preset_number', align: 'center', width: 100, operator: 'RANGE' },
{ label: t('game.record.result_number'), prop: 'result_number', align: 'center', width: 100, operator: 'RANGE' },
{ label: t('game.record.void_reason'), prop: 'void_reason', align: 'center', minWidth: 140, operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE', showOverflowTooltip: true },
{ label: t('game.record.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('game.record.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { status: 0, draw_mode: 0, void_reason: '' },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,59 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules" :disabled="true">
<FormItem :label="t('game.record.period_no')" type="string" v-model="baTable.form.items!.period_no" prop="period_no" />
<FormItem :label="t('game.record.period_start_at')" type="datetime" v-model="baTable.form.items!.period_start_at" prop="period_start_at" />
<FormItem
:label="t('game.record.status')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('game.record.status 0'), '1': t('game.record.status 1'), '2': t('game.record.status 2'), '3': t('game.record.status 3'), '4': t('game.record.status 4'), '5': t('game.record.status 5') } }"
/>
<FormItem
:label="t('game.record.draw_mode')"
type="radio"
v-model="baTable.form.items!.draw_mode"
prop="draw_mode"
:input-attr="{ content: { '0': t('game.record.draw_mode 0'), '1': t('game.record.draw_mode 1') } }"
/>
<FormItem :label="t('game.record.preset_number')" type="number" v-model="baTable.form.items!.preset_number" prop="preset_number" :input-attr="{ step: 1, min: 1, max: 36 }" />
<FormItem :label="t('game.record.result_number')" type="number" v-model="baTable.form.items!.result_number" prop="result_number" :input-attr="{ step: 1, min: 1, max: 36 }" />
<FormItem :label="t('game.record.void_reason')" type="textarea" v-model="baTable.form.items!.void_reason" prop="void_reason" :input-attr="{ rows: 3 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
period_no: [{ required: true, message: t('Please input field', { field: t('game.record.period_no') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="default-main ba-table-box zi-hua-dict-page">
<el-alert type="info" :closable="false" show-icon>
{{ t('game.ziHuaDictionary.desc') }}
</el-alert>
<div class="toolbar">
<el-button type="primary" :loading="saving" :disabled="loading" @click="onSave">
{{ t('game.ziHuaDictionary.btn_save') }}
</el-button>
</div>
<el-table v-loading="loading" border stripe :data="items" row-key="no" max-height="640">
<el-table-column prop="no" :label="t('game.ziHuaDictionary.no')" width="72" align="center" />
<el-table-column :label="t('game.ziHuaDictionary.name')" min-width="120">
<template #default="{ row }">
<el-input v-model="row.name" maxlength="32" show-word-limit />
</template>
</el-table-column>
<el-table-column :label="t('game.ziHuaDictionary.category')" min-width="160">
<template #default="{ row }">
<el-select v-model="row.category" style="width: 100%">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
defineOptions({
name: 'game/ziHuaDictionary',
})
const { t } = useI18n()
type Item = { no: number; name: string; category: string }
const loading = ref(false)
const saving = ref(false)
const items = ref<Item[]>([])
const categories = ref<string[]>(['zodiac', 'beast', 'fowl', 'vermin', 'divine'])
function categoryLabel(cat: string): string {
return t('game.ziHuaDictionary.category_label.' + cat)
}
async function load() {
loading.value = true
try {
const res = await createAxios({
url: '/admin/game.ZiHuaDictionary/index',
method: 'get',
})
if (res.code === 1 && res.data) {
const list = res.data.items as Item[]
items.value = Array.isArray(list) ? list : []
if (Array.isArray(res.data.categories) && res.data.categories.length) {
categories.value = res.data.categories as string[]
}
}
} finally {
loading.value = false
}
}
async function onSave() {
if (!auth('save')) {
return
}
saving.value = true
try {
await createAxios({
url: '/admin/game.ZiHuaDictionary/save',
method: 'post',
data: { items: items.value },
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.zi-hua-dict-page {
.toolbar {
margin: 12px 0;
}
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('operation.operationNotice.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'operation/operationNotice',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/operation.OperationNotice/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('operation.operationNotice.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('operation.operationNotice.title'),
prop: 'title',
align: 'center',
minWidth: 200,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('operation.operationNotice.notice_type'),
prop: 'notice_type',
align: 'center',
width: 120,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { 0: 'info', 1: 'warning' },
replaceValue: {
'0': t('operation.operationNotice.notice_type 0'),
'1': t('operation.operationNotice.notice_type 1'),
},
},
{
label: t('operation.operationNotice.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { 0: 'info', 1: 'success' },
replaceValue: {
'0': t('operation.operationNotice.status 0'),
'1': t('operation.operationNotice.status 1'),
},
},
{
label: t('operation.operationNotice.publish_at'),
prop: 'publish_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('operation.operationNotice.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('operation.operationNotice.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { status: 0, notice_type: 0 },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,56 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem :label="t('operation.operationNotice.title')" type="string" v-model="baTable.form.items!.title" prop="title" />
<FormItem :label="t('operation.operationNotice.content')" type="textarea" v-model="baTable.form.items!.content" prop="content" :input-attr="{ rows: 8 }" />
<FormItem
:label="t('operation.operationNotice.notice_type')"
type="radio"
v-model="baTable.form.items!.notice_type"
prop="notice_type"
:input-attr="{ content: { '0': t('operation.operationNotice.notice_type 0'), '1': t('operation.operationNotice.notice_type 1') } }"
/>
<FormItem
:label="t('operation.operationNotice.status')"
type="radio"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('operation.operationNotice.status 0'), '1': t('operation.operationNotice.status 1') } }"
/>
<FormItem :label="t('operation.operationNotice.publish_at')" type="datetime" v-model="baTable.form.items!.publish_at" prop="publish_at" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [{ required: true, message: t('Please input field', { field: t('operation.operationNotice.title') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,116 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('operation.userNoticeRead.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'operation/userNoticeRead',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/operation.UserNoticeRead/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('operation.userNoticeRead.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{ label: t('operation.userNoticeRead.user_id'), prop: 'user_id', align: 'center', width: 100, operator: 'RANGE', show: false },
{
label: t('operation.userNoticeRead.username'),
prop: 'user.username',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('operation.userNoticeRead.notice_id'), prop: 'notice_id', align: 'center', width: 100, operator: 'RANGE', show: false },
{
label: t('operation.userNoticeRead.notice_title'),
prop: 'operationNotice.title',
align: 'center',
minWidth: 180,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
label: t('operation.userNoticeRead.read_at'),
prop: 'read_at',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('operation.userNoticeRead.confirmed'),
prop: 'confirmed',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { 0: 'info', 1: 'success' },
replaceValue: {
'0': t('operation.userNoticeRead.confirmed 0'),
'1': t('operation.userNoticeRead.confirmed 1'),
},
},
{
label: t('operation.userNoticeRead.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
width: 170,
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', minWidth: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { confirmed: 0 },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,74 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem
:label="t('operation.userNoticeRead.user_id')"
type="remoteSelect"
v-model="baTable.form.items!.user_id"
prop="user_id"
:key="'uid-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'username',
remoteUrl: '/admin/user.User/index',
placeholder: t('Click select'),
}"
/>
<FormItem
:label="t('operation.userNoticeRead.notice_id')"
type="remoteSelect"
v-model="baTable.form.items!.notice_id"
prop="notice_id"
:key="'nid-' + (baTable.form.items!.id ?? 'new')"
:input-attr="{
pk: 'id',
field: 'title',
remoteUrl: '/admin/operation.OperationNotice/index',
placeholder: t('Click select'),
}"
/>
<FormItem :label="t('operation.userNoticeRead.read_at')" type="datetime" v-model="baTable.form.items!.read_at" prop="read_at" />
<FormItem
:label="t('operation.userNoticeRead.confirmed')"
type="radio"
v-model="baTable.form.items!.confirmed"
prop="confirmed"
:input-attr="{ content: { '0': t('operation.userNoticeRead.confirmed 0'), '1': t('operation.userNoticeRead.confirmed 1') } }"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [{ required: true, message: t('Please input field', { field: t('operation.userNoticeRead.user_id') }) }],
notice_id: [{ required: true, message: t('Please input field', { field: t('operation.userNoticeRead.notice_id') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('order.betOrder.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'order/betOrder',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
function formatPickNumbers(_row: anyObj, _column: any, cellValue: unknown) {
if (cellValue === null || cellValue === undefined) {
return '-'
}
if (typeof cellValue === 'string') {
return cellValue
}
try {
return JSON.stringify(cellValue)
} catch {
return String(cellValue)
}
}
function formatAmount(_row: anyObj, _column: any, cellValue: unknown) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '-'
}
const s = String(cellValue).trim().replace(',', '.')
const n = parseFloat(s)
if (!Number.isFinite(n)) {
return String(cellValue)
}
return n.toFixed(4)
}
const baTable = new baTableClass(
new baTableApi('/admin/order.BetOrder/'),
{
pk: 'id',
column: [
{ label: t('order.betOrder.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{ label: t('order.betOrder.period_id'), prop: 'period_id', align: 'center', width: 100, operator: 'RANGE' },
{
label: t('order.betOrder.period_no'),
prop: 'period_no',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
},
{
label: t('order.betOrder.gameRecord_period_no'),
prop: 'gameRecord.period_no',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
},
{
label: t('order.betOrder.gameRecord_status'),
prop: 'gameRecord.status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'success',
'1': 'warning',
'2': 'info',
'3': 'primary',
'4': 'warning',
'5': 'danger',
},
replaceValue: {
'0': t('order.betOrder.gameRecord_status 0'),
'1': t('order.betOrder.gameRecord_status 1'),
'2': t('order.betOrder.gameRecord_status 2'),
'3': t('order.betOrder.gameRecord_status 3'),
'4': t('order.betOrder.gameRecord_status 4'),
'5': t('order.betOrder.gameRecord_status 5'),
},
},
{
label: t('order.betOrder.user_id'),
prop: 'user_id',
align: 'center',
show: false,
width: 90,
operator: 'RANGE',
},
{
label: t('order.betOrder.user_username'),
prop: 'user.username',
align: 'center',
minWidth: 120,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
},
{
label: t('order.betOrder.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
},
{
label: t('order.betOrder.pick_numbers'),
prop: 'pick_numbers',
align: 'center',
minWidth: 120,
operator: false,
formatter: formatPickNumbers,
},
{
label: t('order.betOrder.unit_amount'),
prop: 'unit_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{ label: t('order.betOrder.pick_count'), prop: 'pick_count', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('order.betOrder.total_amount'),
prop: 'total_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{ label: t('order.betOrder.streak_at_bet'), prop: 'streak_at_bet', align: 'center', width: 110, operator: 'RANGE' },
{
label: t('order.betOrder.is_auto'),
prop: 'is_auto',
align: 'center',
width: 90,
operator: 'eq',
render: 'tag',
custom: {
'0': 'info',
'1': 'primary',
},
replaceValue: {
'0': t('order.betOrder.is_auto 0'),
'1': t('order.betOrder.is_auto 1'),
},
},
{
label: t('order.betOrder.win_amount'),
prop: 'win_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('order.betOrder.jackpot_extra_amount'),
prop: 'jackpot_extra_amount',
align: 'center',
minWidth: 120,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('order.betOrder.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'1': 'warning',
'2': 'success',
'3': 'danger',
},
replaceValue: {
'1': t('order.betOrder.status 1'),
'2': t('order.betOrder.status 2'),
'3': t('order.betOrder.status 3'),
},
},
{
label: t('order.betOrder.idempotency_key'),
prop: 'idempotency_key',
align: 'center',
minWidth: 140,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('order.betOrder.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('order.betOrder.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
],
dblClickNotEditColumn: [undefined],
},
{}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('order.depositOrder.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'order/depositOrder',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/order.DepositOrder/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('order.depositOrder.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{
label: t('order.depositOrder.order_no'),
prop: 'order_no',
align: 'center',
minWidth: 170,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('order.depositOrder.user_id'), prop: 'user_id', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('order.depositOrder.user_username'),
prop: 'user.username',
align: 'center',
minWidth: 110,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{
label: t('order.depositOrder.channel_name'),
prop: 'channel.name',
align: 'center',
minWidth: 110,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
},
{ label: t('order.depositOrder.amount'), prop: 'amount', align: 'center', minWidth: 110, operator: 'RANGE' },
{
label: t('order.depositOrder.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'info',
'1': 'warning',
'2': 'success',
'3': 'danger',
},
replaceValue: {
'0': t('order.depositOrder.status 0'),
'1': t('order.depositOrder.status 1'),
'2': t('order.depositOrder.status 2'),
'3': t('order.depositOrder.status 3'),
},
},
{
label: t('order.depositOrder.pay_channel'),
prop: 'pay_channel',
align: 'center',
minWidth: 110,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('order.depositOrder.pay_time'),
prop: 'pay_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('order.depositOrder.remark'),
prop: 'remark',
align: 'center',
minWidth: 150,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{
label: t('order.depositOrder.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('order.depositOrder.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('Operate'),
align: 'center',
width: 90,
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
},
{
defaultItems: { status: 0, amount: '0.0000' },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,48 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem :label="t('order.depositOrder.order_no')" type="string" v-model="baTable.form.items!.order_no" prop="order_no" />
<FormItem :label="t('order.depositOrder.user_id')" type="number" v-model="baTable.form.items!.user_id" prop="user_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.depositOrder.channel_id')" type="number" v-model="baTable.form.items!.channel_id" prop="channel_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.depositOrder.amount')" type="number" v-model="baTable.form.items!.amount" prop="amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.depositOrder.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('order.depositOrder.status 0'), '1': t('order.depositOrder.status 1'), '2': t('order.depositOrder.status 2'), '3': t('order.depositOrder.status 3') } }" />
<FormItem :label="t('order.depositOrder.pay_channel')" type="string" v-model="baTable.form.items!.pay_channel" prop="pay_channel" />
<FormItem :label="t('order.depositOrder.pay_time')" type="datetime" v-model="baTable.form.items!.pay_time" prop="pay_time" />
<FormItem :label="t('order.depositOrder.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
order_no: [{ required: true, message: t('Please input field', { field: t('order.depositOrder.order_no') }) }],
user_id: [{ required: true, message: t('Please input field', { field: t('order.depositOrder.user_id') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,89 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('order.withdrawOrder.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'order/withdrawOrder',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
new baTableApi('/admin/order.WithdrawOrder/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('order.withdrawOrder.id'), prop: 'id', align: 'center', width: 80, operator: 'RANGE', sortable: 'custom' },
{ label: t('order.withdrawOrder.order_no'), prop: 'order_no', align: 'center', minWidth: 170, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('order.withdrawOrder.user_id'), prop: 'user_id', align: 'center', width: 90, operator: 'RANGE' },
{ label: t('order.withdrawOrder.user_username'), prop: 'user.username', align: 'center', minWidth: 110, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tags' },
{ label: t('order.withdrawOrder.channel_name'), prop: 'channel.name', align: 'center', minWidth: 110, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tags' },
{ label: t('order.withdrawOrder.amount'), prop: 'amount', align: 'center', minWidth: 110, operator: 'RANGE' },
{ label: t('order.withdrawOrder.fee'), prop: 'fee', align: 'center', minWidth: 110, operator: 'RANGE' },
{ label: t('order.withdrawOrder.actual_amount'), prop: 'actual_amount', align: 'center', minWidth: 110, operator: 'RANGE' },
{
label: t('order.withdrawOrder.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'info',
'1': 'warning',
'2': 'success',
'3': 'danger',
},
replaceValue: { '0': t('order.withdrawOrder.status 0'), '1': t('order.withdrawOrder.status 1'), '2': t('order.withdrawOrder.status 2'), '3': t('order.withdrawOrder.status 3') },
},
{ label: t('order.withdrawOrder.review_admin_username'), prop: 'reviewAdmin.username', align: 'center', minWidth: 100, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), render: 'tags' },
{ label: t('order.withdrawOrder.review_time'), prop: 'review_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('order.withdrawOrder.remark'), prop: 'remark', align: 'center', minWidth: 150, operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), showOverflowTooltip: true },
{ label: t('order.withdrawOrder.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('order.withdrawOrder.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('Operate'), align: 'center', width: 90, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
},
{
defaultItems: { status: 0, amount: '0.0000', fee: '0.0000', actual_amount: '0.0000' },
}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,50 @@
<template>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)" @close="baTable.toggleForm">
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">{{ baTable.form.operate ? t(baTable.form.operate) : '' }}</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form" :class="'ba-' + baTable.form.operate + '-form'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'">
<el-form v-if="!baTable.form.loading" ref="formRef" @submit.prevent="" @keyup.enter="baTable.onSubmit(formRef)" :model="baTable.form.items" :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="baTable.form.labelWidth + 'px'" :rules="rules">
<FormItem :label="t('order.withdrawOrder.order_no')" type="string" v-model="baTable.form.items!.order_no" prop="order_no" />
<FormItem :label="t('order.withdrawOrder.user_id')" type="number" v-model="baTable.form.items!.user_id" prop="user_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.channel_id')" type="number" v-model="baTable.form.items!.channel_id" prop="channel_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.amount')" type="number" v-model="baTable.form.items!.amount" prop="amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.fee')" type="number" v-model="baTable.form.items!.fee" prop="fee" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.actual_amount')" type="number" v-model="baTable.form.items!.actual_amount" prop="actual_amount" :input-attr="{ step: 0.0001, precision: 4, min: 0 }" />
<FormItem :label="t('order.withdrawOrder.status')" type="radio" v-model="baTable.form.items!.status" prop="status" :input-attr="{ content: { '0': t('order.withdrawOrder.status 0'), '1': t('order.withdrawOrder.status 1'), '2': t('order.withdrawOrder.status 2'), '3': t('order.withdrawOrder.status 3') } }" />
<FormItem :label="t('order.withdrawOrder.review_admin_id')" type="number" v-model="baTable.form.items!.review_admin_id" prop="review_admin_id" :input-attr="{ min: 1, step: 1 }" />
<FormItem :label="t('order.withdrawOrder.review_time')" type="datetime" v-model="baTable.form.items!.review_time" prop="review_time" />
<FormItem :label="t('order.withdrawOrder.remark')" type="textarea" v-model="baTable.form.items!.remark" prop="remark" :input-attr="{ rows: 2 }" />
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
order_no: [{ required: true, message: t('Please input field', { field: t('order.withdrawOrder.order_no') }) }],
user_id: [{ required: true, message: t('Please input field', { field: t('order.withdrawOrder.user_id') }) }],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<TableHeader
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('record.userWalletRecord.quick Search Fields') })"
></TableHeader>
<Table ref="tableRef"></Table>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'record/userWalletRecord',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
function formatAmount(_row: anyObj, _column: any, cellValue: unknown) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '-'
}
const s = String(cellValue).trim().replace(',', '.')
const n = parseFloat(s)
if (!Number.isFinite(n)) {
return String(cellValue)
}
return n.toFixed(4)
}
const bizReplace = {
deposit: t('record.userWalletRecord.biz deposit'),
withdraw: t('record.userWalletRecord.biz withdraw'),
withdraw_freeze: t('record.userWalletRecord.biz withdraw_freeze'),
withdraw_unfreeze: t('record.userWalletRecord.biz withdraw_unfreeze'),
platform_in: t('record.userWalletRecord.biz platform_in'),
platform_out: t('record.userWalletRecord.biz platform_out'),
admin_credit: t('record.userWalletRecord.biz admin_credit'),
admin_deduct: t('record.userWalletRecord.biz admin_deduct'),
bet: t('record.userWalletRecord.biz bet'),
payout: t('record.userWalletRecord.biz payout'),
fee: t('record.userWalletRecord.biz fee'),
void_refund: t('record.userWalletRecord.biz void_refund'),
adjust: t('record.userWalletRecord.biz adjust'),
}
const bizTypeTagCustom = {
deposit: 'success',
withdraw: 'warning',
withdraw_freeze: 'info',
withdraw_unfreeze: 'info',
platform_in: 'success',
platform_out: 'danger',
admin_credit: 'success',
admin_deduct: 'danger',
bet: 'primary',
payout: 'success',
fee: 'warning',
void_refund: 'info',
adjust: 'warning',
}
const dirReplace = {
'1': t('record.userWalletRecord.direction in'),
'2': t('record.userWalletRecord.direction out'),
}
const baTable = new baTableClass(
new baTableApi('/admin/record.UserWalletRecord/'),
{
pk: 'id',
column: [
{ label: t('record.userWalletRecord.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{
label: t('record.userWalletRecord.user_id'),
prop: 'user_id',
align: 'center',
width: 90,
show: false,
operator: 'RANGE',
sortable: false,
},
{
label: t('record.userWalletRecord.user__username'),
prop: 'user.username',
align: 'center',
minWidth: 110,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('record.userWalletRecord.channel__name'),
prop: 'channel.name',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('record.userWalletRecord.biz_type'),
prop: 'biz_type',
align: 'center',
minWidth: 120,
operator: 'eq',
render: 'tag',
custom: bizTypeTagCustom,
replaceValue: bizReplace,
},
{
label: t('record.userWalletRecord.direction'),
prop: 'direction',
align: 'center',
width: 90,
operator: 'eq',
render: 'tag',
custom: {
'1': 'success',
'2': 'danger',
},
replaceValue: dirReplace,
},
{
label: t('record.userWalletRecord.amount'),
prop: 'amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('record.userWalletRecord.balance_before'),
prop: 'balance_before',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('record.userWalletRecord.balance_after'),
prop: 'balance_after',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('record.userWalletRecord.ref_type'),
prop: 'ref_type',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operator: 'LIKE',
},
{ label: t('record.userWalletRecord.ref_id'), prop: 'ref_id', align: 'center', width: 100, operator: 'RANGE' },
{
label: t('record.userWalletRecord.idempotency_key'),
prop: 'idempotency_key',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('record.userWalletRecord.operator_admin__username'),
prop: 'operatorAdmin.username',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('record.userWalletRecord.remark'),
prop: 'remark',
align: 'center',
minWidth: 140,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('record.userWalletRecord.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
],
},
{}
)
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -154,10 +154,11 @@ const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
{ {
label: t('Operate'), label: t('Operate'),
align: 'center', align: 'center',
width: '100', width: '80',
render: 'buttons', render: 'buttons',
buttons: optBtn, buttons: optBtn,
operator: false, operator: false,
fixed: 'right',
}, },
], ],
defaultOrder: { prop: 'last_upload_time', order: 'desc' }, defaultOrder: { prop: 'last_upload_time', order: 'desc' },

View File

@@ -76,6 +76,7 @@ const baTable = new baTableClass(
prop: 'status', prop: 'status',
align: 'center', align: 'center',
render: 'tag', render: 'tag',
effect: 'dark',
custom: { 0: 'danger', 1: 'success' }, custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('security.dataRecycle.Deleting monitoring') }, replaceValue: { 0: t('Disable'), 1: t('security.dataRecycle.Deleting monitoring') },
}, },
@@ -84,10 +85,11 @@ const baTable = new baTableClass(
{ {
label: t('Operate'), label: t('Operate'),
align: 'center', align: 'center',
width: '130', width: '80',
render: 'buttons', render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']), buttons: defaultOptButtons(['edit', 'delete']),
operator: false, operator: false,
fixed: 'right',
}, },
], ],
dblClickNotEditColumn: [undefined, 'status'], dblClickNotEditColumn: [undefined, 'status'],

View File

@@ -167,6 +167,7 @@ const baTable = new baTableClass(new baTableApi(url), {
render: 'buttons', render: 'buttons',
buttons: optButtons, buttons: optButtons,
operator: false, operator: false,
fixed: 'right',
}, },
], ],
dblClickNotEditColumn: [undefined], dblClickNotEditColumn: [undefined],

View File

@@ -85,6 +85,7 @@ const baTable = new sensitiveDataClass(
prop: 'status', prop: 'status',
align: 'center', align: 'center',
render: 'tag', render: 'tag',
effect: 'dark',
custom: { 0: 'danger', 1: 'success' }, custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('security.sensitiveData.Modifying monitoring') }, replaceValue: { 0: t('Disable'), 1: t('security.sensitiveData.Modifying monitoring') },
}, },
@@ -93,10 +94,11 @@ const baTable = new sensitiveDataClass(
{ {
label: t('Operate'), label: t('Operate'),
align: 'center', align: 'center',
width: '130', width: '80',
render: 'buttons', render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']), buttons: defaultOptButtons(['edit', 'delete']),
operator: false, operator: false,
fixed: 'right',
}, },
], ],
dblClickNotEditColumn: [undefined], dblClickNotEditColumn: [undefined],

View File

@@ -180,6 +180,7 @@ const baTable = new baTableClass(new baTableApi(url), {
render: 'buttons', render: 'buttons',
buttons: optButtons, buttons: optButtons,
operator: false, operator: false,
fixed: 'right',
}, },
], ],
dblClickNotEditColumn: [undefined], dblClickNotEditColumn: [undefined],

View File

@@ -1,155 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.group.GroupName') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" />
<!-- 表单 -->
<PopupForm ref="formRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { getUserRules } from '/@/api/backend/user/group'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import { uuid } from '/@/utils/random'
defineOptions({
name: 'user/group',
})
const { t } = useI18n()
const formRef = useTemplateRef('formRef')
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/user.Group/'),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.group.Group name'), prop: 'name', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
replaceValue: { 0: t('Disable'), 1: t('Enable') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{
label: t('Operate'),
align: 'center',
width: '130',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
},
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: {
status: 1,
},
}
)
// 利用提交前钩子重写提交操作
baTable.before.onSubmit = ({ formEl, operate, items }) => {
let submitCallback = () => {
baTable.form.submitLoading = true
baTable.api
.postData(operate, {
...items,
rules: formRef.value?.getCheckeds(),
})
.then((res) => {
baTable.onTableHeaderAction('refresh', {})
baTable.form.submitLoading = false
baTable.form.operateIds?.shift()
if (baTable.form.operateIds!.length > 0) {
baTable.toggleForm('Edit', baTable.form.operateIds)
} else {
baTable.toggleForm()
}
baTable.runAfter('onSubmit', { res })
})
.catch(() => {
baTable.form.submitLoading = false
})
}
if (formEl) {
baTable.form.ref = formEl
formEl.validate((valid) => {
if (valid) {
submitCallback()
}
})
} else {
submitCallback()
}
return false
}
// 打开表单后
baTable.after.toggleForm = ({ operate }) => {
if (operate == 'Add') {
menuRuleTreeUpdate()
}
}
// 获取到编辑数据后
baTable.after.getEditData = () => {
menuRuleTreeUpdate()
}
const menuRuleTreeUpdate = () => {
getUserRules().then((res) => {
baTable.form.extend!.menuRules = res.data.list
if (baTable.form.items!.rules && baTable.form.items!.rules.length) {
if (baTable.form.items!.rules.includes('*')) {
let arr: number[] = []
for (const key in baTable.form.extend!.menuRules) {
arr.push(baTable.form.extend!.menuRules[key].id)
}
baTable.form.extend!.defaultCheckedKeys = arr
} else {
baTable.form.extend!.defaultCheckedKeys = baTable.form.items!.rules
}
} else {
baTable.form.extend!.defaultCheckedKeys = []
}
baTable.form.extend!.treeKey = uuid()
})
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,145 +0,0 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
top="10vh"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
>
<el-form-item prop="name" :label="t('user.group.Group name')">
<el-input
v-model="baTable.form.items!.name"
type="string"
:placeholder="t('Please input field', { field: t('user.group.Group name') })"
></el-input>
</el-form-item>
<el-form-item prop="auth" :label="t('user.group.jurisdiction')">
<el-tree
ref="treeRef"
:key="baTable.form.extend!.treeKey"
:default-checked-keys="baTable.form.extend!.defaultCheckedKeys"
:default-expand-all="true"
show-checkbox
node-key="id"
:props="{ children: 'children', label: 'title', class: treeNodeClass }"
:data="baTable.form.extend!.menuRules"
class="w100"
/>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import type { ElTree, FormItemRule } from 'element-plus'
import FormItem from '/@/components/formItem/index.vue'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('user.group.Group name') })],
auth: [
{
required: true,
validator: (rule: any, val: string, callback: Function) => {
let ids = getCheckeds()
if (ids.length <= 0) {
return callback(new Error(t('Please select field', { field: t('user.group.jurisdiction') })))
}
return callback()
},
},
],
})
const getCheckeds = () => {
return treeRef.value!.getCheckedKeys().concat(treeRef.value!.getHalfCheckedKeys())
}
const treeNodeClass = (data: anyObj, node: Node) => {
if (node.isLeaf) return ''
let addClass = true
for (const key in node.childNodes) {
if (!node.childNodes[key].isLeaf) {
addClass = false
}
}
return addClass ? 'penultimate-node' : ''
}
defineExpose({
getCheckeds,
})
</script>
<style scoped lang="scss">
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;
white-space: pre-wrap;
line-height: 12px;
.el-tree-node {
display: inline-block;
}
.el-tree-node__content {
padding-left: 5px !important;
padding-right: 5px;
.el-tree-node__expand-icon {
display: none;
}
}
}
}
</style>

View File

@@ -1,147 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="
t('Quick search placeholder', { fields: t('user.moneyLog.User name') + '/' + t('user.moneyLog.User nickname') })
"
>
<el-button v-if="!isEmpty(state.userInfo)" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{ state.userInfo.username + '(ID:' + state.userInfo.id + ') ' + t('user.moneyLog.balance') + ':' + state.userInfo.money }}
</span>
</el-button>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce, isEmpty, parseInt } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import PopupForm from './popupForm.vue'
import { add, url } from '/@/api/backend/user/moneyLog'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'user/moneyLog',
})
const { t } = useI18n()
const route = useRoute()
const defalutUser = (route.query.user_id ?? '') as string
const state = reactive({
userInfo: {} as anyObj,
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.moneyLog.User ID'), prop: 'user_id', align: 'center', width: 70 },
{ label: t('user.moneyLog.User name'), prop: 'user.username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.moneyLog.User nickname'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('user.moneyLog.Change balance'), prop: 'money', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.Before change'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.After change'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('user.moneyLog.remarks'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_id: defalutUser,
memo: '',
},
}
)
// 表单提交后
baTable.after.onSubmit = () => {
getUserInfo(baTable.comSearch.form.user_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
// 刷新后
if (event == 'refresh') {
getUserInfo(baTable.comSearch.form.user_id)
}
}
baTable.before.onTableAction = ({ event }) => {
// 公共搜索
if (event === 'com-search') {
baTable.table.filter!.search = baTable.getComSearchData()
for (const key in baTable.table.filter!.search) {
if (['money', 'before', 'after'].includes(baTable.table.filter!.search[key].field)) {
const val = (baTable.table.filter!.search[key].val as string).split(',')
const newVal: (string | number)[] = []
for (const k in val) {
newVal.push(isNaN(parseFloat(val[k])) ? '' : parseFloat(val[k]) * 100)
}
baTable.table.filter!.search[key].val = newVal.join(',')
}
}
baTable.onTableHeaderAction('refresh', { event: 'com-search', data: baTable.table.filter!.search })
return false
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
const getUserInfo = debounce((userId: string) => {
if (userId && parseInt(userId) > 0) {
add(userId).then((res) => {
state.userInfo = res.data.user
})
} else {
state.userInfo = {}
}
}, 300)
getUserInfo(baTable.comSearch.form.user_id)
watch(
() => baTable.comSearch.form.user_id,
(newVal) => {
baTable.form.defaultItems!.user_id = newVal
getUserInfo(newVal)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -1,160 +0,0 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="user_id"
:label="t('user.moneyLog.User ID')"
v-model="baTable.form.items!.user_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'user.id',
field: 'nickname_text',
remoteUrl: '/admin/user.User/index',
onChange: getAdd,
}"
/>
<el-form-item :label="t('user.moneyLog.User name')">
<el-input v-model="state.userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.User nickname')">
<el-input v-model="state.userInfo.nickname" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.Current balance')">
<el-input v-model="state.userInfo.money" disabled type="number"></el-input>
</el-form-item>
<el-form-item prop="money" :label="t('user.moneyLog.Change amount')">
<el-input
@input="changeMoney"
v-model="baTable.form.items!.money"
type="number"
:placeholder="t('user.moneyLog.Please enter the balance change amount')"
></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.Balance after change')">
<el-input v-model="state.after" type="number" disabled></el-input>
</el-form-item>
<el-form-item prop="memo" :label="t('user.moneyLog.remarks')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.memo"
type="textarea"
:placeholder="t('user.moneyLog.Please enter change remarks / description')"
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { add } from '/@/api/backend/user/moneyLog'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.moneyLog.User') }) })],
money: [
buildValidatorData({ name: 'required', title: t('user.moneyLog.Change amount') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (!val || parseFloat(val) == 0) {
return callback(new Error(t('Please enter the correct field', { field: t('user.moneyLog.Change amount') })))
}
return callback()
},
trigger: 'blur',
},
],
memo: [buildValidatorData({ name: 'required', title: t('user.moneyLog.remarks') })],
})
const formRef = useTemplateRef('formRef')
const state: {
userInfo: anyObj
after: number
} = reactive({
userInfo: {},
after: 0,
})
const getAdd = () => {
if (!baTable.form.items!.user_id || parseInt(baTable.form.items!.user_id) <= 0) {
return
}
add(baTable.form.items!.user_id).then((res) => {
state.userInfo = res.data.user
state.after = res.data.user.money
})
}
const changeMoney = (value: string) => {
if (!state.userInfo || typeof state.userInfo == 'undefined') {
state.after = 0
return
}
let newValue = value == '' ? 0 : parseFloat(value)
state.after = parseFloat((parseFloat(state.userInfo.money) + newValue).toFixed(2))
}
// 打开表单时刷新用户数据
watch(
() => baTable.form.operate,
(newValue) => {
if (newValue) {
getAdd()
}
}
)
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -1,108 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.rule.Rule title') })"
/>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table ref="tableRef" :pagination="false" />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
defineOptions({
name: 'user/rule',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const baTable = new baTableClass(
new baTableApi('/admin/user.Rule/'),
{
expandAll: false,
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('auth.rule.title'), prop: 'title', align: 'left', width: '200' },
{ label: t('auth.rule.Icon'), prop: 'icon', align: 'center', width: '60', render: 'icon', default: 'fa fa-circle-o' },
{ label: t('auth.rule.name'), prop: 'name', align: 'center', showOverflowTooltip: true },
{
label: t('auth.rule.type'),
prop: 'type',
align: 'center',
render: 'tag',
custom: { menu: 'danger', menu_dir: 'success', route: 'info' },
replaceValue: {
menu: t('user.rule.Member center menu items'),
menu_dir: t('user.rule.Member center menu contents'),
route: t('user.rule.Normal routing'),
nav: t('user.rule.Top bar menu items'),
button: t('user.rule.Page button'),
nav_user_menu: t('user.rule.Top bar user dropdown'),
},
},
{ label: t('State'), prop: 'status', align: 'center', width: '80', render: 'switch' },
{ label: t('Update time'), prop: 'update_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Create time'), prop: 'create_time', align: 'center', width: '160', render: 'datetime' },
{ label: t('Operate'), align: 'center', width: '130', render: 'buttons', buttons: defaultOptButtons() },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {
type: 'route',
menu_type: 'tab',
extend: 'none',
no_login_valid: '0',
keepalive: 0,
status: 1,
icon: 'fa fa-circle-o',
},
}
)
// 表单提交前
baTable.before.onSubmit = () => {
if (baTable.form.items!.type == 'route') {
baTable.form.items!.menu_type = 'tab'
} else if (['menu', 'menu_dir', 'nav_user_menu'].includes(baTable.form.items!.type)) {
baTable.form.items!.no_login_valid = '0'
}
}
// 取得编辑行的数据后
baTable.after.getEditData = () => {
if (baTable.form.items && !baTable.form.items.icon) {
baTable.form.items.icon = 'fa fa-circle-o'
}
}
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,237 +0,0 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
top="5vh"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
:destroy-on-close="true"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="pid"
:label="t('auth.rule.Superior menu rule')"
v-model="baTable.form.items!.pid"
:placeholder="t('Click select')"
:input-attr="{
params: { isTree: true },
field: 'title',
remoteUrl: baTable.api.actionUrl.get('index'),
emptyValues: ['', null, undefined, 0],
valueOnClear: 0,
}"
/>
<el-form-item :label="t('auth.rule.Rule type')">
<el-radio-group v-model="baTable.form.items!.type">
<el-radio class="ba-el-radio" value="route" :border="true">{{ t('user.rule.Normal routing') }}</el-radio>
<el-radio class="ba-el-radio" value="menu_dir" :border="true">{{ t('user.rule.Member center menu contents') }}</el-radio>
<el-radio class="ba-el-radio" value="menu" :border="true">{{ t('user.rule.Member center menu items') }}</el-radio>
<el-radio class="ba-el-radio" value="nav" :border="true">{{ t('user.rule.Top bar menu items') }}</el-radio>
<el-radio class="ba-el-radio" value="button" :border="true">{{ t('user.rule.Page button') }}</el-radio>
<el-radio class="ba-el-radio" value="nav_user_menu" :border="true">{{ t('user.rule.Top bar user dropdown') }}</el-radio>
</el-radio-group>
<div class="block-help">{{ t('user.rule.Type ' + baTable.form.items!.type + ' tips') }}</div>
</el-form-item>
<el-form-item prop="title" :label="t('auth.rule.Rule title')">
<el-input
v-model="baTable.form.items!.title"
type="string"
:placeholder="t('Please input field', { field: t('auth.rule.Rule title') })"
></el-input>
</el-form-item>
<el-form-item prop="name" :label="t('auth.rule.Rule name')">
<el-input v-model="baTable.form.items!.name" type="string" :placeholder="t('user.rule.English name')"></el-input>
<div class="block-help">
{{ t('auth.rule.It will be registered as the web side routing name and used as the server side API authentication') }}
</div>
</el-form-item>
<el-form-item v-if="baTable.form.items!.type != 'button'" prop="path" :label="t('auth.rule.Routing path')">
<el-input v-model="baTable.form.items!.path" type="string" :placeholder="t('user.rule.Web side routing path')"></el-input>
</el-form-item>
<!-- 规则图标 -->
<FormItem
v-if="baTable.form.items!.type != 'button'"
type="icon"
:label="t('auth.rule.Rule Icon')"
v-model="baTable.form.items!.icon"
:input-attr="{ showIconName: true }"
/>
<!-- 菜单类型tablinkiframe -->
<FormItem
v-if="!['menu_dir', 'button', 'route'].includes(baTable.form.items!.type)"
:label="t('auth.rule.Menu type')"
v-model="baTable.form.items!.menu_type"
type="radio"
:input-attr="{
border: true,
content: { tab: t('auth.rule.Menu type tab'), link: t('auth.rule.Menu type link (offsite)'), iframe: 'Iframe' },
}"
/>
<!-- URL -->
<el-form-item
prop="url"
v-if="!['menu_dir', 'button', 'route'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type != 'tab'"
:label="t('auth.rule.Link address')"
>
<el-input
v-model="baTable.form.items!.url"
type="string"
:placeholder="t('auth.rule.Please enter the URL address of the link or iframe')"
></el-input>
</el-form-item>
<!-- 组件路径 -->
<el-form-item
v-if="
baTable.form.items!.type == 'route' ||
(!['menu_dir', 'button'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type == 'tab')
"
:label="t('auth.rule.Component path')"
>
<el-input
v-model="baTable.form.items!.component"
type="string"
:placeholder="t('user.rule.For example, if you add account/overview as a route only')"
></el-input>
<div class="block-help component-path-tips">
{{ t('user.rule.Component path tips') }}
</div>
</el-form-item>
<!-- 扩展属性 -->
<el-form-item
v-if="!['menu_dir', 'button'].includes(baTable.form.items!.type) && baTable.form.items!.menu_type == 'tab'"
:label="t('auth.rule.Extended properties')"
>
<el-select
class="w100"
v-model="baTable.form.items!.extend"
:placeholder="t('Please select field', { field: t('auth.rule.Extended properties') })"
>
<el-option :label="t('auth.rule.none')" value="none"></el-option>
<el-option :label="t('auth.rule.Add as route only')" value="add_rules_only"></el-option>
<el-option :label="t('auth.rule.Add as menu only')" value="add_menu_only"></el-option>
</el-select>
<div class="block-help">
{{ t('user.rule.Web side component path, please start with /src, such as: /src/views/frontend/index') }}
</div>
</el-form-item>
<FormItem
v-if="!['menu_dir', 'menu', 'nav_user_menu'].includes(baTable.form.items!.type)"
:label="t('user.rule.no_login_valid')"
v-model="baTable.form.items!.no_login_valid"
type="radio"
:input-attr="{
border: true,
content: { '0': t('user.rule.no_login_valid 0'), '1': t('user.rule.no_login_valid 1') },
}"
:block-help="t('user.rule.no_login_valid tips')"
/>
<el-form-item :label="t('auth.rule.Rule comments')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.remark"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:placeholder="t('Please input field', { field: t('auth.rule.Rule comments') })"
></el-input>
</el-form-item>
<el-form-item :label="t('auth.rule.Rule weight')">
<el-input
v-model="baTable.form.items!.weigh"
type="number"
:placeholder="t('auth.rule.Please enter the weight of menu rule (sort by)')"
></el-input>
</el-form-item>
<FormItem
:label="t('State')"
v-model="baTable.form.items!.status"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Disable'), 1: t('Enable') },
}"
/>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
title: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule title') })],
pid: [
{
validator: (rule: any, val: string, callback: Function) => {
if (!val) {
return callback()
}
if (parseInt(val) == parseInt(baTable.form.items!.id)) {
return callback(new Error(t('auth.rule.The superior menu rule cannot be the rule itself')))
}
return callback()
},
trigger: 'blur',
},
],
name: [buildValidatorData({ name: 'required', title: t('auth.rule.Rule name') })],
path: [buildValidatorData({ name: 'required', title: t('auth.rule.Routing path') })],
url: [
buildValidatorData({ name: 'required', message: t('auth.rule.Link address') }),
buildValidatorData({ name: 'url', message: t('auth.rule.Please enter the correct URL') }),
],
})
</script>
<style scoped lang="scss">
.ba-el-radio {
margin-bottom: 10px;
}
.component-path-tips {
color: var(--el-color-warning);
}
</style>

View File

@@ -1,126 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader
:buttons="['refresh', 'add', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="
t('Quick search placeholder', { fields: t('user.moneyLog.User name') + '/' + t('user.moneyLog.User nickname') })
"
>
<el-button v-if="!isEmpty(state.userInfo)" v-blur class="table-header-operate">
<span class="table-header-operate-text">
{{ state.userInfo.username + '(ID:' + state.userInfo.id + ') ' + t('user.scoreLog.integral') + ':' + state.userInfo.score }}
</span>
</el-button>
</TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { debounce, isEmpty, parseInt } from 'lodash-es'
import { provide, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import PopupForm from './popupForm.vue'
import { add, url } from '/@/api/backend/user/scoreLog'
import { baTableApi } from '/@/api/common'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({
name: 'user/scoreLog',
})
const { t } = useI18n()
const route = useRoute()
const defalutUser = (route.query.user_id ?? '') as string
const state = reactive({
userInfo: {} as anyObj,
})
const baTable = new baTableClass(
new baTableApi(url),
{
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ label: t('user.moneyLog.User ID'), prop: 'user_id', align: 'center', width: 70 },
{ label: t('user.moneyLog.User name'), prop: 'user.username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.moneyLog.User nickname'),
prop: 'user.nickname',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{ label: t('user.scoreLog.Change points'), prop: 'score', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.Before change'), prop: 'before', align: 'center', operator: 'RANGE', sortable: 'custom' },
{ label: t('user.moneyLog.After change'), prop: 'after', align: 'center', operator: 'RANGE', sortable: 'custom' },
{
label: t('user.moneyLog.remarks'),
prop: 'memo',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
},
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
],
dblClickNotEditColumn: ['all'],
},
{
defaultItems: {
user_id: defalutUser,
memo: '',
},
}
)
// 表单提交后
baTable.after.onSubmit = () => {
getUserInfo(baTable.comSearch.form.user_id)
}
baTable.after.onTableHeaderAction = ({ event }) => {
// 刷新后
if (event == 'refresh') {
getUserInfo(baTable.comSearch.form.user_id)
}
}
baTable.mount()
baTable.getData()
provide('baTable', baTable)
const getUserInfo = debounce((userId: string) => {
if (userId && parseInt(userId) > 0) {
add(userId).then((res) => {
state.userInfo = res.data.user
})
} else {
state.userInfo = {}
}
}, 300)
getUserInfo(baTable.comSearch.form.user_id)
watch(
() => baTable.comSearch.form.user_id,
(newVal) => {
baTable.form.defaultItems!.user_id = newVal
getUserInfo(newVal)
}
)
</script>
<style scoped lang="scss"></style>

View File

@@ -1,160 +0,0 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ baTable.form.operate ? t(baTable.form.operate) : '' }}
</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div
class="ba-operate-form"
:class="'ba-' + baTable.form.operate + '-form'"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
>
<el-form
ref="formRef"
@keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'"
:rules="rules"
v-if="!baTable.form.loading"
>
<FormItem
type="remoteSelect"
prop="user_id"
:label="t('user.moneyLog.User ID')"
v-model="baTable.form.items!.user_id"
:placeholder="t('Click select')"
:input-attr="{
pk: 'user.id',
field: 'nickname_text',
remoteUrl: '/admin/user.User/index',
onChange: getAdd,
}"
/>
<el-form-item :label="t('user.moneyLog.User name')">
<el-input v-model="state.userInfo.username" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.moneyLog.User nickname')">
<el-input v-model="state.userInfo.nickname" disabled></el-input>
</el-form-item>
<el-form-item :label="t('user.scoreLog.Current points')">
<el-input v-model="state.userInfo.score" disabled type="number"></el-input>
</el-form-item>
<el-form-item prop="score" :label="t('user.moneyLog.Change amount')">
<el-input
@input="changeScore"
v-model="baTable.form.items!.score"
type="number"
:placeholder="t('user.scoreLog.Please enter the change amount of points')"
></el-input>
</el-form-item>
<el-form-item :label="t('user.scoreLog.Points after change')">
<el-input v-model="state.after" type="number" disabled></el-input>
</el-form-item>
<el-form-item prop="memo" :label="t('user.moneyLog.remarks')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.memo"
type="textarea"
:placeholder="t('user.scoreLog.Please enter change remarks / description')"
></el-input>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds!.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { add } from '/@/api/backend/user/scoreLog'
import FormItem from '/@/components/formItem/index.vue'
import type { FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config'
const config = useConfig()
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
user_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.moneyLog.User') }) })],
score: [
buildValidatorData({ name: 'required', title: t('user.moneyLog.Change amount') }),
{
validator: (rule: any, val: string, callback: Function) => {
if (!val || parseInt(val) == 0) {
return callback(new Error(t('Please enter the correct field', { field: t('user.moneyLog.Change amount') })))
}
return callback()
},
trigger: 'blur',
},
],
memo: [buildValidatorData({ name: 'required', title: t('user.moneyLog.remarks') })],
})
const formRef = useTemplateRef('formRef')
const state: {
userInfo: anyObj
after: number
} = reactive({
userInfo: {},
after: 0,
})
const getAdd = () => {
if (!baTable.form.items!.user_id || parseInt(baTable.form.items!.user_id) <= 0) {
return
}
add(baTable.form.items!.user_id).then((res) => {
state.userInfo = res.data.user
state.after = res.data.user.score
})
}
const changeScore = (value: string) => {
if (!state.userInfo || typeof state.userInfo == 'undefined') {
state.after = 0
return
}
let newValue = value == '' ? 0 : parseFloat(value)
state.after = parseFloat(state.userInfo.score) + newValue
}
// 打开表单时刷新用户数据
watch(
() => baTable.form.operate,
(newValue) => {
if (newValue) {
getAdd()
}
}
)
</script>
<style scoped lang="scss">
.preview-img {
width: 60px;
height: 60px;
}
</style>

View File

@@ -1,114 +1,251 @@
<template> <template>
<div class="default-main ba-table-box"> <div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon /> <el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
<!-- 表格顶部菜单 -->
<TableHeader <TableHeader
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']" :buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.user.User name') + '/' + t('user.user.nickname') })" :quick-search-placeholder="t('Quick search placeholder', { fields: t('user.user.quick Search Fields') })"
/> ></TableHeader>
<!-- 表格 --> <Table ref="tableRef"></Table>
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<!-- 表单 -->
<PopupForm /> <PopupForm />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { provide } from 'vue' import { onMounted, provide, useTemplateRef } from 'vue'
import baTableClass from '/@/utils/baTable'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
defineOptions({ defineOptions({
name: 'user/user', name: 'user/user',
}) })
const { t } = useI18n() const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
function formatCoin(_row: anyObj, _column: any, cellValue: unknown) {
if (cellValue === null || cellValue === undefined || cellValue === '') {
return '-'
}
const s = String(cellValue).trim().replace(',', '.')
const n = parseFloat(s)
if (!Number.isFinite(n)) {
return String(cellValue)
}
return n.toFixed(4)
}
/** 杩斿洖澶氭爣绛炬枃妗堟暟缁勶紝渚?render: tags 浣跨敤 */
function formatRiskFlags(row: anyObj, _column: any, cellValue: unknown) {
const raw = cellValue !== undefined && cellValue !== null && cellValue !== '' ? cellValue : row.risk_flags
const f = typeof raw === 'number' ? raw : parseInt(String(raw ?? '0'), 10) || 0
const parts: string[] = []
if (f & 1) parts.push(t('user.user.risk_no_login'))
if (f & 2) parts.push(t('user.user.risk_no_bet'))
if (f & 4) parts.push(t('user.user.risk_no_withdraw'))
if (!parts.length) parts.push(t('user.user.risk_none'))
return parts
}
const baTable = new baTableClass( const baTable = new baTableClass(
new baTableApi('/admin/user.User/'), new baTableApi('/admin/user.User/'),
{ {
pk: 'id',
column: [ column: [
{ type: 'selection', align: 'center', operator: false }, { type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 }, { label: t('user.user.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{ label: t('user.user.User name'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ label: t('user.user.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{ {
label: t('user.user.group'), label: t('user.user.username'),
prop: 'userGroup.name', prop: 'username',
align: 'center', align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'), operatorPlaceholder: t('Fuzzy query'),
render: 'tag', sortable: false,
},
{ label: t('user.user.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
{
label: t('user.user.Gender'),
prop: 'gender',
align: 'center',
render: 'tag',
custom: { '0': 'info', '1': '', '2': 'success' },
replaceValue: { '0': t('Unknown'), '1': t('user.user.male'), '2': t('user.user.female') },
},
{ label: t('user.user.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
{
label: t('user.user.Last login IP'),
prop: 'last_login_ip',
align: 'center',
operator: 'LIKE', operator: 'LIKE',
},
{ label: t('user.user.phone'), prop: 'phone', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{
label: t('user.user.email'),
prop: 'email',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'), operatorPlaceholder: t('Fuzzy query'),
render: 'tag', operator: 'LIKE',
}, },
{ {
label: t('user.user.Last login'), label: t('user.user.head_image'),
prop: 'last_login_time', prop: 'head_image',
align: 'center', align: 'center',
render: 'datetime', width: 72,
sortable: 'custom', operator: false,
render: 'image',
},
{
label: t('user.user.uuid'),
prop: 'uuid',
align: 'center',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('user.user.register_invite_code'),
prop: 'register_invite_code',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operator: 'LIKE',
},
{ label: t('user.user.coin'), prop: 'coin', align: 'center', sortable: false, operator: 'RANGE', formatter: formatCoin },
{
label: t('user.user.total_deposit_coin'),
prop: 'total_deposit_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE', operator: 'RANGE',
width: 160, formatter: formatCoin,
}, },
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
{ {
label: t('State'), label: t('user.user.total_valid_bet_coin'),
prop: 'total_valid_bet_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatCoin,
},
{
label: t('user.user.risk_flags'),
prop: 'risk_flags',
align: 'center',
render: 'tags',
minWidth: 200,
operator: false,
formatter: formatRiskFlags,
custom: {
[t('user.user.risk_none')]: 'primary',
[t('user.user.risk_no_login')]: 'danger',
[t('user.user.risk_no_bet')]: 'danger',
[t('user.user.risk_no_withdraw')]: 'danger',
},
},
{ label: t('user.user.current_streak'), prop: 'current_streak', align: 'center', width: 90, operator: 'RANGE' },
{
label: t('user.user.last_bet_period_no'),
prop: 'last_bet_period_no',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('user.user.status'),
prop: 'status', prop: 'status',
align: 'center', align: 'center',
render: 'tag', operator: 'eq',
custom: { disable: 'danger', enable: 'success' }, sortable: false,
replaceValue: { disable: t('Disable'), enable: t('Enable') }, render: 'switch',
replaceValue: { '0': t('user.user.status 0'), '1': t('user.user.status 1') },
}, },
{ {
label: t('Operate'), label: t('user.user.channel__name'),
prop: 'channel.name',
align: 'center', align: 'center',
width: '100', minWidth: 100,
render: 'buttons', operatorPlaceholder: t('Fuzzy query'),
buttons: defaultOptButtons(['edit', 'delete']), render: 'tags',
operator: false, operator: 'LIKE',
comSearchRender: 'string',
}, },
{
label: t('user.user.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 90,
effect: 'plain',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
customRenderAttr: {
tag: () => ({
color: '#e8f3ff',
style: { color: '#1677ff', borderColor: '#91caff' },
}),
},
},
{
label: t('user.user.remark'),
prop: 'remark',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('user.user.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('user.user.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
], ],
dblClickNotEditColumn: [undefined], dblClickNotEditColumn: [undefined, 'status'],
}, },
{ {
defaultItems: { defaultItems: {
gender: 0, status: '1',
money: '0', coin: '0.0000',
score: '0', total_deposit_coin: '0.0000',
status: 'enable', total_valid_bet_coin: '0.0000',
risk_flags: 0,
current_streak: 0,
last_bet_period_no: '',
register_invite_code: '',
email: '',
head_image: '',
}, },
} }
) )
baTable.mount()
baTable.getData()
provide('baTable', baTable) provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script> </script>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -1,9 +1,7 @@
<template> <template>
<!-- 对话框表单 -->
<el-dialog <el-dialog
class="ba-operate-dialog" class="ba-operate-dialog"
:close-on-click-modal="false" :close-on-click-modal="false"
:destroy-on-close="true"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)" :model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm" @close="baTable.toggleForm"
> >
@@ -19,123 +17,182 @@
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
> >
<el-form <el-form
v-if="!baTable.form.loading"
ref="formRef" ref="formRef"
@submit.prevent=""
@keyup.enter="baTable.onSubmit(formRef)" @keyup.enter="baTable.onSubmit(formRef)"
:model="baTable.form.items" :model="baTable.form.items"
:label-position="config.layout.shrink ? 'top' : 'right'" :label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="baTable.form.labelWidth + 'px'" :label-width="baTable.form.labelWidth + 'px'"
:rules="rules" :rules="rules"
v-if="!baTable.form.loading"
> >
<el-form-item prop="username" :label="t('user.user.User name')"> <!-- <el-alert class="game-user-form-tip" type="info" :closable="false" show-icon>-->
<el-input <!-- {{ t('user.user.form_tip') }}-->
v-model="baTable.form.items!.username" <!-- </el-alert>-->
type="string"
:placeholder="t('Please input field', { field: t('user.user.User name') + '(' + t('user.user.Login account') + ')' })" <el-divider content-position="left">{{ t('user.user.section_basic') }}</el-divider>
></el-input>
</el-form-item>
<el-form-item prop="nickname" :label="t('user.user.nickname')">
<el-input
v-model="baTable.form.items!.nickname"
type="string"
:placeholder="t('Please input field', { field: t('user.user.nickname') })"
></el-input>
</el-form-item>
<FormItem <FormItem
type="remoteSelect" :label="t('user.user.username')"
:label="t('user.user.group')" type="string"
v-model="baTable.form.items!.group_id" v-model="baTable.form.items!.username"
prop="group_id" prop="username"
:placeholder="t('user.user.group')" :placeholder="t('Please input field', { field: t('user.user.username') })"
:input-attr="{
params: { isTree: true, search: [{ field: 'status', val: '1', operator: 'eq' }] },
field: 'name',
remoteUrl: '/admin/user.Group/index',
}"
/> />
<FormItem :label="t('user.user.avatar')" type="image" v-model="baTable.form.items!.avatar" />
<el-form-item prop="email" :label="t('user.user.email')">
<el-input
v-model="baTable.form.items!.email"
type="string"
:placeholder="t('Please input field', { field: t('user.user.email') })"
></el-input>
</el-form-item>
<el-form-item prop="mobile" :label="t('user.user.mobile')">
<el-input
v-model="baTable.form.items!.mobile"
type="string"
:placeholder="t('Please input field', { field: t('user.user.mobile') })"
></el-input>
</el-form-item>
<FormItem <FormItem
:label="t('user.user.Gender')" :label="t('user.user.password')"
v-model="baTable.form.items!.gender" type="password"
type="radio" v-model="baTable.form.items!.password"
:input-attr="{ prop="password"
border: true, :placeholder="t('Please input field', { field: t('user.user.password') })"
content: { 0: t('Unknown'), 1: t('user.user.male'), 2: t('user.user.female') },
}"
/> />
<el-form-item :label="t('user.user.birthday')"> <FormItem
<el-date-picker :label="t('user.user.phone')"
type="string"
v-model="baTable.form.items!.phone"
prop="phone"
:placeholder="t('Please input field', { field: t('user.user.phone') })"
/>
<FormItem
:label="t('user.user.email')"
type="string"
v-model="baTable.form.items!.email"
prop="email"
:placeholder="t('user.user.email_placeholder')"
/>
<FormItem
:label="t('user.user.head_image')"
type="image"
v-model="baTable.form.items!.head_image"
prop="head_image"
/>
<el-divider content-position="left">{{ t('user.user.section_admin_attribution') }}</el-divider>
<el-form-item :label="t('user.user.admin_affiliation')" prop="admin_id">
<el-tree-select
v-model="baTable.form.items!.admin_id"
class="w100" class="w100"
value-format="YYYY-MM-DD" clearable
v-model="baTable.form.items!.birthday" filterable
type="date" :data="adminScopeTree"
:placeholder="t('Please select field', { field: t('user.user.birthday') })" :props="treeProps"
:render-after-expand="false"
:placeholder="t('user.user.admin_affiliation_placeholder')"
@update:model-value="onAdminTreeChange"
/> />
</el-form-item> </el-form-item>
<el-form-item v-if="baTable.form.operate == 'Edit'" :label="t('user.user.balance')">
<el-input v-model="baTable.form.items!.money" readonly>
<template #append>
<el-button @click="changeAccount('money')">{{ t('user.user.Adjustment balance') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item v-if="baTable.form.operate == 'Edit'" :label="t('user.user.integral')">
<el-input v-model="baTable.form.items!.score" readonly>
<template #append>
<el-button @click="changeAccount('score')">{{ t('user.user.Adjust integral') }}</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password" :label="t('user.user.password')">
<el-input
v-model="baTable.form.items!.password"
type="password"
autocomplete="new-password"
:placeholder="
baTable.form.operate == 'Add'
? t('Please input field', { field: t('user.user.password') })
: t('user.user.Please leave blank if not modified')
"
></el-input>
</el-form-item>
<el-form-item prop="motto" :label="t('user.user.Personal signature')">
<el-input
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
v-model="baTable.form.items!.motto"
type="textarea"
:placeholder="t('Please input field', { field: t('user.user.Personal signature') })"
></el-input>
</el-form-item>
<FormItem <FormItem
:label="t('State')" v-if="baTable.form.operate === 'Edit'"
:label="t('user.user.uuid')"
type="string"
v-model="baTable.form.items!.uuid"
prop="uuid"
:input-attr="{ readonly: true, disabled: true }"
/>
<el-divider content-position="left">{{ t('user.user.section_register') }}</el-divider>
<FormItem
:label="t('user.user.register_invite_code')"
type="string"
v-model="baTable.form.items!.register_invite_code"
prop="register_invite_code"
:input-attr="{ readonly: true }"
:placeholder="t('user.user.register_invite_code_auto_placeholder')"
/>
<el-divider content-position="left">{{ t('user.user.section_finance') }}</el-divider>
<FormItem
:label="t('user.user.coin')"
type="number"
v-model="baTable.form.items!.coin"
prop="coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
:placeholder="t('user.user.coin_placeholder')"
/>
<FormItem
:label="t('user.user.total_deposit_coin')"
type="number"
v-model="baTable.form.items!.total_deposit_coin"
prop="total_deposit_coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
/>
<FormItem
:label="t('user.user.total_valid_bet_coin')"
type="number"
v-model="baTable.form.items!.total_valid_bet_coin"
prop="total_valid_bet_coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
/>
<el-divider content-position="left">{{ t('user.user.section_risk') }}</el-divider>
<el-form-item :label="t('user.user.risk_flags')">
<div class="risk-flag-row">
<el-tag
:type="riskBits.noLogin ? 'danger' : 'info'"
:effect="riskBits.noLogin ? 'dark' : 'plain'"
class="risk-flag-tag"
@click="riskBits.noLogin = !riskBits.noLogin"
>
{{ t('user.user.risk_no_login') }}
</el-tag>
<el-tag
:type="riskBits.noBet ? 'danger' : 'info'"
:effect="riskBits.noBet ? 'dark' : 'plain'"
class="risk-flag-tag"
@click="riskBits.noBet = !riskBits.noBet"
>
{{ t('user.user.risk_no_bet') }}
</el-tag>
<el-tag
:type="riskBits.noWithdraw ? 'danger' : 'info'"
:effect="riskBits.noWithdraw ? 'dark' : 'plain'"
class="risk-flag-tag"
@click="riskBits.noWithdraw = !riskBits.noWithdraw"
>
{{ t('user.user.risk_no_withdraw') }}
</el-tag>
</div>
</el-form-item>
<el-divider content-position="left">{{ t('user.user.section_streak') }}</el-divider>
<FormItem
:label="t('user.user.current_streak')"
type="number"
v-model="baTable.form.items!.current_streak"
prop="current_streak"
:input-attr="{ step: 1, min: 0, precision: 0 }"
/>
<FormItem
:label="t('user.user.last_bet_period_no')"
type="string"
v-model="baTable.form.items!.last_bet_period_no"
prop="last_bet_period_no"
:placeholder="t('user.user.last_bet_period_no_placeholder')"
/>
<el-divider content-position="left">{{ t('user.user.section_other') }}</el-divider>
<FormItem
:label="t('user.user.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('user.user.remark') })"
/>
<FormItem
:label="t('user.user.status')"
type="switch"
v-model="baTable.form.items!.status" v-model="baTable.form.items!.status"
type="radio" prop="status"
:input-attr="{ :input-attr="{ content: { '0': t('user.user.status 0'), '1': t('user.user.status 1') } }"
border: true,
content: { disable: t('Disable'), enable: t('Enable') },
}"
/> />
</el-form> </el-form>
</div> </div>
</el-scrollbar> </el-scrollbar>
<template #footer> <template #footer>
<div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'"> <div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
<el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button> <el-button @click="baTable.toggleForm()">{{ t('Cancel') }}</el-button>
<el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary"> <el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
{{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }} {{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
</el-button> </el-button>
@@ -145,15 +202,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, inject, watch, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import { regularPassword } from '/@/utils/validate'
import type { FormItemRule } from 'element-plus' import type { FormItemRule } from 'element-plus'
import { inject, nextTick, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue' import FormItem from '/@/components/formItem/index.vue'
import router from '/@/router/index'
import { buildValidatorData } from '/@/utils/validate'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData, regularPassword } from '/@/utils/validate'
import createAxios from '/@/utils/axios'
const RISK_LOGIN = 1
const RISK_BET = 2
const RISK_WITHDRAW = 4
const config = useConfig() const config = useConfig()
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
@@ -161,78 +221,281 @@ const baTable = inject('baTable') as baTableClass
const { t } = useI18n() const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({ type TreeNode = {
username: [buildValidatorData({ name: 'required', title: t('user.user.User name') }), buildValidatorData({ name: 'account' })], value: string
nickname: [buildValidatorData({ name: 'required', title: t('user.user.nickname') })], label: string
group_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.user.group') }) })], disabled?: boolean
email: [buildValidatorData({ name: 'email', title: t('user.user.email') })], children?: TreeNode[]
mobile: [buildValidatorData({ name: 'mobile' })], channel_id?: number | null
password: [ is_leaf?: boolean
{ invite_code?: string
validator: (rule: any, val: string, callback: Function) => { }
if (baTable.form.operate == 'Add') {
if (!val) { const adminScopeTree = ref<TreeNode[]>([])
return callback(new Error(t('Please input field', { field: t('user.user.password') }))) const adminIdToChannelId = ref<Record<string, number>>({})
} const adminIdToInviteCode = ref<Record<string, string>>({})
} else {
if (!val) { const treeProps = {
return callback() value: 'value',
} label: 'label',
} children: 'children',
if (!regularPassword(val)) { disabled: 'disabled',
return callback(new Error(t('validate.Please enter the correct password'))) }
}
return callback() const riskBits = reactive({
}, noLogin: false,
trigger: 'blur', noBet: false,
}, noWithdraw: false,
],
}) })
const changeAccount = (type: string) => { function syncRiskFromFlags(flags: unknown) {
baTable.toggleForm() const f = typeof flags === 'number' ? flags : parseInt(String(flags ?? '0'), 10) || 0
router.push({ riskBits.noLogin = (f & RISK_LOGIN) !== 0
name: type == 'money' ? 'user/moneyLog' : 'user/scoreLog', riskBits.noBet = (f & RISK_BET) !== 0
query: { riskBits.noWithdraw = (f & RISK_WITHDRAW) !== 0
user_id: baTable.form.items!.id,
},
})
} }
function packRiskFlags(): number {
let r = 0
if (riskBits.noLogin) r |= RISK_LOGIN
if (riskBits.noBet) r |= RISK_BET
if (riskBits.noWithdraw) r |= RISK_WITHDRAW
return r
}
/** 璇嗗埆绠悊鍛樺彾瀛愶細value 闈?group_* 涓旀棤瀛愯妭鐐癸紙涓嶄緷璧?is_leaf锛岄伩鍏?el-tree 涓㈠純鑷畾涔夊瓧娈碉級 */
function isAdminTreeLeaf(n: TreeNode): boolean {
const v = n.value
if (v === undefined || v === null) return false
if (String(v).startsWith('group_')) return false
if (n.children && n.children.length > 0) return false
return true
}
function buildAdminMapsFromTree(nodes: TreeNode[]) {
const mapCh: Record<string, number> = {}
const mapInv: Record<string, string> = {}
const walk = (arr: TreeNode[]) => {
for (const n of arr) {
if (n.children?.length) {
walk(n.children)
}
if (!isAdminTreeLeaf(n)) {
continue
}
const id = String(n.value)
if (n.channel_id !== undefined && n.channel_id !== null && n.channel_id !== '') {
mapCh[id] = typeof n.channel_id === 'number' ? n.channel_id : parseInt(String(n.channel_id), 10)
}
if (typeof n.invite_code === 'string') {
mapInv[id] = n.invite_code
} else if (n.invite_code !== undefined && n.invite_code !== null) {
mapInv[id] = String(n.invite_code)
} else {
mapInv[id] = ''
}
}
}
walk(nodes)
return { mapCh, mapInv }
}
/** 鏄犲皠鏈懡涓椂浠庡師濮嬫爲鏌ユ壘锛堥槻姝?props 瑁佸壀鎴栧紓姝ユ椂搴忛棶棰橈級 */
function findAdminMetaInTree(nodes: TreeNode[], adminId: string): { channel_id?: number; invite_code: string } | null {
const target = String(adminId).trim()
for (const n of nodes) {
if (n.children?.length) {
const nested = findAdminMetaInTree(n.children, adminId)
if (nested) {
return nested
}
}
if (!isAdminTreeLeaf(n)) {
continue
}
if (String(n.value) !== target) {
continue
}
let channelId: number | undefined
if (n.channel_id !== undefined && n.channel_id !== null && n.channel_id !== '') {
channelId = typeof n.channel_id === 'number' ? n.channel_id : parseInt(String(n.channel_id), 10)
}
const invite =
typeof n.invite_code === 'string' ? n.invite_code : n.invite_code != null ? String(n.invite_code) : ''
return { channel_id: channelId, invite_code: invite }
}
return null
}
const loadAdminScopeTree = async () => {
const res = await createAxios({
url: '/admin/user.User/adminScopeTree',
method: 'get',
})
const list = (res.data?.list ?? []) as TreeNode[]
adminScopeTree.value = list
const { mapCh, mapInv } = buildAdminMapsFromTree(list)
adminIdToChannelId.value = mapCh
adminIdToInviteCode.value = mapInv
await nextTick()
const aid = baTable.form.items?.admin_id
if (aid !== undefined && aid !== null && aid !== '') {
onAdminTreeChange(aid as string | number)
}
}
const onAdminTreeChange = (val: string | number | null) => {
if (!baTable.form.items) {
return
}
if (val === null || val === undefined || val === '') {
baTable.form.items.register_invite_code = ''
return
}
const key = typeof val === 'number' ? String(val) : String(val).trim()
let channelId = adminIdToChannelId.value[key]
let inv = adminIdToInviteCode.value[key]
if (channelId === undefined || inv === undefined) {
const meta = findAdminMetaInTree(adminScopeTree.value, key)
if (meta) {
if (channelId === undefined && meta.channel_id !== undefined) {
channelId = meta.channel_id
}
if (inv === undefined) {
inv = meta.invite_code
}
}
}
if (channelId !== undefined) {
baTable.form.items.channel_id = channelId
}
baTable.form.items.register_invite_code = inv !== undefined && inv !== null ? inv : ''
}
onMounted(() => {
loadAdminScopeTree()
})
watch(
() => baTable.form.items?.admin_id,
(val) => {
if (val === undefined || val === null || val === '') return
onAdminTreeChange(val as any)
}
)
// 鏍戞暟鎹紓姝ュ姞杞藉畬鎴愬悗锛屾牴鎹凡閫夌鐞嗗憳鍚屾娓犻亾涓庨個璇风爜锛堢紪杈戝洖鏄撅級
watch(
() => [baTable.form.items?.admin_id, adminScopeTree.value.length] as const,
() => {
const id = baTable.form.items?.admin_id
if (id === undefined || id === null || id === '' || adminScopeTree.value.length === 0) {
return
}
onAdminTreeChange(id as string | number)
}
)
watch(
() => baTable.form.items?.risk_flags,
(v) => {
syncRiskFromFlags(v)
}
)
watch(
riskBits,
() => {
if (!baTable.form.items) return
baTable.form.items.risk_flags = packRiskFlags()
},
{ deep: true }
)
watch( watch(
() => baTable.form.operate, () => baTable.form.operate,
(newVal) => { (op) => {
rules.password![0].required = newVal == 'Add' if (op === 'Add') {
syncRiskFromFlags(0)
}
} }
) )
// 统一 el-tree-select 的 value 为字符串,避免编辑回显不选中
watch(
() => [baTable.form.operate, baTable.form.items?.id] as const,
() => {
const items = baTable.form.items
if (!items || baTable.form.operate !== 'Edit') {
return
}
if (items.admin_id !== undefined && items.admin_id !== null && items.admin_id !== '') {
items.admin_id = String(items.admin_id) as any
}
}
)
const validatorGameUserPassword = (rule: any, val: string, callback: (error?: Error) => void) => {
const operate = baTable.form.operate
const v = typeof val === 'string' ? val.trim() : ''
if (operate === 'Add') {
if (!v) return callback(new Error(t('Please input field', { field: t('user.user.password') })))
if (!regularPassword(v)) return callback(new Error(t('validate.Please enter the correct password')))
return callback()
}
if (!v) return callback()
if (!regularPassword(v)) return callback(new Error(t('validate.Please enter the correct password')))
return callback()
}
const decimalRule = (fieldTitle: string): FormItemRule => ({
trigger: 'blur',
validator: (_rule, val, callback) => {
if (val === null || val === undefined || val === '') {
return callback()
}
const n = typeof val === 'number' ? val : parseFloat(String(val).trim().replace(',', '.'))
if (!Number.isFinite(n) || n < 0) {
return callback(new Error(t('Please enter the correct field', { field: fieldTitle })))
}
return callback()
},
})
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('user.user.username') })],
password: [{ validator: validatorGameUserPassword, trigger: 'blur' }],
phone: [buildValidatorData({ name: 'required', title: t('user.user.phone') })],
coin: [decimalRule(t('user.user.coin'))],
total_deposit_coin: [decimalRule(t('user.user.total_deposit_coin'))],
total_valid_bet_coin: [decimalRule(t('user.user.total_valid_bet_coin'))],
admin_id: [buildValidatorData({ name: 'required', title: t('user.user.admin_affiliation') })],
create_time: [buildValidatorData({ name: 'date', title: t('user.user.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('user.user.update_time') })],
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.avatar-uploader { .game-user-form-tip {
margin-bottom: 12px;
}
.risk-flag-row {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px 10px;
align-items: center; align-items: center;
justify-content: center; }
position: relative; .risk-flag-tag {
border-radius: var(--el-border-radius-small);
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
cursor: pointer; cursor: pointer;
overflow: hidden; user-select: none;
width: 110px;
height: 110px;
}
.avatar-uploader:hover {
border-color: var(--el-color-primary);
}
.avatar {
width: 110px;
height: 110px;
display: block;
}
.image-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
} }
</style> </style>

View File

@@ -29,9 +29,10 @@ const viteConfig = ({ mode }: ConfigEnv): UserConfig => {
open: VITE_OPEN != 'false', open: VITE_OPEN != 'false',
// 开发时把 /api、/admin、/install 代理到 webman避免跨域 // 开发时把 /api、/admin、/install 代理到 webman避免跨域
proxy: { proxy: {
'/api': { target: 'http://localhost:8787', changeOrigin: true }, '/api': { target: 'http://localhost:7979', changeOrigin: true },
'/admin': { target: 'http://localhost:8787', changeOrigin: true }, '/admin': { target: 'http://localhost:7979', changeOrigin: true },
'/install': { target: 'http://localhost:8787', changeOrigin: true }, '/install': { target: 'http://localhost:7979', changeOrigin: true },
'/plugin': { target: 'http://localhost:7979', changeOrigin: true },
}, },
}, },
build: { build: {