Compare commits

12 Commits

37 changed files with 3355 additions and 122 deletions

View File

@@ -7,17 +7,12 @@ APP_DEFAULT_TIMEZONE = Asia/Shanghai
# 语言 # 语言
LANG_DEFAULT_LANG = zh-cn LANG_DEFAULT_LANG = zh-cn
# Database
# 数据库config/thinkorm.php/database.php
DATABASE_DRIVER = mysql
DATABASE_TYPE = mysql DATABASE_TYPE = mysql
DATABASE_HOSTNAME = 127.0.0.1 DATABASE_HOSTNAME = 127.0.0.1
DATABASE_DATABASE = buildadmin-webman DATABASE_DATABASE = webman-buildadmin-dafuweng
DATABASE_USERNAME = buildadmin-webman DATABASE_USERNAME = webman-buildadmin-dafuweng
DATABASE_PASSWORD = 123456 DATABASE_PASSWORD = 6dzMaX32Xdsc4DjS
DATABASE_HOSTPORT = 3306 DATABASE_HOSTPORT = 3306
DATABASE_CHARSET = utf8mb4 DATABASE_CHARSET = utf8mb4
DATABASE_PREFIX = DATABASE_PREFIX =
# 缓存config/cache.php
CACHE_DRIVER = file

View File

@@ -21,7 +21,10 @@ class Admin extends Backend
protected array|string $quickSearchField = ['username', 'nickname']; protected array|string $quickSearchField = ['username', 'nickname'];
protected string|int|bool $dataLimit = 'allAuthAndOthers'; /**
* 开启数据范围;具体范围见重写的 getDataLimitAdminIds角色组树仅本人 + 下级组内管理员)
*/
protected bool|string|int $dataLimit = true;
protected string $dataLimitField = 'id'; protected string $dataLimitField = 'id';
@@ -31,6 +34,17 @@ class Admin extends Backend
return null; 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 public function index(Request $request): Response
{ {
$response = $this->initializeBackend($request); $response = $this->initializeBackend($request);
@@ -357,9 +371,12 @@ class Admin extends Backend
if ($this->auth->isSuperAdmin()) { if ($this->auth->isSuperAdmin()) {
return null; return null;
} }
$authGroups = $this->auth->getAllAuthGroups('allAuthAndOthers'); $allowedGroupIds = array_values(array_unique(array_merge(
Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id'),
$this->auth->getAdminChildGroups()
)));
foreach ($groups as $group) { foreach ($groups as $group) {
if (!in_array($group, $authGroups)) { if (!in_array($group, $allowedGroupIds, false)) {
return $this->error(__('You have no permission to add an administrator to this group!')); return $this->error(__('You have no permission to add an administrator to this group!'));
} }
} }

View File

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

View File

@@ -17,8 +17,6 @@ use Webman\Http\Request;
class Group extends Backend class Group extends Backend
{ {
protected string $authMethod = 'allAuthAndOthers';
protected ?object $model = null; protected ?object $model = null;
protected string|array $preExcludeFields = ['create_time', 'update_time']; protected string|array $preExcludeFields = ['create_time', 'update_time'];
@@ -82,6 +80,9 @@ class Group extends Backend
$rulesRes = $this->handleRules($data); $rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes; if ($rulesRes instanceof Response) return $rulesRes;
$pidRes = $this->validateGroupParentId($data['pid'] ?? null);
if ($pidRes instanceof Response) return $pidRes;
$result = false; $result = false;
$this->model->startTrans(); $this->model->startTrans();
try { try {
@@ -144,6 +145,11 @@ class Group extends Backend
$rulesRes = $this->handleRules($data); $rulesRes = $this->handleRules($data);
if ($rulesRes instanceof Response) return $rulesRes; 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; $result = false;
$this->model->startTrans(); $this->model->startTrans();
try { try {
@@ -294,8 +300,6 @@ class Group extends Backend
$pk = $this->model->getPk(); $pk = $this->model->getPk();
$initKey = $request->get('initKey') ?? $pk; $initKey = $request->get('initKey') ?? $pk;
$absoluteAuth = $request->get('absoluteAuth') ?? false;
if ($this->keyword) { if ($this->keyword) {
$keyword = explode(' ', $this->keyword); $keyword = explode(' ', $this->keyword);
foreach ($keyword as $item) { foreach ($keyword as $item) {
@@ -308,11 +312,14 @@ class Group extends Backend
} }
if (!$this->auth->isSuperAdmin()) { if (!$this->auth->isSuperAdmin()) {
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, $where); $descendantIds = $this->auth->getAdminChildGroups();
if (!$absoluteAuth) { // 本人所在组 + 树形下级;不含同级、不含其它分支(与 getAllAuthGroups 的「权限多寡」脱钩)
$authGroups = array_merge($this->adminGroups, $authGroups); $visibleIds = array_values(array_unique(array_merge($this->adminGroups, $descendantIds)));
if ($visibleIds === []) {
$where[] = ['id', '=', -1];
} else {
$where[] = ['id', 'in', $visibleIds];
} }
$where[] = ['id', 'in', $authGroups];
} }
$data = $this->model->where($where)->select()->toArray(); $data = $this->model->where($where)->select()->toArray();
@@ -337,9 +344,43 @@ class Group extends Backend
private function checkAuth($groupId): ?Response private function checkAuth($groupId): ?Response
{ {
$authGroups = $this->auth->getAllAuthGroups($this->authMethod, []); if ($this->auth->isSuperAdmin()) {
if (!$this->auth->isSuperAdmin() && !in_array($groupId, $authGroups)) { return null;
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~')); }
$descendantIds = $this->auth->getAdminChildGroups();
if (!in_array($groupId, $descendantIds, false)) {
return $this->error(__('You can only operate subordinate role groups in the tree hierarchy~'));
}
return null;
}
/**
* 新增/编辑时校验父级:非超管只能挂在本人所在组或其树形下级之下,不可建顶级(pid=0)
*/
private function validateGroupParentId(mixed $pid): ?Response
{
if ($this->auth->isSuperAdmin()) {
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'));
} }
return null; return null;
} }

View File

@@ -26,11 +26,17 @@ class Channel extends Backend
protected string|array $quickSearchField = ['id', 'code', 'name']; 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 往往未在后台录入; * adminTree 为辅助接口,默认权限节点名 game/channel/admintree 往往未在后台录入;
* 与列表权限 game/channel/index 对齐,避免子管理员已勾「渠道管理」仍 401。 * 与列表权限 game/channel/index 对齐,避免子管理员已勾「渠道管理」仍 401。
*/ */
protected array $noNeedPermission = ['adminTree']; protected array $noNeedPermission = ['adminTree', 'deleteRelatedCounts'];
protected function initController(WebmanRequest $request): ?Response protected function initController(WebmanRequest $request): ?Response
{ {
@@ -38,6 +44,25 @@ class Channel extends Backend
return null; 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();
}
/** /**
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级) * 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
*/ */
@@ -50,11 +75,13 @@ class Channel extends Backend
return $this->error(__('You have no permission')); return $this->error(__('You have no permission'));
} }
$channels = Db::name('game_channel') $channelQuery = Db::name('game_channel')
->field(['id', 'name', 'admin_group_id']) ->field(['id', 'name', 'admin_group_id'])
->order('id', 'asc') ->order('id', 'asc');
->select() if (!$this->auth->isSuperAdmin()) {
->toArray(); $channelQuery->where('admin_id', $this->auth->id);
}
$channels = $channelQuery->select()->toArray();
$groupChildrenCache = []; $groupChildrenCache = [];
$getGroupChildren = function ($groupId) use (&$getGroupChildren, &$groupChildrenCache) { $getGroupChildren = function ($groupId) use (&$getGroupChildren, &$groupChildrenCache) {
@@ -142,6 +169,10 @@ class Channel extends Backend
$data = $this->applyInputFilter($data); $data = $this->applyInputFilter($data);
$data = $this->excludeFields($data); $data = $this->excludeFields($data);
if (!$this->auth->isSuperAdmin()) {
$data['admin_id'] = $this->auth->id;
}
$adminId = $data['admin_id'] ?? null; $adminId = $data['admin_id'] ?? null;
if ($adminId === null || $adminId === '') { if ($adminId === null || $adminId === '') {
return $this->error(__('Parameter %s can not be empty', ['admin_id'])); return $this->error(__('Parameter %s can not be empty', ['admin_id']));
@@ -188,6 +219,20 @@ class Channel extends Backend
return $this->error($e->getMessage()); return $this->error($e->getMessage());
} }
if ($result !== false) { 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);
} catch (Throwable $e) {
return $this->error(__('Game channel copy default config failed') . ': ' . $e->getMessage());
}
}
return $this->success(__('Added successfully')); return $this->success(__('Added successfully'));
} }
return $this->error(__('No rows were added')); return $this->error(__('No rows were added'));
@@ -228,6 +273,10 @@ class Channel extends Backend
unset($data['admin_group_id']); 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); $nextAdminId = array_key_exists('admin_id', $data) ? $data['admin_id'] : ($row['admin_id'] ?? null);
if ($nextAdminId !== null && $nextAdminId !== '') { if ($nextAdminId !== null && $nextAdminId !== '') {
$topGroupId = Db::name('admin_group_access') $topGroupId = Db::name('admin_group_access')
@@ -300,13 +349,282 @@ class Channel extends Backend
->order($order) ->order($order)
->paginate($limit); ->paginate($limit);
$list = $this->buildChannelListWithRealtimeUserCounts($res->items());
return $this->success('', [ return $this->success('', [
'list' => $res->items(), 'list' => $list,
'total' => $res->total(), 'total' => $res->total(),
'remark' => get_route_remark(), '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;
}
/**
* 新建渠道后:将 channel_id=0 的全局默认游戏配置复制一份channel_id 指向新渠道主键
*
* @param int|string $newChannelId 新建 game_channel.id
*/
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 中对应的方法至此进行重写 * 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/ */

View File

@@ -4,6 +4,7 @@ namespace app\admin\controller\game;
use Throwable; use Throwable;
use app\common\controller\Backend; use app\common\controller\Backend;
use support\think\Db;
use support\Response; use support\Response;
use Webman\Http\Request as WebmanRequest; use Webman\Http\Request as WebmanRequest;
@@ -19,7 +20,22 @@ class Config extends Backend
*/ */
protected ?object $model = null; protected ?object $model = null;
protected string|array $defaultSortField = 'group,desc'; /**
* 数据范围:非超管仅本人 + 下级角色组内管理员game_config 无 admin_id通过 channel_id 关联 game_channel.admin_id 限定
*/
protected bool|string|int $dataLimit = 'parent';
/**
* 列表/删除等条件字段为 channel_id见 {@see getDataLimitAdminIds()} 实际返回渠道 ID
*/
protected string $dataLimitField = 'channel_id';
/**
* 表无 admin_id勿自动写入
*/
protected bool $dataLimitFieldAutoFill = false;
protected string|array $defaultSortField = 'channel_id,desc';
protected array $withJoinTable = ['channel']; protected array $withJoinTable = ['channel'];
@@ -27,15 +43,39 @@ class Config extends Backend
protected string|array $quickSearchField = ['ID']; protected string|array $quickSearchField = ['ID'];
/** 权重之和必须100 的配置标识 */ /** default_tier_weight / default_kill_score_weight每项≤100 且各项之和必须=100 */
private const WEIGHT_SUM_100_NAMES = ['default_tier_weight', 'default_kill_score_weight']; private const WEIGHT_SUM_100_NAMES = ['default_tier_weight', 'default_kill_score_weight'];
/** default_bigwin_weight仅校验每项整数、0100005/30 固定 10000不参与「和≤100」 */
private const BIGWIN_WEIGHT_NAME = 'default_bigwin_weight';
protected function initController(WebmanRequest $request): ?Response protected function initController(WebmanRequest $request): ?Response
{ {
$this->model = new \app\common\model\GameConfig(); $this->model = new \app\common\model\GameConfig();
return null; return null;
} }
/**
* 将「可访问管理员 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 * @throws Throwable
*/ */
@@ -55,8 +95,15 @@ class Config extends Backend
return $this->error($err); return $this->error($err);
} }
if ($this->dataLimit && $this->dataLimitFieldAutoFill) { if (!$this->auth->isSuperAdmin()) {
$data[$this->dataLimitField] = $this->auth->id; $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; $result = false;
@@ -118,6 +165,13 @@ class Config extends Backend
$data['group'] = $row['group']; $data['group'] = $row['group'];
$data['name'] = $row['name']; $data['name'] = $row['name'];
$data['title'] = $row['title']; $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); $err = $this->validateGameWeightPayload($data, $row['value'] ?? null);
@@ -157,27 +211,93 @@ class Config extends Backend
} }
/** /**
* game_weight:校验数值、键不可改(编辑)、和为 100特定 name * 与前端 gameWeightFixed.normalizeGameWeightConfigName 一致trim、去括号后说明、小写
*/
private function normalizeGameWeightConfigName(string $raw): string
{
$s = trim($raw);
if (preg_match('/^([^(]+)/u', $s, $m)) {
$s = trim($m[1]);
}
return strtolower($s);
}
/**
* 解析 game_weight JSON 里每一项的权重:支持 int/float、数字字符串与前端 JSON.stringify 一致常为字符串)
*/
private function parseGameWeightScalarToFloat(mixed $v): ?float
{
if (is_int($v) || is_float($v)) {
return (float) $v;
}
if (is_bool($v) || is_array($v) || $v === null) {
return null;
}
$s = trim((string) $v);
if ($s === '') {
return null;
}
$f = filter_var($s, FILTER_VALIDATE_FLOAT);
if ($f !== false) {
return $f;
}
$i = filter_var($s, FILTER_VALIDATE_INT);
if ($i !== false) {
return (float) $i;
}
return null;
}
/**
* game_weighttier/kill 每项≤100 且和必须=100bigwin 每项 010000编辑时键不可改
* *
* @param array<string, mixed> $data * @param array<string, mixed> $data
*/ */
private function validateGameWeightPayload(array $data, ?string $originalValue): ?string private function validateGameWeightPayload(array &$data, ?string $originalValue): ?string
{ {
$group = $data['group'] ?? ''; $group = strtolower(trim((string) ($data['group'] ?? '')));
if ($group !== 'game_weight') { if ($group !== 'game_weight') {
return null; return null;
} }
$name = $data['name'] ?? ''; $rawName = (string) ($data['name'] ?? '');
$value = $data['value'] ?? ''; $rawName = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $rawName);
if (!is_string($value)) { $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'); return __('Parameter error');
} }
$decoded = json_decode($value, true);
if (!is_array($decoded)) { if (!is_array($decoded)) {
return __('Parameter error'); return __('Parameter error');
} }
// 骰子键 530 结构一律按大奖 010000 校验(避免库中 name 与 JSON 不一致时误走 tier/kill 单项上限)
$diceBigwin = $this->gameWeightDecodedIsBigwinDiceKeys($decoded);
// 归一化后的 name + 原始串包含 default_bigwin_weight兼容说明后缀、异常空格避免误走单项上限
$rawLower = strtolower($rawName);
$useBigwinRules = $diceBigwin
|| ($name === self::BIGWIN_WEIGHT_NAME)
|| str_contains($rawLower, 'default_bigwin_weight');
$keys = []; $keys = [];
$numbers = []; $numbers = [];
foreach ($decoded as $item) { foreach ($decoded as $item) {
@@ -185,13 +305,23 @@ class Config extends Backend
return __('Parameter error'); return __('Parameter error');
} }
foreach ($item as $k => $v) { foreach ($item as $k => $v) {
$keys[] = $k; $keys[] = (string) $k;
if (!is_numeric($v)) { $num = $this->parseGameWeightScalarToFloat($v);
if ($num === null) {
return __('Game config weight value must be numeric'); return __('Game config weight value must be numeric');
} }
$num = (float) $v; if ($useBigwinRules) {
if ($num > 100) { if ($num < 0 || $num > 10000) {
return __('Game config weight each value must not exceed 100'); 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; $numbers[] = $num;
} }
@@ -208,16 +338,51 @@ class Config extends Backend
} }
} }
if (in_array($name, self::WEIGHT_SUM_100_NAMES, true)) { if (!$useBigwinRules && in_array($name, self::WEIGHT_SUM_100_NAMES, true)) {
$sum = array_sum($numbers); $sum = array_sum($numbers);
if (abs($sum - 100.0) > 0.000001) { if (abs($sum - 100.0) > 0.000001) {
return __('Game config weight sum must equal 100'); return __('Game config weight sum must equal 100');
} }
} }
if ($valueWasArray) {
$data['value'] = json_encode($decoded, JSON_UNESCAPED_UNICODE);
}
return null; 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> * @return list<string>
*/ */
@@ -233,7 +398,7 @@ class Config extends Backend
continue; continue;
} }
foreach ($item as $k => $_) { foreach ($item as $k => $_) {
$keys[] = $k; $keys[] = (string) $k;
} }
} }
return $keys; return $keys;

View File

@@ -4,6 +4,8 @@ namespace app\admin\controller\game;
use Throwable; use Throwable;
use app\common\controller\Backend; use app\common\controller\Backend;
use app\common\service\GameChannelUserCount;
use support\think\Db;
use support\Response; use support\Response;
use Webman\Http\Request as WebmanRequest; use Webman\Http\Request as WebmanRequest;
@@ -19,18 +21,112 @@ class User extends Backend
*/ */
protected ?object $model = 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|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time'];
protected array $withJoinTable = ['gameChannel', 'admin']; protected array $withJoinTable = ['gameChannel', 'admin'];
protected string|array $quickSearchField = ['id', 'username', 'phone']; 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 protected function initController(WebmanRequest $request): ?Response
{ {
$this->model = new \app\common\model\GameUser(); $this->model = new \app\common\model\GameUser();
return null; 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 生成) * 添加重写password 使用 Admin 同款加密uuid 由 username+channel_id 生成)
* @throws Throwable * @throws Throwable
@@ -45,6 +141,7 @@ class User extends Backend
$data = $this->applyInputFilter($data); $data = $this->applyInputFilter($data);
$data = $this->excludeFields($data); $data = $this->excludeFields($data);
$this->normalizeEmptyTicketCount($data);
$password = $data['password'] ?? null; $password = $data['password'] ?? null;
if (!is_string($password) || trim($password) === '') { if (!is_string($password) || trim($password) === '') {
@@ -59,8 +156,19 @@ class User extends Backend
} }
$data['uuid'] = md5(trim($username) . '|' . $channelId); $data['uuid'] = md5(trim($username) . '|' . $channelId);
if ($this->dataLimit && $this->dataLimitFieldAutoFill) { if ($this->gameUserUsernameExistsInChannel($username, $channelId)) {
$data[$this->dataLimitField] = $this->auth->id; 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; $result = false;
@@ -83,6 +191,14 @@ class User extends Backend
return $this->error($e->getMessage()); return $this->error($e->getMessage());
} }
if ($result !== false) { 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->success(__('Added successfully'));
} }
return $this->error(__('No rows were added')); return $this->error(__('No rows were added'));
@@ -109,6 +225,8 @@ class User extends Backend
return $this->error(__('You have no permission')); return $this->error(__('You have no permission'));
} }
$oldChannelId = $row['game_channel_id'] ?? $row['channel_id'] ?? null;
if ($this->request && $this->request->method() === 'POST') { if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post(); $data = $this->request->post();
if (!$data) { if (!$data) {
@@ -117,6 +235,7 @@ class User extends Backend
$data = $this->applyInputFilter($data); $data = $this->applyInputFilter($data);
$data = $this->excludeFields($data); $data = $this->excludeFields($data);
$this->normalizeEmptyTicketCount($data);
if (array_key_exists('password', $data)) { if (array_key_exists('password', $data)) {
$password = $data['password']; $password = $data['password'];
@@ -139,6 +258,17 @@ class User extends Backend
if (is_string($nextUsername) && trim($nextUsername) !== '' && $nextChannelId !== null && $nextChannelId !== '') { if (is_string($nextUsername) && trim($nextUsername) !== '' && $nextChannelId !== null && $nextChannelId !== '') {
$data['uuid'] = md5(trim($nextUsername) . '|' . $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; $result = false;
@@ -162,6 +292,14 @@ class User extends Backend
return $this->error($e->getMessage()); return $this->error($e->getMessage());
} }
if ($result !== false) { 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->success(__('Update successful'));
} }
return $this->error(__('No rows updated')); return $this->error(__('No rows updated'));
@@ -174,6 +312,51 @@ class User extends Backend
]); ]);
} }
/**
* 删除后按 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 * @throws Throwable
@@ -207,6 +390,270 @@ class User extends Backend
]); ]);
} }
/**
* 新建用户时:超管可选各渠道 default_tier_weight / default_bigwin_weight大奖仅 default_bigwin_weightdefault_kill_score_weight 为 T1T5 击杀分档位,不作大奖回退)
*
* @throws Throwable
*/
public function defaultWeightPresets(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('game/user/index')) {
return $this->error(__('You have no permission'));
}
$allowed = $this->getAllowedChannelIdsForGameConfig();
$channelQuery = Db::name('game_channel')->field(['id', 'name'])->order('id', 'asc');
if ($allowed !== null) {
$channelQuery->where('id', 'in', $allowed);
}
$channels = $channelQuery->select()->toArray();
$tierOut = [];
$bigwinOut = [];
/** 超管:首条为 game_config.channel_id=0 的全局默认权重 */
if ($this->auth->isSuperAdmin()) {
$gp = $this->fetchDefaultWeightsForChannelId(0);
$gname = __('Global default');
$tierOut[] = [
'channel_id' => 0,
'channel_name' => $gname,
'value' => $gp['tier_weight'],
];
$bigwinOut[] = [
'channel_id' => 0,
'channel_name' => $gname,
'value' => $gp['bigwin_weight'],
];
}
if ($channels === []) {
return $this->success('', [
'tier' => $tierOut,
'bigwin' => $bigwinOut,
]);
}
$channelIds = array_column($channels, 'id');
$nameList = [self::GC_NAME_TIER, self::GC_NAME_BIGWIN_PRIMARY];
$rows = Db::name('game_config')
->where('group', self::GC_GROUP_WEIGHT)
->where('name', 'in', $nameList)
->where('channel_id', 'in', $channelIds)
->field(['channel_id', 'name', 'value'])
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$cid = $row['channel_id'];
if (!isset($map[$cid])) {
$map[$cid] = [];
}
$map[$cid][$row['name']] = $row['value'];
}
foreach ($channels as $ch) {
$cid = $ch['id'];
$cname = $ch['name'] ?? '';
$names = $map[$cid] ?? [];
$tierVal = $names[self::GC_NAME_TIER] ?? null;
$tierOut[] = [
'channel_id' => $cid,
'channel_name' => $cname,
'value' => ($tierVal !== null && $tierVal !== '') ? trim((string) $tierVal) : '[]',
];
$bigPrimary = $names[self::GC_NAME_BIGWIN_PRIMARY] ?? null;
$bigVal = ($bigPrimary !== null && $bigPrimary !== '') ? trim((string) $bigPrimary) : null;
$bigwinOut[] = [
'channel_id' => $cid,
'channel_name' => $cname,
'value' => $bigVal !== null ? $bigVal : '[]',
];
}
return $this->success('', [
'tier' => $tierOut,
'bigwin' => $bigwinOut,
]);
}
/**
* 按渠道取默认档位/大奖权重(非超管仅可访问权限内渠道)
*
* @throws Throwable
*/
public function defaultWeightByChannel(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('game/user/index')) {
return $this->error(__('You have no permission'));
}
$channelId = $request->get('channel_id', $request->post('channel_id'));
if ($channelId === null || $channelId === '') {
return $this->error(__('Parameter error'));
}
$cid = (int) $channelId;
if ($cid === 0) {
if (!$this->auth->isSuperAdmin()) {
return $this->error(__('You have no permission'));
}
$pair = $this->fetchDefaultWeightsForChannelId(0);
return $this->success('', [
'tier_weight' => $pair['tier_weight'],
'bigwin_weight' => $pair['bigwin_weight'],
]);
}
if ($cid < 1 || !$this->canAccessChannelGameConfig($cid)) {
return $this->error(__('You have no permission'));
}
$pair = $this->fetchDefaultWeightsForChannelId($cid);
return $this->success('', [
'tier_weight' => $pair['tier_weight'],
'bigwin_weight' => $pair['bigwin_weight'],
]);
}
/**
* @return list<int|string>|null null 表示超管不限制渠道
*/
private function getAllowedChannelIdsForGameConfig(): ?array
{
if ($this->auth->isSuperAdmin()) {
return null;
}
$adminIds = parent::getDataLimitAdminIds();
if ($adminIds === []) {
return [-1];
}
$channelIds = Db::name('game_channel')->where('admin_id', 'in', $adminIds)->column('id');
if ($channelIds === []) {
return [-1];
}
return array_values(array_unique($channelIds));
}
private function canAccessChannelGameConfig(int $channelId): bool
{
$exists = Db::name('game_channel')->where('id', $channelId)->count();
if ($exists < 1) {
return false;
}
$allowed = $this->getAllowedChannelIdsForGameConfig();
if ($allowed === null) {
return true;
}
return in_array($channelId, $allowed, false);
}
/**
* @return array{tier_weight: string, bigwin_weight: string}
*/
private function fetchDefaultWeightsForChannelId(int $channelId): array
{
$rows = Db::name('game_config')
->where('channel_id', $channelId)
->where('group', self::GC_GROUP_WEIGHT)
->where('name', 'in', [self::GC_NAME_TIER, self::GC_NAME_BIGWIN_PRIMARY])
->column('value', 'name');
$tier = $rows[self::GC_NAME_TIER] ?? null;
$tierStr = ($tier !== null && $tier !== '') ? trim((string) $tier) : '[]';
$bigPrimary = $rows[self::GC_NAME_BIGWIN_PRIMARY] ?? null;
$bigStr = '[]';
if ($bigPrimary !== null && $bigPrimary !== '') {
$bigStr = trim((string) $bigPrimary);
}
// 适配导入:强制返回固定键的 JSON缺失键补空字符串
$tierStr = $this->normalizeWeightJsonToFixedKeys($tierStr, self::TIER_WEIGHT_KEYS);
$bigStr = $this->normalizeWeightJsonToFixedKeys($bigStr, self::BIGWIN_WEIGHT_KEYS);
return [
'tier_weight' => $tierStr,
'bigwin_weight' => $bigStr,
];
}
/**
* 将 JSON 权重(数组形式:[{key:value}, ...])归一化到固定键顺序。
* 缺失键补空字符串;解析失败则返回固定键且值为空。
*/
private function normalizeWeightJsonToFixedKeys(string $raw, array $fixedKeys): string
{
$raw = trim($raw);
if ($raw === '' || $raw === '[]') {
$empty = [];
foreach ($fixedKeys as $k) {
$empty[] = [$k => ''];
}
return json_encode($empty, JSON_UNESCAPED_UNICODE);
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
$empty = [];
foreach ($fixedKeys as $k) {
$empty[] = [$k => ''];
}
return json_encode($empty, JSON_UNESCAPED_UNICODE);
}
$map = [];
foreach ($decoded as $item) {
if (!is_array($item)) {
continue;
}
foreach ($item as $k => $v) {
if (!is_string($k) && !is_int($k)) {
continue;
}
$map[strval($k)] = $v === null ? '' : strval($v);
}
}
$pairs = [];
foreach ($fixedKeys as $k) {
$pairs[] = [$k => $map[$k] ?? ''];
}
return json_encode($pairs, JSON_UNESCAPED_UNICODE);
}
/**
* 当前渠道game_channel_id下是否已存在该用户名编辑时排除当前记录主键
*/
private function gameUserUsernameExistsInChannel(string $username, int|string $channelId, string|int|null $excludePk = null): bool
{
$name = trim($username);
$cid = (int) $channelId;
$query = Db::name('game_user')
->where('game_channel_id', $cid)
->where('username', $name);
if ($excludePk !== null && $excludePk !== '') {
$query->where('id', '<>', $excludePk);
}
return $query->count() > 0;
}
/** /**
* 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写 * 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
*/ */

View File

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

View File

@@ -9,5 +9,8 @@ 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~', '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!', '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~', '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~',
'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.', '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.',
]; ];

View File

@@ -37,6 +37,7 @@ return [
'Topic format error' => '上传存储子目录格式错误!', 'Topic format error' => '上传存储子目录格式错误!',
'Driver %s not supported' => '不支持的驱动:%s', 'Driver %s not supported' => '不支持的驱动:%s',
'Unknown' => '未知', 'Unknown' => '未知',
'Global default' => '全局默认channel_id=0',
// 权限类语言包-s // 权限类语言包-s
'Super administrator' => '超级管理员', 'Super administrator' => '超级管理员',
'No permission' => '无权限', 'No permission' => '无权限',
@@ -116,6 +117,14 @@ return [
'Group Name Arr' => '分组名称数组', 'Group Name Arr' => '分组名称数组',
'Game config weight keys cannot be modified' => '权重配置的键不可修改', 'Game config weight keys cannot be modified' => '权重配置的键不可修改',
'Game config weight value must be numeric' => '权重值必须为数字', 'Game config weight value must be numeric' => '权重值必须为数字',
'Game config weight each value must not exceed 100' => '每项权重不能超过100', '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 equal 100' => 'default_tier_weight / default_kill_score_weight 的权重之和必须等于100',
'Game config weight sum must not exceed 100' => 'default_tier_weight / default_kill_score_weight 的权重之和不能超过100',
'Game config bigwin weight must be integer' => '默认大奖权重每项必须为整数',
'Game config bigwin weight each 0 10000' => '默认大奖权重每项须在 010000 之间',
'Game config bigwin weight locked 5 30' => '点数 5 与 30 为豹子号必中,权重固定为 10000不可修改',
'Game user username exists in channel' => '该渠道下已存在相同用户名的用户',
'Game channel delete need confirm related' => '请先确认是否同时删除关联的游戏配置与用户数据',
'Game channel copy default config failed' => '渠道已创建,但复制默认游戏配置失败',
]; ];

View File

@@ -9,5 +9,8 @@ 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' => '分组权限节点超出可分配范围,请刷新重试~',
'Remark lang' => '为保障系统安全,角色组本身的上下级关系仅供参考,系统的实际上下级划分是根据`权限多寡`来确定的,两位管理员的权限节点:相同被认为是`同级`、包含且有额外权限才被认为是`上级`,同级不可管理同级,上级可为下级分配自己拥有的权限节点;若有特殊情况管理员需转`上级`,可建立一个虚拟权限节点', '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' => '角色组以「树形父子关系」为准:仅可管理本人所在组之下的下级组;同级、其他分支及上级组不可在此管理。分配权限时仍只能勾选您自身拥有的权限节点。',
]; ];

View File

@@ -297,6 +297,26 @@ class Auth extends \ba\Auth
return array_unique($children); 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 public function getGroupChildGroups(int $groupId, array &$children): void
{ {
$childrenTemp = AdminGroup::where('pid', $groupId) $childrenTemp = AdminGroup::where('pid', $groupId)

View File

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

View File

@@ -188,7 +188,8 @@ if (!function_exists('get_controller_path')) {
$parts = explode('\\', $relative); $parts = explode('\\', $relative);
$path = []; $path = [];
foreach ($parts as $p) { foreach ($parts as $p) {
$path[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $p)); // 与 BuildAdmin admin_rule.name 一致:多段类名用 camelCase如 auth/adminLog不用 admin_log
$path[] = lcfirst($p);
} }
return implode('/', $path); return implode('/', $path);
} }
@@ -215,10 +216,10 @@ if (!function_exists('get_controller_path')) {
$dotPos = strpos($seg, '.'); $dotPos = strpos($seg, '.');
$mod = substr($seg, 0, $dotPos); $mod = substr($seg, 0, $dotPos);
$ctrl = substr($seg, $dotPos + 1); $ctrl = substr($seg, $dotPos + 1);
$normalized[] = strtolower($mod); $normalized[] = lcfirst($mod);
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $ctrl)); $normalized[] = lcfirst($ctrl);
} else { } else {
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $seg)); $normalized[] = lcfirst($seg);
} }
} }
return implode('/', $normalized); return implode('/', $normalized);

View File

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

View File

@@ -169,8 +169,9 @@ 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::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']); Route::get('/admin/auth/rule/select', [\app\admin\controller\auth\Rule::class, 'select']);
// admin/auth/adminLog // admin/auth/adminLog(兼容 ThinkPHP 风格 /admin/auth.AdminLog/index
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']);
Route::get('/admin/auth.AdminLog/index', [\app\admin\controller\auth\AdminLog::class, 'index']);
// admin/user/user // admin/user/user
Route::get('/admin/user/user/index', [\app\admin\controller\user\User::class, 'index']); Route::get('/admin/user/user/index', [\app\admin\controller\user\User::class, 'index']);
@@ -245,11 +246,21 @@ Route::get('/admin/security/dataRecycleLog/index', [\app\admin\controller\securi
Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']); Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']);
Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']); Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']);
// ==================== 显式路由(优先级高,避免动态路由误匹配) ====================
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']);
// ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action ==================== // ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action ====================
// 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用 // 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用
Route::add( Route::add(
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'], ['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) { function (\Webman\Http\Request $request, string $controllerPart, string $action) {
$pos = strpos($controllerPart, '.'); $pos = strpos($controllerPart, '.');
if ($pos === false) { if ($pos === false) {
@@ -257,8 +268,21 @@ Route::add(
} }
$module = substr($controllerPart, 0, $pos); $module = substr($controllerPart, 0, $pos);
$controller = substr($controllerPart, $pos + 1); $controller = substr($controllerPart, $pos + 1);
$class = '\\app\\admin\\controller\\' . strtolower($module) . '\\' . $controller; // game.user / game.User 等:小写控制器名需解析为 User.phpPSR-4 类名 StudlyCase
if (!class_exists($class)) { $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) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE)); 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)) { if (!method_exists($class, $action)) {
@@ -280,6 +304,26 @@ 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 ==================== // ==================== CORS 预检OPTIONS ====================
// 放在最后注册;显式加上前端会请求的路径,再加固通配 // 放在最后注册;显式加上前端会请求的路径,再加固通配
Route::add('OPTIONS', '/api/index/index', [\app\common\middleware\AllowCrossDomain::class, 'optionsResponse']); Route::add('OPTIONS', '/api/index/index', [\app\common\middleware\AllowCrossDomain::class, 'optionsResponse']);

View File

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

File diff suppressed because one or more lines are too long

23
public/index.html Normal file

File diff suppressed because one or more lines are too long

1
public/install.lock Normal file
View File

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

View File

@@ -5,5 +5,5 @@ export default {
'Parent group': 'Superior group', 'Parent group': 'Superior group',
'The parent group cannot be the group itself': 'The parent group cannot be the group itself', 'The parent group cannot be the group itself': 'The parent group cannot be the group itself',
'Manage subordinate role groups here': 'Manage subordinate role groups here':
'In managing a subordinate role group (excluding a peer role group), you have all the rights of a subordinate role group and additional rights', '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.',
} }

View File

@@ -1,4 +1,7 @@
export default { 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', id: 'id',
code: 'code', code: 'code',
name: 'name', name: 'name',

View File

@@ -9,12 +9,24 @@ export default {
'weight key': 'Key', 'weight key': 'Key',
'weight value': 'Value', 'weight value': 'Value',
'weight sum must 100': 'The sum of weights for default_tier_weight / default_kill_score_weight must equal 100', '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 100': 'Each weight value must not exceed 100',
'weight each max 10000': 'Weight must not exceed 10000',
'bigwin weight must integer': 'Each big win weight must be an integer',
'name opt game_rule': 'game_rule',
'name opt game_rule_en': 'game_rule_en',
'name opt default_tier_weight': 'default_tier_weight',
'name opt default_kill_score_weight': 'default_kill_score_weight',
'name opt default_bigwin_weight': 'default_bigwin_weight',
default_bigwin_weight_help:
'Big win weight range is 010000: 0 means never, 10000 means 100%. Totals 5 and 30 are guaranteed leopard (豹子); fixed at 10000 and cannot be changed.',
'bigwin weight each 0 10000': 'Each weight (except fixed keys) must be between 0 and 10000',
'bigwin weight locked 5 30': 'Weights for totals 5 and 30 are fixed at 10000',
'weight value numeric': 'Weight values must be valid numbers', 'weight value numeric': 'Weight values must be valid numbers',
sort: 'sort', sort: 'sort',
instantiation: 'instantiation', instantiation: 'instantiation',
'instantiation 0': 'instantiation 0', 'instantiation 0': '---',
'instantiation 1': 'instantiation 1', 'instantiation 1': 'YES',
create_time: 'create_time', create_time: 'create_time',
update_time: 'update_time', update_time: 'update_time',
'quick Search Fields': 'ID', 'quick Search Fields': 'ID',

View File

@@ -6,6 +6,22 @@ export default {
phone: 'phone', phone: 'phone',
remark: 'remark', remark: 'remark',
coin: 'coin', 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: 'status',
'status 0': 'status 0', 'status 0': 'status 0',
'status 1': 'status 1', 'status 1': 'status 1',

View File

@@ -4,5 +4,6 @@ export default {
jurisdiction: '权限', jurisdiction: '权限',
'Parent group': '上级分组', 'Parent group': '上级分组',
'The parent group cannot be the group itself': '上级分组不能是分组本身', 'The parent group cannot be the group itself': '上级分组不能是分组本身',
'Manage subordinate role groups here': '在此管理下级角色组(您拥有下级角色组的所有权限并且拥有额外的权限,不含同级)', 'Manage subordinate role groups here':
'在此仅可管理「角色组树」中您所在组之下的下级组;同级、其他分支与上级组不在管理范围内。分配权限时仍只能勾选您自身拥有的节点。',
} }

View File

@@ -1,4 +1,7 @@
export default { export default {
delete_confirm_title: '删除渠道',
delete_confirm_related:
'将同时删除该渠道下 {countConfig} 条游戏配置、{countUser} 条游戏用户数据,此操作不可恢复。确定删除所选渠道吗?',
id: 'ID', id: 'ID',
code: '渠道标识', code: '渠道标识',
name: '渠道名', name: '渠道名',

View File

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

View File

@@ -6,6 +6,22 @@ export default {
phone: '手机号', phone: '手机号',
remark: '备注', remark: '备注',
coin: '平台币', 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 须为有效数字',
status: '状态', status: '状态',
'status 0': '禁用', 'status 0': '禁用',
'status 1': '启用', 'status 1': '启用',

View File

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

View File

@@ -22,12 +22,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue' import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
import PopupForm from './popupForm.vue' import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common' import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table' import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue' import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue' import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable' import baTableClass from '/@/utils/baTable'
import createAxios from '/@/utils/axios'
import { adminBaseRoutePath } from '/@/router/static/adminBase'
defineOptions({ defineOptions({
name: 'game/channel', name: 'game/channel',
@@ -40,8 +43,10 @@ const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
/** /**
* baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件 * baTable 内包含了表格的所有数据且数据具备响应性,然后通过 provide 注入给了后代组件
*/ */
const channelApiBase = `${adminBaseRoutePath}/game.Channel/`
const baTable = new baTableClass( const baTable = new baTableClass(
new baTableApi('/admin/game.Channel/'), new baTableApi(channelApiBase),
{ {
pk: 'id', pk: 'id',
column: [ column: [
@@ -127,6 +132,46 @@ const baTable = new baTableClass(
provide('baTable', baTable) 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(() => { onMounted(() => {
baTable.table.ref = tableRef.value baTable.table.ref = tableRef.value
baTable.mount() baTable.mount()

View File

@@ -61,6 +61,7 @@
:placeholder="t('Please input field', { field: t('game.channel.remark') })" :placeholder="t('Please input field', { field: t('game.channel.remark') })"
/> />
<FormItem <FormItem
v-if="isSuperAdmin"
:label="t('game.channel.admin_id')" :label="t('game.channel.admin_id')"
type="remoteSelect" type="remoteSelect"
v-model="baTable.form.items!.admin_id" v-model="baTable.form.items!.admin_id"
@@ -84,28 +85,34 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormItemRule } from 'element-plus' import type { FormItemRule } from 'element-plus'
import { inject, reactive, useTemplateRef } from 'vue' import { computed, inject, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue' import FormItem from '/@/components/formItem/index.vue'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo'
import type baTableClass from '/@/utils/baTable' import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate' import { buildValidatorData } from '/@/utils/validate'
const config = useConfig() const config = useConfig()
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const { t } = useI18n() const { t } = useI18n()
const rules: Partial<Record<string, FormItemRule[]>> = reactive({ const isSuperAdmin = computed(() => adminInfo.super === true)
const rules = computed<Partial<Record<string, FormItemRule[]>>>(() => ({
code: [buildValidatorData({ name: 'required', title: t('game.channel.code') })], code: [buildValidatorData({ name: 'required', title: t('game.channel.code') })],
name: [buildValidatorData({ name: 'required', title: t('game.channel.name') })], name: [buildValidatorData({ name: 'required', title: t('game.channel.name') })],
user_count: [buildValidatorData({ name: 'integer', title: t('game.channel.user_count') })], user_count: [buildValidatorData({ name: 'integer', title: t('game.channel.user_count') })],
profit_amount: [buildValidatorData({ name: 'float', title: t('game.channel.profit_amount') })], profit_amount: [buildValidatorData({ name: 'float', title: t('game.channel.profit_amount') })],
admin_id: [buildValidatorData({ name: 'required', title: t('game.channel.admin_id') })], admin_id: isSuperAdmin.value
? [buildValidatorData({ name: 'required', title: t('game.channel.admin_id') })]
: [],
create_time: [buildValidatorData({ name: 'date', title: t('game.channel.create_time') })], create_time: [buildValidatorData({ name: 'date', title: t('game.channel.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.channel.update_time') })], update_time: [buildValidatorData({ name: 'date', title: t('game.channel.update_time') })],
}) }))
</script> </script>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -48,22 +48,21 @@ const baTable = new baTableClass(
column: [ column: [
{ type: 'selection', align: 'center', operator: false }, { 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: 70, operator: 'RANGE', sortable: 'custom' },
// { {
// label: t('game.config.channel_id'), label: t('game.config.channel_id'),
// prop: 'channel_id', prop: 'channel_id',
// align: 'center', align: 'center',
// show: false, show: false,
// enableColumnDisplayControl: false, width: 88,
// operatorPlaceholder: t('Fuzzy query'), operator: 'RANGE',
// render: 'tags', sortable: 'custom',
// operator: 'LIKE', },
// comSearchRender: 'string',
// },
{ {
label: t('game.config.channel__name'), label: t('game.config.channel__name'),
prop: 'channel.name', prop: 'channel.name',
align: 'center', align: 'center',
minWidth: 100, minWidth: 100,
sortable: 'true',
operatorPlaceholder: t('Fuzzy query'), operatorPlaceholder: t('Fuzzy query'),
render: 'tags', render: 'tags',
operator: 'LIKE', operator: 'LIKE',
@@ -111,6 +110,7 @@ const baTable = new baTableClass(
label: t('game.config.instantiation'), label: t('game.config.instantiation'),
prop: 'instantiation', prop: 'instantiation',
align: 'center', align: 'center',
custom: { 0: 'error', 1: 'primary' },
operator: 'RANGE', operator: 'RANGE',
sortable: false, sortable: false,
render: 'tag', render: 'tag',
@@ -142,7 +142,7 @@ const baTable = new baTableClass(
{ 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 },
], ],
dblClickNotEditColumn: [undefined, 'instantiation'], dblClickNotEditColumn: [undefined, 'instantiation'],
defaultOrder: { prop: 'group', order: 'desc' }, defaultOrder: { prop: 'channel_id', order: 'desc' },
}, },
{ {
defaultItems: { sort: 100 }, defaultItems: { sort: 100 },

View File

@@ -47,11 +47,11 @@
/> />
<FormItem <FormItem
:label="t('game.config.name')" :label="t('game.config.name')"
type="string" type="select"
v-model="baTable.form.items!.name" v-model="baTable.form.items!.name"
prop="name" prop="name"
:input-attr="{ disabled: metaFieldsDisabled }" :input-attr="{ content: nameSelectContent, disabled: metaFieldsDisabled }"
:placeholder="t('Please input field', { field: t('game.config.name') })" :placeholder="t('Please select field', { field: t('game.config.name') })"
/> />
<FormItem <FormItem
:label="t('game.config.title')" :label="t('game.config.title')"
@@ -79,6 +79,7 @@
class="weight-val" class="weight-val"
:placeholder="t('Please input field', { field: t('game.config.weight value') })" :placeholder="t('Please input field', { field: t('game.config.weight value') })"
clearable clearable
:disabled="isDefaultBigwinWeight && isBigwinDiceLockedKey(row.key)"
@input="onWeightRowChange" @input="onWeightRowChange"
/> />
<el-button v-if="canEditWeightStructure" type="danger" link @click="removeWeightRow(idx)"> <el-button v-if="canEditWeightStructure" type="danger" link @click="removeWeightRow(idx)">
@@ -86,6 +87,7 @@
</el-button> </el-button>
</div> </div>
<el-button v-if="canEditWeightStructure" type="primary" link @click="addWeightRow">{{ t('Add') }}</el-button> <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> </div>
</el-form-item> </el-form-item>
<FormItem <FormItem
@@ -137,6 +139,17 @@ import { useConfig } from '/@/stores/config'
import { useAdminInfo } from '/@/stores/adminInfo' import { useAdminInfo } from '/@/stores/adminInfo'
import type baTableClass from '/@/utils/baTable' import type baTableClass from '/@/utils/baTable'
import { buildValidatorData } from '/@/utils/validate' import { buildValidatorData } from '/@/utils/validate'
import {
fixedRowsFromKeys,
getFixedKeysForGameConfigName,
isBigwinDiceLockedKey,
jsonStringFromFixedKeys,
normalizeGameWeightConfigName,
parseWeightJsonToMap,
rowsToMap,
weightRowsMatchBigwinDiceKeys,
type WeightRow,
} from '/@/utils/gameWeightFixed'
const config = useConfig() const config = useConfig()
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
@@ -169,21 +182,56 @@ const groupSelectContentFiltered = computed(() => {
return groupSelectBase return groupSelectBase
}) })
/** game_weight编辑或非超管时键只读仅超管新增时可增删行、改键 */ /** default_tier_weight / default_bigwin_weight及 default_kill_score_weight键固定仅值可改可增删行 */
const isFixedGameWeightConfig = computed(() => getFixedKeysForGameConfigName(baTable.form.items?.name) !== null)
/** game_weight编辑或非超管时键只读仅超管新增非固定项时可增删行、改键 */
const weightKeyReadonly = computed(() => { const weightKeyReadonly = computed(() => {
if (!isGameWeight.value) return false if (!isGameWeight.value) return false
if (isFixedGameWeightConfig.value) return true
if (baTable.form.operate === 'Edit') return true if (baTable.form.operate === 'Edit') return true
return !isSuperAdmin.value return !isSuperAdmin.value
}) })
const canEditWeightStructure = computed(() => isGameWeight.value && baTable.form.operate === 'Add' && isSuperAdmin.value) const canEditWeightStructure = computed(
() => isGameWeight.value && baTable.form.operate === 'Add' && isSuperAdmin.value && !isFixedGameWeightConfig.value
)
type WeightRow = { key: string; val: string } /** 默认大奖权重:仅校验每项整数与 0100005/30 固定 10000不参与 tier/kill 的「和≤100」 */
const isDefaultBigwinWeight = computed(() => normalizeGameWeightConfigName(baTable.form.items?.name) === 'default_bigwin_weight')
const weightRows = ref<WeightRow[]>([{ key: '', val: '' }]) 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 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') const isGameWeight = computed(() => baTable.form.items?.group === 'game_weight')
function parseValueToWeightRows(raw: unknown): WeightRow[] { function parseValueToWeightRows(raw: unknown): WeightRow[] {
@@ -236,11 +284,29 @@ function weightRowsToJsonString(rows: WeightRow[]): string {
function syncWeightRowsToFormValue() { function syncWeightRowsToFormValue() {
const items = baTable.form.items const items = baTable.form.items
if (!items) return 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) 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() { function onWeightRowChange() {
if (isGameWeight.value) { if (isGameWeight.value) {
enforceDefaultBigwinLockedValues()
syncWeightRowsToFormValue() syncWeightRowsToFormValue()
} }
} }
@@ -263,6 +329,14 @@ function removeWeightRow(idx: number) {
function hydrateWeightRowsFromForm() { function hydrateWeightRowsFromForm() {
if (!isGameWeight.value) return 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) weightRows.value = parseValueToWeightRows(baTable.form.items?.value)
} }
@@ -283,6 +357,27 @@ watch(
watch( watch(
() => baTable.form.items?.group, () => 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') { if (baTable.form.items?.group === 'game_weight') {
hydrateWeightRowsFromForm() hydrateWeightRowsFromForm()
@@ -294,28 +389,78 @@ function validateGameWeightRules(): string | undefined {
if (baTable.form.items?.group !== 'game_weight') { if (baTable.form.items?.group !== 'game_weight') {
return undefined return undefined
} }
const name = baTable.form.items?.name ?? '' const configName = normalizeGameWeightConfigName(baTable.form.items?.name)
const fixedKeys = getFixedKeysForGameConfigName(configName)
const nums: number[] = [] const nums: number[] = []
for (const r of weightRows.value) { if (fixedKeys) {
const k = r.key.trim() const map = rowsToMap(weightRows.value)
if (k === '') continue if (configName === 'default_bigwin_weight') {
const vs = r.val.trim() for (const k of fixedKeys) {
if (vs === '') { const vs = (map[k] ?? '').trim()
return t('Please input field', { field: t('game.config.weight value') }) 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)
}
} }
const n = Number(vs) } else {
if (!Number.isFinite(n)) { // 非固定键名但行结构已是 530 骰子时,按大奖 010000 校验(避免 name 格式异常时误走每项≤10000
return t('game.config.weight value numeric') 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 (n > 100) { if (nums.length === 0) {
return t('game.config.weight each max 100') return t('Please input field', { field: t('game.config.value') })
} }
nums.push(n)
} }
if (nums.length === 0) { if (WEIGHT_SUM100_NAMES.includes(configName)) {
return t('Please input field', { field: t('game.config.value') })
}
if (WEIGHT_SUM100_NAMES.includes(name)) {
let sum = 0 let sum = 0
for (const x of nums) { for (const x of nums) {
sum += x sum += x
@@ -372,4 +517,10 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
flex-shrink: 0; flex-shrink: 0;
color: var(--el-text-color-secondary); 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>

View File

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

View File

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

View File

@@ -23,11 +23,14 @@
import { onMounted, provide, useTemplateRef } from 'vue' import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue' import PopupForm from './popupForm.vue'
import GameUserTicketJsonCell from './GameUserTicketJsonCell.vue'
import GameUserWeightJsonCell from './GameUserWeightJsonCell.vue'
import { baTableApi } from '/@/api/common' import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table' import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue' import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue' import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable' import baTableClass from '/@/utils/baTable'
import { BIGWIN_WEIGHT_KEYS, TIER_WEIGHT_KEYS, jsonStringFromFixedKeys } from '/@/utils/gameWeightFixed'
defineOptions({ defineOptions({
name: 'game/user', name: 'game/user',
@@ -66,6 +69,36 @@ const baTable = new baTableClass(
}, },
{ label: t('game.user.phone'), prop: 'phone', align: 'center', 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.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'), label: t('game.user.status'),
prop: 'status', prop: 'status',
@@ -138,7 +171,12 @@ const baTable = new baTableClass(
dblClickNotEditColumn: [undefined, 'status'], dblClickNotEditColumn: [undefined, 'status'],
}, },
{ {
defaultItems: { status: '1' }, defaultItems: {
status: '1',
tier_weight: jsonStringFromFixedKeys(TIER_WEIGHT_KEYS, {}),
bigwin_weight: jsonStringFromFixedKeys(BIGWIN_WEIGHT_KEYS, {}),
ticket_count: null,
},
} }
) )

View File

@@ -68,6 +68,65 @@
:input-attr="{ step: 1 }" :input-attr="{ step: 1 }"
:placeholder="t('Please input field', { field: t('game.user.coin') })" :placeholder="t('Please input field', { field: t('game.user.coin') })"
/> />
<!-- 档位权重键固定 T1T5仅可改值 -->
<el-form-item :label="t('game.user.tier_weight')" prop="tier_weight">
<div class="weight-value-editor">
<div v-for="(row, idx) in tierWeightRows" :key="'tw-' + idx" class="weight-value-row">
<el-input v-model="row.key" class="weight-key" readonly tabindex="-1" />
<span class="weight-sep">:</span>
<el-input
v-model="row.val"
class="weight-val"
:placeholder="t('Please input field', { field: t('game.user.weight value') })"
clearable
@input="onTierWeightRowChange"
/>
</div>
<div class="form-help">{{ t('game.user.tier_weight_help') }}</div>
</div>
</el-form-item>
<!-- 中大奖权重键固定 530仅可改值 -->
<el-form-item :label="t('game.user.bigwin_weight')" prop="bigwin_weight">
<div class="weight-value-editor">
<div v-for="(row, idx) in bigwinWeightRows" :key="'bw-' + idx" class="weight-value-row">
<el-input v-model="row.key" class="weight-key" readonly tabindex="-1" />
<span class="weight-sep">:</span>
<el-input
v-model="row.val"
class="weight-val"
:placeholder="t('Please input field', { field: t('game.user.weight value') })"
clearable
:disabled="isBigwinValueLocked(row.key)"
@input="onBigwinWeightRowChange"
/>
</div>
<div class="form-help">{{ t('game.user.bigwin_weight_help') }}</div>
</div>
</el-form-item>
<!-- 抽奖券最多一条 JSON 元素 [{"ante":1,"count":1}]不可增行可删除清空 -->
<el-form-item :label="t('game.user.ticket_count')" prop="ticket_count">
<div class="ticket-value-editor">
<div v-for="(row, idx) in ticketRows" :key="'tk-' + idx" class="ticket-value-row">
<span class="ticket-label">{{ t('game.user.ticket_ante') }}</span>
<el-input
v-model="row.ante"
class="ticket-field"
clearable
:placeholder="t('Please input field', { field: t('game.user.ticket_ante') })"
@input="onTicketRowChange"
/>
<span class="ticket-label">{{ t('game.user.ticket_count_times') }}</span>
<el-input
v-model="row.count"
class="ticket-field"
clearable
:placeholder="t('Please input field', { field: t('game.user.ticket_count_times') })"
@input="onTicketRowChange"
/>
<el-button type="danger" link @click="removeTicketRow">{{ t('Delete') }}</el-button>
</div>
</div>
</el-form-item>
<FormItem <FormItem
:label="t('game.user.status')" :label="t('game.user.status')"
type="switch" type="switch"
@@ -77,7 +136,7 @@
/> />
<el-form-item :label="t('game.user.game_channel_id')" prop="admin_id"> <el-form-item :label="t('game.user.game_channel_id')" prop="admin_id">
<el-tree-select <el-tree-select
v-model="baTable.form.items!.admin_id" v-model="adminIdForTree"
class="w100" class="w100"
clearable clearable
filterable filterable
@@ -104,20 +163,33 @@
<script setup lang="ts"> <script setup lang="ts">
import type { FormItemRule } from 'element-plus' import type { FormItemRule } from 'element-plus'
import { inject, onMounted, reactive, ref, useTemplateRef, watch } from 'vue' import { computed, inject, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import FormItem from '/@/components/formItem/index.vue' import FormItem from '/@/components/formItem/index.vue'
import { useAdminInfo } from '/@/stores/adminInfo'
import { useConfig } from '/@/stores/config' import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable' import type baTableClass from '/@/utils/baTable'
import { buildValidatorData, regularPassword } from '/@/utils/validate' import { buildValidatorData, regularPassword } from '/@/utils/validate'
import createAxios from '/@/utils/axios' 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 config = useConfig()
const formRef = useTemplateRef('formRef') const formRef = useTemplateRef('formRef')
const baTable = inject('baTable') as baTableClass const baTable = inject('baTable') as baTableClass
const adminInfo = useAdminInfo()
const { t } = useI18n() const { t } = useI18n()
const isSuperAdmin = computed(() => adminInfo.super === true)
type TreeNode = { type TreeNode = {
value: string value: string
label: string label: string
@@ -137,6 +209,26 @@ const treeProps = {
disabled: 'disabled', 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 loadChannelAdminTree = async () => {
const res = await createAxios({ const res = await createAxios({
url: '/admin/game.Channel/adminTree', url: '/admin/game.Channel/adminTree',
@@ -163,7 +255,7 @@ const onAdminTreeChange = (val: string | number | null) => {
if (val === null || val === undefined || val === '') { if (val === null || val === undefined || val === '') {
return return
} }
const key = typeof val === 'number' ? String(val) : val const key = typeof val === 'number' ? String(val) : String(val)
const channelId = adminIdToChannelId.value[key] const channelId = adminIdToChannelId.value[key]
if (channelId !== undefined) { if (channelId !== undefined) {
baTable.form.items!.game_channel_id = channelId baTable.form.items!.game_channel_id = channelId
@@ -174,14 +266,277 @@ onMounted(() => {
loadChannelAdminTree() 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( watch(
() => baTable.form.items?.admin_id, () => baTable.form.items?.admin_id,
(val) => { (val) => {
if (val === undefined || val === null || val === '') return if (val === undefined || val === null || val === '') return
onAdminTreeChange(val as any) 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 validatorGameUserPassword = (rule: any, val: string, callback: (error?: Error) => void) => {
const operate = baTable.form.operate const operate = baTable.form.operate
const v = typeof val === 'string' ? val.trim() : '' const v = typeof val === 'string' ? val.trim() : ''
@@ -204,10 +559,96 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
password: [{ validator: validatorGameUserPassword, trigger: 'blur' }], password: [{ validator: validatorGameUserPassword, trigger: 'blur' }],
phone: [buildValidatorData({ name: 'required', title: t('game.user.phone') })], phone: [buildValidatorData({ name: 'required', title: t('game.user.phone') })],
coin: [buildValidatorData({ name: 'number', title: t('game.user.coin') })], 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') })], admin_id: [buildValidatorData({ name: 'required', title: t('game.user.admin_id') })],
create_time: [buildValidatorData({ name: 'date', title: t('game.user.create_time') })], create_time: [buildValidatorData({ name: 'date', title: t('game.user.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('game.user.update_time') })], update_time: [buildValidatorData({ name: 'date', title: t('game.user.update_time') })],
}) })
</script> </script>
<style scoped lang="scss"></style> <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>