diff --git a/app/admin/controller/auth/Admin.php b/app/admin/controller/auth/Admin.php index d5aa039..48342ea 100644 --- a/app/admin/controller/auth/Admin.php +++ b/app/admin/controller/auth/Admin.php @@ -253,28 +253,20 @@ 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(__('Please select exactly one role group')); + $isSelfEdit = (int) $this->auth->id === (int) $id; + if ($isSelfEdit) { + unset($data['group_arr'], $data['group_name_arr']); } - $postedGroups = array_map('intval', $data['group_arr'] ?? []); - $rowGroups = array_map('intval', $row->group_arr ?? []); - sort($postedGroups); - sort($rowGroups); - - // 当前管理员编辑自身时,不允许修改角色组 - if ((int)$this->auth->id === (int)$id) { - $postedGroups = $data['group_arr'] ?? []; - if (!is_array($postedGroups)) { - $postedGroups = []; - } - $originGroups = $row->group_arr ?? []; - sort($postedGroups); - sort($originGroups); - if ($postedGroups !== $originGroups) { - return $this->error(__('You cannot modify your own management group!')); + $editGroupArr = null; + if (array_key_exists('group_arr', $data)) { + $data = $this->normalizeSingleGroup($data); + if (!$this->hasSingleGroup($data['group_arr'] ?? null)) { + return $this->error(__('Please select exactly one role group')); } + $editGroupArr = $data['group_arr']; + } elseif (!$isSelfEdit) { + return $this->error(__('Please select exactly one role group')); } if ($this->modelValidate) { @@ -285,8 +277,10 @@ class Admin extends Backend 'password' => 'nullable|string|regex:/^(?!.*[&<>"\'\n\r]).{6,32}$/', 'email' => 'email|unique:admin,email,' . $id, 'mobile' => 'regex:/^1[3-9]\d{9}$/|unique:admin,mobile,' . $id, - 'group_arr' => 'required|array', ]; + if (array_key_exists('group_arr', $data)) { + $rules['group_arr'] = 'required|array'; + } $messages = [ 'username.regex' => __('Please input correct username'), 'password.regex' => __('Please input correct password'), @@ -306,10 +300,10 @@ class Admin extends Backend } $groupAccess = []; - if (!empty($data['group_arr'])) { + if (!$isSelfEdit && !empty($editGroupArr)) { $checkGroups = []; $rowGroupArr = $row->group_arr ?? []; - foreach ($data['group_arr'] as $datum) { + foreach ($editGroupArr as $datum) { if (!in_array($datum, $rowGroupArr)) { $checkGroups[] = $datum; } @@ -323,32 +317,36 @@ class Admin extends Backend } $data = $this->excludeFields($data); - unset($data['invite_code']); - $creatorChannelId = $this->getCreatorChannelId(); - $groupChannelId = $this->resolveChannelIdFromPrimaryGroup($data['group_arr'] ?? []); - if (!$this->auth->isSuperAdmin()) { - if ($creatorChannelId === null || $creatorChannelId === '') { - return $this->error(__('You have no permission')); + unset($data['invite_code'], $data['group_arr'], $data['group_name_arr']); + if (!$isSelfEdit && $editGroupArr !== null) { + $creatorChannelId = $this->getCreatorChannelId(); + $groupChannelId = $this->resolveChannelIdFromPrimaryGroup($editGroupArr); + if (!$this->auth->isSuperAdmin()) { + if ($creatorChannelId === null || $creatorChannelId === '') { + return $this->error(__('You have no permission')); + } + if ($groupChannelId === null || $groupChannelId === '') { + return $this->error(__('Selected role group is not bound to a channel')); + } + if ((string) $groupChannelId !== (string) $creatorChannelId) { + return $this->error(__('Selected role group channel does not match current account')); + } + $data['channel_id'] = $creatorChannelId; + } else { + $data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId; } - if ($groupChannelId === null || $groupChannelId === '') { - return $this->error(__('Selected role group is not bound to a channel')); - } - if ((string) $groupChannelId !== (string) $creatorChannelId) { - return $this->error(__('Selected role group channel does not match current account')); - } - $data['channel_id'] = $creatorChannelId; - } else { - $data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId; } $result = false; $this->model->startTrans(); try { $result = $row->save($data); - Db::name('admin_group_access') - ->where('uid', $id) - ->delete(); - if ($groupAccess) { - Db::name('admin_group_access')->insertAll($groupAccess); + if (!$isSelfEdit) { + Db::name('admin_group_access') + ->where('uid', $id) + ->delete(); + if ($groupAccess) { + Db::name('admin_group_access')->insertAll($groupAccess); + } } $this->model->commit(); } catch (Throwable $e) { diff --git a/app/admin/controller/auth/Group.php b/app/admin/controller/auth/Group.php index 11cf600..1e7f023 100644 --- a/app/admin/controller/auth/Group.php +++ b/app/admin/controller/auth/Group.php @@ -19,6 +19,11 @@ class Group extends Backend { protected string $authMethod = 'allAuthAndOthers'; + /** + * 角色组表单分配权限树(仅需登录 + 具备角色组管理相关权限,不依赖菜单规则管理权限) + */ + protected array $noNeedPermission = ['rules']; + protected ?object $model = null; protected string|array $preExcludeFields = ['create_time', 'update_time']; @@ -90,7 +95,7 @@ class Group extends Backend if ($inheritRes !== null) { return $inheritRes; } - $rulesRes = $this->handleRules($data); + $rulesRes = $this->handleRules($data, $pidInt); if ($rulesRes instanceof Response) return $rulesRes; $result = false; @@ -161,7 +166,7 @@ class Group extends Backend if ($inheritRes !== null) { return $inheritRes; } - $rulesRes = $this->handleRules($data); + $rulesRes = $this->handleRules($data, $pidInt); if ($rulesRes instanceof Response) return $rulesRes; $result = false; @@ -296,9 +301,29 @@ class Group extends Backend } /** + * 当前登录管理员可分配给下级角色组的菜单权限树(与 Rule::getMenus 一致,走角色组管理权限) + */ + public function rules(Request $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + if (!$this->auth->isSuperAdmin() && !$this->canManageRoleGroups()) { + return $this->error(__('You have no permission'), [], 401); + } + + return $this->success('', [ + 'list' => $this->getAssignableMenuRules($request), + ]); + } + + /** + * @param int $pidInt 上级角色组 ID;大于 0 表示创建/编辑的是下级组,可与当前管理员拥有相同菜单权限 * @return array|Response */ - private function handleRules(array &$data) + private function handleRules(array &$data, int $pidInt = 0) { if (!empty($data['rules']) && is_array($data['rules'])) { $superAdmin = true; @@ -310,7 +335,6 @@ class Group extends Backend $checkedRules[] = $postRuleId; } } - foreach ($allRuleIds as $ruleId) { if (!in_array($ruleId, $checkedRules)) { $superAdmin = false; @@ -320,9 +344,15 @@ class Group extends Backend if ($superAdmin && $this->auth->isSuperAdmin()) { $data['rules'] = '*'; } else { - $ownedRuleIds = $this->auth->getRuleIds(); + $ownedRuleIds = $this->normalizeRuleIds($this->auth->getRuleIds()); + $checkedRules = $this->normalizeRuleIds($checkedRules); - if (!array_diff($ownedRuleIds, $checkedRules)) { + // 仅限制「非下级」角色组:防止子管理员新建与自己平级的全权限组;下级组(pid>0)允许授予相同菜单权限 + if ( + $pidInt <= 0 + && $ownedRuleIds !== [] + && !array_diff($ownedRuleIds, $checkedRules) + ) { return $this->error(__('Role group has all your rights, please contact the upper administrator to add or do not need to add!')); } @@ -338,6 +368,23 @@ class Group extends Backend return $data; } + /** + * @param array $ids + * @return array + */ + private function normalizeRuleIds(array $ids): array + { + $result = []; + foreach ($ids as $id) { + if ($id === '*' || $id === '' || $id === null) { + continue; + } + $result[] = (int) $id; + } + + return array_values(array_unique($result)); + } + private function getGroups(Request $request, array $where = []): array { $pk = $this->model->getPk(); @@ -500,4 +547,82 @@ class Group extends Backend return null; } + private function canManageRoleGroups(): bool + { + foreach (['auth/group/index', 'auth/group/add', 'auth/group/edit', 'auth/Group/index', 'auth/Group/add', 'auth/Group/edit'] as $routePath) { + if ($this->auth->check($routePath)) { + return true; + } + } + + return false; + } + + /** + * @return array> + */ + private function getAssignableMenuRules(Request $request): array + { + $ids = $this->auth->getRuleIds(); + $where = []; + if (!in_array('*', $ids, true)) { + $where[] = ['id', 'in', $ids ?: [0]]; + } + + $rules = (new AdminRule()) + ->where($where) + ->order(['weigh' => 'desc']) + ->select() + ->toArray(); + + $toEnglish = !$this->shouldForceMenuTitleZh($request) && $this->shouldTranslateMenuToEnglish(); + foreach ($rules as $idx => $rule) { + $title = $rule['title'] ?? ''; + if (is_string($title) && $title !== '') { + $rules[$idx]['title'] = $toEnglish ? $this->menuTitleToEn($title) : $this->menuTitleToZh($title); + } + } + + return $this->tree->assembleChild($rules); + } + + private function shouldTranslateMenuToEnglish(): bool + { + $lang = function_exists('locale') ? locale() : ''; + $normalized = is_string($lang) ? strtolower(str_replace('_', '-', trim($lang))) : ''; + + return str_starts_with($normalized, 'en'); + } + + private function shouldForceMenuTitleZh(Request $request): bool + { + $flag = $request->get('force_menu_zh') ?? $request->post('force_menu_zh'); + + return in_array($flag, [1, '1', true, 'true', 'yes', 'on'], true); + } + + private function menuTitleToZh(string $title): string + { + static $zhMap = null; + if (!is_array($zhMap)) { + $mapFile = app_path() . '/common/lang/zh-cn/admin_rule_title.php'; + $loaded = is_file($mapFile) ? include $mapFile : []; + $zhMap = is_array($loaded) ? $loaded : []; + } + + return isset($zhMap[$title]) && is_string($zhMap[$title]) ? $zhMap[$title] : $title; + } + + private function menuTitleToEn(string $title): string + { + static $enMap = null; + if (!is_array($enMap)) { + $mapFile = app_path() . '/common/lang/en/admin_rule_title.php'; + $loaded = is_file($mapFile) ? include $mapFile : []; + $enMap = is_array($loaded) ? $loaded : []; + } + + return isset($enMap[$title]) && is_string($enMap[$title]) ? $enMap[$title] : $title; + } + } diff --git a/config/route.php b/config/route.php index b880d4a..04db670 100644 --- a/config/route.php +++ b/config/route.php @@ -208,6 +208,7 @@ Route::post('/admin/auth/group/add', [\app\admin\controller\auth\Group::class, ' Route::post('/admin/auth/group/edit', [\app\admin\controller\auth\Group::class, 'edit']); Route::post('/admin/auth/group/del', [\app\admin\controller\auth\Group::class, 'del']); Route::get('/admin/auth/group/select', [\app\admin\controller\auth\Group::class, 'select']); +Route::get('/admin/auth/group/rules', [\app\admin\controller\auth\Group::class, 'rules']); // admin/auth/rule Route::get('/admin/auth/rule/index', [\app\admin\controller\auth\Rule::class, 'index']); diff --git a/web/src/api/backend/auth/group.ts b/web/src/api/backend/auth/group.ts index ed42107..8d57150 100644 --- a/web/src/api/backend/auth/group.ts +++ b/web/src/api/backend/auth/group.ts @@ -2,7 +2,7 @@ import createAxios from '/@/utils/axios' export function getAdminRules() { return createAxios({ - url: '/admin/auth.Rule/index', + url: '/admin/auth.Group/rules', method: 'get', params: { force_menu_zh: 1, diff --git a/web/src/views/backend/auth/admin/index.vue b/web/src/views/backend/auth/admin/index.vue index 8b6c30c..cfa0217 100644 --- a/web/src/views/backend/auth/admin/index.vue +++ b/web/src/views/backend/auth/admin/index.vue @@ -104,6 +104,14 @@ const baTable = new baTableClass( } ) +// 编辑自身时不提交角色组,避免与后端「不可修改自己所在管理组」校验冲突 +baTable.before.onSubmit = ({ operate, items }) => { + if (operate === 'edit' && items.id == adminInfo.id) { + delete items.group_arr + delete items.group_name_arr + } +} + provide('baTable', baTable) baTable.mount() diff --git a/web/src/views/backend/auth/admin/popupForm.vue b/web/src/views/backend/auth/admin/popupForm.vue index b7405b7..81529fa 100644 --- a/web/src/views/backend/auth/admin/popupForm.vue +++ b/web/src/views/backend/auth/admin/popupForm.vue @@ -162,6 +162,9 @@ const rules: Partial> = reactive({ { required: true, validator: (_rule: any, val: unknown, callback: Function) => { + if (baTable.form.operate === 'Edit' && adminInfo.id == baTable.form.items?.id) { + return callback() + } if (Array.isArray(val)) { if (val.length !== 1) { return callback(new Error(t('auth.admin.Please select exactly one group'))) diff --git a/web/src/views/backend/auth/group/index.vue b/web/src/views/backend/auth/group/index.vue index 8939e4f..137ce79 100644 --- a/web/src/views/backend/auth/group/index.vue +++ b/web/src/views/backend/auth/group/index.vue @@ -182,9 +182,17 @@ const menuRuleTreeUpdate = () => { if (baTable.form.items!.rules && baTable.form.items!.rules.length) { if (baTable.form.items!.rules.includes('*')) { let arr: number[] = [] - for (const key in baTable.form.extend!.menuRules) { - arr.push(baTable.form.extend!.menuRules[key].id) + const walk = (nodes: anyObj[]) => { + nodes.forEach((node) => { + if (node.id) { + arr.push(node.id) + } + if (node.children?.length) { + walk(node.children) + } + }) } + walk(res.data.list || []) baTable.form.extend!.defaultCheckedKeys = arr } else { baTable.form.extend!.defaultCheckedKeys = baTable.form.items!.rules