56 Commits

Author SHA1 Message Date
b019fc2a62 优化实时游戏对局页面 2026-04-20 10:49:36 +08:00
cf9373e5c2 优化切换语言时菜单不翻译的问题 2026-04-20 10:33:57 +08:00
92fb40ae80 修改缓存方式 2026-04-20 10:31:14 +08:00
025cce3e3e 菜单和部分接口返回对语言进行适配 2026-04-20 10:27:01 +08:00
24aab111b5 1.优化开奖和推送
2.新增控制连续开奖赔率
2026-04-20 10:02:27 +08:00
c184fa8a46 1.优化后台测试推送功能页面
2.优化开奖和实时对局页面
2026-04-18 17:16:13 +08:00
5c07967bf9 默认后台地址 2026-04-18 16:32:49 +08:00
68657e2648 优化首页 2026-04-18 16:14:47 +08:00
7d0f11fe43 推送模块 2026-04-18 15:48:31 +08:00
e3f26ba1f7 完善接口和后台页面 2026-04-18 15:19:36 +08:00
a4878a9bbd 移除接口
1.一键重复上一注/api/game/betRebet
2.自动托管设置/api/game/autoBetCreate
3.停止自动托管/api/game/autoBetStop
2026-04-17 17:30:02 +08:00
9954ea741b 优化分红方式 2026-04-17 16:46:38 +08:00
2e0bcd3f23 [游戏管理]用户管理-优化钱包操作 2026-04-17 14:34:22 +08:00
bf3d50a309 1.优化开奖逻辑
2.优化后台开奖派彩
3.优化接口规范
2026-04-17 13:56:13 +08:00
3cf386756b mysql数据恢复 2026-04-17 11:32:33 +08:00
cfe5ec6fb0 API接口
1.新增鉴权接口/api/v1/authToken
2.修复注册,登录,获取首页初始化数据,获取用户信息接口报错
2026-04-16 18:42:44 +08:00
5ee0f2b1f4 优化开奖号码随机性 2026-04-16 17:42:28 +08:00
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
302 changed files with 21889 additions and 13261 deletions

View File

@@ -7,12 +7,27 @@ APP_DEFAULT_TIMEZONE = Asia/Shanghai
# 语言
LANG_DEFAULT_LANG = zh-cn
# Database
# 数据库config/thinkorm.php/database.php
DATABASE_DRIVER = mysql
DATABASE_TYPE = mysql
DATABASE_HOSTNAME = 127.0.0.1
DATABASE_DATABASE = webman-buildadmin-dafuweng
DATABASE_USERNAME = webman-buildadmin-dafuweng
DATABASE_PASSWORD = 6dzMaX32Xdsc4DjS
DATABASE_DATABASE = buildadmin-webman-game-number
DATABASE_USERNAME = buildadmin-webman-game-number
DATABASE_PASSWORD = 123456
DATABASE_HOSTPORT = 3306
DATABASE_CHARSET = utf8mb4
DATABASE_PREFIX =
DATABASE_PREFIX =
# 框架缓存驱动config/cache.php → default供 Think-ORM 模型 Cache、get_sys_config 等使用;与 Redis 热点缓存无关)
# 可选值见 cache.php 中 stores当前一般为 file
CACHE_DRIVER = file
# 游戏热点数据 Redisconfig/game_hot_cache.php用户 / game_config / game_record依赖 config/redis.php
GAME_HOT_CACHE_ENABLED = true
GAME_HOT_CACHE_TTL_GAME_CONFIG = 86400
GAME_HOT_CACHE_TTL_GAME_RECORD = 60
GAME_HOT_CACHE_TTL_USER = 90
# 移动端接口鉴权(/api/v1/authToken
AUTH_TOKEN_SECRET = 564d14asdasd113e46542asd6das1a2a

View File

@@ -161,7 +161,7 @@ php webman migrate
**开发环境需同时启动后端与前端:**
1. **启动后端API 服务,端口 8787**
1. **启动后端API 服务,端口 7979**
**Linux / Mac**
```bash
@@ -184,11 +184,11 @@ php webman migrate
- 前台地址http://localhost:1818/index.html/#/
- 后台地址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` 一致,一般为 `8787`,反代目标使用 `127.0.0.1:8787`)。
部署到服务器时,若使用 **Nginx** 作为站点入口,需将请求转发到本机 **Webman** 进程(默认监听端口与 `config/process.php` 中 `listen` 一致,一般为 `7979`,反代目标使用 `127.0.0.1:8787`)。
在站点 **`server { }`** 块中可增加如下写法:**先由 Nginx 根据 `root` 判断是否存在对应静态文件;不存在则转发到 Webman**`root` 建议指向项目 `public` 目录)。
@@ -201,7 +201,7 @@ location ^~ / {
proxy_http_version 1.1;
proxy_set_header Connection "";
if (!-f $request_filename) {
proxy_pass http://127.0.0.1:8787;
proxy_pass http://127.0.0.1:7979;
}
}
```

View File

@@ -0,0 +1,1254 @@
<?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', 'channelAdminShareList', 'saveChannelAdminShare'];
/**
* 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', 'admin_id'];
protected array $withJoinTable = [];
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'])
->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) {
$channelId = (int) ($ch['id'] ?? 0);
$rootGroupIds = Db::name('admin_group')
->where('channel_id', $channelId)
->where('pid', 0)
->where('status', 1)
->column('id');
$groupIds = [];
foreach ($rootGroupIds as $rootId) {
$groupIds[] = $rootId;
foreach ($getGroupChildren($rootId) 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,
]);
}
/**
* 添加(重写:渠道与角色组在「角色组」侧绑定 channel_id此处不再写入 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']);
if (array_key_exists('admin_group_id', $data)) {
unset($data['admin_group_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'));
}
/**
* 编辑(重写:不再维护 channel.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']);
}
$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
->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 channelAdminShareList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/edit')) {
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'));
}
$adminRows = Db::name('admin')
->field(['id', 'username', 'status'])
->where('channel_id', (int) $row['id'])
->order('id', 'asc')
->select()
->toArray();
$shareRows = Db::name('channel_admin_share')
->where('channel_id', (int) $row['id'])
->column(['share_rate', 'status'], 'admin_id');
$adminIds = [];
foreach ($adminRows as $adminRow) {
$aid = (int) ($adminRow['id'] ?? 0);
if ($aid > 0) {
$adminIds[] = $aid;
}
}
$adminIds = array_values(array_unique($adminIds));
$roleMetaMap = $this->resolveAdminRoleMetaForChannel((int) $row['id'], $adminIds);
$list = [];
foreach ($adminRows as $adminRow) {
$aid = (int) ($adminRow['id'] ?? 0);
if ($aid <= 0) {
continue;
}
$saved = $shareRows[$aid] ?? null;
$roleMeta = $roleMetaMap[$aid] ?? null;
$list[] = [
'admin_id' => $aid,
'username' => (string) ($adminRow['username'] ?? ''),
'role_group_name' => is_array($roleMeta) ? (string) ($roleMeta['role_group_name'] ?? '') : '',
'role_level' => is_array($roleMeta) ? (int) ($roleMeta['role_level'] ?? 9999) : 9999,
'admin_status' => (string) ($adminRow['status'] ?? ''),
'share_rate' => $saved['share_rate'] ?? null,
'status' => isset($saved['status']) ? (int) $saved['status'] : 1,
];
}
usort($list, static function (array $a, array $b): int {
$levelA = (int) ($a['role_level'] ?? 9999);
$levelB = (int) ($b['role_level'] ?? 9999);
if ($levelA !== $levelB) {
return $levelA <=> $levelB;
}
$idA = (int) ($a['admin_id'] ?? 0);
$idB = (int) ($b['admin_id'] ?? 0);
return $idA <=> $idB;
});
return $this->success('', [
'channel_id' => (int) $row['id'],
'channel_name' => (string) ($row['name'] ?? ''),
'list' => $list,
]);
}
/**
* @param array<int, int> $adminIds
* @return array<int, array{role_group_name:string,role_level:int}>
*/
private function resolveAdminRoleMetaForChannel(int $channelId, array $adminIds): array
{
if ($channelId <= 0 || $adminIds === []) {
return [];
}
$groupRows = Db::name('admin_group')
->field(['id', 'pid', 'name'])
->where('channel_id', $channelId)
->order('id', 'asc')
->select()
->toArray();
if ($groupRows === []) {
return [];
}
$groupMap = [];
foreach ($groupRows as $groupRow) {
$gid = (int) ($groupRow['id'] ?? 0);
if ($gid <= 0) {
continue;
}
$groupMap[$gid] = [
'pid' => (int) ($groupRow['pid'] ?? 0),
'name' => trim((string) ($groupRow['name'] ?? '')),
];
}
if ($groupMap === []) {
return [];
}
$groupDepthById = [];
$calcDepth = function (int $groupId) use (&$calcDepth, &$groupDepthById, $groupMap): int {
if (isset($groupDepthById[$groupId])) {
return $groupDepthById[$groupId];
}
$current = $groupMap[$groupId] ?? null;
if (!$current) {
$groupDepthById[$groupId] = 9999;
return 9999;
}
$pid = (int) ($current['pid'] ?? 0);
if ($pid <= 0 || !isset($groupMap[$pid])) {
$groupDepthById[$groupId] = 1;
return 1;
}
$depth = $calcDepth($pid) + 1;
$groupDepthById[$groupId] = $depth;
return $depth;
};
$accessRows = Db::name('admin_group_access')
->field(['uid', 'group_id'])
->where('uid', 'in', $adminIds)
->order('uid', 'asc')
->order('group_id', 'asc')
->select()
->toArray();
$metaMap = [];
foreach ($accessRows as $accessRow) {
$uid = (int) ($accessRow['uid'] ?? 0);
$groupId = (int) ($accessRow['group_id'] ?? 0);
if ($uid <= 0 || $groupId <= 0 || !isset($groupMap[$groupId])) {
continue;
}
$roleName = trim((string) ($groupMap[$groupId]['name'] ?? ''));
if ($roleName === '') {
continue;
}
$depth = $calcDepth($groupId);
if (!isset($metaMap[$uid])) {
$metaMap[$uid] = [
'role_group_name' => $roleName,
'role_level' => $depth,
'group_id' => $groupId,
];
continue;
}
$currentDepth = (int) ($metaMap[$uid]['role_level'] ?? 9999);
$currentGroupId = (int) ($metaMap[$uid]['group_id'] ?? 0);
if ($depth < $currentDepth || ($depth === $currentDepth && $groupId < $currentGroupId)) {
$metaMap[$uid]['role_group_name'] = $roleName;
$metaMap[$uid]['role_level'] = $depth;
$metaMap[$uid]['group_id'] = $groupId;
}
}
$out = [];
foreach ($metaMap as $uid => $meta) {
$out[$uid] = [
'role_group_name' => (string) ($meta['role_group_name'] ?? ''),
'role_level' => (int) ($meta['role_level'] ?? 9999),
];
}
return $out;
}
/**
* 保存渠道管理员分配比例(启用项总和必须=100
*/
public function saveChannelAdminShare(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/edit')) {
return $this->error(__('You have no permission'));
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$id = (int) ($request->post('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'));
}
$rowsRaw = $request->post('list', []);
if (!is_array($rowsRaw) || $rowsRaw === []) {
return $this->error('请至少配置一条分配记录');
}
$adminIds = Db::name('admin')
->where('channel_id', (int) $row['id'])
->column('id');
$adminIdSet = [];
foreach ($adminIds as $adminId) {
$adminIdSet[(int) $adminId] = true;
}
if ($adminIdSet === []) {
return $this->error('该渠道下暂无管理员,无法配置分配比例');
}
$enabledSum = '0.0000';
$insertRows = [];
foreach ($rowsRaw as $line) {
if (!is_array($line)) {
continue;
}
$adminId = (int) ($line['admin_id'] ?? 0);
if ($adminId <= 0 || !isset($adminIdSet[$adminId])) {
continue;
}
$status = ((int) ($line['status'] ?? 1)) === 1 ? 1 : 0;
$shareRaw = $line['share_rate'] ?? null;
$shareRate = self::normalizeAmountScale($shareRaw === null ? '0' : (string) $shareRaw, 4);
if (bccomp($shareRate, '0', 4) < 0 || bccomp($shareRate, '100', 4) > 0) {
return $this->error('分配比例必须在0到100之间');
}
if ($status === 1) {
$enabledSum = bcadd($enabledSum, $shareRate, 4);
}
$insertRows[] = [
'channel_id' => (int) $row['id'],
'admin_id' => $adminId,
'share_rate' => $shareRate,
'status' => $status,
'create_time' => time(),
'update_time' => time(),
];
}
if ($insertRows === []) {
return $this->error('请至少配置一条有效分配记录');
}
if (bccomp($enabledSum, '100.0000', 4) !== 0) {
return $this->error('启用的分配比例总和必须等于100');
}
Db::startTrans();
try {
Db::name('channel_admin_share')->where('channel_id', (int) $row['id'])->delete();
Db::name('channel_admin_share')->insertAll($insertRows);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('分配比例保存成功');
}
/**
* 手动结算(渠道维度):仅接收备注;周期与金额全部由服务端按注单汇总计算,并写入结算周期与佣金记录
*/
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('结算单号已存在,请稍后重试');
}
$shareRows = $this->resolveCommissionSharesForChannel((int) $row['id']);
if ($shareRows === []) {
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,
]);
$commissionRows = $this->buildCommissionRowsForSplit(
$shareRows,
(int) $row['id'],
$periodId,
(string) $payload['calc_base_amount'],
(string) $payload['commission_amount'],
trim($remark) !== '' ? $remark : ('手动结算佣金-CH' . $row['id']),
$now
);
if ($commissionRows === []) {
throw new \RuntimeException('分配比例拆分失败,未生成佣金记录');
}
Db::name('agent_commission_record')->insertAll($commissionRows);
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);
$splitRows = $this->resolveCommissionSharesForChannel($channelId);
$splitPreview = $this->buildCommissionSplitPreview($splitRows, $commission['commission_amount']);
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,
'commission_split' => $splitPreview,
];
}
/**
* 生成代理结算周期单号:仅大写字母与数字、无分隔符;首字符 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'];
}
return array_values(array_unique($ids));
}
/**
* 佣金归属管理员:取该渠道下 admin.channel_id 匹配的首个管理员(按 id 升序)。
*/
private function resolveCommissionAdminIdForChannel(int $channelId): ?int
{
if ($channelId <= 0) {
return null;
}
$aid = Db::name('admin')
->where('channel_id', $channelId)
->order('id', 'asc')
->value('id');
if ($aid === null || $aid === '') {
return null;
}
return (int) $aid;
}
/**
* @return array<int, array{admin_id:int, share_rate:string}>
*/
private function resolveCommissionSharesForChannel(int $channelId): array
{
if ($channelId <= 0) {
return [];
}
$rows = Db::name('channel_admin_share')->alias('cas')
->join('admin a', 'cas.admin_id = a.id')
->field(['cas.admin_id', 'cas.share_rate'])
->where('cas.channel_id', $channelId)
->where('cas.status', 1)
->where('a.status', 'enable')
->order('cas.admin_id', 'asc')
->select()
->toArray();
if ($rows !== []) {
$sum = '0.0000';
$out = [];
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId <= 0) {
continue;
}
$shareRate = self::normalizeAmountScale((string) ($row['share_rate'] ?? '0'), 4);
if (bccomp($shareRate, '0', 4) <= 0) {
continue;
}
$sum = bcadd($sum, $shareRate, 4);
$out[] = [
'admin_id' => $adminId,
'share_rate' => $shareRate,
];
}
if ($out !== [] && bccomp($sum, '100.0000', 4) === 0) {
return $out;
}
}
$fallbackAdminId = $this->resolveCommissionAdminIdForChannel($channelId);
if ($fallbackAdminId === null || $fallbackAdminId <= 0) {
return [];
}
return [[
'admin_id' => (int) $fallbackAdminId,
'share_rate' => '100.0000',
]];
}
/**
* @param array<int, array{admin_id:int, share_rate:string}> $shareRows
* @return array<int, array{settlement_period_id:int, channel_id:int, admin_id:int, commission_rate:string, calc_base_amount:string, commission_amount:string, status:int, settled_at:null, remark:string, create_time:int, update_time:int}>
*/
private function buildCommissionRowsForSplit(
array $shareRows,
int $channelId,
int $periodId,
string $calcBaseAmount,
string $commissionTotal,
string $remark,
int $now
): array {
if ($shareRows === []) {
return [];
}
$sum = '0.0000';
$rows = [];
$lastIndex = count($shareRows) - 1;
foreach ($shareRows as $index => $shareRow) {
$shareRate = self::normalizeAmountScale((string) ($shareRow['share_rate'] ?? '0'), 4);
$shareDec = bcdiv($shareRate, '100', 8);
$amount = $index === $lastIndex
? bcsub($commissionTotal, $sum, 4)
: bcmul($commissionTotal, $shareDec, 4);
if ($index !== $lastIndex) {
$sum = bcadd($sum, $amount, 4);
}
$effectiveRate = bccomp($calcBaseAmount, '0', 4) <= 0 ? '0.000000' : bcdiv($amount, $calcBaseAmount, 6);
$rows[] = [
'settlement_period_id' => $periodId,
'channel_id' => $channelId,
'admin_id' => (int) ($shareRow['admin_id'] ?? 0),
'commission_rate' => $effectiveRate,
'calc_base_amount' => $calcBaseAmount,
'commission_amount' => $amount,
'status' => 0,
'settled_at' => null,
'remark' => $remark . ' | 分配比例=' . $shareRate . '%',
'create_time' => $now,
'update_time' => $now,
];
}
return $rows;
}
/**
* @param array<int, array{admin_id:int, share_rate:string}> $shareRows
* @return array<int, array{admin_id:int, admin_username:string, share_rate:string, commission_amount:string}>
*/
private function buildCommissionSplitPreview(array $shareRows, string $commissionTotal): array
{
if ($shareRows === []) {
return [];
}
$adminIds = [];
foreach ($shareRows as $shareRow) {
$adminIds[] = (int) ($shareRow['admin_id'] ?? 0);
}
$adminNames = Db::name('admin')->where('id', 'in', $adminIds)->column('username', 'id');
$sum = '0.0000';
$out = [];
$lastIndex = count($shareRows) - 1;
foreach ($shareRows as $index => $shareRow) {
$shareRate = self::normalizeAmountScale((string) ($shareRow['share_rate'] ?? '0'), 4);
$shareDec = bcdiv($shareRate, '100', 8);
$amount = $index === $lastIndex
? bcsub($commissionTotal, $sum, 4)
: bcmul($commissionTotal, $shareDec, 4);
if ($index !== $lastIndex) {
$sum = bcadd($sum, $amount, 4);
}
$adminId = (int) ($shareRow['admin_id'] ?? 0);
$out[] = [
'admin_id' => $adminId,
'admin_username' => (string) ($adminNames[$adminId] ?? ('#' . $adminId)),
'share_rate' => $shareRate,
'commission_amount' => $amount,
];
}
return $out;
}
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 static function normalizeAmountScale(string $amount, int $scale): string
{
$raw = trim(str_replace(',', '.', $amount));
if ($raw === '') {
return '0';
}
$negative = false;
if (str_starts_with($raw, '-')) {
$negative = true;
$raw = ltrim(substr($raw, 1));
}
if (!str_contains($raw, '.')) {
$v = ltrim($raw, '0');
$v = $v === '' ? '0' : $v;
return $negative ? ('-' . $v) : $v;
}
[$intPart, $fracPart] = explode('.', $raw, 2);
$intPart = ltrim($intPart, '0');
$intPart = $intPart === '' ? '0' : $intPart;
$fracPart = preg_replace('/\D+/', '', $fracPart) ?? '';
if (strlen($fracPart) > $scale) {
$fracPart = substr($fracPart, 0, $scale);
} else {
$fracPart = str_pad($fracPart, $scale, '0');
}
$v = $intPart . '.' . $fracPart;
return $negative ? ('-' . $v) : $v;
}
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

@@ -5,18 +5,389 @@ declare(strict_types=1);
namespace app\admin\controller;
use app\common\controller\Backend;
use support\think\Db;
use Webman\Http\Request;
use support\Response;
/**
* 后台首页统计:按渠道数据范围(非超管仅本渠道)汇总用户、充值、提现、投注等核心指标。
*/
class Dashboard extends Backend
{
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($response !== null) {
return $response;
}
$scope = $this->channelScopeOrNull();
$todayStart = strtotime(date('Y-m-d'));
$todayEnd = $todayStart + 86400 - 1;
$yesterdayStart = $todayStart - 86400;
$yesterdayEnd = $todayStart - 1;
$userTotal = $this->countUsers($scope);
$newToday = $this->countUsersInRange($scope, $todayStart, $todayEnd);
$newYesterday = $this->countUsersInRange($scope, $yesterdayStart, $yesterdayEnd);
$growthPct = null;
if ($newYesterday > 0) {
$growthPct = round(($newToday - $newYesterday) / $newYesterday * 100, 1);
} elseif ($newToday > 0) {
$growthPct = 100.0;
}
$depositAgg = $this->aggregateDepositToday($scope, $todayStart, $todayEnd);
$withdrawPending = $this->countWithdrawPending($scope);
$betAgg = $this->aggregateBetToday($scope, $todayStart, $todayEnd);
$trend = $this->buildSevenDayTrend($scope);
$channelShare = $this->buildChannelShare($scope);
$depositAmountChannelShare = $this->buildDepositAmountChannelShare($scope);
$recentUsers = $this->fetchRecentUsers($scope, 10);
return $this->success('', [
'remark' => get_route_remark()
'remark' => get_route_remark(),
'stats' => [
'user_total' => $userTotal,
'user_new_today' => $newToday,
'user_new_yesterday' => $newYesterday,
'user_new_growth_pct' => $growthPct,
'deposit_today_amount' => $depositAgg['amount'],
'deposit_today_count' => $depositAgg['count'],
'withdraw_pending' => $withdrawPending,
'bet_today_amount' => $betAgg['amount'],
'bet_today_count' => $betAgg['count'],
],
'trend' => $trend,
'channel_share' => $channelShare,
'deposit_amount_channel_share' => $depositAmountChannelShare,
'recent_users' => $recentUsers,
]);
}
/**
* @param int[]|null $scope null=超管不限制;非 null 时 whereIn channel_id
*/
private function countUsers(?array $scope): int
{
$q = Db::name('user');
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
return intval($q->count());
}
/**
* @param int[]|null $scope
*/
private function countUsersInRange(?array $scope, int $start, int $end): int
{
$q = Db::name('user')
->where('create_time', '>=', $start)
->where('create_time', '<=', $end);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
return intval($q->count());
}
/**
* 今日成功充值status=1按创建日落在今日与 mock 即时成功一致)。
*
* @param int[]|null $scope
* @return array{count:int, amount:string}
*/
private function aggregateDepositToday(?array $scope, int $todayStart, int $todayEnd): array
{
$q = Db::name('deposit_order')
->where('status', 1)
->where('create_time', '>=', $todayStart)
->where('create_time', '<=', $todayEnd);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->fieldRaw('COUNT(*) AS c, COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')->find();
if (!is_array($rows)) {
$rows = [];
}
$count = isset($rows['c']) ? intval($rows['c']) : 0;
$sum = isset($rows['s']) ? strval($rows['s']) : '0';
$amount = $this->formatMoney2($sum);
return ['count' => $count, 'amount' => $amount];
}
/**
* @param int[]|null $scope
*/
private function countWithdrawPending(?array $scope): int
{
$q = Db::name('withdraw_order')->where('status', 0);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
return intval($q->count());
}
/**
* 今日投注创建时间在今日且订单未作废status 1 或 2
*
* @param int[]|null $scope
* @return array{count:int, amount:string}
*/
private function aggregateBetToday(?array $scope, int $todayStart, int $todayEnd): array
{
$q = Db::name('bet_order')
->whereIn('status', [1, 2])
->where('create_time', '>=', $todayStart)
->where('create_time', '<=', $todayEnd);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->fieldRaw('COUNT(*) AS c, COALESCE(SUM(CAST(total_amount AS DECIMAL(18,4))),0) AS s')->find();
if (!is_array($rows)) {
$rows = [];
}
$count = isset($rows['c']) ? intval($rows['c']) : 0;
$sum = isset($rows['s']) ? strval($rows['s']) : '0';
return ['count' => $count, 'amount' => $this->formatMoney2($sum)];
}
/**
* @param int[]|null $scope
* @return array{days:string[], new_users:int[], deposit_amount:string[], bet_amount:string[]}
*/
private function buildSevenDayTrend(?array $scope): array
{
$days = [];
$newUsers = [];
$depositAmounts = [];
$betAmounts = [];
for ($i = 6; $i >= 0; $i--) {
$dayStart = strtotime(date('Y-m-d', strtotime('-' . $i . ' day')));
$dayEnd = $dayStart + 86400 - 1;
$days[] = date('m-d', $dayStart);
$newUsers[] = $this->countUsersInRange($scope, $dayStart, $dayEnd);
$dq = Db::name('deposit_order')
->where('status', 1)
->where('create_time', '>=', $dayStart)
->where('create_time', '<=', $dayEnd);
if ($scope !== null) {
$dq->whereIn('channel_id', $scope);
}
$drow = $dq->fieldRaw('COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')->find();
$dsum = is_array($drow) && isset($drow['s']) ? strval($drow['s']) : '0';
$depositAmounts[] = $this->formatMoney2($dsum);
$bq = Db::name('bet_order')
->whereIn('status', [1, 2])
->where('create_time', '>=', $dayStart)
->where('create_time', '<=', $dayEnd);
if ($scope !== null) {
$bq->whereIn('channel_id', $scope);
}
$brow = $bq->fieldRaw('COALESCE(SUM(CAST(total_amount AS DECIMAL(18,4))),0) AS s')->find();
$bsum = is_array($brow) && isset($brow['s']) ? strval($brow['s']) : '0';
$betAmounts[] = $this->formatMoney2($bsum);
}
return [
'days' => $days,
'new_users' => $newUsers,
'deposit_amount' => $depositAmounts,
'bet_amount' => $betAmounts,
];
}
/**
* 用户按渠道分布(取前 8 名,其余合并为「其他」)。
*
* @param int[]|null $scope
* @return list<array{name:string, value:int}>
*/
private function buildChannelShare(?array $scope): array
{
$q = Db::name('user')->fieldRaw('channel_id, COUNT(*) AS c')->group('channel_id');
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->orderRaw('c DESC')->select()->toArray();
if ($rows === []) {
return [];
}
$channelNames = Db::name('channel')->column('name', 'id');
$list = [];
foreach ($rows as $row) {
$cid = $row['channel_id'];
$cnt = intval($row['c'] ?? 0);
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
$name = '未分配渠道';
} else {
$id = intval(strval($cid));
$name = $channelNames[$id] ?? ('#' . strval($id));
}
$list[] = ['name' => $name, 'value' => $cnt];
}
if (count($list) <= 8) {
return $list;
}
$head = array_slice($list, 0, 8);
$rest = array_slice($list, 8);
$other = 0;
foreach ($rest as $item) {
$other += $item['value'];
}
if ($other > 0) {
$head[] = ['name' => '其他', 'value' => $other];
}
return $head;
}
/**
* 成功充值金额按订单归属渠道汇总status=1受渠道范围限制
*
* @param int[]|null $scope
* @return list<array{name:string, value:string}> value 为两位小数字符串,供前端饼图展示
*/
private function buildDepositAmountChannelShare(?array $scope): array
{
$q = Db::name('deposit_order')
->where('status', 1)
->fieldRaw('channel_id, COALESCE(SUM(CAST(amount AS DECIMAL(18,4))),0) AS s')
->group('channel_id');
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->select()->toArray();
if ($rows === []) {
return [];
}
$channelNames = Db::name('channel')->column('name', 'id');
$list = [];
foreach ($rows as $row) {
$cid = $row['channel_id'];
$sumRaw = isset($row['s']) ? strval($row['s']) : '0';
$amountStr = $this->formatMoney2($sumRaw);
if (bccomp($amountStr, '0', 2) <= 0) {
continue;
}
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
$name = '未分配渠道';
} else {
$id = intval(strval($cid));
$name = $channelNames[$id] ?? ('#' . strval($id));
}
$list[] = [
'name' => $name,
'value' => $amountStr,
];
}
usort($list, static function (array $a, array $b): int {
return bccomp($b['value'], $a['value'], 2);
});
if (count($list) <= 8) {
return $list;
}
$head = array_slice($list, 0, 8);
$rest = array_slice($list, 8);
$other = '0';
foreach ($rest as $item) {
$other = bcadd($other, $item['value'], 4);
}
$otherFormatted = $this->formatMoney2($other);
if (bccomp($otherFormatted, '0', 2) > 0) {
$head[] = [
'name' => '其他',
'value' => $otherFormatted,
];
}
return $head;
}
/**
* @param int[]|null $scope
* @return list<array{id:int, username:string, create_time:int, channel_name:string, head_image:string}>
*/
private function fetchRecentUsers(?array $scope, int $limit): array
{
$q = Db::name('user')
->field(['id', 'username', 'create_time', 'channel_id', 'head_image'])
->order('id', 'desc')
->limit($limit);
if ($scope !== null) {
$q->whereIn('channel_id', $scope);
}
$rows = $q->select()->toArray();
if ($rows === []) {
return [];
}
$channelNames = Db::name('channel')->column('name', 'id');
$out = [];
foreach ($rows as $row) {
$cid = $row['channel_id'] ?? null;
if ($cid === null || $cid === '' || intval(strval($cid)) === 0) {
$cname = '';
} else {
$id = intval(strval($cid));
$cname = $channelNames[$id] ?? '';
}
$out[] = [
'id' => intval($row['id'] ?? 0),
'username' => strval($row['username'] ?? ''),
'create_time' => intval($row['create_time'] ?? 0),
'channel_name' => $cname,
'head_image' => strval($row['head_image'] ?? ''),
];
}
return $out;
}
private function formatMoney2(string $amount): string
{
if (!is_numeric($amount)) {
return '0.00';
}
$normalized = bcadd($amount, '0', 4);
return bcadd($normalized, '0', 2);
}
/**
* 非超管:按管理员所属渠道过滤;未绑定渠道时按 channel_id IN (0) 与列表页一致。
* 超管:返回 null表示 SQL 不加渠道条件。
*
* @return int[]|null
*/
private function channelScopeOrNull(): ?array
{
if (!$this->auth || $this->auth->isSuperAdmin()) {
return null;
}
$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'];
}
return $ids !== [] ? array_values(array_unique($ids)) : [0];
}
}

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;
use ba\Random;
use Throwable;
use support\think\Db;
use support\validation\Validator;
@@ -21,10 +22,7 @@ class Admin extends Backend
protected array|string $quickSearchField = ['username', 'nickname'];
/**
* 开启数据范围;具体范围见重写的 getDataLimitAdminIds角色组树仅本人 + 下级组内管理员)
*/
protected bool|string|int $dataLimit = true;
protected string|int|bool $dataLimit = 'parent';
protected string $dataLimitField = 'id';
@@ -34,17 +32,6 @@ class Admin extends Backend
return null;
}
/**
* 非超管:仅可管理「本人 + 树形下级组内」的管理员账号;与角色组管理页的可见范围一致(列表不含仅同级的其他管理员)
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) {
return [];
}
return $this->auth->getSelfAndSubordinateAdminIds();
}
public function index(Request $request): Response
{
$response = $this->initializeBackend($request);
@@ -75,9 +62,9 @@ class Admin extends Backend
$res = $query
->order($order)
->paginate($limit);
$items = $res->items();
return $this->success('', [
'list' => $res->items(),
'list' => $items,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
@@ -163,6 +150,10 @@ class Admin extends Backend
if (!$data) {
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) {
try {
@@ -186,6 +177,24 @@ class Admin extends Backend
$passwd = $data['password'] ?? '';
$data = $this->excludeFields($data);
$creatorChannelId = $this->getCreatorChannelId();
$groupChannelId = $this->resolveChannelIdFromPrimaryGroup($data['group_arr'] ?? []);
if (!$this->auth->isSuperAdmin()) {
if ($creatorChannelId === null || $creatorChannelId === '') {
return $this->error(__('You have no permission'));
}
if ($groupChannelId === null || $groupChannelId === '') {
return $this->error('所选角色组未绑定渠道');
}
if ((string) $groupChannelId !== (string) $creatorChannelId) {
return $this->error('所选角色组渠道与当前账号不一致');
}
$data['channel_id'] = $creatorChannelId;
$data['parent_admin_id'] = $this->auth->id;
} else {
$data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId;
}
$data['invite_code'] = $this->generateUniqueInviteCode();
$result = false;
if (!empty($data['group_arr'])) {
$authRes = $this->checkGroupAuth($data['group_arr']);
@@ -244,6 +253,29 @@ class Admin extends Backend
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$data = $this->normalizeSingleGroup($data);
if (!$this->hasSingleGroup($data['group_arr'] ?? null)) {
return $this->error('请选择且仅选择一个角色组');
}
$postedGroups = array_map('intval', $data['group_arr'] ?? []);
$rowGroups = array_map('intval', $row->group_arr ?? []);
sort($postedGroups);
sort($rowGroups);
// 当前管理员编辑自身时,不允许修改角色组
if ((int)$this->auth->id === (int)$id) {
$postedGroups = $data['group_arr'] ?? [];
if (!is_array($postedGroups)) {
$postedGroups = [];
}
$originGroups = $row->group_arr ?? [];
sort($postedGroups);
sort($originGroups);
if ($postedGroups !== $originGroups) {
return $this->error(__('You cannot modify your own management group!'));
}
}
if ($this->modelValidate) {
try {
@@ -290,15 +322,31 @@ class Admin extends Backend
if ($authRes !== null) return $authRes;
}
Db::name('admin_group_access')
->where('uid', $id)
->delete();
$data = $this->excludeFields($data);
unset($data['invite_code']);
$creatorChannelId = $this->getCreatorChannelId();
$groupChannelId = $this->resolveChannelIdFromPrimaryGroup($data['group_arr'] ?? []);
if (!$this->auth->isSuperAdmin()) {
if ($creatorChannelId === null || $creatorChannelId === '') {
return $this->error(__('You have no permission'));
}
if ($groupChannelId === null || $groupChannelId === '') {
return $this->error('所选角色组未绑定渠道');
}
if ((string) $groupChannelId !== (string) $creatorChannelId) {
return $this->error('所选角色组渠道与当前账号不一致');
}
$data['channel_id'] = $creatorChannelId;
} else {
$data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId;
}
$result = false;
$this->model->startTrans();
try {
$result = $row->save($data);
Db::name('admin_group_access')
->where('uid', $id)
->delete();
if ($groupAccess) {
Db::name('admin_group_access')->insertAll($groupAccess);
}
@@ -371,15 +419,87 @@ class Admin extends Backend
if ($this->auth->isSuperAdmin()) {
return null;
}
$allowedGroupIds = array_values(array_unique(array_merge(
Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id'),
$this->auth->getAdminChildGroups()
)));
$authGroups = $this->getManageableGroupIds();
foreach ($groups as $group) {
if (!in_array($group, $allowedGroupIds, false)) {
if (!in_array($group, $authGroups)) {
return $this->error(__('You have no permission to add an administrator to this group!'));
}
}
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'];
}
return null;
}
/**
* @param array<int|string> $groupIds
*/
private function resolveChannelIdFromPrimaryGroup(array $groupIds): mixed
{
if ($groupIds === []) {
return null;
}
$gid = $groupIds[0];
if ($gid === null || $gid === '') {
return null;
}
return Db::name('admin_group')->where('id', $gid)->value('channel_id');
}
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 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

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\admin\controller\auth;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\AdminLog as AdminLogModel;
use support\Response;
@@ -35,10 +36,7 @@ class AdminLog extends Backend
list($where, $alias, $limit, $order) = $this->queryBuilder();
if (!$this->auth->isSuperAdmin()) {
$scopeIds = $this->auth->getSelfAndSubordinateAdminIds();
if ($scopeIds !== []) {
$where[] = ['admin_id', 'in', $scopeIds];
}
$where[] = ['admin_id', '=', $this->auth->id];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)

View File

@@ -17,6 +17,8 @@ use Webman\Http\Request;
class Group extends Backend
{
protected string $authMethod = 'allAuthAndOthers';
protected ?object $model = null;
protected string|array $preExcludeFields = ['create_time', 'update_time'];
@@ -32,6 +34,7 @@ class Group extends Backend
protected bool $assembleTree = true;
protected array $adminGroups = [];
protected array $manageableGroupIds = [];
protected function initController(Request $request): ?Response
{
@@ -46,6 +49,7 @@ class Group extends Backend
$this->assembleTree = $isTree && !$this->initValue;
$this->adminGroups = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id');
$this->manageableGroupIds = $this->getManageableGroupIds();
return null;
}
@@ -77,12 +81,18 @@ class Group extends Backend
}
$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'));
}
$inheritRes = $this->applyChannelInheritance($data, $pidInt);
if ($inheritRes !== null) {
return $inheritRes;
}
$rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes;
$pidRes = $this->validateGroupParentId($data['pid'] ?? null);
if ($pidRes instanceof Response) return $pidRes;
$result = false;
$this->model->startTrans();
try {
@@ -142,14 +152,18 @@ class Group extends Backend
}
$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'));
}
$inheritRes = $this->applyChannelInheritance($data, $pidInt);
if ($inheritRes !== null) {
return $inheritRes;
}
$rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes;
if (array_key_exists('pid', $data)) {
$pidRes = $this->validateGroupParentId($data['pid'] ?? null);
if ($pidRes instanceof Response) return $pidRes;
}
$result = false;
$this->model->startTrans();
try {
@@ -174,6 +188,7 @@ class Group extends Backend
return $this->error($e->getMessage());
}
if ($result !== false) {
$this->syncDescendantChannelIds(intval((string)$row['id']));
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
@@ -193,11 +208,39 @@ class Group extends Backend
}
$rowData = $row->toArray();
$rowData['rules'] = array_values($rules);
$rowData = $this->enrichChannelDisplay($rowData);
return $this->success('', [
'row' => $rowData
]);
}
/**
* 表单只读展示:根据 channel_id 解析渠道名称与渠道负责人admin.channel_id → admin.username取首个
*/
public function channelBindPreview(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$cid = $request->get('channel_id') ?? $request->post('channel_id');
if ($cid === null || $cid === '') {
return $this->success('', [
'channel_name' => '',
'channel_admin_username' => '',
]);
}
if (!Db::name('channel')->where('id', $cid)->value('id')) {
return $this->error(__('Record not found'));
}
$row = $this->enrichChannelDisplay(['channel_id' => $cid]);
return $this->success('', [
'channel_name' => $row['channel_name'] ?? '',
'channel_admin_username' => $row['channel_admin_username'] ?? '',
]);
}
public function del(Request $request): Response
{
$response = $this->initializeBackend($request);
@@ -300,6 +343,8 @@ class Group extends Backend
$pk = $this->model->getPk();
$initKey = $request->get('initKey') ?? $pk;
$absoluteAuth = $request->get('absoluteAuth') ?? false;
if ($this->keyword) {
$keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) {
@@ -312,18 +357,30 @@ class Group extends Backend
}
if (!$this->auth->isSuperAdmin()) {
$descendantIds = $this->auth->getAdminChildGroups();
// 本人所在组 + 树形下级;不含同级、不含其它分支(与 getAllAuthGroups 的「权限多寡」脱钩)
$visibleIds = array_values(array_unique(array_merge($this->adminGroups, $descendantIds)));
if ($visibleIds === []) {
$where[] = ['id', '=', -1];
} else {
$where[] = ['id', 'in', $visibleIds];
// 仅本人所在角色组 + 其下级子组getManageableGroupIds不包含上级/同级。无父节点在结果集时assembleChild 将该节点作为树根展示,符合「只看我这条线」
$authGroups = $this->manageableGroupIds;
if ($absoluteAuth) {
$authGroups = array_values(array_diff($authGroups, $this->adminGroups));
}
$where[] = ['id', 'in', $authGroups ?: [0]];
}
$data = $this->model->where($where)->select()->toArray();
$channelIds = [];
foreach ($data as $datum) {
$c = $datum['channel_id'] ?? null;
if ($c !== null && $c !== '') {
$channelIds[] = $c;
}
}
$channelNames = [];
if ($channelIds !== []) {
$channelNames = Db::name('channel')->where('id', 'in', array_unique($channelIds))->column('name', 'id');
}
foreach ($data as &$datum) {
$c = $datum['channel_id'] ?? null;
$datum['channel_name'] = ($c !== null && $c !== '') ? ($channelNames[$c] ?? '') : '';
if ($datum['rules']) {
if ($datum['rules'] == '*') {
$datum['rules'] = __('Super administrator');
@@ -338,50 +395,109 @@ class Group extends Backend
$datum['rules'] = __('No permission');
}
}
unset($datum);
return $this->assembleTree ? $this->tree->assembleChild($data) : $data;
}
private function checkAuth($groupId): ?Response
{
if ($this->auth->isSuperAdmin()) {
return null;
}
$descendantIds = $this->auth->getAdminChildGroups();
if (!in_array($groupId, $descendantIds, false)) {
return $this->error(__('You can only operate subordinate role groups in the tree hierarchy~'));
$authGroups = $this->manageableGroupIds;
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 null;
}
/**
* 新增/编辑时校验父级:非超管只能挂在本人所在组或其树形下级之下,不可建顶级(pid=0)
*/
private function validateGroupParentId(mixed $pid): ?Response
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)));
}
/**
* 顶级角色组可选渠道;子级继承父级 channel_id不信任客户端提交的子级 channel_id
*
* @param array<string, mixed> $data
*/
private function applyChannelInheritance(array &$data, int $pidInt): ?Response
{
if ($pidInt === 0) {
if (!$this->auth->isSuperAdmin()) {
unset($data['channel_id']);
$cc = $this->getCreatorChannelId();
if ($cc !== null && $cc !== '') {
$data['channel_id'] = $cc;
}
}
$cid = $data['channel_id'] ?? null;
if ($cid !== null && $cid !== '') {
$exists = Db::name('channel')->where('id', $cid)->value('id');
if (!$exists) {
return $this->error(__('Record not found'));
}
}
return null;
}
if ($pid === null || $pid === '' || $pid === false) {
return $this->error(__('Non super administrators cannot create top-level role groups'));
}
if ($pid === 0 || $pid === '0') {
return $this->error(__('Non super administrators cannot create top-level role groups'));
}
if (!is_numeric($pid)) {
return $this->error(__('The parent group is not within your manageable scope'));
}
$allowed = array_values(array_unique(array_merge($this->adminGroups, $this->auth->getAdminChildGroups())));
$ok = false;
foreach ($allowed as $aid) {
if ($aid == $pid) {
$ok = true;
break;
}
}
if (!$ok) {
return $this->error(__('The parent group is not within your manageable scope'));
unset($data['channel_id']);
$parent = Db::name('admin_group')->where('id', $pidInt)->find();
if (!$parent) {
return $this->error(__('Record not found'));
}
$data['channel_id'] = $parent['channel_id'];
return null;
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function enrichChannelDisplay(array $row): array
{
$row['channel_name'] = '';
$row['channel_admin_username'] = '';
$cid = $row['channel_id'] ?? null;
if ($cid === null || $cid === '') {
return $row;
}
$ch = Db::name('channel')->where('id', $cid)->field(['id', 'name'])->find();
if (!$ch) {
return $row;
}
$row['channel_name'] = $ch['name'] ?? '';
$row['channel_admin_username'] = (string) (Db::name('admin')->where('channel_id', $cid)->order('id', 'asc')->value('username') ?? '');
return $row;
}
private function syncDescendantChannelIds(int $groupId): void
{
$channelId = Db::name('admin_group')->where('id', $groupId)->value('channel_id');
$children = Db::name('admin_group')->where('pid', $groupId)->column('id');
foreach ($children as $childId) {
Db::name('admin_group')->where('id', $childId)->update(['channel_id' => $channelId]);
$this->syncDescendantChannelIds($childId);
}
}
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'];
}
return null;
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\DepositTier as DepositTierLib;
use app\common\service\GameHotDataRedis;
use InvalidArgumentException;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 充值档位独立编辑(仅 game_config.deposit_tier
*/
class DepositTier 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;
}
/**
* 读取 game_config.deposit_tier 的档位列表
*/
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', DepositTierLib::CONFIG_KEY)->find();
$items = DepositTierLib::parseFromConfigValue($row['config_value'] ?? null);
return $this->success('', [
'items' => $items,
]);
}
/**
* 保存 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 = DepositTierLib::prepareItemsForSave(array_values($items));
$json = DepositTierLib::encodeForDb($clean);
} catch (InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$now = time();
try {
$exists = Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', DepositTierLib::CONFIG_KEY)->update([
'config_value' => $json,
'value_type' => 'json',
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => DepositTierLib::CONFIG_KEY,
'config_value' => $json,
'value_type' => 'json',
'remark' => '充值档位 JSON 数组(独立表单维护)',
'create_time' => $now,
'update_time' => $now,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
GameHotDataRedis::gameConfigForget(DepositTierLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\DepositTier;
use app\common\library\game\StreakWinReward;
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;
}
/**
* @return list<string>
*/
protected function excludedConfigKeys(): array
{
return [
ZiHuaDictionaryLib::CONFIG_KEY,
DepositTier::CONFIG_KEY,
StreakWinReward::CONFIG_KEY,
];
}
/**
* 列表:排除独立表单维护的配置键
*/
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', 'not in', $this->excludedConfigKeys()];
}
$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(),
]);
}
/**
* 远程下拉:排除独立维护的配置键
*/
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();
$table = strtolower($this->model->getTable());
$mainShort = $alias[$table] ?? '';
if ($mainShort !== '') {
$where[] = [$mainShort . '.config_key', 'not in', $this->excludedConfigKeys()];
}
$res = $this->model
->field($fields)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
]);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\library\game\StreakWinReward as StreakWinRewardLib;
use app\common\service\GameHotDataRedis;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 连胜奖励game_config.streak_win_reward
*/
class StreakWinReward 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;
}
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', StreakWinRewardLib::CONFIG_KEY)->find();
$rows = StreakWinRewardLib::parseFromConfigValue($row['config_value'] ?? null);
return $this->success('', [
'rows' => $rows,
]);
}
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('rows');
if (!is_array($payload)) {
return $this->error('参数错误');
}
$encoded = StreakWinRewardLib::encodeForDb($payload);
$now = time();
Db::startTrans();
try {
$exists = Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->find();
if ($exists) {
Db::name('game_config')->where('config_key', StreakWinRewardLib::CONFIG_KEY)->update([
'config_value' => $encoded,
'update_time' => $now,
]);
} else {
Db::name('game_config')->insert([
'config_key' => StreakWinRewardLib::CONFIG_KEY,
'config_value' => $encoded,
'value_type' => 'json',
'remark' => '连胜奖励',
'create_time' => $now,
'update_time' => $now,
]);
}
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
StreakWinRewardLib::clearCache();
GameHotDataRedis::gameConfigForget(StreakWinRewardLib::CONFIG_KEY);
return $this->success('保存成功');
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace app\admin\controller\config;
use app\common\controller\Backend;
use app\common\service\GameHotDataRedis;
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());
}
GameHotDataRedis::gameConfigForget(ZiHuaDictionaryLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
}
}

View File

@@ -1,661 +0,0 @@
<?php
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use app\common\library\GameRewardConfigTemplate;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 渠道管理
*/
class Channel extends Backend
{
/**
* GameChannel模型对象
* @var object|null
* @phpstan-var \app\common\model\GameChannel|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'];
/**
* 非超级管理员仅能操作 game_channel.admin_id 为当前账号的渠道;超管不限制
* @see \app\common\controller\Backend::getDataLimitAdminIds()
*/
protected bool|string|int $dataLimit = true;
/**
* adminTree 为辅助接口,默认权限节点名 game/channel/admintree 往往未在后台录入;
* 与列表权限 game/channel/index 对齐,避免子管理员已勾「渠道管理」仍 401。
*/
protected array $noNeedPermission = ['adminTree', 'deleteRelatedCounts'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameChannel();
return null;
}
/**
* 列表;附带 delete_related_counts=1 时返回删除前关联数据统计(走与 index 相同的路由入口,避免单独 URL 在部分环境下 404
*
* @throws Throwable
*/
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$delPreview = $request->get('delete_related_counts');
if ($delPreview === '1' || $delPreview === 1 || $delPreview === true) {
return $this->deleteRelatedCountsResponse($request);
}
return $this->_index();
}
/**
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
*/
public function adminTree(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if (!$this->auth->check('game/channel/index')) {
return $this->error(__('You have no permission'));
}
$channelQuery = Db::name('game_channel')
->field(['id', 'name', 'admin_group_id'])
->order('id', 'asc');
if (!$this->auth->isSuperAdmin()) {
$channelQuery->where('admin_id', $this->auth->id);
}
$channels = $channelQuery->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);
if (!$this->auth->isSuperAdmin()) {
$data['admin_id'] = $this->auth->id;
}
$adminId = $data['admin_id'] ?? null;
if ($adminId === null || $adminId === '') {
return $this->error(__('Parameter %s can not be empty', ['admin_id']));
}
// 不允许前端填写,统一后端根据管理员所属“顶级角色组(pid=0)”自动回填
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->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) {
$newChannelId = $this->resolveNewChannelIdAfterInsert($data);
if (!$this->isPositiveChannelId($newChannelId)) {
$code = $data['code'] ?? null;
if (is_string($code) && trim($code) !== '') {
$newChannelId = Db::name('game_channel')->where('code', trim($code))->order('id', 'desc')->value('id');
}
}
if ($this->isPositiveChannelId($newChannelId)) {
try {
$this->copyGameConfigFromChannelZero($newChannelId);
$this->copyRewardConfigFromTemplate($newChannelId);
} catch (Throwable $e) {
return $this->error(__('Game channel copy default config failed') . ': ' . $e->getMessage());
}
}
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'));
}
$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);
// 不允许前端填写,统一后端根据管理员所属“顶级角色组(pid=0)”自动回填
if (array_key_exists('admin_group_id', $data)) {
unset($data['admin_group_id']);
}
if (!$this->auth->isSuperAdmin()) {
unset($data['admin_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;
}
$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
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
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();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['adminGroup' => ['name'], 'admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$list = $this->buildChannelListWithRealtimeUserCounts($res->items());
return $this->success('', [
'list' => $list,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 列表 user_count 按 game_user.game_channel_id 实时 COUNT与库字段无关用户增删改时会回写 game_channel.user_count
*
* @param iterable<int|string, mixed> $items
* @return list<array<string, mixed>>
*/
private function buildChannelListWithRealtimeUserCounts(iterable $items): array
{
$rows = [];
foreach ($items as $item) {
$rows[] = is_array($item) ? $item : $item->toArray();
}
if ($rows === []) {
return [];
}
$ids = [];
foreach ($rows as $r) {
if (isset($r['id'])) {
$ids[] = $r['id'];
}
}
if ($ids === []) {
return $rows;
}
$agg = Db::name('game_user')
->where('game_channel_id', 'in', $ids)
->field('game_channel_id, count(*) as cnt')
->group('game_channel_id')
->select()
->toArray();
$countMap = [];
foreach ($agg as $a) {
$countMap[$a['game_channel_id']] = (int) $a['cnt'];
}
foreach ($rows as &$r) {
$cid = $r['id'] ?? null;
$r['user_count'] = ($cid !== null && $cid !== '') ? ($countMap[$cid] ?? 0) : 0;
}
unset($r);
return $rows;
}
/**
* 删除前统计:与当前选中渠道相关的游戏配置条数、游戏用户条数(须具备 game/channel/del
*
* @throws Throwable
*/
public function deleteRelatedCounts(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->deleteRelatedCountsResponse($request);
}
/**
* @throws Throwable
*/
private function deleteRelatedCountsResponse(WebmanRequest $request): Response
{
if (!$this->auth->check('game/channel/del')) {
return $this->error(__('You have no permission'));
}
$channelIds = $this->getAuthorizedChannelIdsForIncomingIds($request);
if ($channelIds === []) {
return $this->success('', [
'game_config_count' => 0,
'game_user_count' => 0,
]);
}
// 实时统计game_config.channel_id、game_user.game_channel_id与渠道 id 一致)
$configCount = Db::name('game_config')->where('channel_id', 'in', $channelIds)->count();
$userCount = Db::name('game_user')->where('game_channel_id', 'in', $channelIds)->count();
return $this->success('', [
'game_config_count' => $configCount,
'game_user_count' => $userCount,
]);
}
/**
* 删除渠道:若存在关联的游戏配置或用户,须带 confirm_cascade=1同时级联删除关联数据
*
* @throws Throwable
*/
protected function _del(): Response
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : [];
if (!is_array($ids)) {
$ids = $ids !== null && $ids !== '' ? [$ids] : [];
}
if ($ids === []) {
return $this->error(__('Parameter error'));
}
$pk = $this->model->getPk();
$where[] = [$pk, 'in', $ids];
$data = $this->model->where($where)->select();
if (count($data) === 0) {
return $this->error(__('No rows were deleted'));
}
$channelIds = [];
foreach ($data as $v) {
$channelIds[] = $v[$pk];
}
// 删除确认用实时条数game_config.channel_id、game_user.game_channel_id
$configCount = Db::name('game_config')->where('channel_id', 'in', $channelIds)->count();
$userCount = Db::name('game_user')->where('game_channel_id', 'in', $channelIds)->count();
$confirmCascade = $this->request->get('confirm_cascade');
if ($confirmCascade === null || $confirmCascade === '') {
$confirmCascade = $this->request->post('confirm_cascade');
}
$confirmed = $confirmCascade === 1 || $confirmCascade === '1' || $confirmCascade === true;
if (($configCount > 0 || $userCount > 0) && !$confirmed) {
return $this->error(__('Game channel delete need confirm related'), [
'need_confirm' => true,
'game_config_count' => $configCount,
'game_user_count' => $userCount,
]);
}
$count = 0;
$this->model->startTrans();
try {
Db::name('game_config')->where('channel_id', 'in', $channelIds)->delete();
Db::name('game_user')->where('game_channel_id', 'in', $channelIds)->delete();
foreach ($data as $v) {
$count += $v->delete();
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($count) {
return $this->success(__('Deleted successfully'));
}
return $this->error(__('No rows were deleted'));
}
/**
* @param list<string|int> $ids
* @return list<int|string>
*/
private function getAuthorizedChannelIdsForIncomingIds(WebmanRequest $request): array
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $request->post('ids') ?? $request->get('ids') ?? [];
if (!is_array($ids)) {
$ids = $ids !== null && $ids !== '' ? [$ids] : [];
}
if ($ids === []) {
return [];
}
$pk = $this->model->getPk();
$where[] = [$pk, 'in', $ids];
return $this->model->where($where)->column($pk);
}
/**
* ThinkORM 在连接池/部分驱动下insert 后 getKey() 可能未及时带上自增 id这里多路兜底
*
* @param array<string, mixed> $postedChannelData 已过滤后的入库数据(含 code 等)
*/
private function resolveNewChannelIdAfterInsert(array $postedChannelData): int|string|null
{
$pk = $this->model->getPk();
$id = $this->model->getKey();
if ($this->isPositiveChannelId($id)) {
return $id;
}
$rowData = $this->model->getData();
if (is_array($rowData)) {
if (isset($rowData[$pk]) && $this->isPositiveChannelId($rowData[$pk])) {
return $rowData[$pk];
}
if (isset($rowData['id']) && $this->isPositiveChannelId($rowData['id'])) {
return $rowData['id'];
}
}
$lastInsId = $this->model->db()->getLastInsID();
if ($this->isPositiveChannelId($lastInsId)) {
return $lastInsId;
}
$lastInsId2 = Db::name('game_channel')->getLastInsID();
if ($this->isPositiveChannelId($lastInsId2)) {
return $lastInsId2;
}
$code = $postedChannelData['code'] ?? null;
if (is_string($code) && trim($code) !== '') {
$found = Db::name('game_channel')->where('code', trim($code))->order('id', 'desc')->value('id');
if ($this->isPositiveChannelId($found)) {
return $found;
}
}
return null;
}
private function isPositiveChannelId(mixed $id): bool
{
if ($id === null || $id === '') {
return false;
}
if (is_numeric($id)) {
return $id > 0;
}
return false;
}
/**
* 新建渠道后game_reward_config 优先从 game_channel_id=0 的默认模板复制;若无则使用 resource JSON 模板
*
* @param int|string $newChannelId 新建 game_channel.id
*/
private function copyRewardConfigFromTemplate(int|string $newChannelId): void
{
$exists = Db::name('game_reward_config')->where('game_channel_id', $newChannelId)->count();
if ($exists > 0) {
return;
}
$now = time();
$tpl = Db::name('game_reward_config')->whereIn('game_channel_id', [0, '0'])->order('id', 'asc')->find();
if ($tpl) {
Db::name('game_reward_config')->insert([
'game_channel_id' => $newChannelId,
'tier_reward_form' => $tpl['tier_reward_form'],
'bigwin_form' => $tpl['bigwin_form'],
'create_time' => $now,
'update_time' => $now,
]);
return;
}
$cols = GameRewardConfigTemplate::getDefaultJsonColumns();
Db::name('game_reward_config')->insert([
'game_channel_id' => $newChannelId,
'tier_reward_form' => $cols['tier_reward_form'],
'bigwin_form' => $cols['bigwin_form'],
'create_time' => $now,
'update_time' => $now,
]);
}
private function copyGameConfigFromChannelZero(int|string $newChannelId): void
{
$exists = Db::name('game_config')->where('channel_id', $newChannelId)->count();
if ($exists > 0) {
return;
}
// 全局模板channel_id 为 0 或字符串 '0'(与业务约定一致)
$rows = Db::name('game_config')
->whereIn('channel_id', [0, '0'])
->select()
->toArray();
if ($rows === []) {
return;
}
$now = time();
foreach ($rows as $row) {
foreach (['ID', 'id', 'Id'] as $pkField) {
unset($row[$pkField]);
}
$row['channel_id'] = $newChannelId;
$row['create_time'] = $now;
$row['update_time'] = $now;
Db::name('game_config')->strict(false)->insert($row);
}
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -2,52 +2,29 @@
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use support\think\Db;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏配置
* 游戏配置game_config
*/
class Config extends Backend
{
/**
* GameConfig模型对象
* @var object|null
* @phpstan-var \app\common\model\GameConfig|null
*/
protected ?object $model = null;
/**
* 数据范围:非超管仅本人 + 下级角色组内管理员game_config 无 admin_id通过 channel_id 关联 game_channel.admin_id 限定
*/
protected bool|string|int $dataLimit = 'parent';
protected string|array $preExcludeFields = ['id', 'create_time', 'update_time'];
/**
* 列表/删除等条件字段为 channel_id见 {@see getDataLimitAdminIds()} 实际返回渠道 ID
*/
protected string $dataLimitField = 'channel_id';
protected string|array $quickSearchField = ['id', 'config_key', 'remark'];
/**
* 表无 admin_id勿自动写入
*/
protected bool $dataLimitFieldAutoFill = false;
protected string|array $defaultSortField = ['id' => 'asc'];
protected string|array $defaultSortField = 'channel_id,desc';
protected string|array $orderGuarantee = ['id' => 'asc'];
protected array $withJoinTable = ['channel'];
protected bool $modelValidate = true;
protected array|string $preExcludeFields = ['create_time', 'update_time'];
protected string|array $quickSearchField = ['ID'];
/** default_tier_weight / default_kill_score_weight每项≤100 且各项之和必须=100 */
private const WEIGHT_SUM_100_NAMES = ['default_tier_weight', 'default_kill_score_weight'];
/** default_bigwin_weight仅校验每项整数、0100005/30 固定 10000不参与「和≤100」 */
private const BIGWIN_WEIGHT_NAME = 'default_bigwin_weight';
protected bool $modelSceneValidate = true;
protected function initController(WebmanRequest $request): ?Response
{
@@ -56,375 +33,25 @@ class Config extends Backend
}
/**
* 将「可访问管理员 ID」转为「其负责的渠道 ID」供 queryBuilder 使用 channel_id IN (...)
*
* @return list<int|string>
*/
protected function getDataLimitAdminIds(): array
{
if (!$this->dataLimit || !$this->auth || $this->auth->isSuperAdmin()) {
return [];
}
$adminIds = parent::getDataLimitAdminIds();
if ($adminIds === []) {
return [];
}
$channelIds = Db::name('game_channel')->where('admin_id', 'in', $adminIds)->column('id');
if ($channelIds === []) {
return [-1];
}
return array_values(array_unique($channelIds));
}
/**
* @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);
$err = $this->validateGameWeightPayload($data, null);
if ($err !== null) {
return $this->error($err);
}
if (!$this->auth->isSuperAdmin()) {
$allowedChannelIds = $this->getDataLimitAdminIds();
$cid = $data['channel_id'] ?? null;
if ($cid === null || $cid === '') {
return $this->error(__('Parameter %s can not be empty', ['channel_id']));
}
if ($allowedChannelIds !== [] && !in_array($cid, $allowedChannelIds)) {
return $this->error(__('You have no permission'));
}
}
$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'));
}
/**
* @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'));
}
$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);
if (!$this->auth->isSuperAdmin()) {
$data['channel_id'] = $row['channel_id'];
$data['group'] = $row['group'];
$data['name'] = $row['name'];
$data['title'] = $row['title'];
} elseif (!isset($data['name']) || $data['name'] === '' || $data['name'] === null) {
// JSON/表单未带 name 时回退库值避免走「每项≤10000」
$data['name'] = $row['name'] ?? '';
}
// 超管编辑时若未传 group用库值参与 game_weight 校验(与 name 回退一致)
if ($this->auth->isSuperAdmin() && trim((string) ($data['group'] ?? '')) === '') {
$data['group'] = (string) ($row['group'] ?? '');
}
$err = $this->validateGameWeightPayload($data, $row['value'] ?? null);
if ($err !== null) {
return $this->error($err);
}
$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
]);
}
/**
* 与前端 gameWeightFixed.normalizeGameWeightConfigName 一致trim、去括号后说明、小写
*/
private function normalizeGameWeightConfigName(string $raw): string
{
$s = trim($raw);
if (preg_match('/^([^(]+)/u', $s, $m)) {
$s = trim($m[1]);
}
return strtolower($s);
}
/**
* 解析 game_weight JSON 里每一项的权重:支持 int/float、数字字符串与前端 JSON.stringify 一致常为字符串)
*/
private function parseGameWeightScalarToFloat(mixed $v): ?float
{
if (is_int($v) || is_float($v)) {
return (float) $v;
}
if (is_bool($v) || is_array($v) || $v === null) {
return null;
}
$s = trim((string) $v);
if ($s === '') {
return null;
}
$f = filter_var($s, FILTER_VALIDATE_FLOAT);
if ($f !== false) {
return $f;
}
$i = filter_var($s, FILTER_VALIDATE_INT);
if ($i !== false) {
return (float) $i;
}
return null;
}
/**
* game_weighttier/kill 每项≤100 且和必须=100bigwin 每项 010000编辑时键不可改
*
* @param array<string, mixed> $data
*/
private function validateGameWeightPayload(array &$data, ?string $originalValue): ?string
{
$group = strtolower(trim((string) ($data['group'] ?? '')));
if ($group !== 'game_weight') {
return null;
}
$rawName = (string) ($data['name'] ?? '');
$rawName = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $rawName);
$name = $this->normalizeGameWeightConfigName($rawName);
$rawValue = $data['value'] ?? null;
$valueWasArray = false;
if (is_array($rawValue)) {
$decoded = $rawValue;
$valueWasArray = true;
} elseif (is_string($rawValue)) {
$s = trim($rawValue);
if (str_starts_with($s, "\xEF\xBB\xBF")) {
$s = substr($s, 3);
}
if ($s === '') {
return __('Parameter error');
}
$decoded = json_decode($s, true);
// 双重 JSON 字符串(外层已解析为字符串时)
if (is_string($decoded)) {
$decoded = json_decode(trim($decoded), true);
}
} else {
return __('Parameter error');
}
if (!is_array($decoded)) {
return __('Parameter error');
}
// 骰子键 530 结构一律按大奖 010000 校验(避免库中 name 与 JSON 不一致时误走 tier/kill 单项上限)
$diceBigwin = $this->gameWeightDecodedIsBigwinDiceKeys($decoded);
// 归一化后的 name + 原始串包含 default_bigwin_weight兼容说明后缀、异常空格避免误走单项上限
$rawLower = strtolower($rawName);
$useBigwinRules = $diceBigwin
|| ($name === self::BIGWIN_WEIGHT_NAME)
|| str_contains($rawLower, 'default_bigwin_weight');
$keys = [];
$numbers = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
return __('Parameter error');
}
foreach ($item as $k => $v) {
$keys[] = (string) $k;
$num = $this->parseGameWeightScalarToFloat($v);
if ($num === null) {
return __('Game config weight value must be numeric');
}
if ($useBigwinRules) {
if ($num < 0 || $num > 10000) {
return __('Game config bigwin weight each 0 10000');
}
$ks = strval($k);
if (($ks === '5' || $ks === '30') && abs($num - 10000.0) > 0.000001) {
return __('Game config bigwin weight locked 5 30');
}
} else {
if ($num > 100) {
return __('Game config weight each value must not exceed 100');
}
}
$numbers[] = $num;
}
}
if (count($numbers) === 0) {
return __('Parameter %s can not be empty', ['value']);
}
if ($originalValue !== null && $originalValue !== '') {
$oldKeys = $this->extractGameWeightKeys($originalValue);
if ($oldKeys !== $keys) {
return __('Game config weight keys cannot be modified');
}
}
if (!$useBigwinRules && in_array($name, self::WEIGHT_SUM_100_NAMES, true)) {
$sum = array_sum($numbers);
if (abs($sum - 100.0) > 0.000001) {
return __('Game config weight sum must equal 100');
}
}
if ($valueWasArray) {
$data['value'] = json_encode($decoded, JSON_UNESCAPED_UNICODE);
}
return null;
}
/**
* 是否为 default_bigwin_weight 的固定骰子键集合(与前端 BIGWIN_WEIGHT_KEYS 一致)
*
* @param array<mixed> $decoded
*/
private function gameWeightDecodedIsBigwinDiceKeys(array $decoded): bool
{
$keys = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
return false;
}
foreach (array_keys($item) as $k) {
$keys[] = trim((string) $k);
}
}
if (count($keys) !== 6) {
return false;
}
$ints = [];
foreach ($keys as $k) {
if ($k === '' || !ctype_digit($k)) {
return false;
}
$ints[] = (int) $k;
}
sort($ints, SORT_NUMERIC);
return $ints === [5, 10, 15, 20, 25, 30];
}
/**
* @return list<string>
*/
private function extractGameWeightKeys(string $value): array
{
$decoded = json_decode($value, true);
if (!is_array($decoded)) {
return [];
}
$keys = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
foreach ($item as $k => $_) {
$keys[] = (string) $k;
}
}
return $keys;
}
/**
* 查看
* @throws Throwable
* 列表:排除独立表单维护的 36 字花字典
*/
protected function _index(): Response
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
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();
$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)
->visible(['channel' => ['name']])
->alias($alias)
->where($where)
->order($order)
@@ -436,8 +63,4 @@ class Config extends Backend
'remark' => get_route_remark(),
]);
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应方法至此进行重写
*/
}

View File

@@ -0,0 +1,108 @@
<?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)) {
$errMsg = $res['msg'] ?? null;
return $this->error(is_string($errMsg) ? $errMsg : __('Calculation failed'));
}
$okMsg = $res['msg'] ?? '';
return $this->success(is_string($okMsg) ? $okMsg : '', $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');
if (!is_numeric((string) $manualRaw)) {
return $this->error(__('Please enter the draw number'));
}
$manualNumber = (int) $manualRaw;
$res = GameLiveService::scheduleDraw($recordId, $manualNumber);
if (!($res['ok'] ?? false)) {
$errMsg = $res['msg'] ?? null;
return $this->error(is_string($errMsg) ? $errMsg : __('Schedule failed'));
}
$okMsg = $res['msg'] ?? '';
return $this->success(is_string($okMsg) ? $okMsg : '', $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', 'platform_profit_amount', 'winner_user_count'];
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

@@ -1,277 +0,0 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use app\common\library\GameRewardConfigTemplate;
use app\common\library\GameRewardTierBoardGenerator;
use app\common\library\GameRewardWeightSeeder;
use app\common\model\GameRewardConfig;
use app\common\validate\GameRewardConfig as GameRewardConfigValidate;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏奖励配置(渠道表单页,路由 /admin/game/rewardConfig
* 约定game_channel_id = 0 为超管维护的「全渠道默认模板」,新建渠道时优先从此行复制到新渠道
*/
class RewardConfig extends Backend
{
/** 默认模板渠道主键(非真实渠道,仅存库一行) */
private const DEFAULT_TEMPLATE_CHANNEL_ID = 0;
public function index(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
[$channelId, $err] = $this->resolveTargetChannelId($request, false);
if ($err !== null) {
return $err;
}
$row = GameRewardConfig::where('game_channel_id', $channelId)->find();
if ($row) {
return $this->success('', [
'row' => $row->toArray(),
]);
}
$defaults = GameRewardConfigTemplate::getDefaultJsonColumns();
return $this->success('', [
'row' => [
'id' => null,
'game_channel_id' => $channelId,
'tier_reward_form' => $defaults['tier_reward_form'],
'bigwin_form' => $defaults['bigwin_form'],
],
]);
}
public function save(WebmanRequest $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', ['']));
}
[$channelId, $err] = $this->resolveTargetChannelId($request, true);
if ($err !== null) {
return $err;
}
if (!$this->channelExists($channelId)) {
return $this->error(__('Record not found'));
}
$tier = $data['tier_reward_form'] ?? null;
$big = $data['bigwin_form'] ?? null;
if (!is_string($tier) || !is_string($big)) {
return $this->error(__('Parameter error'));
}
try {
$validate = new GameRewardConfigValidate();
$validate->scene('channel_form')->check([
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
$existing = GameRewardConfig::where('game_channel_id', $channelId)->find();
try {
if ($existing) {
$existing->save([
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
} else {
$m = new GameRewardConfig();
$m->save([
'game_channel_id' => $channelId,
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Update successful'));
}
/**
* 按条数与结算标准生成 26 格档位奖励并保存(保留当前 bigwin_form
*/
public function generateTierBoard(WebmanRequest $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 || !is_array($data)) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
[$channelId, $err] = $this->resolveTargetChannelId($request, true);
if ($err !== null) {
return $err;
}
if (!$this->channelExists($channelId)) {
return $this->error(__('Record not found'));
}
try {
$out = GameRewardTierBoardGenerator::generate($data);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
$tier = $out['tier_reward_form'];
$existing = GameRewardConfig::where('game_channel_id', $channelId)->find();
if ($existing && is_string($existing->bigwin_form) && trim($existing->bigwin_form) !== '') {
$big = $existing->bigwin_form;
} else {
$defaults = GameRewardConfigTemplate::getDefaultJsonColumns();
$big = $defaults['bigwin_form'];
}
try {
$validate = new GameRewardConfigValidate();
$validate->scene('channel_form')->check([
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
try {
if ($existing) {
$existing->save([
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
} else {
$m = new GameRewardConfig();
$m->save([
'game_channel_id' => $channelId,
'tier_reward_form' => $tier,
'bigwin_form' => $big,
]);
}
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Update successful'));
}
/**
* 根据当前档位奖励 JSON 生成 game_reward_weight先清空该渠道再写入 52 条)
*/
public function generateRewardWeight(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
[$channelId, $err] = $this->resolveTargetChannelId($request, true);
if ($err !== null) {
return $err;
}
if (!$this->channelExists($channelId)) {
return $this->error(__('Record not found'));
}
$row = GameRewardConfig::where('game_channel_id', $channelId)->find();
if (!$row || !is_string($row->tier_reward_form) || trim($row->tier_reward_form) === '') {
return $this->error('请先保存档位奖励配置');
}
try {
GameRewardWeightSeeder::syncFromTierRewardForm($channelId, $row->tier_reward_form);
} catch (Throwable $e) {
return $this->error($e->getMessage());
}
return $this->success(__('Update successful'));
}
/**
* @return array{0: int, 1: Response|null}
*/
private function resolveTargetChannelId(WebmanRequest $request, bool $isPost): array
{
if ($this->auth->isSuperAdmin()) {
$raw = $isPost ? ($request->post('game_channel_id') ?? $request->get('game_channel_id')) : $request->get('game_channel_id');
if ($raw === null || $raw === '') {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, null];
}
if (!is_numeric($raw)) {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, $this->error(__('Parameter error'))];
}
$cid = intval(strval($raw));
if ($cid < 0) {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, $this->error(__('Parameter error'))];
}
if ($cid === self::DEFAULT_TEMPLATE_CHANNEL_ID) {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, null];
}
return [$cid, null];
}
$ids = Db::name('game_channel')->where('admin_id', $this->auth->id)->order('id', 'asc')->column('id');
if ($ids === []) {
return [self::DEFAULT_TEMPLATE_CHANNEL_ID, $this->error(__('Record not found'))];
}
return [intval(strval($ids[0])), null];
}
private function channelExists(int $channelId): bool
{
if ($channelId === self::DEFAULT_TEMPLATE_CHANNEL_ID) {
return $this->auth->isSuperAdmin();
}
if ($this->auth->isSuperAdmin()) {
return Db::name('game_channel')->where('id', $channelId)->count() > 0;
}
return Db::name('game_channel')->where('id', $channelId)->where('admin_id', $this->auth->id)->count() > 0;
}
}

View File

@@ -1,70 +0,0 @@
<?php
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 游戏奖励权重配置
*/
class RewardWeight extends Backend
{
/**
* GameRewardWeight模型对象
* @var object|null
* @phpstan-var \app\common\model\GameRewardWeight|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'create_time', 'update_time'];
protected array $withJoinTable = ['gameChannel'];
protected string|array $quickSearchField = ['id'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameRewardWeight();
return null;
}
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
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();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['gameChannel' => ['name']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -1,660 +0,0 @@
<?php
namespace app\admin\controller\game;
use Throwable;
use app\common\controller\Backend;
use app\common\service\GameChannelUserCount;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 用户管理
*/
class User extends Backend
{
/**
* GameUser模型对象
* @var object|null
* @phpstan-var \app\common\model\GameUser|null
*/
protected ?object $model = null;
/**
* 数据范围:非超管仅本人 + 下级角色组内管理员(与 auth.Admin 一致,见 Backend::getDataLimitAdminIds parent
*/
protected bool|string|int $dataLimit = 'parent';
/**
* admin_id 由表单选择归属管理员,勿在保存时强制改为当前登录账号
*/
protected bool $dataLimitFieldAutoFill = false;
protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time'];
protected array $withJoinTable = ['gameChannel', 'admin'];
protected string|array $quickSearchField = ['id', 'username', 'phone'];
/**
* 与渠道 Config 类似:从游戏配置拉取默认权重;辅助接口,需具备用户管理列表权限
*/
protected array $noNeedPermission = ['defaultWeightPresets', 'defaultWeightByChannel'];
/** game_weight 分组下的配置名 */
private const GC_GROUP_WEIGHT = 'game_weight';
private const GC_NAME_TIER = 'default_tier_weight';
private const GC_NAME_BIGWIN_PRIMARY = 'default_bigwin_weight';
/** 档位权重固定键T1~T5 */
private const TIER_WEIGHT_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'];
/** 中大奖权重固定键5~30 */
private const BIGWIN_WEIGHT_KEYS = ['5', '10', '15', '20', '25', '30'];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\GameUser();
return null;
}
/**
* 抽奖券 ticket_count空串、[]、[""]、无有效 {ante,count} 时写入 NULL避免库里出现无意义 JSON
*
* @param array<string, mixed> $data
*/
private function normalizeEmptyTicketCount(array &$data): void
{
if (!array_key_exists('ticket_count', $data)) {
return;
}
$v = $data['ticket_count'];
if ($v === null) {
$data['ticket_count'] = null;
return;
}
if ($v === '') {
$data['ticket_count'] = null;
return;
}
if (!is_string($v)) {
return;
}
$s = trim($v);
if ($s === '' || $s === '[]' || strtolower($s) === 'null') {
$data['ticket_count'] = null;
return;
}
$decoded = json_decode($s, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
return;
}
if (!is_array($decoded)) {
return;
}
if ($decoded === []) {
$data['ticket_count'] = null;
return;
}
$valid = false;
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
if (!array_key_exists('ante', $item) || !array_key_exists('count', $item)) {
continue;
}
$ante = $item['ante'];
$count = $item['count'];
if ($ante === '' || $ante === null || $count === '' || $count === null) {
continue;
}
if (!is_numeric($ante) || !is_numeric($count)) {
continue;
}
$valid = true;
break;
}
if (!$valid) {
$data['ticket_count'] = null;
}
}
/**
* 添加重写password 使用 Admin 同款加密uuid 由 username+channel_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);
$this->normalizeEmptyTicketCount($data);
$password = $data['password'] ?? null;
if (!is_string($password) || trim($password) === '') {
return $this->error(__('Parameter %s can not be empty', ['password']));
}
$data['password'] = hash_password($password);
$username = $data['username'] ?? '';
$channelId = $data['channel_id'] ?? ($data['game_channel_id'] ?? null);
if (!is_string($username) || trim($username) === '' || $channelId === null || $channelId === '') {
return $this->error(__('Parameter %s can not be empty', ['username/channel_id']));
}
$data['uuid'] = md5(trim($username) . '|' . $channelId);
if ($this->gameUserUsernameExistsInChannel($username, $channelId)) {
return $this->error(__('Game user username exists in channel'));
}
if (!$this->auth->isSuperAdmin()) {
$allowed = $this->getDataLimitAdminIds();
$adminIdNew = $data['admin_id'] ?? null;
if ($adminIdNew === null || $adminIdNew === '') {
return $this->error(__('Parameter %s can not be empty', ['admin_id']));
}
if ($allowed !== [] && !in_array($adminIdNew, $allowed)) {
return $this->error(__('You have no permission'));
}
}
$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) {
$cid = $data['game_channel_id'] ?? $data['channel_id'] ?? null;
if (($cid === null || $cid === '') && $this->model) {
$rowData = $this->model->getData();
$cid = $rowData['game_channel_id'] ?? $rowData['channel_id'] ?? null;
}
if ($cid !== null && $cid !== '') {
GameChannelUserCount::syncFromGameUser($cid);
}
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* 编辑重写password 使用 Admin 同款加密uuid 由 username+channel_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'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
$oldChannelId = $row['game_channel_id'] ?? $row['channel_id'] ?? null;
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);
$this->normalizeEmptyTicketCount($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'];
} elseif (array_key_exists('game_channel_id', $data)) {
$nextChannelId = $data['game_channel_id'];
} else {
$nextChannelId = $row['channel_id'] ?? $row['game_channel_id'] ?? null;
}
if (is_string($nextUsername) && trim($nextUsername) !== '' && $nextChannelId !== null && $nextChannelId !== '') {
$data['uuid'] = md5(trim($nextUsername) . '|' . $nextChannelId);
if ($this->gameUserUsernameExistsInChannel($nextUsername, $nextChannelId, $row[$pk])) {
return $this->error(__('Game user username exists in channel'));
}
}
if (!$this->auth->isSuperAdmin()) {
$allowed = $this->getDataLimitAdminIds();
$adminIdAfter = array_key_exists('admin_id', $data) ? $data['admin_id'] : ($row['admin_id'] ?? null);
if ($allowed !== [] && $adminIdAfter !== null && $adminIdAfter !== '' && !in_array($adminIdAfter, $allowed)) {
return $this->error(__('You have no permission'));
}
}
$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) {
$merged = array_merge($row->toArray(), $data);
$newChannelId = $merged['game_channel_id'] ?? $merged['channel_id'] ?? null;
if ($newChannelId !== null && $newChannelId !== '') {
GameChannelUserCount::syncFromGameUser($newChannelId);
}
if ($oldChannelId !== null && $oldChannelId !== '' && (string) $oldChannelId !== (string) $newChannelId) {
GameChannelUserCount::syncFromGameUser($oldChannelId);
}
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
// GET: 返回编辑数据时,剔除敏感字段
unset($row['password'], $row['salt'], $row['token'], $row['refresh_token']);
return $this->success('', [
'row' => $row
]);
}
/**
* 删除后按 game_user 重算相关渠道的 user_count
*
* @throws Throwable
*/
protected function _del(): Response
{
$where = [];
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds) {
$where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds];
}
$ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : [];
$ids = is_array($ids) ? $ids : [];
$where[] = [$this->model->getPk(), 'in', $ids];
$data = $this->model->where($where)->select();
$channelIdsToSync = [];
foreach ($data as $v) {
$cid = $v['game_channel_id'] ?? $v['channel_id'] ?? null;
if ($cid !== null && $cid !== '') {
$channelIdsToSync[] = $cid;
}
}
$count = 0;
$this->model->startTrans();
try {
foreach ($data as $v) {
$count += $v->delete();
}
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($count) {
GameChannelUserCount::syncChannels($channelIdsToSync);
return $this->success(__('Deleted successfully'));
}
return $this->error(__('No rows were deleted'));
}
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
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();
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['gameChannel' => ['name'], 'admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 新建用户时:超管可选各渠道 default_tier_weight / default_bigwin_weight大奖仅 default_bigwin_weightdefault_kill_score_weight 为 T1T5 击杀分档位,不作大奖回退)
*
* @throws Throwable
*/
public function defaultWeightPresets(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('game/user/index')) {
return $this->error(__('You have no permission'));
}
$allowed = $this->getAllowedChannelIdsForGameConfig();
$channelQuery = Db::name('game_channel')->field(['id', 'name'])->order('id', 'asc');
if ($allowed !== null) {
$channelQuery->where('id', 'in', $allowed);
}
$channels = $channelQuery->select()->toArray();
$tierOut = [];
$bigwinOut = [];
/** 超管:首条为 game_config.channel_id=0 的全局默认权重 */
if ($this->auth->isSuperAdmin()) {
$gp = $this->fetchDefaultWeightsForChannelId(0);
$gname = __('Global default');
$tierOut[] = [
'channel_id' => 0,
'channel_name' => $gname,
'value' => $gp['tier_weight'],
];
$bigwinOut[] = [
'channel_id' => 0,
'channel_name' => $gname,
'value' => $gp['bigwin_weight'],
];
}
if ($channels === []) {
return $this->success('', [
'tier' => $tierOut,
'bigwin' => $bigwinOut,
]);
}
$channelIds = array_column($channels, 'id');
$nameList = [self::GC_NAME_TIER, self::GC_NAME_BIGWIN_PRIMARY];
$rows = Db::name('game_config')
->where('group', self::GC_GROUP_WEIGHT)
->where('name', 'in', $nameList)
->where('channel_id', 'in', $channelIds)
->field(['channel_id', 'name', 'value'])
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$cid = $row['channel_id'];
if (!isset($map[$cid])) {
$map[$cid] = [];
}
$map[$cid][$row['name']] = $row['value'];
}
foreach ($channels as $ch) {
$cid = $ch['id'];
$cname = $ch['name'] ?? '';
$names = $map[$cid] ?? [];
$tierVal = $names[self::GC_NAME_TIER] ?? null;
$tierOut[] = [
'channel_id' => $cid,
'channel_name' => $cname,
'value' => ($tierVal !== null && $tierVal !== '') ? trim((string) $tierVal) : '[]',
];
$bigPrimary = $names[self::GC_NAME_BIGWIN_PRIMARY] ?? null;
$bigVal = ($bigPrimary !== null && $bigPrimary !== '') ? trim((string) $bigPrimary) : null;
$bigwinOut[] = [
'channel_id' => $cid,
'channel_name' => $cname,
'value' => $bigVal !== null ? $bigVal : '[]',
];
}
return $this->success('', [
'tier' => $tierOut,
'bigwin' => $bigwinOut,
]);
}
/**
* 按渠道取默认档位/大奖权重(非超管仅可访问权限内渠道)
*
* @throws Throwable
*/
public function defaultWeightByChannel(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('game/user/index')) {
return $this->error(__('You have no permission'));
}
$channelId = $request->get('channel_id', $request->post('channel_id'));
if ($channelId === null || $channelId === '') {
return $this->error(__('Parameter error'));
}
$cid = (int) $channelId;
if ($cid === 0) {
if (!$this->auth->isSuperAdmin()) {
return $this->error(__('You have no permission'));
}
$pair = $this->fetchDefaultWeightsForChannelId(0);
return $this->success('', [
'tier_weight' => $pair['tier_weight'],
'bigwin_weight' => $pair['bigwin_weight'],
]);
}
if ($cid < 1 || !$this->canAccessChannelGameConfig($cid)) {
return $this->error(__('You have no permission'));
}
$pair = $this->fetchDefaultWeightsForChannelId($cid);
return $this->success('', [
'tier_weight' => $pair['tier_weight'],
'bigwin_weight' => $pair['bigwin_weight'],
]);
}
/**
* @return list<int|string>|null null 表示超管不限制渠道
*/
private function getAllowedChannelIdsForGameConfig(): ?array
{
if ($this->auth->isSuperAdmin()) {
return null;
}
$adminIds = parent::getDataLimitAdminIds();
if ($adminIds === []) {
return [-1];
}
$channelIds = Db::name('game_channel')->where('admin_id', 'in', $adminIds)->column('id');
if ($channelIds === []) {
return [-1];
}
return array_values(array_unique($channelIds));
}
private function canAccessChannelGameConfig(int $channelId): bool
{
$exists = Db::name('game_channel')->where('id', $channelId)->count();
if ($exists < 1) {
return false;
}
$allowed = $this->getAllowedChannelIdsForGameConfig();
if ($allowed === null) {
return true;
}
return in_array($channelId, $allowed, false);
}
/**
* @return array{tier_weight: string, bigwin_weight: string}
*/
private function fetchDefaultWeightsForChannelId(int $channelId): array
{
$rows = Db::name('game_config')
->where('channel_id', $channelId)
->where('group', self::GC_GROUP_WEIGHT)
->where('name', 'in', [self::GC_NAME_TIER, self::GC_NAME_BIGWIN_PRIMARY])
->column('value', 'name');
$tier = $rows[self::GC_NAME_TIER] ?? null;
$tierStr = ($tier !== null && $tier !== '') ? trim((string) $tier) : '[]';
$bigPrimary = $rows[self::GC_NAME_BIGWIN_PRIMARY] ?? null;
$bigStr = '[]';
if ($bigPrimary !== null && $bigPrimary !== '') {
$bigStr = trim((string) $bigPrimary);
}
// 适配导入:强制返回固定键的 JSON缺失键补空字符串
$tierStr = $this->normalizeWeightJsonToFixedKeys($tierStr, self::TIER_WEIGHT_KEYS);
$bigStr = $this->normalizeWeightJsonToFixedKeys($bigStr, self::BIGWIN_WEIGHT_KEYS);
return [
'tier_weight' => $tierStr,
'bigwin_weight' => $bigStr,
];
}
/**
* 将 JSON 权重(数组形式:[{key:value}, ...])归一化到固定键顺序。
* 缺失键补空字符串;解析失败则返回固定键且值为空。
*/
private function normalizeWeightJsonToFixedKeys(string $raw, array $fixedKeys): string
{
$raw = trim($raw);
if ($raw === '' || $raw === '[]') {
$empty = [];
foreach ($fixedKeys as $k) {
$empty[] = [$k => ''];
}
return json_encode($empty, JSON_UNESCAPED_UNICODE);
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$empty = [];
foreach ($fixedKeys as $k) {
$empty[] = [$k => ''];
}
return json_encode($empty, JSON_UNESCAPED_UNICODE);
}
$map = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
foreach ($item as $k => $v) {
if (!is_string($k) && !is_int($k)) {
continue;
}
$map[strval($k)] = $v === null ? '' : strval($v);
}
}
$pairs = [];
foreach ($fixedKeys as $k) {
$pairs[] = [$k => $map[$k] ?? ''];
}
return json_encode($pairs, JSON_UNESCAPED_UNICODE);
}
/**
* 当前渠道game_channel_id下是否已存在该用户名编辑时排除当前记录主键
*/
private function gameUserUsernameExistsInChannel(string $username, int|string $channelId, string|int|null $excludePk = null): bool
{
$name = trim($username);
$cid = (int) $channelId;
$query = Db::name('game_user')
->where('game_channel_id', $cid)
->where('username', $name);
if ($excludePk !== null && $excludePk !== '') {
$query->where('id', '<>', $excludePk);
}
return $query->count() > 0;
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -0,0 +1,119 @@
<?php
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\library\game\ZiHuaDictionary as ZiHuaDictionaryLib;
use app\common\service\GameHotDataRedis;
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());
}
GameHotDataRedis::gameConfigForget(ZiHuaDictionaryLib::CONFIG_KEY);
return $this->success(__('Saved successfully'));
}
}

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,127 @@
<?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'];
}
return array_values(array_unique($ids));
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace app\admin\controller\order;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 充值订单
*
* 订单的"由 0 转 1成功入账"统一走 app\common\library\finance\DepositSettlement。
* 当前充值接口为 mock 支付网关,点击即成功;后台不再保留人工审核按钮,
* 如需人工补单,请通过后续专门的"补单/冲正"工具完成,而不是在这个 CRUD 里直接改 status。
*
* 编辑入口现在只用于"查看详情"GET 返回订单 + 关联的 user/channel 信息,
* 阻止 POST 任何改字段的动作(保证金额、状态只能由结算服务变更)。
*/
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', 'deposit_tier_id', 'idempotency_key'];
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(),
]);
}
/**
* GET 时返回关联信息,便于前端详情弹窗直接渲染 user.username / channel.name
* POST 一律拒绝,保证充值订单的金额/状态只能由结算服务变更。
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
if ($id === null || $id === '') {
return $this->error(__('Parameter error'));
}
if ($this->request && $this->request->method() === 'POST') {
return $this->error('充值订单为自动入账,禁止直接修改,如需补单请走专用工具');
}
$row = $this->loadWithRelations(intval(strval($id)));
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->checkChannelScoped($row)) {
return $this->error(__('You have no permission'));
}
return $this->success('', ['row' => $row]);
}
private function loadWithRelations(int $id): ?array
{
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
])
->where($this->model->getTable() . '.id', $id)
->find();
if (!$row) {
return null;
}
return $row->toArray();
}
private function checkChannelScoped(array $row): bool
{
if (!$this->auth || $this->auth->isSuperAdmin()) {
return true;
}
$channelIds = $this->getScopedChannelIdsForFilter();
if ($channelIds === []) {
return false;
}
$raw = $row['channel_id'] ?? null;
if ($raw === null || $raw === '') {
return false;
}
if (!is_numeric(strval($raw))) {
return false;
}
return in_array(intval(strval($raw)), $channelIds, true);
}
/**
* @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'];
}
return array_values(array_unique($ids));
}
}

View File

@@ -0,0 +1,474 @@
<?php
namespace app\admin\controller\order;
use app\common\controller\Backend;
use support\think\Db;
use support\Response;
use Throwable;
use Webman\Http\Request as WebmanRequest;
/**
* 提现订单
*
* 当前审核流转:
* - 用户端提交提现时立即冻结余额user.coin - apply_amount并生成 withdraw_orderstatus=0与 withdraw 流水direction=2
* - 管理员在后台审核通过approve→ status=1拒绝reject→ status=2 并回冲用户余额与流水。
* - 通过流程不再额外扣钱包,因为申请时已冻结;仅在管理员调整 amount/fee 时写一条差额流水。
*/
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(),
]);
}
/**
* GET 时返回关联信息,便于编辑弹窗直接渲染 user.username/channel.name
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
if ($id === null || $id === '') {
return $this->error(__('Parameter error'));
}
if ($this->request && $this->request->method() === 'POST') {
// 历史 CRUD 的 POST 编辑已被 approve/reject 替代,这里阻止直接改金额绕过审核流程
return $this->error('请使用通过/拒绝按钮完成审核');
}
$row = $this->loadWithRelations(intval(strval($id)));
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->checkChannelScoped($row)) {
return $this->error(__('You have no permission'));
}
return $this->success('', ['row' => $row]);
}
/**
* 审核通过:允许调整 amount/feeactual_amount 自动为 amount - fee。
* 对金额差额自动在用户钱包与流水中做增减,保持账务平衡。
*/
public function approve(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$id = $this->intParam($request->post('id'));
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$newAmount = $this->decimalParam($request->post('amount'), '0');
$newFee = $this->decimalParam($request->post('fee'), '0');
if (bccomp($newAmount, '0', 4) <= 0) {
return $this->error('申请金额必须大于 0');
}
if (bccomp($newFee, '0', 4) < 0) {
return $this->error('手续费不能为负');
}
if (bccomp($newFee, $newAmount, 4) > 0) {
return $this->error('手续费不能大于申请金额');
}
$newActual = bcsub($newAmount, $newFee, 4);
$remarkRaw = $request->post('remark');
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
$order = Db::name('withdraw_order')->where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if (!$this->checkChannelScoped($order)) {
return $this->error(__('You have no permission'));
}
$currentStatus = $this->intParam($order['status'] ?? 0);
if ($currentStatus !== 0) {
return $this->error('该订单已审核,无需重复操作');
}
$userId = $this->intParam($order['user_id'] ?? 0);
if ($userId <= 0) {
return $this->error('订单缺少用户信息');
}
$oldAmount = bcadd(strval($order['amount'] ?? '0'), '0', 4);
$diff = bcsub($newAmount, $oldAmount, 4);
$now = time();
$adminId = $this->intParam($this->auth->id ?? 0);
$adminName = $this->adminDisplayName();
$channelIdRaw = $order['channel_id'] ?? null;
$channelId = ($channelIdRaw === null || $channelIdRaw === '')
? null
: $this->intParam($channelIdRaw);
if ($remark === '') {
$remark = '管理员(' . $adminName . ')审核通过:金额 '
. $this->shortAmount($newAmount) . ',手续费 ' . $this->shortAmount($newFee)
. ',实际到账 ' . $this->shortAmount($newActual);
}
Db::startTrans();
try {
// 金额调整差额处理
$cmp = bccomp($diff, '0', 4);
if ($cmp > 0) {
// 新金额更大:再冻结用户 diff
$userRow = Db::name('user')->where('id', $userId)->find();
if (!$userRow) {
Db::rollback();
return $this->error('关联用户不存在');
}
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 4);
if (bccomp($beforeCoin, $diff, 4) < 0) {
Db::rollback();
return $this->error('用户余额不足以补扣调整差额');
}
$afterCoin = bcsub($beforeCoin, $diff, 4);
Db::name('user')->where('id', $userId)->update([
'coin' => $afterCoin,
'total_withdraw_coin' => Db::raw('total_withdraw_coin + ' . $diff),
'update_time' => $now,
]);
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'withdraw',
'direction' => 2,
'amount' => $diff,
'balance_before' => $beforeCoin,
'balance_after' => $afterCoin,
'ref_type' => 'withdraw_order',
'ref_id' => $id,
'idempotency_key' => 'wd_adjust_add_' . strval($order['order_no'] ?? $id) . '_' . $now,
'operator_admin_id' => $adminId > 0 ? $adminId : null,
'remark' => '管理员(' . $adminName . ')审核调增申请金额差额 '
. $this->shortAmount($diff),
'create_time' => $now,
]);
} elseif ($cmp < 0) {
// 新金额更小:退回差额
$abs = bcsub('0', $diff, 4);
$userRow = Db::name('user')->where('id', $userId)->find();
if (!$userRow) {
Db::rollback();
return $this->error('关联用户不存在');
}
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 4);
$afterCoin = bcadd($beforeCoin, $abs, 4);
Db::name('user')->where('id', $userId)->update([
'coin' => $afterCoin,
'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $abs),
'update_time' => $now,
]);
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'withdraw_refund',
'direction' => 1,
'amount' => $abs,
'balance_before' => $beforeCoin,
'balance_after' => $afterCoin,
'ref_type' => 'withdraw_order',
'ref_id' => $id,
'idempotency_key' => 'wd_adjust_sub_' . strval($order['order_no'] ?? $id) . '_' . $now,
'operator_admin_id' => $adminId > 0 ? $adminId : null,
'remark' => '管理员(' . $adminName . ')审核调减申请金额差额 '
. $this->shortAmount($abs),
'create_time' => $now,
]);
}
Db::name('withdraw_order')->where('id', $id)->update([
'amount' => $newAmount,
'fee' => $newFee,
'actual_amount' => $newActual,
'status' => 1,
'review_admin_id' => $adminId > 0 ? $adminId : null,
'review_time' => $now,
'remark' => substr($remark, 0, 255),
'update_time' => $now,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('审核通过', [
'id' => $id,
'amount' => $newAmount,
'fee' => $newFee,
'actual_amount' => $newActual,
'status' => 1,
]);
}
/**
* 审核拒绝必须填写驳回原因remark
* 回冲申请时的冻结user.coin += amounttotal_withdraw_coin -= amount写一条 withdraw_refund 流水。
*/
public function reject(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$id = $this->intParam($request->post('id'));
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$remarkRaw = $request->post('remark');
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
if ($remark === '') {
return $this->error('请填写拒绝原因');
}
$order = Db::name('withdraw_order')->where('id', $id)->find();
if (!$order) {
return $this->error(__('Record not found'));
}
if (!$this->checkChannelScoped($order)) {
return $this->error(__('You have no permission'));
}
$currentStatus = $this->intParam($order['status'] ?? 0);
if ($currentStatus !== 0) {
return $this->error('该订单已审核,无需重复操作');
}
$userId = $this->intParam($order['user_id'] ?? 0);
if ($userId <= 0) {
return $this->error('订单缺少用户信息');
}
$amount = bcadd(strval($order['amount'] ?? '0'), '0', 4);
$channelIdRaw = $order['channel_id'] ?? null;
$channelId = ($channelIdRaw === null || $channelIdRaw === '')
? null
: $this->intParam($channelIdRaw);
$now = time();
$adminId = $this->intParam($this->auth->id ?? 0);
$adminName = $this->adminDisplayName();
Db::startTrans();
try {
$userRow = Db::name('user')->where('id', $userId)->find();
if (!$userRow) {
Db::rollback();
return $this->error('关联用户不存在');
}
$beforeCoin = bcadd(strval($userRow['coin'] ?? '0'), '0', 4);
$afterCoin = bcadd($beforeCoin, $amount, 4);
Db::name('user')->where('id', $userId)->update([
'coin' => $afterCoin,
'total_withdraw_coin' => Db::raw('total_withdraw_coin - ' . $amount),
'update_time' => $now,
]);
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'withdraw_refund',
'direction' => 1,
'amount' => $amount,
'balance_before' => $beforeCoin,
'balance_after' => $afterCoin,
'ref_type' => 'withdraw_order',
'ref_id' => $id,
'idempotency_key' => 'wd_reject_' . strval($order['order_no'] ?? $id) . '_' . $now,
'operator_admin_id' => $adminId > 0 ? $adminId : null,
'remark' => '管理员(' . $adminName . ')驳回提现,退回冻结金额 '
. $this->shortAmount($amount) . '' . $remark,
'create_time' => $now,
]);
Db::name('withdraw_order')->where('id', $id)->update([
'status' => 2,
'review_admin_id' => $adminId > 0 ? $adminId : null,
'review_time' => $now,
'remark' => substr($remark, 0, 255),
'update_time' => $now,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('审核已拒绝', [
'id' => $id,
'status' => 2,
'remark' => $remark,
]);
}
private function loadWithRelations(int $id): ?array
{
$row = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'reviewAdmin' => ['username'],
])
->where($this->model->getTable() . '.id', $id)
->find();
if (!$row) {
return null;
}
return $row->toArray();
}
private function checkChannelScoped(array|object $row): bool
{
if (!$this->auth || $this->auth->isSuperAdmin()) {
return true;
}
$channelIds = $this->getScopedChannelIdsForFilter();
if ($channelIds === []) {
return false;
}
$raw = is_array($row) ? ($row['channel_id'] ?? null) : ($row->channel_id ?? null);
if ($raw === null || $raw === '') {
// 无归属渠道的数据只有超管可见
return false;
}
$cid = $this->intParam($raw);
return in_array($cid, $channelIds, true);
}
private function intParam($raw): int
{
if ($raw === null || $raw === '') {
return 0;
}
if (!is_numeric(strval($raw))) {
return 0;
}
return intval(strval($raw));
}
private function decimalParam($raw, string $default): string
{
if ($raw === null || $raw === '' || !is_numeric(strval($raw))) {
return bcadd($default, '0', 4);
}
return bcadd(strval($raw), '0', 4);
}
private function adminDisplayName(): string
{
if (!$this->auth) {
return 'admin';
}
$name = $this->auth->username ?? null;
if (is_string($name) && $name !== '') {
return $name;
}
$id = $this->intParam($this->auth->id ?? 0);
return '#' . strval($id);
}
/**
* 把 4 位小数金额压缩成最多 2 位小数用于展示(不影响落库精度)
*/
private function shortAmount(string $amount): string
{
if (!is_numeric($amount)) {
return $amount;
}
$normalized = bcadd($amount, '0', 4);
$negative = false;
if (str_starts_with($normalized, '-')) {
$negative = true;
$normalized = substr($normalized, 1);
}
$parts = explode('.', $normalized, 2);
$intPart = $parts[0] ?? '0';
$fracPart = $parts[1] ?? '0000';
$displayFrac = substr($fracPart, 0, 2);
$v = $intPart . '.' . str_pad($displayFrac, 2, '0');
return $negative ? ('-' . $v) : $v;
}
/**
* @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'];
}
return array_values(array_unique($ids));
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\test;
use app\common\controller\Backend;
use app\common\library\admin\PushChannelConfigHelper;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 推送测试public-game-period对局公共频道
*/
class PushGamePeriod extends Backend
{
protected ?object $model = null;
public function pushConfig(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->success('', [
'url' => PushChannelConfigHelper::wsBaseUrl(),
'app_key' => PushChannelConfigHelper::appKey(),
'channel' => 'public-game-period',
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\test;
use app\common\controller\Backend;
use app\common\library\admin\PushChannelConfigHelper;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 推送测试public-operation-notice公告广播频道
*/
class PushOperationNotice extends Backend
{
protected ?object $model = null;
public function pushConfig(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
return $this->success('', [
'url' => PushChannelConfigHelper::wsBaseUrl(),
'app_key' => PushChannelConfigHelper::appKey(),
'channel' => 'public-operation-notice',
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\test;
use app\common\controller\Backend;
use app\common\library\admin\PushChannelConfigHelper;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 推送测试private-user-{uuid}(用户私有频道)
*/
class PushPrivateUser extends Backend
{
protected ?object $model = null;
public function pushConfig(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$uuid = trim((string) ($request->get('uuid') ?? $request->post('uuid') ?? ''));
if ($uuid === '') {
return $this->error(__('Parameter %s can not be empty', ['uuid']));
}
if (strlen($uuid) > 64 || !preg_match('/^[0-9a-zA-Z_-]+$/', $uuid)) {
return $this->error(__('Parameter error'));
}
return $this->success('', [
'url' => PushChannelConfigHelper::wsBaseUrl(),
'app_key' => PushChannelConfigHelper::appKey(),
'channel' => 'private-user-' . $uuid,
]);
}
}

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,111 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\user;
use Throwable;
use app\common\controller\Backend;
use app\admin\model\User as UserModel;
use Webman\Http\Request;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 用户管理
*/
class User extends Backend
{
/**
* User模型对象
* @var object|null
* @phpstan-var \app\common\model\User|null
*/
protected ?object $model = null;
protected array $withJoinTable = ['userGroup'];
protected array|string $preExcludeFields = ['last_login_time', 'login_failure', 'password', 'salt'];
protected array|string $quickSearchField = ['username', 'nickname', 'id'];
protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time', 'invite_code', 'coin', 'total_deposit_coin', 'total_withdraw_coin', 'bet_flow_coin'];
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;
}
public function index(Request $request): Response
/**
* 添加重写password 使用 Admin 同款加密uuid 为 10 位唯一对外标识)
* @throws Throwable
*/
protected function _add(): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
if ($request->get('select') || $request->post('select')) {
return $this->select($request);
}
$data = $this->applyInputFilter($data);
$inviteResolved = $this->applyInviteCodeToUserChannel($data);
if ($inviteResolved !== null) {
return $inviteResolved;
}
$data = $this->excludeFields($data);
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$password = $data['password'] ?? null;
if (!is_string($password) || trim($password) === '') {
return $this->error(__('Parameter %s can not be empty', ['password']));
}
$data['password'] = hash_password($password);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
$username = $data['username'] ?? '';
$channelId = $data['channel_id'] ?? null;
if (!is_string($username) || trim($username) === '' || $channelId === null || $channelId === '') {
return $this->error(__('Parameter %s can not be empty', ['username/channel_id']));
}
$data['uuid'] = \app\common\model\User::generateUniquePublicCode10();
public function add(Request $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$data = $request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$passwd = $data['password'] ?? '';
$data = $this->excludeFields($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 = 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());
}
$result = $this->model->save($data);
$this->model->commit();
if ($passwd) {
$this->model->resetPassword($this->model->id, $passwd);
if ($result !== false) {
return $this->success(__('Added successfully'));
}
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
return $this->error(__('No rows were added'));
}
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 创建后不因改名改渠道自动变更)
* @throws Throwable
*/
protected function _edit(): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$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);
if (!$row) {
return $this->error(__('Record not found'));
@@ -106,23 +116,36 @@ class User extends Backend
return $this->error(__('You have no permission'));
}
if ($request->method() === 'POST') {
$data = $request->post();
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
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);
if (array_key_exists('password', $data)) {
$password = $data['password'];
if (!is_string($password) || trim($password) === '') {
unset($data['password']);
} else {
$data['password'] = hash_password($password);
}
}
$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]]));
$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);
@@ -131,31 +154,392 @@ class User extends Backend
$this->model->rollback();
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']);
$row['password'] = '';
return $this->success('', ['row' => $row]);
// GET: 返回编辑数据时,剔除敏感字段
unset($row['password'], $row['salt'], $row['token'], $row['refresh_token']);
return $this->success('', [
'row' => $row
]);
}
public function select(Request $request): Response
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
// 如果是 select 则转发到 select 方法,若未重写该方法,其实还是继续执行 index
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();
$res = $this->model
->withoutField('password,salt')
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible(['channel' => ['name'], 'admin' => ['username']])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'list' => $res->items(),
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
}
/**
* 后台钱包加减点(不允许在用户编辑表单直接改余额)
*/
public function walletAdjust(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$userIdRaw = $request->post('user_id');
$userId = is_numeric(strval($userIdRaw)) ? intval(strval($userIdRaw)) : 0;
if ($userId <= 0) {
return $this->error(__('Parameter error'));
}
$opRaw = $request->post('op');
$op = is_string($opRaw) ? trim($opRaw) : '';
if (!in_array($op, ['credit', 'deduct'], true)) {
return $this->error('操作类型不正确');
}
$amountRaw = $request->post('amount');
$amountText = is_string($amountRaw) || is_numeric($amountRaw) ? trim(strval($amountRaw)) : '';
if ($amountText === '' || !is_numeric($amountText)) {
return $this->error('金额格式不正确');
}
if (bccomp($amountText, '0', 4) <= 0) {
return $this->error('金额必须大于0');
}
$remarkRaw = $request->post('remark');
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
$adminName = is_string($this->auth->username ?? null) ? $this->auth->username : ('#' . strval($this->auth->id));
$amountForRemark = self::formatAmountForDisplay($amountText);
if ($remark === '') {
$actionText = $op === 'credit' ? '加点' : '扣点';
$remark = '后台管理员(' . $adminName . '' . $actionText . $amountForRemark . '(值)';
}
$user = $this->model->where('id', $userId)->find();
if (!$user) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($user[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
$channelIdRaw = $user['channel_id'] ?? null;
$channelId = is_numeric(strval($channelIdRaw)) ? intval(strval($channelIdRaw)) : null;
$before = strval($user['coin'] ?? '0');
$delta = self::normalizeAmountScale($amountText, 4);
if ($op === 'credit') {
$after = bcadd($before, $delta, 4);
$bizType = 'admin_credit';
$direction = 1;
} else {
if (bccomp($before, $delta, 4) < 0) {
return $this->error('余额不足,扣点失败');
}
$after = bcsub($before, $delta, 4);
$bizType = 'admin_deduct';
$direction = 2;
}
$now = time();
$idem = 'admin_adjust_' . $userId . '_' . $this->auth->id . '_' . $now . '_' . random_int(1000, 9999);
Db::startTrans();
try {
Db::name('user')->where('id', $userId)->update([
'coin' => $after,
'update_time' => $now,
]);
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => $bizType,
'direction' => $direction,
'amount' => $delta,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'admin_user_wallet_adjust',
'ref_id' => null,
'idempotency_key' => $idem,
'operator_admin_id' => intval(strval($this->auth->id)),
'remark' => substr($remark, 0, 500),
'create_time' => $now,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('钱包调整成功', [
'user_id' => $userId,
'coin_before' => self::formatAmountForDisplay($before),
'coin_after' => self::formatAmountForDisplay($after),
'amount' => self::formatAmountForDisplay($delta),
'op' => $op,
]);
}
private static function normalizeAmountScale(string $amount, int $scale): string
{
$raw = trim(str_replace(',', '.', $amount));
if ($raw === '') {
return '0';
}
$negative = false;
if (str_starts_with($raw, '-')) {
$negative = true;
$raw = ltrim(substr($raw, 1));
}
if (!str_contains($raw, '.')) {
$v = ltrim($raw, '0');
$v = $v === '' ? '0' : $v;
return $negative ? ('-' . $v) : $v;
}
[$intPart, $fracPart] = explode('.', $raw, 2);
$intPart = ltrim($intPart, '0');
$intPart = $intPart === '' ? '0' : $intPart;
$fracPart = preg_replace('/\D+/', '', $fracPart) ?? '';
if (strlen($fracPart) > $scale) {
$fracPart = substr($fracPart, 0, $scale);
} else {
$fracPart = str_pad($fracPart, $scale, '0');
}
$v = $intPart . '.' . $fracPart;
return $negative ? ('-' . $v) : $v;
}
private static function formatAmountForDisplay(string $amount): string
{
$normalized = self::normalizeAmountScale($amount, 4);
$negative = false;
if (str_starts_with($normalized, '-')) {
$negative = true;
$normalized = substr($normalized, 1);
}
$parts = explode('.', $normalized, 2);
$intPart = $parts[0] ?? '0';
$fracPart = $parts[1] ?? '0000';
$displayFrac = substr($fracPart, 0, 2);
$v = $intPart . '.' . str_pad($displayFrac, 2, '0');
return $negative ? ('-' . $v) : $v;
}
/**
* 角色组 → 管理员树(仅当前账号可管理的角色组及其下管理员;用于游戏用户归属)
* 同一管理员若属于多个组,只挂在 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)));
}
/**
* 请求中的 `invite_code`(子代理 admin.invite_code解析为 `channel_id`,并写入 `register_invite_code`、`admin_id`。
* 若同时提交 `channel_id` / `admin_id`,须与邀请码对应记录一致。
*/
private function applyInviteCodeToUserChannel(array &$data): ?Response
{
if (!array_key_exists('invite_code', $data)) {
return null;
}
$raw = $data['invite_code'];
unset($data['invite_code']);
$inviteCode = is_string($raw) ? trim($raw) : '';
if ($inviteCode === '') {
return null;
}
$row = Db::name('admin')->field(['id', 'channel_id'])->where('invite_code', $inviteCode)->find();
if (!$row) {
return $this->error(__('Invite code does not exist'));
}
$cid = $row['channel_id'] ?? null;
if ($cid === null || $cid === '') {
return $this->error(__('Invite code not bound to channel'));
}
$cidInt = intval(trim((string) $cid));
if ($cidInt <= 0) {
return $this->error(__('Invite code not bound to channel'));
}
if (isset($data['channel_id']) && $data['channel_id'] !== null && $data['channel_id'] !== '') {
$existing = intval(trim((string) $data['channel_id']));
if ($existing > 0 && $existing !== $cidInt) {
return $this->error(__('Parameter error'));
}
}
$data['channel_id'] = $cidInt;
$data['register_invite_code'] = $inviteCode;
$aidRaw = $row['id'] ?? null;
if ($aidRaw === null || $aidRaw === '') {
return $this->error(__('Parameter error'));
}
$aidInt = intval(trim((string) $aidRaw));
if ($aidInt <= 0) {
return $this->error(__('Parameter error'));
}
if (isset($data['admin_id']) && $data['admin_id'] !== null && $data['admin_id'] !== '') {
$reqAid = intval(trim((string) $data['admin_id']));
if ($reqAid > 0 && $reqAid !== $aidInt) {
return $this->error(__('Parameter error'));
}
}
$data['admin_id'] = $aidInt;
return null;
}
/**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/
}

View File

@@ -0,0 +1,129 @@
<?php
namespace app\admin\controller\user;
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'];
}
return array_values(array_unique($ids));
}
}

View File

@@ -34,7 +34,6 @@ return [
'The uploaded file is too large (%sMiB), Maximum file size:%sMiB' => 'The uploaded file is too large (%sMiB), maximum file size:%sMiB',
'No files have been uploaded or the file size exceeds the upload limit of the server' => 'No files have been uploaded or the file size exceeds the server upload limit.',
'Unknown' => 'Unknown',
'Global default' => 'Global default (channel_id=0)',
'Super administrator' => 'Super administrator',
'No permission' => 'No permission',
'%first% etc. %count% items' => '%first% etc. %count% items',
@@ -95,17 +94,6 @@ return [
'No rows were restore' => 'No rows were restored',
'%d records and files have been deleted' => '%d records and files have been deleted',
'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',
'Game config weight keys cannot be modified' => 'Weight config keys cannot be modified',
'Game config weight value must be numeric' => 'Weight values must be numeric',
'Game config weight each value must not exceed 100' => 'Weight must not exceed 10000',
'Game config weight each value must not exceed 10000' => 'Weight must not exceed 10000',
'Game config weight sum must equal 100' => 'The sum of weights for default_tier_weight / default_kill_score_weight must equal 100',
'Game config weight sum must not exceed 100' => 'The sum of weights for default_tier_weight / default_kill_score_weight must not exceed 100',
'Game config bigwin weight must be integer' => 'Each default big win weight must be an integer',
'Game config bigwin weight each 0 10000' => 'Each default big win weight must be between 0 and 10000',
'Game config bigwin weight locked 5 30' => 'Dice totals 5 and 30 are guaranteed leopard (豹子); weight is fixed at 10000 and cannot be changed',
'Game user username exists in channel' => 'This username already exists in the selected channel',
'Game channel delete need confirm related' => 'Please confirm deletion of related game configuration and user data first',
'Game channel copy default config failed' => 'Channel was created, but copying default game configuration failed',
];

View File

@@ -9,8 +9,5 @@ return [
'You need to have all the permissions of the group and have additional permissions before you can operate the group~' => 'You need to have all the permissions of the group and have additional permissions before you can operate the group~',
'Role group has all your rights, please contact the upper administrator to add or do not need to add!' => 'Role group has all your rights, please contact the upper administrator to add or do not need to add!',
'The group permission node exceeds the range that can be allocated' => 'The group permission node exceeds the range that can be allocated, please refresh and try again~',
'You can only operate subordinate role groups in the tree hierarchy~' => 'You can only manage role groups that are strictly below yours in the role-group tree (not peers or other branches).',
'Non super administrators cannot create top-level role groups' => 'Non super administrators cannot create top-level role groups',
'The parent group is not within your manageable scope' => 'The selected parent group is outside your manageable scope',
'Remark lang' => 'Role groups follow the tree (parent/child): you may only manage groups under your own membership branch; peers, other branches and ancestors cannot be managed here. When assigning rules you can still only select permission nodes you own.',
'Remark lang' => 'For system security, the hierarchical relationship of role groups is for reference only. The actual hierarchy is determined by the number of permission nodes: same permissions = peer, containing and having additional permissions = superior. Peers cannot manage peers; superiors can assign their permission nodes to subordinates. For special cases where an admin needs to become a superior, create a virtual permission node.',
];

View File

@@ -37,7 +37,6 @@ return [
'Topic format error' => '上传存储子目录格式错误!',
'Driver %s not supported' => '不支持的驱动:%s',
'Unknown' => '未知',
'Global default' => '全局默认channel_id=0',
// 权限类语言包-s
'Super administrator' => '超级管理员',
'No permission' => '无权限',
@@ -114,17 +113,6 @@ return [
'No rows were restore' => '未恢复任何行',
'%d records and files have been deleted' => '已删除%d条记录和文件',
'Please input correct username' => '请输入正确的用户名',
'Please enter a valid commission rate for non-top role group' => '非顶级角色组须填写 0100 的分红比例(%)',
'Group Name Arr' => '分组名称数组',
'Game config weight keys cannot be modified' => '权重配置的键不可修改',
'Game config weight value must be numeric' => '权重值必须为数字',
'Game config weight each value must not exceed 100' => '权重不能超过10000',
'Game config weight each value must not exceed 10000' => '权重不能超过10000',
'Game config weight sum must equal 100' => 'default_tier_weight / default_kill_score_weight 的权重之和必须等于100',
'Game config weight sum must not exceed 100' => 'default_tier_weight / default_kill_score_weight 的权重之和不能超过100',
'Game config bigwin weight must be integer' => '默认大奖权重每项必须为整数',
'Game config bigwin weight each 0 10000' => '默认大奖权重每项须在 010000 之间',
'Game config bigwin weight locked 5 30' => '点数 5 与 30 为豹子号必中,权重固定为 10000不可修改',
'Game user username exists in channel' => '该渠道下已存在相同用户名的用户',
'Game channel delete need confirm related' => '请先确认是否同时删除关联的游戏配置与用户数据',
'Game channel copy default config failed' => '渠道已创建,但复制默认游戏配置失败',
];

View File

@@ -9,8 +9,5 @@ return [
'You need to have all the permissions of the group and have additional permissions before you can operate the group~' => '您需要拥有该分组的所有权限且还有额外权限时,才可以操作该分组~',
'Role group has all your rights, please contact the upper administrator to add or do not need to add!' => '角色组拥有您的全部权限,请联系上级管理员添加或无需添加!',
'The group permission node exceeds the range that can be allocated' => '分组权限节点超出可分配范围,请刷新重试~',
'You can only operate subordinate role groups in the tree hierarchy~' => '仅可管理在角色组树中属于您下级的角色组(不含同级、不含其他分支)~',
'Non super administrators cannot create top-level role groups' => '非超级管理员不能创建顶级角色组',
'The parent group is not within your manageable scope' => '所选父级角色组不在您可管理的范围内',
'Remark lang' => '角色组以「树形父子关系」为准:仅可管理本人所在组之下的下级组;同级、其他分支及上级组不可在此管理。分配权限时仍只能勾选您自身拥有的权限节点。',
'Remark lang' => '为保障系统安全,角色组本身的上下级关系仅供参考,系统的实际上下级划分是根据`权限多寡`来确定的,两位管理员的权限节点:相同被认为是`同级`、包含且有额外权限才被认为是`上级`,同级不可管理同级,上级可为下级分配自己拥有的权限节点;若有特殊情况管理员需转`上级`,可建立一个虚拟权限节点',
];

View File

@@ -277,7 +277,34 @@ class Auth extends \ba\Auth
public function getMenus(int $uid = 0): array
{
return parent::getMenus($uid ?: $this->id);
$menus = parent::getMenus($uid ?: $this->id);
// 库内 title 为中文;仅英文界面走 __()。若对 zh-cn 也 __()Symfony 在找不到键时会 fallback 到 en
// 命中 admin_rule_title 后会把中文标题误译成英文。
$localeNorm = str_replace('_', '-', strtolower(locale()));
$toEnglish = ($localeNorm === 'en');
return $this->translateMenuRuleTitles($menus, $toEnglish);
}
/**
* 将 admin_rule.title 在英文界面译为英文;中文界面保持库内原文。
* 英文映射见 app/common/lang/en/admin_rule_title.php
*
* @param array<int, array<string, mixed>> $menus
* @return array<int, array<string, mixed>>
*/
private function translateMenuRuleTitles(array $menus, bool $toEnglish): array
{
foreach ($menus as $k => $item) {
if (isset($item['title']) && is_string($item['title']) && $item['title'] !== '') {
$menus[$k]['title'] = $toEnglish ? __($item['title']) : $item['title'];
}
if (!empty($item['children']) && is_array($item['children'])) {
$menus[$k]['children'] = $this->translateMenuRuleTitles($item['children'], $toEnglish);
}
}
return $menus;
}
public function isSuperAdmin(): bool
@@ -297,26 +324,6 @@ class Auth extends \ba\Auth
return array_unique($children);
}
/**
* 本人 + 树形下级角色组内的管理员 ID与管理员管理列表数据范围一致
*/
public function getSelfAndSubordinateAdminIds(): array
{
if ($this->isSuperAdmin()) {
return [];
}
$descendantGroupIds = $this->getAdminChildGroups();
$adminIds = [];
if ($descendantGroupIds !== []) {
$adminIds = Db::name('admin_group_access')
->where('group_id', 'in', $descendantGroupIds)
->column('uid');
}
$adminIds[] = $this->id;
return array_values(array_unique($adminIds));
}
public function getGroupChildGroups(int $groupId, array &$children): void
{
$childrenTemp = AdminGroup::where('pid', $groupId)

View File

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

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,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

@@ -5,11 +5,14 @@ namespace app\api\controller;
use ba\Date;
use ba\Captcha;
use ba\Random;
use app\common\library\finance\WithdrawFlow;
use app\common\model\User;
use app\common\facade\Token;
use app\common\model\UserScoreLog;
use app\common\model\UserMoneyLog;
use app\common\controller\Frontend;
use app\common\facade\Token as TokenFacade;
use support\think\Db;
use support\validation\Validator;
use support\validation\ValidationException;
use Webman\Http\Request;
@@ -18,7 +21,96 @@ use support\Response;
class Account extends Frontend
{
protected array $noNeedLogin = ['retrievePassword'];
protected array $noNeedPermission = ['verification', 'changeBind'];
// 移动端 API 不走会员权限表user_group/user_rule校验仅校验登录态
protected array $noNeedPermission = ['*'];
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();
$userId = intval(strval($user->id));
$coinBalance = WithdrawFlow::amountString($user->coin ?? '0');
// 打码量 / 提现配额快照
$flow = WithdrawFlow::status($userId, [
'total_deposit_coin' => $user->total_deposit_coin ?? '0',
'total_withdraw_coin' => $user->total_withdraw_coin ?? '0',
'bet_flow_coin' => $user->bet_flow_coin ?? '0',
]);
$maxWithdrawable = WithdrawFlow::maxWithdrawable($coinBalance, $flow);
// 待审核提现订单数(配合 /api/finance/withdrawCreate 的 3 笔上限)
$pendingWithdrawCount = Db::name('withdraw_order')
->where('user_id', $userId)
->where('status', 0)
->count();
$payload = [
'code' => 1,
'message' => __('ok'),
'data' => [
'uuid' => $user->uuid ?? '',
'username' => $user->username,
'head_image' => $user->avatar ?? '',
'phone' => $user->phone ?? '',
'email' => $user->email ?? '',
'register_invite_code' => $user->register_invite_code ?? '',
'channel_id' => $user->channel_id,
'risk_flags' => $user->risk_flags ?? 0,
'current_streak' => $user->current_streak ?? 0,
'last_bet_period_no' => $user->last_bet_period_no ?? '',
'create_time' => $user->create_time ?? 0,
// 资金字段4 位小数字符串,与 /api/wallet/balanceSummary 对齐)
'coin' => $coinBalance,
'coin_balance' => $coinBalance,
'frozen_balance' => '0.0000',
'total_deposit_coin' => WithdrawFlow::amountString($user->total_deposit_coin ?? '0'),
'total_withdraw_coin' => WithdrawFlow::amountString($user->total_withdraw_coin ?? '0'),
'bet_flow_coin' => $flow['bet_flow_coin'],
'max_withdrawable' => $maxWithdrawable,
'withdraw_flow' => [
'ratio' => $flow['ratio'],
'net_deposit' => $flow['net_deposit'],
'required_bet_flow' => $flow['required_bet_flow'],
'remaining_bet_flow' => $flow['remaining_bet_flow'],
'eligible' => $flow['eligible'],
'max_withdraw_by_flow' => $flow['flow_unlimited'] ? null : $flow['max_withdraw_by_flow'],
'flow_unlimited' => $flow['flow_unlimited'],
'pending_withdraw' => [
'count' => $pendingWithdrawCount,
'max' => WithdrawFlow::MAX_PENDING_WITHDRAW,
],
],
],
];
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
{
@@ -126,7 +218,7 @@ class Account extends Frontend
if (!Token::check($params['accountVerificationToken'], $params['type'] . '-pass', $user->id)) {
return $this->error(__('You need to verify your account before modifying the binding information'));
}
} elseif (!isset($params['password']) || !verify_password($params['password'], $user->password, ['salt' => $user->salt])) {
} elseif (!isset($params['password']) || !verify_password($params['password'], $user->password, ['salt' => $user->salt ?? ''])) {
return $this->error(__('Password error'));
}
@@ -167,7 +259,7 @@ class Account extends Frontend
$model = $this->auth->getUser();
$params = $request->only(['oldPassword', 'newPassword']);
if (!verify_password($params['oldPassword'], $model->password, ['salt' => $model->salt])) {
if (!verify_password($params['oldPassword'], $model->password, ['salt' => $model->salt ?? ''])) {
return $this->error(__('Old password error'));
}

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

@@ -0,0 +1,150 @@
<?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 = ['register', 'login', 'refreshToken', 'userRegister', 'userLogin', 'tokenRefresh'];
protected array $noNeedAuthToken = ['register', 'refreshToken', 'userRegister', 'tokenRefresh'];
public function userRegister(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$username = trim((string) $request->post('username', ''));
if ($username === '') {
$username = trim((string) $request->post('account', ''));
}
$password = (string) $request->post('password', '');
$inviteCode = trim((string) $request->post('invite_code', ''));
if ($username === '' || $password === '') {
return $this->mobileError(1001, 'Missing parameters');
}
if ($inviteCode === '') {
return $this->mobileError(1001, 'Invite code required');
}
if (!preg_match('/^1[3-9]\d{9}$/', $username)) {
return $this->mobileError(1003, 'Please enter the correct mobile number');
}
$phone = $username;
$email = '';
if (User::where('username', $username)->find() || User::where('phone', $username)->find()) {
return $this->mobileError(2003, 'Account already registered', [
'already_registered' => true,
]);
}
$extend = [];
$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'];
$channelId = $inviterAdmin['channel_id'] ?? null;
if ($channelId === null || $channelId === '' || (int) $channelId <= 0) {
return $this->mobileError(2002, 'Invite code not bound to channel');
}
$extend['channel_id'] = (int) $channelId;
$registered = $this->auth->register($username, $password, $phone, $email, 1, $extend);
if (!$registered) {
$dup = $this->auth->getRegisterDuplicateKind();
if ($dup === 'username' || $dup === 'email' || $dup === 'phone') {
return $this->mobileError(2003, 'Account already registered', [
'already_registered' => true,
]);
}
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');
}
return $this->mobileSuccess($this->buildLoginPayload());
}
public function userLogin(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$username = trim((string) $request->post('username', ''));
if ($username === '') {
$username = trim((string) $request->post('account', ''));
}
$password = (string) $request->post('password', '');
if ($username === '' || $password === '') {
return $this->mobileError(1001, 'Missing parameters');
}
$ok = $this->auth->login($username, $password, true);
if (!$ok) {
return $this->mobileError(1101, 'Incorrect account or password');
}
return $this->mobileSuccess($this->buildLoginPayload());
}
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([
'user-token' => $newToken,
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
]);
}
private function buildLoginPayload(): array
{
$userInfo = $this->auth->getUserInfo();
return [
'user-token' => $userInfo['token'] ?? '',
'refresh_token' => $userInfo['refresh_token'] ?? '',
'expires_in' => config('buildadmin.user_token_keep_time', 259200),
'user' => [
'username' => $userInfo['username'] ?? '',
'uuid' => $userInfo['uuid'] ?? '',
'coin' => $userInfo['coin'] ?? '0.0000',
'channel_id' => $userInfo['channel_id'] ?? null,
'risk_flags' => $userInfo['risk_flags'] ?? 0,
],
];
}
}

View File

@@ -74,7 +74,7 @@ class Ems extends Frontend
return $this->error(__('Please use the account registration email to send the verification code'));
}
$password = $request->post('password');
if (!verify_password($password, $this->auth->password, ['salt' => $this->auth->salt])) {
if (!verify_password($password, $this->auth->password, ['salt' => $this->auth->salt ?? ''])) {
return $this->error(__('Password error'));
}
}

View File

@@ -0,0 +1,538 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\library\finance\DepositSettlement;
use app\common\library\finance\WithdrawFlow;
use app\common\library\game\DepositTier as DepositTierLib;
use app\common\model\DepositOrder;
use app\common\model\GameConfig;
use app\common\model\WithdrawOrder;
use support\Response;
use support\think\Db;
use Throwable;
use Webman\Http\Request;
class Finance extends MobileBase
{
/**
* 充值档位列表(仅启用档位,按 sort 升序)
*/
public function depositTierList(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$lang = $this->currentLang();
$tiers = $this->loadEnabledTiers();
$list = [];
foreach ($tiers as $tier) {
$amount = $this->amountString($tier['amount'] ?? '0');
$bonus = $this->amountString($tier['bonus_amount'] ?? '0');
$total = bcadd($amount, $bonus, 4);
$localized = DepositTierLib::localize($tier, $lang);
$list[] = [
'id' => $tier['id'],
'title' => $localized['title'],
'amount' => $amount,
'bonus_amount' => $bonus,
'total_amount' => $total,
'desc' => $localized['desc'],
];
}
return $this->mobileSuccess([
'list' => $list,
]);
}
/**
* 获取当前请求语言标识(由中间件 LoadLangPack 设置到 locale规范为小写、以 "-" 连字
*/
private function currentLang(): string
{
$lang = function_exists('locale') ? locale() : '';
if (!is_string($lang) || $lang === '') {
return 'zh-cn';
}
return strtolower(str_replace('_', '-', $lang));
}
/**
* 创建充值订单
*
* 当前为 mock 支付网关,点击即成功:服务端直接在同一请求内完成订单入账。
* 未来接入真实第三方支付时,仅需把 "立即结算" 替换为 "返回 pay_url 进入网关"
* 并把入账动作放到网关回调里完成(回调中调用 DepositSettlement::settle
*
* 请求application/json 或 x-www-form-urlencoded
* - tier_id: 必填,档位 ID需在 game_config.deposit_tier 启用档位内)
* - idempotency_key: 必填,客户端幂等键,短时间内重复提交只生成一次订单
*
* 响应(统一结构,未来接入第三方也保持此形状):
* - order_no / amount / pay_channel / paid / pay_url / status / create_time / pay_time
*/
public function depositCreate(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$tierId = $this->stringParam($request->input('tier_id'));
$idempotencyKey = $this->stringParam($request->input('idempotency_key'));
if ($tierId === '' || $idempotencyKey === '') {
return $this->mobileError(1001, 'Missing parameters');
}
if (mb_strlen($idempotencyKey) > 64) {
return $this->mobileError(1002, 'Idempotency key is too long');
}
$tiers = $this->loadEnabledTiers();
$tier = DepositTierLib::findById($tiers, $tierId);
if (!$tier) {
return $this->mobileError(2003, 'Deposit tier not available');
}
// 幂等命中:直接返回已有订单
try {
$existing = DepositOrder::where('idempotency_key', $idempotencyKey)->find();
if ($existing) {
if (intval($existing->user_id) !== intval($this->auth->id)) {
return $this->mobileError(1002, 'Idempotency key conflict');
}
return $this->mobileSuccess($this->buildDepositResponse($existing));
}
} catch (Throwable $e) {
// 忽略幂等查询失败,继续创建
}
$user = $this->auth->getUser();
$orderNo = 'DP' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
$tierSnapshot = [
'id' => $tier['id'],
'title' => is_string($tier['title'] ?? null) ? $tier['title'] : '',
'title_en' => is_string($tier['title_en'] ?? null) ? $tier['title_en'] : '',
'amount' => $this->amountString($tier['amount'] ?? '0'),
'bonus_amount' => $this->amountString($tier['bonus_amount'] ?? '0'),
'desc' => is_string($tier['desc'] ?? null) ? $tier['desc'] : '',
'desc_en' => is_string($tier['desc_en'] ?? null) ? $tier['desc_en'] : '',
];
$now = time();
$channelId = null;
if (isset($user->channel_id) && is_numeric(strval($user->channel_id))) {
$channelId = intval(strval($user->channel_id));
}
$orderId = 0;
try {
$order = DepositOrder::create([
'order_no' => $orderNo,
'idempotency_key' => $idempotencyKey,
'user_id' => intval($user->id),
'channel_id' => $channelId,
'amount' => $tierSnapshot['amount'],
'bonus_amount' => $tierSnapshot['bonus_amount'],
'status' => 0,
'pay_channel' => 'mock_gateway',
'deposit_tier_id' => $tier['id'],
'proof_image' => '',
'pay_account_snapshot' => json_encode($tierSnapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'remark' => '',
'create_time' => $now,
'update_time' => $now,
]);
$orderId = intval($order->id);
} catch (Throwable $e) {
$msg = $e->getMessage();
if (stripos($msg, 'Duplicate') !== false && stripos($msg, 'uk_deposit_order_idem') !== false) {
$existing = DepositOrder::where('idempotency_key', $idempotencyKey)->find();
if ($existing) {
return $this->mobileSuccess($this->buildDepositResponse($existing));
}
}
return $this->mobileError(2000, $msg);
}
// Mock 网关:立即结算,入账到钱包
try {
DepositSettlement::settle(
$orderId,
DepositSettlement::SOURCE_MOCK_GATEWAY,
'mock gateway auto settled'
);
} catch (Throwable $e) {
return $this->mobileError(2000, $e->getMessage());
}
$settled = DepositOrder::where('id', $orderId)->find();
if (!$settled) {
return $this->mobileError(2000, 'Order not found after settle');
}
return $this->mobileSuccess($this->buildDepositResponse($settled));
}
/**
* 将订单模型转换为统一的创建/详情响应数据
*/
private function buildDepositResponse($order): array
{
$status = $this->mapDepositStatus($order->status);
$paid = $status === 'paid';
$amount = $this->amountString($order->amount);
$bonus = $this->amountString($order->bonus_amount);
$total = bcadd($amount, $bonus, 4);
return [
'order_no' => is_string($order->order_no) ? $order->order_no : strval($order->order_no),
'amount' => $amount,
'bonus_amount' => $bonus,
'total_amount' => $total,
'status' => $status,
'paid' => $paid,
'pay_channel' => is_string($order->pay_channel) ? $order->pay_channel : strval($order->pay_channel),
'pay_url' => '',
'create_time' => is_numeric(strval($order->create_time)) ? intval(strval($order->create_time)) : 0,
'pay_time' => is_numeric(strval($order->pay_time)) ? intval(strval($order->pay_time)) : 0,
];
}
/**
* 将任意金额输入归一化为 4 位小数字符串(不做类型强制转换)
*/
private function amountString($raw): string
{
if (is_string($raw)) {
$s = trim($raw);
} elseif (is_int($raw) || is_float($raw)) {
$s = strval($raw);
} else {
return '0.0000';
}
if ($s === '' || !is_numeric($s)) {
return '0.0000';
}
return bcadd($s, '0', 4);
}
/**
* 查看充值订单详情(原 depositDetail。根据 order_no 返回完整订单快照。
*/
public function depositDetail(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$orderNo = $this->stringParam($request->input('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($this->buildDepositResponse($order));
}
/**
* 查询当前用户的充值订单列表(分页)。列表项返回 order_no / amount / bonus_amount / status
* 其他字段请调用 /api/finance/depositDetail。
*/
public function depositList(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$page = $this->intValue($request->input('page', 1));
if ($page <= 0) {
$page = 1;
}
$pageSize = $this->intValue($request->input('page_size', 20));
if ($pageSize <= 0 || $pageSize > 100) {
$pageSize = 20;
}
$paginate = DepositOrder::where('user_id', $this->auth->id)
->order('id', 'desc')
->paginate(['page' => $page, 'list_rows' => $pageSize]);
$list = [];
foreach ($paginate->items() as $row) {
$list[] = [
'order_no' => $row->order_no,
'amount' => $this->amountString($row->amount ?? '0'),
'bonus_amount' => $this->amountString($row->bonus_amount ?? '0'),
'status' => $this->mapDepositStatus($row->status ?? null),
];
}
return $this->mobileSuccess([
'list' => $list,
'pagination' => [
'page' => $paginate->currentPage(),
'page_size' => $paginate->listRows(),
'total' => $paginate->total(),
],
]);
}
public function withdrawCreate(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$withdrawCoinRaw = $request->post('withdraw_coin', '');
$withdrawCoin = is_string($withdrawCoinRaw) ? trim($withdrawCoinRaw) : (is_numeric($withdrawCoinRaw) ? strval($withdrawCoinRaw) : '');
$receiveAccount = trim(is_string($request->post('receive_account', '')) ? $request->post('receive_account', '') : '');
$receiveType = trim(is_string($request->post('receive_type', '')) ? $request->post('receive_type', '') : '');
$idempotencyKey = trim(is_string($request->post('idempotency_key', '')) ? $request->post('idempotency_key', '') : '');
if ($withdrawCoin === '' || $receiveAccount === '' || $receiveType === '' || $idempotencyKey === '') {
return $this->mobileError(1001, 'Missing parameters');
}
if (!is_numeric($withdrawCoin) || bccomp($withdrawCoin, '0', 4) <= 0) {
return $this->mobileError(1001, 'Invalid withdraw amount');
}
$withdrawCoin = bcadd($withdrawCoin, '0', 4);
$user = $this->auth->getUser();
$userId = intval(strval($user->id));
// 待审核订单数限制:同一用户最多 MAX_PENDING_WITHDRAW 笔 status=0待审核
$pendingCount = Db::name('withdraw_order')
->where('user_id', $userId)
->where('status', 0)
->count();
if ($pendingCount >= WithdrawFlow::MAX_PENDING_WITHDRAW) {
return $this->mobileError(2004, 'Too many pending withdraw orders', [
'max_pending' => WithdrawFlow::MAX_PENDING_WITHDRAW,
'pending_count' => $pendingCount,
]);
}
$balanceBefore = bcadd(strval($user->coin ?? '0'), '0', 4);
if (bccomp($balanceBefore, $withdrawCoin, 4) < 0) {
return $this->mobileError(2001, 'Insufficient balance');
}
// 单笔上限校验:提现金额 <= min(coin, max_withdraw_by_flow)
// - max_withdraw_by_flow = max(0, bet_flow_coin / ratio - total_withdraw_coin)
// - ratio = 0 视为"不限打码",上限仅取余额
// 超过上限直接回传 max_withdrawable前端可据此提示"最大可提现金额为 XXX"。
$flowStatus = WithdrawFlow::status($userId, [
'total_deposit_coin' => $user->total_deposit_coin ?? '0',
'total_withdraw_coin' => $user->total_withdraw_coin ?? '0',
'bet_flow_coin' => $user->bet_flow_coin ?? '0',
]);
$maxWithdrawable = WithdrawFlow::maxWithdrawable($balanceBefore, $flowStatus);
if (bccomp($withdrawCoin, $maxWithdrawable, 4) > 0) {
return $this->mobileError(2002, 'Withdraw exceeds available bet flow', [
'max_withdrawable' => $maxWithdrawable,
'coin_balance' => $balanceBefore,
'bet_flow_coin' => $flowStatus['bet_flow_coin'],
'total_withdraw_coin' => WithdrawFlow::amountString($user->total_withdraw_coin ?? '0'),
'ratio' => $flowStatus['ratio'],
'max_withdraw_by_flow' => $flowStatus['flow_unlimited'] ? null : $flowStatus['max_withdraw_by_flow'],
]);
}
$channelIdRaw = $user->channel_id ?? null;
$channelId = ($channelIdRaw !== null && $channelIdRaw !== '' && is_numeric(strval($channelIdRaw)))
? intval(strval($channelIdRaw))
: null;
$orderNo = 'WD' . date('YmdHis') . substr(str_replace('.', '', uniqid('', true)), -6);
$feeCoin = bcmul($withdrawCoin, '0.005', 4);
$actualArrivalCoin = bcsub($withdrawCoin, $feeCoin, 4);
$balanceAfter = bcsub($balanceBefore, $withdrawCoin, 4);
$now = time();
Db::startTrans();
try {
// 钱包即时扣减(冻结语义):审核通过即定稿;审核驳回在管理端回冲。
$affected = Db::name('user')
->where('id', $userId)
->where('coin', '>=', $withdrawCoin)
->update([
'coin' => Db::raw('coin - ' . $withdrawCoin),
'total_withdraw_coin' => Db::raw('total_withdraw_coin + ' . $withdrawCoin),
'update_time' => $now,
]);
if ($affected <= 0) {
Db::rollback();
return $this->mobileError(2001, 'Insufficient balance');
}
$orderId = Db::name('withdraw_order')->insertGetId([
'order_no' => $orderNo,
'user_id' => $userId,
'channel_id' => $channelId,
'amount' => $withdrawCoin,
'fee' => $feeCoin,
'actual_amount' => $actualArrivalCoin,
'status' => 0,
'review_admin_id' => null,
'review_time' => null,
'remark' => '',
'create_time' => $now,
'update_time' => $now,
]);
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'withdraw',
'direction' => 2,
'amount' => $withdrawCoin,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'ref_type' => 'withdraw_order',
'ref_id' => $orderId,
'idempotency_key' => 'wd_apply_' . $orderNo,
'operator_admin_id' => null,
'remark' => '用户申请提现(待审核冻结):' . $orderNo,
'create_time' => $now,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->mobileError(2000, $e->getMessage());
}
return $this->mobileSuccess([
'order_no' => $orderNo,
'status' => 'pending_review',
'fee_coin' => $feeCoin,
'actual_arrival_coin' => $actualArrivalCoin,
'risk_review_required' => true,
]);
}
/**
* 查看提现订单详情(原 withdrawDetail。根据 order_no 返回完整订单快照。
*/
public function withdrawDetail(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$orderNo = $this->stringParam($request->input('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');
}
$remarkRaw = $order->remark ?? '';
$remark = is_string($remarkRaw) ? $remarkRaw : strval($remarkRaw);
$statusCode = $this->intValue($order->status);
return $this->mobileSuccess([
'order_no' => $order->order_no,
'status' => $this->mapWithdrawStatus($statusCode),
'withdraw_coin' => $order->amount,
'fee_coin' => $order->fee,
'actual_arrival_coin' => $order->actual_amount,
'reject_reason' => $statusCode === 2 && $remark !== '' ? $remark : null,
'create_time' => $order->create_time,
'review_time' => $order->review_time,
]);
}
/**
* 查询当前用户的提现订单列表(分页)。列表项返回 order_no / amount / status
* 手续费、实到账、拒绝原因等请调用 /api/finance/withdrawDetail。
*/
public function withdrawList(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$page = $this->intValue($request->input('page', 1));
if ($page <= 0) {
$page = 1;
}
$pageSize = $this->intValue($request->input('page_size', 20));
if ($pageSize <= 0 || $pageSize > 100) {
$pageSize = 20;
}
$paginate = WithdrawOrder::where('user_id', $this->auth->id)
->order('id', 'desc')
->paginate(['page' => $page, 'list_rows' => $pageSize]);
$list = [];
foreach ($paginate->items() as $row) {
$list[] = [
'order_no' => $row->order_no,
'amount' => $this->amountString($row->amount ?? '0'),
'status' => $this->mapWithdrawStatus($row->status ?? null),
];
}
return $this->mobileSuccess([
'list' => $list,
'pagination' => [
'page' => $paginate->currentPage(),
'page_size' => $paginate->listRows(),
'total' => $paginate->total(),
],
]);
}
private function stringParam($raw): string
{
if ($raw === null) {
return '';
}
if (!is_string($raw)) {
return '';
}
return trim($raw);
}
private function loadEnabledTiers(): array
{
$row = GameConfig::where('config_key', DepositTierLib::CONFIG_KEY)->find();
$all = DepositTierLib::parseFromConfigValue($row?->config_value ?? null);
return DepositTierLib::publicList($all);
}
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';
}
/**
* 映射 withdraw_order.status0 待审 / 1 通过 / 2 拒绝 / 3 已打款)到移动端状态字符串
*/
private function mapWithdrawStatus($statusCode): string
{
$code = $this->intValue($statusCode);
if ($code === 1 || $code === 3) {
return 'approved';
}
if ($code === 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;
}
}

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

@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\library\game\ZiHuaDictionary;
use app\common\model\BetOrder;
use app\common\model\GameRecord;
use app\common\model\UserWalletRecord;
use app\common\service\GameHotDataRedis;
use app\common\service\UserPushService;
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;
}
$periodRow = GameHotDataRedis::gameRecordLatest();
$now = time();
$startAt = $periodRow ? $this->intValue($periodRow['period_start_at'] ?? 0) : $now;
$lockAt = $startAt + 20;
$openAt = $startAt + 22;
$countdown = $periodRow ? max(0, ($startAt + 30) - $now) : 0;
$dictionaryConfig = GameHotDataRedis::gameConfigRow(ZiHuaDictionary::CONFIG_KEY) ?? [];
$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' => (string) ($periodRow['period_no'] ?? ''),
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
'countdown' => $countdown,
'lock_at' => $lockAt,
'open_at' => $openAt,
],
'bet_config' => [
'pick_max_number_count' => $this->getPickMaxNumberCount(),
'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 = GameHotDataRedis::gameConfigRow(ZiHuaDictionary::CONFIG_KEY) ?? [];
$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->input('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;
}
$periodRow = GameHotDataRedis::gameRecordLatest();
if (!$periodRow) {
return $this->mobileError(2002, 'Game period does not exist');
}
$now = time();
$startAt = $this->intValue($periodRow['period_start_at'] ?? 0);
return $this->mobileSuccess([
'period_id' => $periodRow['id'],
'period_no' => $periodRow['period_no'],
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
'countdown' => max(0, ($startAt + 30) - $now),
'bet_close_in' => max(0, ($startAt + 20) - $now),
'result_number' => $periodRow['result_number'] ?? null,
]);
}
/**
* 提交下注入参极简——period_no + numbers + bet_amount整笔总金额 + idempotency_key。
*
* 下注判定:开奖号码 ∈ pick_numbers 即算中奖,赔付按整笔 total_amount × odds 计算
* (派彩 = 压注总额 × 连胜奖励表 odds_factorstreak_at_bet 为下注时快照)。
*/
public function betPlace(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$periodNo = trim((string) $request->post('period_no', ''));
$numbersRaw = $request->post('numbers', '');
$betAmount = trim((string) $request->post('bet_amount', ''));
$idempotencyKey = trim((string) $request->post('idempotency_key', ''));
if ($periodNo === '' || $betAmount === '' || $idempotencyKey === '') {
return $this->mobileError(1001, 'Missing parameters');
}
if (!is_numeric($betAmount) || bccomp($betAmount, '0', 4) <= 0) {
return $this->mobileError(1003, 'Invalid parameter value');
}
$totalAmount = bcadd($betAmount, '0', 4);
$numbers = $this->parseBetNumbersFromRequest($numbersRaw);
if ($numbers === []) {
return $this->mobileError(1003, 'Invalid parameter value');
}
$maxSelect = $this->getPickMaxNumberCount();
if (count($numbers) > $maxSelect) {
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();
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,
'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();
$uid = filter_var($user->id, FILTER_VALIDATE_INT);
if ($uid !== false) {
GameHotDataRedis::userForget($uid);
}
UserPushService::publish((int) $user->id, UserPushService::EVT_BET_ACCEPTED, [
'order_no' => $orderNo,
'period_no' => (string) $period->period_no,
'status' => 'accepted',
'balance_after' => $after,
'total_amount' => $totalAmount,
'current_streak' => (int) ($user->current_streak ?? 0),
]);
} 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 betMyOrders(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$page = $this->intValue($request->input('page', 1));
$pageSize = $this->intValue($request->input('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 语义一致)
'bet_amount' => $item->total_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(),
],
]);
}
/**
* 下注号码:`numbers` 为逗号分隔字符串(如 `1,8,16`);兼容旧版 JSON 数组。
*
* @return list<int>
*/
private function parseBetNumbersFromRequest($numbersRaw): array
{
if (is_array($numbersRaw)) {
$out = [];
foreach ($numbersRaw as $v) {
$n = filter_var($v, FILTER_VALIDATE_INT);
if ($n === false || $n < 1 || $n > 36) {
return [];
}
$out[] = $n;
}
$out = array_values(array_unique($out));
sort($out);
return $out;
}
$raw = trim((string) $numbersRaw);
if ($raw === '') {
return [];
}
$parts = preg_split('/\s*,\s*/', $raw);
$out = [];
foreach ($parts as $p) {
if ($p === '') {
continue;
}
$n = filter_var($p, FILTER_VALIDATE_INT);
if ($n === false || $n < 1 || $n > 36) {
return [];
}
$out[] = $n;
}
$out = array_values(array_unique($out));
sort($out);
return $out;
}
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';
}
/**
* 单注最多可选号码个数:`game_config.config_key = pick_max_number_count`
*/
private function getPickMaxNumberCount(): int
{
$v = $this->intValue($this->getConfigValue('pick_max_number_count', '10'));
if ($v < 1) {
return 1;
}
if ($v > 36) {
return 36;
}
return $v;
}
private function getConfigValue(string $key, string $default): string
{
$row = GameHotDataRedis::gameConfigRow($key);
if ($row === null) {
return $default;
}
$value = $row['config_value'] ?? null;
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,6 @@ use ba\Terminal;
use ba\Filesystem;
use app\common\controller\Api;
use app\admin\model\Admin as AdminModel;
use app\admin\model\User as UserModel;
use app\process\Monitor;
use support\Response;
use Webman\Http\Request;
@@ -627,10 +626,6 @@ class Install extends Api
$adminModel->resetPassword($defaultAdmin->id, $param['adminpassword']);
}
// 默认用户密码修改
$user = new UserModel();
$user->resetPassword(1, Random::build());
// 修改站点名称
if (class_exists(\app\admin\model\Config::class)) {
\app\admin\model\Config::where('name', 'site_name')->update([
@@ -668,19 +663,20 @@ class Install extends Api
/**
* 获取安装完成后的访问地址(根据请求来源区分 API 与前端开发模式)
* - 通过 API 访问(8787index.html#/admin、index.html#/
* - 通过 API 访问(7979index#/admin、index#/(无 index 与 # 之间的斜杠)
* - 通过前端开发服务访问1818/#/admin、/#/
*/
public function accessUrls(Request $request): Response
{
$this->setRequest($request);
$host = $request->header('host', '127.0.0.1:8787');
$port = '8787';
$host = $request->header('host', '127.0.0.1:7979');
$port = '7979';
if (str_contains($host, ':')) {
$port = substr($host, strrpos($host, ':') + 1);
}
$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') {
$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->input('page', 1), 1);
$pageSize = $this->intValue($request->input('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->input('notice_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->input('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 app\common\controller\Frontend;
use app\common\facade\Token;
use support\think\Db;
use support\validation\Validator;
use support\validation\ValidationException;
use Webman\Http\Request;
@@ -46,6 +47,7 @@ class User extends Frontend
'captchaId' => $params['captchaId'] ?? '',
'captchaInfo' => $params['captchaInfo'] ?? '',
'registerType' => $params['registerType'] ?? '',
'invite_code' => $params['invite_code'] ?? '',
]);
if (!in_array($params['tab'], ['login', 'register'])) {
@@ -72,7 +74,24 @@ class User extends Frontend
if (!$captchaObj->check($params['captcha'], $params[$params['registerType']] . 'user_register')) {
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(__('Invite code does not exist'));
}
$ch = $inviterAdmin['channel_id'] ?? null;
if ($ch === null || $ch === '' || intval(trim((string) $ch)) <= 0) {
return $this->error(__('Invite code not bound to channel'));
}
$extend['register_invite_code'] = $params['invite_code'];
$extend['admin_id'] = $inviterAdmin['id'];
$extend['channel_id'] = intval(trim((string) $ch));
}
$res = $this->auth->register($params['username'], $params['password'], $params['mobile'], $params['email'], 1, $extend);
}
if ($res === true) {
@@ -81,6 +100,10 @@ class User extends Frontend
'routePath' => '/user'
]);
}
$dup = $this->auth->getRegisterDuplicateKind();
if ($params['tab'] === 'register' && ($dup === 'username' || $dup === 'email' || $dup === 'phone')) {
return $this->error(__('Account already registered'));
}
$msg = $this->auth->getError();
return $this->error($msg ?: __('Check in failed, please try again or contact the website administrator~'));
}
@@ -117,6 +140,7 @@ class User extends Frontend
'email' => 'required_if:registerType,email|email|unique:user,email',
'mobile' => 'required_if:registerType,mobile|regex:/^1[3-9]\d{9}$/|unique:user,mobile',
'captcha' => 'required|string',
'invite_code' => 'nullable|string|max:64',
],
[
'username.regex' => __('Please input correct username'),
@@ -129,6 +153,7 @@ class User extends Frontend
'password' => __('Password'),
'captcha' => __('captcha'),
'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,99 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use app\common\library\finance\WithdrawFlow;
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();
$coinBalance = WithdrawFlow::amountString($user->coin ?? '0');
$flow = WithdrawFlow::status(intval($user->id), [
'total_deposit_coin' => $user->total_deposit_coin ?? '0',
'total_withdraw_coin' => $user->total_withdraw_coin ?? '0',
'bet_flow_coin' => $user->bet_flow_coin ?? '0',
]);
$maxWithdrawable = WithdrawFlow::maxWithdrawable($coinBalance, $flow);
return $this->mobileSuccess([
'coin_balance' => $coinBalance,
'frozen_balance' => '0.0000',
'withdrawable_balance' => $coinBalance,
'max_withdrawable' => $maxWithdrawable,
'total_deposit_coin' => WithdrawFlow::amountString($user->total_deposit_coin ?? '0'),
'total_withdraw_coin' => WithdrawFlow::amountString($user->total_withdraw_coin ?? '0'),
'bet_flow_coin' => $flow['bet_flow_coin'],
'withdraw_flow' => [
'ratio' => $flow['ratio'],
'net_deposit' => $flow['net_deposit'],
'required_bet_flow' => $flow['required_bet_flow'],
'remaining_bet_flow' => $flow['remaining_bet_flow'],
'eligible' => $flow['eligible'],
'max_withdraw_by_flow' => $flow['flow_unlimited'] ? null : $flow['max_withdraw_by_flow'],
'flow_unlimited' => $flow['flow_unlimited'],
],
]);
}
public function recordList(Request $request): Response
{
$response = $this->initializeMobile($request);
if ($response !== null) {
return $response;
}
$type = trim((string) $request->input('type', 'all'));
$page = $this->intValue($request->input('page', 1), 1);
$pageSize = $this->intValue($request->input('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,40 @@ return [
'Please login first' => 'Please login first',
'You have no permission' => 'No permission to operate',
'Captcha error' => 'Captcha error!',
'ok' => 'success',
'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',
'Register only supports phone' => 'Register only supports phone',
'Invite code required' => 'Invite code is required',
'Invite code not bound to channel' => 'This invite code is not bound to a valid channel',
'Account already registered' => 'This phone number is already registered. Please sign in.',
'Please enter the correct mobile number' => 'Please enter the correct mobile number',
'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',
// Deposit / Withdraw
'Idempotency key is too long' => 'Idempotency key is too long',
'Idempotency key conflict' => 'Idempotency key conflict, please do not submit repeatedly',
'Deposit tier not available' => 'The selected deposit tier is not available',
'Order not found after settle' => 'Order not found after settlement',
'Invalid withdraw amount' => 'Invalid withdraw amount',
'Withdraw exceeds available bet flow' => 'The withdraw amount exceeds the available bet-flow quota',
'Too many pending withdraw orders' => 'You already have withdraw orders under review, please wait for them to be processed',
// Member center account
'Data updated successfully~' => 'Data updated successfully~',
'Password has been changed~' => 'Password has been changed~',

View File

@@ -44,6 +44,40 @@ return [
'Parameter error' => '参数错误!',
'Token expiration' => '登录态过期,请重新登录!',
'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' => '邀请码不存在',
'Register only supports phone' => '注册仅支持手机号',
'Invite code required' => '请填写邀请码',
'Invite code not bound to channel' => '该邀请码未绑定有效渠道',
'Account already registered' => '该手机号已注册,请直接登录',
'Please enter the correct mobile number' => '请输入正确的手机号',
'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' => '公告不存在',
// 充值 / 提现
'Idempotency key is too long' => '幂等键过长',
'Idempotency key conflict' => '幂等键冲突(请勿重复提交)',
'Deposit tier not available' => '所选充值档位不可用',
'Order not found after settle' => '充值成功后未找到订单',
'Invalid withdraw amount' => '提现金额不合法',
'Withdraw exceeds available bet flow' => '提现金额超出可提现额度',
'Too many pending withdraw orders' => '用户当前存在多笔提现订单,请等待审核',
// 会员中心 account
'Data updated successfully~' => '资料更新成功~',
'Password has been changed~' => '密码已修改~',

View File

@@ -142,14 +142,26 @@ class Backend extends Api
if ($needLogin) {
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'), [
'type' => Auth::NEED_LOGIN,
], 0, ['statusCode' => Auth::LOGIN_RESPONSE_CODE]);
], 0);
}
if ($needPermission) {
$controllerPath = $this->getControllerPath($request);
$routePath = $controllerPath . '/' . $action;
if (!$this->auth->check($routePath)) {
$routePaths = $this->buildPermissionRoutePaths($controllerPath, $action);
$pass = false;
foreach ($routePaths as $routePath) {
if ($this->auth->check($routePath)) {
$pass = true;
break;
}
}
if (!$pass) {
return $this->error(__('You have no permission'), [], 401);
}
}
@@ -167,6 +179,37 @@ class Backend extends Api
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
* @return Response|null 需直接返回时返回 Response否则 null
@@ -431,4 +474,26 @@ class Backend extends Api
{
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

@@ -36,7 +36,13 @@ class Frontend extends Api
try {
$this->auth = Auth::instance(['request' => $request]);
$token = get_auth_token(['ba', 'user', 'token'], $request);
$token = trim((string) $request->header('user-token', ''));
if ($token === '') {
$token = trim((string) $request->header('user_token', ''));
}
if ($token === '') {
$token = get_auth_token(['ba', 'user', 'token'], $request);
}
if ($token) $this->auth->init($token);
} catch (TokenExpirationException) {
if ($needLogin) return $this->error(__('Token expiration'), [], 409);

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
/**
* 后台 admin_rule.title 中文(及少数英文占位)→ 英文展示。
* 新增菜单时若标题为中文,请在此补充映射,或在库中直接使用英文标题。
*/
return [
// 通用按钮
'查看' => 'View',
'添加' => 'Add',
'编辑' => 'Edit',
'删除' => 'Delete',
'保存' => 'Save',
'快速排序' => 'Quick sort',
'还原' => 'Restore',
'回滚' => 'Rollback',
'查看详情' => 'View details',
'生成' => 'Generate',
'安装' => 'Install',
'调整状态' => 'Change state',
'卸载' => 'Uninstall',
'更新' => 'Update',
'审核通过' => 'Approve',
'审核驳回' => 'Reject',
// 安装包默认菜单
'控制台' => 'Dashboard',
'权限管理' => 'Permission management',
'角色组管理' => 'Role groups',
'管理员管理' => 'Administrators',
'菜单规则管理' => 'Menu rules',
'管理员日志管理' => 'Admin logs',
'常规管理' => 'Routine',
'系统配置' => 'System configuration',
'附件管理' => 'Attachments',
'个人资料' => 'Profile',
'数据安全管理' => 'Data security',
'数据回收站' => 'Data recycle bin',
'敏感数据修改记录' => 'Sensitive data change log',
'数据回收规则管理' => 'Data recycle rules',
'敏感字段规则管理' => 'Sensitive field rules',
'模块市场' => 'Module store',
'CRUD代码生成' => 'CRUD generator',
// 会员端 user_rule若出现在后台接口中
'我的账户' => 'My account',
'账户概览' => 'Overview',
'积分记录' => 'Score log',
'余额记录' => 'Balance log',
// 业务扩展菜单
'用户管理' => 'User management',
'用户列表' => 'User list',
'订单管理' => 'Order management',
'游戏管理' => 'Game management',
'配置管理' => 'Configuration',
'游戏配置' => 'Game configuration',
'常规配置' => 'General settings',
'记录管理' => 'Records',
'游戏对局' => 'Game rounds',
'游戏对局记录' => 'Game records',
'游戏实时对局' => 'Game live',
'压注订单' => 'Bet orders',
'充值订单' => 'Deposit orders',
'提现订单' => 'Withdraw orders',
'用户钱包流水' => 'User wallet transactions',
'36字花字典' => '36 Zihua dictionary',
'代理与结算' => 'Agents & settlement',
'代理结算周期' => 'Agent settlement periods',
'代理佣金记录' => 'Agent commission records',
'运营' => 'Operations',
'运营公告' => 'Operation notices',
'用户阅读记录' => 'User read log',
'充值档位' => 'Deposit tiers',
'连胜奖励' => 'Win streak rewards',
'连胜降低档位' => 'Streak reduction tiers',
'钱包加减点' => 'Wallet adjust',
'测试' => 'Test',
'推送-对局公共频道' => 'Push: public game period',
'推送-公告广播频道' => 'Push: operation notices',
'推送-用户私有频道' => 'Push: user private',
'渠道管理' => 'Channel management',
// 演示/运营公告标题(若入库为菜单展示)
'系统维护通知(演示)' => 'Maintenance notice (demo)',
'提现风控升级提醒(演示)' => 'Withdraw risk notice (demo)',
'活动奖励发放说明(演示)' => 'Promotion notice (demo)',
// 部分按钮 title 为动作名game/live 等迁移写入)
'index' => 'View',
'snapshot' => 'Snapshot',
'pushConfig' => 'Push config',
'recordSettings' => 'Period settings',
'createNextManual' => 'Create next period manually',
'periodSettings' => 'Period settings',
'manualSettle' => 'Manual settle',
// 其它中文按钮文案
'期号开关' => 'Period toggle',
'手动创建下一期' => 'Create next period manually',
];

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'No active game in progress' => 'No active game in progress',
'Current game status does not allow calculation' => 'Current game status does not allow calculation',
'Betting period has not ended; calculation is not available yet' => 'Betting period has not ended; calculation is not available yet',
'Manual draw number is out of the allowed range' => 'Manual draw number is out of the allowed range',
'Calculation completed' => 'Calculation completed',
'Draw number is out of the allowed range' => 'Draw number is out of the allowed range',
'Current game status does not allow scheduling the draw' => 'Current game status does not allow scheduling the draw',
'Betting has not ended; cannot schedule the draw' => 'Betting has not ended; cannot schedule the draw',
'This period has ended; please refresh the page' => 'This period has ended; please refresh the page',
'Draw number scheduled; it will be used when the countdown ends' => 'Draw number scheduled; it will be used when the countdown ends',
'Current game status does not allow drawing' => 'Current game status does not allow drawing',
'Betting period has not ended; drawing is not available yet' => 'Betting period has not ended; drawing is not available yet',
'Period countdown has not ended; cannot draw yet' => 'Period countdown has not ended; cannot draw yet',
'Draw completed; paying out' => 'Draw completed; paying out',
'Game live: settlement error' => 'Game live: settlement error',
'Calculation failed' => 'Calculation failed',
'Please enter the draw number' => 'Please enter the draw number',
'Schedule failed' => 'Schedule failed',
];

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'No active game in progress' => '未找到进行中的对局',
'Current game status does not allow calculation' => '当前对局状态不可计算',
'Betting period has not ended; calculation is not available yet' => '下注开放时长未结束,暂不可计算',
'Manual draw number is out of the allowed range' => '手动开奖号码超出允许范围',
'Calculation completed' => '计算完成',
'Draw number is out of the allowed range' => '开奖号码超出允许范围',
'Current game status does not allow scheduling the draw' => '当前对局状态不可预约开奖',
'Betting has not ended; cannot schedule the draw' => '下注尚未结束,无法预约开奖',
'This period has ended; please refresh the page' => '本期倒计时已结束,请刷新页面',
'Draw number scheduled; it will be used when the countdown ends' => '已预约本期开奖号码,倒计时结束后将使用该号码开奖',
'Current game status does not allow drawing' => '当前对局状态不可开奖',
'Betting period has not ended; drawing is not available yet' => '下注开放时长未结束,不可开奖',
'Period countdown has not ended; cannot draw yet' => '本期倒计时未结束,无法开奖',
'Draw completed; paying out' => '开奖完成,派彩中',
'Game live: settlement error' => '对局结算出错',
'Calculation failed' => '计算失败',
'Please enter the draw number' => '请填写开奖号码',
'Schedule failed' => '预约失败',
];

View File

@@ -7,6 +7,7 @@ use ba\Random;
use support\think\Db;
use app\common\model\User;
use app\common\facade\Token;
use app\common\service\GameHotDataRedis;
use Webman\Http\Request;
/**
@@ -27,7 +28,14 @@ class Auth extends \ba\Auth
protected int $keepTime = 86400;
protected int $refreshTokenKeepTime = 2592000;
protected array $allowFields = ['id', 'username', 'nickname', 'email', 'mobile', 'avatar', 'gender', 'birthday', 'money', 'score', 'join_time', 'motto', 'last_login_time', 'last_login_ip'];
/** 注册失败原因:`username`/`email` 表示账号已占用,供接口返回友好提示 */
protected string $registerDuplicateKind = '';
protected array $allowFields = [
'id', 'username', 'nickname', 'email', 'phone', 'avatar', 'gender', 'birthday',
'coin', 'channel_id', 'risk_flags', 'uuid',
'join_time', 'motto', 'last_login_time', 'last_login_ip',
];
public function __construct(array $config = [])
{
@@ -62,17 +70,18 @@ class Auth extends \ba\Auth
Token::tokenExpirationCheck($tokenData);
$userId = $tokenData['user_id'];
if ($tokenData['type'] == self::TOKEN_TYPE && $userId > 0) {
$this->model = User::where('id', $userId)->find();
$this->model = GameHotDataRedis::userModelFromCacheOrDb($userId);
if (!$this->model) {
$this->setError('Account not exist');
return false;
}
if ($this->model->status != 'enable') {
$status = $this->model->status ?? '';
if ($status === 'disable' || $status === 0 || $status === '0') {
$this->setError('Account disabled');
return false;
}
$this->token = $token;
$this->loginSuccessful();
$this->loginEd = true;
return true;
}
}
@@ -81,8 +90,9 @@ class Auth extends \ba\Auth
return false;
}
public function register(string $username, string $password = '', string $mobile = '', string $email = '', int $group = 1, array $extend = []): bool
public function register(string $username, string $password = '', string $phone = '', string $email = '', int $group = 1, array $extend = []): bool
{
$this->registerDuplicateKind = '';
$request = function_exists('request') ? request() : null;
$ip = $request ? $request->getRealIp() : '0.0.0.0';
@@ -90,18 +100,27 @@ class Auth extends \ba\Auth
$this->setError(__('Email'));
return false;
}
if ($username && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) {
$isMobileUsername = preg_match('/^1[3-9]\d{9}$/', $username) === 1;
$isNormalUsername = preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username) === 1;
if ($username && !$isMobileUsername && !$isNormalUsername) {
$this->setError(__('Username'));
return false;
}
if (User::where('email', $email)->find() && $email) {
$this->registerDuplicateKind = 'email';
$this->setError(__('Email') . ' ' . __('already exists'));
return false;
}
if (User::where('username', $username)->find()) {
$this->registerDuplicateKind = 'username';
$this->setError(__('Username') . ' ' . __('already exists'));
return false;
}
if ($phone !== '' && User::where('phone', $phone)->find()) {
$this->registerDuplicateKind = 'phone';
$this->setError(__('Mobile') . ' ' . __('already exists'));
return false;
}
$nickname = preg_replace_callback('/1[3-9]\d{9}/', fn($m) => substr($m[0], 0, 3) . '****' . substr($m[0], 7), $username);
$time = time();
@@ -112,9 +131,11 @@ class Auth extends \ba\Auth
'join_time' => $time,
'last_login_ip' => $ip,
'last_login_time' => $time,
'status' => 'enable',
'status' => 1,
'uuid' => User::generateUniquePublicCode10(),
'remark' => User::formatLoginRemark($time, $ip),
];
$data = array_merge(compact('username', 'password', 'mobile', 'email'), $data, $extend);
$data = array_merge(compact('username', 'password', 'phone', 'email'), $data, $extend);
Db::startTrans();
try {
@@ -139,7 +160,7 @@ class Auth extends \ba\Auth
public function login(string $username, string $password, bool $keep): bool
{
$accountType = false;
if (preg_match('/^1[3-9]\d{9}$/', $username)) $accountType = 'mobile';
if (preg_match('/^1[3-9]\d{9}$/', $username)) $accountType = 'phone';
elseif (filter_var($username, FILTER_VALIDATE_EMAIL)) $accountType = 'email';
elseif (preg_match('/^[a-zA-Z][a-zA-Z0-9_]{2,15}$/', $username)) $accountType = 'username';
if (!$accountType) {
@@ -152,7 +173,8 @@ class Auth extends \ba\Auth
$this->setError('Account not exist');
return false;
}
if ($this->model->status == 'disable') {
$status = $this->model->status ?? '';
if ($status === 'disable' || $status === 0 || $status === '0') {
$this->setError('Account disabled');
return false;
}
@@ -171,7 +193,7 @@ class Auth extends \ba\Auth
}
}
if (!verify_password($password, $this->model->password, ['salt' => $this->model->salt])) {
if (!verify_password($password, $this->model->password, ['salt' => $this->model->salt ?? ''])) {
$this->loginFailed();
$this->setError('Password is incorrect');
return false;
@@ -211,6 +233,7 @@ class Auth extends \ba\Auth
$this->model->login_failure = 0;
$this->model->last_login_time = time();
$this->model->last_login_ip = $ip;
$this->model->remark = User::formatLoginRemark(time(), $ip);
$this->model->save();
$this->loginEd = true;
$this->model->commit();
@@ -340,6 +363,11 @@ class Auth extends \ba\Auth
return $this->error ? __($this->error) : '';
}
public function getRegisterDuplicateKind(): string
{
return $this->registerDuplicateKind;
}
protected function reset(bool $deleteToken = true): bool
{
if ($deleteToken && $this->token) {
@@ -349,6 +377,7 @@ class Auth extends \ba\Auth
$this->loginEd = false;
$this->model = null;
$this->refreshToken = '';
$this->registerDuplicateKind = '';
$this->setError('');
$this->setKeepTime((int)config('buildadmin.user_token_keep_time', 86400));
return true;

View File

@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\library;
/**
* 游戏奖励配置:默认模板(与 resource/game_reward_config_template.json 一致)
*/
class GameRewardConfigTemplate
{
private static ?array $cached = null;
public static function templatePath(): string
{
return dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'resource' . DIRECTORY_SEPARATOR . 'game_reward_config_template.json';
}
/**
* @return array{tier_reward_form: string, bigwin_form: string}
*/
public static function getDefaultJsonColumns(): array
{
if (self::$cached !== null) {
return self::$cached;
}
$path = self::templatePath();
if (!is_file($path)) {
throw new \RuntimeException('game_reward_config_template.json missing: ' . $path);
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
throw new \RuntimeException('game_reward_config_template.json read failed');
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new \RuntimeException('game_reward_config_template.json invalid JSON');
}
$tier = $decoded['tier_reward_form'] ?? null;
$big = $decoded['bigwin_form'] ?? null;
if (!is_array($tier) || !is_array($big)) {
throw new \RuntimeException('game_reward_config_template.json missing tier_reward_form or bigwin_form');
}
$tierJson = json_encode($tier, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$bigJson = json_encode($big, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($tierJson) || !is_string($bigJson)) {
throw new \RuntimeException('game_reward_config_template encode failed');
}
self::$cached = [
'tier_reward_form' => $tierJson,
'bigwin_form' => $bigJson,
];
return self::$cached;
}
}

View File

@@ -1,273 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\library;
/**
* 按「顺/逆时针摇点落格」条数约束生成 26 格档位盘面(写入 tier_reward_form JSON
*/
final class GameRewardTierBoardGenerator
{
private const LEOPARD = [5, 10, 15, 20, 25, 30];
private static function landingCw(int $d): int
{
$start = $d - 5;
return ($start + $d) % 26;
}
/** 图二:逆时针 end = start D若小于 0 则 26 + start D */
private static function landingCcw(int $d): int
{
$start = $d - 5;
$x = $start - $d;
return $x >= 0 ? $x : 26 + $start - $d;
}
/**
* 豹子点数:该次摇取顺、逆落点档位不能为 T4、T5
*
* @param list<string> $tier
*/
private static function leopardOk(array $tier): bool
{
foreach (self::LEOPARD as $d) {
foreach ([self::landingCw($d), self::landingCcw($d)] as $idx) {
$t = $tier[$idx];
if ($t === 'T4' || $t === 'T5') {
return false;
}
}
}
return true;
}
/**
* @param array<string, mixed> $params
* @return array{tier_reward_form: string}
*/
public static function generate(array $params): array
{
$t1Cw = self::intParam($params, 't1_fixed_cw');
$t1Ccw = self::intParam($params, 't1_fixed_ccw');
$t2MinCw = self::intParam($params, 't2_min_cw');
$t2MinCcw = self::intParam($params, 't2_min_ccw');
$t4Cw = self::intParam($params, 't4_fixed_cw');
$t4Ccw = self::intParam($params, 't4_fixed_ccw');
$t5Cw = self::intParam($params, 't5_fixed_cw');
$t5Ccw = self::intParam($params, 't5_fixed_ccw');
$amt1 = self::numParam($params, 'amt_t1');
$amt2 = self::numParam($params, 'amt_t2');
$amt3 = self::numParam($params, 'amt_t3');
$amt4 = self::numParam($params, 'amt_t4');
$bestTier = null;
$bestScore = INF;
for ($attempt = 0; $attempt < 32; $attempt++) {
$tier = self::randomInitialTier($attempt);
$temp = 5.0;
for ($step = 0; $step < 8000; $step++) {
$score = self::score($tier, $t1Cw, $t1Ccw, $t2MinCw, $t2MinCcw, $t4Cw, $t4Ccw, $t5Cw, $t5Ccw);
if ($score < $bestScore) {
$bestScore = $score;
$bestTier = $tier;
}
$i = mt_rand(0, 25);
$j = mt_rand(0, 25);
if ($i === $j) {
continue;
}
$oldI = $tier[$i];
$oldJ = $tier[$j];
$tier[$i] = $oldJ;
$tier[$j] = $oldI;
if (!self::leopardOk($tier)) {
$tier[$i] = $oldI;
$tier[$j] = $oldJ;
continue;
}
$newScore = self::score($tier, $t1Cw, $t1Ccw, $t2MinCw, $t2MinCcw, $t4Cw, $t4Ccw, $t5Cw, $t5Ccw);
$delta = $newScore - $score;
$u = mt_rand() / max(1, mt_getrandmax());
if ($delta < 0 || ($temp > 0.02 && exp(-$delta / $temp) > $u)) {
// keep
} else {
$tier[$i] = $oldI;
$tier[$j] = $oldJ;
}
$temp *= 0.999;
}
}
if ($bestTier === null || $bestScore > 45) {
throw new \RuntimeException('无法在豹子与条数约束下收敛盘面,请调整条数后重试');
}
$json = self::buildJson($bestTier, $amt1, $amt2, $amt3, $amt4);
return ['tier_reward_form' => $json];
}
/** @return list<string> */
private static function randomInitialTier(int $seedBias): array
{
mt_srand((int) (microtime(true) * 1000000) + $seedBias * 10007);
$tier = [];
for ($p = 0; $p < 26; $p++) {
$tier[$p] = ['T1', 'T2', 'T3'][mt_rand(0, 2)];
}
if (!self::leopardOk($tier)) {
for ($p = 0; $p < 26; $p++) {
$tier[$p] = 'T3';
}
}
return $tier;
}
/**
* @param list<string> $tier
*/
private static function score(
array $tier,
int $t1Cw,
int $t1Ccw,
int $t2MinCw,
int $t2MinCcw,
int $t4Cw,
int $t4Ccw,
int $t5Cw,
int $t5Ccw
): float {
if (!self::leopardOk($tier)) {
return 1e9;
}
$h = self::histogram($tier);
$s = 0.0;
$s += ($h['cw']['T1'] - $t1Cw) ** 2;
$s += ($h['ccw']['T1'] - $t1Ccw) ** 2;
$s += max(0, $t2MinCw - $h['cw']['T2']) ** 2 * 8;
$s += max(0, $t2MinCcw - $h['ccw']['T2']) ** 2 * 8;
$s += ($h['cw']['T4'] - $t4Cw) ** 2;
$s += ($h['ccw']['T4'] - $t4Ccw) ** 2;
$s += ($h['cw']['T5'] - $t5Cw) ** 2;
$s += ($h['ccw']['T5'] - $t5Ccw) ** 2;
return $s;
}
/**
* @param list<string> $tier
* @return array{cw: array<string, int>, ccw: array<string, int>}
*/
private static function histogram(array $tier): array
{
$cw = ['T1' => 0, 'T2' => 0, 'T3' => 0, 'T4' => 0, 'T5' => 0];
$ccw = ['T1' => 0, 'T2' => 0, 'T3' => 0, 'T4' => 0, 'T5' => 0];
for ($d = 5; $d <= 30; $d++) {
$cw[$tier[self::landingCw($d)]]++;
$ccw[$tier[self::landingCcw($d)]]++;
}
return ['cw' => $cw, 'ccw' => $ccw];
}
/**
* @param list<string> $tier
*/
private static function remarkForTier(string $t, float $amt2): string
{
if ($t === 'T1') {
return '大奖';
}
if ($t === 'T2') {
return $amt2 < 100 ? '完美回本' : '小赚';
}
if ($t === 'T3') {
return '抽水';
}
if ($t === 'T4') {
return '惩罚';
}
if ($t === 'T5') {
return '再来一次';
}
return '';
}
private static function buildJson(array $tier, float $amt1, float $amt2, float $amt3, float $amt4): string
{
$rows = [];
for ($i = 0; $i < 26; $i++) {
$t = $tier[$i];
if ($t === 'T5') {
$ui = '再来一次';
$uiEn = 'Once again';
$ev = '0';
} else {
$a = match ($t) {
'T1' => $amt1,
'T2' => $amt2,
'T3' => $amt3,
'T4' => $amt4,
default => $amt3,
};
$ui = self::fmtMoney($a);
$uiEn = self::fmtMoney($a);
$ev = self::fmtMoney($a);
}
$rows[] = [
'grid_number' => strval(5 + $i),
'ui_text' => $ui,
'ui_text_en' => $uiEn,
'real_ev' => $ev,
'tier' => $t,
'remark' => self::remarkForTier($t, $amt2),
];
}
return json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
private static function fmtMoney(float $v): string
{
if (abs($v - round($v)) < 0.000001) {
return strval((int) round($v));
}
return rtrim(rtrim(sprintf('%.4f', $v), '0'), '.');
}
private static function intParam(array $params, string $key): int
{
$v = $params[$key] ?? 0;
if (is_string($v) && trim($v) === '') {
return 0;
}
if (!is_numeric($v)) {
return 0;
}
return intval(strval($v));
}
private static function numParam(array $params, string $key): float
{
$v = $params[$key] ?? 0;
if (is_string($v) && trim($v) === '') {
return 0.0;
}
if (!is_numeric($v)) {
return 0.0;
}
return floatval(strval($v));
}
}

View File

@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\library;
use support\think\Db;
use Throwable;
/**
* 根据档位奖励 JSON 生成 game_reward_weight 对照(先删后插)
*/
final class GameRewardWeightSeeder
{
private const LEOPARD = [5, 10, 15, 20, 25, 30];
/**
* @throws Throwable
*/
public static function syncFromTierRewardForm(int $gameChannelId, string $tierRewardFormJson): void
{
$decoded = json_decode($tierRewardFormJson, true);
if (!is_array($decoded) || count($decoded) !== 26) {
throw new \RuntimeException('档位奖励表单必须为 26 条且为合法 JSON');
}
$byGrid = [];
foreach ($decoded as $idx => $row) {
if (!is_array($row)) {
throw new \RuntimeException('档位奖励表单第' . strval($idx + 1) . '条格式错误');
}
$g = $row['grid_number'] ?? null;
if (!is_numeric($g)) {
throw new \RuntimeException('档位奖励表单第' . strval($idx + 1) . '条点数无效');
}
$gi = intval(strval($g));
if ($gi < 5 || $gi > 30) {
throw new \RuntimeException('档位奖励表单点数须在 530');
}
$byGrid[$gi] = $row;
}
$cells = [];
for ($i = 0; $i < 26; $i++) {
$g = 5 + $i;
if (!isset($byGrid[$g])) {
throw new \RuntimeException('档位奖励表单缺少点数 ' . strval($g));
}
$cells[$i] = self::normalizeCell($byGrid[$g], $i + 1);
}
self::assertLeopardOk($cells);
$batch = [];
for ($d = 5; $d <= 30; $d++) {
$start = $d - 5;
$endCw = ($start + $d) % 26;
$x = $start - $d;
$endCcw = $x >= 0 ? $x : 26 + $start - $d;
$batch[] = self::buildInsertRow($gameChannelId, 0, $d, $start, $endCw, $cells[$endCw]);
$batch[] = self::buildInsertRow($gameChannelId, 1, $d, $start, $endCcw, $cells[$endCcw]);
}
$now = time();
foreach ($batch as $k => $_) {
$batch[$k]['create_time'] = $now;
$batch[$k]['update_time'] = $now;
}
Db::startTrans();
try {
Db::name('game_reward_weight')->where('game_channel_id', $gameChannelId)->delete();
Db::name('game_reward_weight')->insertAll($batch);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
$msg = $e->getMessage();
if (str_contains($msg, 'game_reward_weight') || str_contains($msg, "doesn't exist")) {
throw new \RuntimeException('写入失败:请确认已创建数据表 game_reward_weight 并已执行迁移。' . $msg);
}
throw $e;
}
}
/**
* @param list<array{ui_text: string, real_ev: float, tier: string, remark: string}> $cells
*/
private static function assertLeopardOk(array $cells): void
{
foreach (self::LEOPARD as $d) {
$start = $d - 5;
$endCw = ($start + $d) % 26;
$x = $start - $d;
$endCcw = $x >= 0 ? $x : 26 + $start - $d;
foreach ([$endCw, $endCcw] as $idx) {
$t = $cells[$idx]['tier'];
if ($t === 'T4' || $t === 'T5') {
throw new \RuntimeException(
'豹子点数 ' . strval($d) . ' 的落点不能为 T4/T5请先在档位表中调整后再生成权重对照'
);
}
}
}
}
/**
* @param array<string, mixed> $row
* @return array{ui_text: string, real_ev: float, tier: string, remark: string}
*/
private static function normalizeCell(array $row, int $rowNo): array
{
$ui = $row['ui_text'] ?? null;
$ev = $row['real_ev'] ?? null;
$tier = $row['tier'] ?? null;
if (!is_string($ui) || trim($ui) === '') {
throw new \RuntimeException('档位奖励表单第' . strval($rowNo) . '条显示文本不能为空');
}
if ($ev === null || $ev === '' || !is_numeric($ev)) {
throw new \RuntimeException('档位奖励表单第' . strval($rowNo) . '条实际中奖无效');
}
if (!is_string($tier) || !in_array($tier, ['T1', 'T2', 'T3', 'T4', 'T5'], true)) {
throw new \RuntimeException('档位奖励表单第' . strval($rowNo) . '条档位无效');
}
$remark = $row['remark'] ?? '';
$remarkStr = is_string($remark) ? $remark : '';
return [
'ui_text' => $ui,
'real_ev' => floatval(strval($ev)),
'tier' => $tier,
'remark' => $remarkStr,
];
}
/**
* @param array{ui_text: string, real_ev: float, tier: string, remark: string} $cell
* @return array<string, int|float|string>
*/
private static function buildInsertRow(
int $gameChannelId,
int $direction,
int $gridNumber,
int $startIndex,
int $endIndex,
array $cell
): array {
return [
'game_channel_id' => $gameChannelId,
'direction' => $direction,
'grid_number' => $gridNumber,
'start_index' => $startIndex,
'end_index' => $endIndex,
'ui_text' => $cell['ui_text'],
'real_ev' => $cell['real_ev'],
'tier' => $cell['tier'],
'type' => self::tierToType($cell['tier']),
'remark' => $cell['remark'],
'weight' => 1,
];
}
private static function tierToType(string $tier): int
{
return match ($tier) {
'T1' => 3,
'T2' => 2,
'T3' => -1,
'T4' => -2,
'T5' => 1,
};
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace app\common\library\admin;
/**
* 后台推送测试页:读取 webman/push 配置(与 game/Live::pushConfig 口径一致)
*/
final class PushChannelConfigHelper
{
public static function wsBaseUrl(): string
{
$ws = (string) config('plugin.webman.push.app.websocket');
$ws = str_replace('websocket://', 'ws://', $ws);
return str_replace('0.0.0.0', '127.0.0.1', $ws);
}
public static function appKey(): string
{
return (string) config('plugin.webman.push.app.app_key');
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace app\common\library\finance;
use RuntimeException;
use support\think\Db;
use Throwable;
/**
* 充值订单结算公共库
*
* 所有"把 deposit_order 变为成功并给玩家钱包加币"的逻辑必须收敛到这里,
* 以便 mock 支付瞬时成功、未来第三方网关回调、历史数据的人工补单共用同一事务边界。
*
* 关键约束:
* - 只结算 status=0 的订单,幂等;重复调用同一订单返回现有结算结果;
* - 钱包流水 user_wallet_record 以 "deposit_settle_{order_no}" 为 idempotency_key保证不重复入账
* - 同时更新 user.coin 与 update_timeuser_wallet_record 记录 balance_before/after 快照。
*/
final class DepositSettlement
{
public const SOURCE_MOCK_GATEWAY = 'mock_gateway';
public const SOURCE_ADMIN_APPROVE = 'admin_approve';
public const SOURCE_THIRD_PARTY = 'third_party';
/**
* 结算指定订单。
*
* @param int $orderId deposit_order.id
* @param string $source 来源SOURCE_* 常量),写入 remark
* @param string $sourceLabel 人类可读描述,写入 remark如 "mock gateway auto settled"
* @param int|null $operatorAdminId 操作管理员 ID仅管理员审核时有值
* @param string|null $extraRemark 追加到订单 remark可选
*
* @return array{
* order_id: int,
* order_no: string,
* amount: string,
* balance_before: string,
* balance_after: string,
* pay_time: int,
* already_settled: bool,
* }
*
* @throws RuntimeException 订单不存在、金额非法、并发冲突等
*/
public static function settle(
int $orderId,
string $source,
string $sourceLabel,
?int $operatorAdminId = null,
?string $extraRemark = null
): array {
if ($orderId <= 0) {
throw new RuntimeException('订单 ID 非法');
}
$order = Db::name('deposit_order')->where('id', $orderId)->find();
if (!$order) {
throw new RuntimeException('订单不存在');
}
$orderNo = is_string($order['order_no']) ? $order['order_no'] : strval($order['order_no']);
if ($orderNo === '') {
throw new RuntimeException('订单号为空');
}
$statusRaw = $order['status'] ?? 0;
$status = is_numeric($statusRaw) ? intval($statusRaw) : 0;
// 如果已结算,直接返回已有结果(幂等)
if ($status === 1) {
$userId = is_numeric($order['user_id'] ?? null) ? intval($order['user_id']) : 0;
$coinAfter = '0.0000';
if ($userId > 0) {
$coin = Db::name('user')->where('id', $userId)->value('coin');
$coinAfter = is_string($coin) ? $coin : strval($coin);
}
$amt = self::amountString($order['amount'] ?? '0');
$bns = self::amountString($order['bonus_amount'] ?? '0');
return [
'order_id' => $orderId,
'order_no' => $orderNo,
'amount' => $amt,
'bonus_amount' => $bns,
'credit' => bcadd($amt, $bns, 4),
'balance_before' => $coinAfter,
'balance_after' => $coinAfter,
'pay_time' => is_numeric($order['pay_time'] ?? null) ? intval($order['pay_time']) : 0,
'already_settled' => true,
];
}
if ($status !== 0) {
throw new RuntimeException('订单状态不允许结算');
}
$amount = self::amountString($order['amount'] ?? '0');
if (bccomp($amount, '0', 4) <= 0) {
throw new RuntimeException('订单金额异常');
}
$bonus = self::amountString($order['bonus_amount'] ?? '0');
if (bccomp($bonus, '0', 4) < 0) {
$bonus = '0.0000';
}
$credit = bcadd($amount, $bonus, 4);
$userId = is_numeric($order['user_id'] ?? null) ? intval($order['user_id']) : 0;
if ($userId <= 0) {
throw new RuntimeException('订单所属玩家无效');
}
$user = Db::name('user')->where('id', $userId)->find();
if (!$user) {
throw new RuntimeException('玩家不存在');
}
$channelId = is_numeric($order['channel_id'] ?? null) ? intval($order['channel_id']) : null;
$balanceBefore = self::amountString($user['coin'] ?? '0');
$balanceAfter = bcadd($balanceBefore, $credit, 4);
$now = time();
$baseRemark = is_string($order['remark'] ?? null) ? $order['remark'] : '';
// 备注包含充值与赠送的明细,方便后续稽核
$detail = sprintf('amount=%s,bonus=%s,credit=%s', $amount, $bonus, $credit);
$note = sprintf('[%s] %s (%s)', $source, $sourceLabel, $detail);
$combined = $baseRemark === '' ? $note : ($baseRemark . ' | ' . $note);
if ($extraRemark !== null && $extraRemark !== '') {
$combined .= ' | ' . $extraRemark;
}
$finalRemark = mb_substr($combined, 0, 255);
$walletIdem = 'deposit_settle_' . $orderNo;
Db::startTrans();
try {
$affected = Db::name('deposit_order')
->where('id', $orderId)
->where('status', 0)
->update([
'status' => 1,
'pay_time' => $now,
'review_admin_id' => $operatorAdminId,
'review_time' => $operatorAdminId !== null ? $now : null,
'remark' => $finalRemark,
'update_time' => $now,
]);
if ($affected <= 0) {
throw new RuntimeException('订单状态已变更,请刷新后重试');
}
Db::name('user')->where('id', $userId)->update([
'coin' => $balanceAfter,
'update_time' => $now,
]);
$walletExists = Db::name('user_wallet_record')
->where('idempotency_key', $walletIdem)
->value('id');
if (!$walletExists) {
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'deposit',
'direction' => 1,
'amount' => $credit,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'ref_type' => 'deposit_order',
'ref_id' => $orderId,
'idempotency_key' => $walletIdem,
'operator_admin_id' => $operatorAdminId,
'remark' => mb_substr($note, 0, 500),
'create_time' => $now,
]);
}
Db::commit();
} catch (Throwable $e) {
Db::rollback();
throw new RuntimeException($e->getMessage());
}
return [
'order_id' => $orderId,
'order_no' => $orderNo,
'amount' => $amount,
'bonus_amount' => $bonus,
'credit' => $credit,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'pay_time' => $now,
'already_settled' => false,
];
}
/**
* 将任意数值输入格式化为 4 位小数字符串(不做强制类型转换)
*/
private static function amountString($raw): string
{
if (is_string($raw)) {
$s = trim($raw);
} elseif (is_int($raw) || is_float($raw)) {
$s = strval($raw);
} else {
return '0.0000';
}
if (!is_numeric($s)) {
return '0.0000';
}
return bcadd($s, '0', 4);
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace app\common\library\finance;
use app\common\service\GameHotDataRedis;
/**
* 提现打码量(流水)门槛工具库
*
* 业务口径(打码量即提现配额模型):
* - 每笔提现消耗等额打码配额:折算 = withdraw_coin × ratio
* - lifetime_withdrawable_from_flow = bet_flow_coin / ratio
* - max_withdraw_by_flow = max(0, lifetime_withdrawable_from_flow - total_withdraw_coin)
* - 单笔上限max_withdrawable = min(coin_balance, max_withdraw_by_flow)
* - ratio 来自 game_config.withdraw_bet_flow_ratioratio = 0 代表不限制打码量,此时
* max_withdraw_by_flow 视为"无限大"(由 UNLIMITED_FLOW 哨兵值表示API 层兜底用余额)
*
* 向后兼容:原门槛 bet_flow_coin >= (total_deposit - total_withdraw) × ratio 已被
* "单笔上限 ≤ max_withdraw_by_flow" 取代且语义等价更细腻:任何通过新校验的请求必然
* 也满足旧门槛口径。字段 required_bet_flow / remaining_bet_flow / eligible 保留仅作展示。
*/
final class WithdrawFlow
{
public const CONFIG_KEY = 'withdraw_bet_flow_ratio';
public const DEFAULT_RATIO = '1.0000';
/** 当 ratio = 0不限打码max_withdraw_by_flow 用此哨兵表示"无限"。14 位整数位足够覆盖任何业务金额。 */
public const UNLIMITED_FLOW = '99999999999999.9999';
/** 单用户最多允许同时存在的「待审核」(withdraw_order.status=0) 提现订单数。 */
public const MAX_PENDING_WITHDRAW = 3;
/**
* 读取当前打码倍数(字符串 4 位小数,至少 0
*/
public static function ratio(): string
{
$row = GameHotDataRedis::gameConfigRow(self::CONFIG_KEY);
if (!$row) {
return self::DEFAULT_RATIO;
}
$val = $row['config_value'] ?? '';
if (!is_string($val) || trim($val) === '' || !is_numeric(trim($val))) {
return self::DEFAULT_RATIO;
}
$normalized = bcadd(trim($val), '0', 4);
if (bccomp($normalized, '0', 4) < 0) {
return '0.0000';
}
return $normalized;
}
/**
* 归一化金额字段到 4 位小数字符串,非法输入返回 '0.0000'
*/
public static function amountString($raw): string
{
if ($raw === null || $raw === '') {
return '0.0000';
}
if (is_string($raw)) {
$s = trim($raw);
} elseif (is_int($raw) || is_float($raw)) {
$s = strval($raw);
} else {
return '0.0000';
}
if (!is_numeric($s)) {
return '0.0000';
}
return bcadd($s, '0', 4);
}
/**
* 核算玩家当前打码量状态
*
* @param array{
* total_deposit_coin?: mixed,
* total_withdraw_coin?: mixed,
* bet_flow_coin?: mixed,
* }|null $userSnapshot 允许外部传入字典(节省一次查询);为 null 时按 $userId 从库取
*
* @return array{
* ratio: string,
* net_deposit: string,
* required_bet_flow: string,
* bet_flow_coin: string,
* remaining_bet_flow: string,
* eligible: bool,
* max_withdraw_by_flow: string,
* flow_unlimited: bool,
* }
*/
public static function status(?int $userId, ?array $userSnapshot = null): array
{
if ($userSnapshot === null && $userId !== null) {
$userSnapshot = Db::name('user')
->field(['total_deposit_coin', 'total_withdraw_coin', 'bet_flow_coin'])
->where('id', $userId)
->find();
}
$userSnapshot = is_array($userSnapshot) ? $userSnapshot : [];
$deposit = self::amountString($userSnapshot['total_deposit_coin'] ?? '0');
$withdraw = self::amountString($userSnapshot['total_withdraw_coin'] ?? '0');
$flow = self::amountString($userSnapshot['bet_flow_coin'] ?? '0');
$net = bcsub($deposit, $withdraw, 4);
if (bccomp($net, '0', 4) < 0) {
$net = '0.0000';
}
$ratio = self::ratio();
$required = bcmul($net, $ratio, 4);
$remaining = bcsub($required, $flow, 4);
if (bccomp($remaining, '0', 4) < 0) {
$remaining = '0.0000';
}
$eligible = bccomp($flow, $required, 4) >= 0;
// max_withdraw_by_flow = max(0, bet_flow_coin / ratio - total_withdraw_coin)
$unlimited = bccomp($ratio, '0', 4) === 0;
if ($unlimited) {
$maxByFlow = self::UNLIMITED_FLOW;
} else {
$lifetime = bcdiv($flow, $ratio, 4);
$maxByFlow = bcsub($lifetime, $withdraw, 4);
if (bccomp($maxByFlow, '0', 4) < 0) {
$maxByFlow = '0.0000';
}
}
return [
'ratio' => $ratio,
'net_deposit' => $net,
'required_bet_flow' => $required,
'bet_flow_coin' => $flow,
'remaining_bet_flow' => $remaining,
'eligible' => $eligible,
'max_withdraw_by_flow' => $maxByFlow,
'flow_unlimited' => $unlimited,
];
}
/**
* 取单笔最大可提现额 = min(coin_balance, max_withdraw_by_flow)。
* 返回值为 4 位小数字符串,已与 ratio=0不限逻辑兼容。
*/
public static function maxWithdrawable(string $coinBalance, array $flowStatus): string
{
$coin = self::amountString($coinBalance);
if (bccomp($coin, '0', 4) < 0) {
$coin = '0.0000';
}
if (!empty($flowStatus['flow_unlimited'])) {
return $coin;
}
$byFlow = self::amountString($flowStatus['max_withdraw_by_flow'] ?? '0');
return bccomp($coin, $byFlow, 4) <= 0 ? $coin : $byFlow;
}
}

View File

@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
namespace app\common\library\game;
use InvalidArgumentException;
/**
* 充值档位game_config.deposit_tier仅存 JSON 数组
*
* 每一项字段mock/第三方支付模式,已不再保存收款账户信息;支持中英文双语):
* - id : string档位稳定 ID如 t_xxxxxxxx
* - title : string档位中文名称必填前端中文环境展示
* - title_en : string档位英文名称可选前端英文环境展示为空时回退到 title
* - amount : string充值金额4 位小数)
* - bonus_amount : string赠送金额4 位小数,可为 0
* - desc : string档位中文描述可空<=255
* - desc_en : string档位英文描述可空<=255为空时回退到 desc
* - sort : int排序权重小值在前
* - status : int0=停用1=启用
*
* 历史数据兼容:老字段 name 会在 title 缺失时作为 title 兜底(更老的 account_name 亦会兜底)。
*/
final class DepositTier
{
public const CONFIG_KEY = 'deposit_tier';
/**
* 从 game_config.config_value 中解析出档位数组(容错)
*
* @return list<array{
* id: string,
* title: string,
* title_en: string,
* amount: string,
* bonus_amount: string,
* desc: string,
* desc_en: string,
* sort: int,
* status: int,
* }>
*/
public static function parseFromConfigValue($raw): array
{
if (!is_string($raw) || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
if (isset($decoded['tiers']) && is_array($decoded['tiers'])) {
$list = $decoded['tiers'];
} else {
$list = $decoded;
}
return self::normalizeList($list);
}
/**
* @param list<mixed> $items
*/
public static function normalizeList(array $items): array
{
$out = [];
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$id = isset($row['id']) && is_string($row['id']) ? trim($row['id']) : '';
if ($id === '') {
$id = self::generateId();
}
$title = self::stringField($row, 'title');
if ($title === '') {
// 兼容历史:字段名 name 或更老的 account_name
$title = self::stringField($row, 'name');
if ($title === '') {
$title = self::stringField($row, 'account_name');
}
}
$titleEn = self::stringField($row, 'title_en');
$amount = self::normalizeAmount($row['amount'] ?? '');
$bonus = self::normalizeAmount($row['bonus_amount'] ?? '0');
$desc = self::stringField($row, 'desc');
if ($desc === '') {
$desc = self::stringField($row, 'remark');
}
$descEn = self::stringField($row, 'desc_en');
$sort = isset($row['sort']) && is_numeric($row['sort']) ? intval($row['sort']) : 0;
$status = isset($row['status']) && is_numeric($row['status']) ? intval($row['status']) : 1;
$status = $status === 1 ? 1 : 0;
$out[] = [
'id' => $id,
'title' => $title,
'title_en' => $titleEn,
'amount' => $amount,
'bonus_amount' => $bonus,
'desc' => $desc,
'desc_en' => $descEn,
'sort' => $sort,
'status' => $status,
];
}
usort($out, static function (array $a, array $b): int {
if ($a['sort'] !== $b['sort']) {
return $a['sort'] <=> $b['sort'];
}
$ida = is_string($a['id']) ? $a['id'] : '';
$idb = is_string($b['id']) ? $b['id'] : '';
return strcmp($ida, $idb);
});
return $out;
}
/**
* 校验 POST 数据并输出用于入库的清洁数据
*
* @param list<array<string, mixed>> $items
*
* @throws InvalidArgumentException
*/
public static function prepareItemsForSave(array $items): array
{
$seenId = [];
$out = [];
foreach ($items as $idx => $row) {
$no = $idx + 1;
if (!is_array($row)) {
throw new InvalidArgumentException('第 ' . $no . ' 行格式错误');
}
$id = isset($row['id']) && is_string($row['id']) ? trim($row['id']) : '';
if ($id === '') {
$id = self::generateId();
}
if (!preg_match('/^[a-zA-Z0-9_\-]{1,32}$/', $id)) {
throw new InvalidArgumentException('第 ' . $no . ' 行 ID 非法');
}
if (isset($seenId[$id])) {
throw new InvalidArgumentException('档位 ID 重复:' . $id);
}
$seenId[$id] = true;
$title = self::stringField($row, 'title');
if ($title === '') {
// 兼容上游(例如自动迁移脚本)传递历史 name 字段
$title = self::stringField($row, 'name');
}
if ($title === '') {
throw new InvalidArgumentException('第 ' . $no . ' 行中文充值名称不能为空');
}
if (mb_strlen($title) > 64) {
throw new InvalidArgumentException('第 ' . $no . ' 行中文充值名称过长');
}
$titleEn = self::stringField($row, 'title_en');
if (mb_strlen($titleEn) > 64) {
throw new InvalidArgumentException('第 ' . $no . ' 行英文充值名称过长');
}
$amount = self::normalizeAmount($row['amount'] ?? '');
if (bccomp($amount, '0', 4) <= 0) {
throw new InvalidArgumentException('第 ' . $no . ' 行充值金额必须大于 0');
}
$bonus = self::normalizeAmount($row['bonus_amount'] ?? '0');
if (bccomp($bonus, '0', 4) < 0) {
throw new InvalidArgumentException('第 ' . $no . ' 行赠送金额不能为负数');
}
$desc = self::stringField($row, 'desc');
if (mb_strlen($desc) > 255) {
throw new InvalidArgumentException('第 ' . $no . ' 行中文描述过长');
}
$descEn = self::stringField($row, 'desc_en');
if (mb_strlen($descEn) > 255) {
throw new InvalidArgumentException('第 ' . $no . ' 行英文描述过长');
}
$sort = isset($row['sort']) && is_numeric($row['sort']) ? intval($row['sort']) : 0;
$statusRaw = isset($row['status']) && is_numeric($row['status']) ? intval($row['status']) : 1;
$status = $statusRaw === 1 ? 1 : 0;
$out[] = [
'id' => $id,
'title' => $title,
'title_en' => $titleEn,
'amount' => $amount,
'bonus_amount' => $bonus,
'desc' => $desc,
'desc_en' => $descEn,
'sort' => $sort,
'status' => $status,
];
}
usort($out, static function (array $a, array $b): int {
if ($a['sort'] !== $b['sort']) {
return $a['sort'] <=> $b['sort'];
}
$ida = is_string($a['id']) ? $a['id'] : '';
$idb = is_string($b['id']) ? $b['id'] : '';
return strcmp($ida, $idb);
});
return $out;
}
/**
* @param list<array<string, mixed>> $items
*/
public static function encodeForDb(array $items): string
{
$encoded = json_encode($items, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new InvalidArgumentException('JSON 编码失败');
}
return $encoded;
}
/**
* 过滤出启用档位并按 sort 升序,供移动端选择
*/
public static function publicList(array $items): array
{
$enabled = array_values(array_filter($items, static function (array $row): bool {
if (!isset($row['status'])) {
return false;
}
$val = is_numeric($row['status']) ? intval($row['status']) : 0;
return $val === 1;
}));
usort($enabled, static function (array $a, array $b): int {
$sa = isset($a['sort']) && is_numeric($a['sort']) ? intval($a['sort']) : 0;
$sb = isset($b['sort']) && is_numeric($b['sort']) ? intval($b['sort']) : 0;
if ($sa !== $sb) {
return $sa <=> $sb;
}
$ida = isset($a['id']) && is_string($a['id']) ? $a['id'] : '';
$idb = isset($b['id']) && is_string($b['id']) ? $b['id'] : '';
return strcmp($ida, $idb);
});
return $enabled;
}
/**
* 按 ID 从档位列表中取出指定档位;未找到返回 null
*/
public static function findById(array $items, string $id): ?array
{
foreach ($items as $row) {
if (!is_array($row)) {
continue;
}
$rid = $row['id'] ?? '';
if (is_string($rid) && $rid === $id) {
return $row;
}
}
return null;
}
/**
* 根据语言选择档位对外展示的 title/desc。
*
* @param array<string, mixed> $item
* @return array{title: string, desc: string}
*/
public static function localize(array $item, string $lang): array
{
$title = self::stringField($item, 'title');
$titleEn = self::stringField($item, 'title_en');
$desc = self::stringField($item, 'desc');
$descEn = self::stringField($item, 'desc_en');
$isEn = self::isEnglishLang($lang);
$pickedTitle = $isEn ? ($titleEn !== '' ? $titleEn : $title) : ($title !== '' ? $title : $titleEn);
$pickedDesc = $isEn ? ($descEn !== '' ? $descEn : $desc) : ($desc !== '' ? $desc : $descEn);
return [
'title' => $pickedTitle,
'desc' => $pickedDesc,
];
}
/**
* 生成 10 位稳定 IDt_ + 8 位随机 base32
*/
public static function generateId(): string
{
$chars = 'abcdefghijkmnpqrstuvwxyz23456789';
$len = strlen($chars);
$id = 't_';
for ($i = 0; $i < 8; $i++) {
$id .= $chars[random_int(0, $len - 1)];
}
return $id;
}
/**
* 将金额归一化为 4 位小数字符串;非法输入返回 '0.0000'
*/
public static function normalizeAmount($raw): string
{
if ($raw === null || $raw === '') {
return '0.0000';
}
if (is_string($raw)) {
$s = trim($raw);
} elseif (is_int($raw) || is_float($raw)) {
$s = strval($raw);
} else {
return '0.0000';
}
$s = str_replace(',', '.', $s);
if (!is_numeric($s)) {
return '0.0000';
}
return bcadd($s, '0', 4);
}
/**
* 从数组取字符串字段并 trim非字符串返回空串
*
* @param array<string, mixed> $row
*/
private static function stringField(array $row, string $key): string
{
if (!isset($row[$key])) {
return '';
}
$v = $row[$key];
return is_string($v) ? trim($v) : '';
}
private static function isEnglishLang(string $lang): bool
{
$normalized = strtolower(str_replace('_', '-', trim($lang)));
if ($normalized === '') {
return false;
}
return $normalized === 'en' || str_starts_with($normalized, 'en-');
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace app\common\library\game;
use app\common\service\GameHotDataRedis;
/**
* 连胜奖励game_config.streak_win_reward按「连胜档位」110 配置赔率系数与是否大奖。
*
* 派彩total_amount × odds_factor与后台「连胜奖励」表一致不再额外 ×33
*/
final class StreakWinReward
{
public const CONFIG_KEY = 'streak_win_reward';
/** @var list<array{streak: int, odds_factor: int, is_jackpot: bool}>|null */
private static ?array $cache = null;
public static function clearCache(): void
{
self::$cache = null;
}
/**
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
*/
public static function defaultRows(): array
{
$out = [];
for ($s = 1; $s <= 10; $s++) {
$out[] = [
'streak' => $s,
'odds_factor' => $s,
'is_jackpot' => $s === 10,
];
}
return $out;
}
/**
* @param mixed $raw
*
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
*/
public static function parseFromConfigValue($raw): array
{
if (!is_string($raw) || trim($raw) === '') {
return self::defaultRows();
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return self::defaultRows();
}
$list = $decoded['rows'] ?? $decoded;
if (!is_array($list)) {
return self::defaultRows();
}
$byStreak = [];
foreach ($list as $row) {
if (!is_array($row)) {
continue;
}
$streak = isset($row['streak']) && is_numeric($row['streak']) ? (int) $row['streak'] : 0;
if ($streak < 1 || $streak > 10) {
continue;
}
$factor = isset($row['odds_factor']) && is_numeric($row['odds_factor']) ? (int) $row['odds_factor'] : $streak;
if ($factor < 1) {
$factor = 1;
}
$jack = !empty($row['is_jackpot']);
$byStreak[$streak] = [
'streak' => $streak,
'odds_factor' => $factor,
'is_jackpot' => $jack,
];
}
$out = [];
for ($s = 1; $s <= 10; $s++) {
$out[] = $byStreak[$s] ?? [
'streak' => $s,
'odds_factor' => $s,
'is_jackpot' => $s === 10,
];
}
return $out;
}
/**
* 从库加载并缓存
*
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
*/
public static function loadRows(): array
{
if (self::$cache !== null) {
return self::$cache;
}
$row = GameHotDataRedis::gameConfigRow(self::CONFIG_KEY);
self::$cache = self::parseFromConfigValue($row['config_value'] ?? null);
return self::$cache;
}
/**
* streak_at_bet 为下注时快照0 表示尚未连胜);档位取 min(streak_at_bet+1, 10)。
*/
public static function levelFromStreakAtBet(int $streakAtBet): int
{
$level = $streakAtBet + 1;
if ($level < 1) {
$level = 1;
}
if ($level > 10) {
$level = 10;
}
return $level;
}
/**
* @return array{streak: int, odds_factor: int, is_jackpot: bool}
*/
public static function rowForStreakAtBet(int $streakAtBet): array
{
$level = self::levelFromStreakAtBet($streakAtBet);
foreach (self::loadRows() as $row) {
if ((int) ($row['streak'] ?? 0) === $level) {
return $row;
}
}
return [
'streak' => $level,
'odds_factor' => $level,
'is_jackpot' => $level === 10,
];
}
public static function isJackpotForStreakAtBet(int $streakAtBet): bool
{
return self::rowForStreakAtBet($streakAtBet)['is_jackpot'] === true;
}
/**
* 返回该注单适用的「赔率乘数」字符串(= 配置档位的 odds_factor供 bcmul(total_amount, ..., 4)。
*/
public static function totalOddsMultiplierForStreakAtBet(int $streakAtBet): string
{
$factor = (int) self::rowForStreakAtBet($streakAtBet)['odds_factor'];
if ($factor < 1) {
$factor = 1;
}
return bcadd((string) $factor, '0', 4);
}
/**
* @param list<array{streak: int, odds_factor: int, is_jackpot: bool}> $rows
*/
public static function encodeForDb(array $rows): string
{
$normalized = [];
for ($s = 1; $s <= 10; $s++) {
$found = null;
foreach ($rows as $r) {
if (!is_array($r)) {
continue;
}
$st = isset($r['streak']) && is_numeric($r['streak']) ? (int) $r['streak'] : 0;
if ($st === $s) {
$found = $r;
break;
}
}
if ($found === null) {
$normalized[] = [
'streak' => $s,
'odds_factor' => $s,
'is_jackpot' => $s === 10,
];
} else {
$f = isset($found['odds_factor']) && is_numeric($found['odds_factor']) ? (int) $found['odds_factor'] : $s;
if ($f < 1) {
$f = 1;
}
$normalized[] = [
'streak' => $s,
'odds_factor' => $f,
'is_jackpot' => !empty($found['is_jackpot']),
];
}
}
$json = json_encode(['rows' => $normalized], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
return '{"rows":[]}';
}
return $json;
}
}

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

@@ -30,7 +30,7 @@ class AllowCrossDomain implements MiddlewareInterface
'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, server',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, batoken, ba-user-token, user-token, access-token, think-lang, server',
];
$origin = $request->header('origin');
if (is_array($origin)) {

View File

@@ -23,25 +23,87 @@ class LoadLangPack implements MiddlewareInterface
return $handler($request);
}
/**
* 解析当前请求语言。
* - 后台 admin优先请求头 think-langzh-cn / en其次 lang 头,再次查询/表单参数 lang支持 zh→zh-cn
* - 对外 api优先查询/表单参数 langzh / en其次 lang 头,再次 think-lang。
*/
protected function resolveLangSet(Request $request): string
{
$path = trim($request->path(), '/');
$parts = explode('/', $path);
$app = $parts[0] ?? 'api';
$queryRaw = $request->get('lang');
if ($queryRaw === null || $queryRaw === '') {
$queryRaw = $request->post('lang');
}
$queryLang = is_string($queryRaw) ? $queryRaw : '';
$thinkRaw = $request->header('think-lang');
$thinkLang = is_string($thinkRaw) ? $thinkRaw : '';
$headerLangRaw = $request->header('lang');
$headerLang = is_string($headerLangRaw) ? $headerLangRaw : '';
$normalize = static function (string $raw): string {
$s = str_replace('_', '-', strtolower(trim($raw)));
if ($s === 'zh') {
return 'zh-cn';
}
return $s;
};
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
$candidates = [];
if ($app === 'admin') {
if ($thinkLang !== '') {
$candidates[] = $normalize($thinkLang);
}
if ($headerLang !== '') {
$candidates[] = $normalize($headerLang);
}
if ($queryLang !== '') {
$candidates[] = $normalize($queryLang);
}
} else {
if ($queryLang !== '') {
$candidates[] = $normalize($queryLang);
}
if ($headerLang !== '') {
$candidates[] = $normalize($headerLang);
}
if ($thinkLang !== '') {
$candidates[] = $normalize($thinkLang);
}
}
foreach ($candidates as $code) {
if ($code !== '' && in_array($code, $allowLangList, true)) {
return $code;
}
}
$acceptRaw = $request->header('accept-language');
$acceptLang = is_string($acceptRaw) ? $acceptRaw : '';
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
return 'zh-cn';
}
if (preg_match('/^en/i', $acceptLang)) {
return 'en';
}
$defaultRaw = config('lang.default_lang', config('translation.locale', 'zh-cn'));
$default = is_string($defaultRaw) ? $defaultRaw : 'zh-cn';
return str_replace('_', '-', strtolower($default));
}
protected function loadLang(Request $request): void
{
// 优先从请求头 think-lang 获取前端选择的语言(与前端 axios 发送的 header 对应)
// 安装页等未发送 think-lang 时,回退到 Accept-Language 或配置默认值
$headerLang = $request->header('think-lang');
$allowLangList = config('lang.allow_lang_list', ['zh-cn', 'en']);
if ($headerLang && in_array(str_replace('_', '-', strtolower($headerLang)), $allowLangList)) {
$langSet = str_replace('_', '-', strtolower($headerLang));
} else {
$acceptLang = $request->header('accept-language', '');
if (preg_match('/^zh[-_]?cn|^zh/i', $acceptLang)) {
$langSet = 'zh-cn';
} elseif (preg_match('/^en/i', $acceptLang)) {
$langSet = 'en';
} else {
$langSet = config('lang.default_lang', config('translation.locale', 'zh-cn'));
}
$langSet = str_replace('_', '-', strtolower($langSet));
}
$langSet = $this->resolveLangSet($request);
// 设置当前请求的翻译语言,使 __() 和 trans() 使用正确的语言
if (function_exists('locale')) {
@@ -66,6 +128,12 @@ class LoadLangPack implements MiddlewareInterface
$translator->addResource('phpfile', $rootLangFile, $langSet, 'messages');
}
// 1.5 公共语言包app/common/lang/{locale}/*.php供 common 服务层与多应用共用
$commonLangPattern = base_path() . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'common' . DIRECTORY_SEPARATOR . 'lang' . DIRECTORY_SEPARATOR . $langSet . DIRECTORY_SEPARATOR . '*.php';
foreach (glob($commonLangPattern) ?: [] as $commonLangFile) {
$translator->addResource('phpfile', $commonLangFile, $langSet, 'messages');
}
// 2. 加载控制器专用语言包(如 zh-cn/auth/group.php供 get_route_remark 等使用
// 同时加载到 messages 域,使 __() 能正确翻译控制器内的文案(如安装页错误提示)
$controllerPath = get_controller_path($request);

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,51 @@
<?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',
'total_amount' => 'string',
'win_amount' => 'string',
'jackpot_extra_amount' => 'string',
'status' => '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

@@ -5,12 +5,12 @@ namespace app\common\model;
use support\think\Model;
/**
* GameChannel
* Channel
*/
class GameChannel extends Model
class Channel extends Model
{
// 表名
protected $name = 'game_channel';
protected $name = 'channel';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
@@ -19,21 +19,14 @@ class GameChannel extends Model
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,20 @@
<?php
namespace app\common\model;
use support\think\Model;
class ChannelAdminShare extends Model
{
protected $name = 'channel_admin_share';
protected $autoWriteTimestamp = true;
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'share_rate' => 'string',
'status' => 'integer',
];
}

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

@@ -2,31 +2,43 @@
namespace app\common\model;
use app\common\library\game\StreakWinReward;
use app\common\service\GameHotDataRedis;
use support\think\Model;
/**
* GameConfig
* 游戏/平台动态参数KV
*/
class GameConfig extends Model
{
// 表主键
protected $pk = 'ID';
// 表名
protected $name = 'game_config';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
public function channel(): \think\model\relation\BelongsTo
public static function onAfterWrite(GameConfig $model): void
{
return $this->belongsTo(\app\common\model\GameChannel::class, 'channel_id', 'id');
$key = trim((string) ($model->getAttr('config_key') ?? ''));
if ($key !== '') {
GameHotDataRedis::gameConfigForget($key);
}
if ($key === StreakWinReward::CONFIG_KEY) {
StreakWinReward::clearCache();
}
}
}
public static function onAfterDelete(GameConfig $model): void
{
$key = trim((string) ($model->getAttr('config_key') ?? ''));
if ($key !== '') {
GameHotDataRedis::gameConfigForget($key);
}
if ($key === StreakWinReward::CONFIG_KEY) {
StreakWinReward::clearCache();
}
}
}

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,57 @@
<?php
namespace app\common\model;
use app\common\service\GameHotDataRedis;
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',
'ai_locked_number' => 'integer',
'pending_draw_number' => 'integer',
'payout_until' => 'integer',
'platform_profit_amount' => 'string',
'winner_user_count' => '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;
}
public static function onAfterWrite(GameRecord $model): void
{
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
GameHotDataRedis::gameRecordForget($id === false ? null : $id);
}
public static function onAfterDelete(GameRecord $model): void
{
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
GameHotDataRedis::gameRecordForget($id === false ? null : $id);
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* GameRewardConfig
*/
class GameRewardConfig extends Model
{
// 表名
protected $name = 'game_reward_config';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换(避免时间戳被写成 'now' 字符串)
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
public function gameChannel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\GameChannel::class, 'game_channel_id', 'id');
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* GameRewardWeight
*/
class GameRewardWeight extends Model
{
// 表名
protected $name = 'game_reward_weight';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
public function gameChannel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\GameChannel::class, 'game_channel_id', 'id');
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace app\common\model;
use support\think\Model;
/**
* GameUser
*/
class GameUser extends Model
{
// 表名
protected $name = 'game_user';
// 自动写入时间戳字段
protected $autoWriteTimestamp = true;
// 字段类型转换
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
];
public function getcoinAttr($value): ?float
{
return is_null($value) ? null : (float)$value;
}
public function gameChannel(): \think\model\relation\BelongsTo
{
return $this->belongsTo(\app\common\model\GameChannel::class, 'game_channel_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,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,81 @@
namespace app\common\model;
use app\common\model\traits\TimestampInteger;
use app\common\service\GameHotDataRedis;
use support\think\Model;
/**
* 会员公共模型
* 用户主表
*/
class User extends Model
{
use TimestampInteger;
protected $name = 'user';
protected string $table = 'user';
protected string $pk = 'id';
protected bool $autoWriteTimestamp = true;
protected $autoWriteTimestamp = true;
public function getAvatarAttr($value): string
/**
* 生成 10 位唯一对外标识(大写字母与数字,排除易混淆字符)
*/
public static function generateUniquePublicCode10(): string
{
return full_url($value, false, config('buildadmin.default_avatar'));
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$len = strlen($chars);
for ($attempt = 0; $attempt < 80; $attempt++) {
$code = '';
for ($i = 0; $i < 10; $i++) {
$code .= $chars[random_int(0, $len - 1)];
}
if (!self::where('uuid', $code)->find()) {
return $code;
}
}
throw new \RuntimeException('Failed to generate unique user uuid');
}
public function setAvatarAttr($value): string
public static function formatLoginRemark(int $timestamp, string $ip): string
{
return $value == full_url('', false, config('buildadmin.default_avatar')) ? '' : $value;
return '最后登录:' . date('Y-m-d H:i:s', $timestamp) . ' IP:' . $ip;
}
public function resetPassword($uid, $newPassword)
protected $type = [
'create_time' => 'integer',
'update_time' => 'integer',
'coin' => 'string',
'total_deposit_coin' => 'string',
'total_withdraw_coin' => 'string',
'bet_flow_coin' => 'string',
'risk_flags' => 'integer',
'current_streak' => 'integer',
];
public function channel(): \think\model\relation\BelongsTo
{
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword), 'salt' => '']);
return $this->belongsTo(Channel::class, 'channel_id', 'id');
}
public function getMoneyAttr($value): string
public function admin(): \think\model\relation\BelongsTo
{
return bcdiv((string)$value, '100', 2);
return $this->belongsTo(\app\admin\model\Admin::class, 'admin_id', 'id');
}
public function setMoneyAttr($value): string
public function resetPassword(int|string $uid, string $newPassword): int
{
return bcmul((string)$value, '100', 2);
return $this->where(['id' => $uid])->update(['password' => hash_password($newPassword)]);
}
public static function onAfterWrite(User $model): void
{
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
if ($id !== false && $id > 0) {
GameHotDataRedis::userForget($id);
}
}
public static function onAfterDelete(User $model): void
{
$id = filter_var($model->getAttr('id'), FILTER_VALIDATE_INT);
if ($id !== false && $id > 0) {
GameHotDataRedis::userForget($id);
}
}
}

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,328 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\library\game\StreakWinReward;
use support\think\Db;
use Throwable;
/**
* 开奖后结算注单:写入 win_amount、status=已结算;中奖时入账并记 user_wallet_recordbiz_type=payout
* 连胜赔率来自 game_config.streak_win_reward结算后更新 user.current_streak未中奖则连胜归 0
*/
final class GameBetSettleService
{
/**
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
*
* @return array{jackpot_hits: list<array{user_id: int, period_no: string, total_win: string, result_number: int}>}
*
* @throws Throwable
*/
public static function settleBetsForDraw(int $recordId, int $resultNumber): array
{
if ($recordId <= 0 || $resultNumber < 1) {
return ['jackpot_hits' => []];
}
$now = time();
$bets = Db::name('bet_order')
->where('period_id', $recordId)
->where('status', 1)
->order('id', 'asc')
->select()
->toArray();
/** @var array<int, array{period_no: string, total_win: string, balance_after: string, orders: list<array{order_no: string, win_amount: string, hit: bool}>}> */
$aggregateByUser = [];
/** @var array<int, array{streak_at: int, had_win: bool}> */
$userOutcome = [];
/** @var array<int, true> */
$jackpotNotify = [];
foreach ($bets as $bet) {
$betId = (int) ($bet['id'] ?? 0);
if ($betId <= 0) {
continue;
}
$userId = (int) ($bet['user_id'] ?? 0);
if ($userId > 0 && !isset($userOutcome[$userId])) {
$userOutcome[$userId] = [
'streak_at' => (int) ($bet['streak_at_bet'] ?? 0),
'had_win' => false,
];
}
$win = self::computeWinAmount($bet, $resultNumber);
$jackpot = '0.0000';
$affected = Db::name('bet_order')
->where('id', $betId)
->where('status', 1)
->update([
'win_amount' => $win,
'jackpot_extra_amount' => $jackpot,
'status' => 2,
'update_time' => $now,
]);
if ($affected === 0) {
continue;
}
self::creditUserBetFlow($bet, $now);
if ($userId > 0) {
if (bccomp($win, '0', 4) > 0) {
$userOutcome[$userId]['had_win'] = true;
}
if (bccomp($win, '0', 4) > 0 && StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0))) {
$jackpotNotify[$userId] = true;
}
}
if ($userId <= 0) {
continue;
}
$balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
if (bccomp($win, '0', 4) > 0) {
$paid = self::creditUserPayout($bet, $betId, $win, $now);
if ($paid !== null) {
$balanceAfter = $paid;
}
}
$periodNo = (string) ($bet['period_no'] ?? '');
if (!isset($aggregateByUser[$userId])) {
$aggregateByUser[$userId] = [
'period_no' => $periodNo,
'total_win' => '0.0000',
'balance_after' => $balanceAfter,
'orders' => [],
];
}
$aggregateByUser[$userId]['total_win'] = bcadd($aggregateByUser[$userId]['total_win'], $win, 4);
$aggregateByUser[$userId]['balance_after'] = $balanceAfter;
$aggregateByUser[$userId]['orders'][] = [
'order_no' => (string) $betId,
'win_amount' => $win,
'hit' => bccomp($win, '0', 4) > 0,
];
}
foreach ($userOutcome as $userId => $info) {
$streakAt = (int) ($info['streak_at'] ?? 0);
$hadWin = (bool) ($info['had_win'] ?? false);
if ($hadWin) {
$next = $streakAt + 1;
if ($next > 10) {
$next = 10;
}
} else {
$next = 0;
}
Db::name('user')->where('id', $userId)->update([
'current_streak' => $next,
'update_time' => $now,
]);
GameHotDataRedis::userForget($userId);
}
foreach ($aggregateByUser as $userId => $agg) {
$hitOrderCount = 0;
foreach ($agg['orders'] as $o) {
if (($o['hit'] ?? false) === true) {
$hitOrderCount++;
}
}
UserPushService::publish((int) $userId, UserPushService::EVT_BET_SETTLED, [
'period_no' => $agg['period_no'],
'result_number' => $resultNumber,
'total_win_amount' => $agg['total_win'],
'order_count' => count($agg['orders']),
'hit_order_count' => $hitOrderCount,
'balance_after' => $agg['balance_after'],
]);
if (bccomp($agg['total_win'], '0', 4) > 0) {
UserPushService::publish((int) $userId, UserPushService::EVT_WALLET_CHANGED, [
'reason' => 'payout',
'ref_type' => 'game_period',
'ref_id' => (string) $recordId,
'delta' => $agg['total_win'],
'balance_after' => $agg['balance_after'],
]);
}
}
$jackpotHits = [];
foreach ($jackpotNotify as $uid => $_) {
if (!isset($aggregateByUser[$uid])) {
continue;
}
$agg = $aggregateByUser[$uid];
if (bccomp($agg['total_win'], '0', 4) <= 0) {
continue;
}
$jackpotHits[] = [
'user_id' => (int) $uid,
'period_no' => (string) ($agg['period_no'] ?? ''),
'total_win' => (string) $agg['total_win'],
'result_number' => $resultNumber,
];
}
return ['jackpot_hits' => $jackpotHits];
}
/**
* 补偿:库中已结束局次但注单仍为待开奖的,可重复调用(幂等)。
*/
public static function settlePendingForEndedRecords(): int
{
$rows = Db::name('game_record')
->where('status', 4)
->whereNotNull('result_number')
->field(['id', 'result_number'])
->order('id', 'asc')
->select()
->toArray();
$count = 0;
foreach ($rows as $row) {
$rid = (int) ($row['id'] ?? 0);
$rn = (int) ($row['result_number'] ?? 0);
if ($rid <= 0 || $rn < 1) {
continue;
}
$pending = Db::name('bet_order')
->where('period_id', $rid)
->where('status', 1)
->count();
if ($pending === 0) {
continue;
}
Db::startTrans();
try {
$out = self::settleBetsForDraw($rid, $rn);
Db::commit();
JackpotPushService::publishHits($out['jackpot_hits'] ?? []);
$count++;
} catch (Throwable $e) {
Db::rollback();
throw $e;
}
}
return $count;
}
/**
* 应付派彩:开奖号码 ∈ pick_numbers 即中奖;整笔 total_amount × odds_factorodds_factor 来自连胜奖励表对应档位)。
*/
public static function computeWinAmount(array $bet, int $resultNumber): string
{
$pickNumbers = $bet['pick_numbers'] ?? null;
if (is_string($pickNumbers)) {
$decoded = json_decode($pickNumbers, true);
$pickNumbers = is_array($decoded) ? $decoded : [];
}
if (!is_array($pickNumbers)) {
$pickNumbers = [];
}
if (!in_array($resultNumber, array_map('intval', $pickNumbers), true)) {
return '0.0000';
}
$total = (string) ($bet['total_amount'] ?? '0');
$streak = (int) ($bet['streak_at_bet'] ?? 0);
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
return bcmul($total, $odds, 4);
}
/**
* 累加玩家打码量(流水):按本注单 total_amount 1:1 加到 user.bet_flow_coin。
*
* 幂等性由调用点保证:只有 bet_order 首次从 status=1 变更为 status=2返回 $affected=1
* 时才会调用本方法,重复结算不会触发。
*/
private static function creditUserBetFlow(array $bet, int $now): void
{
$userId = isset($bet['user_id']) && is_numeric($bet['user_id']) ? intval($bet['user_id']) : 0;
if ($userId <= 0) {
return;
}
$totalRaw = $bet['total_amount'] ?? '0';
$total = is_string($totalRaw) ? trim($totalRaw) : (is_numeric($totalRaw) ? strval($totalRaw) : '0');
if ($total === '' || !is_numeric($total)) {
return;
}
$flow = bcadd($total, '0', 4);
if (bccomp($flow, '0', 4) <= 0) {
return;
}
Db::name('user')
->where('id', $userId)
->update([
'bet_flow_coin' => Db::raw('bet_flow_coin + ' . $flow),
'update_time' => $now,
]);
GameHotDataRedis::userForget($userId);
}
/**
* @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null
*/
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string
{
$userId = (int) ($bet['user_id'] ?? 0);
if ($userId <= 0) {
return null;
}
$idem = 'payout_bet_' . $betId;
if (Db::name('user_wallet_record')->where('idempotency_key', $idem)->value('id')) {
$coin = Db::name('user')->where('id', $userId)->value('coin');
return (string) ($coin ?? '0');
}
$user = Db::name('user')->where('id', $userId)->find();
if (!$user) {
return null;
}
$before = (string) ($user['coin'] ?? '0');
$after = bcadd($before, $winAmount, 4);
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $bet['channel_id'] ?? null,
'biz_type' => 'payout',
'direction' => 1,
'amount' => $winAmount,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'bet_order',
'ref_id' => $betId,
'idempotency_key' => $idem,
'operator_admin_id' => null,
'remark' => '压注派彩',
'create_time' => $now,
]);
Db::name('user')->where('id', $userId)->update([
'coin' => $after,
'update_time' => $now,
]);
GameHotDataRedis::userForget($userId);
return $after;
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
/**
* 按 game_user 表统计各渠道用户数,回写 game_channel.user_count
*/
class GameChannelUserCount
{
/**
* 统计 game_user.game_channel_id = 该渠道 id 的行数,更新 game_channel.user_count
*/
public static function syncFromGameUser(int|string|null $channelId): void
{
if ($channelId === null || $channelId === '') {
return;
}
if (is_numeric($channelId) && (float) $channelId < 1) {
return;
}
$count = Db::name('game_user')->where('game_channel_id', $channelId)->count();
Db::name('game_channel')->where('id', $channelId)->update(['user_count' => $count]);
}
/**
* @param list<int|string|null> $channelIds
*/
public static function syncChannels(array $channelIds): void
{
$seen = [];
foreach ($channelIds as $cid) {
if ($cid === null || $cid === '') {
continue;
}
$k = (string) $cid;
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
self::syncFromGameUser($cid);
}
}
}

View File

@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\model\User;
use support\Redis;
use support\think\Db;
use Throwable;
/**
* 热点数据 Redis 缓存game_config、game_record、user 全行 JSON。
* Redis 不可用时自动回退为直连数据库。
*/
final class GameHotDataRedis
{
private const KEY_GC = 'dfw:v1:gc:';
private const KEY_GR_ID = 'dfw:v1:gr:id:';
private const KEY_GR_ACTIVE = 'dfw:v1:gr:active';
private const KEY_GR_LATEST = 'dfw:v1:gr:latest';
private const KEY_USER = 'dfw:v1:user:';
public static function enabled(): bool
{
return config('game_hot_cache.enabled', true) === true;
}
/**
* @return array<string, mixed>|null
*/
public static function gameConfigRow(string $configKey): ?array
{
if ($configKey === '') {
return null;
}
if (self::enabled()) {
$cached = self::redisGet(self::KEY_GC . $configKey);
if ($cached !== null && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
return $decoded;
}
}
}
$row = Db::name('game_config')->where('config_key', $configKey)->find();
if (!$row) {
return null;
}
if (self::enabled()) {
$ttl = self::intConfig('ttl_game_config', 86400);
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
return $row;
}
public static function gameConfigForget(string $configKey): void
{
if ($configKey === '') {
return;
}
self::redisDel(self::KEY_GC . $configKey);
}
/**
* @return array<string, mixed>|null
*/
public static function gameRecordById(int $id): ?array
{
if ($id <= 0) {
return null;
}
$key = self::KEY_GR_ID . $id;
if (self::enabled()) {
$cached = self::redisGet($key);
if ($cached !== null && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
return $decoded;
}
}
}
$row = Db::name('game_record')->where('id', $id)->find();
if (!$row) {
return null;
}
if (self::enabled()) {
$ttl = self::intConfig('ttl_game_record', 60);
self::redisSetEx($key, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
return $row;
}
/**
* 当前未完全结束的对局status ∈ 0,1,2,3 中 id 最大的一条)
*
* @return array<string, mixed>|null
*/
public static function gameRecordActive(): ?array
{
if (self::enabled()) {
$cached = self::redisGet(self::KEY_GR_ACTIVE);
if ($cached !== null && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
return $decoded;
}
}
}
$row = Db::name('game_record')->whereIn('status', [0, 1, 2, 3])->order('id', 'desc')->find();
if (!$row) {
return null;
}
if (self::enabled()) {
$ttl = self::intConfig('ttl_game_record', 60);
self::redisSetEx(self::KEY_GR_ACTIVE, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
return $row;
}
/**
* 最新一条对局(含已结束),与 GameRecord::order('id','desc')->find() 一致
*
* @return array<string, mixed>|null
*/
public static function gameRecordLatest(): ?array
{
if (self::enabled()) {
$cached = self::redisGet(self::KEY_GR_LATEST);
if ($cached !== null && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
return $decoded;
}
}
}
$row = Db::name('game_record')->order('id', 'desc')->find();
if (!$row) {
return null;
}
if (self::enabled()) {
$ttl = self::intConfig('ttl_game_record', 60);
self::redisSetEx(self::KEY_GR_LATEST, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
return $row;
}
/**
* 任意 game_record 变更后调用:删除活跃局、最新局及指定 id 缓存
*/
public static function gameRecordForget(?int $id = null): void
{
$keys = [self::KEY_GR_ACTIVE, self::KEY_GR_LATEST];
if ($id !== null && $id > 0) {
$keys[] = self::KEY_GR_ID . $id;
}
self::redisDel(...$keys);
}
/**
* @return array<string, mixed>|null
*/
public static function userRow(int $userId): ?array
{
if ($userId <= 0) {
return null;
}
$key = self::KEY_USER . $userId;
if (self::enabled()) {
$cached = self::redisGet($key);
if ($cached !== null && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
return $decoded;
}
}
}
$row = Db::name('user')->where('id', $userId)->find();
if (!$row) {
return null;
}
if (self::enabled()) {
$ttl = self::intConfig('ttl_user', 90);
self::redisSetEx($key, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
return $row;
}
public static function userRemember(User $user): void
{
if (!self::enabled()) {
return;
}
$id = $user->getAttr('id');
$uid = filter_var($id, FILTER_VALIDATE_INT);
if ($uid === false || $uid <= 0) {
return;
}
$ttl = self::intConfig('ttl_user', 90);
self::redisSetEx(self::KEY_USER . $uid, $ttl, json_encode($user->toArray(), JSON_UNESCAPED_UNICODE));
}
public static function userForget(int $userId): void
{
if ($userId <= 0) {
return;
}
self::redisDel(self::KEY_USER . $userId);
}
/**
* 用缓存行构造已存在库的 User供 Auth 等高频读)
*/
public static function userModelFromCacheOrDb(int $userId): ?User
{
$row = self::userRow($userId);
if ($row === null) {
return null;
}
return (new User())->newInstance($row);
}
private static function intConfig(string $name, int $default): int
{
$v = config('game_hot_cache.' . $name, $default);
if (is_int($v)) {
return $v > 0 ? $v : $default;
}
if (is_numeric($v)) {
$n = filter_var($v, FILTER_VALIDATE_INT);
if ($n === false) {
return $default;
}
return $n > 0 ? $n : $default;
}
return $default;
}
private static function redisGet(string $key): ?string
{
try {
$v = Redis::get($key);
if ($v === false || $v === null) {
return null;
}
if (!is_string($v)) {
return null;
}
return $v;
} catch (Throwable) {
return null;
}
}
private static function redisSetEx(string $key, int $ttl, string $value): void
{
try {
Redis::setEx($key, $ttl, $value);
} catch (Throwable) {
}
}
private static function redisDel(string ...$keys): void
{
if ($keys === []) {
return;
}
try {
Redis::del(...$keys);
} catch (Throwable) {
}
}
}

View File

@@ -0,0 +1,723 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\library\game\StreakWinReward;
use support\think\Db;
use Throwable;
use Webman\Push\Api;
final class GameLiveService
{
private const CHANNEL = 'game-live';
private const EVENT = 'bet-updated';
/** 与《36字花-移动端接口设计草案》7.1 对齐:公共对局频道 */
private const CHANNEL_PUBLIC_GAME_PERIOD = 'public-game-period';
private const EVT_PERIOD_TICK = 'period.tick';
private const EVT_PERIOD_LOCKED = 'period.locked';
private const EVT_PERIOD_OPENED = 'period.opened';
private const EVT_PERIOD_PAYOUT = 'period.payout';
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';
/** 开奖结果号码池1 至此上限(与单注可选号码个数配置无关) */
private const DRAW_NUMBER_MAX = 36;
/** 开奖后派彩展示宽限期(秒),之后再创建下一期 */
private const PAYOUT_GRACE_SECONDS = 3;
public static function buildSnapshot(?int $recordId = null): array
{
$record = self::resolveRecord($recordId);
if (!$record) {
return self::emptySnapshotPayload();
}
$rid = (int) $record['id'];
self::ensureAiLocked($rid);
$record = self::reloadRecord($rid);
if (!$record) {
return self::emptySnapshotPayload();
}
$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);
$status = (int) $record['status'];
$payoutUntil = isset($record['payout_until']) ? (int) $record['payout_until'] : 0;
$payoutRemaining = 0;
if ($status === 3 && $payoutUntil > 0) {
$payoutRemaining = max(0, $payoutUntil - time());
}
$bets = Db::name('bet_order')
->where('period_id', $rid)
->order('id', 'desc')
->limit(200)
->select()
->toArray();
$candidates = [];
$canCalculate = $elapsed >= $betSeconds && ($status === 0 || $status === 1);
if ($canCalculate) {
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
$loss = self::estimateLossForNumber($bets, $n);
$candidates[] = [
'number' => $n,
'estimated_loss' => $loss,
];
}
}
$aiLocked = $record['ai_locked_number'] ?? null;
$aiDisplay = null;
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
$aiDisplay = (int) $aiLocked;
}
$pendingRaw = $record['pending_draw_number'] ?? null;
$pendingDraw = null;
if ($pendingRaw !== null && $pendingRaw !== '' && is_numeric((string) $pendingRaw)) {
$pd = (int) $pendingRaw;
if ($pd >= 1 && $pd <= self::DRAW_NUMBER_MAX) {
$pendingDraw = $pd;
}
}
$canScheduleDraw = ($status === 0 || $status === 1)
&& $elapsed >= $betSeconds
&& $elapsed < $periodSeconds;
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'],
'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' => $aiDisplay,
'calc_number' => $aiDisplay,
'pending_draw_number' => $pendingDraw,
'period_seconds' => $periodSeconds,
'bet_seconds' => $betSeconds,
'pick_max_number_count' => $pickMax,
'draw_number_max' => self::DRAW_NUMBER_MAX,
'remaining_seconds' => $remaining,
'bet_remaining_seconds' => $betRemaining,
'payout_remaining_seconds' => $payoutRemaining,
'is_payout_phase' => $status === 3,
'can_calculate' => $canCalculate,
'can_draw' => $canScheduleDraw,
'can_schedule_draw' => $canScheduleDraw,
'server_time' => time(),
];
}
/**
* @return array<string, mixed>
*/
private static function emptySnapshotPayload(): array
{
return [
'record' => null,
'bets' => [],
'candidate_numbers' => [],
'ai_default_number' => null,
'calc_number' => null,
'pending_draw_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(),
'draw_number_max' => self::DRAW_NUMBER_MAX,
'remaining_seconds' => 0,
'bet_remaining_seconds' => 0,
'payout_remaining_seconds' => 0,
'is_payout_phase' => false,
'can_calculate' => false,
'can_draw' => false,
'can_schedule_draw' => false,
'server_time' => time(),
];
}
public static function calculateResult(?int $recordId, ?int $manualNumber = null): array
{
$record = self::resolveRecord($recordId);
if (!$record) {
return ['ok' => false, 'msg' => __('No active game in progress')];
}
if (!in_array((int) $record['status'], [0, 1], true)) {
return ['ok' => false, 'msg' => __('Current game status does not allow calculation')];
}
$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' => __('Betting period has not ended; calculation is not available yet')];
}
self::ensureAiLocked((int) $record['id']);
$record = self::reloadRecord((int) $record['id']);
if (!$record) {
return ['ok' => false, 'msg' => __('No active game in progress')];
}
$pickMax = self::getPickMaxNumberCount();
if ($manualNumber !== null && ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX)) {
return ['ok' => false, 'msg' => __('Manual draw number is out of the allowed range')];
}
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
$candidates = [];
$bestNumber = null;
$bestLoss = null;
$bestNumbers = [];
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
$loss = self::estimateLossForNumber($bets, $n);
$candidates[] = ['number' => $n, 'estimated_loss' => $loss];
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
$bestLoss = $loss;
$bestNumbers = [$n];
continue;
}
if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) {
$bestNumbers[] = $n;
}
}
$bestNumber = self::pickRandomNumber($bestNumbers);
$aiLocked = $record['ai_locked_number'] ?? null;
$aiDisplay = null;
if ($aiLocked !== null && $aiLocked !== '' && is_numeric((string) $aiLocked)) {
$aiDisplay = (int) $aiLocked;
}
$finalNumber = $manualNumber ?? $bestNumber;
$finalLoss = '0.0000';
if ($finalNumber !== null) {
$finalLoss = self::estimateLossForNumber($bets, $finalNumber);
}
return [
'ok' => true,
'msg' => __('Calculation completed'),
'record' => $record,
'period_seconds' => $periodSeconds,
'bet_seconds' => $betSeconds,
'pick_max_number_count' => $pickMax,
'draw_number_max' => self::DRAW_NUMBER_MAX,
'candidate_numbers' => $candidates,
'ai_default_number' => $aiDisplay,
'final_number' => $finalNumber,
'final_estimated_loss' => $finalLoss,
];
}
/**
* 管理员预约本期开奖号码(倒计时结束后由 tick 自动开奖,不立即开奖)。
*/
public static function scheduleDraw(?int $recordId, int $manualNumber): array
{
if ($manualNumber < 1 || $manualNumber > self::DRAW_NUMBER_MAX) {
return ['ok' => false, 'msg' => __('Draw number is out of the allowed range')];
}
$record = self::resolveRecord($recordId);
if (!$record) {
return ['ok' => false, 'msg' => __('No active game in progress')];
}
if (!in_array((int) $record['status'], [0, 1], true)) {
return ['ok' => false, 'msg' => __('Current game status does not allow scheduling the draw')];
}
$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' => __('Betting has not ended; cannot schedule the draw')];
}
if ($elapsed >= $periodSeconds) {
return ['ok' => false, 'msg' => __('This period has ended; please refresh the page')];
}
self::ensureAiLocked((int) $record['id']);
Db::name('game_record')->where('id', (int) $record['id'])->update([
'pending_draw_number' => $manualNumber,
'update_time' => time(),
]);
GameHotDataRedis::gameRecordForget((int) $record['id']);
self::publishSnapshot(null);
return [
'ok' => true,
'msg' => __('Draw number scheduled; it will be used when the countdown ends'),
];
}
/**
* 倒计时结束自动开奖AI 或预约号码);派彩宽限期后由 finalizePayoutGrace 结单并开下一期。
*/
public static function drawResult(?int $recordId, ?int $manualNumber = null): array
{
$record = self::resolveRecord($recordId);
if (!$record) {
return ['ok' => false, 'msg' => __('No active game in progress')];
}
if (!in_array((int) $record['status'], [0, 1], true)) {
return ['ok' => false, 'msg' => __('Current game status does not allow drawing')];
}
$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' => __('Betting period has not ended; drawing is not available yet')];
}
if ($elapsed < $periodSeconds) {
return ['ok' => false, 'msg' => __('Period countdown has not ended; cannot draw yet')];
}
self::ensureAiLocked((int) $record['id']);
$record = self::reloadRecord((int) $record['id']);
if (!$record) {
return ['ok' => false, 'msg' => __('No active game in progress')];
}
$useManual = $manualNumber;
if ($useManual === null) {
$p = $record['pending_draw_number'] ?? null;
if ($p !== null && $p !== '' && is_numeric((string) $p)) {
$pn = (int) $p;
if ($pn >= 1 && $pn <= self::DRAW_NUMBER_MAX) {
$useManual = $pn;
}
}
}
$finalNumber = null;
$drawMode = 0;
if ($useManual !== null && $useManual >= 1 && $useManual <= self::DRAW_NUMBER_MAX) {
$finalNumber = $useManual;
$drawMode = 1;
} else {
$al = $record['ai_locked_number'] ?? null;
if ($al !== null && $al !== '' && is_numeric((string) $al)) {
$finalNumber = (int) $al;
}
}
if ($finalNumber === null || $finalNumber < 1) {
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
$finalNumber = self::computeBestNumberFromBets($bets) ?? 1;
$drawMode = 0;
}
$bets = Db::name('bet_order')->where('period_id', (int) $record['id'])->select()->toArray();
$finalLoss = self::estimateLossForNumber($bets, $finalNumber);
$now = time();
$payoutUntil = $now + self::PAYOUT_GRACE_SECONDS;
$settleOut = ['jackpot_hits' => []];
Db::startTrans();
try {
Db::name('game_record')->where('id', (int) $record['id'])->update([
'status' => 3,
'result_number' => $finalNumber,
'draw_mode' => $drawMode,
'pending_draw_number' => null,
'payout_until' => $payoutUntil,
'update_time' => $now,
]);
$settleOut = GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return ['ok' => false, 'msg' => __('Game live: settlement error') . ': ' . $e->getMessage()];
}
GameHotDataRedis::gameRecordForget((int) $record['id']);
try {
GameRecordStatService::refreshForRecordId((int) $record['id']);
} catch (Throwable) {
}
JackpotPushService::publishHits($settleOut['jackpot_hits'] ?? []);
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil);
self::publishSnapshot(null);
return [
'ok' => true,
'msg' => __('Draw completed; paying out'),
'result_number' => $finalNumber,
'estimated_loss' => $finalLoss,
'payout_until' => $payoutUntil,
];
}
/**
* 派彩宽限期结束:将本期置为已结束并创建下一期。
*/
public static function finalizePayoutGrace(): void
{
$row = Db::name('game_record')
->where('status', 3)
->where('payout_until', '>', 0)
->where('payout_until', '<=', time())
->order('id', 'desc')
->find();
if (!$row) {
return;
}
$id = (int) $row['id'];
Db::startTrans();
try {
Db::name('game_record')->where('id', $id)->update([
'status' => 4,
'payout_until' => null,
'update_time' => time(),
]);
GameRecordService::createNextRecordAfterDraw();
Db::commit();
} catch (Throwable) {
Db::rollback();
return;
}
GameHotDataRedis::gameRecordForget($id);
GameRecordStatService::refreshForRecordId($id);
self::publishSnapshot(null);
}
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']);
self::ensureAiLocked((int) $record['id']);
$record = self::reloadRecord((int) $record['id']);
if (!$record) {
return;
}
$elapsed = max(0, time() - (int) $record['period_start_at']);
if ($elapsed < $periodSeconds) {
return;
}
self::drawResult((int) $record['id'], null);
}
public static function publishSnapshot(?int $recordId = null): void
{
try {
$payload = self::buildSnapshot($recordId);
$api = self::createPushApi();
$api->trigger(self::CHANNEL, self::EVENT, $payload);
self::publishPublicPeriodTick($payload, $api);
} catch (Throwable) {
}
}
private static function createPushApi(): Api
{
return 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')
);
}
/**
* 移动端公共频道:每秒心跳,含期号、倒计时、阶段(对齐 lobbyInit/periodCurrent 语义)
*/
private static function publishPublicPeriodTick(array $snapshot, Api $api): void
{
$record = $snapshot['record'] ?? null;
$serverTime = (int) ($snapshot['server_time'] ?? time());
$remaining = (int) ($snapshot['remaining_seconds'] ?? 0);
$betCloseIn = (int) ($snapshot['bet_remaining_seconds'] ?? 0);
$payoutRem = (int) ($snapshot['payout_remaining_seconds'] ?? 0);
$isPayout = !empty($snapshot['is_payout_phase']);
$periodNo = '';
$dbStatus = 0;
$resultNumber = null;
if (is_array($record)) {
$periodNo = (string) ($record['period_no'] ?? '');
$dbStatus = (int) ($record['status'] ?? 0);
$rn = $record['result_number'] ?? null;
$resultNumber = is_numeric((string) $rn) ? (int) $rn : null;
}
if ($record === null || $periodNo === '') {
$status = 'idle';
} else {
$status = self::mapPublicPeriodStatus($dbStatus, $betCloseIn);
}
$payload = [
'server_time' => $serverTime,
'period_no' => $periodNo,
'status' => $status,
'countdown' => $remaining,
'bet_close_in'=> $betCloseIn,
'payout_remaining_seconds' => $payoutRem,
'is_payout_phase' => $isPayout,
'payout_message' => $isPayout ? '派彩中,请稍候' : '',
];
if ($periodNo !== '' && $record !== null) {
$start = (int) ($record['period_start_at'] ?? 0);
$betSeconds = (int) ($snapshot['bet_seconds'] ?? 20);
$periodSeconds = (int) ($snapshot['period_seconds'] ?? 30);
if ($start > 0) {
$payload['lock_at'] = $start + $betSeconds;
$payload['open_at'] = $start + $periodSeconds;
}
}
if ($resultNumber !== null) {
$payload['result_number'] = $resultNumber;
}
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_TICK, $payload);
}
/**
* @param array<string, mixed> $record game_record 行
*/
private static function publishPublicPeriodLocked(array $record): void
{
try {
$start = (int) ($record['period_start_at'] ?? 0);
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
$periodNo = (string) ($record['period_no'] ?? '');
$payload = [
'period_no' => $periodNo,
'lock_at' => $start > 0 ? $start + $betSeconds : time(),
];
$api = self::createPushApi();
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_LOCKED, $payload);
} catch (Throwable) {
}
}
private static function publishPublicPeriodOpened(string $periodNo, int $resultNumber, int $openTime): void
{
try {
$payload = [
'period_no' => $periodNo,
'result_number' => $resultNumber,
'open_time' => $openTime,
];
$api = self::createPushApi();
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_OPENED, $payload);
} catch (Throwable) {
}
}
/**
* 派彩阶段开始(开奖后宽限期内推送)
*/
private static function publishPublicPeriodPayout(string $periodNo, int $resultNumber, int $payoutUntil): void
{
try {
$payload = [
'period_no' => $periodNo,
'result_number' => $resultNumber,
'payout_until' => $payoutUntil,
'message' => '派彩中,请稍候',
];
$api = self::createPushApi();
$api->trigger(self::CHANNEL_PUBLIC_GAME_PERIOD, self::EVT_PERIOD_PAYOUT, $payload);
} catch (Throwable) {
}
}
/**
* 与文档 3.1/4.1 中 status 字符串对齐betting / locked / settling / finished
*/
private static function mapPublicPeriodStatus(int $dbStatus, int $betCloseIn): string
{
if ($dbStatus === 0) {
return $betCloseIn > 0 ? 'betting' : 'locked';
}
if ($dbStatus === 1) {
return 'locked';
}
if ($dbStatus === 4) {
return 'finished';
}
if ($dbStatus === 3) {
return 'payouting';
}
if ($dbStatus === 2) {
return 'settling';
}
return 'finished';
}
private static function resolveRecord(?int $recordId): ?array
{
if ($recordId !== null && $recordId > 0) {
$row = GameHotDataRedis::gameRecordById($recordId);
if ($row) {
return $row;
}
}
return GameHotDataRedis::gameRecordActive();
}
private static function reloadRecord(int $id): ?array
{
$row = GameHotDataRedis::gameRecordById($id);
return $row ?: null;
}
/**
* 封盘后计算并锁定 AI 号码本期不变并封盘status 0→1
*/
private static function ensureAiLocked(int $recordId): void
{
$record = GameHotDataRedis::gameRecordById($recordId);
if (!$record) {
return;
}
$betSeconds = self::getConfigInt(self::KEY_BET_SECONDS, 20);
$elapsed = max(0, time() - (int) $record['period_start_at']);
if ($elapsed < $betSeconds) {
return;
}
$st = (int) $record['status'];
if ($st !== 0 && $st !== 1) {
return;
}
$existing = $record['ai_locked_number'] ?? null;
if ($existing !== null && $existing !== '' && is_numeric((string) $existing) && (int) $existing > 0) {
if ($st === 0) {
Db::name('game_record')->where('id', $recordId)->update([
'status' => 1,
'update_time' => time(),
]);
GameHotDataRedis::gameRecordForget($recordId);
$record['status'] = 1;
self::publishPublicPeriodLocked($record);
}
return;
}
$bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray();
$best = self::computeBestNumberFromBets($bets);
if ($best === null || $best < 1) {
$best = 1;
}
$update = [
'ai_locked_number' => $best,
'update_time' => time(),
];
if ($st === 0) {
$update['status'] = 1;
}
Db::name('game_record')->where('id', $recordId)->update($update);
GameHotDataRedis::gameRecordForget($recordId);
$record = array_merge($record, $update);
if ($st === 0) {
self::publishPublicPeriodLocked($record);
}
}
/**
* @param array<int, array<string, mixed>> $bets
*/
private static function computeBestNumberFromBets(array $bets): ?int
{
$bestLoss = null;
$bestNumbers = [];
for ($n = 1; $n <= self::DRAW_NUMBER_MAX; $n++) {
$loss = self::estimateLossForNumber($bets, $n);
if ($bestLoss === null || bccomp((string) $loss, (string) $bestLoss, 4) < 0) {
$bestLoss = $loss;
$bestNumbers = [$n];
continue;
}
if (bccomp((string) $loss, (string) $bestLoss, 4) === 0) {
$bestNumbers[] = $n;
}
}
return self::pickRandomNumber($bestNumbers);
}
private static function getConfigInt(string $key, int $default): int
{
$row = GameHotDataRedis::gameConfigRow($key);
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;
}
$total = (string) ($bet['total_amount'] ?? '0');
$streak = (int) ($bet['streak_at_bet'] ?? 0);
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
$orderPayout = bcmul($total, $odds, 4);
$payout = bcadd($payout, $orderPayout, 4);
}
return $payout;
}
private static function pickRandomNumber(array $numbers): ?int
{
if ($numbers === []) {
return null;
}
if (count($numbers) === 1) {
return $numbers[0];
}
$index = random_int(0, count($numbers) - 1);
return $numbers[$index];
}
}

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,139 @@
<?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 = GameHotDataRedis::gameConfigRow($key);
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,
]);
GameHotDataRedis::gameRecordForget();
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,
]);
GameHotDataRedis::gameConfigForget($key);
return;
}
Db::name('game_config')->insert([
'config_key' => $key,
'config_value' => $value,
'value_type' => $valueType,
'remark' => $remark,
'create_time' => $now,
'update_time' => $now,
]);
GameHotDataRedis::gameConfigForget($key);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\library\game\StreakWinReward;
use support\think\Db;
/**
* 对局维度统计:平台盈亏、中奖人数(与 GameLiveService 单号派彩口径一致)。
*/
final class GameRecordStatService
{
/**
* 根据注单与开奖号码回写 game_record 统计字段(已结束对局)。
*/
public static function refreshForRecordId(int $recordId): void
{
if ($recordId <= 0) {
return;
}
$record = Db::name('game_record')->where('id', $recordId)->find();
if (!$record) {
return;
}
$status = (int) ($record['status'] ?? 0);
$now = time();
if ($status !== 4) {
Db::name('game_record')->where('id', $recordId)->update([
'platform_profit_amount' => '0.0000',
'winner_user_count' => 0,
'update_time' => $now,
]);
GameHotDataRedis::gameRecordForget($recordId);
return;
}
$resultRaw = $record['result_number'] ?? null;
if ($resultRaw === null || $resultRaw === '') {
return;
}
$resultNum = (int) $resultRaw;
$bets = Db::name('bet_order')->where('period_id', $recordId)->select()->toArray();
$totalBet = '0.0000';
$totalPayout = '0.0000';
$winnerUserIds = [];
foreach ($bets as $bet) {
$st = (int) ($bet['status'] ?? 0);
if ($st === 3) {
continue;
}
$tb = (string) ($bet['total_amount'] ?? '0');
$totalBet = bcadd($totalBet, $tb, 4);
if ($st === 2) {
$payout = bcadd((string) ($bet['win_amount'] ?? '0'), (string) ($bet['jackpot_extra_amount'] ?? '0'), 4);
} else {
$payout = self::estimatePayoutForBet($bet, $resultNum);
}
$totalPayout = bcadd($totalPayout, $payout, 4);
if (bccomp($payout, '0', 4) > 0) {
$uid = (int) ($bet['user_id'] ?? 0);
if ($uid > 0) {
$winnerUserIds[$uid] = true;
}
}
}
$profit = bcsub($totalBet, $totalPayout, 4);
Db::name('game_record')->where('id', $recordId)->update([
'platform_profit_amount' => $profit,
'winner_user_count' => count($winnerUserIds),
'update_time' => $now,
]);
GameHotDataRedis::gameRecordForget($recordId);
}
/**
* 与 GameLiveService::estimateLossForNumber 一致:命中号码时 total_amount × odds_factor。
*/
private static function estimatePayoutForBet(array $bet, int $resultNumber): string
{
$pickNumbers = $bet['pick_numbers'] ?? null;
if (is_string($pickNumbers)) {
$decoded = json_decode($pickNumbers, true);
$pickNumbers = is_array($decoded) ? $decoded : [];
}
if (!is_array($pickNumbers)) {
$pickNumbers = [];
}
if (!in_array($resultNumber, array_map('intval', $pickNumbers), true)) {
return '0.0000';
}
$total = (string) ($bet['total_amount'] ?? '0');
$streak = (int) ($bet['streak_at_bet'] ?? 0);
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
return bcmul($total, $odds, 4);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use Throwable;
use Webman\Push\Api;
/**
* 大奖派彩:玩家私有频道 + 公共频道(对局频道 + 公告频道,便于大厅与公告测试页均能收到)
*/
final class JackpotPushService
{
private const CHANNEL_GAME_PERIOD = 'public-game-period';
private const CHANNEL_OPERATION_NOTICE = 'public-operation-notice';
private const EVT_JACKPOT_HIT = 'jackpot.hit';
/**
* @param list<array{user_id: int, period_no: string, total_win: string, result_number: int}> $hits
*/
public static function publishHits(array $hits): void
{
foreach ($hits as $h) {
$uid = (int) ($h['user_id'] ?? 0);
if ($uid <= 0) {
continue;
}
$periodNo = (string) ($h['period_no'] ?? '');
$totalWin = (string) ($h['total_win'] ?? '0');
$rn = (int) ($h['result_number'] ?? 0);
UserPushService::publish($uid, UserPushService::EVT_JACKPOT_HIT, [
'period_no' => $periodNo,
'total_win_amount' => $totalWin,
'result_number' => $rn,
'is_jackpot' => true,
]);
self::publishPublicChannels($periodNo, $uid, $totalWin, $rn);
}
}
/**
* @param array<string, mixed> $payload
*/
private static function triggerChannel(Api $api, string $channel, array $payload): void
{
$api->trigger($channel, self::EVT_JACKPOT_HIT, $payload);
}
private static function publishPublicChannels(string $periodNo, int $userId, string $totalWin, int $resultNumber): void
{
try {
$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')
);
$payload = [
'period_no' => $periodNo,
'user_id' => $userId,
'total_win_amount' => $totalWin,
'result_number' => $resultNumber,
'message' => '恭喜玩家命中大奖派彩',
];
self::triggerChannel($api, self::CHANNEL_GAME_PERIOD, $payload);
self::triggerChannel($api, self::CHANNEL_OPERATION_NOTICE, $payload);
} catch (Throwable) {
}
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use support\think\Db;
use Throwable;
use Webman\Push\Api;
/**
* 用户私有频道推送private-user-{uuid}(与移动端接口设计草案 7.1 一致)
*/
final class UserPushService
{
public const EVT_BET_ACCEPTED = 'bet.accepted';
/** 单注开奖结果(含未中奖 win_amount=0 */
public const EVT_BET_SETTLED = 'bet.settled';
public const EVT_WALLET_CHANGED = 'wallet.changed';
/** 命中配置为「大奖」的连胜档派彩(私有频道) */
public const EVT_JACKPOT_HIT = 'jackpot.hit';
private static function channelName(string $uuid): string
{
return 'private-user-' . $uuid;
}
private static function createApi(): Api
{
return 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')
);
}
public static function uuidForUserId(int $userId): ?string
{
if ($userId <= 0) {
return null;
}
$u = Db::name('user')->where('id', $userId)->value('uuid');
return is_string($u) && $u !== '' ? $u : null;
}
/**
* @param array<string, mixed> $data
*/
public static function publish(int $userId, string $event, array $data): void
{
$uuid = self::uuidForUserId($userId);
if ($uuid === null) {
return;
}
try {
self::createApi()->trigger(self::channelName($uuid), $event, $data);
} catch (Throwable) {
}
}
}

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,25 @@
<?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',
'remark' => 'max:255',
];
protected $scene = [
'add' => ['code', 'name', 'agent_mode', 'status', 'remark'],
'edit' => ['name', 'agent_mode', 'status', '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

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

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace app\common\validate;
use think\Validate;
@@ -8,24 +10,14 @@ 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 $message = [
];
/**
* 验证场景
*/
protected $scene = [
'add' => [],
'edit' => [],
'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

@@ -1,145 +0,0 @@
<?php
namespace app\common\validate;
use think\Validate;
class GameRewardConfig extends Validate
{
protected $failException = true;
/**
* 验证规则
*/
protected $rule = [
'game_channel_id' => 'require|integer|gt:0',
'tier_reward_form' => 'require|checkTierRewardForm',
'bigwin_form' => 'require|checkBigwinForm',
];
/**
* 提示消息
*/
protected $message = [
'game_channel_id.require' => '请选择渠道',
'game_channel_id.integer' => '渠道参数格式错误',
'game_channel_id.gt' => '渠道参数格式错误',
'tier_reward_form.require' => '档位奖励表单不能为空',
'bigwin_form.require' => '超级大奖表单不能为空',
];
/**
* 验证场景
*/
protected $scene = [
'add' => ['game_channel_id', 'tier_reward_form', 'bigwin_form'],
'edit' => ['game_channel_id', 'tier_reward_form', 'bigwin_form'],
/** 渠道表单页:渠道由后端固定,仅校验两份 JSON */
'channel_form' => ['tier_reward_form', 'bigwin_form'],
];
private function parseJsonArray(mixed $value, string $label): array|string
{
if (!is_string($value) || trim($value) === '') {
return $label . '不能为空';
}
try {
$decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
return $label . '必须为合法 JSON';
}
if (!is_array($decoded)) {
return $label . '格式错误';
}
return $decoded;
}
protected function checkTierRewardForm($value, $rule, array $data = []): bool|string
{
$decoded = $this->parseJsonArray($value, '档位奖励表单');
if (!is_array($decoded)) {
return $decoded;
}
$expectedGrid = range(5, 30);
if (count($decoded) !== 26) {
return '档位奖励表单必须固定 26 条';
}
$allowTiers = ['T1', 'T2', 'T3', 'T4', 'T5'];
foreach ($decoded as $idx => $row) {
$rowNo = $idx + 1;
if (!is_array($row)) {
return '档位奖励表单第' . $rowNo . '条格式错误';
}
$gridNumber = $row['grid_number'] ?? null;
$uiText = $row['ui_text'] ?? null;
$realEv = $row['real_ev'] ?? null;
$tier = $row['tier'] ?? null;
if (!is_numeric($gridNumber)) {
return '档位奖励表单第' . $rowNo . '条点数必须为数字';
}
$gridInt = intval(strval($gridNumber));
if ($gridInt !== $expectedGrid[$idx]) {
return '档位奖励表单点数必须固定为 5-30 且不可修改';
}
if (!is_string($uiText) || trim($uiText) === '') {
return '档位奖励表单第' . $rowNo . '条显示文本不能为空';
}
if ($realEv === null || $realEv === '' || !is_numeric($realEv)) {
return '档位奖励表单第' . $rowNo . '条实际中奖必须为数字';
}
if (!is_string($tier) || !in_array($tier, $allowTiers, true)) {
return '档位奖励表单第' . $rowNo . '条档位只能是 T1-T5';
}
}
return true;
}
protected function checkBigwinForm($value, $rule, array $data = []): bool|string
{
$decoded = $this->parseJsonArray($value, '超级大奖表单');
if (!is_array($decoded)) {
return $decoded;
}
$expectedGrid = [5, 10, 15, 20, 25, 30];
if (count($decoded) !== 6) {
return '超级大奖表单必须固定 6 条';
}
foreach ($decoded as $idx => $row) {
$rowNo = $idx + 1;
if (!is_array($row)) {
return '超级大奖表单第' . $rowNo . '条格式错误';
}
$gridNumber = $row['grid_number'] ?? null;
$uiText = $row['ui_text'] ?? null;
$realEv = $row['real_ev'] ?? null;
$tier = $row['tier'] ?? null;
if (!is_numeric($gridNumber)) {
return '超级大奖表单第' . $rowNo . '条点数必须为数字';
}
$gridInt = intval(strval($gridNumber));
if ($gridInt !== $expectedGrid[$idx]) {
return '超级大奖表单点数必须固定为 5、10、15、20、25、30 且不可修改';
}
if (!is_string($uiText) || trim($uiText) === '') {
return '超级大奖表单第' . $rowNo . '条显示文本不能为空';
}
if ($realEv === null || $realEv === '' || !is_numeric($realEv)) {
return '超级大奖表单第' . $rowNo . '条实际中奖必须为数字';
}
if (!is_string($tier) || $tier !== 'BIGWIN') {
return '超级大奖表单第' . $rowNo . '条档位必须为 BIGWIN';
}
}
return true;
}
}

View File

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

View File

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

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

@@ -161,6 +161,20 @@ if (!function_exists('get_auth_token')) {
}
}
}
$authorization = $request->header('authorization');
if (is_string($authorization) && preg_match('/^\s*Bearer\s+(.+?)\s*$/i', $authorization, $matches) === 1) {
$tokens[] = $matches[1];
}
foreach (['user_token', 'user-token', 'access_token', 'access-token'] as $key) {
$headerToken = $request->header($key);
if ($headerToken) {
$tokens[] = $headerToken;
}
$paramToken = $request->get($key) ?? $request->post($key);
if ($paramToken) {
$tokens[] = $paramToken;
}
}
return $tokens[0] ?? '';
}
}
@@ -170,7 +184,7 @@ if (!function_exists('get_controller_path')) {
* 从 Request 或路由获取控制器路径(等价于 ThinkPHP controllerPath
* 优先从 $request->controllerWebman 路由匹配时设置)解析,否则从 path 解析
* @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
{
@@ -188,8 +202,7 @@ if (!function_exists('get_controller_path')) {
$parts = explode('\\', $relative);
$path = [];
foreach ($parts as $p) {
// 与 BuildAdmin admin_rule.name 一致:多段类名用 camelCase如 auth/adminLog不用 admin_log
$path[] = lcfirst($p);
$path[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $p));
}
return implode('/', $path);
}
@@ -216,10 +229,10 @@ if (!function_exists('get_controller_path')) {
$dotPos = strpos($seg, '.');
$mod = substr($seg, 0, $dotPos);
$ctrl = substr($seg, $dotPos + 1);
$normalized[] = lcfirst($mod);
$normalized[] = lcfirst($ctrl);
$normalized[] = strtolower($mod);
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $ctrl));
} else {
$normalized[] = lcfirst($seg);
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $seg));
}
}
return implode('/', $normalized);

View File

@@ -0,0 +1,21 @@
<?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::finalizePayoutGrace();
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

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

View File

@@ -44,7 +44,7 @@ return [
// 默认驱动方式
'default' => 'mysql',
// 加密key
'key' => 'u4w3NzEr5QTv2ygjYOoMVZ6snKAePxJp',
'key' => 'L1iYVS0PChKA9pjcFdmOGb4zfDIHo5xw',
// 加密方式
'algo' => 'ripemd160',
// 驱动

28
config/game_hot_cache.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/**
* 游戏侧热点数据 Redis 缓存(用户 / game_config / game_record
*
* 环境变量见 .env-example 中 GAME_HOT_CACHE_*
*/
$envInt = static function (string $key, int $default): int {
$raw = env($key, null);
if ($raw === null || $raw === '') {
return $default;
}
$n = filter_var($raw, FILTER_VALIDATE_INT);
return ($n === false || $n < 1) ? $default : $n;
};
return [
'enabled' => filter_var(env('GAME_HOT_CACHE_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
/** game_config 行缓存(秒),后台修改会主动删除 */
'ttl_game_config' => $envInt('GAME_HOT_CACHE_TTL_GAME_CONFIG', 86400),
/** game_record 行缓存(秒) */
'ttl_game_record' => $envInt('GAME_HOT_CACHE_TTL_GAME_RECORD', 60),
/** user 行缓存(秒),余额/连胜变更会主动删除 */
'ttl_user' => $envInt('GAME_HOT_CACHE_TTL_USER', 90),
];

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 [
'webman' => [
'handler' => Http::class,
'listen' => 'http://0.0.0.0:8787',
'listen' => 'http://0.0.0.0:7979',
'count' => cpu_count() * 4,
'user' => '',
'group' => '',
@@ -35,6 +35,18 @@ return [
'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
'monitor' => [
'handler' => app\process\Monitor::class,

View File

@@ -68,6 +68,9 @@ Route::get('/install/index', function () use ($installLockFileForInstall, $insta
// api/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 登录/注册)
Route::add(['GET', 'POST'], '/api/user/checkIn', [\app\api\controller\User::class, 'checkIn']);
Route::post('/api/user/logout', [\app\api\controller\User::class, 'logout']);
@@ -108,6 +111,42 @@ Route::post('/api/account/retrievePassword', [\app\api\controller\Account::class
// api/ems
Route::post('/api/ems/send', [\app\api\controller\Ems::class, 'send']);
// ==================== 移动端用户接口(统一收口到 /api/user/* ====================
// 约定:移动端所有业务接口一律使用 POST 调用;查询类同时兼容 GET 便于浏览器调试。
Route::post('/api/user/register', [\app\api\controller\Auth::class, 'userRegister']);
Route::post('/api/user/login', [\app\api\controller\Auth::class, 'userLogin']);
Route::post('/api/user/refreshToken', [\app\api\controller\Auth::class, 'tokenRefresh']);
Route::add(['GET', 'POST'], '/api/user/profile', [\app\api\controller\Account::class, 'userProfile']);
Route::post('/api/user/retrievePassword', [\app\api\controller\Account::class, 'retrievePassword']);
// 兼容旧移动端路径,后续客户端切换完成后可移除
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::add(['GET', 'POST'], '/api/account/userProfile', [\app\api\controller\Account::class, 'userProfile']);
Route::add(['GET', 'POST'], '/api/game/lobbyInit', [\app\api\controller\Game::class, 'lobbyInit']);
Route::add(['GET', 'POST'], '/api/game/dictionaryList', [\app\api\controller\Game::class, 'dictionaryList']);
Route::add(['GET', 'POST'], '/api/game/periodHistory', [\app\api\controller\Game::class, 'periodHistory']);
Route::add(['GET', 'POST'], '/api/game/periodCurrent', [\app\api\controller\Game::class, 'periodCurrent']);
Route::post('/api/game/betPlace', [\app\api\controller\Game::class, 'betPlace']);
Route::add(['GET', 'POST'], '/api/game/betMyOrders', [\app\api\controller\Game::class, 'betMyOrders']);
Route::add(['GET', 'POST'], '/api/wallet/balanceSummary', [\app\api\controller\Wallet::class, 'balanceSummary']);
Route::add(['GET', 'POST'], '/api/wallet/recordList', [\app\api\controller\Wallet::class, 'recordList']);
Route::add(['GET', 'POST'], '/api/finance/depositTierList', [\app\api\controller\Finance::class, 'depositTierList']);
Route::post('/api/finance/depositCreate', [\app\api\controller\Finance::class, 'depositCreate']);
Route::add(['GET', 'POST'], '/api/finance/depositDetail', [\app\api\controller\Finance::class, 'depositDetail']);
Route::add(['GET', 'POST'], '/api/finance/depositList', [\app\api\controller\Finance::class, 'depositList']);
Route::post('/api/finance/withdrawCreate', [\app\api\controller\Finance::class, 'withdrawCreate']);
Route::add(['GET', 'POST'], '/api/finance/withdrawDetail', [\app\api\controller\Finance::class, 'withdrawDetail']);
Route::add(['GET', 'POST'], '/api/finance/withdrawList', [\app\api\controller\Finance::class, 'withdrawList']);
Route::get('/api/notice/noticeList', [\app\api\controller\Notice::class, 'noticeList']);
Route::get('/api/notice/noticeDetail', [\app\api\controller\Notice::class, 'noticeDetail']);
Route::get('/api/notice/noticeConfirm', [\app\api\controller\Notice::class, 'noticeConfirm']);
// ==================== Admin 路由 ====================
// Admin 多为 JSON API前端可能用 GET 传参查列表、POST 提交表单,使用 any 确保兼容
@@ -169,32 +208,8 @@ Route::post('/admin/auth/rule/edit', [\app\admin\controller\auth\Rule::class, 'e
Route::post('/admin/auth/rule/del', [\app\admin\controller\auth\Rule::class, 'del']);
Route::get('/admin/auth/rule/select', [\app\admin\controller\auth\Rule::class, 'select']);
// admin/auth/adminLog(兼容 ThinkPHP 风格 /admin/auth.AdminLog/index
// 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
Route::get('/admin/routine/config/index', [\app\admin\controller\routine\Config::class, 'index']);
@@ -246,21 +261,39 @@ Route::get('/admin/security/dataRecycleLog/index', [\app\admin\controller\securi
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::add(['GET', 'POST', 'HEAD'], '/admin/game.User/defaultWeightPresets', [\app\admin\controller\game\User::class, 'defaultWeightPresets']);
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.User/defaultWeightByChannel', [\app\admin\controller\game\User::class, 'defaultWeightByChannel']);
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.user/defaultWeightPresets', [\app\admin\controller\game\User::class, 'defaultWeightPresets']);
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.user/defaultWeightByChannel', [\app\admin\controller\game\User::class, 'defaultWeightByChannel']);
// 游戏渠道:删除前统计关联数据(显式路由,避免动态路由 404
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.Channel/deleteRelatedCounts', [\app\admin\controller\game\Channel::class, 'deleteRelatedCounts']);
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.channel/deleteRelatedCounts', [\app\admin\controller\game\Channel::class, 'deleteRelatedCounts']);
// ==================== 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 ====================
// 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用
Route::add(
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
'/admin/{controllerPart:[a-zA-Z]+\\.[a-zA-Z0-9]+}/{action:[^/]+}',
'/admin/{controllerPart:[a-zA-Z]+\\.[a-zA-Z0-9]+}/{action}',
function (\Webman\Http\Request $request, string $controllerPart, string $action) {
$pos = strpos($controllerPart, '.');
if ($pos === false) {
@@ -268,21 +301,8 @@ Route::add(
}
$module = substr($controllerPart, 0, $pos);
$controller = substr($controllerPart, $pos + 1);
// game.user / game.User 等:小写控制器名需解析为 User.phpPSR-4 类名 StudlyCase
$class = null;
$candidates = array_unique([
$controller,
ucfirst($controller),
ucfirst(strtolower($controller)),
]);
foreach ($candidates as $base) {
$tryClass = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $base;
if (class_exists($tryClass)) {
$class = $tryClass;
break;
}
}
if ($class === null) {
$class = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $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)) {
@@ -304,26 +324,6 @@ Route::add(
}
);
// 兜底closure 显式调用,避免路由绑定在某些情况下仍落到 404
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.User/defaultWeightByChannel/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\User())->defaultWeightByChannel($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.user/defaultWeightByChannel/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\User())->defaultWeightByChannel($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.User/defaultWeightPresets/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\User())->defaultWeightPresets($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.user/defaultWeightPresets/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\User())->defaultWeightPresets($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.Channel/deleteRelatedCounts/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\Channel())->deleteRelatedCounts($request);
});
Route::add(['GET', 'POST', 'HEAD'], '/admin/game.channel/deleteRelatedCounts/', function (\Webman\Http\Request $request) {
return (new \app\admin\controller\game\Channel())->deleteRelatedCounts($request);
});
// ==================== CORS 预检OPTIONS ====================
// 放在最后注册;显式加上前端会请求的路径,再加固通配
Route::add('OPTIONS', '/api/index/index', [\app\common\middleware\AllowCrossDomain::class, 'optionsResponse']);

View File

@@ -42,11 +42,11 @@ return [
// 服务器地址
'hostname' => $env('database.hostname', '127.0.0.1'),
// 数据库名(与 database.php / .env 一致)
'database' => $env('database.database', 'webman-buildadmin-dafuweng'),
'database' => $env('database.database', 'buildadmin-webman'),
// 用户名(与 .env DATABASE_USERNAME 一致,默认勿用 root 以免与本机 MySQL 不符)
'username' => $env('database.username', 'webman-buildadmin-dafuweng'),
'username' => $env('database.username', 'buildadmin-webman'),
// 密码(与 .env DATABASE_PASSWORD 一致)
'password' => $env('database.password', '6dzMaX32Xdsc4DjS'),
'password' => $env('database.password', '123456'),
// 端口
'hostport' => $env('database.hostport', '3306'),
// 数据库连接参数MYSQL_ATTR_USE_BUFFERED_QUERY 避免 "Cannot execute queries while other unbuffered queries are active"

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

@@ -17,8 +17,6 @@ class InstallData extends AbstractMigration
$this->menuRule();
$this->securityDataRecycle();
$this->securitySensitiveData();
$this->user();
$this->userGroup();
$this->userRule();
}
@@ -231,8 +229,17 @@ class InstallData extends AbstractMigration
public function menuRule(): void
{
if (!$this->hasTable('menu_rule')) return;
$table = $this->table('menu_rule');
// Install 迁移在已存在 admin_rule旧版表名时会跳过创建 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 = [
[
'id' => '1',
@@ -447,245 +454,6 @@ class InstallData extends AbstractMigration
'updatetime' => $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',
'type' => 'menu_dir',
@@ -1155,7 +923,7 @@ class InstallData extends AbstractMigration
'createtime' => $this->nowTime,
],
];
$exist = Db::name('menu_rule')->where('id', 1)->value('id');
$exist = Db::name($ruleTable)->where('id', 1)->value('id');
if (!$exist) {
$table->insert($rows)->saveData();
}
@@ -1205,16 +973,6 @@ class InstallData extends AbstractMigration
'updatetime' => $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,
'name' => '数据回收规则',
@@ -1248,18 +1006,6 @@ class InstallData extends AbstractMigration
'updatetime' => $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,
'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
{
$table = $this->table('user_rule');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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:
- "./:/app"
ports:
- "8787:8787"
- "7979:7979"
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
upstream webman {
server 127.0.0.1:8787;
server 127.0.0.1:7979;
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';
if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv\Dotenv::createUnsafeImmutable($baseDir)->load();
} else {
Dotenv\Dotenv::createMutable($baseDir)->load();
}
Dotenv\Dotenv::createMutable($baseDir)->load();
}
if (!function_exists('env')) {

View File

@@ -10,11 +10,8 @@ $baseDir = __DIR__;
require $baseDir . '/vendor/autoload.php';
if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv\Dotenv::createUnsafeImmutable($baseDir)->load();
} else {
Dotenv\Dotenv::createMutable($baseDir)->load();
}
// 必须用 MutableWebman Worker 已加载过时Immutable 不会覆盖 $_ENV会导致 Phinx 与 Db::name() 前缀/库名不一致
Dotenv\Dotenv::createMutable($baseDir)->load();
}
if (!function_exists('env')) {

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
install-end

File diff suppressed because one or more lines are too long

View File

@@ -1,147 +1,163 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/install/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BuildAdmin-安装</title>
<script>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/install/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BuildAdmin-安装</title>
<script>
(function(){
var urls = { adminUrl: '', frontUrl: '' };
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 || ''; }
}).catch(function(){});
function ensureQuickPanel() {
if (!urls.adminUrl && !urls.frontUrl) return;
if (document.getElementById('__ba_install_quick_urls__')) return;
var wrap = document.createElement('div');
wrap.id = '__ba_install_quick_urls__';
wrap.style.position = 'fixed';
wrap.style.right = '16px';
wrap.style.bottom = '16px';
wrap.style.zIndex = '99999';
wrap.style.maxWidth = '560px';
wrap.style.fontFamily = 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"';
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">' +
'<div style="font-weight:600;color:#111">安装完成快捷入口</div>' +
'<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>' +
'<div style="padding:12px;display:flex;flex-direction:column;gap:10px">' +
'<div>' +
var urls = { adminUrl: '', frontUrl: '' };
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 || ''; }
}).catch(function(){});
function ensureQuickPanel() {
if (!urls.adminUrl && !urls.frontUrl) return;
if (document.getElementById('__ba_install_quick_urls__')) return;
var wrap = document.createElement('div');
wrap.id = '__ba_install_quick_urls__';
wrap.style.position = 'fixed';
wrap.style.right = '16px';
wrap.style.bottom = '16px';
wrap.style.zIndex = '99999';
wrap.style.maxWidth = '560px';
wrap.style.fontFamily = 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"';
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">' +
'<div style="font-weight:600;color:#111">安装完成快捷入口</div>' +
'<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>' +
'<div style="padding:12px;display:flex;flex-direction:column;gap:10px">' +
'<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="admin" target="_blank" rel="noreferrer" style="color:#1677ff;text-decoration:none;word-break:break-all"></a>' +
'<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>' +
'<a data-k="admin" target="_blank" rel="noreferrer" style="color:#1677ff;text-decoration:none;word-break:break-all"></a>' +
'<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>' +
'</div>' +
'</div>' +
'<div>' +
'</div>' +
'<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>' +
'<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);
'</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(); });
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);
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);
});
});
}
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() {
if (!urls.adminUrl && !urls.frontUrl) return;
document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){
var v = (inp.value || '').trim();
if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 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;
});
ensureQuickPanel();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); });
else setInterval(applyUrls, 800);
function applyUrls() {
if (!urls.adminUrl && !urls.frontUrl) return;
document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){
var v = (inp.value || '').trim();
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);
})();
(function(){
function closeMigrateModal() {
if (!document.body) return;
var txt = document.body.innerText || document.body.textContent || '';
if (txt.indexOf('数据表迁移失败') < 0 && txt.indexOf('数据表自动迁移失败') < 0) return;
var btns = document.body.querySelectorAll('button, [role="button"], .el-button');
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.textContent && b.textContent.indexOf('继续安装') >= 0) { b.click(); return; }
function closeMigrateModal() {
if (!document.body) return;
var txt = document.body.innerText || document.body.textContent || '';
if (txt.indexOf('数据表迁移失败') < 0 && txt.indexOf('数据表自动迁移失败') < 0) return;
var btns = document.body.querySelectorAll('button, [role="button"], .el-button');
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);
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>
</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>

View File

@@ -1,38 +0,0 @@
{
"tier_reward_form": [
{"grid_number": 5, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 6, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 7, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 8, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 9, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 10, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 11, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 12, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 13, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 14, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 15, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 16, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 17, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 18, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 19, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 20, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 21, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 22, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 23, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 24, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 25, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 26, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 27, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 28, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 29, "ui_text": "", "real_ev": "", "tier": "T1"},
{"grid_number": 30, "ui_text": "", "real_ev": "", "tier": "T1"}
],
"bigwin_form": [
{"grid_number": 5, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 10, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 15, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 20, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 25, "ui_text": "", "real_ev": "", "tier": "BIGWIN"},
{"grid_number": 30, "ui_text": "", "real_ev": "", "tier": "BIGWIN"}
]
}

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;
}
/**
* 入口为 /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 优先)
* @param string|null $name 参数名null 返回全部

View File

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

View File

@@ -2,6 +2,53 @@ import createAxios from '/@/utils/axios'
export const url = '/admin/Dashboard/'
export interface DashboardTrend {
days: string[]
new_users: number[]
deposit_amount: string[]
bet_amount: string[]
}
export interface DashboardChannelShareItem {
name: string
value: number
}
/** 成功充值金额按渠道value 为两位小数字符串) */
export interface DashboardDepositAmountChannelItem {
name: string
value: string
}
export interface DashboardRecentUser {
id: number
username: string
create_time: number
channel_name: string
head_image: string
}
export interface DashboardStats {
user_total: number
user_new_today: number
user_new_yesterday: number
user_new_growth_pct: number | null
deposit_today_amount: string
deposit_today_count: number
withdraw_pending: number
bet_today_amount: string
bet_today_count: number
}
export interface DashboardPayload {
remark: string
stats: DashboardStats
trend: DashboardTrend
channel_share: DashboardChannelShareItem[]
deposit_amount_channel_share: DashboardDepositAmountChannelItem[]
recent_users: DashboardRecentUser[]
}
export function index() {
return createAxios({
url: url + 'index',

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

@@ -109,7 +109,7 @@ export function postLogout() {
export function retrievePassword(params: anyObj) {
return createAxios(
{
url: accountUrl + 'retrievePassword',
url: userUrl + 'retrievePassword',
method: 'POST',
data: params,
},

View File

@@ -2,6 +2,7 @@
import { isArray, isString } from 'lodash-es'
import type { PropType, VNode } from 'vue'
import { computed, createVNode, defineComponent, reactive, resolveComponent } from 'vue'
import { dayjs } from 'element-plus'
import { getArea } from '/@/api/common'
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
import { inputTypes } from '/@/components/baInput'
@@ -40,6 +41,29 @@ export default defineComponent({
},
emits: ['update:modelValue'],
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
const attrs = computed(() => {
return { ...props.attr, ...props.data }
@@ -186,6 +210,7 @@ export default defineComponent({
valueFormat = 'YYYY'
break
}
const valueComputed = computed(() => normalizeDateTimeValue(props.modelValue, valueFormat))
return () =>
createVNode(
resolveComponent('el-date-picker'),
@@ -194,7 +219,7 @@ export default defineComponent({
type: props.type,
'value-format': valueFormat,
...attrs.value,
modelValue: props.modelValue,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
},
slots
@@ -300,7 +325,7 @@ export default defineComponent({
'year',
() => {
return () => {
const valueComputed = computed(() => (!props.modelValue ? null : '' + props.modelValue))
const valueComputed = computed(() => normalizeDateTimeValue(props.modelValue, 'YYYY'))
return createVNode(
resolveComponent('el-date-picker'),
{

View File

@@ -8,9 +8,9 @@ import { adminBaseRoutePath } from '/@/router/static/adminBase'
export default {
'/': ['./frontend/${lang}/index.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'],
// /admin/game/rewardConfig 会加载 rewardConfig.ts页面标题等仍在 rewardConfigForm.tsgame.rewardConfigForm
[adminBaseRoutePath + '/game/rewardConfig']: ['./backend/${lang}/game/rewardConfigForm.ts'],
/** 推送测试三页:共享 test.push.* 文案(见 PushChannelTestPage.vue */
[adminBaseRoutePath + '/test/pushGamePeriod']: ['./backend/${lang}/test/push.ts'],
[adminBaseRoutePath + '/test/pushOperationNotice']: ['./backend/${lang}/test/push.ts'],
[adminBaseRoutePath + '/test/pushPrivateUser']: ['./backend/${lang}/test/push.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,8 @@ export default {
avatar: 'Avatar',
email: 'Email',
mobile: 'Mobile Number',
invite_code: 'Invite code',
'Please select exactly one group': 'Please select exactly one group',
'Last login': 'Last login',
Password: 'Password',
'Please leave blank if not modified': 'Please leave blank if you do not modify.',

View File

@@ -1,9 +1,16 @@
export default {
GroupName: 'Group Name',
'Group name': 'Group Name',
channel_id: 'Channel',
channel_name: 'Channel name',
channel_admin: 'Channel admin',
channel_auto_bind: 'Will bind to the current account channel automatically',
channel_inherit_hint:
'Sub groups do not pick a channel separately: saving uses the parent group channel; changing parent syncs automatically.',
system_group_no_channel: 'System (no channel)',
jurisdiction: 'Permissions',
'Parent group': 'Superior group',
'The parent group cannot be the group itself': 'The parent group cannot be the group itself',
'Manage subordinate role groups here':
'You can only manage role groups under your branch in the tree; peers, other branches and ancestors are out of scope. You can still only assign permission nodes you own.',
'In managing a subordinate role group (excluding a peer role group), you have all the rights of a subordinate role group and additional rights',
}

View File

@@ -0,0 +1,87 @@
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.',
admin_select_tip: 'Only administrators within your data permission scope are listed; search by username.',
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',
share_config: 'Share config',
share_config_title: 'Channel admin share config',
share_config_tip: 'Only enabled rows participate in settlement split, and enabled share total must equal 100%.',
share_rate_percent: 'Share rate(%)',
share_total_enabled: 'Enabled total',
share_total_must_100: 'Enabled share total must equal 100%',
admin_id_placeholder: 'Select an admin (within your permission scope)',
admin__username: 'Person in charge',
admin_group_names: 'Role group',
admin_group_paths: 'Role hierarchy',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id,code,name',
}

View File

@@ -0,0 +1,29 @@
export default {
title: 'Deposit Tiers',
desc: 'Configure the deposit tiers players can pick when creating a deposit order. In the third-party payment mode, only tier specs (name, amount, bonus, description) are maintained; receiving accounts are no longer stored here. Maintain both Chinese and English text for the title and description: the mobile API returns the language matching the request `lang` header, falling back to Chinese if English is blank. Changes take effect immediately.',
btn_add: 'Add Tier',
btn_save: 'Save',
btn_remove: 'Delete',
confirm_remove: 'Delete this deposit tier?',
tier_id: 'Tier ID',
auto_id: '(generated on save)',
sort: 'Sort',
status: 'Enabled',
title_col: 'Title (ZH)',
title_ph: 'e.g. 新手首充、VIP 高额充值',
title_en_col: 'Title (EN)',
title_en_ph: 'e.g. Starter Pack, VIP Recharge',
amount: 'Amount',
amount_ph: 'e.g. 100.00',
bonus_amount: 'Bonus',
bonus_ph: 'e.g. 20.00, use 0 if none',
desc_col: 'Description (ZH)',
desc_ph: 'Optional Chinese description, up to 255 chars',
desc_en_col: 'Description (EN)',
desc_en_ph: 'Optional English description, up to 255 chars',
currency: '',
operate: 'Action',
err_title: 'Row {no}: Chinese title is required',
err_amount: 'Row {no}: amount must be a number greater than 0',
err_bonus: 'Row {no}: bonus must be a number no less than 0',
}

View File

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

View File

@@ -0,0 +1,8 @@
export default {
desc: 'Streak levels 110: payout = bet total × odds_factor. Jackpot rows trigger jackpot.hit on the user private channel, public-game-period, and public-operation-notice when won.',
btn_save: 'Save',
streak: 'Streak (rounds)',
odds_factor: 'Odds factor',
is_jackpot: 'Jackpot',
err_factor: 'Row {no}: odds factor must be ≥ 1',
}

View File

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

View File

@@ -36,4 +36,27 @@ export default {
second: 'Second',
day: 'Day',
'Number of attachments Uploaded': 'Number of attachments upload',
stat_user_total: 'Total users',
stat_new_today: 'New users today',
stat_deposit_today: 'Deposits today (success)',
stat_withdraw_pending: 'Withdrawals pending review',
stat_hint_pending: 'Pending',
chart_new_user_deposit: 'Last 7 days: new users & daily deposits',
chart_bet_7d: 'Last 7 days: bet amount',
chart_channel_users: 'Users by channel',
chart_deposit_status: 'Deposit order status',
chart_deposit_amount_channel: 'Deposit amount by channel',
recent_users: 'Recent sign-ups',
no_data: 'No data',
bet_today_line: "Today's bet volume",
orders_unit: ' orders',
deposit_orders_today: '{n} successful today',
series_new_users: 'New users',
series_deposit_amount: 'Deposit amount',
series_bet_amount: 'Bet amount',
load_failed: 'Failed to load statistics. Please try again later.',
seconds_ago: 's ago',
minutes_ago: 'm ago',
hours_ago: 'h ago',
}

View File

@@ -0,0 +1,27 @@
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',
total_amount: 'Total bet amount',
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

@@ -1,21 +0,0 @@
export default {
delete_confirm_title: 'Delete channel',
delete_confirm_related:
'This will also delete {countConfig} game config row(s) and {countUser} game user row(s) under this channel. This cannot be undone. Continue?',
id: 'id',
code: 'code',
name: 'name',
user_count: 'user_count',
profit_amount: 'profit_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__username: 'username',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id,code,name',
}

View File

@@ -1,33 +1,14 @@
export default {
ID: 'ID',
channel_id: 'channel_id',
channel__name: 'name',
group: 'group',
name: 'name',
title: 'title',
value: 'value',
'weight key': 'Key',
'weight value': 'Value',
'weight sum must 100': 'The sum of weights for default_tier_weight / default_kill_score_weight must equal 100',
'weight sum max 100': 'The sum of weights for default_tier_weight / default_kill_score_weight must not exceed 100',
'weight each max 100': 'Each weight value must not exceed 100',
'weight each max 10000': 'Weight must not exceed 10000',
'bigwin weight must integer': 'Each big win weight must be an integer',
'name opt game_rule': 'game_rule',
'name opt game_rule_en': 'game_rule_en',
'name opt default_tier_weight': 'default_tier_weight',
'name opt default_kill_score_weight': 'default_kill_score_weight',
'name opt default_bigwin_weight': 'default_bigwin_weight',
default_bigwin_weight_help:
'Big win weight range is 010000: 0 means never, 10000 means 100%. Totals 5 and 30 are guaranteed leopard (豹子); fixed at 10000 and cannot be changed.',
'bigwin weight each 0 10000': 'Each weight (except fixed keys) must be between 0 and 10000',
'bigwin weight locked 5 30': 'Weights for totals 5 and 30 are fixed at 10000',
'weight value numeric': 'Weight values must be valid numbers',
sort: 'sort',
instantiation: 'instantiation',
'instantiation 0': '---',
'instantiation 1': 'YES',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'ID',
'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,29 @@
export default {
tip: 'Realtime bets; after lock the AI default number is fixed for this round and used at countdown end (or your scheduled number). After draw, ~3s payout grace then next round.',
current_record: 'Current round',
ai_default_number: 'AI default number',
pending_draw: 'Scheduled draw number',
countdown: 'Countdown',
bet_countdown: 'Bet left',
draw_countdown: 'Draw left',
payout_countdown: 'Payout left',
payout_na: '—',
payout_phase: 'Payout in progress',
action_panel: 'Actions',
manual_draw_number: 'Scheduled draw',
btn_calc: 'Calculate PnL',
btn_draw: 'Schedule draw',
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',
total_amount: 'Total bet amount',
streak_at_bet: 'Streak at bet',
}

View File

@@ -0,0 +1,29 @@
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',
platform_profit_amount: 'Round P/L (platform)',
winner_user_count: 'Winners',
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

@@ -1,25 +0,0 @@
export default {
id: 'id',
game_channel_id: 'game_channel_id',
gamechannel__name: 'name',
tier_reward_form: 'tier_reward_form',
bigwin_form: 'bigwin_form',
create_time: 'create_time',
update_time: 'update_time',
grid_number: 'grid_number',
ui_text: 'ui_text',
ui_text_en: 'ui_text (EN)',
remark: 'remark',
real_ev: 'real_ev',
tier: 'tier',
tier_t1: 'T1',
tier_t2: 'T2',
tier_t3: 'T3',
tier_t4: 'T4',
tier_t5: 'T5',
tier_bigwin: 'BIGWIN',
tier_reward_form_help:
'Fixed 26 rows (5-30), no add/delete. Editable: ui_text, ui_text_en, real_ev, tier, remark.',
bigwin_form_help: 'Fixed 6 rows (5,10,15,20,25,30), no add/delete. Editable: ui_text, real_ev.',
'quick Search Fields': 'id',
}

View File

@@ -1,46 +0,0 @@
export default {
title: 'Game reward config',
intro:
'Super admins can maintain the global default template (game_channel_id=0). New channels copy this reward config first; if no template row exists yet, the built-in JSON template is used. Channel admins can only edit their own channel.',
super_scope_label: 'Scope',
super_scope_template: 'Global default template',
super_scope_channel: 'Specific channel',
super_scope_hint: 'Pick a channel below, then click Refresh to load.',
btn_add: 'Save',
btn_reset: 'Reset',
btn_gen_tier: 'Generate tier board',
btn_gen_weight: 'Generate reward weight table',
gen_tier_title: 'Generate reward index by rules',
gen_tier_rule:
'[Same logic as reward comparison]\n' +
'• 26 cells id 025; grid_number 530 unique.\n' +
'• Roll D: start_index = id where grid_number=D; CW end=(start+D)%26; CCW end=startD, if <0 then +26.\n' +
'• Comparison rows use D as dice points; tier/settlement/copy from landing cell.\n\n' +
'[Leopard rolls]\n' +
'For D in 5,10,15,20,25,30, CW/CCW landing tier cannot be T4/T5.\n\n' +
'[Settlement vs tier]\n' +
'<0→T4; 0100→T3; 100200→T2; >200→T1; T5 amount 0. Below you set per-tier amounts; T1T4 zh/en display text = amount string; T5 fixed.\n\n' +
'[Inputs]\n' +
'Counts: T1/T4/T5 fixed per direction; T2 minimum per direction.',
gen_tier_footer_hint: 'T2 is a lower bound; if generation fails, relax counts. You can still edit the table after.',
gen_tier_cancel: 'Cancel',
gen_tier_submit: 'Generate and save',
gen_t1_label: 'T1 grand prize',
gen_t1_fixed: 'Fixed count (CW/CCW)',
gen_t2_label: 'T2 small profit / break-even',
gen_t2_min: 'Minimum count',
gen_t3_label: 'T3 commission',
gen_t3_amt_only: 'Settlement amount',
gen_t4_label: 'T4 penalty',
gen_t4_fixed: 'Fixed count (CW/CCW)',
gen_t5_label: 'T5 try again',
gen_t5_fixed: 'Fixed count (CW/CCW)',
gen_settlement: 'Settlement amount',
gen_dir_cw: 'Clockwise',
gen_dir_ccw: 'Counter-clockwise',
gen_weight_confirm_title: 'Create reward comparison',
gen_weight_confirm_body:
'Rules: start_index = id of the cell whose grid_number equals roll D; CW end_index=(start_index+D)%26; CCW end_index = start_indexD if ≥0 else 26+start_indexD. Existing rows for this channel in game_reward_weight will be deleted, then 52 rows created (D=5..30 × two directions). Tier, settlement, display text and remark come from the landing cell in the tier table. Continue?',
gen_weight_confirm_ok: 'Confirm',
gen_weight_need_channel: 'Select a channel and refresh before generating weights.',
}

View File

@@ -1,26 +0,0 @@
export default {
id: 'id',
game_channel_id: 'game_channel_id',
gamechannel__name: 'name',
direction: 'direction',
'direction 0': 'direction 0',
'direction 1': 'direction 1',
grid_number: 'grid_number',
start_index: 'start_index',
end_index: 'end_index',
ui_text: 'ui_text',
real_ev: 'real_ev',
tier: 'tier',
type: 'type',
'type -2': 'type -2',
'type -1': 'type -1',
'type 0': 'type 0',
'type 1': 'type 1',
'type 2': 'type 2',
'type 3': 'type 3',
remark: 'remark',
weight: 'weight',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id',
}

View File

@@ -1,35 +1,46 @@
export default {
id: 'id',
username: 'username',
password: 'password',
uuid: 'uuid',
phone: 'phone',
remark: 'remark',
coin: 'coin',
tier_weight: 'tier weight',
bigwin_weight: 'big win weight',
tier_weight_preset: 'tier weight preset (game config)',
bigwin_weight_preset: 'big win weight preset (game config)',
'weight value': 'weight value',
'weight value numeric': 'Weight values must be valid numbers',
'weight each max 100': 'Each weight value must not exceed 100',
tier_weight_help: 'Sum of T1~T5 must not exceed 100',
tier_weight_sum_max_100: 'Sum of tier weights (T1~T5) must not exceed 100',
bigwin_weight_help: 'Only requires each item ≤ 10000; points 5 and 30 are guaranteed big wins, fixed to 10000',
bigwin_weight_each_max_10000: 'Each big win weight must not exceed 10000',
ticket_count: 'tickets',
ticket_ante: 'bets',
ticket_count_times: 'times',
'ticket row incomplete': 'Each row must have both ante and count',
'ticket row numeric': 'ante and count must be valid numbers',
status: 'status',
'status 0': 'status 0',
'status 1': 'status 1',
game_channel_id: 'game_channel_id',
gamechannel__name: 'name',
admin_id: 'admin_id',
admin__username: 'username',
create_time: 'create_time',
update_time: 'update_time',
'quick Search Fields': 'id,username,phone',
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_withdraw_coin: 'Total withdraw (coin)',
bet_flow_coin: 'Bet flow (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

@@ -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,33 @@
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',
total_amount: 'Total bet amount',
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,26 @@
export default {
'quick Search Fields': 'Order No./User ID/Pay channel/Tier/Idempotency key',
id: 'ID',
order_no: 'Order No.',
idempotency_key: 'Idempotency key',
user_id: 'User ID',
channel_id: 'Channel ID',
amount: 'Amount',
bonus_amount: 'Bonus',
total_credit: 'Total credit',
status: 'Status',
'status 0': 'Pending',
'status 1': 'Success',
'status 2': 'Failed',
'status 3': 'Canceled',
pay_channel: 'Pay channel',
pay_time: 'Pay time',
deposit_tier_id: 'Deposit tier',
remark: 'Remark',
create_time: 'Created',
update_time: 'Updated',
user_username: 'Username',
channel_name: 'Channel',
detail_title: 'Deposit Order Detail',
close_btn: 'Close',
}

View File

@@ -0,0 +1,36 @@
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: 'User',
channel_name: 'Channel',
review_admin_username: 'Reviewer',
review_title: 'Withdraw review',
review_reject_title: 'Reject withdraw',
review_btn_approve: 'Approve',
review_btn_reject: 'Reject',
review_btn_back: 'Back',
review_btn_confirm_reject: 'Confirm reject',
review_reject_tip: 'Rejected withdrawals will refund the frozen amount back to the user wallet.',
review_reject_placeholder: 'Enter reject reason (visible to the user on mobile history)',
reject_reason_required: 'Please enter reject reason',
already_reviewed: 'This order has already been reviewed',
amount_invalid: 'Apply amount must be greater than 0',
fee_invalid: 'Fee cannot be negative',
fee_exceed_amount: 'Fee cannot exceed apply amount',
}

View File

@@ -0,0 +1,12 @@
export default {
connected: 'Push connected (subscribed)',
disconnected: 'Disconnected or idle',
user_uuid: 'User uuid',
user_uuid_placeholder: '10-char public id as in mobile profile',
btn_connect: 'Connect & subscribe',
btn_disconnect: 'Disconnect',
btn_clear: 'Clear log',
channel_label: 'Channel',
log_title: 'Event log',
log_empty: 'No messages yet. Ensure the push worker is running and the server publishes to this channel.',
}

View File

@@ -0,0 +1,3 @@
export default {
tip: 'Subscribe to public-game-period (global period channel) for period.tick / period.locked / period.opened / period.payout / jackpot.hit. The server must publish to this channel.',
}

View File

@@ -0,0 +1,3 @@
export default {
tip: 'Subscribe to public-operation-notice for broadcast notices such as notice.popout. The server must publish to this channel.',
}

View File

@@ -0,0 +1,3 @@
export default {
tip: 'Subscribe to private-user-{uuid}: enter the player uuid; auth uses /plugin/webman/push/auth like the mobile client. For bet.accepted, wallet.changed, etc.',
}

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,54 @@
export default {
'User name': 'Username',
nickname: 'Nickname',
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',
id: 'ID',
username: 'Username',
password: 'Password',
'Please leave blank if not modified': 'Please leave blank if you do not modify',
'Personal signature': 'Personal signature',
'Login account': 'Login account name',
uuid: 'UUID',
phone: 'Phone',
email: 'Email',
email_placeholder: 'Optional (register with phone or email)',
head_image: 'Avatar',
remark: 'Remark',
coin: 'Coin balance',
coin_placeholder: 'Amounts are displayed with 2 decimals',
total_deposit_coin: 'Total deposit (coin)',
total_withdraw_coin: 'Total withdraw (coin)',
bet_flow_coin: 'Bet flow (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',
wallet_adjust_title: 'Wallet adjustment',
wallet_adjust_op: 'Operation',
wallet_adjust_credit: 'Credit',
wallet_adjust_deduct: 'Deduct',
wallet_adjust_amount: 'Amount',
wallet_adjust_amount_invalid: 'Please enter an amount greater than 0',
wallet_adjust_operator_admin: 'operator admin',
wallet_adjust_default_remark: 'Backend admin ({admin}) {action} {amount} (value)',
}

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,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,8 @@ export default {
avatar: '头像',
email: '电子邮箱',
mobile: '手机号',
invite_code: '邀请码',
'Please select exactly one group': '请选择且仅选择一个角色组',
'Last login': '最后登录',
Password: '密码',
'Please leave blank if not modified': '不修改请留空',

View File

@@ -1,9 +1,14 @@
export default {
GroupName: '组名',
'Group name': '组别名称',
channel_id: '所属渠道',
channel_name: '渠道名称',
channel_admin: '渠道管理员',
channel_auto_bind: '将自动绑定为当前账号所属渠道',
channel_inherit_hint: '子级不单独选渠道:保存时将使用上级分组对应渠道,变更上级时会自动同步。',
system_group_no_channel: '系统级(未绑定渠道)',
jurisdiction: '权限',
'Parent group': '上级分组',
'The parent group cannot be the group itself': '上级分组不能是分组本身',
'Manage subordinate role groups here':
'在此仅可管理「角色组树」中您所在组之下的下级组;同级、其他分支与上级组不在管理范围内。分配权限时仍只能勾选您自身拥有的节点。',
'Manage subordinate role groups here': '在此管理下级角色组(您拥有下级角色组的所有权限并且拥有额外的权限,不含同级)',
}

View File

@@ -0,0 +1,87 @@
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: '按渠道分组展示可关联的管理员账号,请在「渠道名称」下选择具体管理员(叶子节点)。渠道负责人仅对应一名管理员。',
admin_select_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: '备注',
share_config: '分配比例',
share_config_title: '渠道管理员分配比例',
share_config_tip: '仅启用项参与结算拆分且启用项占比总和必须等于100%。',
share_rate_percent: '分配比例(%)',
share_total_enabled: '启用项合计',
share_total_must_100: '启用项分配比例总和必须等于100%',
admin_id_placeholder: '请选择管理员(仅当前权限范围内)',
admin__username: '负责人',
admin_group_names: '角色组',
admin_group_paths: '角色组层级',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、渠道标识、渠道名',
}

View File

@@ -0,0 +1,29 @@
export default {
title: '充值档位',
desc: '配置玩家创建充值订单时可选的充值档位。第三方支付模式下仅需维护档位规格:名称、充值金额、赠送金额、描述等;不再保存收款账户信息。充值名称/描述需分别维护中英文两套:移动端接口会根据请求头 `lang` 返回对应语言,英文缺省时回退到中文。修改后立即生效。',
btn_add: '新增档位',
btn_save: '保存',
btn_remove: '删除',
confirm_remove: '确定删除该充值档位?',
tier_id: '档位 ID',
auto_id: '(保存时生成)',
sort: '排序',
status: '启用',
title_col: '充值名称(中文)',
title_ph: '例如新手首充、VIP 高额充值',
title_en_col: '充值名称(英文)',
title_en_ph: 'e.g. Starter Pack, VIP Recharge',
amount: '充值金额',
amount_ph: '例如100.00',
bonus_amount: '赠送金额',
bonus_ph: '例如20.00,无赠送填 0',
desc_col: '描述(中文)',
desc_ph: '可选,展示给中文玩家的档位说明,最长 255 字',
desc_en_col: '描述(英文)',
desc_en_ph: 'Optional English description for EN players, up to 255 chars',
currency: '币',
operate: '操作',
err_title: '第 {no} 行:中文充值名称不能为空',
err_amount: '第 {no} 行:充值金额必须为大于 0 的数字',
err_bonus: '第 {no} 行:赠送金额必须为不小于 0 的数字',
}

View File

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

View File

@@ -0,0 +1,8 @@
export default {
desc: '110 档连胜:派彩 = 压注总额 × 赔率系数odds_factor。勾选「大奖」的档位在中奖时会对玩家私有频道、public-game-period 与 public-operation-notice 推送 jackpot.hit。',
btn_save: '保存',
streak: '连胜档(局)',
odds_factor: '赔率系数',
is_jackpot: '是否大奖',
err_factor: '第 {no} 行赔率系数须 ≥1',
}

View File

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

View File

@@ -36,4 +36,27 @@ export default {
second: '秒',
day: '天',
'Number of attachments Uploaded': '附件上传量',
stat_user_total: '会员总数',
stat_new_today: '今日新增用户',
stat_deposit_today: '今日充值(成功)',
stat_withdraw_pending: '待审核提现',
stat_hint_pending: '待处理',
chart_new_user_deposit: '近7日新增用户与每日充值',
chart_bet_7d: '近7日投注金额',
chart_channel_users: '用户渠道分布',
chart_deposit_status: '充值订单状态分布',
chart_deposit_amount_channel: '充值金额渠道分布',
recent_users: '最新注册用户',
no_data: '暂无数据',
bet_today_line: '今日投注流水',
orders_unit: '笔',
deposit_orders_today: '今日成功 {n} 笔',
series_new_users: '新增用户',
series_deposit_amount: '充值金额',
series_bet_amount: '投注金额',
load_failed: '统计数据加载失败,请稍后重试',
seconds_ago: '秒前',
minutes_ago: '分钟前',
hours_ago: '小时前',
}

View File

@@ -0,0 +1,27 @@
export default {
'quick Search Fields': 'ID/期号/幂等键',
id: 'ID',
period_id: '对局ID',
period_no: '期号',
user_id: '用户ID',
channel_id: '渠道ID',
pick_numbers: '选号',
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

@@ -1,21 +0,0 @@
export default {
delete_confirm_title: '删除渠道',
delete_confirm_related:
'将同时删除该渠道下 {countConfig} 条游戏配置、{countUser} 条游戏用户数据,此操作不可恢复。确定删除所选渠道吗?',
id: 'ID',
code: '渠道标识',
name: '渠道名',
user_count: '用户数',
profit_amount: '利润',
status: '状态',
'status 0': '禁用',
'status 1': '启用',
remark: '备注',
admin_group_id: '管理角色组',
admingroup__name: '组名',
admin_id: '管理员',
admin__username: '用户名',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID、渠道标识、渠道名',
}

View File

@@ -1,33 +1,14 @@
export default {
ID: 'ID',
channel_id: '渠道id',
channel__name: '渠道名',
group: '分组',
name: '配置标识',
title: '配置名称',
value: '',
'weight key': '',
'weight value': '数值',
'weight sum must 100': 'default_tier_weight / default_kill_score_weight 的权重之和必须等于 100',
'weight sum max 100': 'default_tier_weight / default_kill_score_weight 的权重之和不能超过 100',
'weight each max 100': '每项权重不能超过 100',
'weight each max 10000': '权重不能超过10000',
'bigwin weight must integer': '大奖权重每项必须为整数',
'name opt game_rule': 'game_rule游戏规则',
'name opt game_rule_en': 'game_rule_en游戏规则英文',
'name opt default_tier_weight': 'default_tier_weight默认档位权重',
'name opt default_kill_score_weight': 'default_kill_score_weight默认击杀分权重',
'name opt default_bigwin_weight': 'default_bigwin_weight默认大奖权重',
default_bigwin_weight_help:
'大奖权重区间为 0100000 表示不可能出现10000 表示 100% 出现。点数 5 与 30 为豹子号必中组合,权重固定为 10000不可修改。',
'bigwin weight each 0 10000': '除固定项外,每项大奖权重须在 010000 之间',
'bigwin weight locked 5 30': '点数 5 与 30 权重固定为 10000不可修改',
'weight value numeric': '权重值必须为有效数字',
sort: '排序',
instantiation: '实例化',
'instantiation 0': '---',
'instantiation 1': '需要',
'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: '更新时间',
'quick Search Fields': 'ID',
}

View File

@@ -0,0 +1,29 @@
export default {
tip: '实时监听压注记录;封盘后 AI 默认号码会锁定,本期倒计时结束按该号码(或您预约的号码)开奖;开奖后约 3 秒派彩再进入下一期。',
current_record: '当前对局',
ai_default_number: 'AI默认开奖号码',
pending_draw: '已预约开奖号码',
countdown: '倒计时',
bet_countdown: '下注剩余',
draw_countdown: '开奖剩余',
payout_countdown: '派彩剩余',
payout_na: '—',
payout_phase: '派彩中,请稍候',
action_panel: '操作区',
manual_draw_number: '预约开奖',
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: '压注号码',
total_amount: '压注总额',
streak_at_bet: '下注时连胜',
}

View File

@@ -0,0 +1,29 @@
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: '开奖号码',
platform_profit_amount: '对局盈亏(平台)',
winner_user_count: '中奖人数',
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

@@ -1,25 +0,0 @@
export default {
id: 'ID',
game_channel_id: '渠道',
gamechannel__name: '渠道名',
tier_reward_form: '档位奖励表单',
bigwin_form: '超级大奖表单',
create_time: '创建时间',
update_time: '更新时间',
grid_number: '色子点数',
ui_text: '显示文本',
ui_text_en: '显示文本(en)',
remark: '备注',
real_ev: '实际中奖',
tier: '档位',
tier_t1: 'T1',
tier_t2: 'T2',
tier_t3: 'T3',
tier_t4: 'T4',
tier_t5: 'T5',
tier_bigwin: 'BIGWIN',
tier_reward_form_help:
'固定 26 条(点数 5-30不可新增或删除可修改显示文本、英文显示、实际中奖、档位与备注生成器会预填英文与备注',
bigwin_form_help: '固定 6 条(点数 5、10、15、20、25、30不可新增或删除仅可修改显示文本、实际中奖',
'quick Search Fields': 'ID',
}

View File

@@ -1,48 +0,0 @@
export default {
title: '游戏奖励配置',
intro:
'超级管理员可维护「全渠道默认模板」(对应库中 game_channel_id=0。新建渠道时会优先复制该模板的档位奖励与超级大奖配置若尚未保存过模板则使用项目内置 JSON 模板。渠道管理员仅能编辑自己负责渠道的配置。',
super_scope_label: '维护范围',
super_scope_template: '全渠道默认模板',
super_scope_channel: '指定渠道',
super_scope_hint: '选择「指定渠道」后请在下拉框中选择具体渠道并点击刷新加载。',
btn_add: '新增',
btn_reset: '重置',
btn_gen_tier: '生成游戏奖励配置',
btn_gen_weight: '生成游戏奖励权重配置',
gen_tier_title: '按规则生成奖励索引',
gen_tier_rule:
'【生成逻辑(与创建奖励对照一致)】\n' +
'• 盘面 26 格按 id 升序为位置 025每条配置的 grid_number 为 530 且不重复。\n' +
'• 摇取点数 D530起点为「grid_number=D」所在格位的 idstart_index顺时针落点 = (起点 + D) mod 26逆时针落点 = 起点 D若小于 0 则 +26。\n' +
'• 对照表每条记录的「色子点数」列为 D档位、真实结算、显示文案取自落点格位对应 id 的配置。\n\n' +
'【豹子摇取点数】\n' +
'摇取点数为 5、10、15、20、25、30 时,其顺/逆时针落点档位不能为 T4、T5。\n\n' +
'【结算金额与档位】\n' +
'结算金额 < 0 → T40 < 结算金额 < 100 → T3100 < 结算金额 < 200 → T2200 < 结算金额 → T1T5 结算金额=0。\n' +
'下方填写各档位统一结算金额标准T1T4 的中/英文显示文本将等于该金额字符串T5 固定「再来一次」/「Once again」。\n\n' +
'【本弹窗输入】\n' +
'条数T1/T4/T5 为顺时针与逆时针各自的固定条数T2 为顺时针与逆时针各自「不少于」的条数。生成后仍可在主表中微调。',
gen_tier_footer_hint:
'T1/T4/T5 为精确条数T2 为下限;生成失败时请放宽条数或稍后再试。生成后可在上方表格中继续修改。',
gen_tier_cancel: '取消',
gen_tier_submit: '生成并保存',
gen_t1_label: 'T1 大奖',
gen_t1_fixed: '固定条数(顺/逆)',
gen_t2_label: 'T2 小赚/回本',
gen_t2_min: '最少条数',
gen_t3_label: 'T3 抽水',
gen_t3_amt_only: '结算金额',
gen_t4_label: 'T4 惩罚',
gen_t4_fixed: '固定条数(顺/逆)',
gen_t5_label: 'T5 再来一次',
gen_t5_fixed: '固定条数(顺/逆)',
gen_settlement: '结算金额',
gen_dir_cw: '顺时针',
gen_dir_ccw: '逆时针',
gen_weight_confirm_title: '创建奖励对照',
gen_weight_confirm_body:
'按规则创建奖励对照:起始索引 start_index 为奖励配置中 grid_number 与摇取点数 D 相同的那一格的 id顺时针 end_index=(start_index+摇取点数)%26逆时针 end_index=start_index摇取点数若≥0 则取该值,否则 26+start_index摇取点数。将先清空该渠道 game_reward_weight 表中现有数据,再为 530 共 26 个点数、顺/逆时针各生成一条(共 52 条)。档位、真实结算、显示文案、备注取自落点格位在档位表中的配置。是否继续?',
gen_weight_confirm_ok: '确定创建',
gen_weight_need_channel: '请先选择具体渠道并刷新后再生成权重对照。',
}

View File

@@ -1,26 +0,0 @@
export default {
id: 'ID',
game_channel_id: '渠道',
gamechannel__name: '渠道名',
direction: '方向',
'direction 0': '顺时针',
'direction 1': '逆时针',
grid_number: '点数',
start_index: '起始索引',
end_index: '结束索引',
ui_text: '显示文本',
real_ev: '实际中奖金额',
tier: '档位',
type: '奖励类型',
'type -2': '唯一惩罚',
'type -1': '抽水',
'type 0': '回本',
'type 1': '再来一次',
'type 2': '小赚',
'type 3': '大奖格',
remark: '备注',
weight: '权重',
create_time: '创建时间',
update_time: '修改时间',
'quick Search Fields': 'ID',
}

View File

@@ -2,34 +2,45 @@ export default {
id: 'ID',
username: '用户名',
password: '密码',
uuid: '用户唯一标识',
uuid: 'uuid',
phone: '手机号',
email: '邮箱',
email_placeholder: '可选,与手机号二选一注册时填写',
head_image: '头像',
remark: '备注',
coin: '平台币',
tier_weight: '档位权重',
bigwin_weight: '中大奖权重',
tier_weight_preset: '档位权重模板(游戏配置)',
bigwin_weight_preset: '大奖权重模板(游戏配置)',
'weight value': '权重数值',
'weight value numeric': '权重值必须为有效数字',
'weight each max 100': '每项权重 100',
tier_weight_help: 'T1~T5 权重之和不能超过 100',
tier_weight_sum_max_100: '档位权重T1~T5之和不能超过 100',
bigwin_weight_help: '仅限制每项权重不超过 10000点数 5 和 30 为必中大奖组合,权重固定为 10000',
bigwin_weight_each_max_10000: '每项中大奖权重不能超过 10000',
ticket_count: '抽奖券',
ticket_ante: '注',
ticket_count_times: '次数',
'ticket row incomplete': '每行需同时填写 ante 与 count',
'ticket row numeric': 'ante、count 须为有效数字',
coin: '游戏币余额',
coin_placeholder: 'decimal(18,4),禁止业务用浮点存库',
total_deposit_coin: '累计充值(币)',
total_withdraw_coin: '累计提现(币)',
bet_flow_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': '启用',
game_channel_id: '所属渠道',
gamechannel__name: '渠道名',
admin_id: '所属管理员',
admin__username: '用户名',
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、用户名、手机号',
'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

@@ -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,33 @@
export default {
'quick Search Fields': 'ID/期号/幂等键',
id: 'ID',
period_id: '对局ID',
period_no: '期号',
user_id: '用户ID',
channel_id: '渠道ID',
pick_numbers: '选号',
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,26 @@
export default {
'quick Search Fields': '订单号/用户ID/支付通道/档位ID/幂等键',
id: 'ID',
order_no: '订单号',
idempotency_key: '幂等键',
user_id: '用户ID',
user_username: '用户名',
channel_id: '渠道ID',
channel_name: '渠道',
amount: '金额',
bonus_amount: '赠送金额',
total_credit: '实际到账',
status: '状态',
'status 0': '待支付',
'status 1': '成功',
'status 2': '失败',
'status 3': '已取消',
pay_channel: '支付通道',
pay_time: '支付时间',
deposit_tier_id: '充值档位',
remark: '备注',
create_time: '创建时间',
update_time: '更新时间',
detail_title: '充值订单详情',
close_btn: '关闭',
}

View File

@@ -0,0 +1,36 @@
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: '审核人',
review_title: '提现审核',
review_reject_title: '提现拒绝',
review_btn_approve: '通过',
review_btn_reject: '拒绝',
review_btn_back: '返回',
review_btn_confirm_reject: '确认拒绝',
review_reject_tip: '拒绝审核后,冻结的提现金额将原路退回用户钱包余额。',
review_reject_placeholder: '请输入拒绝原因,玩家可在提现记录中看到该说明',
reject_reason_required: '请输入拒绝原因',
already_reviewed: '该订单已审核,无需重复操作',
amount_invalid: '申请金额必须大于 0',
fee_invalid: '手续费不能为负',
fee_exceed_amount: '手续费不能大于申请金额',
}

View File

@@ -0,0 +1,12 @@
export default {
connected: '推送服务已连接(已订阅频道)',
disconnected: '未连接或已断开',
user_uuid: '用户 uuid',
user_uuid_placeholder: '与移动端档案一致的 10 位对外标识',
btn_connect: '连接并订阅',
btn_disconnect: '断开',
btn_clear: '清空日志',
channel_label: '当前频道',
log_title: '事件日志',
log_empty: '暂无推送,请确认 push 进程已启动且服务端会向对应频道发消息。',
}

View File

@@ -0,0 +1,3 @@
export default {
tip: '订阅文档中的「全局对局频道」public-game-period用于验证 period.tick / period.locked / period.opened / period.payout / jackpot.hit 等公共事件(需服务端向该频道推送)。',
}

View File

@@ -0,0 +1,3 @@
export default {
tip: '订阅「公告广播频道」public-operation-notice全站公告 notice.popout 与本游戏 jackpot.hit连胜大奖广播均会发往此频道需服务端推送。',
}

View File

@@ -0,0 +1,3 @@
export default {
tip: '订阅「用户私有频道」private-user-{uuid}:请输入玩家 uuid连接后将走 /plugin/webman/push/auth 鉴权(与移动端一致)。用于验证 bet.accepted、wallet.changed 等私有事件。',
}

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,54 @@
export default {
'User name': '用户名',
nickname: '昵称',
group: '分组',
avatar: '头像',
Gender: '性别',
male: '男',
female: '女',
mobile: '手机号',
'Last login IP': '最后登录IP',
'Last login': '最后登录',
email: '电子邮箱',
birthday: '生日',
balance: '余额',
'Adjustment balance': '调整余额',
integral: '积分',
'Adjust integral': '调整积分',
id: 'ID',
username: '用户名',
password: '密码',
'Please leave blank if not modified': '不修改请留空',
'Personal signature': '个性签名',
'Login account': '登录账户名',
uuid: 'uuid',
phone: '手机号',
email: '邮箱',
email_placeholder: '可选,与手机号二选一注册时填写',
head_image: '头像',
remark: '备注',
coin: '余额',
coin_placeholder: '金额展示统一两位小数',
total_deposit_coin: '累计充值(币)',
total_withdraw_coin: '累计提现(币)',
bet_flow_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: '其他',
wallet_adjust_title: '钱包加减点',
wallet_adjust_op: '操作类型',
wallet_adjust_credit: '加点',
wallet_adjust_deduct: '扣点',
wallet_adjust_amount: '操作金额',
wallet_adjust_amount_invalid: '请输入大于0的金额',
wallet_adjust_operator_admin: '操作管理员',
wallet_adjust_default_remark: '后台管理员({admin}{action}{amount}(值)',
}

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

@@ -37,6 +37,12 @@ router.beforeEach((to, from, next) => {
// 按需动态加载页面的语言包-start
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()
if (to.path in langAutoLoadMap) {
loadPath.push(...langAutoLoadMap[to.path as keyof typeof langAutoLoadMap])
@@ -47,7 +53,10 @@ router.beforeEach((to, from, next) => {
// 去除 path 中的 /admin
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 {
prefix = './frontend/' + config.lang.defaultLang
loadPath.push(prefix + to.path + '.ts')
@@ -55,7 +64,9 @@ router.beforeEach((to, from, next) => {
// 根据路由 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 = []

View File

@@ -100,6 +100,16 @@ export const useTerminal = defineStore(
}
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)
state.taskList = state.taskList.concat({
uuid: uuid(),

View File

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

View File

@@ -0,0 +1,82 @@
/**
* 后台推送频道测试:加载官方 push.js 并订阅频道、监听文档约定事件
*/
const DOC_EVENTS = [
'period.tick',
'period.locked',
'period.opened',
'period.payout',
'jackpot.hit',
'bet.accepted',
'bet.settled',
'wallet.changed',
'notice.popout',
'withdraw.review_required',
]
export async function loadPushJs(): Promise<void> {
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)
})
}
export type PushTestLogLine = { t: number; event: string; payload: string }
export function startPushChannelListener(options: {
url: string
app_key: string
channel: string
/** 私有频道需走 /plugin/webman/push/auth */
usePrivateAuth: boolean
onLog: (line: PushTestLogLine) => void
onConnected: (ok: boolean) => void
}): { disconnect: () => void } {
const PushCtor = (window as any).Push
const cfg: anyObj = { url: options.url, app_key: options.app_key }
if (options.usePrivateAuth) {
cfg.auth = '/plugin/webman/push/auth'
}
const client = new PushCtor(cfg)
const ch = client.subscribe(options.channel)
const pushLog = (event: string, data: unknown) => {
let payload = ''
try {
payload = typeof data === 'string' ? data : JSON.stringify(data)
} catch {
payload = String(data)
}
options.onLog({ t: Date.now(), event, payload })
}
ch.on('pusher:subscription_succeeded', () => {
options.onConnected(true)
pushLog('pusher:subscription_succeeded', {})
})
for (const ev of DOC_EVENTS) {
ch.on(ev, (data: unknown) => {
options.onConnected(true)
pushLog(ev, data)
})
}
return {
disconnect: () => {
try {
client.disconnect()
} catch {
/* ignore */
}
options.onConnected(false)
},
}
}

View File

@@ -1,117 +0,0 @@
/** 档位权重固定键(与 GameConfig / GameUser JSON 一致) */
export const TIER_WEIGHT_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
/** 中大奖权重固定键 */
export const BIGWIN_WEIGHT_KEYS = ['5', '10', '15', '20', '25', '30'] as const
/** 默认大奖权重:点数 5、30 为豹子号必中,权重固定 10000 */
export function isBigwinDiceLockedKey(key: string): boolean {
return key === '5' || key === '30'
}
export type WeightRow = { key: string; val: string }
export function weightArrayToMap(arr: unknown): Record<string, string> {
const map: Record<string, string> = {}
if (!Array.isArray(arr)) {
return map
}
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
for (const [k, v] of Object.entries(item)) {
map[k] = v === null || v === undefined ? '' : String(v)
}
}
}
return map
}
/** 解析 game_weight JSON 为键值映射(键为 T1 / 5 等) */
export function parseWeightJsonToMap(raw: unknown): Record<string, string> {
if (raw === null || raw === undefined || raw === '') {
return {}
}
if (typeof raw === 'string') {
const s = raw.trim()
if (!s || s === '[]') {
return {}
}
try {
const parsed = JSON.parse(s)
return weightArrayToMap(parsed)
} catch {
return {}
}
}
if (Array.isArray(raw)) {
return weightArrayToMap(raw)
}
return {}
}
export function fixedRowsFromKeys(keys: readonly string[], map: Record<string, string>): WeightRow[] {
const rows: WeightRow[] = []
for (const k of keys) {
rows.push({ key: k, val: map[k] ?? '' })
}
return rows
}
export function rowsToMap(rows: WeightRow[]): Record<string, string> {
const m: Record<string, string> = {}
for (const r of rows) {
m[r.key] = r.val
}
return m
}
export function jsonStringFromFixedKeys(keys: readonly string[], map: Record<string, string>): string {
const pairs: Record<string, string>[] = []
for (const k of keys) {
const one: Record<string, string> = {}
one[k] = map[k] ?? ''
pairs.push(one)
}
return JSON.stringify(pairs)
}
/**
* 统一配置标识trim、去掉「 (说明)」「(说明)」「(说明)」等后缀、小写,避免下拉/接口返回不一致导致走错位校验如误报每项≤10000
*/
export function normalizeGameWeightConfigName(name: string | undefined): string {
if (name === undefined || name === null) {
return ''
}
let s = String(name).trim()
// 截到第一个半角/全角左括号之前(兼容 "key (说明)"、"key说明"、"key(说明)"、无前导空格)
const m = s.match(/^([^(]+)/u)
if (m) {
s = m[1].trim()
}
return s.toLowerCase()
}
/** 当前行是否为大奖骰子键 530与 BIGWIN_WEIGHT_KEYS 一致) */
export function weightRowsMatchBigwinDiceKeys(rows: { key: string }[]): boolean {
const keys = rows
.map((r) => r.key.trim())
.filter((k) => k !== '')
.sort((a, b) => Number(a) - Number(b))
if (keys.length !== BIGWIN_WEIGHT_KEYS.length) {
return false
}
return BIGWIN_WEIGHT_KEYS.every((k, i) => keys[i] === k)
}
/** GameConfig 中按 name 判断是否使用固定键编辑 */
export function getFixedKeysForGameConfigName(name: string | undefined): readonly string[] | null {
const n = normalizeGameWeightConfigName(name)
/** 击杀分权重与档位权重同为 T1T5库中 JSON 为 [{"T1":"0"},...],非骰子点 530 */
if (n === 'default_tier_weight' || n === 'default_kill_score_weight') {
return TIER_WEIGHT_KEYS
}
if (n === 'default_bigwin_weight') {
return BIGWIN_WEIGHT_KEYS
}
return null
}

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

@@ -48,7 +48,15 @@ const baTable = new baTableClass(
{ 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.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.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.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
@@ -66,17 +74,25 @@ const baTable = new baTableClass(
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { disable: 'danger', enable: 'success' },
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { disable: t('Disable'), enable: t('Enable') },
customRenderAttr: {
switch: () => ({
activeValue: 'enable',
inactiveValue: 'disable',
}),
},
},
{
label: t('Operate'),
align: 'center',
width: '100',
minWidth: '80',
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined, 'status'],

View File

@@ -43,18 +43,26 @@
/>
<FormItem
:label="t('auth.admin.group')"
v-model="baTable.form.items!.group_arr"
v-model="singleGroupValue"
prop="group_arr"
type="remoteSelect"
:key="'group-' + baTable.form.items!.id"
: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 },
field: 'name',
remoteUrl: '/admin/auth.Group/index',
placeholder: t('Click select'),
}"
/>
<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.email')"
@@ -115,7 +123,7 @@
</template>
<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 type baTableClass from '/@/utils/baTable'
import { regularPassword, buildValidatorData } from '/@/utils/validate'
@@ -131,10 +139,43 @@ const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
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({
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
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') }) })],
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],
password: [

View File

@@ -90,7 +90,17 @@ const baTable = new baTableClass(new baTableApi('/admin/auth.AdminLog/'), {
showOverflowTooltip: true,
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'),
prop: 'useragent',
@@ -111,10 +121,11 @@ const baTable = new baTableClass(new baTableApi('/admin/auth.AdminLog/'), {
{
label: t('Operate'),
align: 'center',
width: '100',
minWidth: '80',
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined],

View File

@@ -51,27 +51,47 @@ const baTable: baTableClass = new baTableClass(
new baTableApi('/admin/auth.Group/'),
{
expandAll: true,
dblClickNotEditColumn: [undefined],
dblClickNotEditColumn: [undefined, 'status'],
column: [
{ 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.channel_name'),
prop: 'channel_name',
align: 'center',
minWidth: '140',
},
{ label: t('auth.group.jurisdiction'), prop: 'rules', align: 'center' },
{
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { 0: t('Disable'), 1: t('Enable') },
},
{ 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(['edit', 'delete']) },
{
label: t('Operate'),
align: 'center',
width: '80',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
fixed: 'right',
},
],
},
{
defaultItems: {
status: 1,
channel_id: null,
},
}
)
@@ -80,9 +100,18 @@ const baTable: baTableClass = new baTableClass(
baTable.before.onSubmit = ({ formEl, operate, items }) => {
let submitCallback = () => {
baTable.form.submitLoading = true
const postItems: anyObj = { ...items }
const pid = Number(postItems.pid ?? 0)
if (pid !== 0) {
delete postItems.channel_id
delete postItems.channel_name
delete postItems.channel_admin_username
} else if (!adminInfo.super) {
delete postItems.channel_id
}
baTable.api
.postData(operate, {
...items,
...postItems,
rules: formRef.value?.getCheckeds(),
})
.then((res) => {
@@ -139,6 +168,10 @@ baTable.after.toggleForm = ({ operate }) => {
// 编辑请求完成后钩子
baTable.after.getEditData = () => {
const pid = Number(baTable.form.items?.pid ?? 0)
if (pid !== 0 && baTable.form.items) {
delete baTable.form.items.channel_id
}
menuRuleTreeUpdate()
}

View File

@@ -41,6 +41,42 @@
valueOnClear: 0,
}"
/>
<p
v-if="!isRootGroup"
class="group-channel-inherit-hint"
:style="{ paddingLeft: (baTable.form.labelWidth ?? 120) + 'px' }"
>
{{ t('auth.group.channel_inherit_hint') }}
</p>
<!-- 顶级+超管可选渠道展示只读渠道名称channel_id 仅由表单传给后端 -->
<FormItem
v-if="isRootGroup && adminInfo.super"
:label="t('auth.group.channel_id')"
v-model="baTable.form.items!.channel_id"
type="remoteSelect"
prop="channel_id"
:input-attr="{
pk: 'id',
field: 'name',
remoteUrl: '/admin/channel/index',
placeholder: t('Click select'),
emptyValues: ['', null, undefined, 0],
valueOnClear: null,
}"
/>
<template v-if="isRootGroup && adminInfo.super && channelPreviewName">
<el-form-item :label="t('auth.group.channel_name')">
<el-input :model-value="channelPreviewName" readonly />
</el-form-item>
</template>
<!-- 子级只读展示上级对应渠道名称不提交 channel_id由后端按父级写入 -->
<template v-if="!isRootGroup && channelPreviewName">
<el-form-item :label="t('auth.group.channel_name')">
<el-input :model-value="channelPreviewName" readonly />
</el-form-item>
</template>
<el-form-item prop="name" :label="t('auth.group.Group name')">
<el-input
@@ -86,7 +122,7 @@
</template>
<script setup lang="ts">
import { reactive, inject, useTemplateRef } from 'vue'
import { reactive, inject, useTemplateRef, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
@@ -94,13 +130,105 @@ import type { ElTree, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import type Node from 'element-plus/es/components/tree/src/model/node'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import createAxios from '/@/utils/axios'
const config = useConfig()
const adminInfo = useAdminInfo()
const formRef = useTemplateRef('formRef')
const treeRef = useTemplateRef('treeRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const isRootGroup = computed(() => {
const p = baTable.form.items?.pid
if (p === undefined || p === null || p === '') {
return true
}
return Number(p) === 0
})
const strFromRow = (key: string): string => {
const row = baTable.form.items
if (!row) return ''
const v = row[key]
return typeof v === 'string' ? v : ''
}
const channelPreviewName = computed(() => strFromRow('channel_name'))
/**
* 子角色组选择上级分组后只拉取展示用渠道名channel_id 由后端按父级保存,不在此写入提交字段。
*/
watch(
() => baTable.form.items?.pid,
async (pid, oldPid) => {
const items = baTable.form.items
if (!items || !baTable.form.operate || !['Add', 'Edit'].includes(baTable.form.operate)) {
return
}
const pidNum = Number(pid ?? 0)
const oldNum = oldPid === undefined || oldPid === null || oldPid === '' ? null : Number(oldPid)
if (pidNum === 0) {
if (adminInfo.super && oldNum !== null && oldNum !== 0) {
items.channel_id = null
items['channel_name'] = ''
}
return
}
delete items.channel_id
try {
const res = await createAxios(
{
url: '/admin/auth.Group/edit',
method: 'get',
params: { id: pidNum },
},
{ showErrorMessage: false, showCodeMessage: false }
)
const row = res.data.row
if (row) {
items['channel_name'] = typeof row.channel_name === 'string' ? row.channel_name : ''
}
} catch {
items['channel_name'] = ''
}
}
)
/** 顶级+超管:所选渠道变更时刷新只读渠道名 */
watch(
() => baTable.form.items?.channel_id,
async (cid) => {
const items = baTable.form.items
if (!items || !baTable.form.operate || !['Add', 'Edit'].includes(baTable.form.operate)) {
return
}
if (!isRootGroup.value || !adminInfo.super) {
return
}
if (cid === null || cid === undefined || cid === '') {
items['channel_name'] = ''
return
}
try {
const res = await createAxios(
{
url: '/admin/auth.Group/channelBindPreview',
method: 'get',
params: { channel_id: cid },
},
{ showErrorMessage: false, showCodeMessage: false }
)
items['channel_name'] = typeof res.data.channel_name === 'string' ? res.data.channel_name : ''
} catch {
items['channel_name'] = ''
}
}
)
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
name: [buildValidatorData({ name: 'required', title: t('auth.group.Group name') })],
@@ -153,6 +281,14 @@ defineExpose({
</script>
<style scoped lang="scss">
.group-channel-inherit-hint {
margin: -6px 0 14px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
box-sizing: border-box;
}
:deep(.penultimate-node) {
.el-tree-node__children {
padding-left: 60px;

View File

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

View File

@@ -0,0 +1,600 @@
<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.share_config')">
<el-table :data="manualSettle.form.commission_split" border size="small" class="w100">
<el-table-column prop="admin_username" :label="t('channel.admin__username')" min-width="100" />
<el-table-column prop="share_rate" :label="t('channel.share_rate_percent')" min-width="90">
<template #default="scope">{{ scope.row.share_rate }}%</template>
</el-table-column>
<el-table-column prop="commission_amount" :label="t('channel.manual_settle_commission_amount')" min-width="110" />
</el-table>
</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>
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="shareDialog.visible" @close="closeShareDialog">
<template #header>
<div class="title">{{ t('channel.share_config_title') }}</div>
</template>
<div v-loading="shareDialog.loading" class="manual-settle-dialog-body">
<el-alert type="info" :closable="false" show-icon class="mb-12">
{{ t('channel.share_config_tip') }}
</el-alert>
<el-table :data="shareDialog.list" border size="small">
<el-table-column :label="t('channel.admin_group_names')" min-width="260">
<template #default="scope">
<span v-if="scope.row.role_group_name" class="share-group-single">{{ scope.row.role_group_name }}</span>
<span v-else class="share-group-empty">-</span>
</template>
</el-table-column>
<el-table-column prop="username" :label="t('channel.admin__username')" min-width="120" />
<el-table-column :label="t('channel.status')" width="120">
<template #default="scope">
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0" />
</template>
</el-table-column>
<el-table-column :label="t('channel.share_rate_percent')" min-width="180">
<template #default="scope">
<el-input-number
v-model="scope.row.share_rate"
:disabled="scope.row.status !== 1"
:min="0"
:max="100"
:step="0.01"
:precision="2"
class="w100"
/>
</template>
</el-table-column>
</el-table>
<div class="share-total-row">
<span>{{ t('channel.share_total_enabled') }}: </span>
<el-tag :type="shareEnabledTotal === '100.00' ? 'success' : 'danger'">{{ shareEnabledTotal }}%</el-tag>
</div>
</div>
<template #footer>
<el-button @click="closeShareDialog">{{ t('Cancel') }}</el-button>
<el-button type="primary" :loading="shareDialog.saving" @click="saveShareDialog">{{ t('Save') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, provide, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
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: 'shareConfig',
title: 'channel.share_config',
text: '',
type: 'primary',
icon: 'el-icon-Setting',
class: 'table-row-share-config',
disabledTip: false,
display: () => auth('edit'),
click: (row: TableRow) => {
void openShareDialog(row)
},
},
{
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 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: '',
commission_split: [] as Array<{ admin_id: number; admin_username: string; share_rate: string; commission_amount: string }>,
remark: '',
},
})
const shareDialog = reactive({
visible: false,
loading: false,
saving: false,
channelId: 0,
list: [] as Array<{ admin_id: number; username: string; role_group_name: string; role_level: number; status: number; share_rate: number | null }>,
})
const shareEnabledTotal = computed(() => {
let sum = 0
for (const row of shareDialog.list) {
if (row.status === 1) {
const n = Number(row.share_rate ?? 0)
if (Number.isFinite(n)) {
sum += n
}
}
}
return sum.toFixed(2)
})
const closeShareDialog = () => {
shareDialog.visible = false
shareDialog.channelId = 0
shareDialog.list = []
}
const openShareDialog = async (row: TableRow) => {
shareDialog.channelId = Number(row.id || 0)
shareDialog.visible = true
shareDialog.loading = true
try {
const res = await createAxios(
{
url: '/admin/channel/channelAdminShareList',
method: 'get',
params: { id: row.id },
},
{ showErrorMessage: true }
)
if (res.code !== 1 || !res.data) {
closeShareDialog()
return
}
const list = Array.isArray(res.data.list) ? res.data.list : []
shareDialog.list = list.map((item: anyObj) => {
const rate = item.share_rate
return {
admin_id: Number(item.admin_id || 0),
username: String(item.username || ''),
role_group_name: String(item.role_group_name || ''),
role_level: Number(item.role_level ?? 9999),
status: Number(item.status ?? 1) === 1 ? 1 : 0,
share_rate: rate === null || rate === undefined || rate === '' ? null : Number(rate),
}
})
} finally {
shareDialog.loading = false
}
}
const saveShareDialog = async () => {
if (!shareDialog.channelId) {
return
}
if (shareEnabledTotal.value !== '100.00') {
ElMessage.error(t('channel.share_total_must_100'))
return
}
shareDialog.saving = true
try {
await createAxios(
{
url: '/admin/channel/saveChannelAdminShare',
method: 'post',
data: {
id: shareDialog.channelId,
list: shareDialog.list.map((row) => ({
admin_id: row.admin_id,
status: row.status,
share_rate: Number(row.share_rate || 0).toFixed(2),
})),
},
},
{ showSuccessMessage: true }
)
closeShareDialog()
} finally {
shareDialog.saving = false
}
}
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.commission_split = []
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.commission_split = Array.isArray(d.commission_split) ? d.commission_split : []
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.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.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">
.mb-12 {
margin-bottom: 12px;
}
.share-total-row {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.share-group-single {
font-size: 12px;
line-height: 1.4;
color: var(--el-text-color-regular);
}
.share-group-empty {
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -0,0 +1,519 @@
<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-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 { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
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 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(() => {
syncSettlePlanFromItems()
})
watch(
() => [baTable.form.operate, baTable.form.items?.id] as const,
() => {
const items = baTable.form.items
if (!items || baTable.form.operate !== 'Edit') {
return
}
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') })],
})
</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;
}
</style>

View File

@@ -0,0 +1,278 @@
<template>
<div class="default-main ba-table-box deposit-tier-page">
<el-alert type="info" :closable="false" show-icon>
{{ t('config.depositTier.desc') }}
</el-alert>
<div class="toolbar">
<el-button type="primary" :disabled="loading" @click="onAdd">
<Icon name="el-icon-Plus" />
<span class="ml-6">{{ t('config.depositTier.btn_add') }}</span>
</el-button>
<el-button type="success" :loading="saving" :disabled="loading" @click="onSave">
{{ t('config.depositTier.btn_save') }}
</el-button>
</div>
<el-table
v-loading="loading"
border
stripe
:data="items"
row-key="_rowKey"
max-height="720"
class="deposit-tier-table"
header-align="center"
>
<el-table-column prop="sort" :label="t('config.depositTier.sort')" width="100" align="center" header-align="center">
<template #default="{ row }">
<div class="cell-center">
<el-input-number v-model="row.sort" :min="0" :max="9999" :controls="false" style="width: 100%; max-width: 160px" />
</div>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.status')" width="100" align="center" header-align="center">
<template #default="{ row }">
<div class="cell-center">
<el-switch v-model="row.status" :active-value="1" :inactive-value="0" />
</div>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.title_col')" min-width="180" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.title" maxlength="64" :placeholder="t('config.depositTier.title_ph')" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.title_en_col')" min-width="180" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.title_en" maxlength="64" :placeholder="t('config.depositTier.title_en_ph')" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.amount')" min-width="140" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.amount" :placeholder="t('config.depositTier.amount_ph')">
<template #suffix>
<span class="currency">{{ t('config.depositTier.currency') }}</span>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.bonus_amount')" min-width="140" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.bonus_amount" :placeholder="t('config.depositTier.bonus_ph')">
<template #suffix>
<span class="currency">{{ t('config.depositTier.currency') }}</span>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.desc_col')" min-width="220" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.desc" maxlength="255" :autosize="{ minRows: 1, maxRows: 3 }" type="textarea" :placeholder="t('config.depositTier.desc_ph')" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.desc_en_col')" min-width="220" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.desc_en" maxlength="255" :autosize="{ minRows: 1, maxRows: 3 }" type="textarea" :placeholder="t('config.depositTier.desc_en_ph')" />
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.tier_id')" width="140" align="center" header-align="center">
<template #default="{ row }">
<el-text class="tier-id" truncated>{{ row.id || t('config.depositTier.auto_id') }}</el-text>
</template>
</el-table-column>
<el-table-column :label="t('config.depositTier.operate')" width="90" align="center" header-align="center" fixed="right">
<template #default="{ $index }">
<el-button type="danger" link @click="onRemove($index)">
{{ t('config.depositTier.btn_remove') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
defineOptions({
name: 'config/depositTier',
})
const { t } = useI18n()
type Tier = {
id: string
title: string
title_en: string
amount: string
bonus_amount: string
desc: string
desc_en: string
sort: number
status: number
_rowKey?: string
}
const loading = ref(false)
const saving = ref(false)
const items = ref<Tier[]>([])
function genRowKey(): string {
return 'r_' + Math.random().toString(36).slice(2, 10)
}
function emptyTier(): Tier {
return {
id: '',
title: '',
title_en: '',
amount: '',
bonus_amount: '0',
desc: '',
desc_en: '',
sort: 0,
status: 1,
_rowKey: genRowKey(),
}
}
async function load() {
loading.value = true
try {
const res = await createAxios({
url: '/admin/config.DepositTier/index',
method: 'get',
})
if (res.code === 1 && res.data) {
const list = (res.data.items || []) as Tier[]
items.value = (Array.isArray(list) ? list : []).map((it) => ({
...emptyTier(),
...it,
_rowKey: genRowKey(),
}))
}
} finally {
loading.value = false
}
}
function onAdd() {
items.value.push(emptyTier())
}
async function onRemove(idx: number) {
try {
await ElMessageBox.confirm(t('config.depositTier.confirm_remove'), t('Warning'), {
type: 'warning',
confirmButtonText: t('Delete'),
cancelButtonText: t('Cancel'),
})
} catch {
return
}
items.value.splice(idx, 1)
}
async function onSave() {
if (!auth('save')) {
return
}
for (let i = 0; i < items.value.length; i++) {
const row = items.value[i]
if (!row.title || !row.title.trim()) {
ElMessage.warning(t('config.depositTier.err_title', { no: i + 1 }))
return
}
const amount = Number(row.amount)
if (!row.amount || Number.isNaN(amount) || amount <= 0) {
ElMessage.warning(t('config.depositTier.err_amount', { no: i + 1 }))
return
}
const bonusRaw = row.bonus_amount === '' || row.bonus_amount === null || row.bonus_amount === undefined ? '0' : row.bonus_amount
const bonus = Number(bonusRaw)
if (Number.isNaN(bonus) || bonus < 0) {
ElMessage.warning(t('config.depositTier.err_bonus', { no: i + 1 }))
return
}
}
saving.value = true
try {
await createAxios({
url: '/admin/config.DepositTier/save',
method: 'post',
data: {
items: items.value.map((r) => ({
id: r.id,
title: r.title,
title_en: r.title_en || '',
amount: r.amount,
bonus_amount: r.bonus_amount === '' || r.bonus_amount === null || r.bonus_amount === undefined ? '0' : r.bonus_amount,
desc: r.desc || '',
desc_en: r.desc_en || '',
sort: r.sort,
status: r.status,
})),
},
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.deposit-tier-page {
.toolbar {
margin: 12px 0;
display: flex;
gap: 12px;
}
.currency {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.tier-id {
display: inline-block;
max-width: 120px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
.deposit-tier-table {
:deep(.el-table__header th),
:deep(.el-table__body td) {
text-align: center;
}
}
.cell-center {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
</style>

View File

@@ -0,0 +1,134 @@
<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',
filter: { page: 1, limit: 50 },
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

@@ -1,7 +1,4 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<template>
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
@@ -30,44 +27,41 @@
:rules="rules"
>
<FormItem
:label="t('game.channel.code')"
:label="t('config.gameConfig.config_key')"
type="string"
v-model="baTable.form.items!.code"
prop="code"
:placeholder="t('Please input field', { field: t('game.channel.code') })"
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('game.channel.name')"
type="string"
v-model="baTable.form.items!.name"
prop="name"
:placeholder="t('Please input field', { field: t('game.channel.name') })"
: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('game.channel.status')"
type="switch"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('game.channel.status 0'), '1': t('game.channel.status 1') } }"
: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('game.channel.remark')"
:label="t('config.gameConfig.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('game.channel.remark') })"
/>
<FormItem
v-if="isSuperAdmin"
:label="t('game.channel.admin_id')"
type="remoteSelect"
v-model="baTable.form.items!.admin_id"
prop="admin_id"
:input-attr="{ pk: 'admin.id', field: 'username', remoteUrl: '/admin/auth.Admin/index', params: { top_group: '1' } }"
:placeholder="t('Please select field', { field: t('game.channel.admin_id') })"
:input-attr="{ rows: 2 }"
/>
</el-form>
</div>
@@ -85,34 +79,29 @@
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { computed, inject, useTemplateRef } from 'vue'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const { t } = useI18n()
const isSuperAdmin = computed(() => adminInfo.super === true)
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
config_key: [buildRequired('config.gameConfig.config_key')],
value_type: [buildRequired('config.gameConfig.value_type')],
})
const rules = computed<Partial<Record<string, FormItemRule[]>>>(() => ({
code: [buildValidatorData({ name: 'required', title: t('game.channel.code') })],
name: [buildValidatorData({ name: 'required', title: t('game.channel.name') })],
user_count: [buildValidatorData({ name: 'integer', title: t('game.channel.user_count') })],
profit_amount: [buildValidatorData({ name: 'float', title: t('game.channel.profit_amount') })],
admin_id: isSuperAdmin.value
? [buildValidatorData({ name: 'required', title: t('game.channel.admin_id') })]
: [],
create_time: [buildValidatorData({ name: 'date', title: t('game.channel.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.channel.update_time') })],
}))
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,141 @@
<template>
<div class="default-main ba-table-box streak-reward-page">
<el-alert type="info" :closable="false" show-icon>
{{ t('config.streakWinReward.desc') }}
</el-alert>
<div class="toolbar">
<el-button type="success" :loading="saving" :disabled="loading" @click="onSave">
{{ t('config.streakWinReward.btn_save') }}
</el-button>
</div>
<el-table
v-loading="loading"
border
stripe
:data="rows"
max-height="720"
class="streak-reward-table"
header-align="center"
>
<el-table-column prop="streak" :label="t('config.streakWinReward.streak')" width="110" align="center" header-align="center" />
<el-table-column :label="t('config.streakWinReward.odds_factor')" min-width="200" align="center" header-align="center">
<template #default="{ row }">
<div class="cell-center">
<el-input-number v-model="row.odds_factor" :min="1" :max="999" :step="1" :controls="true" />
</div>
</template>
</el-table-column>
<el-table-column :label="t('config.streakWinReward.is_jackpot')" min-width="90" align="center" header-align="center">
<template #default="{ row }">
<div class="cell-center">
<el-switch v-model="row.is_jackpot" />
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import createAxios from '/@/utils/axios'
import { auth } from '/@/utils/common'
defineOptions({
name: 'config/streakWinReward',
})
const { t } = useI18n()
type Row = {
streak: number
odds_factor: number
is_jackpot: boolean
}
const loading = ref(false)
const saving = ref(false)
const rows = ref<Row[]>([])
async function load() {
loading.value = true
try {
const res = await createAxios({
url: '/admin/config.StreakWinReward/index',
method: 'get',
})
if (res.code === 1 && res.data && Array.isArray(res.data.rows)) {
rows.value = res.data.rows.map((r: Row) => ({
streak: typeof r.streak === 'number' ? r.streak : 1,
odds_factor: typeof r.odds_factor === 'number' ? r.odds_factor : 1,
is_jackpot: !!r.is_jackpot,
}))
}
} finally {
loading.value = false
}
}
async function onSave() {
if (!auth('save')) {
return
}
for (let i = 0; i < rows.value.length; i++) {
const row = rows.value[i]
if (row.odds_factor < 1) {
ElMessage.warning(t('config.streakWinReward.err_factor', { no: i + 1 }))
return
}
}
saving.value = true
try {
await createAxios({
url: '/admin/config.StreakWinReward/save',
method: 'post',
data: {
rows: rows.value.map((r) => ({
streak: r.streak,
odds_factor: r.odds_factor,
is_jackpot: r.is_jackpot,
})),
},
showSuccessMessage: true,
})
await load()
} finally {
saving.value = false
}
}
onMounted(() => {
void load()
})
</script>
<style scoped lang="scss">
.streak-reward-page {
.toolbar {
margin: 12px 0;
display: flex;
gap: 12px;
}
}
.streak-reward-table {
:deep(.el-table__header th),
:deep(.el-table__body td) {
text-align: center;
}
}
.cell-center {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
</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',
render: 'tag',
sortable: false,
effect: 'dark',
replaceValue: {
delete: t('crud.log.status delete'),
success: t('crud.log.status success'),
error: t('crud.log.status error'),
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'),

View File

@@ -1,126 +1,84 @@
<template>
<div class="default-main">
<div class="banner">
<el-row :gutter="10">
<el-col :md="24" :lg="18">
<div class="welcome suspension">
<img class="welcome-img" :src="headerSvg" alt="" />
<div class="welcome-text">
<div class="welcome-title">{{ adminInfo.nickname + t('utils.comma') + getGreet() }}</div>
<div class="welcome-note">{{ state.remark }}</div>
</div>
</div>
</el-col>
<el-col :lg="6" class="hidden-md-and-down">
<div class="working">
<img class="working-coffee" :src="coffeeSvg" alt="" />
<div class="working-text">
{{ t('dashboard.You have worked today') }}<span class="time">{{ state.workingTimeFormat }}</span>
</div>
<div @click="onChangeWorkState()" class="working-opt working-rest">
{{ state.pauseWork ? t('dashboard.Continue to work') : t('dashboard.have a bit of rest') }}
</div>
</div>
</el-col>
</el-row>
</div>
<div class="default-main dashboard-page" v-loading="loading">
<div class="small-panel-box">
<el-row :gutter="20">
<el-col :sm="12" :lg="6">
<div class="small-panel user-reg suspension">
<div class="small-panel-title">{{ t('dashboard.Member registration') }}</div>
<div class="small-panel-title">{{ t('dashboard.stat_user_total') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#8595F4" size="20" name="fa fa-line-chart" />
<el-statistic :value="userRegNumberOutput" :value-style="statisticValueStyle" />
<Icon color="#8595F4" size="20" name="fa fa-users" />
<el-statistic :value="stats.user_total" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+14%</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel file suspension">
<div class="small-panel-title">{{ t('dashboard.Number of attachments Uploaded') }}</div>
<div class="small-panel-title">{{ t('dashboard.stat_new_today') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#AD85F4" size="20" name="fa fa-file-text" />
<el-statistic :value="fileNumberOutput" :value-style="statisticValueStyle" />
<Icon color="#AD85F4" size="20" name="fa fa-user-plus" />
<el-statistic :value="stats.user_new_today" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+50%</div>
<div class="content-right" :class="growthClass">{{ growthText }}</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel users suspension">
<div class="small-panel-title">{{ t('dashboard.Total number of members') }}</div>
<div class="small-panel-title">{{ t('dashboard.stat_deposit_today') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#74A8B5" size="20" name="fa fa-users" />
<el-statistic :value="usersNumberOutput" :value-style="statisticValueStyle" />
<Icon color="#74A8B5" size="20" name="fa fa-credit-card" />
<el-statistic :value="depositTodayDisplay" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+28%</div>
<div class="content-right color-success">{{ depositCountText }}</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel addons suspension">
<div class="small-panel-title">{{ t('dashboard.Number of installed plug-ins') }}</div>
<div class="small-panel-title">{{ t('dashboard.stat_withdraw_pending') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#F48595" size="20" name="fa fa-object-group" />
<el-statistic :value="addonsNumberOutput" :value-style="statisticValueStyle" />
<Icon color="#F48595" size="20" name="fa fa-clock-o" />
<el-statistic :value="stats.withdraw_pending" :value-style="statisticValueStyle" />
</div>
<div class="content-right">+88%</div>
<div class="content-right color-warning">{{ t('dashboard.stat_hint_pending') }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.Membership growth')">
<el-card shadow="hover" :header="t('dashboard.chart_new_user_deposit')">
<div class="user-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.Annex growth')">
<el-card shadow="hover" :header="t('dashboard.chart_bet_7d')">
<div class="file-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="6">
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.New member')">
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.recent_users')">
<div class="new-user-growth">
<el-scrollbar>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<el-empty v-if="recentUsers.length === 0" :description="t('dashboard.no_data')" />
<el-scrollbar v-else>
<div v-for="u in recentUsers" :key="u.id" class="new-user-item">
<img
class="new-user-avatar"
:src="u.head_image ? fullUrl(u.head_image) : fullUrl('/static/images/avatar.png')"
alt=""
/>
<div class="new-user-base">
<div class="new-user-name">妙码生花</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">码上生花</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" src="~assets/login-header.png" alt="" />
<div class="new-user-base">
<div class="new-user-name">Admin</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
<div class="new-user-item">
<img class="new-user-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
<div class="new-user-base">
<div class="new-user-name">纯属虚构</div>
<div class="new-user-time">12分钟前{{ t('dashboard.Joined us') }}</div>
<div class="new-user-name">{{ u.username || '-' }}</div>
<div class="new-user-time">{{ formatUserTime(u.create_time) }}</div>
<div v-if="u.channel_name" class="new-user-channel">{{ u.channel_name }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
@@ -134,12 +92,12 @@
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.Member source')">
<el-card shadow="hover" :header="t('dashboard.chart_channel_users')">
<div class="user-source-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.Member last name')">
<el-card shadow="hover" :header="t('dashboard.chart_deposit_amount_channel')">
<div class="user-surname-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
@@ -149,350 +107,235 @@
</template>
<script setup lang="ts">
import { useEventListener, useTemplateRefsList, useTransition } from '@vueuse/core'
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
import * as echarts from 'echarts'
import { CSSProperties, nextTick, onActivated, onBeforeMount, onMounted, onUnmounted, reactive, toRefs, watch } from 'vue'
import type { EChartsType } from 'echarts/core'
import { CSSProperties, computed, nextTick, onActivated, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { index } from '/@/api/backend/dashboard'
import coffeeSvg from '/@/assets/dashboard/coffee.svg'
import headerSvg from '/@/assets/dashboard/header-1.svg'
import { useAdminInfo } from '/@/stores/adminInfo'
import { WORKING_TIME } from '/@/stores/constant/cacheKey'
import type { DashboardDepositAmountChannelItem, DashboardPayload, DashboardRecentUser } from '/@/api/backend/dashboard'
import { useNavTabs } from '/@/stores/navTabs'
import { fullUrl, getGreet } from '/@/utils/common'
import { Local } from '/@/utils/storage'
let workTimer: number
import { fullUrl } from '/@/utils/common'
defineOptions({
name: 'dashboard',
})
const d = new Date()
const { t } = useI18n()
const navTabs = useNavTabs()
const adminInfo = useAdminInfo()
const chartRefs = useTemplateRefsList<HTMLDivElement>()
const loading = ref(true)
const state: {
charts: any[]
remark: string
workingTimeFormat: string
pauseWork: boolean
charts: EChartsType[]
} = reactive({
charts: [],
remark: 'dashboard.Loading',
workingTimeFormat: '',
pauseWork: false,
})
/**
* 带有数字向上变化特效的数据
*/
const countUp = reactive({
userRegNumber: 0,
fileNumber: 0,
usersNumber: 0,
addonsNumber: 0,
const stats = reactive({
user_total: 0,
user_new_today: 0,
user_new_yesterday: 0,
user_new_growth_pct: null as number | null,
deposit_today_amount: '0.00',
deposit_today_count: 0,
withdraw_pending: 0,
bet_today_amount: '0.00',
bet_today_count: 0,
})
const trend = reactive({
days: [] as string[],
new_users: [] as number[],
deposit_amount: [] as string[],
bet_amount: [] as string[],
})
const channelShare = ref<{ name: string; value: number }[]>([])
const depositAmountChannelShare = ref<DashboardDepositAmountChannelItem[]>([])
const recentUsers = ref<DashboardRecentUser[]>([])
const depositTodayDisplay = computed(() => {
const n = parseFloat(stats.deposit_today_amount)
if (!Number.isFinite(n)) {
return stats.deposit_today_amount
}
return n.toFixed(2)
})
const growthText = computed(() => {
const p = stats.user_new_growth_pct
if (p === null || p === undefined) {
return '—'
}
const sign = p > 0 ? '+' : ''
return sign + p.toFixed(1) + '%'
})
const growthClass = computed(() => {
const p = stats.user_new_growth_pct
if (p === null || p === undefined) {
return 'color-info'
}
if (p > 0) {
return 'color-success'
}
if (p < 0) {
return 'color-danger'
}
return 'color-info'
})
const depositCountText = computed(() => {
return t('dashboard.deposit_orders_today', { n: stats.deposit_today_count })
})
const countUpRefs = toRefs(countUp)
const userRegNumberOutput = useTransition(countUpRefs.userRegNumber, { duration: 1500 })
const fileNumberOutput = useTransition(countUpRefs.fileNumber, { duration: 1500 })
const usersNumberOutput = useTransition(countUpRefs.usersNumber, { duration: 1500 })
const addonsNumberOutput = useTransition(countUpRefs.addonsNumber, { duration: 1500 })
const statisticValueStyle: CSSProperties = {
fontSize: '28px',
}
index().then((res) => {
state.remark = res.data.remark
})
const initCountUp = () => {
// 虚拟数据
countUpRefs.userRegNumber.value = 5456
countUpRefs.fileNumber.value = 1234
countUpRefs.usersNumber.value = 9486
countUpRefs.addonsNumber.value = 875
const disposeCharts = () => {
for (const c of state.charts) {
c.dispose()
}
state.charts = []
}
const initUserGrowthChart = () => {
const userGrowthChart = echarts.init(chartRefs.value[0] as HTMLElement)
const option = {
grid: {
top: 40,
right: 0,
bottom: 20,
left: 40,
},
xAxis: {
data: [
t('dashboard.Monday'),
t('dashboard.Tuesday'),
t('dashboard.Wednesday'),
t('dashboard.Thursday'),
t('dashboard.Friday'),
t('dashboard.Saturday'),
t('dashboard.Sunday'),
],
},
yAxis: {},
const parseMoneySeries = (arr: string[]) => arr.map((s) => parseFloat(String(s)) || 0)
const initTrendDepositChart = () => {
const el = chartRefs.value[0] as HTMLElement
if (!el) {
return
}
const chart = echarts.init(el)
chart.setOption({
color: ['#8595F4', '#67C23A'],
grid: { top: 48, right: 56, bottom: 24, left: 48 },
tooltip: { trigger: 'axis' },
legend: {
data: [t('dashboard.Visits'), t('dashboard.Registration volume')],
textStyle: {
color: '#73767a',
},
data: [t('dashboard.series_new_users'), t('dashboard.series_deposit_amount')],
textStyle: { color: '#73767a' },
top: 0,
},
series: [
xAxis: { type: 'category', data: trend.days, boundaryGap: false },
yAxis: [
{ type: 'value', name: t('dashboard.series_new_users'), splitLine: { lineStyle: { type: 'dashed' } } },
{
name: t('dashboard.Visits'),
data: [100, 160, 280, 230, 190, 200, 480],
type: 'line',
smooth: true,
areaStyle: {
color: '#8595F4',
},
},
{
name: t('dashboard.Registration volume'),
data: [45, 180, 146, 99, 210, 127, 288],
type: 'line',
smooth: true,
areaStyle: {
color: '#F48595',
opacity: 0.5,
},
type: 'value',
name: t('dashboard.series_deposit_amount'),
position: 'right',
splitLine: { show: false },
},
],
}
userGrowthChart.setOption(option)
state.charts.push(userGrowthChart)
}
const initFileGrowthChart = () => {
const fileGrowthChart = echarts.init(chartRefs.value[1] as HTMLElement)
const option = {
grid: {
top: 30,
right: 0,
bottom: 20,
left: 0,
},
tooltip: {
trigger: 'item',
},
legend: {
type: 'scroll',
bottom: 0,
data: (function () {
var list = []
for (var i = 1; i <= 28; i++) {
list.push(i + 2000 + '')
}
return list
})(),
textStyle: {
color: '#73767a',
},
},
visualMap: {
top: 'middle',
right: 10,
color: ['red', 'yellow'],
calculable: true,
},
radar: {
indicator: [
{ name: t('dashboard.picture') },
{ name: t('dashboard.file') },
{ name: t('dashboard.table') },
{ name: t('dashboard.Compressed package') },
{ name: t('dashboard.other') },
],
},
series: (function () {
var series = []
for (var i = 1; i <= 28; i++) {
series.push({
type: 'radar',
symbol: 'none',
lineStyle: {
width: 1,
},
emphasis: {
areaStyle: {
color: 'rgba(0,250,0,0.3)',
},
},
data: [
{
value: [(40 - i) * 10, (38 - i) * 4 + 60, i * 5 + 10, i * 9, (i * i) / 2],
name: i + 2000 + '',
},
],
})
}
return series
})(),
}
fileGrowthChart.setOption(option)
state.charts.push(fileGrowthChart)
}
const initUserSourceChart = () => {
const UserSourceChart = echarts.init(chartRefs.value[2] as HTMLElement)
const pathSymbols = {
reindeer:
'path://M-22.788,24.521c2.08-0.986,3.611-3.905,4.984-5.892 c-2.686,2.782-5.047,5.884-9.102,7.312c-0.992,0.005-0.25-2.016,0.34-2.362l1.852-0.41c0.564-0.218,0.785-0.842,0.902-1.347 c2.133-0.727,4.91-4.129,6.031-6.194c1.748-0.7,4.443-0.679,5.734-2.293c1.176-1.468,0.393-3.992,1.215-6.557 c0.24-0.754,0.574-1.581,1.008-2.293c-0.611,0.011-1.348-0.061-1.959-0.608c-1.391-1.245-0.785-2.086-1.297-3.313 c1.684,0.744,2.5,2.584,4.426,2.586C-8.46,3.012-8.255,2.901-8.04,2.824c6.031-1.952,15.182-0.165,19.498-3.937 c1.15-3.933-1.24-9.846-1.229-9.938c0.008-0.062-1.314-0.004-1.803-0.258c-1.119-0.771-6.531-3.75-0.17-3.33 c0.314-0.045,0.943,0.259,1.439,0.435c-0.289-1.694-0.92-0.144-3.311-1.946c0,0-1.1-0.855-1.764-1.98 c-0.836-1.09-2.01-2.825-2.992-4.031c-1.523-2.476,1.367,0.709,1.816,1.108c1.768,1.704,1.844,3.281,3.232,3.983 c0.195,0.203,1.453,0.164,0.926-0.468c-0.525-0.632-1.367-1.278-1.775-2.341c-0.293-0.703-1.311-2.326-1.566-2.711 c-0.256-0.384-0.959-1.718-1.67-2.351c-1.047-1.187-0.268-0.902,0.521-0.07c0.789,0.834,1.537,1.821,1.672,2.023 c0.135,0.203,1.584,2.521,1.725,2.387c0.102-0.259-0.035-0.428-0.158-0.852c-0.125-0.423-0.912-2.032-0.961-2.083 c-0.357-0.852-0.566-1.908-0.598-3.333c0.4-2.375,0.648-2.486,0.549-0.705c0.014,1.143,0.031,2.215,0.602,3.247 c0.807,1.496,1.764,4.064,1.836,4.474c0.561,3.176,2.904,1.749,2.281-0.126c-0.068-0.446-0.109-2.014-0.287-2.862 c-0.18-0.849-0.219-1.688-0.113-3.056c0.066-1.389,0.232-2.055,0.277-2.299c0.285-1.023,0.4-1.088,0.408,0.135 c-0.059,0.399-0.131,1.687-0.125,2.655c0.064,0.642-0.043,1.768,0.172,2.486c0.654,1.928-0.027,3.496,1,3.514 c1.805-0.424,2.428-1.218,2.428-2.346c-0.086-0.704-0.121-0.843-0.031-1.193c0.221-0.568,0.359-0.67,0.312-0.076 c-0.055,0.287,0.031,0.533,0.082,0.794c0.264,1.197,0.912,0.114,1.283-0.782c0.15-0.238,0.539-2.154,0.545-2.522 c-0.023-0.617,0.285-0.645,0.309,0.01c0.064,0.422-0.248,2.646-0.205,2.334c-0.338,1.24-1.105,3.402-3.379,4.712 c-0.389,0.12-1.186,1.286-3.328,2.178c0,0,1.729,0.321,3.156,0.246c1.102-0.19,3.707-0.027,4.654,0.269 c1.752,0.494,1.531-0.053,4.084,0.164c2.26-0.4,2.154,2.391-1.496,3.68c-2.549,1.405-3.107,1.475-2.293,2.984 c3.484,7.906,2.865,13.183,2.193,16.466c2.41,0.271,5.732-0.62,7.301,0.725c0.506,0.333,0.648,1.866-0.457,2.86 c-4.105,2.745-9.283,7.022-13.904,7.662c-0.977-0.194,0.156-2.025,0.803-2.247l1.898-0.03c0.596-0.101,0.936-0.669,1.152-1.139 c3.16-0.404,5.045-3.775,8.246-4.818c-4.035-0.718-9.588,3.981-12.162,1.051c-5.043,1.423-11.449,1.84-15.895,1.111 c-3.105,2.687-7.934,4.021-12.115,5.866c-3.271,3.511-5.188,8.086-9.967,10.414c-0.986,0.119-0.48-1.974,0.066-2.385l1.795-0.618 C-22.995,25.682-22.849,25.035-22.788,24.521z',
plane: 'path://M1.112,32.559l2.998,1.205l-2.882,2.268l-2.215-0.012L1.112,32.559z M37.803,23.96 c0.158-0.838,0.5-1.509,0.961-1.904c-0.096-0.037-0.205-0.071-0.344-0.071c-0.777-0.005-2.068-0.009-3.047-0.009 c-0.633,0-1.217,0.066-1.754,0.18l2.199,1.804H37.803z M39.738,23.036c-0.111,0-0.377,0.325-0.537,0.924h1.076 C40.115,23.361,39.854,23.036,39.738,23.036z M39.934,39.867c-0.166,0-0.674,0.705-0.674,1.986s0.506,1.986,0.674,1.986 s0.672-0.705,0.672-1.986S40.102,39.867,39.934,39.867z M38.963,38.889c-0.098-0.038-0.209-0.07-0.348-0.073 c-0.082,0-0.174,0-0.268-0.001l-7.127,4.671c0.879,0.821,2.42,1.417,4.348,1.417c0.979,0,2.27-0.006,3.047-0.01 c0.139,0,0.25-0.034,0.348-0.072c-0.646-0.555-1.07-1.643-1.07-2.967C37.891,40.529,38.316,39.441,38.963,38.889z M32.713,23.96 l-12.37-10.116l-4.693-0.004c0,0,4,8.222,4.827,10.121H32.713z M59.311,32.374c-0.248,2.104-5.305,3.172-8.018,3.172H39.629 l-25.325,16.61L9.607,52.16c0,0,6.687-8.479,7.95-10.207c1.17-1.6,3.019-3.699,3.027-6.407h-2.138 c-5.839,0-13.816-3.789-18.472-5.583c-2.818-1.085-2.396-4.04-0.031-4.04h0.039l-3.299-11.371h3.617c0,0,4.352,5.696,5.846,7.5 c2,2.416,4.503,3.678,8.228,3.87h30.727c2.17,0,4.311,0.417,6.252,1.046c3.49,1.175,5.863,2.7,7.199,4.027 C59.145,31.584,59.352,32.025,59.311,32.374z M22.069,30.408c0-0.815-0.661-1.475-1.469-1.475c-0.812,0-1.471,0.66-1.471,1.475 s0.658,1.475,1.471,1.475C21.408,31.883,22.069,31.224,22.069,30.408z M27.06,30.408c0-0.815-0.656-1.478-1.466-1.478 c-0.812,0-1.471,0.662-1.471,1.478s0.658,1.477,1.471,1.477C26.404,31.885,27.06,31.224,27.06,30.408z M32.055,30.408 c0-0.815-0.66-1.475-1.469-1.475c-0.808,0-1.466,0.66-1.466,1.475s0.658,1.475,1.466,1.475 C31.398,31.883,32.055,31.224,32.055,30.408z M37.049,30.408c0-0.815-0.658-1.478-1.467-1.478c-0.812,0-1.469,0.662-1.469,1.478 s0.656,1.477,1.469,1.477C36.389,31.885,37.049,31.224,37.049,30.408z M42.039,30.408c0-0.815-0.656-1.478-1.465-1.478 c-0.811,0-1.469,0.662-1.469,1.478s0.658,1.477,1.469,1.477C41.383,31.885,42.039,31.224,42.039,30.408z M55.479,30.565 c-0.701-0.436-1.568-0.896-2.627-1.347c-0.613,0.289-1.551,0.476-2.73,0.476c-1.527,0-1.639,2.263,0.164,2.316 C52.389,32.074,54.627,31.373,55.479,30.565z',
rocket: 'path://M-244.396,44.399c0,0,0.47-2.931-2.427-6.512c2.819-8.221,3.21-15.709,3.21-15.709s5.795,1.383,5.795,7.325C-237.818,39.679-244.396,44.399-244.396,44.399z M-260.371,40.827c0,0-3.881-12.946-3.881-18.319c0-2.416,0.262-4.566,0.669-6.517h17.684c0.411,1.952,0.675,4.104,0.675,6.519c0,5.291-3.87,18.317-3.87,18.317H-260.371z M-254.745,18.951c-1.99,0-3.603,1.676-3.603,3.744c0,2.068,1.612,3.744,3.603,3.744c1.988,0,3.602-1.676,3.602-3.744S-252.757,18.951-254.745,18.951z M-255.521,2.228v-5.098h1.402v4.969c1.603,1.213,5.941,5.069,7.901,12.5h-17.05C-261.373,7.373-257.245,3.558-255.521,2.228zM-265.07,44.399c0,0-6.577-4.721-6.577-14.896c0-5.942,5.794-7.325,5.794-7.325s0.393,7.488,3.211,15.708C-265.539,41.469-265.07,44.399-265.07,44.399z M-252.36,45.15l-1.176-1.22L-254.789,48l-1.487-4.069l-1.019,2.116l-1.488-3.826h8.067L-252.36,45.15z',
train: 'path://M67.335,33.596L67.335,33.596c-0.002-1.39-1.153-3.183-3.328-4.218h-9.096v-2.07h5.371 c-4.939-2.07-11.199-4.141-14.89-4.141H19.72v12.421v5.176h38.373c4.033,0,8.457-1.035,9.142-5.176h-0.027 c0.076-0.367,0.129-0.751,0.129-1.165L67.335,33.596L67.335,33.596z M27.999,30.413h-3.105v-4.141h3.105V30.413z M35.245,30.413 h-3.104v-4.141h3.104V30.413z M42.491,30.413h-3.104v-4.141h3.104V30.413z M49.736,30.413h-3.104v-4.141h3.104V30.413z M14.544,40.764c1.143,0,2.07-0.927,2.07-2.07V35.59V25.237c0-1.145-0.928-2.07-2.07-2.07H-9.265c-1.143,0-2.068,0.926-2.068,2.07 v10.351v3.105c0,1.144,0.926,2.07,2.068,2.07H14.544L14.544,40.764z M8.333,26.272h3.105v4.141H8.333V26.272z M1.087,26.272h3.105 v4.141H1.087V26.272z M-6.159,26.272h3.105v4.141h-3.105V26.272z M-9.265,41.798h69.352v1.035H-9.265V41.798z',
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'none',
},
formatter: function (params: any) {
return params[0].name + ': ' + params[0].value
},
},
xAxis: {
data: [t('dashboard.Baidu'), t('dashboard.Direct access'), t('dashboard.take a plane'), t('dashboard.Take the high-speed railway')],
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#e54035',
},
},
yAxis: {
splitLine: { show: false },
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { show: false },
},
color: ['#e54035'],
series: [
{
name: 'hill',
type: 'pictorialBar',
barCategoryGap: '-130%',
symbol: 'path://M0,10 L10,10 C5.5,10 5.5,5 5,0 C4.5,5 4.5,10 0,10 z',
itemStyle: {
opacity: 0.5,
},
emphasis: {
itemStyle: {
opacity: 1,
},
},
data: [123, 60, 25, 80],
z: 10,
name: t('dashboard.series_new_users'),
type: 'line',
smooth: true,
data: trend.new_users,
areaStyle: { color: 'rgba(133,149,244,0.15)' },
},
{
name: 'glyph',
type: 'pictorialBar',
barGap: '-100%',
symbolPosition: 'end',
symbolSize: 50,
symbolOffset: [0, '-120%'],
data: [
{
value: 123,
symbol: pathSymbols.reindeer,
symbolSize: [60, 60],
},
{
value: 60,
symbol: pathSymbols.rocket,
symbolSize: [50, 60],
},
{
value: 25,
symbol: pathSymbols.plane,
symbolSize: [65, 35],
},
{
value: 80,
symbol: pathSymbols.train,
symbolSize: [50, 30],
},
],
name: t('dashboard.series_deposit_amount'),
type: 'line',
smooth: true,
yAxisIndex: 1,
data: parseMoneySeries(trend.deposit_amount),
areaStyle: { color: 'rgba(103,194,58,0.12)' },
},
],
}
UserSourceChart.setOption(option)
state.charts.push(UserSourceChart)
})
state.charts.push(chart)
}
const initUserSurnameChart = () => {
const userSurnameChart = echarts.init(chartRefs.value[3] as HTMLElement)
const data = genData(20)
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
legend: {
type: 'scroll',
orient: 'vertical',
right: 10,
top: 20,
bottom: 20,
data: data.legendData,
textStyle: {
color: '#73767a',
},
},
const initBetTrendChart = () => {
const el = chartRefs.value[1] as HTMLElement
if (!el) {
return
}
const chart = echarts.init(el)
chart.setOption({
color: ['#E6A23C'],
grid: { top: 36, right: 16, bottom: 24, left: 48 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: trend.days },
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
series: [
{
name: t('dashboard.series_bet_amount'),
type: 'bar',
data: parseMoneySeries(trend.bet_amount),
itemStyle: { borderRadius: [4, 4, 0, 0] },
},
],
})
state.charts.push(chart)
}
const initChannelPie = () => {
const el = chartRefs.value[2] as HTMLElement
if (!el) {
return
}
const chart = echarts.init(el)
const data = channelShare.value.map((x) => ({ name: x.name, value: x.value }))
chart.setOption({
tooltip: { trigger: 'item' },
legend: { type: 'scroll', bottom: 0, textStyle: { color: '#73767a' } },
series: [
{
name: t('dashboard.full name'),
type: 'pie',
radius: '55%',
center: ['40%', '50%'],
data: data.seriesData,
radius: ['36%', '62%'],
center: ['50%', '46%'],
data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.15)' },
},
},
],
})
state.charts.push(chart)
}
const initDepositAmountChannelPie = () => {
const el = chartRefs.value[3] as HTMLElement
if (!el) {
return
}
function genData(count: any) {
// prettier-ignore
const nameList = [
'赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫', '蒋', '沈', '韩', '杨', '朱', '秦', '尤', '许', '何', '吕', '施', '张', '孔', '曹', '严', '华', '金', '魏', '陶', '姜', '戚', '谢', '邹', '喻', '柏', '水', '窦', '章', '云', '苏', '潘', '葛', '奚', '范', '彭', '郎', '鲁', '韦', '昌', '马', '苗', '凤', '花', '方', '俞', '任', '袁', '柳', '酆', '鲍', '史', '唐', '费', '廉', '岑', '薛', '雷', '贺', '倪', '汤', '滕', '殷', '罗', '毕', '郝', '邬', '安', '常', '乐', '于', '时', '傅', '皮', '卞', '齐', '康', '伍', '余', '元', '卜', '顾', '孟', '平', '黄', '和', '穆', '萧', '尹', '姚', '邵', '湛', '汪', '祁', '毛', '禹', '狄', '米', '贝', '明', '臧', '计', '伏', '成', '戴', '谈', '宋', '茅', '庞', '熊', '纪', '舒', '屈', '项', '祝', '董', '梁', '杜', '阮', '蓝', '闵', '席', '季', '麻', '强', '贾', '路', '娄', '危'
];
const legendData = []
const seriesData = []
for (var i = 0; i < count; i++) {
var name = Math.random() > 0.85 ? makeWord(2, 1) + '·' + makeWord(2, 0) : makeWord(2, 1)
legendData.push(name)
seriesData.push({
name: name,
value: Math.round(Math.random() * 100000),
})
}
const chart = echarts.init(el)
const data = depositAmountChannelShare.value.map((x) => {
const v = parseFloat(String(x.value))
return {
legendData: legendData,
seriesData: seriesData,
name: x.name,
value: Number.isFinite(v) ? v : 0,
}
function makeWord(max: any, min: any) {
const nameLen = Math.ceil(Math.random() * max + min)
const name = []
for (var i = 0; i < nameLen; i++) {
name.push(nameList[Math.round(Math.random() * nameList.length - 1)])
}
return name.join('')
}
}
userSurnameChart.setOption(option)
state.charts.push(userSurnameChart)
})
chart.setOption({
tooltip: {
trigger: 'item',
formatter: (p: { name?: string; value?: number; percent?: number }) => {
const name = p.name ?? ''
const val = typeof p.value === 'number' && Number.isFinite(p.value) ? p.value : 0
const pct = typeof p.percent === 'number' && Number.isFinite(p.percent) ? p.percent : 0
return name + '<br/>' + val.toFixed(2) + ' (' + pct.toFixed(1) + '%)'
},
},
legend: { type: 'scroll', bottom: 0, textStyle: { color: '#73767a' } },
series: [
{
type: 'pie',
radius: ['32%', '58%'],
center: ['50%', '46%'],
data,
emphasis: {
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.15)' },
},
},
],
})
state.charts.push(chart)
}
const echartsResize = () => {
@@ -503,95 +346,66 @@ const echartsResize = () => {
})
}
const onChangeWorkState = () => {
const time = parseInt((new Date().getTime() / 1000).toString())
const workingTime = Local.get(WORKING_TIME)
if (state.pauseWork) {
// 继续工作
workingTime.pauseTime += time - workingTime.startPauseTime
workingTime.startPauseTime = 0
Local.set(WORKING_TIME, workingTime)
state.pauseWork = false
startWork()
} else {
// 暂停工作
workingTime.startPauseTime = time
Local.set(WORKING_TIME, workingTime)
clearInterval(workTimer)
state.pauseWork = true
const applyPayload = (payload: DashboardPayload) => {
const s = payload.stats
stats.user_total = s.user_total
stats.user_new_today = s.user_new_today
stats.user_new_yesterday = s.user_new_yesterday
stats.user_new_growth_pct = s.user_new_growth_pct
stats.deposit_today_amount = s.deposit_today_amount
stats.deposit_today_count = s.deposit_today_count
stats.withdraw_pending = s.withdraw_pending
stats.bet_today_amount = s.bet_today_amount
stats.bet_today_count = s.bet_today_count
trend.days = payload.trend.days
trend.new_users = payload.trend.new_users
trend.deposit_amount = payload.trend.deposit_amount
trend.bet_amount = payload.trend.bet_amount
channelShare.value = payload.channel_share
depositAmountChannelShare.value = payload.deposit_amount_channel_share ?? []
recentUsers.value = payload.recent_users
}
const loadDashboard = async () => {
loading.value = true
try {
const res = await index()
const payload = res.data as unknown as DashboardPayload
applyPayload(payload)
await nextTick()
disposeCharts()
initTrendDepositChart()
initBetTrendChart()
initChannelPie()
initDepositAmountChannelPie()
echartsResize()
} catch (_e) {
// 统计数据加载失败时由 axios 拦截器提示;此处不重复展示
} finally {
loading.value = false
}
}
const startWork = () => {
const workingTime = Local.get(WORKING_TIME) || { date: '', startTime: 0, pauseTime: 0, startPauseTime: 0 }
const currentDate = d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
const time = parseInt((new Date().getTime() / 1000).toString())
if (workingTime.date != currentDate) {
workingTime.date = currentDate
workingTime.startTime = time
workingTime.pauseTime = workingTime.startPauseTime = 0
Local.set(WORKING_TIME, workingTime)
function formatUserTime(ts: number): string {
if (!ts || ts <= 0) {
return '—'
}
let startPauseTime = 0
if (workingTime.startPauseTime <= 0) {
state.pauseWork = false
startPauseTime = 0
} else {
state.pauseWork = true
startPauseTime = time - workingTime.startPauseTime // 已暂停时间
const now = Math.floor(Date.now() / 1000)
const diff = now - ts
if (diff < 60) {
return diff + t('dashboard.seconds_ago')
}
let workingSeconds = time - workingTime.startTime - workingTime.pauseTime - startPauseTime
state.workingTimeFormat = formatSeconds(workingSeconds)
if (!state.pauseWork) {
workTimer = window.setInterval(() => {
workingSeconds++
state.workingTimeFormat = formatSeconds(workingSeconds)
}, 1000)
if (diff < 3600) {
return Math.floor(diff / 60) + t('dashboard.minutes_ago')
}
}
const formatSeconds = (seconds: number) => {
var secondTime = 0 // 秒
var minuteTime = 0 // 分
var hourTime = 0 // 小时
var dayTime = 0 // 天
var result = ''
if (seconds < 60) {
secondTime = seconds
} else {
// 获取分钟除以60取整数得到整数分钟
minuteTime = Math.floor(seconds / 60)
// 获取秒数,秒数取佘,得到整数秒数
secondTime = Math.floor(seconds % 60)
// 如果分钟大于60将分钟转换成小时
if (minuteTime >= 60) {
// 获取小时获取分钟除以60得到整数小时
hourTime = Math.floor(minuteTime / 60)
// 获取小时后取佘的分获取分钟除以60取佘的分
minuteTime = Math.floor(minuteTime % 60)
if (hourTime >= 24) {
// 获取天数, 获取小时除以24得到整数天
dayTime = Math.floor(hourTime / 24)
// 获取小时后取余小时获取分钟除以24取余的分
hourTime = Math.floor(hourTime % 24)
}
}
if (diff < 86400) {
return Math.floor(diff / 3600) + t('dashboard.hours_ago')
}
result =
hourTime +
t('dashboard.hour') +
((minuteTime >= 10 ? minuteTime : '0' + minuteTime) + t('dashboard.minute')) +
((secondTime >= 10 ? secondTime : '0' + secondTime) + t('dashboard.second'))
if (dayTime > 0) {
result = dayTime + t('dashboard.day') + result
}
return result
const d = new Date(ts * 1000)
const pad = (n: number) => (n < 10 ? '0' + n : String(n))
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
}
onActivated(() => {
@@ -599,23 +413,12 @@ onActivated(() => {
})
onMounted(() => {
startWork()
initCountUp()
initUserGrowthChart()
initFileGrowthChart()
initUserSourceChart()
initUserSurnameChart()
loadDashboard()
useEventListener(window, 'resize', echartsResize)
})
onBeforeMount(() => {
for (const key in state.charts) {
state.charts[key].dispose()
}
})
onUnmounted(() => {
clearInterval(workTimer)
disposeCharts()
})
watch(
@@ -624,89 +427,12 @@ watch(
echartsResize()
}
)
</script>
<style scoped lang="scss">
.welcome {
background: #e1eaf9;
border-radius: 6px;
display: flex;
align-items: center;
padding: 15px 20px !important;
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
.welcome-img {
height: 100px;
margin-right: 10px;
user-select: none;
}
.welcome-title {
font-size: 1.5rem;
line-height: 30px;
color: var(--ba-color-primary-light);
}
.welcome-note {
padding-top: 6px;
font-size: 15px;
color: var(--el-text-color-primary);
}
}
.working {
height: 130px;
display: flex;
justify-content: center;
flex-wrap: wrap;
height: 100%;
position: relative;
&:hover {
.working-coffee {
-webkit-transform: translateY(-4px) scale(1.02);
-moz-transform: translateY(-4px) scale(1.02);
-ms-transform: translateY(-4px) scale(1.02);
-o-transform: translateY(-4px) scale(1.02);
transform: translateY(-4px) scale(1.02);
z-index: 999;
}
}
.working-coffee {
transition: all 0.3s ease;
width: 80px;
}
.working-text {
display: block;
width: 100%;
font-size: 15px;
text-align: center;
color: var(--el-text-color-primary);
}
.working-opt {
position: absolute;
top: -40px;
right: 10px;
background-color: rgba($color: #000000, $alpha: 0.3);
padding: 10px 20px;
border-radius: 20px;
color: var(--ba-bg-color-overlay);
transition: all 0.3s ease;
cursor: pointer;
opacity: 0;
z-index: 999;
&:active {
background-color: rgba($color: #000000, $alpha: 0.6);
}
}
&:hover {
.working-opt {
opacity: 1;
top: 0;
}
.working-done {
opacity: 1;
top: 50px;
}
}
}
.small-panel-box {
margin-top: 20px;
margin-top: 0;
}
.small-panel {
background-color: #e9edf2;
@@ -731,8 +457,11 @@ watch(
}
}
.content-right {
font-size: 18px;
font-size: 14px;
margin-left: auto;
text-align: right;
max-width: 48%;
line-height: 1.3;
}
.color-success {
color: var(--el-color-success);
@@ -755,62 +484,62 @@ watch(
.file-growth-chart {
height: 260px;
}
.user-source-chart,
.user-surname-chart {
height: 360px;
}
.new-user-growth {
height: 300px;
}
.user-source-chart,
.user-surname-chart {
height: 400px;
}
.new-user-item {
display: flex;
align-items: center;
padding: 20px;
margin: 10px 15px;
padding: 16px 20px;
margin: 8px 12px;
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
background-color: var(--ba-bg-color-overlay);
.new-user-avatar {
height: 48px;
width: 48px;
border-radius: 50%;
object-fit: cover;
}
.new-user-base {
margin-left: 10px;
flex: 1;
min-width: 0;
color: #2c3f5d;
.new-user-name {
font-size: 15px;
font-weight: 500;
}
.new-user-time {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.new-user-channel {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 2px;
}
}
.new-user-arrow {
margin-left: auto;
flex-shrink: 0;
}
}
.new-user-card :deep(.el-card__body) {
padding: 0;
}
@media screen and (max-width: 425px) {
.welcome-img {
display: none;
}
}
@media screen and (max-width: 1200px) {
.lg-mb-20 {
margin-bottom: 20px;
}
}
html.dark {
.welcome {
background-color: var(--ba-bg-color-overlay);
}
.working-opt {
color: var(--el-text-color-primary);
background-color: var(--ba-border-color);
}
.small-panel {
background-color: var(--ba-bg-color-overlay);
.small-panel-content {

View File

@@ -1,185 +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('game.channel.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
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'
import createAxios from '/@/utils/axios'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
defineOptions({
name: 'game/channel',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const channelApiBase = `${adminBaseRoutePath}/game.Channel/`
const baTable = new baTableClass(
new baTableApi(channelApiBase),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.channel.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.channel.code'),
prop: 'code',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('game.channel.name'),
prop: 'name',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('game.channel.user_count'), prop: 'user_count', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('game.channel.profit_amount'), prop: 'profit_amount', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.channel.status'),
prop: 'status',
align: 'center',
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('game.channel.status 0'), '1': t('game.channel.status 1') },
},
{
label: t('game.channel.admingroup__name'),
prop: 'adminGroup.name',
align: 'center',
minWidth: 110,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('game.channel.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 90,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('game.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('game.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: 80, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: { status: '1' },
}
)
provide('baTable', baTable)
baTable.postDel = (ids: string[]) => {
if (baTable.runBefore('postDel', { ids }) === false) return
void (async () => {
try {
const stats = await createAxios(
{
url: channelApiBase + 'index',
method: 'get',
params: { delete_related_counts: '1', ids },
},
{ showSuccessMessage: false }
)
const counts = stats.data as { game_config_count?: number; game_user_count?: number }
const countConfig = Number(counts?.game_config_count ?? 0)
const countUser = Number(counts?.game_user_count ?? 0)
await ElMessageBox.confirm(
t('game.channel.delete_confirm_related', { countConfig, countUser }),
t('game.channel.delete_confirm_title'),
{
type: 'warning',
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
}
)
const delRes = await createAxios(
{
url: channelApiBase + 'del',
method: 'DELETE',
params: { ids, confirm_cascade: 1 },
},
{ showSuccessMessage: true }
)
baTable.onTableHeaderAction('refresh', { event: 'delete', ids })
baTable.runAfter('postDel', { res: delRes })
} catch {
// 用户取消或接口失败axios 已提示)
}
})()
}
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,93 +0,0 @@
<template>
<div v-if="isGameWeight && weightTagLabels.length" class="game-config-value-tags">
<el-tag
v-for="(label, idx) in weightTagLabels"
:key="idx"
class="m-4"
effect="light"
type="primary"
size="default"
>
{{ label }}
</el-tag>
</div>
<span v-else class="game-config-value-plain">{{ plainText }}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
renderRow: TableRow
renderField: TableColumn
renderValue: unknown
renderColumn: import('element-plus').TableColumnCtx<TableRow>
renderIndex: number
}>()
const isGameWeight = computed(() => props.renderRow?.group === 'game_weight')
/**
* value 形如 [{"T1":"5"},{"T2":"20"},...] 或同结构的 JSON 字符串
*/
function parseWeightTagLabels(raw: unknown): string[] {
if (raw === null || raw === undefined || raw === '') {
return []
}
let arr: unknown[] = []
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return []
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : []
} catch {
return []
}
} else if (Array.isArray(raw)) {
arr = raw
} else {
return []
}
const labels: string[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
for (const [k, v] of Object.entries(item as Record<string, unknown>)) {
labels.push(`${k}:${String(v)}`)
}
}
}
return labels
}
const weightTagLabels = computed(() => parseWeightTagLabels(props.renderValue))
const plainText = computed(() => {
const v = props.renderValue
if (v === null || v === undefined) return ''
if (typeof v === 'object') {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
return String(v)
})
</script>
<style scoped lang="scss">
.game-config-value-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 0;
}
.m-4 {
margin: 4px;
}
.game-config-value-plain {
word-break: break-all;
}
</style>

View File

@@ -2,19 +2,13 @@
<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('game.config.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
</div>
</template>
@@ -23,7 +17,6 @@
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import GameConfigValueCell from './GameConfigValueCell.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
@@ -38,85 +31,60 @@ const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/game.Config/'),
{
pk: 'ID',
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.config.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('game.config.channel_id'),
prop: 'channel_id',
label: t('game.config.config_key'),
prop: 'config_key',
align: 'center',
show: false,
width: 88,
operator: 'RANGE',
sortable: 'custom',
},
{
label: t('game.config.channel__name'),
prop: 'channel.name',
align: 'center',
minWidth: 100,
sortable: 'true',
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
showOverflowTooltip: true,
},
{
label: t('game.config.group'),
prop: 'group',
label: t('game.config.config_value'),
prop: 'config_value',
align: 'center',
minWidth: 160,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.config.name'),
prop: 'name',
label: t('game.config.value_type'),
prop: 'value_type',
align: 'center',
minWidth: 180,
operatorPlaceholder: t('Fuzzy query'),
width: 110,
operator: 'eq',
render: 'tag',
sortable: false,
operator: 'LIKE',
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.title'),
prop: 'title',
label: t('game.config.remark'),
prop: 'remark',
align: 'center',
minWidth: 85,
minWidth: 200,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.config.value'),
prop: 'value',
align: 'center',
minWidth: 220,
sortable: false,
operator: 'LIKE',
comSearchRender: 'string',
render: 'customRender',
customRender: GameConfigValueCell,
},
{
label: t('game.config.instantiation'),
prop: 'instantiation',
align: 'center',
custom: { 0: 'error', 1: 'primary' },
operator: 'RANGE',
sortable: false,
render: 'tag',
replaceValue: { '0': t('game.config.instantiation 0'), '1': t('game.config.instantiation 1') },
},
{ label: t('game.config.sort'), prop: 'sort', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.config.create_time'),
prop: 'create_time',
@@ -125,7 +93,7 @@ const baTable = new baTableClass(
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
@@ -136,16 +104,15 @@ const baTable = new baTableClass(
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 160,
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined, 'instantiation'],
defaultOrder: { prop: 'channel_id', order: 'desc' },
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { sort: 100 },
defaultItems: { value_type: 'string', remark: '' },
}
)

View File

@@ -1,7 +1,4 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
@@ -30,91 +27,41 @@
:rules="rules"
>
<FormItem
:label="t('game.config.channel_id')"
type="remoteSelect"
v-model="baTable.form.items!.channel_id"
prop="channel_id"
:input-attr="{ ...channelRemoteAttr, disabled: metaFieldsDisabled }"
:placeholder="t('Please select field', { field: t('game.config.channel_id') })"
/>
<FormItem
:label="t('game.config.group')"
type="select"
v-model="baTable.form.items!.group"
prop="group"
:input-attr="{ content: groupSelectContentFiltered, disabled: metaFieldsDisabled }"
:placeholder="t('Please select field', { field: t('game.config.group') })"
/>
<FormItem
:label="t('game.config.name')"
type="select"
v-model="baTable.form.items!.name"
prop="name"
:input-attr="{ content: nameSelectContent, disabled: metaFieldsDisabled }"
:placeholder="t('Please select field', { field: t('game.config.name') })"
/>
<FormItem
:label="t('game.config.title')"
:label="t('game.config.config_key')"
type="string"
v-model="baTable.form.items!.title"
prop="title"
:input-attr="{ disabled: metaFieldsDisabled }"
:placeholder="t('Please input field', { field: t('game.config.title') })"
v-model="baTable.form.items!.config_key"
prop="config_key"
:placeholder="t('Please input field', { field: t('game.config.config_key') })"
:input-attr="{ disabled: baTable.form.operate === 'Edit' }"
/>
<!-- game_weight数组形式编辑存库仍为 JSON 字符串 -->
<el-form-item v-if="isGameWeight" :label="t('game.config.value')" prop="value">
<div class="weight-value-editor">
<div v-for="(row, idx) in weightRows" :key="idx" class="weight-value-row">
<el-input
v-model="row.key"
class="weight-key"
:readonly="weightKeyReadonly"
:clearable="!weightKeyReadonly"
:placeholder="t('Please input field', { field: t('game.config.weight key') })"
@input="onWeightRowChange"
/>
<span class="weight-sep">:</span>
<el-input
v-model="row.val"
class="weight-val"
:placeholder="t('Please input field', { field: t('game.config.weight value') })"
clearable
:disabled="isDefaultBigwinWeight && isBigwinDiceLockedKey(row.key)"
@input="onWeightRowChange"
/>
<el-button v-if="canEditWeightStructure" type="danger" link @click="removeWeightRow(idx)">
{{ t('Delete') }}
</el-button>
</div>
<el-button v-if="canEditWeightStructure" type="primary" link @click="addWeightRow">{{ t('Add') }}</el-button>
<div v-if="isDefaultBigwinWeight" class="form-help">{{ t('game.config.default_bigwin_weight_help') }}</div>
</div>
</el-form-item>
<FormItem
v-else
:label="t('game.config.value')"
:label="t('game.config.config_value')"
type="textarea"
v-model="baTable.form.items!.value"
prop="value"
:input-attr="{ rows: 3 }"
@keyup.enter.stop=""
@keyup.ctrl.enter="baTable.onSubmit(formRef)"
:placeholder="t('Please input field', { field: t('game.config.value') })"
v-model="baTable.form.items!.config_value"
prop="config_value"
:input-attr="{ rows: 5 }"
:placeholder="t('Please input field', { field: t('game.config.config_value') })"
/>
<FormItem
:label="t('game.config.sort')"
type="number"
v-model="baTable.form.items!.sort"
prop="sort"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('game.config.sort') })"
:label="t('game.config.value_type')"
type="radio"
v-model="baTable.form.items!.value_type"
prop="value_type"
:input-attr="{
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.instantiation')"
type="switch"
v-model="baTable.form.items!.instantiation"
prop="instantiation"
:input-attr="{ content: { '0': t('game.config.instantiation 0'), '1': t('game.config.instantiation 1') } }"
:label="t('game.config.remark')"
type="textarea"
v-model="baTable.form.items!.remark"
prop="remark"
:input-attr="{ rows: 2 }"
/>
</el-form>
</div>
@@ -132,395 +79,28 @@
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
import { inject, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate'
import {
fixedRowsFromKeys,
getFixedKeysForGameConfigName,
isBigwinDiceLockedKey,
jsonStringFromFixedKeys,
normalizeGameWeightConfigName,
parseWeightJsonToMap,
rowsToMap,
weightRowsMatchBigwinDiceKeys,
type WeightRow,
} from '/@/utils/gameWeightFixed'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const { t } = useI18n()
const isSuperAdmin = computed(() => adminInfo.super === true)
/** 编辑且非超级管理员:渠道、分组、配置标识、配置名称不可改 */
const metaFieldsDisabled = computed(() => !isSuperAdmin.value && baTable.form.operate === 'Edit')
const channelRemoteAttr = {
pk: 'game_channel.id',
field: 'name',
remoteUrl: '/admin/game.Channel/index',
}
const groupSelectBase = {
game_config: 'game_config',
game_weight: 'game_weight',
}
/** 非超级管理员新增时不可选 game_weight需先由超级管理员建好键结构 */
const groupSelectContentFiltered = computed(() => {
if (!isSuperAdmin.value && baTable.form.operate === 'Add') {
return { game_config: groupSelectBase.game_config }
}
return groupSelectBase
})
/** default_tier_weight / default_bigwin_weight及 default_kill_score_weight键固定仅值可改不可增删行 */
const isFixedGameWeightConfig = computed(() => getFixedKeysForGameConfigName(baTable.form.items?.name) !== null)
/** game_weight编辑或非超管时键只读仅超管新增非固定项时可增删行、改键 */
const weightKeyReadonly = computed(() => {
if (!isGameWeight.value) return false
if (isFixedGameWeightConfig.value) return true
if (baTable.form.operate === 'Edit') return true
return !isSuperAdmin.value
})
const canEditWeightStructure = computed(
() => isGameWeight.value && baTable.form.operate === 'Add' && isSuperAdmin.value && !isFixedGameWeightConfig.value
)
/** 默认大奖权重:仅校验每项整数与 0100005/30 固定 10000不参与 tier/kill 的「和≤100」 */
const isDefaultBigwinWeight = computed(() => normalizeGameWeightConfigName(baTable.form.items?.name) === 'default_bigwin_weight')
const weightRows = ref<WeightRow[]>([{ key: '', val: '' }])
/** default_tier_weight / default_kill_score_weight每项≤100且权重之和必须=100 */
const WEIGHT_SUM100_NAMES = ['default_tier_weight', 'default_kill_score_weight']
/** 配置标识:按分组限定可选项;编辑时若库中旧值不在列表中则临时追加一条 */
const nameSelectContent = computed((): Record<string, string> => {
const g = baTable.form.items?.group
let base: Record<string, string> = {}
if (g === 'game_config') {
base = {
game_rule: t('game.config.name opt game_rule'),
game_rule_en: t('game.config.name opt game_rule_en'),
}
} else if (g === 'game_weight') {
base = {
default_tier_weight: t('game.config.name opt default_tier_weight'),
default_kill_score_weight: t('game.config.name opt default_kill_score_weight'),
default_bigwin_weight: t('game.config.name opt default_bigwin_weight'),
}
}
const n = baTable.form.items?.name
if (typeof n === 'string' && n.trim() !== '' && base[n] === undefined) {
const norm = normalizeGameWeightConfigName(n)
if (norm !== '' && base[norm] !== undefined) {
return { ...base, [n]: base[norm] }
}
return { ...base, [n]: n }
}
return base
})
const isGameWeight = computed(() => baTable.form.items?.group === 'game_weight')
function parseValueToWeightRows(raw: unknown): WeightRow[] {
if (raw === null || raw === undefined || raw === '') {
return [{ key: '', val: '' }]
}
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return [{ key: '', val: '' }]
try {
const parsed = JSON.parse(s)
return arrayToWeightRows(parsed)
} catch {
return [{ key: '', val: '' }]
}
}
if (Array.isArray(raw)) {
return arrayToWeightRows(raw)
}
return [{ key: '', val: '' }]
}
function arrayToWeightRows(arr: unknown): WeightRow[] {
if (!Array.isArray(arr)) {
return [{ key: '', val: '' }]
}
const out: WeightRow[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
for (const [k, v] of Object.entries(item)) {
out.push({ key: k, val: v === null || v === undefined ? '' : String(v) })
}
}
}
return out.length ? out : [{ key: '', val: '' }]
}
function weightRowsToJsonString(rows: WeightRow[]): string {
const pairs: Record<string, string>[] = []
for (const r of rows) {
const k = r.key.trim()
if (k === '') continue
const one: Record<string, string> = {}
one[k] = r.val
pairs.push(one)
}
return JSON.stringify(pairs)
}
function syncWeightRowsToFormValue() {
const items = baTable.form.items
if (!items) return
const fixedKeys = getFixedKeysForGameConfigName(items.name)
if (fixedKeys) {
const map = rowsToMap(weightRows.value)
items.value = jsonStringFromFixedKeys(fixedKeys, map)
return
}
items.value = weightRowsToJsonString(weightRows.value)
}
function enforceDefaultBigwinLockedValues() {
if (normalizeGameWeightConfigName(baTable.form.items?.name) !== 'default_bigwin_weight') {
return
}
for (const r of weightRows.value) {
if (isBigwinDiceLockedKey(r.key)) {
r.val = '10000'
}
}
}
function onWeightRowChange() {
if (isGameWeight.value) {
enforceDefaultBigwinLockedValues()
syncWeightRowsToFormValue()
}
}
function addWeightRow() {
if (!canEditWeightStructure.value) return
weightRows.value.push({ key: '', val: '' })
syncWeightRowsToFormValue()
}
function removeWeightRow(idx: number) {
if (!canEditWeightStructure.value) return
if (weightRows.value.length <= 1) {
weightRows.value = [{ key: '', val: '' }]
} else {
weightRows.value.splice(idx, 1)
}
syncWeightRowsToFormValue()
}
function hydrateWeightRowsFromForm() {
if (!isGameWeight.value) return
const fixedKeys = getFixedKeysForGameConfigName(baTable.form.items?.name)
if (fixedKeys) {
const map = parseWeightJsonToMap(baTable.form.items?.value)
weightRows.value = fixedRowsFromKeys(fixedKeys, map)
enforceDefaultBigwinLockedValues()
syncWeightRowsToFormValue()
return
}
weightRows.value = parseValueToWeightRows(baTable.form.items?.value)
}
watch(isGameWeight, (gw) => {
if (gw) {
hydrateWeightRowsFromForm()
}
})
watch(
() => baTable.form.loading,
(loading) => {
if (loading === false && baTable.form.items?.group === 'game_weight') {
hydrateWeightRowsFromForm()
}
}
)
watch(
() => baTable.form.items?.group,
() => {
if (baTable.form.items?.group === 'game_weight') {
hydrateWeightRowsFromForm()
}
const items = baTable.form.items
if (!items || !isSuperAdmin.value) {
return
}
const c = nameSelectContent.value
const keys = Object.keys(c)
if (keys.length === 0) {
return
}
if (typeof items.name !== 'string' || items.name === '' || c[items.name] === undefined) {
items.name = keys[0]
}
}
)
watch(
() => baTable.form.items?.name,
() => {
if (baTable.form.items?.group === 'game_weight') {
hydrateWeightRowsFromForm()
}
}
)
function validateGameWeightRules(): string | undefined {
if (baTable.form.items?.group !== 'game_weight') {
return undefined
}
const configName = normalizeGameWeightConfigName(baTable.form.items?.name)
const fixedKeys = getFixedKeysForGameConfigName(configName)
const nums: number[] = []
if (fixedKeys) {
const map = rowsToMap(weightRows.value)
if (configName === 'default_bigwin_weight') {
for (const k of fixedKeys) {
const vs = (map[k] ?? '').trim()
if (vs === '') {
return t('Please input field', { field: t('game.config.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.config.weight value numeric')
}
if (isBigwinDiceLockedKey(k)) {
if (n !== 10000) {
return t('game.config.bigwin weight locked 5 30')
}
} else if (n < 0 || n > 10000) {
return t('game.config.bigwin weight each 0 10000')
}
nums.push(n)
}
} else {
for (const k of fixedKeys) {
const vs = (map[k] ?? '').trim()
if (vs === '') {
return t('Please input field', { field: t('game.config.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.config.weight value numeric')
}
if (n > 100) {
return t('game.config.weight each max 100')
}
nums.push(n)
}
}
} else {
// 非固定键名但行结构已是 530 骰子时,按大奖 010000 校验(避免 name 格式异常时误走每项≤10000
const treatAsBigwin = configName === 'default_bigwin_weight' || weightRowsMatchBigwinDiceKeys(weightRows.value)
for (const r of weightRows.value) {
const k = r.key.trim()
if (k === '') continue
const vs = r.val.trim()
if (vs === '') {
return t('Please input field', { field: t('game.config.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.config.weight value numeric')
}
if (treatAsBigwin) {
if (isBigwinDiceLockedKey(k)) {
if (n !== 10000) {
return t('game.config.bigwin weight locked 5 30')
}
} else if (n < 0 || n > 10000) {
return t('game.config.bigwin weight each 0 10000')
}
} else if (n > 10000) {
return t('game.config.weight each max 10000')
}
nums.push(n)
}
if (nums.length === 0) {
return t('Please input field', { field: t('game.config.value') })
}
}
if (WEIGHT_SUM100_NAMES.includes(configName)) {
let sum = 0
for (const x of nums) {
sum += x
}
if (Math.abs(sum - 100) > 0.000001) {
return t('game.config.weight sum must 100')
}
}
return undefined
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
group: [buildValidatorData({ name: 'required', title: t('game.config.group') })],
name: [buildValidatorData({ name: 'required', title: t('game.config.name') })],
title: [buildValidatorData({ name: 'required', title: t('game.config.title') })],
sort: [buildValidatorData({ name: 'number', title: t('game.config.sort') })],
instantiation: [buildValidatorData({ name: 'number', title: t('game.config.instantiation') })],
value: [
{
validator: (_rule, _val, callback) => {
const err = validateGameWeightRules()
if (err) {
callback(new Error(err))
return
}
callback()
},
trigger: ['blur', 'change'],
},
],
create_time: [buildValidatorData({ name: 'date', title: t('game.config.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.config.update_time') })],
config_key: [buildRequired('game.config.config_key')],
value_type: [buildRequired('game.config.value_type')],
})
function buildRequired(langKey: string): FormItemRule {
return {
required: true,
message: t('Please input field', { field: t(langKey) }),
}
}
</script>
<style scoped lang="scss">
.weight-value-editor {
width: 100%;
}
.weight-value-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.weight-key {
max-width: 140px;
}
.weight-val {
flex: 1;
min-width: 80px;
}
.weight-sep {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.form-help {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</style>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,654 @@
<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 live-control-card">
<el-alert v-if="snapshot.is_payout_phase" type="warning" :title="t('game.live.payout_phase')" show-icon class="mb-12" />
<div class="live-control-layout">
<div class="live-control-main">
<el-descriptions :column="1" border size="small" class="live-desc">
<el-descriptions-item :label="t('game.live.current_record')">
<span class="period-no">{{ snapshot.record?.period_no || '—' }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('game.live.ai_default_number')">
<span class="num-em">{{ snapshot.ai_default_number ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="snapshot.pending_draw_number != null" :label="t('game.live.pending_draw')">
<el-tag type="primary" effect="plain">{{ snapshot.pending_draw_number }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="countdown-block">
<div class="countdown-block__title">{{ t('game.live.countdown') }}</div>
<div class="countdown-cards">
<div class="cd-card">
<span class="cd-card__label">{{ t('game.live.bet_countdown') }}</span>
<span class="cd-card__val">{{ countdownParts.bet }}s</span>
</div>
<div class="cd-card">
<span class="cd-card__label">{{ t('game.live.draw_countdown') }}</span>
<span class="cd-card__val">{{ countdownParts.draw }}s</span>
</div>
<div class="cd-card" :class="{ 'is-active': snapshot.is_payout_phase }">
<span class="cd-card__label">{{ t('game.live.payout_countdown') }}</span>
<span class="cd-card__val">{{ countdownParts.payout }}</span>
</div>
</div>
</div>
<div class="calc-result-bar">
<span class="calc-result-bar__item">
<span class="calc-result-bar__k">{{ t('game.live.calc_result_number') }}</span>
<span class="calc-result-bar__v">{{ calcResultNumber ?? '—' }}</span>
</span>
<span class="calc-result-bar__item">
<span class="calc-result-bar__k">{{ t('game.live.calc_estimated_loss') }}</span>
<span class="calc-result-bar__v mono">{{ calcEstimatedLoss }}</span>
</span>
</div>
</div>
<aside class="live-control-aside">
<div class="aside-title">{{ t('game.live.action_panel') }}</div>
<div class="aside-field">
<span class="aside-field__label">{{ t('game.live.manual_draw_number') }}</span>
<el-input-number
v-model="manualNumber"
class="aside-field__input"
:min="1"
:max="snapshot.draw_number_max ?? 36"
:step="1"
controls-position="right"
/>
</div>
<div class="aside-btns">
<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_schedule_draw" @click="onDraw">
{{ t('game.live.btn_draw') }}
</el-button>
<el-button :loading="loading" @click="loadSnapshot">{{ t('Refresh') }}</el-button>
</div>
</aside>
</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="total_amount" :label="t('game.live.total_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
pending_draw_number: number | null
period_seconds?: number
bet_seconds?: number
pick_max_number_count?: number
/** 开奖号码池上限1draw_number_max与单注可选号码上限无关 */
draw_number_max?: number
remaining_seconds?: number
bet_remaining_seconds?: number
payout_remaining_seconds?: number
is_payout_phase?: boolean
can_calculate?: boolean
can_draw?: boolean
can_schedule_draw?: boolean
server_time?: number
}
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,
pending_draw_number: null,
period_seconds: 30,
bet_seconds: 20,
pick_max_number_count: 10,
draw_number_max: 36,
remaining_seconds: 0,
bet_remaining_seconds: 0,
payout_remaining_seconds: 0,
is_payout_phase: false,
can_calculate: false,
can_draw: false,
can_schedule_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')
/** 服务端 Unix 秒 本地 Unix 秒,用于派彩倒计时与服务器对齐 */
const serverSkewSeconds = ref(0)
/** 每秒递增,驱动派彩剩余秒本地刷新 */
const clockTick = ref(0)
let clockTimer: number | null = null
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 '-'
}
function syncServerClock(serverTime: unknown): void {
if (typeof serverTime === 'number' && Number.isFinite(serverTime)) {
serverSkewSeconds.value = serverTime - Math.floor(Date.now() / 1000)
snapshot.server_time = serverTime
}
}
function readPayoutUntilUnix(rec: anyObj | null): number | null {
if (!rec) {
return null
}
const v = rec.payout_until
if (v === null || v === undefined || v === '') {
return null
}
if (typeof v === 'number' && Number.isFinite(v)) {
return v
}
if (typeof v === 'string' && /^\d+$/.test(v)) {
return parseInt(v, 10)
}
return null
}
/** 派彩剩余秒:优先用 payout_until 与对时后的「服务器当前秒」计算,便于每秒递减 */
const payoutRemainingLive = computed(() => {
clockTick.value
if (!snapshot.is_payout_phase) {
return null
}
const until = readPayoutUntilUnix(snapshot.record)
if (until !== null) {
const serverNow = Math.floor(Date.now() / 1000) + serverSkewSeconds.value
const diff = until - serverNow
return diff > 0 ? diff : 0
}
return snapshot.payout_remaining_seconds ?? 0
})
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.pending_draw_number =
typeof res.data.pending_draw_number === 'number' ? res.data.pending_draw_number : null
snapshot.period_seconds = res.data.period_seconds ?? 30
snapshot.bet_seconds = res.data.bet_seconds ?? 20
snapshot.pick_max_number_count = res.data.pick_max_number_count ?? 10
snapshot.draw_number_max = res.data.draw_number_max ?? 36
snapshot.remaining_seconds = res.data.remaining_seconds ?? 0
snapshot.bet_remaining_seconds = res.data.bet_remaining_seconds ?? 0
snapshot.payout_remaining_seconds = res.data.payout_remaining_seconds ?? 0
snapshot.is_payout_phase = !!res.data.is_payout_phase
snapshot.can_calculate = !!res.data.can_calculate
snapshot.can_draw = !!res.data.can_draw
snapshot.can_schedule_draw = !!res.data.can_schedule_draw || !!res.data.can_draw
syncServerClock(res.data.server_time)
const dmax = res.data.draw_number_max ?? 36
if (manualNumber.value === null || manualNumber.value < 1 || manualNumber.value > dmax) 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.pending_draw_number =
typeof payload.pending_draw_number === 'number' ? payload.pending_draw_number : null
snapshot.period_seconds = payload.period_seconds ?? 30
snapshot.bet_seconds = payload.bet_seconds ?? 20
snapshot.pick_max_number_count = payload.pick_max_number_count ?? 10
snapshot.draw_number_max = payload.draw_number_max ?? 36
snapshot.remaining_seconds = payload.remaining_seconds ?? 0
snapshot.bet_remaining_seconds = payload.bet_remaining_seconds ?? 0
snapshot.payout_remaining_seconds = payload.payout_remaining_seconds ?? 0
snapshot.is_payout_phase = !!payload.is_payout_phase
snapshot.can_calculate = !!payload.can_calculate
snapshot.can_draw = !!payload.can_draw
snapshot.can_schedule_draw = !!payload.can_schedule_draw || !!payload.can_draw
syncServerClock(payload.server_time)
})
} 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 countdownParts = computed(() => {
const bet = snapshot.bet_remaining_seconds ?? 0
const draw = snapshot.remaining_seconds ?? 0
let payout = t('game.live.payout_na')
if (snapshot.is_payout_phase && payoutRemainingLive.value !== null) {
payout = `${payoutRemainingLive.value}s`
}
return { bet, draw, payout }
})
onMounted(async () => {
clockTimer = window.setInterval(() => {
clockTick.value++
}, 1000)
await loadSnapshot()
try {
await initPush()
} catch {
pushConnected.value = false
startPolling()
}
})
onUnmounted(() => {
try {
if (pushClient && typeof pushClient.disconnect === 'function') {
pushClient.disconnect()
}
} catch {
// ignore
}
stopPolling()
stopPushWatchdog()
if (clockTimer !== null) {
window.clearInterval(clockTimer)
clockTimer = null
}
})
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;
}
.live-control-card {
:deep(.el-card__body) {
padding-top: 8px;
}
}
.live-control-layout {
display: flex;
flex-wrap: wrap;
gap: 20px 24px;
align-items: flex-start;
}
.live-control-main {
flex: 1 1 320px;
min-width: 0;
}
.live-desc {
margin-bottom: 16px;
:deep(.el-descriptions__label) {
width: 120px;
font-weight: 500;
}
}
.period-no {
display: inline-block;
max-width: 100%;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.num-em {
font-size: 16px;
font-weight: 600;
color: var(--el-color-primary);
}
.countdown-block {
margin-bottom: 14px;
}
.countdown-block__title {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.countdown-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.cd-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 10px 12px;
background: var(--el-fill-color-blank);
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
text-align: center;
min-width: 0;
}
/* 移动端保持同一排,压缩间距与字号以省纵向空间 */
@media (max-width: 768px) {
.countdown-block {
margin-bottom: 10px;
}
.countdown-block__title {
margin-bottom: 6px;
}
.countdown-cards {
gap: 6px;
}
.cd-card {
padding: 6px 4px;
border-radius: 6px;
gap: 2px;
min-width: 0;
}
.cd-card__label {
font-size: 11px;
line-height: 1.2;
padding: 0 2px;
}
.cd-card__val {
font-size: 15px;
}
}
.cd-card.is-active {
border-color: var(--el-color-warning-light-5);
background: var(--el-color-warning-light-9);
}
.cd-card__label {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.3;
}
.cd-card__val {
font-size: 18px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--el-text-color-primary);
}
.calc-result-bar {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
padding: 10px 14px;
border-radius: 8px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
}
.calc-result-bar__item {
display: inline-flex;
align-items: baseline;
gap: 8px;
}
.calc-result-bar__k {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.calc-result-bar__v {
font-size: 14px;
font-weight: 500;
}
.calc-result-bar__v.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.live-control-aside {
flex: 0 0 240px;
padding-left: 20px;
border-left: 1px solid var(--el-border-color-lighter);
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 992px) {
.live-control-aside {
flex: 1 1 100%;
padding-left: 0;
border-left: none;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
.aside-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-regular);
}
.aside-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.aside-field__label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.aside-field__input {
width: 100%;
}
.aside-btns {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 8px;
align-items: stretch;
}
.aside-btns .el-button {
flex: 1;
min-width: 0;
margin-left: 0;
padding-left: 8px;
padding-right: 8px;
}
.aside-btns .el-button :deep(span) {
white-space: normal;
line-height: 1.25;
text-align: center;
}
</style>

View File

@@ -0,0 +1,113 @@
<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 formatCoin = (_row: any, _column: any, cellValue: number | string | null | undefined) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return '—'
const n = Number(cellValue)
if (Number.isNaN(n)) return '—'
return n.toFixed(4)
}
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.platform_profit_amount'),
prop: 'platform_profit_amount',
align: 'center',
minWidth: 130,
operator: 'RANGE',
sortable: false,
formatter: formatCoin,
},
{
label: t('game.record.winner_user_count'),
prop: 'winner_user_count',
align: 'center',
width: 110,
operator: 'RANGE',
sortable: false,
},
{ 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,61 @@
<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.platform_profit_amount')" type="string" v-model="baTable.form.items!.platform_profit_amount" prop="platform_profit_amount" />
<FormItem :label="t('game.record.winner_user_count')" type="number" v-model="baTable.form.items!.winner_user_count" prop="winner_user_count" :input-attr="{ step: 1, min: 0 }" />
<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

@@ -1,892 +0,0 @@
<template>
<div class="default-main ba-table-box">
<el-card shadow="never" class="reward-form-card" v-loading="pageLoading">
<template #header>
<span class="card-title">{{ t('game.rewardConfigForm.title') }}</span>
</template>
<el-alert
v-if="showTemplateIntro"
class="intro-alert"
type="info"
:closable="false"
show-icon
>
<template #title>{{ t('game.rewardConfigForm.intro') }}</template>
</el-alert>
<el-form
ref="formRef"
class="reward-form-body"
@submit.prevent=""
:model="formModel"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="formLabelWidth + 'px'"
:rules="rules"
>
<template v-if="isSuperAdmin">
<el-form-item :label="t('game.rewardConfigForm.super_scope_label')">
<el-radio-group v-model="superEditScope" @change="onSuperScopeChange">
<el-radio-button value="template">{{ t('game.rewardConfigForm.super_scope_template') }}</el-radio-button>
<el-radio-button value="channel">{{ t('game.rewardConfigForm.super_scope_channel') }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-alert
v-if="superEditScope === 'channel'"
class="scope-hint"
type="warning"
:closable="false"
:title="t('game.rewardConfigForm.super_scope_hint')"
/>
<div v-if="superEditScope === 'channel'" class="channel-bar">
<div class="channel-picker">
<FormItem
:label="t('game.rewardConfig.game_channel_id')"
type="remoteSelect"
v-model="formModel.game_channel_id"
prop="game_channel_id"
:input-attr="channelRemoteAttr"
:placeholder="t('Please select field', { field: t('game.rewardConfig.game_channel_id') })"
/>
</div>
<el-button type="primary" @click="loadData" :disabled="!formModel.game_channel_id">{{ t('Refresh') }}</el-button>
</div>
</template>
<template v-if="showInner">
<el-form-item :label="t('game.rewardConfig.tier_reward_form')" prop="tier_reward_form">
<div class="block-editor tier-editor">
<div class="line line-head">
<span>{{ t('game.rewardConfig.grid_number') }}</span>
<span>{{ t('game.rewardConfig.ui_text') }}</span>
<span>{{ t('game.rewardConfig.ui_text_en') }}</span>
<span>{{ t('game.rewardConfig.real_ev') }}</span>
<span>{{ t('game.rewardConfig.tier') }}</span>
<span>{{ t('game.rewardConfig.remark') }}</span>
</div>
<div v-for="(row, idx) in tierRows" :key="'tier-' + idx" class="line">
<el-input v-model="row.grid_number" disabled />
<el-input
v-model="row.ui_text"
:placeholder="t('Please input field', { field: t('game.rewardConfig.ui_text') })"
@input="syncPayload"
/>
<el-input
v-model="row.ui_text_en"
:placeholder="t('Please input field', { field: t('game.rewardConfig.ui_text_en') })"
@input="syncPayload"
/>
<el-input
v-model="row.real_ev"
:placeholder="t('Please input field', { field: t('game.rewardConfig.real_ev') })"
@input="syncPayload"
/>
<el-select v-model="row.tier" style="width: 100px" @change="syncPayload">
<el-option :label="t('game.rewardConfig.tier_t1')" value="T1" />
<el-option :label="t('game.rewardConfig.tier_t2')" value="T2" />
<el-option :label="t('game.rewardConfig.tier_t3')" value="T3" />
<el-option :label="t('game.rewardConfig.tier_t4')" value="T4" />
<el-option :label="t('game.rewardConfig.tier_t5')" value="T5" />
</el-select>
<el-input
v-model="row.remark"
:placeholder="t('Please input field', { field: t('game.rewardConfig.remark') })"
@input="syncPayload"
/>
</div>
<div class="form-help">{{ t('game.rewardConfig.tier_reward_form_help') }}</div>
</div>
</el-form-item>
<el-form-item :label="t('game.rewardConfig.bigwin_form')" prop="bigwin_form">
<div class="block-editor bigwin-editor">
<div class="line line-head">
<span>{{ t('game.rewardConfig.grid_number') }}</span>
<span>{{ t('game.rewardConfig.ui_text') }}</span>
<span>{{ t('game.rewardConfig.real_ev') }}</span>
<span>{{ t('game.rewardConfig.tier') }}</span>
</div>
<div v-for="(row, idx) in bigwinRows" :key="'bigwin-' + idx" class="line">
<el-input v-model="row.grid_number" disabled />
<el-input
v-model="row.ui_text"
:placeholder="t('Please input field', { field: t('game.rewardConfig.ui_text') })"
@input="syncPayload"
/>
<el-input
v-model="row.real_ev"
:placeholder="t('Please input field', { field: t('game.rewardConfig.real_ev') })"
@input="syncPayload"
/>
<el-input v-model="row.tier" disabled style="width: 120px" />
</div>
<div class="form-help">{{ t('game.rewardConfig.bigwin_form_help') }}</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitLoading" @click="onSubmit">{{ t('game.rewardConfigForm.btn_add') }}</el-button>
<el-button @click="onReset">{{ t('game.rewardConfigForm.btn_reset') }}</el-button>
<el-button v-auth="'generateTierBoard'" @click="openGenTierDialog">{{ t('game.rewardConfigForm.btn_gen_tier') }}</el-button>
<el-button v-auth="'generateRewardWeight'" :loading="genWeightSubmitting" @click="onGenWeightClick">{{
t('game.rewardConfigForm.btn_gen_weight')
}}</el-button>
</el-form-item>
</template>
</el-form>
</el-card>
<el-dialog
v-model="genTierDialogVisible"
class="gen-tier-dialog"
:title="t('game.rewardConfigForm.gen_tier_title')"
:width="genTierDialogWidth"
:fullscreen="genTierDialogFullscreen"
:align-center="!genTierDialogFullscreen"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
<el-scrollbar :max-height="genTierScrollMaxHeight" class="gen-tier-scroll">
<div class="gen-rule">{{ t('game.rewardConfigForm.gen_tier_rule') }}</div>
<el-form
:model="genTierForm"
class="gen-tier-form"
:label-position="genTierFormLabelPosition"
:label-width="genTierFormLabelWidth"
>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t1_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t1_fixed') + '' + t('game.rewardConfigForm.gen_dir_cw') + ''">
<el-input-number v-model="genTierForm.t1_fixed_cw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_t1_fixed') + '' + t('game.rewardConfigForm.gen_dir_ccw') + ''">
<el-input-number v-model="genTierForm.t1_fixed_ccw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_settlement')">
<el-input-number v-model="genTierForm.amt_t1" :min="0" :step="1" controls-position="right" />
</el-form-item>
</div>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t2_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t2_min') + '' + t('game.rewardConfigForm.gen_dir_cw') + ''">
<el-input-number v-model="genTierForm.t2_min_cw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_t2_min') + '' + t('game.rewardConfigForm.gen_dir_ccw') + ''">
<el-input-number v-model="genTierForm.t2_min_ccw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_settlement')">
<el-input-number v-model="genTierForm.amt_t2" :min="0" :precision="2" :step="0.1" controls-position="right" />
</el-form-item>
</div>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t3_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t3_amt_only')">
<el-input-number v-model="genTierForm.amt_t3" :min="0" :precision="2" :step="0.1" controls-position="right" />
</el-form-item>
</div>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t4_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t4_fixed') + '' + t('game.rewardConfigForm.gen_dir_cw') + ''">
<el-input-number v-model="genTierForm.t4_fixed_cw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_t4_fixed') + '' + t('game.rewardConfigForm.gen_dir_ccw') + ''">
<el-input-number v-model="genTierForm.t4_fixed_ccw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_settlement')">
<el-input-number v-model="genTierForm.amt_t4" :precision="2" :step="0.1" controls-position="right" />
</el-form-item>
</div>
<div class="gen-tier-block">
<div class="gen-tier-block-title">{{ t('game.rewardConfigForm.gen_t5_label') }}</div>
<el-form-item :label="t('game.rewardConfigForm.gen_t5_fixed') + '' + t('game.rewardConfigForm.gen_dir_cw') + ''">
<el-input-number v-model="genTierForm.t5_fixed_cw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_t5_fixed') + '' + t('game.rewardConfigForm.gen_dir_ccw') + ''">
<el-input-number v-model="genTierForm.t5_fixed_ccw" :min="0" :max="26" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('game.rewardConfigForm.gen_settlement')">
<el-input-number :model-value="0" disabled />
</el-form-item>
</div>
</el-form>
<div class="gen-tier-footer-hint">{{ t('game.rewardConfigForm.gen_tier_footer_hint') }}</div>
</el-scrollbar>
<template #footer>
<div class="gen-tier-dialog-footer">
<el-button @click="genTierDialogVisible = false">{{ t('game.rewardConfigForm.gen_tier_cancel') }}</el-button>
<el-button v-auth="'generateTierBoard'" type="primary" :loading="genTierSubmitting" @click="onGenTierSubmit">{{
t('game.rewardConfigForm.gen_tier_submit')
}}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { FormInstance, FormItemRule } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useWindowSize } from '@vueuse/core'
import { computed, 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 { useAdminInfo } from '/@/stores/adminInfo'
import createAxios from '/@/utils/axios'
defineOptions({
name: 'game/rewardConfig',
})
type RewardRow = {
grid_number: string
ui_text: string
ui_text_en: string
real_ev: string
tier: string
remark: string
}
const { t } = useI18n()
const config = useConfig()
const adminInfo = useAdminInfo()
const { width: windowWidth } = useWindowSize()
/** 生成弹窗:窄屏全屏/宽百分比,宽屏固定最大宽度 */
const genTierDialogFullscreen = computed(() => windowWidth.value <= 520)
const genTierDialogWidth = computed(() => {
if (windowWidth.value <= 520) {
return '100%'
}
if (windowWidth.value <= 768) {
return '92%'
}
return '720px'
})
const genTierFormLabelPosition = computed(() => (windowWidth.value < 640 ? 'top' : 'right'))
const genTierFormLabelWidth = computed(() => (windowWidth.value < 640 ? 'auto' : '150px'))
const genTierScrollMaxHeight = computed(() => (genTierDialogFullscreen.value ? 'calc(100vh - 140px)' : 'min(70vh, 640px)'))
const formRef = useTemplateRef<FormInstance>('formRef')
const TIER_GRIDS = Array.from({ length: 26 }, (_v, i) => String(i + 5))
const BIGWIN_GRIDS = ['5', '10', '15', '20', '25', '30']
const isSuperAdmin = computed(() => adminInfo.super === true)
const formLabelWidth = computed(() => (config.layout.shrink ? 100 : 140))
const channelRemoteAttr = { pk: 'game_channel.id', field: 'name', remoteUrl: '/admin/game.Channel/index' }
const pageLoading = ref(false)
const submitLoading = ref(false)
const genTierDialogVisible = ref(false)
const genTierSubmitting = ref(false)
const genWeightSubmitting = ref(false)
const channelChangeSilent = ref(false)
const lastAutoLoadChannelId = ref<number | null>(null)
const genTierForm = reactive({
t1_fixed_cw: 3,
t1_fixed_ccw: 3,
t2_min_cw: 5,
t2_min_ccw: 5,
t4_fixed_cw: 1,
t4_fixed_ccw: 1,
t5_fixed_cw: 1,
t5_fixed_ccw: 1,
amt_t1: 3,
amt_t2: 1.5,
amt_t3: 0.5,
amt_t4: -0.4,
})
/** 超管template = game_channel_id 0 默认模板channel = 编辑指定渠道 */
const superEditScope = ref<'template' | 'channel'>('template')
/** 仅超管维护「全渠道默认模板」game_channel_id=0、未绑定具体渠道时展示顶部说明渠道管理员或超管选「指定渠道」时不展示 */
const showTemplateIntro = computed(() => isSuperAdmin.value && superEditScope.value === 'template')
const formModel = reactive({
id: null as number | string | null,
game_channel_id: 0 as number | string,
tier_reward_form: '',
bigwin_form: '',
})
const tierRows = ref<RewardRow[]>(
TIER_GRIDS.map((g) => ({ grid_number: g, ui_text: '', ui_text_en: '', real_ev: '', tier: 'T1', remark: '' }))
)
const bigwinRows = ref<RewardRow[]>(
BIGWIN_GRIDS.map((g) => ({ grid_number: g, ui_text: '', ui_text_en: '', real_ev: '', tier: 'BIGWIN', remark: '' }))
)
const showInner = computed(() => {
if (!isSuperAdmin.value) {
return true
}
if (superEditScope.value === 'template') {
return true
}
return !!formModel.game_channel_id
})
function parseRows(raw: unknown): RewardRow[] {
if (typeof raw !== 'string' || raw.trim() === '') return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
const out: RewardRow[] = []
for (const item of parsed) {
if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
const obj = item as Record<string, unknown>
out.push({
grid_number: String(obj.grid_number ?? ''),
ui_text: String(obj.ui_text ?? ''),
ui_text_en: String(obj.ui_text_en ?? ''),
real_ev: String(obj.real_ev ?? ''),
tier: String(obj.tier ?? ''),
remark: String(obj.remark ?? ''),
})
}
return out
} catch {
return []
}
}
function toTierRows(raw: unknown): RewardRow[] {
const parsed = parseRows(raw)
const map = new Map<string, RewardRow>()
for (const r of parsed) map.set(r.grid_number, r)
return TIER_GRIDS.map((g) => {
const row = map.get(g)
return {
grid_number: g,
ui_text: row?.ui_text ?? '',
ui_text_en: row?.ui_text_en ?? '',
real_ev: row?.real_ev ?? '',
tier: row?.tier && row.tier !== 'BIGWIN' ? row.tier : 'T1',
remark: row?.remark ?? '',
}
})
}
function toBigwinRows(raw: unknown): RewardRow[] {
const parsed = parseRows(raw)
const map = new Map<string, RewardRow>()
for (const r of parsed) map.set(r.grid_number, r)
return BIGWIN_GRIDS.map((g) => {
const row = map.get(g)
return {
grid_number: g,
ui_text: row?.ui_text ?? '',
ui_text_en: row?.ui_text_en ?? '',
real_ev: row?.real_ev ?? '',
tier: 'BIGWIN',
remark: row?.remark ?? '',
}
})
}
function syncPayload() {
formModel.tier_reward_form = JSON.stringify(tierRows.value)
formModel.bigwin_form = JSON.stringify(bigwinRows.value)
}
function applyRowToForm(row: Record<string, unknown>) {
channelChangeSilent.value = true
formModel.id = (row.id as number | string | null | undefined) ?? null
formModel.game_channel_id = (row.game_channel_id as number | string) ?? 0
tierRows.value = toTierRows(row.tier_reward_form)
bigwinRows.value = toBigwinRows(row.bigwin_form)
syncPayload()
channelChangeSilent.value = false
}
function validateForms(): string | undefined {
if (tierRows.value.length !== 26 || bigwinRows.value.length !== 6) {
return t('Parameter error')
}
for (let i = 0; i < tierRows.value.length; i++) {
const row = tierRows.value[i]
if (row.grid_number !== TIER_GRIDS[i]) return t('Parameter error')
if (row.ui_text.trim() === '' || row.real_ev.trim() === '') return t('Please input field', { field: t('game.rewardConfig.ui_text') })
if (!Number.isFinite(Number(row.real_ev))) return t('game.rewardConfig.real_ev')
if (!['T1', 'T2', 'T3', 'T4', 'T5'].includes(row.tier)) return t('Parameter error')
}
for (let i = 0; i < bigwinRows.value.length; i++) {
const row = bigwinRows.value[i]
if (row.grid_number !== BIGWIN_GRIDS[i]) return t('Parameter error')
if (row.tier !== 'BIGWIN') return t('Parameter error')
if (row.ui_text.trim() === '' || row.real_ev.trim() === '') return t('Please input field', { field: t('game.rewardConfig.ui_text') })
if (!Number.isFinite(Number(row.real_ev))) return t('game.rewardConfig.real_ev')
}
return undefined
}
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
game_channel_id: [
{
validator: (_rule, _val, callback) => {
if (!isSuperAdmin.value || superEditScope.value !== 'channel') {
callback()
return
}
if (!_val && _val !== 0) {
callback(new Error(t('Please select field', { field: t('game.rewardConfig.game_channel_id') })))
return
}
if (_val === 0 || _val === '0') {
callback(new Error(t('Please select field', { field: t('game.rewardConfig.game_channel_id') })))
return
}
callback()
},
trigger: ['change', 'blur'],
},
],
tier_reward_form: [
{
validator: (_rule, _val, callback) => {
const err = validateForms()
if (err) return callback(new Error(err))
callback()
},
trigger: ['blur', 'change'],
},
],
bigwin_form: [
{
validator: (_rule, _val, callback) => {
const err = validateForms()
if (err) return callback(new Error(err))
callback()
},
trigger: ['blur', 'change'],
},
],
})
function resolveRequestChannelId(): number | null {
if (!isSuperAdmin.value) {
return null
}
if (superEditScope.value === 'template') {
return 0
}
const v = formModel.game_channel_id
if (!v && v !== 0) return null
const n = Number(v)
if (!Number.isFinite(n) || n <= 0) return null
return n
}
async function loadData() {
if (isSuperAdmin.value && superEditScope.value === 'channel' && !formModel.game_channel_id) {
return
}
pageLoading.value = true
try {
const params: Record<string, number> = {}
const cid = resolveRequestChannelId()
if (cid !== null) {
params.game_channel_id = cid
}
const res = await createAxios<{ row?: Record<string, unknown> }>(
{
url: '/admin/game.RewardConfig/index',
method: 'get',
params,
},
{ showErrorMessage: true, loading: false }
)
const row = res.data?.row
if (row && typeof row === 'object' && !Array.isArray(row)) {
applyRowToForm(row as Record<string, unknown>)
}
} catch (error: unknown) {
const err = error as { code?: string; message?: string }
if (err.code === 'ERR_CANCELED' || String(err.message ?? '').toLowerCase().includes('canceled')) {
return
}
} finally {
pageLoading.value = false
}
}
function onSuperScopeChange(val: string | number | boolean | undefined) {
if (val === 'template') {
formModel.game_channel_id = 0
loadData()
} else {
formModel.game_channel_id = ''
lastAutoLoadChannelId.value = null
formRef.value?.clearValidate(['game_channel_id'])
}
}
async function onSubmit() {
const form = formRef.value
if (!form) return
syncPayload()
await form.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const body: Record<string, string | number> = {
tier_reward_form: formModel.tier_reward_form,
bigwin_form: formModel.bigwin_form,
}
const cid = resolveRequestChannelId()
if (cid !== null) {
body.game_channel_id = cid
}
await createAxios(
{
url: '/admin/game.RewardConfig/save',
method: 'post',
data: body,
},
{ showSuccessMessage: true, loading: false }
)
await loadData()
} finally {
submitLoading.value = false
}
})
}
async function onReset() {
formRef.value?.clearValidate()
await loadData()
}
function openGenTierDialog() {
genTierDialogVisible.value = true
}
async function onGenTierSubmit() {
genTierSubmitting.value = true
try {
const body: Record<string, string | number> = {
t1_fixed_cw: genTierForm.t1_fixed_cw,
t1_fixed_ccw: genTierForm.t1_fixed_ccw,
t2_min_cw: genTierForm.t2_min_cw,
t2_min_ccw: genTierForm.t2_min_ccw,
t4_fixed_cw: genTierForm.t4_fixed_cw,
t4_fixed_ccw: genTierForm.t4_fixed_ccw,
t5_fixed_cw: genTierForm.t5_fixed_cw,
t5_fixed_ccw: genTierForm.t5_fixed_ccw,
amt_t1: genTierForm.amt_t1,
amt_t2: genTierForm.amt_t2,
amt_t3: genTierForm.amt_t3,
amt_t4: genTierForm.amt_t4,
}
const cid = resolveRequestChannelId()
if (cid !== null) {
body.game_channel_id = cid
}
await createAxios(
{
url: '/admin/game.RewardConfig/generateTierBoard',
method: 'post',
data: body,
},
{ showSuccessMessage: true, loading: false }
)
genTierDialogVisible.value = false
await loadData()
} finally {
genTierSubmitting.value = false
}
}
async function onGenWeightClick() {
if (isSuperAdmin.value && superEditScope.value === 'channel' && !formModel.game_channel_id) {
ElMessage.warning(t('game.rewardConfigForm.gen_weight_need_channel'))
return
}
try {
await ElMessageBox.confirm(
t('game.rewardConfigForm.gen_weight_confirm_body'),
t('game.rewardConfigForm.gen_weight_confirm_title'),
{
type: 'warning',
confirmButtonText: t('game.rewardConfigForm.gen_weight_confirm_ok'),
cancelButtonText: t('Cancel'),
}
)
} catch {
return
}
genWeightSubmitting.value = true
try {
const body: Record<string, number> = {}
const cid = resolveRequestChannelId()
if (cid !== null) {
body.game_channel_id = cid
}
await createAxios(
{
url: '/admin/game.RewardConfig/generateRewardWeight',
method: 'post',
data: body,
},
{ showSuccessMessage: true, loading: false }
)
} finally {
genWeightSubmitting.value = false
}
}
onMounted(() => {
if (isSuperAdmin.value) {
superEditScope.value = 'template'
formModel.game_channel_id = 0
loadData()
} else {
loadData()
}
})
watch(
() => formModel.game_channel_id,
(val, oldVal) => {
if (!isSuperAdmin.value || superEditScope.value !== 'channel') {
return
}
if (channelChangeSilent.value) {
return
}
const nextCid = Number(val)
const prevCid = Number(oldVal)
if (Number.isFinite(nextCid) && Number.isFinite(prevCid) && nextCid === prevCid) {
return
}
if (!Number.isFinite(nextCid) || nextCid <= 0) {
return
}
if (lastAutoLoadChannelId.value === nextCid) {
return
}
lastAutoLoadChannelId.value = nextCid
void loadData()
}
)
</script>
<style scoped lang="scss">
.reward-form-card {
max-width: 1280px;
}
.card-title {
font-weight: 600;
}
.intro-alert {
margin-bottom: 16px;
}
.scope-hint {
margin-bottom: 12px;
}
.channel-bar {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.channel-picker {
flex: 1 1 360px;
min-width: 320px;
max-width: 520px;
}
.channel-picker :deep(.el-form-item) {
margin-bottom: 0;
}
.channel-picker :deep(.el-select) {
width: 100%;
}
.reward-form-body {
margin-top: 8px;
}
.block-editor {
width: 100%;
}
.tier-editor .line {
display: grid;
grid-template-columns: 64px minmax(88px, 1fr) minmax(88px, 1fr) minmax(88px, 1fr) minmax(96px, 110px) minmax(124px, 1fr);
column-gap: 10px;
row-gap: 8px;
margin-bottom: 8px;
}
.bigwin-editor .line {
display: grid;
grid-template-columns: 110px 1fr 1fr 120px;
gap: 8px;
margin-bottom: 8px;
}
.line-head {
margin-bottom: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 20px;
font-weight: 600;
}
.form-help {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
@media (max-width: 768px) {
.reward-form-card :deep(.el-card__body) {
padding: 12px 10px;
}
.reward-form-body :deep(.el-form-item) {
margin-bottom: 12px;
}
.channel-picker {
min-width: 100%;
max-width: 100%;
}
.channel-bar {
gap: 8px;
margin-bottom: 10px;
}
.channel-bar > .el-button {
width: 100%;
}
/* 移动端主配置表:保持列宽,允许左右滚动,避免被压扁 */
.tier-editor,
.bigwin-editor {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px;
}
.tier-editor .line {
min-width: 820px;
grid-template-columns: 48px 80px 80px 80px 100px 130px;
column-gap: 10px;
row-gap: 8px;
}
.bigwin-editor .line {
min-width: 560px;
grid-template-columns: 48px 80px 80px 100px;
}
.line-head {
font-size: 11px;
line-height: 16px;
}
.reward-form-body :deep(.el-form-item:last-child .el-form-item__content) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.reward-form-body :deep(.el-form-item:last-child .el-button) {
flex: 1 1 calc(50% - 8px);
min-width: 128px;
margin-left: 0;
}
}
</style>
<!-- 弹窗 append-to-body 后不在当前组件 DOM scoped 样式无法作用单独写全局选择器 -->
<style lang="scss">
.gen-tier-dialog.el-dialog {
box-sizing: border-box;
max-width: 100vw;
}
.gen-tier-dialog .el-dialog__header {
padding: 12px 14px;
margin-right: 0;
}
.gen-tier-dialog .el-dialog__body {
padding: 8px 12px 12px;
box-sizing: border-box;
overflow-x: hidden;
}
.gen-tier-dialog .el-dialog__footer {
padding: 10px 12px 14px;
}
.gen-tier-dialog .gen-tier-scroll {
padding-right: 4px;
}
.gen-tier-dialog .gen-rule {
white-space: pre-line;
overflow-wrap: anywhere;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
background: var(--el-fill-color-light);
padding: 12px 12px;
border-radius: 8px;
margin-bottom: 12px;
font-size: 13px;
line-height: 1.55;
color: var(--el-text-color-regular);
}
.gen-tier-dialog .gen-tier-block {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.gen-tier-dialog .gen-tier-block:last-of-type {
border-bottom: none;
}
.gen-tier-dialog .gen-tier-block-title {
font-weight: 600;
margin-bottom: 8px;
color: var(--el-text-color-primary);
font-size: 14px;
}
.gen-tier-dialog .gen-tier-form .el-form-item {
margin-bottom: 14px;
}
.gen-tier-dialog .gen-tier-form.el-form--label-top .el-form-item__label {
line-height: 1.4;
margin-bottom: 6px;
height: auto;
white-space: normal;
word-break: break-word;
}
.gen-tier-dialog .gen-tier-form .el-input-number {
width: 100%;
max-width: 320px;
}
.gen-tier-dialog .gen-tier-footer-hint {
margin-top: 4px;
padding: 0 2px 8px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
overflow-wrap: anywhere;
word-break: break-word;
}
.gen-tier-dialog .gen-tier-dialog-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
@media (max-width: 639px) {
.gen-tier-dialog .gen-tier-form .el-input-number {
max-width: none;
}
}
</style>

View File

@@ -1,149 +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('game.rewardWeight.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<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/rewardWeight',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/game.RewardWeight/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.rewardWeight.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.rewardWeight.gamechannel__name'),
prop: 'gameChannel.name',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('game.rewardWeight.direction'),
prop: 'direction',
align: 'center',
operator: 'eq',
sortable: false,
render: 'tag',
replaceValue: { '0': t('game.rewardWeight.direction 0'), '1': t('game.rewardWeight.direction 1') },
},
{ label: t('game.rewardWeight.grid_number'), prop: 'grid_number', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('game.rewardWeight.start_index'), prop: 'start_index', align: 'center', sortable: false, operator: 'RANGE' },
{ label: t('game.rewardWeight.end_index'), prop: 'end_index', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.rewardWeight.ui_text'),
prop: 'ui_text',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('game.rewardWeight.real_ev'), prop: 'real_ev', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.rewardWeight.tier'),
prop: 'tier',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('game.rewardWeight.type'),
prop: 'type',
align: 'center',
operator: 'eq',
sortable: false,
render: 'tag',
replaceValue: {
'-2': t('game.rewardWeight.type -2'),
'-1': t('game.rewardWeight.type -1'),
'0': t('game.rewardWeight.type 0'),
'1': t('game.rewardWeight.type 1'),
'2': t('game.rewardWeight.type 2'),
'3': t('game.rewardWeight.type 3'),
},
},
{ label: t('game.rewardWeight.remark'), prop: 'remark', align: 'center', operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
{ label: t('game.rewardWeight.weight'), prop: 'weight', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.rewardWeight.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('game.rewardWeight.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: 100, render: 'buttons', buttons: optButtons, operator: false },
],
dblClickNotEditColumn: [undefined],
},
{
defaultItems: { direction: '0', 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

@@ -1,87 +0,0 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<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.rewardWeight.game_channel_id')" type="remoteSelect" v-model="baTable.form.items!.game_channel_id" prop="game_channel_id" :input-attr="{ pk: 'game_channel.id', field: 'name', remoteUrl: '/admin/game.Channel/index' }" :placeholder="t('Please select field', { field: t('game.rewardWeight.game_channel_id') })" />
<FormItem :label="t('game.rewardWeight.direction')" type="radio" v-model="baTable.form.items!.direction" prop="direction" :input-attr="{ content: { '0': t('game.rewardWeight.direction 0'), '1': t('game.rewardWeight.direction 1') } }" :placeholder="t('Please select field', { field: t('game.rewardWeight.direction') })" />
<FormItem :label="t('game.rewardWeight.grid_number')" type="number" v-model="baTable.form.items!.grid_number" prop="grid_number" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.grid_number') })" />
<FormItem :label="t('game.rewardWeight.start_index')" type="number" v-model="baTable.form.items!.start_index" prop="start_index" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.start_index') })" />
<FormItem :label="t('game.rewardWeight.end_index')" type="number" v-model="baTable.form.items!.end_index" prop="end_index" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.end_index') })" />
<FormItem :label="t('game.rewardWeight.ui_text')" type="string" v-model="baTable.form.items!.ui_text" prop="ui_text" :placeholder="t('Please input field', { field: t('game.rewardWeight.ui_text') })" />
<FormItem :label="t('game.rewardWeight.real_ev')" type="number" v-model="baTable.form.items!.real_ev" prop="real_ev" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.real_ev') })" />
<FormItem :label="t('game.rewardWeight.tier')" type="string" v-model="baTable.form.items!.tier" prop="tier" :placeholder="t('Please input field', { field: t('game.rewardWeight.tier') })" />
<FormItem :label="t('game.rewardWeight.type')" type="select" v-model="baTable.form.items!.type" prop="type" :input-attr="{ content: { '-2': t('game.rewardWeight.type -2'), '-1': t('game.rewardWeight.type -1'), '0': t('game.rewardWeight.type 0'), '1': t('game.rewardWeight.type 1'), '2': t('game.rewardWeight.type 2'), '3': t('game.rewardWeight.type 3') } }" :placeholder="t('Please select field', { field: t('game.rewardWeight.type') })" />
<FormItem :label="t('game.rewardWeight.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('game.rewardWeight.remark') })" />
<FormItem :label="t('game.rewardWeight.weight')" type="number" v-model="baTable.form.items!.weight" prop="weight" :input-attr="{ step: 1 }" :placeholder="t('Please input field', { field: t('game.rewardWeight.weight') })" />
</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'
import { buildValidatorData } from '/@/utils/validate'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
direction: [buildValidatorData({ name: 'required', title: t('game.rewardWeight.direction') })],
grid_number: [buildValidatorData({ name: 'integer', title: t('game.rewardWeight.grid_number') }), buildValidatorData({ name: 'required', title: t('game.rewardWeight.grid_number') })],
start_index: [buildValidatorData({ name: 'integer', title: t('game.rewardWeight.start_index') }), buildValidatorData({ name: 'required', title: t('game.rewardWeight.start_index') })],
end_index: [buildValidatorData({ name: 'integer', title: t('game.rewardWeight.end_index') }), buildValidatorData({ name: 'required', title: t('game.rewardWeight.end_index') })],
ui_text: [buildValidatorData({ name: 'required', title: t('game.rewardWeight.ui_text') })],
real_ev: [buildValidatorData({ name: 'number', title: t('game.rewardWeight.real_ev') }), buildValidatorData({ name: 'required', title: t('game.rewardWeight.real_ev') })],
tier: [buildValidatorData({ name: 'required', title: t('game.rewardWeight.tier') })],
type: [buildValidatorData({ name: 'required', title: t('game.rewardWeight.type') })],
weight: [buildValidatorData({ name: 'integer', title: t('game.rewardWeight.weight') })],
create_time: [buildValidatorData({ name: 'date', title: t('game.rewardWeight.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.rewardWeight.update_time') })],
})
</script>
<style scoped lang="scss"></style>

View File

@@ -1,83 +0,0 @@
<template>
<div v-if="tagLabels.length" class="game-user-ticket-tags">
<el-tag v-for="(label, idx) in tagLabels" :key="idx" class="m-4" effect="light" type="success" size="default">
{{ label }}
</el-tag>
</div>
<span v-else class="game-user-json-plain">{{ plainText }}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
renderRow: TableRow
renderField: TableColumn
renderValue: unknown
renderColumn: import('element-plus').TableColumnCtx<TableRow>
renderIndex: number
}>()
/** [{"ante":1,"count":1},...] */
function parseTicketTagLabels(raw: unknown): string[] {
if (raw === null || raw === undefined || raw === '') {
return []
}
let arr: unknown[] = []
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return []
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : []
} catch {
return []
}
} else if (Array.isArray(raw)) {
arr = raw
} else {
return []
}
const labels: string[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
const o = item as Record<string, unknown>
const ante = o.ante
const count = o.count
labels.push(`ante:${String(ante)} count:${String(count)}`)
}
}
return labels
}
const tagLabels = computed(() => parseTicketTagLabels(props.renderValue))
const plainText = computed(() => {
const v = props.renderValue
if (v === null || v === undefined) return ''
if (typeof v === 'object') {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
return String(v)
})
</script>
<style scoped lang="scss">
.game-user-ticket-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 0;
}
.m-4 {
margin: 4px;
}
.game-user-json-plain {
word-break: break-all;
}
</style>

View File

@@ -1,82 +0,0 @@
<template>
<div v-if="tagLabels.length" class="game-user-json-weight-tags">
<el-tag v-for="(label, idx) in tagLabels" :key="idx" class="m-4" effect="light" type="primary" size="default">
{{ label }}
</el-tag>
</div>
<span v-else class="game-user-json-plain">{{ plainText }}</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
renderRow: TableRow
renderField: TableColumn
renderValue: unknown
renderColumn: import('element-plus').TableColumnCtx<TableRow>
renderIndex: number
}>()
/** [{"T1":"5"},{"T2":"20"},...] */
function parseWeightTagLabels(raw: unknown): string[] {
if (raw === null || raw === undefined || raw === '') {
return []
}
let arr: unknown[] = []
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return []
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : []
} catch {
return []
}
} else if (Array.isArray(raw)) {
arr = raw
} else {
return []
}
const labels: string[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
for (const [k, v] of Object.entries(item as Record<string, unknown>)) {
labels.push(`${k}:${String(v)}`)
}
}
}
return labels
}
const tagLabels = computed(() => parseWeightTagLabels(props.renderValue))
const plainText = computed(() => {
const v = props.renderValue
if (v === null || v === undefined) return ''
if (typeof v === 'object') {
try {
return JSON.stringify(v)
} catch {
return String(v)
}
}
return String(v)
})
</script>
<style scoped lang="scss">
.game-user-json-weight-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 0;
}
.m-4 {
margin: 4px;
}
.game-user-json-plain {
word-break: break-all;
}
</style>

View File

@@ -1,195 +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('game.user.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 表格列有多种自定义渲染方式比如自定义组件具名插槽等参见文档 -->
<!-- 要使用 el-table 组件原有的属性直接加在 Table 标签上即可 -->
<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 GameUserTicketJsonCell from './GameUserTicketJsonCell.vue'
import GameUserWeightJsonCell from './GameUserWeightJsonCell.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'
import { BIGWIN_WEIGHT_KEYS, TIER_WEIGHT_KEYS, jsonStringFromFixedKeys } from '/@/utils/gameWeightFixed'
defineOptions({
name: 'game/user',
})
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/
const baTable = new baTableClass(
new baTableApi('/admin/game.User/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.user.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('game.user.username'),
prop: 'username',
align: 'center',
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{
label: t('game.user.uuid'),
prop: 'uuid',
align: 'center',
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
sortable: false,
operator: 'LIKE',
},
{ label: t('game.user.phone'), prop: 'phone', align: 'center', operatorPlaceholder: t('Fuzzy query'), sortable: false, operator: 'LIKE' },
{ label: t('game.user.coin'), prop: 'coin', align: 'center', sortable: false, operator: 'RANGE' },
{
label: t('game.user.tier_weight'),
prop: 'tier_weight',
align: 'center',
minWidth: 200,
sortable: false,
operator: false,
render: 'customRender',
customRender: GameUserWeightJsonCell,
},
{
label: t('game.user.bigwin_weight'),
prop: 'bigwin_weight',
align: 'center',
minWidth: 200,
sortable: false,
operator: false,
render: 'customRender',
customRender: GameUserWeightJsonCell,
},
{
label: t('game.user.ticket_count'),
prop: 'ticket_count',
align: 'center',
minWidth: 220,
sortable: false,
operator: false,
render: 'customRender',
customRender: GameUserTicketJsonCell,
},
{
label: t('game.user.status'),
prop: 'status',
align: 'center',
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { '0': t('game.user.status 0'), '1': t('game.user.status 1') },
},
{
label: t('game.user.gamechannel__name'),
prop: 'gameChannel.name',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
},
{
label: t('game.user.admin__username'),
prop: 'admin.username',
align: 'center',
minWidth: 90,
effect: 'plain',
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
operator: 'LIKE',
comSearchRender: 'string',
//修改tag颜色
customRenderAttr: {
tag: () => ({
color: '#e8f3ff',
style: { color: '#1677ff', borderColor: '#91caff' },
}),
},
},
{
label: t('game.user.remark'),
prop: 'remark',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('game.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('game.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, 'status'],
},
{
defaultItems: {
status: '1',
tier_weight: jsonStringFromFixedKeys(TIER_WEIGHT_KEYS, {}),
bigwin_weight: jsonStringFromFixedKeys(BIGWIN_WEIGHT_KEYS, {}),
ticket_count: null,
},
}
)
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

@@ -1,654 +0,0 @@
<template>
<!-- 对话框表单 -->
<!-- 建议使用 Prettier 格式化代码 -->
<!-- el-form 内可以混用 el-form-itemFormItemba-input 等输入组件 -->
<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.user.username')"
type="string"
v-model="baTable.form.items!.username"
prop="username"
:placeholder="t('Please input field', { field: t('game.user.username') })"
/>
<FormItem
:label="t('game.user.password')"
type="password"
v-model="baTable.form.items!.password"
prop="password"
:placeholder="t('Please input field', { field: t('game.user.password') })"
/>
<FormItem
:label="t('game.user.phone')"
type="string"
v-model="baTable.form.items!.phone"
prop="phone"
:placeholder="t('Please input field', { field: t('game.user.phone') })"
/>
<FormItem
:label="t('game.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('game.user.remark') })"
/>
<FormItem
:label="t('game.user.coin')"
type="number"
v-model="baTable.form.items!.coin"
prop="coin"
:input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('game.user.coin') })"
/>
<!-- 档位权重键固定 T1T5仅可改值 -->
<el-form-item :label="t('game.user.tier_weight')" prop="tier_weight">
<div class="weight-value-editor">
<div v-for="(row, idx) in tierWeightRows" :key="'tw-' + idx" class="weight-value-row">
<el-input v-model="row.key" class="weight-key" readonly tabindex="-1" />
<span class="weight-sep">:</span>
<el-input
v-model="row.val"
class="weight-val"
:placeholder="t('Please input field', { field: t('game.user.weight value') })"
clearable
@input="onTierWeightRowChange"
/>
</div>
<div class="form-help">{{ t('game.user.tier_weight_help') }}</div>
</div>
</el-form-item>
<!-- 中大奖权重键固定 530仅可改值 -->
<el-form-item :label="t('game.user.bigwin_weight')" prop="bigwin_weight">
<div class="weight-value-editor">
<div v-for="(row, idx) in bigwinWeightRows" :key="'bw-' + idx" class="weight-value-row">
<el-input v-model="row.key" class="weight-key" readonly tabindex="-1" />
<span class="weight-sep">:</span>
<el-input
v-model="row.val"
class="weight-val"
:placeholder="t('Please input field', { field: t('game.user.weight value') })"
clearable
:disabled="isBigwinValueLocked(row.key)"
@input="onBigwinWeightRowChange"
/>
</div>
<div class="form-help">{{ t('game.user.bigwin_weight_help') }}</div>
</div>
</el-form-item>
<!-- 抽奖券最多一条 JSON 元素 [{"ante":1,"count":1}]不可增行可删除清空 -->
<el-form-item :label="t('game.user.ticket_count')" prop="ticket_count">
<div class="ticket-value-editor">
<div v-for="(row, idx) in ticketRows" :key="'tk-' + idx" class="ticket-value-row">
<span class="ticket-label">{{ t('game.user.ticket_ante') }}</span>
<el-input
v-model="row.ante"
class="ticket-field"
clearable
:placeholder="t('Please input field', { field: t('game.user.ticket_ante') })"
@input="onTicketRowChange"
/>
<span class="ticket-label">{{ t('game.user.ticket_count_times') }}</span>
<el-input
v-model="row.count"
class="ticket-field"
clearable
:placeholder="t('Please input field', { field: t('game.user.ticket_count_times') })"
@input="onTicketRowChange"
/>
<el-button type="danger" link @click="removeTicketRow">{{ t('Delete') }}</el-button>
</div>
</div>
</el-form-item>
<FormItem
:label="t('game.user.status')"
type="switch"
v-model="baTable.form.items!.status"
prop="status"
:input-attr="{ content: { '0': t('game.user.status 0'), '1': t('game.user.status 1') } }"
/>
<el-form-item :label="t('game.user.game_channel_id')" prop="admin_id">
<el-tree-select
v-model="adminIdForTree"
class="w100"
clearable
filterable
:data="channelAdminTree"
:props="treeProps"
:render-after-expand="false"
:placeholder="t('Please select field', { field: t('game.user.admin_id') })"
@change="onAdminTreeChange"
/>
</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 { computed, inject, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { buildValidatorData, regularPassword } from '/@/utils/validate'
import createAxios from '/@/utils/axios'
import {
BIGWIN_WEIGHT_KEYS,
TIER_WEIGHT_KEYS,
fixedRowsFromKeys,
jsonStringFromFixedKeys,
parseWeightJsonToMap,
rowsToMap,
type WeightRow,
} from '/@/utils/gameWeightFixed'
const config = useConfig()
const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const { t } = useI18n()
const isSuperAdmin = computed(() => adminInfo.super === true)
type TreeNode = {
value: string
label: string
disabled?: boolean
children?: TreeNode[]
channel_id?: number
is_leaf?: boolean
}
const channelAdminTree = ref<TreeNode[]>([])
const adminIdToChannelId = ref<Record<string, number>>({})
const treeProps = {
value: 'value',
label: 'label',
children: 'children',
disabled: 'disabled',
}
/** 与 adminTree 叶子 value字符串一致避免 number 与 string 不一致导致 el-tree-select 只显示原始 ID */
const adminIdForTree = computed<string | undefined>({
get() {
const v = baTable.form.items?.admin_id
if (v === undefined || v === null || v === '') return undefined
return String(v)
},
set(v: string | number | undefined | null) {
if (!baTable.form.items) return
if (v === undefined || v === null || v === '') {
baTable.form.items.admin_id = undefined
return
}
const n = Number(v)
if (Number.isFinite(n)) {
baTable.form.items.admin_id = n
}
},
})
const loadChannelAdminTree = async () => {
const res = await createAxios({
url: '/admin/game.Channel/adminTree',
method: 'get',
})
const list = (res.data?.list ?? []) as TreeNode[]
channelAdminTree.value = list
const map: Record<string, number> = {}
const walk = (nodes: TreeNode[]) => {
for (const n of nodes) {
if (n.children && n.children.length) {
walk(n.children)
} else if (n.is_leaf && n.channel_id !== undefined) {
map[n.value] = n.channel_id
}
}
}
walk(list)
adminIdToChannelId.value = map
}
const onAdminTreeChange = (val: string | number | null) => {
if (val === null || val === undefined || val === '') {
return
}
const key = typeof val === 'number' ? String(val) : String(val)
const channelId = adminIdToChannelId.value[key]
if (channelId !== undefined) {
baTable.form.items!.game_channel_id = channelId
}
}
onMounted(() => {
loadChannelAdminTree()
})
type TicketRow = { ante: string; count: string }
const tierWeightRows = ref<WeightRow[]>(fixedRowsFromKeys(TIER_WEIGHT_KEYS, {}))
const bigwinWeightRows = ref<WeightRow[]>(fixedRowsFromKeys(BIGWIN_WEIGHT_KEYS, {}))
const ticketRows = ref<TicketRow[]>([{ ante: '', count: '1' }])
function isBigwinValueLocked(key: string): boolean {
return key === '5' || key === '30'
}
function enforceBigwinFixedValues() {
for (const r of bigwinWeightRows.value) {
if (isBigwinValueLocked(r.key)) {
r.val = '10000'
}
}
}
function syncTierWeightToForm() {
const items = baTable.form.items
if (!items) return
items.tier_weight = jsonStringFromFixedKeys(TIER_WEIGHT_KEYS, rowsToMap(tierWeightRows.value))
}
function syncBigwinWeightToForm() {
const items = baTable.form.items
if (!items) return
items.bigwin_weight = jsonStringFromFixedKeys(BIGWIN_WEIGHT_KEYS, rowsToMap(bigwinWeightRows.value))
}
function onTierWeightRowChange() {
syncTierWeightToForm()
}
function onBigwinWeightRowChange() {
enforceBigwinFixedValues()
syncBigwinWeightToForm()
}
function parseTicketRows(raw: unknown): TicketRow[] {
if (raw === null || raw === undefined || raw === '') {
return [{ ante: '', count: '1' }]
}
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return [{ ante: '', count: '1' }]
try {
const parsed = JSON.parse(s)
return arrayToTicketRows(parsed)
} catch {
return [{ ante: '', count: '1' }]
}
}
if (Array.isArray(raw)) {
return arrayToTicketRows(raw)
}
return [{ ante: '', count: '1' }]
}
function arrayToTicketRows(arr: unknown): TicketRow[] {
if (!Array.isArray(arr)) {
return [{ ante: '', count: '1' }]
}
const out: TicketRow[] = []
for (const item of arr) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
const ante = Reflect.get(item, 'ante')
const count = Reflect.get(item, 'count')
out.push({
ante: ante === null || ante === undefined ? '' : String(ante),
count: count === null || count === undefined || String(count) === '' ? '1' : String(count),
})
break
}
}
return out.length ? out : [{ ante: '', count: '1' }]
}
function ticketRowsToJsonString(rows: TicketRow[]): string {
const body: { ante: number; count: number }[] = []
for (const r of rows) {
const a = r.ante.trim()
const c = r.count.trim()
if (a === '' && c === '') continue
if (a === '' || c === '') continue
const na = Number(a)
const nc = Number(c)
body.push({ ante: na, count: nc })
}
if (body.length === 0) {
return ''
}
return JSON.stringify(body)
}
function syncTicketToForm() {
const items = baTable.form.items
if (!items) return
const a = ticketRows.value[0]?.ante?.trim() ?? ''
const c = ticketRows.value[0]?.count?.trim() ?? ''
if (a === '' || c === '') {
items.ticket_count = null
return
}
const json = ticketRowsToJsonString(ticketRows.value)
items.ticket_count = json === '' ? null : json
}
function applyTierBigwinFromJson(tierJson: string, bigwinJson: string) {
const items = baTable.form.items
if (!items) return
const tm = parseWeightJsonToMap(tierJson)
const bm = parseWeightJsonToMap(bigwinJson)
items.tier_weight = jsonStringFromFixedKeys(TIER_WEIGHT_KEYS, tm)
items.bigwin_weight = jsonStringFromFixedKeys(BIGWIN_WEIGHT_KEYS, bm)
tierWeightRows.value = fixedRowsFromKeys(TIER_WEIGHT_KEYS, tm)
bigwinWeightRows.value = fixedRowsFromKeys(BIGWIN_WEIGHT_KEYS, bm)
enforceBigwinFixedValues()
syncBigwinWeightToForm()
}
async function loadAndApplyDefaultsForChannel(channelId: number) {
try {
const res = await createAxios(
{
url: '/admin/game.User/defaultWeightByChannel',
method: 'get',
params: { channel_id: channelId },
},
{
showErrorMessage: false,
showCodeMessage: false,
}
)
applyTierBigwinFromJson(res.data.tier_weight ?? '[]', res.data.bigwin_weight ?? '[]')
} catch {
// 路由或权限异常时不阻断打开表单,保持可手工编辑
}
}
function onTicketRowChange() {
syncTicketToForm()
}
/** 最多一条,删除/不填则 ticket_count 为空 */
function removeTicketRow() {
ticketRows.value = [{ ante: '', count: '1' }]
syncTicketToForm()
}
function hydrateJsonFieldsFromForm() {
const tm = parseWeightJsonToMap(baTable.form.items?.tier_weight)
const bm = parseWeightJsonToMap(baTable.form.items?.bigwin_weight)
tierWeightRows.value = fixedRowsFromKeys(TIER_WEIGHT_KEYS, tm)
bigwinWeightRows.value = fixedRowsFromKeys(BIGWIN_WEIGHT_KEYS, bm)
syncTierWeightToForm()
enforceBigwinFixedValues()
syncBigwinWeightToForm()
ticketRows.value = normalizeTicketRowsToOne(parseTicketRows(baTable.form.items?.ticket_count))
syncTicketToForm()
}
function normalizeTicketRowsToOne(rows: TicketRow[]): TicketRow[] {
if (rows.length === 0) {
return [{ ante: '', count: '1' }]
}
const first = rows[0]
return [
{
ante: first?.ante ?? '',
count: first?.count && String(first.count).trim() !== '' ? String(first.count) : '1',
},
]
}
watch(
() => baTable.form.loading,
(loading) => {
if (loading === false) {
hydrateJsonFieldsFromForm()
}
}
)
watch(
() => [baTable.form.operate, baTable.form.loading] as const,
async ([op, loading]) => {
if (op !== 'Add' || loading !== false) return
if (!isSuperAdmin.value) return
await loadAndApplyDefaultsForChannel(0)
}
)
watch(
() => baTable.form.items?.game_channel_id,
async (ch) => {
if (baTable.form.operate !== 'Add') return
if (ch === undefined || ch === null || ch === '') return
const cid = Number(ch)
if (!Number.isFinite(cid)) return
if (isSuperAdmin.value) {
return
}
await loadAndApplyDefaultsForChannel(cid)
}
)
watch(
() => baTable.form.items?.admin_id,
(val) => {
if (val === undefined || val === null || val === '') return
onAdminTreeChange(typeof val === 'number' ? val : Number(val))
}
)
function validateTierWeightRows(): string | undefined {
let sum = 0
for (const r of tierWeightRows.value) {
const vs = r.val.trim()
if (vs === '') {
return t('Please input field', { field: t('game.user.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.user.weight value numeric')
}
if (n > 100) {
return t('game.user.weight each max 100')
}
sum += n
}
if (sum > 100 + 0.000001) {
return t('game.user.tier_weight_sum_max_100')
}
return undefined
}
function validateBigwinWeightRows(): string | undefined {
for (const r of bigwinWeightRows.value) {
const vs = r.val.trim()
if (vs === '') {
return t('Please input field', { field: t('game.user.weight value') })
}
const n = Number(vs)
if (!Number.isFinite(n)) {
return t('game.user.weight value numeric')
}
if (n > 10000) {
return t('game.user.bigwin_weight_each_max_10000')
}
}
return undefined
}
function validateTicketRowsField(): string | undefined {
for (const r of ticketRows.value) {
const a = r.ante.trim()
const c = r.count.trim()
if (a === '' && c === '') continue
if (a === '' || c === '') {
return t('game.user.ticket row incomplete')
}
const na = Number(a)
const nc = Number(c)
if (!Number.isFinite(na) || !Number.isFinite(nc)) {
return t('game.user.ticket row numeric')
}
}
return undefined
}
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('game.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 rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('game.user.username') })],
password: [{ validator: validatorGameUserPassword, trigger: 'blur' }],
phone: [buildValidatorData({ name: 'required', title: t('game.user.phone') })],
coin: [buildValidatorData({ name: 'number', title: t('game.user.coin') })],
tier_weight: [
{
validator: (_rule, _val, callback) => {
const err = validateTierWeightRows()
if (err) {
callback(new Error(err))
return
}
callback()
},
trigger: ['blur', 'change'],
},
],
bigwin_weight: [
{
validator: (_rule, _val, callback) => {
const err = validateBigwinWeightRows()
if (err) {
callback(new Error(err))
return
}
callback()
},
trigger: ['blur', 'change'],
},
],
ticket_count: [
{
validator: (_rule, _val, callback) => {
const err = validateTicketRowsField()
if (err) {
callback(new Error(err))
return
}
callback()
},
trigger: ['blur', 'change'],
},
],
admin_id: [buildValidatorData({ name: 'required', title: t('game.user.admin_id') })],
create_time: [buildValidatorData({ name: 'date', title: t('game.user.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.user.update_time') })],
})
</script>
<style scoped lang="scss">
.weight-value-editor {
width: 100%;
}
.weight-value-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.weight-key {
max-width: 140px;
}
.weight-val {
flex: 1;
min-width: 80px;
}
.weight-sep {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.ticket-value-editor {
width: 100%;
}
.ticket-value-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.ticket-field {
flex: 1;
min-width: 100px;
max-width: 160px;
}
.ticket-label {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.form-help {
margin-top: 4px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
}
</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',
sortable: false,
render: 'switch',
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' },
],
dblClickNotEditColumn: [undefined, 'status'],
},
{
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,254 @@
<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',
show: false,
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: 200,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tag',
},
{
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.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,214 @@
<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.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'])
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(2)
}
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_username'),
prop: 'user.username',
align: 'center',
minWidth: 120,
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',
formatter: formatAmount,
},
{
label: t('order.depositOrder.bonus_amount'),
prop: 'bonus_amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('order.depositOrder.status'),
prop: 'status',
align: 'center',
width: 100,
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: {
'0': 'info',
'1': 'success',
'2': 'danger',
'3': 'warning',
},
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: 130,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
},
{
label: t('order.depositOrder.deposit_tier_id'),
prop: 'deposit_tier_id',
align: 'center',
minWidth: 120,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
show: false,
},
{
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.idempotency_key'),
prop: 'idempotency_key',
align: 'center',
minWidth: 170,
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
showOverflowTooltip: true,
show: false,
},
{
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',
show: false,
},
{
label: t('Operate'),
align: 'center',
width: 90,
render: 'buttons',
buttons: optButtons,
operator: false,
fixed: 'right',
},
],
},
{
defaultItems: { status: 0, amount: '0.0000', bonus_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,231 @@
<template>
<el-dialog
class="ba-operate-dialog deposit-detail-dialog"
:close-on-click-modal="false"
:model-value="isOpen"
width="640px"
@close="onDialogClose"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ t('order.depositOrder.detail_title') }}
</div>
</template>
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
<div
class="ba-operate-form ba-edit-form"
:style="config.layout.shrink ? '' : 'width: calc(100% - ' + (baTable.form.labelWidth ?? 120) / 2 + 'px)'"
>
<el-form
v-if="!loading"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
@submit.prevent=""
>
<el-form-item :label="t('order.depositOrder.order_no')">
<el-input :model-value="form.order_no" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.idempotency_key')">
<el-input :model-value="form.idempotency_key || '-'" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.user_username')">
<el-input :model-value="form.user_text" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.channel_name')">
<el-input :model-value="form.channel_text" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.status')">
<el-tag :type="statusTagType" effect="dark" size="small">{{ statusLabel }}</el-tag>
</el-form-item>
<el-form-item :label="t('order.depositOrder.amount')">
<el-input :model-value="amountText" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.bonus_amount')">
<el-input :model-value="bonusText" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.total_credit')">
<el-input :model-value="totalCreditText" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.pay_channel')">
<el-input :model-value="form.pay_channel || '-'" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.pay_time')">
<el-input :model-value="form.pay_time_text" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.deposit_tier_id')">
<el-input :model-value="form.deposit_tier_id || '-'" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.remark')">
<el-input v-model="form.remark" type="textarea" :rows="2" readonly />
</el-form-item>
<el-form-item :label="t('order.depositOrder.create_time')">
<el-input :model-value="form.create_time_text" readonly />
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div class="detail-footer">
<el-button type="primary" v-blur @click="onDialogClose">{{ t('order.depositOrder.close_btn') }}</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, inject, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const loading = ref(false)
const form = reactive({
id: 0,
order_no: '',
idempotency_key: '',
user_text: '-',
channel_text: '-',
pay_channel: '',
pay_time_text: '-',
deposit_tier_id: '',
remark: '',
create_time_text: '-',
amount: 0,
bonus_amount: 0,
status: 0,
})
const isOpen = computed(() => ['Edit'].includes(baTable.form.operate ?? ''))
watch(
() => ({ visible: isOpen.value, loadingState: baTable.form.loading, items: baTable.form.items }),
({ visible, loadingState }) => {
if (!visible) return
loading.value = loadingState === true
if (loadingState) return
hydrate()
},
{ deep: true, immediate: true }
)
const hydrate = () => {
const row = baTable.form.items as Record<string, unknown> | undefined
if (!row || !row['id']) {
return
}
form.id = Number(row['id'] ?? 0)
form.order_no = String(row['order_no'] ?? '')
form.idempotency_key = String(row['idempotency_key'] ?? '')
form.pay_channel = String(row['pay_channel'] ?? '')
form.deposit_tier_id = String(row['deposit_tier_id'] ?? '')
form.remark = String(row['remark'] ?? '')
form.amount = parseNumber(row['amount'])
form.bonus_amount = parseNumber(row['bonus_amount'])
form.status = Number(row['status'] ?? 0)
form.create_time_text = formatTime(row['create_time'])
form.pay_time_text = formatTime(row['pay_time'])
form.user_text = resolveRelationText(row, 'user', row['user_id'])
form.channel_text = resolveRelationText(row, 'channel', row['channel_id'])
}
const statusLabel = computed(() => t('order.depositOrder.status ' + form.status))
const statusTagType = computed(() => {
switch (form.status) {
case 1:
return 'success'
case 2:
return 'danger'
case 3:
return 'warning'
default:
return 'info'
}
})
const amountText = computed(() => formatAmount(form.amount))
const bonusText = computed(() => formatAmount(form.bonus_amount))
const totalCreditText = computed(() => formatAmount(Number((form.amount + form.bonus_amount).toFixed(2))))
const onDialogClose = () => {
baTable.toggleForm()
}
function parseNumber(raw: unknown): number {
if (raw === null || raw === undefined || raw === '') return 0
const n = Number(raw)
if (!Number.isFinite(n)) return 0
return Number(n.toFixed(2))
}
function formatAmount(value: number): string {
if (!Number.isFinite(value)) return '0.00'
return value.toFixed(2)
}
function formatTime(raw: unknown): string {
if (raw === null || raw === undefined || raw === '' || raw === 0) return '-'
const sec = Number(raw)
if (!Number.isFinite(sec) || sec <= 0) return '-'
const d = new Date(sec * 1000)
const pad = (n: number) => (n < 10 ? '0' + n : String(n))
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
' ' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds())
)
}
function resolveRelationText(row: Record<string, unknown>, relationKey: string, fallbackId: unknown): string {
const rel = row[relationKey]
if (rel && typeof rel === 'object') {
const r = rel as Record<string, unknown>
const name = r['username'] ?? r['name']
if (typeof name === 'string' && name !== '') {
const id = fallbackId === null || fallbackId === undefined || fallbackId === '' ? '' : ' (ID: ' + String(fallbackId) + ')'
return name + id
}
}
if (fallbackId === null || fallbackId === undefined || fallbackId === '') {
return '-'
}
return 'ID: ' + String(fallbackId)
}
</script>
<style scoped lang="scss">
.deposit-detail-dialog {
:deep(.el-dialog__body) {
padding-top: 8px;
}
}
.detail-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 640px) {
:deep(.deposit-detail-dialog) {
width: calc(100vw - 24px) !important;
max-width: 100vw;
}
}
</style>

View File

@@ -0,0 +1,163 @@
<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.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'])
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: 120,
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,434 @@
<template>
<el-dialog
class="ba-operate-dialog withdraw-review-dialog"
:close-on-click-modal="false"
:model-value="isOpen"
width="640px"
@close="onDialogClose"
>
<template #header>
<div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
{{ step === 'reject' ? t('order.withdrawOrder.review_reject_title') : t('order.withdrawOrder.review_title') }}
</div>
</template>
<el-scrollbar v-loading="loading" class="ba-table-form-scrollbar">
<div class="ba-operate-form ba-edit-form" :style="config.layout.shrink ? '' : 'width: calc(100% - ' + (baTable.form.labelWidth ?? 120) / 2 + 'px)'">
<!-- 第一页关联信息 + 申请金额 / 手续费 -->
<el-form
v-if="!loading && step === 'review'"
ref="reviewFormRef"
:model="form"
:rules="reviewRules"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
@submit.prevent=""
>
<el-form-item :label="t('order.withdrawOrder.order_no')">
<el-input v-model="form.order_no" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.user_username')">
<el-input :model-value="form.user_text" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.channel_name')">
<el-input :model-value="form.channel_text" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.status')">
<el-tag :type="statusTagType" effect="dark" size="small">{{ statusLabel }}</el-tag>
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.create_time')">
<el-input :model-value="form.create_time_text" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.amount')" prop="amount">
<el-input-number
v-model="form.amount"
:min="0"
:precision="2"
:step="1"
:controls="false"
:disabled="!isPending"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.fee')" prop="fee">
<el-input-number
v-model="form.fee"
:min="0"
:precision="2"
:step="0.1"
:controls="false"
:disabled="!isPending"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.actual_amount')">
<el-input :model-value="actualAmountText" readonly />
</el-form-item>
<el-form-item v-if="!isPending" :label="t('order.withdrawOrder.review_admin_username')">
<el-input :model-value="form.review_admin_text" readonly />
</el-form-item>
<el-form-item v-if="!isPending" :label="t('order.withdrawOrder.review_time')">
<el-input :model-value="form.review_time_text" readonly />
</el-form-item>
<el-form-item v-if="!isPending" :label="t('order.withdrawOrder.remark')">
<el-input v-model="form.remark" type="textarea" :rows="2" readonly />
</el-form-item>
</el-form>
<!-- 第二页拒绝 -> 必填备注 -->
<el-form
v-if="!loading && step === 'reject'"
ref="rejectFormRef"
:model="rejectForm"
:rules="rejectRules"
:label-position="config.layout.shrink ? 'top' : 'right'"
:label-width="(baTable.form.labelWidth ?? 120) + 'px'"
@submit.prevent=""
>
<el-alert
class="review-reject-hint"
type="warning"
show-icon
:closable="false"
:title="t('order.withdrawOrder.review_reject_tip')"
/>
<el-form-item :label="t('order.withdrawOrder.order_no')">
<el-input v-model="form.order_no" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.amount')">
<el-input :model-value="amountText" readonly />
</el-form-item>
<el-form-item :label="t('order.withdrawOrder.remark')" prop="remark">
<el-input
v-model="rejectForm.remark"
type="textarea"
:rows="4"
maxlength="255"
show-word-limit
:placeholder="t('order.withdrawOrder.review_reject_placeholder')"
/>
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div class="review-footer">
<template v-if="step === 'review'">
<el-button @click="onDialogClose">{{ t('Cancel') }}</el-button>
<template v-if="isPending">
<el-button type="danger" :loading="submitting" @click="gotoReject">{{ t('order.withdrawOrder.review_btn_reject') }}</el-button>
<el-button type="primary" v-blur :loading="submitting" @click="submitApprove">{{ t('order.withdrawOrder.review_btn_approve') }}</el-button>
</template>
</template>
<template v-else>
<el-button @click="backToReview">{{ t('order.withdrawOrder.review_btn_back') }}</el-button>
<el-button type="danger" v-blur :loading="submitting" @click="submitReject">{{ t('order.withdrawOrder.review_btn_confirm_reject') }}</el-button>
</template>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import createAxios from '/@/utils/axios'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
const config = useConfig()
const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const reviewFormRef = useTemplateRef<FormInstance>('reviewFormRef')
const rejectFormRef = useTemplateRef<FormInstance>('rejectFormRef')
type Step = 'review' | 'reject'
const step = ref<Step>('review')
const loading = ref(false)
const submitting = ref(false)
const form = reactive({
id: 0,
order_no: '',
user_text: '-',
channel_text: '-',
create_time_text: '',
review_admin_text: '-',
review_time_text: '-',
amount: 0,
fee: 0,
status: 0,
remark: '',
})
const rejectForm = reactive({
remark: '',
})
const isOpen = computed(() => ['Edit'].includes(baTable.form.operate ?? ''))
const isPending = computed(() => form.status === 0)
watch(
isOpen,
(visible) => {
if (!visible) {
return
}
step.value = 'review'
rejectForm.remark = ''
}
)
watch(
() => ({ visible: isOpen.value, loadingState: baTable.form.loading, items: baTable.form.items }),
({ visible, loadingState }) => {
if (!visible) return
loading.value = loadingState === true
if (loadingState) return
hydrate()
},
{ deep: true, immediate: true }
)
const hydrate = () => {
const row = baTable.form.items as Record<string, unknown> | undefined
if (!row || !row['id']) {
return
}
form.id = Number(row['id'] ?? 0)
form.order_no = String(row['order_no'] ?? '')
form.amount = parseNumber(row['amount'])
form.fee = parseNumber(row['fee'])
form.status = Number(row['status'] ?? 0)
form.remark = String(row['remark'] ?? '')
form.create_time_text = formatTime(row['create_time'])
form.review_time_text = formatTime(row['review_time'])
form.user_text = resolveRelationText(row, 'user', row['user_id'])
form.channel_text = resolveRelationText(row, 'channel', row['channel_id'])
form.review_admin_text = resolveRelationText(row, 'reviewAdmin', row['review_admin_id'])
}
const statusLabel = computed(() => t('order.withdrawOrder.status ' + form.status))
const statusTagType = computed(() => {
switch (form.status) {
case 1:
return 'success'
case 2:
return 'danger'
case 3:
return 'success'
default:
return 'warning'
}
})
const amountText = computed(() => formatAmount(form.amount))
const actualAmountText = computed(() => {
const actual = Number((form.amount - form.fee).toFixed(2))
return formatAmount(actual < 0 ? 0 : actual)
})
const reviewRules: Record<string, FormItemRule[]> = {
amount: [
{
required: true,
validator: (_r, value, cb) => {
if (value === null || value === undefined || Number(value) <= 0) {
cb(new Error(t('order.withdrawOrder.amount_invalid')))
return
}
cb()
},
},
],
fee: [
{
required: true,
validator: (_r, value, cb) => {
if (value === null || value === undefined || Number(value) < 0) {
cb(new Error(t('order.withdrawOrder.fee_invalid')))
return
}
if (Number(value) > Number(form.amount)) {
cb(new Error(t('order.withdrawOrder.fee_exceed_amount')))
return
}
cb()
},
},
],
}
const rejectRules: Record<string, FormItemRule[]> = {
remark: [
{
required: true,
validator: (_r, value, cb) => {
const text = typeof value === 'string' ? value.trim() : ''
if (text === '') {
cb(new Error(t('order.withdrawOrder.reject_reason_required')))
return
}
cb()
},
trigger: 'blur',
},
],
}
const onDialogClose = () => {
if (submitting.value) {
return
}
step.value = 'review'
baTable.toggleForm()
}
const gotoReject = async () => {
step.value = 'reject'
rejectForm.remark = ''
}
const backToReview = () => {
step.value = 'review'
}
const submitApprove = async () => {
if (!isPending.value) {
ElMessage.warning(t('order.withdrawOrder.already_reviewed'))
return
}
const formEl = reviewFormRef.value
if (!formEl) return
const valid = await formEl.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await createAxios(
{
url: '/admin/order.WithdrawOrder/approve',
method: 'POST',
data: {
id: form.id,
amount: form.amount.toFixed(4),
fee: form.fee.toFixed(4),
},
},
{ showSuccessMessage: true }
)
baTable.onTableHeaderAction('refresh', {})
baTable.toggleForm()
} catch (_e) {
// errors already surfaced by axios interceptor
} finally {
submitting.value = false
}
}
const submitReject = async () => {
const formEl = rejectFormRef.value
if (!formEl) return
const valid = await formEl.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await createAxios(
{
url: '/admin/order.WithdrawOrder/reject',
method: 'POST',
data: {
id: form.id,
remark: rejectForm.remark.trim(),
},
},
{ showSuccessMessage: true }
)
baTable.onTableHeaderAction('refresh', {})
baTable.toggleForm()
} catch (_e) {
// errors already surfaced by axios interceptor
} finally {
submitting.value = false
}
}
function parseNumber(raw: unknown): number {
if (raw === null || raw === undefined || raw === '') return 0
const n = Number(raw)
if (!Number.isFinite(n)) return 0
return Number(n.toFixed(2))
}
function formatAmount(value: number): string {
if (!Number.isFinite(value)) return '0.00'
return value.toFixed(2)
}
function formatTime(raw: unknown): string {
if (raw === null || raw === undefined || raw === '' || raw === 0) return '-'
const sec = Number(raw)
if (!Number.isFinite(sec) || sec <= 0) return '-'
const d = new Date(sec * 1000)
const pad = (n: number) => (n < 10 ? '0' + n : String(n))
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
' ' +
pad(d.getHours()) +
':' +
pad(d.getMinutes()) +
':' +
pad(d.getSeconds())
)
}
function resolveRelationText(row: Record<string, unknown>, relationKey: string, fallbackId: unknown): string {
const rel = row[relationKey]
if (rel && typeof rel === 'object') {
const r = rel as Record<string, unknown>
const name = r['username'] ?? r['name']
if (typeof name === 'string' && name !== '') {
const id = fallbackId === null || fallbackId === undefined || fallbackId === '' ? '' : ' (ID: ' + String(fallbackId) + ')'
return name + id
}
}
if (fallbackId === null || fallbackId === undefined || fallbackId === '') {
return '-'
}
return 'ID: ' + String(fallbackId)
}
</script>
<style scoped lang="scss">
.withdraw-review-dialog {
:deep(.el-dialog__body) {
padding-top: 8px;
}
.review-reject-hint {
margin-bottom: 14px;
}
}
.review-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 640px) {
:deep(.withdraw-review-dialog) {
width: calc(100vw - 24px) !important;
max-width: 100vw;
}
}
</style>

View File

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

View File

@@ -75,8 +75,9 @@ const baTable = new baTableClass(
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { 0: t('Disable'), 1: t('security.dataRecycle.Deleting monitoring') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
@@ -84,10 +85,11 @@ const baTable = new baTableClass(
{
label: t('Operate'),
align: 'center',
width: '130',
width: '80',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined, 'status'],

View File

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

View File

@@ -84,8 +84,9 @@ const baTable = new sensitiveDataClass(
label: t('State'),
prop: 'status',
align: 'center',
render: 'tag',
custom: { 0: 'danger', 1: 'success' },
operator: 'eq',
sortable: false,
render: 'switch',
replaceValue: { 0: t('Disable'), 1: t('security.sensitiveData.Modifying monitoring') },
},
{ label: t('Update time'), prop: 'update_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
@@ -93,13 +94,14 @@ const baTable = new sensitiveDataClass(
{
label: t('Operate'),
align: 'center',
width: '130',
width: '80',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
fixed: 'right',
},
],
dblClickNotEditColumn: [undefined],
dblClickNotEditColumn: [undefined, 'status'],
},
{
defaultItems: {

View File

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

View File

@@ -0,0 +1,142 @@
<template>
<div class="default-main">
<el-alert type="info" :title="tip" show-icon class="mb-12" />
<el-alert :type="connected ? 'success' : 'warning'" :title="connected ? t('test.push.connected') : t('test.push.disconnected')" show-icon class="mb-12" />
<el-card shadow="never" class="mb-12">
<el-form :inline="true" @submit.prevent>
<el-form-item v-if="useUuid" :label="t('test.push.user_uuid')">
<el-input v-model="userUuid" :placeholder="t('test.push.user_uuid_placeholder')" clearable style="width: 280px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="connect">{{ t('test.push.btn_connect') }}</el-button>
<el-button :disabled="!session" @click="disconnect">{{ t('test.push.btn_disconnect') }}</el-button>
<el-button @click="clearLogs">{{ t('test.push.btn_clear') }}</el-button>
</el-form-item>
</el-form>
<div class="text-muted mb-8">{{ t('test.push.channel_label') }}: {{ channelName || '-' }}</div>
</el-card>
<el-card shadow="never">
<template #header>{{ t('test.push.log_title') }}</template>
<el-scrollbar max-height="480">
<pre class="push-log">{{ logText }}</pre>
</el-scrollbar>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import createAxios from '/@/utils/axios'
import { loadPushJs, startPushChannelListener, type PushTestLogLine } from '/@/utils/backend/pushChannelTest'
const props = withDefaults(
defineProps<{
/** 如 /admin/test.PushGamePeriod/pushConfig */
configApi: string
useUuid?: boolean
tip: string
}>(),
{
useUuid: false,
}
)
const { t } = useI18n()
const loading = ref(false)
const connected = ref(false)
const channelName = ref('')
const userUuid = ref('')
const logs = ref<PushTestLogLine[]>([])
const session = ref<{ disconnect: () => void } | null>(null)
const logText = computed(() => {
if (!logs.value.length) return t('test.push.log_empty')
return logs.value
.map((row) => {
const time = new Date(row.t).toLocaleString()
return `[${time}] ${row.event}\n${row.payload}`
})
.join('\n\n')
})
function appendLog(line: PushTestLogLine) {
logs.value = [line, ...logs.value].slice(0, 200)
}
function clearLogs() {
logs.value = []
}
async function connect() {
if (props.useUuid && !userUuid.value.trim()) {
return
}
loading.value = true
disconnect()
try {
await loadPushJs()
const params: anyObj = {}
if (props.useUuid) {
params.uuid = userUuid.value.trim()
}
const res = await createAxios({
url: props.configApi,
method: 'get',
params,
showCodeMessage: true,
})
if (res.code !== 1 || !res.data) {
connected.value = false
return
}
const { url, app_key, channel } = res.data
channelName.value = typeof channel === 'string' ? channel : ''
try {
const handle = startPushChannelListener({
url,
app_key,
channel: channelName.value,
usePrivateAuth: !!props.useUuid,
onLog: appendLog,
onConnected: (ok) => {
connected.value = ok
},
})
session.value = handle
} catch {
connected.value = false
}
} finally {
loading.value = false
}
}
function disconnect() {
if (session.value) {
session.value.disconnect()
session.value = null
}
connected.value = false
}
onUnmounted(() => {
disconnect()
})
</script>
<style scoped>
.push-log {
margin: 0;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.text-muted {
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,10 @@
<template>
<PushChannelTestPage config-api="/admin/test.PushGamePeriod/pushConfig" :tip="t('test.pushGamePeriod.tip')" />
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import PushChannelTestPage from '../components/PushChannelTestPage.vue'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,10 @@
<template>
<PushChannelTestPage config-api="/admin/test.PushOperationNotice/pushConfig" :tip="t('test.pushOperationNotice.tip')" />
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import PushChannelTestPage from '../components/PushChannelTestPage.vue'
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<PushChannelTestPage
config-api="/admin/test.PushPrivateUser/pushConfig"
use-uuid
:tip="t('test.pushPrivateUser.tip')"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import PushChannelTestPage from '../components/PushChannelTestPage.vue'
const { t } = useI18n()
</script>

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

@@ -2,113 +2,437 @@
<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.user.User name') + '/' + t('user.user.nickname') })"
/>
:quick-search-placeholder="t('Quick search placeholder', { fields: t('user.user.quick Search Fields') })"
></TableHeader>
<!-- 表格 -->
<!-- 要使用`el-table`组件原有的属性直接加在Table标签上即可 -->
<Table />
<Table ref="tableRef"></Table>
<!-- 表单 -->
<PopupForm />
<el-dialog v-model="walletDialogVisible" class="ba-operate-dialog wallet-adjust-dialog" :close-on-click-modal="false" width="520px">
<template #header>
<div class="title">{{ t('user.user.wallet_adjust_title') }}</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div class="ba-operate-form wallet-adjust-form">
<el-form :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="config.layout.shrink ? '' : '110px'">
<el-form-item :label="t('user.user.username')">
<el-input :model-value="walletForm.username" readonly />
</el-form-item>
<el-form-item :label="t('user.user.coin')">
<el-tag type="primary">{{ walletForm.current_coin }}</el-tag>
</el-form-item>
<el-form-item :label="t('user.user.wallet_adjust_op')">
<el-radio-group v-model="walletForm.op" class="wallet-adjust-op-group" @change="syncWalletRemark">
<el-radio label="credit">{{ t('user.user.wallet_adjust_credit') }}</el-radio>
<el-radio label="deduct">{{ t('user.user.wallet_adjust_deduct') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('user.user.wallet_adjust_amount')">
<el-input-number
class="wallet-adjust-amount-input"
v-model="walletForm.amount"
:min="0.01"
:step="0.01"
:precision="2"
@change="syncWalletRemark"
/>
</el-form-item>
<el-form-item :label="t('user.user.remark')">
<el-input v-model="walletForm.remark" type="textarea" :rows="3" />
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div class="wallet-adjust-dialog-footer">
<el-button @click="walletDialogVisible = false">{{ t('Cancel') }}</el-button>
<el-button type="primary" :loading="walletSubmitting" @click="submitWalletAdjust">{{ t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { provide } 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 { onMounted, provide, reactive, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
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'
import createAxios from '/@/utils/axios'
import { useConfig } from '/@/stores/config'
defineOptions({
name: 'user/user',
})
const { t } = useI18n()
const config = useConfig()
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(2)
}
/** 杩斿洖澶氭爣绛炬枃妗堟暟缁勶紝渚?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
}
function buildDisplayAmount(v: unknown): string {
if (v === null || v === undefined || v === '') return '0.00'
const n = parseFloat(String(v).trim().replace(',', '.'))
if (!Number.isFinite(n)) return '0.00'
return n.toFixed(2)
}
const walletDialogVisible = ref(false)
const walletSubmitting = ref(false)
const walletForm = reactive({
user_id: 0,
username: '',
current_coin: '0.00',
op: 'credit',
amount: 100,
remark: '',
})
function syncWalletRemark() {
const action = walletForm.op === 'credit' ? t('user.user.wallet_adjust_credit') : t('user.user.wallet_adjust_deduct')
walletForm.remark = t('user.user.wallet_adjust_default_remark', {
admin: t('user.user.wallet_adjust_operator_admin'),
action,
amount: Number(walletForm.amount || 0).toFixed(2),
})
}
function openWalletDialog(row: anyObj) {
walletForm.user_id = Number(row.id || 0)
walletForm.username = String(row.username ?? '-')
walletForm.current_coin = buildDisplayAmount(row.coin)
walletForm.op = 'credit'
walletForm.amount = 100
syncWalletRemark()
walletDialogVisible.value = true
}
async function submitWalletAdjust() {
if (!walletForm.user_id) return
if (!(walletForm.amount > 0)) {
ElMessage.error(t('user.user.wallet_adjust_amount_invalid'))
return
}
walletSubmitting.value = true
try {
const res = await createAxios({
url: '/admin/user.User/walletAdjust',
method: 'post',
data: {
user_id: walletForm.user_id,
op: walletForm.op,
amount: Number(walletForm.amount).toFixed(2),
remark: walletForm.remark,
},
})
if (res.code === 1) {
ElMessage.success(res.msg || t('Success'))
walletDialogVisible.value = false
await baTable.getData()
} else {
ElMessage.error(res.msg || t('Unknown error'))
}
} finally {
walletSubmitting.value = false
}
}
const baTable = new baTableClass(
new baTableApi('/admin/user.User/'),
{
pk: 'id',
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
{ 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.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
{
label: t('user.user.group'),
prop: 'userGroup.name',
label: t('user.user.username'),
prop: 'username',
align: 'center',
operator: 'LIKE',
operatorPlaceholder: t('Fuzzy query'),
render: 'tag',
},
{ 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',
sortable: false,
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'),
render: 'tag',
operator: 'LIKE',
},
{
label: t('user.user.Last login'),
prop: 'last_login_time',
label: t('user.user.head_image'),
prop: 'head_image',
align: 'center',
render: 'datetime',
sortable: 'custom',
width: 72,
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',
minWidth: 100,
sortable: false,
operator: 'RANGE',
width: 160,
render: 'tag',
formatter: formatCoin,
customRenderAttr: {
tag: ({ row }) => ({
class: 'wallet-balance-tag',
onClick: () => openWalletDialog(row),
}),
},
},
{ 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_deposit_coin'),
prop: 'total_deposit_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatCoin,
},
{
label: t('user.user.total_withdraw_coin'),
prop: 'total_withdraw_coin',
align: 'center',
minWidth: 110,
sortable: false,
operator: 'RANGE',
formatter: formatCoin,
},
{
label: t('user.user.bet_flow_coin'),
prop: 'bet_flow_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',
align: 'center',
render: 'tag',
custom: { disable: 'danger', enable: 'success' },
replaceValue: { disable: t('Disable'), enable: t('Enable') },
operator: 'eq',
sortable: false,
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',
width: '100',
render: 'buttons',
buttons: defaultOptButtons(['edit', 'delete']),
operator: false,
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
render: 'tags',
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: {
gender: 0,
money: '0',
score: '0',
status: 'enable',
status: '1',
coin: '0.00',
total_deposit_coin: '0.00',
total_withdraw_coin: '0.00',
bet_flow_coin: '0.00',
risk_flags: 0,
current_streak: 0,
last_bet_period_no: '',
register_invite_code: '',
email: '',
head_image: '',
},
}
)
baTable.mount()
baTable.getData()
provide('baTable', baTable)
onMounted(() => {
baTable.table.ref = tableRef.value
baTable.mount()
baTable.getData()?.then(() => {
baTable.initSort()
baTable.dragSort()
})
})
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.wallet-balance-tag {
cursor: pointer;
}
.wallet-adjust-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.wallet-adjust-op-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.wallet-adjust-amount-input {
width: 100%;
}
.wallet-adjust-amount-input :deep(.el-input__wrapper) {
width: 100%;
}
.wallet-adjust-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 768px) {
.wallet-adjust-dialog :deep(.el-dialog) {
width: calc(100vw - 16px) !important;
margin-top: 4vh !important;
}
.wallet-adjust-op-group {
gap: 8px 12px;
}
.wallet-adjust-dialog-footer {
display: flex;
gap: 8px;
}
.wallet-adjust-dialog-footer .el-button {
flex: 1;
}
}
</style>

View File

@@ -1,9 +1,7 @@
<template>
<!-- 对话框表单 -->
<el-dialog
class="ba-operate-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="['Add', 'Edit'].includes(baTable.form.operate!)"
@close="baTable.toggleForm"
>
@@ -19,123 +17,158 @@
: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"
v-if="!baTable.form.loading"
>
<el-form-item prop="username" :label="t('user.user.User name')">
<el-input
v-model="baTable.form.items!.username"
type="string"
:placeholder="t('Please input field', { field: t('user.user.User name') + '(' + t('user.user.Login account') + ')' })"
></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>
<!-- <el-alert class="game-user-form-tip" type="info" :closable="false" show-icon>-->
<!-- {{ t('user.user.form_tip') }}-->
<!-- </el-alert>-->
<el-divider content-position="left">{{ t('user.user.section_basic') }}</el-divider>
<FormItem
type="remoteSelect"
:label="t('user.user.group')"
v-model="baTable.form.items!.group_id"
prop="group_id"
:placeholder="t('user.user.group')"
:input-attr="{
params: { isTree: true, search: [{ field: 'status', val: '1', operator: 'eq' }] },
field: 'name',
remoteUrl: '/admin/user.Group/index',
}"
:label="t('user.user.username')"
type="string"
v-model="baTable.form.items!.username"
prop="username"
:placeholder="t('Please input field', { field: t('user.user.username') })"
/>
<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
:label="t('user.user.Gender')"
v-model="baTable.form.items!.gender"
type="radio"
:input-attr="{
border: true,
content: { 0: t('Unknown'), 1: t('user.user.male'), 2: t('user.user.female') },
}"
:label="t('user.user.password')"
type="password"
v-model="baTable.form.items!.password"
prop="password"
:placeholder="t('Please input field', { field: t('user.user.password') })"
/>
<el-form-item :label="t('user.user.birthday')">
<el-date-picker
<FormItem
: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"
value-format="YYYY-MM-DD"
v-model="baTable.form.items!.birthday"
type="date"
:placeholder="t('Please select field', { field: t('user.user.birthday') })"
clearable
filterable
:data="adminScopeTree"
:props="treeProps"
:render-after-expand="false"
:placeholder="t('user.user.admin_affiliation_placeholder')"
@update:model-value="onAdminTreeChange"
/>
</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
: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_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"
type="radio"
:input-attr="{
border: true,
content: { disable: t('Disable'), enable: t('Enable') },
}"
prop="status"
:input-attr="{ content: { '0': t('user.user.status 0'), '1': t('user.user.status 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 @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>
@@ -145,15 +178,18 @@
</template>
<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 { inject, nextTick, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue'
import router from '/@/router/index'
import { buildValidatorData } from '/@/utils/validate'
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 formRef = useTemplateRef('formRef')
@@ -161,78 +197,264 @@ const baTable = inject('baTable') as baTableClass
const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('user.user.User name') }), buildValidatorData({ name: 'account' })],
nickname: [buildValidatorData({ name: 'required', title: t('user.user.nickname') })],
group_id: [buildValidatorData({ name: 'required', message: t('Please select field', { field: t('user.user.group') }) })],
email: [buildValidatorData({ name: 'email', title: t('user.user.email') })],
mobile: [buildValidatorData({ name: 'mobile' })],
password: [
{
validator: (rule: any, val: string, callback: Function) => {
if (baTable.form.operate == 'Add') {
if (!val) {
return callback(new Error(t('Please input field', { field: t('user.user.password') })))
}
} else {
if (!val) {
return callback()
}
}
if (!regularPassword(val)) {
return callback(new Error(t('validate.Please enter the correct password')))
}
return callback()
},
trigger: 'blur',
},
],
type TreeNode = {
value: string
label: string
disabled?: boolean
children?: TreeNode[]
channel_id?: number | null
is_leaf?: boolean
invite_code?: string
}
const adminScopeTree = ref<TreeNode[]>([])
const adminIdToChannelId = ref<Record<string, number>>({})
const adminIdToInviteCode = ref<Record<string, string>>({})
const treeProps = {
value: 'value',
label: 'label',
children: 'children',
disabled: 'disabled',
}
const riskBits = reactive({
noLogin: false,
noBet: false,
noWithdraw: false,
})
const changeAccount = (type: string) => {
baTable.toggleForm()
router.push({
name: type == 'money' ? 'user/moneyLog' : 'user/scoreLog',
query: {
user_id: baTable.form.items!.id,
},
})
function syncRiskFromFlags(flags: unknown) {
const f = typeof flags === 'number' ? flags : parseInt(String(flags ?? '0'), 10) || 0
riskBits.noLogin = (f & RISK_LOGIN) !== 0
riskBits.noBet = (f & RISK_BET) !== 0
riskBits.noWithdraw = (f & RISK_WITHDRAW) !== 0
}
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(
() => baTable.form.operate,
(newVal) => {
rules.password![0].required = newVal == 'Add'
(op) => {
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 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') })],
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>
<style scoped lang="scss">
.avatar-uploader {
.game-user-form-tip {
margin-bottom: 12px;
}
.risk-flag-row {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
align-items: center;
justify-content: center;
position: relative;
border-radius: var(--el-border-radius-small);
box-shadow: var(--el-box-shadow-light);
border: 1px dashed var(--el-border-color);
}
.risk-flag-tag {
cursor: pointer;
overflow: hidden;
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%;
user-select: none;
}
</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('user.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: 'user/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(2)
}
const bizReplace = {
deposit: t('user.userWalletRecord.biz deposit'),
withdraw: t('user.userWalletRecord.biz withdraw'),
withdraw_freeze: t('user.userWalletRecord.biz withdraw_freeze'),
withdraw_unfreeze: t('user.userWalletRecord.biz withdraw_unfreeze'),
platform_in: t('user.userWalletRecord.biz platform_in'),
platform_out: t('user.userWalletRecord.biz platform_out'),
admin_credit: t('user.userWalletRecord.biz admin_credit'),
admin_deduct: t('user.userWalletRecord.biz admin_deduct'),
bet: t('user.userWalletRecord.biz bet'),
payout: t('user.userWalletRecord.biz payout'),
fee: t('user.userWalletRecord.biz fee'),
void_refund: t('user.userWalletRecord.biz void_refund'),
adjust: t('user.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('user.userWalletRecord.direction in'),
'2': t('user.userWalletRecord.direction out'),
}
const baTable = new baTableClass(
new baTableApi('/admin/user.UserWalletRecord/'),
{
pk: 'id',
column: [
{ label: t('user.userWalletRecord.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{
label: t('user.userWalletRecord.user_id'),
prop: 'user_id',
align: 'center',
width: 90,
show: false,
operator: 'RANGE',
sortable: false,
},
{
label: t('user.userWalletRecord.user__username'),
prop: 'user.username',
align: 'center',
minWidth: 110,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('user.userWalletRecord.channel__name'),
prop: 'channel.name',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('user.userWalletRecord.biz_type'),
prop: 'biz_type',
align: 'center',
minWidth: 120,
operator: 'eq',
render: 'tag',
custom: bizTypeTagCustom,
replaceValue: bizReplace,
},
{
label: t('user.userWalletRecord.direction'),
prop: 'direction',
align: 'center',
width: 90,
operator: 'eq',
render: 'tag',
custom: {
'1': 'success',
'2': 'danger',
},
replaceValue: dirReplace,
},
{
label: t('user.userWalletRecord.amount'),
prop: 'amount',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('user.userWalletRecord.balance_before'),
prop: 'balance_before',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('user.userWalletRecord.balance_after'),
prop: 'balance_after',
align: 'center',
minWidth: 110,
operator: 'RANGE',
formatter: formatAmount,
},
{
label: t('user.userWalletRecord.ref_type'),
prop: 'ref_type',
align: 'center',
minWidth: 100,
showOverflowTooltip: true,
operator: 'LIKE',
},
{ label: t('user.userWalletRecord.ref_id'), prop: 'ref_id', align: 'center', width: 100, operator: 'RANGE' },
{
label: t('user.userWalletRecord.idempotency_key'),
prop: 'idempotency_key',
align: 'center',
minWidth: 120,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('user.userWalletRecord.operator_admin__username'),
prop: 'operatorAdmin.username',
align: 'center',
minWidth: 100,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
render: 'tags',
comSearchRender: 'string',
},
{
label: t('user.userWalletRecord.remark'),
prop: 'remark',
align: 'center',
minWidth: 140,
showOverflowTooltip: true,
operator: 'LIKE',
},
{
label: t('user.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

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