Compare commits
56 Commits
master-gam
...
b019fc2a62
| Author | SHA1 | Date | |
|---|---|---|---|
| b019fc2a62 | |||
| cf9373e5c2 | |||
| 92fb40ae80 | |||
| 025cce3e3e | |||
| 24aab111b5 | |||
| c184fa8a46 | |||
| 5c07967bf9 | |||
| 68657e2648 | |||
| 7d0f11fe43 | |||
| e3f26ba1f7 | |||
| a4878a9bbd | |||
| 9954ea741b | |||
| 2e0bcd3f23 | |||
| bf3d50a309 | |||
| 3cf386756b | |||
| cfe5ec6fb0 | |||
| 5ee0f2b1f4 | |||
| 545a818094 | |||
| 7b39a2a505 | |||
| 015d1e4d5b | |||
| c7149e7058 | |||
| 15fdd3ba57 | |||
| 7aaa66d2dc | |||
| a9a66063b4 | |||
| 6c491bac81 | |||
| 54f460a242 | |||
| 5bf948e309 | |||
| fb16321b7e | |||
| 7e695d32cf | |||
| 080ead200b | |||
| 0b0c0ee04a | |||
| 518642ddb0 | |||
| 569f9e7749 | |||
| f88afbcadb | |||
| 92545067d2 | |||
| 6bca594769 | |||
| 56df105af6 | |||
| c213b2d9e9 | |||
| 6573f59531 | |||
| e2097450eb | |||
| b6e5f09479 | |||
| c9f0154174 | |||
| a0bcc73503 | |||
| 14b9920667 | |||
| ba80e7c392 | |||
| 9d06c7a226 | |||
| c01e6430db | |||
| 979751f719 | |||
| 8cc28096c4 | |||
| 7b002c9410 | |||
| 66c14eacb3 | |||
| 36c344731e | |||
| 6af87681b7 | |||
| a410098b17 | |||
| 8a1287d8ed | |||
| 2d14527da8 |
25
.env-example
25
.env-example
@@ -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
|
||||
|
||||
# 游戏热点数据 Redis(config/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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
1254
app/admin/controller/Channel.php
Normal file
1254
app/admin/controller/Channel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
36
app/admin/controller/agent/CommissionRecord.php
Normal file
36
app/admin/controller/agent/CommissionRecord.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
34
app/admin/controller/agent/SettlementPeriod.php
Normal file
34
app/admin/controller/agent/SettlementPeriod.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
133
app/admin/controller/config/DepositTier.php
Normal file
133
app/admin/controller/config/DepositTier.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
127
app/admin/controller/config/GameConfig.php
Normal file
127
app/admin/controller/config/GameConfig.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
123
app/admin/controller/config/StreakWinReward.php
Normal file
123
app/admin/controller/config/StreakWinReward.php
Normal 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('保存成功');
|
||||
}
|
||||
}
|
||||
133
app/admin/controller/config/ZiHuaDictionary.php
Normal file
133
app/admin/controller/config/ZiHuaDictionary.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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 中对应的方法至此进行重写
|
||||
*/
|
||||
}
|
||||
@@ -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:仅校验每项整数、0~10000(5/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_weight:tier/kill 每项≤100 且和必须=100;bigwin 每项 0~10000;编辑时键不可改
|
||||
*
|
||||
* @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');
|
||||
}
|
||||
|
||||
// 骰子键 5~30 结构一律按大奖 0~10000 校验(避免库中 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 中对应方法至此进行重写
|
||||
*/
|
||||
}
|
||||
|
||||
108
app/admin/controller/game/Live.php
Normal file
108
app/admin/controller/game/Live.php
Normal 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);
|
||||
}
|
||||
}
|
||||
114
app/admin/controller/game/Period.php
Normal file
114
app/admin/controller/game/Period.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
69
app/admin/controller/game/Record.php
Normal file
69
app/admin/controller/game/Record.php
Normal 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('游戏对局记录不可删除');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 中对应的方法至此进行重写
|
||||
*/
|
||||
}
|
||||
@@ -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_weight;default_kill_score_weight 为 T1~T5 击杀分档位,不作大奖回退)
|
||||
*
|
||||
* @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 中对应的方法至此进行重写
|
||||
*/
|
||||
}
|
||||
119
app/admin/controller/game/ZiHuaDictionary.php
Normal file
119
app/admin/controller/game/ZiHuaDictionary.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
33
app/admin/controller/operation/OperationNotice.php
Normal file
33
app/admin/controller/operation/OperationNotice.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
app/admin/controller/operation/UserNoticeRead.php
Normal file
35
app/admin/controller/operation/UserNoticeRead.php
Normal 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;
|
||||
}
|
||||
}
|
||||
127
app/admin/controller/order/BetOrder.php
Normal file
127
app/admin/controller/order/BetOrder.php
Normal 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));
|
||||
}
|
||||
}
|
||||
156
app/admin/controller/order/DepositOrder.php
Normal file
156
app/admin/controller/order/DepositOrder.php
Normal 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));
|
||||
}
|
||||
}
|
||||
474
app/admin/controller/order/WithdrawOrder.php
Normal file
474
app/admin/controller/order/WithdrawOrder.php
Normal 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_order(status=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/fee;actual_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 += amount;total_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));
|
||||
}
|
||||
}
|
||||
32
app/admin/controller/test/PushGamePeriod.php
Normal file
32
app/admin/controller/test/PushGamePeriod.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
app/admin/controller/test/PushOperationNotice.php
Normal file
32
app/admin/controller/test/PushOperationNotice.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
app/admin/controller/test/PushPrivateUser.php
Normal file
40
app/admin/controller/test/PushPrivateUser.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 中对应的方法至此进行重写
|
||||
*/
|
||||
}
|
||||
129
app/admin/controller/user/UserWalletRecord.php
Normal file
129
app/admin/controller/user/UserWalletRecord.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
@@ -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' => '非顶级角色组须填写 0~100 的分红比例(%)',
|
||||
'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' => '默认大奖权重每项须在 0~10000 之间',
|
||||
'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' => '渠道已创建,但复制默认游戏配置失败',
|
||||
];
|
||||
@@ -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' => '为保障系统安全,角色组本身的上下级关系仅供参考,系统的实际上下级划分是根据`权限多寡`来确定的,两位管理员的权限节点:相同被认为是`同级`、包含且有额外权限才被认为是`上级`,同级不可管理同级,上级可为下级分配自己拥有的权限节点;若有特殊情况管理员需转`上级`,可建立一个虚拟权限节点',
|
||||
];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' => '']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
150
app/api/controller/Auth.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
538
app/api/controller/Finance.php
Normal file
538
app/api/controller/Finance.php
Normal 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.status(0 待审 / 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
382
app/api/controller/Game.php
Normal 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_factor;streak_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 访问(8787):index.html#/admin、index.html#/
|
||||
* - 通过 API 访问(7979):index#/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';
|
||||
|
||||
72
app/api/controller/MobileBase.php
Normal file
72
app/api/controller/MobileBase.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
|
||||
123
app/api/controller/Notice.php
Normal file
123
app/api/controller/Notice.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
85
app/api/controller/V1.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
|
||||
99
app/api/controller/Wallet.php
Normal file
99
app/api/controller/Wallet.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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~',
|
||||
|
||||
@@ -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~' => '密码已修改~',
|
||||
|
||||
@@ -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 303,axios 会跟随重定向,拿不到 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
103
app/common/lang/en/admin_rule_title.php
Normal file
103
app/common/lang/en/admin_rule_title.php
Normal 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',
|
||||
];
|
||||
24
app/common/lang/en/game_live.php
Normal file
24
app/common/lang/en/game_live.php
Normal 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',
|
||||
];
|
||||
24
app/common/lang/zh-cn/game_live.php
Normal file
24
app/common/lang/zh-cn/game_live.php
Normal 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' => '预约失败',
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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('档位奖励表单点数须在 5~30');
|
||||
}
|
||||
$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,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
app/common/library/admin/PushChannelConfigHelper.php
Normal file
24
app/common/library/admin/PushChannelConfigHelper.php
Normal 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');
|
||||
}
|
||||
}
|
||||
218
app/common/library/finance/DepositSettlement.php
Normal file
218
app/common/library/finance/DepositSettlement.php
Normal 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_time,user_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);
|
||||
}
|
||||
}
|
||||
164
app/common/library/finance/WithdrawFlow.php
Normal file
164
app/common/library/finance/WithdrawFlow.php
Normal 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_ratio;ratio = 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;
|
||||
}
|
||||
}
|
||||
351
app/common/library/game/DepositTier.php
Normal file
351
app/common/library/game/DepositTier.php
Normal 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 : int,0=停用,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 位稳定 ID(t_ + 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-');
|
||||
}
|
||||
}
|
||||
205
app/common/library/game/StreakWinReward.php
Normal file
205
app/common/library/game/StreakWinReward.php
Normal 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):按「连胜档位」1~10 配置赔率系数与是否大奖。
|
||||
*
|
||||
* 派彩: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;
|
||||
}
|
||||
}
|
||||
226
app/common/library/game/ZiHuaDictionary.php
Normal file
226
app/common/library/game/ZiHuaDictionary.php
Normal 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('编号必须在 1–36');
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -23,25 +23,87 @@ class LoadLangPack implements MiddlewareInterface
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前请求语言。
|
||||
* - 后台 admin:优先请求头 think-lang(zh-cn / en),其次 lang 头,再次查询/表单参数 lang(支持 zh→zh-cn)。
|
||||
* - 对外 api:优先查询/表单参数 lang(zh / 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);
|
||||
|
||||
38
app/common/model/AgentCommissionRecord.php
Normal file
38
app/common/model/AgentCommissionRecord.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
24
app/common/model/AgentSettlementPeriod.php
Normal file
24
app/common/model/AgentSettlementPeriod.php
Normal 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',
|
||||
];
|
||||
}
|
||||
|
||||
51
app/common/model/BetOrder.php
Normal file
51
app/common/model/BetOrder.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
20
app/common/model/ChannelAdminShare.php
Normal file
20
app/common/model/ChannelAdminShare.php
Normal 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',
|
||||
];
|
||||
}
|
||||
|
||||
30
app/common/model/DepositOrder.php
Normal file
30
app/common/model/DepositOrder.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
app/common/model/GamePeriod.php
Normal file
43
app/common/model/GamePeriod.php
Normal 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;
|
||||
}
|
||||
}
|
||||
57
app/common/model/GameRecord.php
Normal file
57
app/common/model/GameRecord.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
40
app/common/model/OperationNotice.php
Normal file
40
app/common/model/OperationNotice.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
app/common/model/UserNoticeRead.php
Normal file
28
app/common/model/UserNoticeRead.php
Normal 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');
|
||||
}
|
||||
}
|
||||
41
app/common/model/UserWalletRecord.php
Normal file
41
app/common/model/UserWalletRecord.php
Normal 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');
|
||||
}
|
||||
}
|
||||
37
app/common/model/WithdrawOrder.php
Normal file
37
app/common/model/WithdrawOrder.php
Normal 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');
|
||||
}
|
||||
}
|
||||
328
app/common/service/GameBetSettleService.php
Normal file
328
app/common/service/GameBetSettleService.php
Normal 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_record(biz_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_factor(odds_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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
279
app/common/service/GameHotDataRedis.php
Normal file
279
app/common/service/GameHotDataRedis.php
Normal 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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
723
app/common/service/GameLiveService.php
Normal file
723
app/common/service/GameLiveService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
44
app/common/service/GamePeriodService.php
Normal file
44
app/common/service/GamePeriodService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
139
app/common/service/GameRecordService.php
Normal file
139
app/common/service/GameRecordService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
107
app/common/service/GameRecordStatService.php
Normal file
107
app/common/service/GameRecordStatService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
72
app/common/service/JackpotPushService.php
Normal file
72
app/common/service/JackpotPushService.php
Normal 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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
64
app/common/service/UserPushService.php
Normal file
64
app/common/service/UserPushService.php
Normal 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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/common/validate/AgentCommissionRecord.php
Normal file
28
app/common/validate/AgentCommissionRecord.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
25
app/common/validate/AgentSettlementPeriod.php
Normal file
25
app/common/validate/AgentSettlementPeriod.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
25
app/common/validate/Channel.php
Normal file
25
app/common/validate/Channel.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
23
app/common/validate/DepositOrder.php
Normal file
23
app/common/validate/DepositOrder.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
@@ -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' => [],
|
||||
];
|
||||
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
|
||||
}
|
||||
|
||||
26
app/common/validate/GamePeriod.php
Normal file
26
app/common/validate/GamePeriod.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
26
app/common/validate/GameRecord.php
Normal file
26
app/common/validate/GameRecord.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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' => [],
|
||||
];
|
||||
|
||||
}
|
||||
@@ -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' => [],
|
||||
];
|
||||
|
||||
}
|
||||
41
app/common/validate/OperationNotice.php
Normal file
41
app/common/validate/OperationNotice.php
Normal 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;
|
||||
}
|
||||
}
|
||||
32
app/common/validate/User.php
Normal file
32
app/common/validate/User.php
Normal 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');
|
||||
}
|
||||
}
|
||||
24
app/common/validate/UserNoticeRead.php
Normal file
24
app/common/validate/UserNoticeRead.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
25
app/common/validate/WithdrawOrder.php
Normal file
25
app/common/validate/WithdrawOrder.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
@@ -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->controller(Webman 路由匹配时设置)解析,否则从 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);
|
||||
|
||||
21
app/process/GameLiveTicker.php
Normal file
21
app/process/GameLiveTicker.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
19
app/process/GamePeriodAutoTicker.php
Normal file
19
app/process/GamePeriodAutoTicker.php
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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. "
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ return [
|
||||
// 默认驱动方式
|
||||
'default' => 'mysql',
|
||||
// 加密key
|
||||
'key' => 'u4w3NzEr5QTv2ygjYOoMVZ6snKAePxJp',
|
||||
'key' => 'L1iYVS0PChKA9pjcFdmOGb4zfDIHo5xw',
|
||||
// 加密方式
|
||||
'algo' => 'ripemd160',
|
||||
// 驱动
|
||||
|
||||
28
config/game_hot_cache.php
Normal file
28
config/game_hot_cache.php
Normal 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),
|
||||
];
|
||||
10
config/plugin/webman/push/app.php
Normal file
10
config/plugin/webman/push/app.php
Normal 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'
|
||||
];
|
||||
21
config/plugin/webman/push/process.php
Normal file
21
config/plugin/webman/push/process.php
Normal 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'),
|
||||
],
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
87
config/plugin/webman/push/route.php
Normal file
87
config/plugin/webman/push/route.php
Normal 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';
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
140
config/route.php
140
config/route.php
@@ -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/user(GET 获取配置,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 风格 URL(module.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.php(PSR-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']);
|
||||
|
||||
@@ -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")
|
||||
|
||||
411
database/dfw_36zihua_schema_v1.sql
Normal file
411
database/dfw_36zihua_schema_v1.sql
Normal 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 '顶级代理管理员ID(admin.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 '渠道角色组ID(admin_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_user(C 端用户)
|
||||
-- ---------------------------------------------------------------------
|
||||
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 '所属渠道ID(channel.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 '所属渠道ID(channel.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 '所属渠道ID(channel.id),NULL表示系统级角色组' 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());
|
||||
-- =====================================================================
|
||||
@@ -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
840
database/sql/buildadmin-webman-game-number_原版_用于数据恢复.sql
Normal file
840
database/sql/buildadmin-webman-game-number_原版_用于数据恢复.sql
Normal file
File diff suppressed because one or more lines are too long
867
database/webman-buildadmin-dafuweng_20260401171133_backup.sql
Normal file
867
database/webman-buildadmin-dafuweng_20260401171133_backup.sql
Normal file
File diff suppressed because one or more lines are too long
@@ -7,5 +7,5 @@ services:
|
||||
volumes:
|
||||
- "./:/app"
|
||||
ports:
|
||||
- "8787:8787"
|
||||
- "7979:7979"
|
||||
command: ["php", "start.php", "start" ]
|
||||
429
docs/36字花-数据库与实施计划.md
Normal file
429
docs/36字花-数据库与实施计划.md
Normal 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 总览(与下文 P0~P4 对应)
|
||||
|
||||
| 阶段 | 目标 | 主要内容 |
|
||||
|------|------|----------|
|
||||
| **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字花字典** | 运营参数与字典维护 |
|
||||
| 中 | **充值订单 / 提现订单** | 充提流程管理与审核 |
|
||||
| 低 | **公告 / 站内信**、**代理结算** 等 | 属 **P3~P4**,核心跑通后再接 |
|
||||
|
||||
### 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 秒节奏 | 0–20s 可下注,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
164
docs/36字花prd.md
Normal 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(如下注、提现申请)必须配置严格的频率限制,防止恶意脚本撞库或刷单。
|
||||
@@ -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
168
docs/业务流程.md
Normal 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,并产生一条充值流水记录。系统记录该用户的“需完成流水额度”(即 💎 100,1倍提现门槛)。
|
||||
|
||||
### 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
145
docs/后端.md
Normal 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;` 确保高并发下余额不会被扣成负数。
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
// 必须用 Mutable:Webman 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
@@ -1 +0,0 @@
|
||||
install-end
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
36
scripts/generate_auth_signature.php
Normal file
36
scripts/generate_auth_signature.php
Normal 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']) . '×tamp=' . rawurlencode($params['timestamp']) . '&device_id=' . rawurlencode($params['device_id']) . '&signature=' . rawurlencode($signature) . PHP_EOL;
|
||||
|
||||
@@ -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/post,post 优先)
|
||||
* @param string|null $name 参数名,null 返回全部
|
||||
|
||||
@@ -4,5 +4,5 @@ ENV = 'development'
|
||||
# base路径
|
||||
VITE_BASE_PATH = './'
|
||||
|
||||
# 本地环境接口地址 - 用空字符串走同源,由 vite 代理到 8787,避免 CORS
|
||||
# 本地环境接口地址 - 用空字符串走同源,由 vite 代理到 7979,避免 CORS
|
||||
VITE_AXIOS_BASE_URL = ''
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import createAxios from '/@/utils/axios'
|
||||
|
||||
export function getUserRules() {
|
||||
return createAxios({
|
||||
url: '/admin/user.Rule/index',
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export function postLogout() {
|
||||
export function retrievePassword(params: anyObj) {
|
||||
return createAxios(
|
||||
{
|
||||
url: accountUrl + 'retrievePassword',
|
||||
url: userUrl + 'retrievePassword',
|
||||
method: 'POST',
|
||||
data: params,
|
||||
},
|
||||
|
||||
@@ -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'),
|
||||
{
|
||||
|
||||
@@ -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.ts(game.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'],
|
||||
}
|
||||
|
||||
22
web/src/lang/backend/en/agent/commissionRecord.ts
Normal file
22
web/src/lang/backend/en/agent/commissionRecord.ts
Normal 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',
|
||||
}
|
||||
|
||||
19
web/src/lang/backend/en/agent/settlementPeriod.ts
Normal file
19
web/src/lang/backend/en/agent/settlementPeriod.ts
Normal 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',
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
87
web/src/lang/backend/en/channel.ts
Normal file
87
web/src/lang/backend/en/channel.ts
Normal 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',
|
||||
}
|
||||
29
web/src/lang/backend/en/config/depositTier.ts
Normal file
29
web/src/lang/backend/en/config/depositTier.ts
Normal 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',
|
||||
}
|
||||
3
web/src/lang/backend/en/config/gameConfig.ts
Normal file
3
web/src/lang/backend/en/config/gameConfig.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import gameConfig from '../game/config'
|
||||
export default gameConfig
|
||||
|
||||
8
web/src/lang/backend/en/config/streakWinReward.ts
Normal file
8
web/src/lang/backend/en/config/streakWinReward.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
desc: 'Streak levels 1–10: 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',
|
||||
}
|
||||
3
web/src/lang/backend/en/config/ziHuaDictionary.ts
Normal file
3
web/src/lang/backend/en/config/ziHuaDictionary.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import dict from '../game/ziHuaDictionary'
|
||||
export default dict
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
27
web/src/lang/backend/en/game/betOrder.ts
Normal file
27
web/src/lang/backend/en/game/betOrder.ts
Normal 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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 0–10000: 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',
|
||||
}
|
||||
|
||||
29
web/src/lang/backend/en/game/live.ts
Normal file
29
web/src/lang/backend/en/game/live.ts
Normal 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',
|
||||
}
|
||||
29
web/src/lang/backend/en/game/record.ts
Normal file
29
web/src/lang/backend/en/game/record.ts
Normal 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)',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 0–25; grid_number 5–30 unique.\n' +
|
||||
'• Roll D: start_index = id where grid_number=D; CW end=(start+D)%26; CCW end=start−D, 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; 0–100→T3; 100–200→T2; >200→T1; T5 amount 0. Below you set per-tier amounts; T1–T4 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_index−D if ≥0 else 26+start_index−D. 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.',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
33
web/src/lang/backend/en/game/walletRecord.ts
Normal file
33
web/src/lang/backend/en/game/walletRecord.ts
Normal 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',
|
||||
}
|
||||
16
web/src/lang/backend/en/game/ziHuaDictionary.ts
Normal file
16
web/src/lang/backend/en/game/ziHuaDictionary.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
|
||||
15
web/src/lang/backend/en/operation/operationNotice.ts
Normal file
15
web/src/lang/backend/en/operation/operationNotice.ts
Normal 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',
|
||||
}
|
||||
13
web/src/lang/backend/en/operation/userNoticeRead.ts
Normal file
13
web/src/lang/backend/en/operation/userNoticeRead.ts
Normal 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',
|
||||
}
|
||||
33
web/src/lang/backend/en/order/betOrder.ts
Normal file
33
web/src/lang/backend/en/order/betOrder.ts
Normal 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',
|
||||
}
|
||||
26
web/src/lang/backend/en/order/depositOrder.ts
Normal file
26
web/src/lang/backend/en/order/depositOrder.ts
Normal 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',
|
||||
}
|
||||
36
web/src/lang/backend/en/order/withdrawOrder.ts
Normal file
36
web/src/lang/backend/en/order/withdrawOrder.ts
Normal 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',
|
||||
}
|
||||
12
web/src/lang/backend/en/test/push.ts
Normal file
12
web/src/lang/backend/en/test/push.ts
Normal 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.',
|
||||
}
|
||||
3
web/src/lang/backend/en/test/pushGamePeriod.ts
Normal file
3
web/src/lang/backend/en/test/pushGamePeriod.ts
Normal 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.',
|
||||
}
|
||||
3
web/src/lang/backend/en/test/pushOperationNotice.ts
Normal file
3
web/src/lang/backend/en/test/pushOperationNotice.ts
Normal 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.',
|
||||
}
|
||||
3
web/src/lang/backend/en/test/pushPrivateUser.ts
Normal file
3
web/src/lang/backend/en/test/pushPrivateUser.ts
Normal 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.',
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
GroupName: 'Group name',
|
||||
'Group name': 'Group name',
|
||||
jurisdiction: 'Permissions',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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)',
|
||||
}
|
||||
|
||||
33
web/src/lang/backend/en/user/userWalletRecord.ts
Normal file
33
web/src/lang/backend/en/user/userWalletRecord.ts
Normal 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',
|
||||
}
|
||||
22
web/src/lang/backend/zh-cn/agent/commissionRecord.ts
Normal file
22
web/src/lang/backend/zh-cn/agent/commissionRecord.ts
Normal 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: '更新时间',
|
||||
}
|
||||
|
||||
19
web/src/lang/backend/zh-cn/agent/settlementPeriod.ts
Normal file
19
web/src/lang/backend/zh-cn/agent/settlementPeriod.ts
Normal 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: '更新时间',
|
||||
}
|
||||
|
||||
@@ -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': '不修改请留空',
|
||||
|
||||
@@ -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': '在此管理下级角色组(您拥有下级角色组的所有权限并且拥有额外的权限,不含同级)',
|
||||
}
|
||||
|
||||
87
web/src/lang/backend/zh-cn/channel.ts
Normal file
87
web/src/lang/backend/zh-cn/channel.ts
Normal 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、渠道标识、渠道名',
|
||||
}
|
||||
29
web/src/lang/backend/zh-cn/config/depositTier.ts
Normal file
29
web/src/lang/backend/zh-cn/config/depositTier.ts
Normal 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 的数字',
|
||||
}
|
||||
3
web/src/lang/backend/zh-cn/config/gameConfig.ts
Normal file
3
web/src/lang/backend/zh-cn/config/gameConfig.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import gameConfig from '../game/config'
|
||||
export default gameConfig
|
||||
|
||||
8
web/src/lang/backend/zh-cn/config/streakWinReward.ts
Normal file
8
web/src/lang/backend/zh-cn/config/streakWinReward.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
desc: '1~10 档连胜:派彩 = 压注总额 × 赔率系数(odds_factor)。勾选「大奖」的档位在中奖时会对玩家私有频道、public-game-period 与 public-operation-notice 推送 jackpot.hit。',
|
||||
btn_save: '保存',
|
||||
streak: '连胜档(局)',
|
||||
odds_factor: '赔率系数',
|
||||
is_jackpot: '是否大奖',
|
||||
err_factor: '第 {no} 行赔率系数须 ≥1',
|
||||
}
|
||||
3
web/src/lang/backend/zh-cn/config/ziHuaDictionary.ts
Normal file
3
web/src/lang/backend/zh-cn/config/ziHuaDictionary.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import dict from '../game/ziHuaDictionary'
|
||||
export default dict
|
||||
|
||||
@@ -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: '小时前',
|
||||
}
|
||||
|
||||
27
web/src/lang/backend/zh-cn/game/betOrder.ts
Normal file
27
web/src/lang/backend/zh-cn/game/betOrder.ts
Normal 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: '渠道',
|
||||
}
|
||||
@@ -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、渠道标识、渠道名',
|
||||
}
|
||||
@@ -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:
|
||||
'大奖权重区间为 0~10000:0 表示不可能出现,10000 表示 100% 出现。点数 5 与 30 为豹子号必中组合,权重固定为 10000,不可修改。',
|
||||
'bigwin weight each 0 10000': '除固定项外,每项大奖权重须在 0~10000 之间',
|
||||
'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',
|
||||
}
|
||||
|
||||
29
web/src/lang/backend/zh-cn/game/live.ts
Normal file
29
web/src/lang/backend/zh-cn/game/live.ts
Normal 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: '下注时连胜',
|
||||
}
|
||||
29
web/src/lang/backend/zh-cn/game/record.ts
Normal file
29
web/src/lang/backend/zh-cn/game/record.ts
Normal 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: '手动创建下一局',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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 升序为位置 0~25;每条配置的 grid_number 为 5~30 且不重复。\n' +
|
||||
'• 摇取点数 D(5~30):起点为「grid_number=D」所在格位的 id(start_index),顺时针落点 = (起点 + D) mod 26,逆时针落点 = 起点 − D(若小于 0 则 +26)。\n' +
|
||||
'• 对照表每条记录的「色子点数」列为 D;档位、真实结算、显示文案取自落点格位对应 id 的配置。\n\n' +
|
||||
'【豹子摇取点数】\n' +
|
||||
'摇取点数为 5、10、15、20、25、30 时,其顺/逆时针落点档位不能为 T4、T5。\n\n' +
|
||||
'【结算金额与档位】\n' +
|
||||
'结算金额 < 0 → T4;0 < 结算金额 < 100 → T3;100 < 结算金额 < 200 → T2;200 < 结算金额 → T1;T5 结算金额=0。\n' +
|
||||
'下方填写各档位统一结算金额标准;T1~T4 的中/英文显示文本将等于该金额字符串;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 表中现有数据,再为 5~30 共 26 个点数、顺/逆时针各生成一条(共 52 条)。档位、真实结算、显示文案、备注取自落点格位在档位表中的配置。是否继续?',
|
||||
gen_weight_confirm_ok: '确定创建',
|
||||
gen_weight_need_channel: '请先选择具体渠道并刷新后再生成权重对照。',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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: '其他',
|
||||
}
|
||||
|
||||
33
web/src/lang/backend/zh-cn/game/walletRecord.ts
Normal file
33
web/src/lang/backend/zh-cn/game/walletRecord.ts
Normal 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': '其他调账',
|
||||
}
|
||||
16
web/src/lang/backend/zh-cn/game/ziHuaDictionary.ts
Normal file
16
web/src/lang/backend/zh-cn/game/ziHuaDictionary.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
title: '36字花字典',
|
||||
desc: '用于维护 36 字花的展示名称与归类。修改后会影响前台选号展示、相关筛选统计及后台对照信息,请确认无误后再保存。',
|
||||
no: '编号',
|
||||
name: '名称',
|
||||
category: '类型',
|
||||
btn_save: '保存',
|
||||
category_label: {
|
||||
zodiac: '生肖',
|
||||
beast: '猛兽',
|
||||
fowl: '飞禽',
|
||||
vermin: '虫蛇',
|
||||
divine: '神兽',
|
||||
},
|
||||
}
|
||||
|
||||
15
web/src/lang/backend/zh-cn/operation/operationNotice.ts
Normal file
15
web/src/lang/backend/zh-cn/operation/operationNotice.ts
Normal 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: '更新时间',
|
||||
}
|
||||
13
web/src/lang/backend/zh-cn/operation/userNoticeRead.ts
Normal file
13
web/src/lang/backend/zh-cn/operation/userNoticeRead.ts
Normal 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: '创建时间',
|
||||
}
|
||||
33
web/src/lang/backend/zh-cn/order/betOrder.ts
Normal file
33
web/src/lang/backend/zh-cn/order/betOrder.ts
Normal 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: '渠道',
|
||||
}
|
||||
26
web/src/lang/backend/zh-cn/order/depositOrder.ts
Normal file
26
web/src/lang/backend/zh-cn/order/depositOrder.ts
Normal 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: '关闭',
|
||||
}
|
||||
36
web/src/lang/backend/zh-cn/order/withdrawOrder.ts
Normal file
36
web/src/lang/backend/zh-cn/order/withdrawOrder.ts
Normal 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: '手续费不能大于申请金额',
|
||||
}
|
||||
12
web/src/lang/backend/zh-cn/test/push.ts
Normal file
12
web/src/lang/backend/zh-cn/test/push.ts
Normal 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 进程已启动且服务端会向对应频道发消息。',
|
||||
}
|
||||
3
web/src/lang/backend/zh-cn/test/pushGamePeriod.ts
Normal file
3
web/src/lang/backend/zh-cn/test/pushGamePeriod.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
tip: '订阅文档中的「全局对局频道」public-game-period,用于验证 period.tick / period.locked / period.opened / period.payout / jackpot.hit 等公共事件(需服务端向该频道推送)。',
|
||||
}
|
||||
3
web/src/lang/backend/zh-cn/test/pushOperationNotice.ts
Normal file
3
web/src/lang/backend/zh-cn/test/pushOperationNotice.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
tip: '订阅「公告广播频道」public-operation-notice:全站公告 notice.popout 与本游戏 jackpot.hit(连胜大奖广播)均会发往此频道;需服务端推送。',
|
||||
}
|
||||
3
web/src/lang/backend/zh-cn/test/pushPrivateUser.ts
Normal file
3
web/src/lang/backend/zh-cn/test/pushPrivateUser.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
tip: '订阅「用户私有频道」private-user-{uuid}:请输入玩家 uuid,连接后将走 /plugin/webman/push/auth 鉴权(与移动端一致)。用于验证 bet.accepted、wallet.changed 等私有事件。',
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
GroupName: '组名',
|
||||
'Group name': '组别名称',
|
||||
jurisdiction: '权限',
|
||||
}
|
||||
@@ -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: '用户',
|
||||
}
|
||||
@@ -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 工程内的菜单时,无需填写此项,请根据菜单使用场景填写',
|
||||
}
|
||||
@@ -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': '请输入变更备注/说明',
|
||||
}
|
||||
@@ -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}(值)',
|
||||
}
|
||||
|
||||
33
web/src/lang/backend/zh-cn/user/userWalletRecord.ts
Normal file
33
web/src/lang/backend/zh-cn/user/userWalletRecord.ts
Normal 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': '其他调账',
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
82
web/src/utils/backend/pushChannelTest.ts
Normal file
82
web/src/utils/backend/pushChannelTest.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/** 当前行是否为大奖骰子键 5~30(与 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)
|
||||
/** 击杀分权重与档位权重同为 T1~T5;库中 JSON 为 [{"T1":"0"},...],非骰子点 5~30 */
|
||||
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
|
||||
}
|
||||
157
web/src/views/backend/agent/commissionRecord/index.vue
Normal file
157
web/src/views/backend/agent/commissionRecord/index.vue
Normal 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>
|
||||
86
web/src/views/backend/agent/commissionRecord/popupForm.vue
Normal file
86
web/src/views/backend/agent/commissionRecord/popupForm.vue
Normal 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>
|
||||
152
web/src/views/backend/agent/settlementPeriod/index.vue
Normal file
152
web/src/views/backend/agent/settlementPeriod/index.vue
Normal 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>
|
||||
48
web/src/views/backend/agent/settlementPeriod/popupForm.vue
Normal file
48
web/src/views/backend/agent/settlementPeriod/popupForm.vue
Normal 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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
600
web/src/views/backend/channel/index.vue
Normal file
600
web/src/views/backend/channel/index.vue
Normal 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>
|
||||
519
web/src/views/backend/channel/popupForm.vue
Normal file
519
web/src/views/backend/channel/popupForm.vue
Normal 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>
|
||||
278
web/src/views/backend/config/depositTier/index.vue
Normal file
278
web/src/views/backend/config/depositTier/index.vue
Normal 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>
|
||||
134
web/src/views/backend/config/gameConfig/index.vue
Normal file
134
web/src/views/backend/config/gameConfig/index.vue
Normal 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>
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<template>
|
||||
<!-- 对话框表单 -->
|
||||
<!-- 建议使用 Prettier 格式化代码 -->
|
||||
<!-- el-form 内可以混用 el-form-item、FormItem、ba-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>
|
||||
|
||||
141
web/src/views/backend/config/streakWinReward/index.vue
Normal file
141
web/src/views/backend/config/streakWinReward/index.vue
Normal 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>
|
||||
104
web/src/views/backend/config/ziHuaDictionary/index.vue
Normal file
104
web/src/views/backend/config/ziHuaDictionary/index.vue
Normal 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>
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: '' },
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<template>
|
||||
<!-- 对话框表单 -->
|
||||
<!-- 建议使用 Prettier 格式化代码 -->
|
||||
<!-- el-form 内可以混用 el-form-item、FormItem、ba-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
|
||||
)
|
||||
|
||||
/** 默认大奖权重:仅校验每项整数与 0~10000(5/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 {
|
||||
// 非固定键名但行结构已是 5~30 骰子时,按大奖 0~10000 校验(避免 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>
|
||||
|
||||
654
web/src/views/backend/game/live/index.vue
Normal file
654
web/src/views/backend/game/live/index.vue
Normal 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
|
||||
/** 开奖号码池上限(1–draw_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>
|
||||
113
web/src/views/backend/game/record/index.vue
Normal file
113
web/src/views/backend/game/record/index.vue
Normal 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>
|
||||
61
web/src/views/backend/game/record/popupForm.vue
Normal file
61
web/src/views/backend/game/record/popupForm.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<!-- 对话框表单 -->
|
||||
<!-- 建议使用 Prettier 格式化代码 -->
|
||||
<!-- el-form 内可以混用 el-form-item、FormItem、ba-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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,654 +0,0 @@
|
||||
<template>
|
||||
<!-- 对话框表单 -->
|
||||
<!-- 建议使用 Prettier 格式化代码 -->
|
||||
<!-- el-form 内可以混用 el-form-item、FormItem、ba-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') })"
|
||||
/>
|
||||
<!-- 档位权重:键固定 T1–T5,仅可改值 -->
|
||||
<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>
|
||||
<!-- 中大奖权重:键固定 5–30,仅可改值 -->
|
||||
<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>
|
||||
102
web/src/views/backend/game/ziHuaDictionary/index.vue
Normal file
102
web/src/views/backend/game/ziHuaDictionary/index.vue
Normal 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>
|
||||
130
web/src/views/backend/operation/operationNotice/index.vue
Normal file
130
web/src/views/backend/operation/operationNotice/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
116
web/src/views/backend/operation/userNoticeRead/index.vue
Normal file
116
web/src/views/backend/operation/userNoticeRead/index.vue
Normal 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>
|
||||
74
web/src/views/backend/operation/userNoticeRead/popupForm.vue
Normal file
74
web/src/views/backend/operation/userNoticeRead/popupForm.vue
Normal 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>
|
||||
254
web/src/views/backend/order/betOrder/index.vue
Normal file
254
web/src/views/backend/order/betOrder/index.vue
Normal 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>
|
||||
214
web/src/views/backend/order/depositOrder/index.vue
Normal file
214
web/src/views/backend/order/depositOrder/index.vue
Normal 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>
|
||||
231
web/src/views/backend/order/depositOrder/popupForm.vue
Normal file
231
web/src/views/backend/order/depositOrder/popupForm.vue
Normal 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>
|
||||
163
web/src/views/backend/order/withdrawOrder/index.vue
Normal file
163
web/src/views/backend/order/withdrawOrder/index.vue
Normal 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>
|
||||
434
web/src/views/backend/order/withdrawOrder/popupForm.vue
Normal file
434
web/src/views/backend/order/withdrawOrder/popupForm.vue
Normal 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>
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -167,6 +167,7 @@ const baTable = new baTableClass(new baTableApi(url), {
|
||||
render: 'buttons',
|
||||
buttons: optButtons,
|
||||
operator: false,
|
||||
fixed: 'right',
|
||||
},
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -180,6 +180,7 @@ const baTable = new baTableClass(new baTableApi(url), {
|
||||
render: 'buttons',
|
||||
buttons: optButtons,
|
||||
operator: false,
|
||||
fixed: 'right',
|
||||
},
|
||||
],
|
||||
dblClickNotEditColumn: [undefined],
|
||||
|
||||
142
web/src/views/backend/test/components/PushChannelTestPage.vue
Normal file
142
web/src/views/backend/test/components/PushChannelTestPage.vue
Normal 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>
|
||||
10
web/src/views/backend/test/pushGamePeriod/index.vue
Normal file
10
web/src/views/backend/test/pushGamePeriod/index.vue
Normal 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>
|
||||
10
web/src/views/backend/test/pushOperationNotice/index.vue
Normal file
10
web/src/views/backend/test/pushOperationNotice/index.vue
Normal 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>
|
||||
14
web/src/views/backend/test/pushPrivateUser/index.vue
Normal file
14
web/src/views/backend/test/pushPrivateUser/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }"
|
||||
/>
|
||||
<!-- 菜单类型:tab、link、iframe -->
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
223
web/src/views/backend/user/userWalletRecord/index.vue
Normal file
223
web/src/views/backend/user/userWalletRecord/index.vue
Normal 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>
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user