diff --git a/app/admin/controller/auth/Admin.php b/app/admin/controller/auth/Admin.php index 8ab0762..d6a494c 100644 --- a/app/admin/controller/auth/Admin.php +++ b/app/admin/controller/auth/Admin.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\admin\controller\auth; +use ba\Random; use Throwable; use support\think\Db; use support\validation\Validator; @@ -21,7 +22,7 @@ class Admin extends Backend protected array|string $quickSearchField = ['username', 'nickname']; - protected string|int|bool $dataLimit = 'allAuthAndOthers'; + protected string|int|bool $dataLimit = 'parent'; protected string $dataLimitField = 'id'; @@ -61,9 +62,18 @@ class Admin extends Backend $res = $query ->order($order) ->paginate($limit); + $items = $res->items(); + $topGroupUids = $this->getTopGroupUserMap(array_column($items, 'id')); + foreach ($items as &$item) { + $id = $item['id'] ?? null; + if ($id === 1 || isset($topGroupUids[$id])) { + $item['commission_rate'] = null; + } + } + unset($item); return $this->success('', [ - 'list' => $res->items(), + 'list' => $items, 'total' => $res->total(), 'remark' => get_route_remark(), ]); @@ -149,6 +159,10 @@ class Admin extends Backend if (!$data) { return $this->error(__('Parameter %s can not be empty', [''])); } + $data = $this->normalizeSingleGroup($data); + if (!$this->hasSingleGroup($data['group_arr'] ?? null)) { + return $this->error('请选择且仅选择一个角色组'); + } if ($this->modelValidate) { try { @@ -172,6 +186,25 @@ class Admin extends Backend $passwd = $data['password'] ?? ''; $data = $this->excludeFields($data); + $creatorChannelId = $this->getCreatorChannelId(); + if (!$this->auth->isSuperAdmin()) { + if ($creatorChannelId === null || $creatorChannelId === '') { + return $this->error(__('You have no permission')); + } + $data['channel_id'] = $creatorChannelId; + $data['parent_admin_id'] = $this->auth->id; + } + $data['invite_code'] = $this->generateUniqueInviteCode(); + $requireCommissionRate = $this->requireCommissionRate($data['group_arr'] ?? []); + if ($requireCommissionRate) { + if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) { + return $this->error(__('Please enter a valid commission rate for non-top role group')); + } + $commissionRes = $this->validateAdminCommissionByGroups($data['group_arr'] ?? [], floatval((string)$data['commission_rate'])); + if ($commissionRes !== null) return $commissionRes; + } else { + $data['commission_rate'] = null; + } $result = false; if (!empty($data['group_arr'])) { $authRes = $this->checkGroupAuth($data['group_arr']); @@ -230,6 +263,37 @@ class Admin extends Backend if (!$data) { return $this->error(__('Parameter %s can not be empty', [''])); } + $data = $this->normalizeSingleGroup($data); + if (!$this->hasSingleGroup($data['group_arr'] ?? null)) { + return $this->error('请选择且仅选择一个角色组'); + } + + // 未提交分红比例时,若角色组未变更则沿用数据库原值(避免表单单项 number 校验把空串判错) + $postedGroups = array_map('intval', $data['group_arr'] ?? []); + $rowGroups = array_map('intval', $row->group_arr ?? []); + sort($postedGroups); + sort($rowGroups); + $sameGroups = $postedGroups === $rowGroups; + $postedCommission = $data['commission_rate'] ?? null; + if (($postedCommission === null || $postedCommission === '') && $sameGroups && $this->isValidCommissionRate($row['commission_rate'] ?? null)) { + $data['commission_rate'] = $row['commission_rate']; + } + + // 当前管理员编辑自身时,不允许修改角色组和分红比 + if ((int)$this->auth->id === (int)$id) { + $postedGroups = $data['group_arr'] ?? []; + if (!is_array($postedGroups)) { + $postedGroups = []; + } + $originGroups = $row->group_arr ?? []; + sort($postedGroups); + sort($originGroups); + $postedRate = $data['commission_rate'] ?? null; + $originRate = $row['commission_rate'] ?? null; + if ($postedGroups !== $originGroups || (string)$postedRate !== (string)$originRate) { + return $this->error(__('You cannot modify your own management group!')); + } + } if ($this->modelValidate) { try { @@ -276,15 +340,29 @@ class Admin extends Backend if ($authRes !== null) return $authRes; } - Db::name('admin_group_access') - ->where('uid', $id) - ->delete(); - $data = $this->excludeFields($data); + unset($data['invite_code']); + $creatorChannelId = $this->getCreatorChannelId(); + if (!$this->auth->isSuperAdmin() && $creatorChannelId !== null && $creatorChannelId !== '') { + $data['channel_id'] = $creatorChannelId; + } + $requireCommissionRate = $this->requireCommissionRate($data['group_arr'] ?? []); + if ($requireCommissionRate) { + if (!$this->isValidCommissionRate($data['commission_rate'] ?? null)) { + return $this->error(__('Please enter a valid commission rate for non-top role group')); + } + $commissionRes = $this->validateAdminCommissionByGroups($data['group_arr'] ?? [], floatval((string)$data['commission_rate']), intval((string)$id)); + if ($commissionRes !== null) return $commissionRes; + } else { + $data['commission_rate'] = null; + } $result = false; $this->model->startTrans(); try { $result = $row->save($data); + Db::name('admin_group_access') + ->where('uid', $id) + ->delete(); if ($groupAccess) { Db::name('admin_group_access')->insertAll($groupAccess); } @@ -357,7 +435,7 @@ class Admin extends Backend if ($this->auth->isSuperAdmin()) { return null; } - $authGroups = $this->auth->getAllAuthGroups('allAuthAndOthers'); + $authGroups = $this->getManageableGroupIds(); foreach ($groups as $group) { if (!in_array($group, $authGroups)) { return $this->error(__('You have no permission to add an administrator to this group!')); @@ -365,4 +443,132 @@ class Admin extends Backend } return null; } + + private function getManageableGroupIds(): array + { + if ($this->auth->isSuperAdmin()) { + return Db::name('admin_group')->where('status', 1)->column('id'); + } + $own = Db::name('admin_group_access')->where('uid', $this->auth->id)->column('group_id'); + $children = $this->auth->getAdminChildGroups(); + return array_values(array_unique(array_merge($own, $children))); + } + + private function getCreatorChannelId(): mixed + { + $currentAdmin = Db::name('admin') + ->field(['id', 'channel_id']) + ->where('id', $this->auth->id) + ->find(); + if ($currentAdmin && !empty($currentAdmin['channel_id'])) { + return $currentAdmin['channel_id']; + } + $channelId = Db::name('channel') + ->where('top_admin_id', $this->auth->id) + ->value('id'); + return $channelId ?: null; + } + + private function generateUniqueInviteCode(): string + { + $tries = 0; + do { + $tries++; + $code = strtoupper(substr(Random::uuid(), 0, 8)); + $exists = Db::name('admin')->where('invite_code', $code)->find(); + } while ($exists && $tries < 20); + return $code; + } + + private function requireCommissionRate(array $groupIds): bool + { + if (!$groupIds) { + return false; + } + $count = Db::name('admin_group') + ->where('id', 'in', $groupIds) + ->where('pid', '<>', 0) + ->count(); + return $count > 0; + } + + private function isValidCommissionRate(mixed $value): bool + { + if ($value === null || $value === '') { + return false; + } + $rate = trim((string)$value); + if (!preg_match('/^(100(\.00?)?|[0-9]{1,2}(\.[0-9]{1,2})?)$/', $rate)) { + return false; + } + return true; + } + + private function validateAdminCommissionByGroups(array $groupIds, float $currentRate, ?int $excludeAdminId = null): ?Response + { + if (!$groupIds) { + return null; + } + $groups = Db::name('admin_group') + ->where('id', 'in', $groupIds) + ->where('pid', '<>', 0) + ->column('name', 'id'); + foreach ($groups as $groupId => $groupName) { + $query = Db::name('admin_group_access')->alias('aga') + ->join('admin a', 'aga.uid = a.id') + ->where('aga.group_id', intval((string)$groupId)); + if ($excludeAdminId !== null) { + $query = $query->where('a.id', '<>', $excludeAdminId); + } + $sum = (float)$query->sum('a.commission_rate'); + $remaining = 100 - $sum; + if ($currentRate > $remaining + 0.000001) { + $exceed = $currentRate - $remaining; + return $this->error(sprintf('角色组[%s]分红比例总和不能超过100%%,当前剩余 %.2f%%,本次超出 %.2f%%', $groupName, max(0, $remaining), $exceed)); + } + } + return null; + } + + private function getTopGroupUserMap(array $userIds): array + { + if (!$userIds) { + return []; + } + $uids = Db::name('admin_group_access')->alias('aga') + ->join('admin_group ag', 'aga.group_id = ag.id') + ->where('aga.uid', 'in', $userIds) + ->where('ag.pid', 0) + ->column('aga.uid'); + $map = []; + foreach ($uids as $uid) { + $map[$uid] = true; + } + return $map; + } + + private function normalizeSingleGroup(array $data): array + { + if (!array_key_exists('group_arr', $data)) { + return $data; + } + $group = $data['group_arr']; + if (is_array($group)) { + $data['group_arr'] = array_values(array_filter($group, static function ($item) { + return $item !== null && $item !== ''; + })); + return $data; + } + if ($group === null || $group === '') { + $data['group_arr'] = []; + return $data; + } + $data['group_arr'] = [$group]; + return $data; + } + + private function hasSingleGroup(mixed $groups): bool + { + return is_array($groups) && count($groups) === 1; + } } diff --git a/app/admin/controller/game/User.php b/app/admin/controller/game/User.php index 736fe11..7c94700 100644 --- a/app/admin/controller/game/User.php +++ b/app/admin/controller/game/User.php @@ -21,7 +21,7 @@ class User extends Backend protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time']; - protected array $withJoinTable = ['gameChannel', 'admin']; + protected array $withJoinTable = ['channel', 'admin']; protected string|array $quickSearchField = ['id', 'username', 'phone']; @@ -53,7 +53,7 @@ class User extends Backend $data['password'] = hash_password($password); $username = $data['username'] ?? ''; - $channelId = $data['channel_id'] ?? ($data['game_channel_id'] ?? null); + $channelId = $data['channel_id'] ?? null; if (!is_string($username) || trim($username) === '' || $channelId === null || $channelId === '') { return $this->error(__('Parameter %s can not be empty', ['username/channel_id'])); } @@ -131,10 +131,8 @@ class User extends Backend $nextChannelId = null; if (array_key_exists('channel_id', $data)) { $nextChannelId = $data['channel_id']; - } elseif (array_key_exists('game_channel_id', $data)) { - $nextChannelId = $data['game_channel_id']; } else { - $nextChannelId = $row['channel_id'] ?? $row['game_channel_id'] ?? null; + $nextChannelId = $row['channel_id'] ?? null; } if (is_string($nextUsername) && trim($nextUsername) !== '' && $nextChannelId !== null && $nextChannelId !== '') { @@ -194,7 +192,7 @@ class User extends Backend $res = $this->model ->withJoin($this->withJoinTable, $this->withJoinType) ->with($this->withJoinTable) - ->visible(['gameChannel' => ['name'], 'admin' => ['username']]) + ->visible(['channel' => ['name'], 'admin' => ['username']]) ->alias($alias) ->where($where) ->order($order) diff --git a/app/common/model/GameUser.php b/app/common/model/GameUser.php index 6345d64..40f363a 100644 --- a/app/common/model/GameUser.php +++ b/app/common/model/GameUser.php @@ -27,9 +27,14 @@ class GameUser extends Model return is_null($value) ? null : (float)$value; } + public function channel(): \think\model\relation\BelongsTo + { + return $this->belongsTo(\app\common\model\Channel::class, 'channel_id', 'id'); + } + public function gameChannel(): \think\model\relation\BelongsTo { - return $this->belongsTo(\app\common\model\GameChannel::class, 'game_channel_id', 'id'); + return $this->channel(); } public function admin(): \think\model\relation\BelongsTo diff --git a/web/src/lang/backend/en/auth/admin.ts b/web/src/lang/backend/en/auth/admin.ts index 285962a..d2e08a1 100644 --- a/web/src/lang/backend/en/auth/admin.ts +++ b/web/src/lang/backend/en/auth/admin.ts @@ -5,6 +5,13 @@ export default { avatar: 'Avatar', email: 'Email', mobile: 'Mobile Number', + invite_code: 'Invite code', + commission_rate: 'Commission rate(%)', + commission_rate_desc_title: 'Admin commission notes', + commission_rate_desc_1: 'Admin commission means this admin allocation ratio inside assigned group.', + commission_rate_desc_2: 'Current admin commission = current group commission × current admin commission rate.', + commission_rate_desc_3: 'Within same group, total admin commission rate cannot exceed 100%; exceed and remaining are returned on validation.', + 'Please select exactly one group': 'Please select exactly one group', 'Last login': 'Last login', Password: 'Password', 'Please leave blank if not modified': 'Please leave blank if you do not modify.', diff --git a/web/src/lang/backend/en/game/user.ts b/web/src/lang/backend/en/game/user.ts index 7a211d5..0321200 100644 --- a/web/src/lang/backend/en/game/user.ts +++ b/web/src/lang/backend/en/game/user.ts @@ -9,8 +9,8 @@ export default { status: 'status', 'status 0': 'status 0', 'status 1': 'status 1', - game_channel_id: 'game_channel_id', - gamechannel__name: 'name', + channel_id: 'channel_id', + channel__name: 'name', admin_id: 'admin_id', admin__username: 'username', create_time: 'create_time', diff --git a/web/src/lang/backend/zh-cn/auth/admin.ts b/web/src/lang/backend/zh-cn/auth/admin.ts index e5c671f..52d261c 100644 --- a/web/src/lang/backend/zh-cn/auth/admin.ts +++ b/web/src/lang/backend/zh-cn/auth/admin.ts @@ -5,6 +5,13 @@ export default { avatar: '头像', email: '电子邮箱', mobile: '手机号', + invite_code: '邀请码', + commission_rate: '分红比(%)', + commission_rate_desc_title: '管理员分红说明', + commission_rate_desc_1: '管理员分红用于该管理员在所属角色组内的分配比例。', + commission_rate_desc_2: '当前管理员分红=当前角色分红×当前管理员分红比例。', + commission_rate_desc_3: '同一角色组内,管理员分红比例总和不能超过100%;超额会提示超出值与剩余额度。', + 'Please select exactly one group': '请选择且仅选择一个角色组', 'Last login': '最后登录', Password: '密码', 'Please leave blank if not modified': '不修改请留空', diff --git a/web/src/lang/backend/zh-cn/game/user.ts b/web/src/lang/backend/zh-cn/game/user.ts index 5d876ea..7ae6471 100644 --- a/web/src/lang/backend/zh-cn/game/user.ts +++ b/web/src/lang/backend/zh-cn/game/user.ts @@ -9,8 +9,8 @@ export default { status: '状态', 'status 0': '禁用', 'status 1': '启用', - game_channel_id: '所属渠道', - gamechannel__name: '渠道名', + channel_id: '所属渠道', + channel__name: '渠道名', admin_id: '所属管理员', admin__username: '用户名', create_time: '创建时间', diff --git a/web/src/views/backend/auth/admin/index.vue b/web/src/views/backend/auth/admin/index.vue index 295f068..4cf90e5 100644 --- a/web/src/views/backend/auth/admin/index.vue +++ b/web/src/views/backend/auth/admin/index.vue @@ -40,6 +40,13 @@ optButtons[1].display = (row) => { return row.id != adminInfo.id } +const formatRatePercent = (_row: any, _column: any, cellValue: number | string | null) => { + if (cellValue === null || cellValue === undefined || cellValue === '') return '--' + const num = Number(cellValue) + if (Number.isNaN(num)) return '--' + return `${num.toFixed(2)}%` +} + const baTable = new baTableClass( new baTableApi('/admin/auth.Admin/'), { @@ -48,7 +55,23 @@ const baTable = new baTableClass( { label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 }, { label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, { label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, - { label: t('auth.admin.group'), prop: 'group_name_arr', align: 'center', operator: false, render: 'tags' }, + { + label: t('auth.admin.group'), + prop: 'group_name_arr', + align: 'center', + minWidth: 150, + operator: false, + render: 'tags', + }, + { label: t('auth.admin.invite_code'), prop: 'invite_code', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, + { + label: t('auth.admin.commission_rate'), + prop: 'commission_rate', + align: 'center', + minWidth: 90, + operator: 'RANGE', + formatter: formatRatePercent, + }, { label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false }, { label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, { label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') }, diff --git a/web/src/views/backend/auth/admin/popupForm.vue b/web/src/views/backend/auth/admin/popupForm.vue index cf3e43c..46c52e6 100644 --- a/web/src/views/backend/auth/admin/popupForm.vue +++ b/web/src/views/backend/auth/admin/popupForm.vue @@ -43,18 +43,43 @@ /> + + + + +