From 5697d9e02c8c0bed6dd3ede7ec80ecce3602e1e9 Mon Sep 17 00:00:00 2001 From: H0nGzA1 <2505811377@qq.com> Date: Thu, 9 Apr 2026 19:47:41 +0800 Subject: [PATCH] feat(permission): add auto-scan API & grouped role permission UI - Menu page: auto-scan ViewSet APIs for a selected menu, preview & batch create MenuButton records with inline name/value editing, grouped table - Role page: group button permissions by Model > CRUD intent (Read/Write/ Delete/Other) with expand/collapse, indeterminate checkboxes - Backend: new batch_set_role_menu_btn action + value field in serializer - Frontend: 300ms debounced batch updates to avoid DB lock on rapid toggles - i18n: add scan.* keys and role intent labels for zh-cn/en/zh-tw --- .../views/role_menu_button_permission.py | 46 +- docs/auto-permission-scan.md | 141 ++++++ web/src/i18n/pages/role/en.ts | 6 + web/src/i18n/pages/role/zh-cn.ts | 6 + web/src/i18n/pages/role/zh-tw.ts | 97 ++++ .../menu/components/ScanModal/index.vue | 22 +- .../system/role/components/RoleMenuBtn.vue | 471 +++++++++++++----- web/src/views/system/role/components/api.ts | 12 + web/src/views/system/role/types.ts | 2 + 9 files changed, 689 insertions(+), 114 deletions(-) create mode 100644 docs/auto-permission-scan.md diff --git a/backend/dvadmin/system/views/role_menu_button_permission.py b/backend/dvadmin/system/views/role_menu_button_permission.py index c041887..e3d1cb8 100644 --- a/backend/dvadmin/system/views/role_menu_button_permission.py +++ b/backend/dvadmin/system/views/role_menu_button_permission.py @@ -106,7 +106,7 @@ class RoleMenuButtonSerializer(CustomModelSerializer): class Meta: model = MenuButton - fields = ['id', 'menu', 'name', 'isCheck', 'data_range', 'role_menu_btn_perm_id', 'dept'] + fields = ['id', 'menu', 'name', 'name_en', 'name_zh_tw', 'value', 'isCheck', 'data_range', 'role_menu_btn_perm_id', 'dept'] class RoleMenuFieldSerializer(CustomModelSerializer): @@ -222,6 +222,50 @@ class RoleMenuButtonPermissionViewSet(CustomModelViewSet): return DetailResponse(data=[], msg="更新成功") + @action(methods=['PUT'], detail=False, permission_classes=[IsAuthenticated]) + def batch_set_role_menu_btn(self, request): + """ + 批量设置 角色-菜单-按钮 + 请求体: {"buttons": [{"btnId": 1, "isCheck": true, "data_range": 0, "dept": []}, ...]} + """ + data = request.data + buttons = data.get('buttons', []) + if not buttons: + return DetailResponse(data={}, msg="buttons 不能为空", code=4000) + + role_id = data.get('roleId') + menu_id = data.get('menuId') + + # 分离需要创建和需要删除的 + to_create = [b for b in buttons if b.get('isCheck')] + to_delete_ids = [b['btnId'] for b in buttons if not b.get('isCheck')] + + # 批量删除 + if to_delete_ids: + RoleMenuButtonPermission.objects.filter( + role_id=role_id, menu_button_id__in=to_delete_ids + ).delete() + + # 批量创建(跳过已存在的) + if to_create: + existing = set( + RoleMenuButtonPermission.objects.filter(role_id=role_id) + .values_list('menu_button_id', flat=True) + ) + to_create_filtered = [b for b in to_create if b['btnId'] not in existing] + if to_create_filtered: + instances = [ + RoleMenuButtonPermission( + role_id=role_id, + menu_button_id=b['btnId'], + data_range=b.get('data_range') or 0, + ) + for b in to_create_filtered + ] + RoleMenuButtonPermission.objects.bulk_create(instances, ignore_conflicts=True) + + return DetailResponse(data={'updated': len(buttons)}, msg=f"更新成功 {len(buttons)} 项") + @action(methods=['PUT'], detail=False, permission_classes=[IsAuthenticated]) def set_role_menu_btn(self, request): """ diff --git a/docs/auto-permission-scan.md b/docs/auto-permission-scan.md new file mode 100644 index 0000000..9986fa2 --- /dev/null +++ b/docs/auto-permission-scan.md @@ -0,0 +1,141 @@ +# API 权限自动扫描与批量生成 + +**日期:** 2026-04-09 + +## 1. 概述 + +### 背景 +后端接口权限很多,前端给角色分配权限时需要手动一个个添加按钮权限,操作繁琐。系统提供自动化能力,自动扫描 Django app 下的 ViewSet 接口,并批量生成 `MenuButton` 权限记录。 + +### 已实现功能 + +1. **菜单管理页面 - 自动扫描接口权限**:选中菜单 → 选择 Django app → 扫描预览 → 批量生成按钮权限 +2. **角色管理页面 - 按钮权限分组展示**:按 Model > CRUD 语义分组展示,支持批量勾选,防抖合并请求 +3. **后端批量更新接口**:减少数据库并发锁问题 + +--- + +## 2. 菜单管理 - 自动扫描功能 + +### 流程 +``` +菜单管理页面 → 选中菜单 → 点击「自动扫描」→ 选择 App → 开始扫描 + → 预览分组表格(按 ViewSet 分组,可折叠) + → 行内编辑 name/value → 勾选 → 确认生成 + → 批量创建 MenuButton 记录 +``` + +### 后端新增 API + +| API | URL | 方法 | 功能 | +|-----|-----|------|------| +| 获取 App 列表 | `/api/system/menu_button/scan_get_apps/` | GET | 扫描 dvadmin 下所有有 views/ 子目录的 app | +| 扫描 ViewSet | `/api/system/menu_button/scan_viewset/` | POST | 扫描指定 app 下所有 ViewSet,返回接口预览数据 | +| 批量创建 | `/api/system/menu_button/scan_batch_create/` | POST | 批量创建 MenuButton,跳过已存在记录 | + +### ViewSet 扫描规则 +- 扫描 `dvadmin/{app}/views/` 子目录下的所有 `.py` 文件 +- 使用 `pkgutil.iterModules` 发现子模块 +- 对每个模块,用 `inspect.getmembers` 找 `GenericViewSet` 子类 +- 标准 CRUD action 通过硬编码字典映射(list/create/retrieve/update/partial_update/destroy) +- 自定义 `@action` 方法通过 `inspect.getmembers(method)` + `method.mapping` 发现 +- 排除基类(名字含 Custom/Base/Generic/Abstract) +- 使用 `seen_viewset_ids = set()` 按 `id(cls)` 去重 +- `value` 格式:`{app}:{Model}:{Action}`,如 `system:User:List` + +### 前端组件 +- `web/src/views/system/menu/components/ScanModal/index.vue` +- 两步流程:选择 App → 预览表格 +- 分组折叠表格:el-collapse 按 ViewSet 分组 +- 表格列:勾选 | 接口路径 | 方法 | 按钮名称(name) | 权限标识(value) | 状态 +- 全选/取消全选工具栏 +- 行内编辑:name 和 value 列支持点击编辑 +- `is_existing=true` 的行禁止编辑,显示灰色 +- 选择同步:el-table ref + `toggleRowSelection()` 同步 checkbox 状态 + +--- + +## 3. 角色管理 - 按钮权限分组展示 + +### 背景 +角色分配权限页面原本是平铺所有按钮,找起来困难。 + +### 解决方案 +按 **Model > CRUD 语义** 二级分组展示: + +``` +▼ User (5 个操作) + ☑ 查询 (绿色) → List, Retrieve, Search, Export... + ☑ 新增/修改 (蓝色) → Create, Update, Copy, Import... + ☐ 删除 (红色) → Delete + ☐ 其他 (灰色) → 自定义 action + +▼ Role (4 个操作) + ... +``` + +### 分组规则(value 格式 `app:model:action`) + +| 意图 | 匹配关键词 | 标签颜色 | +|------|-----------|---------| +| 查询 | list, retrieve, export, search, query, detail, get | 绿色 | +| 新增/修改 | create, update, import, copy, add, edit, save | 蓝色 | +| 删除 | delete, remove, destroy | 红色 | +| 其他 | 未能匹配以上关键词的 action | 灰色 | + +### 防抖批量更新 +- 300ms 防抖:合并多次勾选操作为一次请求 +- 后端新增批量接口:`PUT /api/system/role_menu_button_permission/batch_set_role_menu_btn/` +- 请求体:`{ roleId, menuId, buttons: [{btnId, isCheck, data_range, dept}] }` +- 避免 SQLite 数据库锁问题 + +### 后端变更 +- `role_menu_button_permission.py`:`RoleMenuButtonSerializer` 添加 `value` 字段 +- 新增 `batch_set_role_menu_btn` action + +--- + +## 4. i18n 国际化 + +新增多语言 key: +- `scan.*` — 扫描功能相关(菜单页面) +- `role.buttons.intentRead/intentWrite/intentDelete/intentOther` — CRUD 意图标签 +- `role.buttons.expandAll/collapseAll` — 展开/折叠 + +--- + +## 5. 文件清单 + +### 后端 +| 文件 | 变更 | +|------|------| +| `dvadmin/system/views/menu_button.py` | 新增 3 个 action:scan_get_apps、scan_viewset、scan_batch_create | +| `dvadmin/system/views/role_menu_button_permission.py` | 添加 `value` 到 serializer;新增 `batch_set_role_menu_btn` | +| `dvadmin/test_app/` | 新增测试 app(Blog、Product ViewSet) | +| `application/settings.py` | 注册 test_app | +| `application/urls.py` | 注册 test_app 路由 | + +### 前端 +| 文件 | 变更 | +|------|------| +| `web/src/views/system/menu/components/ScanModal/index.vue` | 新增:扫描弹窗组件 | +| `web/src/views/system/menu/api.ts` | 新增 3 个 API | +| `web/src/views/system/menu/index.vue` | 集成自动扫描按钮 | +| `web/src/views/system/role/components/RoleMenuBtn.vue` | 重写:分组展示 + 防抖批量 | +| `web/src/views/system/role/components/api.ts` | 新增 batchSetRoleMenuBtn | +| `web/src/views/system/role/types.ts` | 添加 `value` 字段 | +| `web/src/i18n/pages/menu/{zh-cn,en,zh-tw}.ts` | 添加 scan.* key | +| `web/src/i18n/pages/role/{zh-cn,en,zh-tw,zh-tw}.ts` | 添加 intent*/expandAll/collapseAll key | + +--- + +## 6. 验证步骤 + +- [ ] 菜单管理:选中菜单 → 自动扫描 → 选择 App → 预览 → 确认生成 +- [ ] 扫描结果:已存在的不重复创建,新接口正常生成 +- [ ] 行内编辑:name 和 value 可编辑,已存在行禁止编辑 +- [ ] 全选/取消全选:工具栏按钮和分组 header 都能正确同步 +- [ ] 角色权限:按钮按 Model > CRUD 语义分组展示 +- [ ] 全选/半选状态:Model 级和 CRUD 意图级都有 indeterminate 状态 +- [ ] 批量防抖:快速勾选多个按钮,最终只发一条请求 +- [ ] 多语言:中/英/繁切换正常 diff --git a/web/src/i18n/pages/role/en.ts b/web/src/i18n/pages/role/en.ts index 3ee8bc8..dac3d72 100644 --- a/web/src/i18n/pages/role/en.ts +++ b/web/src/i18n/pages/role/en.ts @@ -73,6 +73,12 @@ export default { assignUsers: 'Assign Users', batchDelete: 'Batch Delete', confirm: 'Confirm', + expandAll: 'Expand All', + collapseAll: 'Collapse All', + intentRead: 'Read', + intentWrite: 'Create/Update', + intentDelete: 'Delete', + intentOther: 'Other', }, transfer: { unassignedUsers: 'Unassigned Users', diff --git a/web/src/i18n/pages/role/zh-cn.ts b/web/src/i18n/pages/role/zh-cn.ts index 24b8abf..a7f3474 100644 --- a/web/src/i18n/pages/role/zh-cn.ts +++ b/web/src/i18n/pages/role/zh-cn.ts @@ -73,6 +73,12 @@ export default { assignUsers: '授权用户', batchDelete: '批量删除', confirm: '确定', + expandAll: '展开所有', + collapseAll: '收起所有', + intentRead: '查询', + intentWrite: '新增/修改', + intentDelete: '删除', + intentOther: '其他', }, transfer: { unassignedUsers: '未授权用户', diff --git a/web/src/i18n/pages/role/zh-tw.ts b/web/src/i18n/pages/role/zh-tw.ts index e69de29..338411e 100644 --- a/web/src/i18n/pages/role/zh-tw.ts +++ b/web/src/i18n/pages/role/zh-tw.ts @@ -0,0 +1,97 @@ +// 定義內容 +export default { + message: { + pages: { + role: { + table: { + columns: { + index: '序號', + name: '角色名稱', + key: '權限標識', + sort: '排序', + status: '狀態', + actions: '操作', + }, + }, + form: { + name: '角色名稱', + key: '權限標識', + sort: '排序', + status: '狀態', + remark: '備註', + namePlaceholder: '請輸入角色名稱', + keyPlaceholder: '輸入權限標識', + sortPlaceholder: '請輸入排序', + remarkPlaceholder: '請輸入備註', + }, + validation: { + nameRequired: '角色名稱必填', + keyRequired: '權限標識必填', + sortRequired: '排序必填', + }, + dialog: { + addRole: '新增角色', + editRole: '編輯角色', + assignPermissions: '權限配置', + assignUsers: '授權用戶', + deleteConfirm: '確定刪除該角色嗎?', + menuPermission: '菜單權限', + buttonPermission: '按鈕權限', + columnPermission: '列字段權限', + interfacePermission: '接口權限', + currentRole: '當前授權角色:', + authorizedUsers: '授權人員:', + selectDataPermission: '請選擇', + customDeptPlaceholder: '請選擇自定義部門', + dataPermissionConfig: '數據權限配置', + defaultInterfacePermission: '默認接口權限:', + configureOperationPermission: '配置操作功能接口權限,配置數據權限點擊小齒輪', + }, + messages: { + addSuccess: '新增成功', + updateSuccess: '更新成功', + deleteSuccess: '刪除成功', + deleteFailed: '刪除失敗', + assignSuccess: '分配成功', + saveSuccess: '保存成功', + checkUsersFirst: '請先勾選用戶', + batchDeleteConfirm: '確定要刪除這{count}位用戶的權限嗎', + deleteUserConfirm: '確定要刪除這 "{count}" 位用戶的權限嗎', + }, + buttons: { + add: '新增', + view: '查看', + edit: '編輯', + delete: '刪除', + save: '保存', + cancel: '取消', + reset: '重置', + export: '導出', + query: '查詢', + refresh: '刷新', + assignPermission: '權限配置', + assignUsers: '授權用戶', + batchDelete: '批量刪除', + confirm: '確定', + expandAll: '展開全部', + collapseAll: '收起全部', + intentRead: '查詢', + intentWrite: '新增/修改', + intentDelete: '刪除', + intentOther: '其他', + }, + transfer: { + unassignedUsers: '未授權用戶', + assignedUsers: '已授權用戶', + }, + dataPermission: { + ownDataOnly: '僅本人數據權限', + deptAndBelow: '本部門及以下數據權限', + deptOnly: '本部門數據權限', + allData: '全部數據權限', + customData: '自定數據權限', + }, + }, + }, + }, +}; diff --git a/web/src/views/system/menu/components/ScanModal/index.vue b/web/src/views/system/menu/components/ScanModal/index.vue index 6ec7a43..304bac1 100644 --- a/web/src/views/system/menu/components/ScanModal/index.vue +++ b/web/src/views/system/menu/components/ScanModal/index.vue @@ -62,6 +62,7 @@ import { ref, computed } from 'vue'; +import type { ElTable } from 'element-plus'; import { ElMessage } from 'element-plus'; import { scanGetApps, scanViewSet, scanBatchCreate } from '../../api'; @@ -202,6 +204,7 @@ const scanning = ref(false); const submitting = ref(false); const tableData = ref([]); const expandedGroups = ref([]); +const tableRefs = ref>>({}); const rowKey = (btn: Button) => `${btn.path}::${btn.method}::${btn.action}`; @@ -241,8 +244,14 @@ const toggleGroup = (group: ViewSetGroup, checked: boolean) => { } } } - // 触发 el-table 的选中状态更新(通过重新设置 data 触发) - triggerTableRefresh(group); + const tableRef = tableRefs.value[group.viewset]; + if (tableRef) { + for (const btn of group.buttons) { + if (!btn.is_existing) { + tableRef.toggleRowSelection(btn, checked); + } + } + } }; const toggleAll = (checked: boolean) => { @@ -269,6 +278,14 @@ const onSelectionChange = (group: ViewSetGroup, selectedRows: Button[]) => { } else { group._selectedSet!.delete(key); } + const tableRef = tableRefs.value[group.viewset]; + if (tableRef) { + for (const btn of group.buttons) { + if (!btn.is_existing) { + tableRef.toggleRowSelection(btn, checked); + } + } + } } }; @@ -382,6 +399,7 @@ const handleClose = () => { selectedApp.value = ''; tableData.value = []; expandedGroups.value = []; + tableRefs.value = {}; }; const handleOpen = async () => { diff --git a/web/src/views/system/role/components/RoleMenuBtn.vue b/web/src/views/system/role/components/RoleMenuBtn.vue index 5f897a5..dadfd96 100644 --- a/web/src/views/system/role/components/RoleMenuBtn.vue +++ b/web/src/views/system/role/components/RoleMenuBtn.vue @@ -26,23 +26,74 @@ class="dialog-tree" style="margin-left: 15px; width: AUTO; min-width: 250px; margin-top: 0;" /> - - 配置操作功能接口权限,配置数据权限点击小齿轮 +
+ {{ $t('message.pages.role.dialog.configureOperationPermission') }} + {{ $t('message.pages.role.buttons.expandAll') }} + {{ $t('message.pages.role.buttons.collapseAll') }} +
- -
- {{ btn.data_range !== null ? `${btn.name}(${formatDataRange(btn.data_range, btn.dept)})` : btn.name }} - - - - - -
-
+ + + + + + +
+
+
+ + {{ intent.label }} +
+
+ +
+ {{ btn.name }} + + + +
+
+
+
+
+
+
- + +
@@ -75,44 +126,53 @@ import { RoleDrawerStores } from '../stores/RoleDrawerStores'; import { RoleMenuBtnStores } from '../stores/RoleMenuBtnStores'; import { RoleMenuTreeStores } from '../stores/RoleMenuTreeStores'; import { RoleMenuBtnType } from '../types'; -import { getRoleToDeptAll, setRoleMenuBtn, setRoleMenuBtnDataRange } from './api'; +import { getRoleToDeptAll, batchSetRoleMenuBtn, setRoleMenuBtnDataRange } from './api'; +import { Folder, Setting } from '@element-plus/icons-vue'; import XEUtils from 'xe-utils'; import { ElMessage } from 'element-plus'; import { Local } from '/@/utils/storage'; -const RoleDrawer = RoleDrawerStores(); // 角色-菜单 -const RoleMenuTree = RoleMenuTreeStores(); // 角色-菜单 -const RoleMenuBtn = RoleMenuBtnStores(); // 角色-菜单-按钮 +/** CRUD 语义类型 */ +type IntentType = 'read' | 'write' | 'delete' | 'other'; + +/** 按 intent 分组的按钮列表 */ +interface IntentGroup { + type: IntentType; + label: string; + buttons: RoleMenuBtnType[]; +} + +/** 按 model 分组的按钮列表 */ +interface ModelGroup { + model: string; + intents: IntentGroup[]; +} + +const RoleDrawer = RoleDrawerStores(); +const RoleMenuTree = RoleMenuTreeStores(); +const RoleMenuBtn = RoleMenuBtnStores(); const dialogVisible = ref(false); -// 默认按钮 const default_selectBtn = ref({ id: 0, menu_btn_pre_id: 0, - /** 是否选中 */ isCheck: false, - /** 按钮名称 */ name: '', - /** 数据权限范围 */ data_range: Local.get('role_default_data_range'), dept: Local.get('role_default_custom_dept'), }); -// 选中的按钮 const selectBtn = ref({ id: 0, menu_btn_pre_id: 0, - /** 是否选中 */ isCheck: false, - /** 按钮名称 */ name: '', - /** 数据权限范围 */ data_range: 0, dept: [], }); -/** - * 数据权限范围 - */ + +const t = (key: string) => i18n.global.t(key); + const dataPermissionRange = ref([ { label: '仅本人数据权限', value: 0 }, { label: '本部门及以下数据权限', value: 1 }, @@ -120,120 +180,246 @@ const dataPermissionRange = ref([ { label: '全部数据权限', value: 3 }, { label: '自定数据权限', value: 4 }, ]); -/** - * 自定义数据权限的部门树配置 - */ + const defaultTreeProps = { children: 'children', label: 'name', value: 'id', }; - -/** - * 默认数据权限下拉选择事件 - * 保留数据到cache - */ -const defaulthandlePermissionRangeChange = async (val: number) => { - if (val < 4) { - // default_selectBtn.value.dept = []; - // Local.set('role_default_custom_dept', []); - } - default_selectBtn.value.data_range = val; - Local.set('role_default_data_range', val); +/** CRUD 意图映射配置 */ +const INTENT_CONFIG: Record = { + read: { + keywords: ['list', 'retrieve', 'export', 'search', 'query', 'detail', 'info', 'get'], + labelKey: 'message.pages.role.buttons.intentRead', + order: 1, + }, + write: { + keywords: ['create', 'update', 'patch', 'import', 'copy', 'add', 'edit', 'save', 'submit'], + labelKey: 'message.pages.role.buttons.intentWrite', + order: 2, + }, + delete: { + keywords: ['delete', 'remove', 'destroy'], + labelKey: 'message.pages.role.buttons.intentDelete', + order: 3, + }, + other: { + keywords: [], + labelKey: 'message.pages.role.buttons.intentOther', + order: 4, + }, }; -/** - * 默认部门下拉选择事件 - * 保留数据到cache - */ - const customhandlePermissionRangeChange = async (dept: Array) => { - default_selectBtn.value.dept = dept; - Local.set('role_default_custom_dept', dept); -}; - -/** - * 自定数据权限下拉选择事件 - */ -const handlePermissionRangeChange = async (val: number) => { - if (val < 4) { - selectBtn.value.dept = []; - } -}; -/** - * 格式化按钮数据范围 - */ -const formatDataRange = computed(() => { - return function (datarange: number, dept: Array) { - const datarangeitem = XEUtils.find(dataPermissionRange.value, (item: any) => { - if (item.value === datarange) { - return item.label; - } - }); - // 数据权限与默认数据权限一致 - if (datarange === default_selectBtn.value.data_range) { - // 判断选择的部门是否一致 - if (datarange !== 4 || JSON.stringify(dept) === JSON.stringify(default_selectBtn.value.dept)) { - - return "默认接口权限" - } +/** 根据 action 名称判断 CRUD 意图 */ +const getIntentType = (action: string): IntentType => { + const lower = action.toLowerCase(); + for (const [type, config] of Object.entries(INTENT_CONFIG)) { + if (config.keywords.some(kw => lower.includes(kw))) { + return type as IntentType; } - // datarange === 4 选择的部门不一致返回datarangeitem.label - return datarangeitem.label; + } + return 'other'; +}; + +/** CRUD 意图标签颜色 */ +const intentTagType = (type: IntentType): string => { + const map: Record = { + read: 'success', + write: 'primary', + delete: 'danger', + other: 'info', }; + return map[type]; +}; + +const expandedModels = ref([]); + +/** 按 Model > CRUD 意图分组 */ +const groupedButtons = computed(() => { + // value 格式: app:model:action + // 示例: system:User:List, test_app:Blog:Create + const modelMap: Map> = new Map(); + + for (const btn of RoleMenuBtn.$state) { + const value = (btn as any).value || ''; + const parts = value.split(':'); + const model = parts[1] || parts[0] || 'other'; + const action = parts[2] || ''; + + if (!modelMap.has(model)) { + modelMap.set(model, new Map()); + } + const intentMap = modelMap.get(model)!; + + const intentType = getIntentType(action); + if (!intentMap.has(intentType)) { + intentMap.set(intentType, []); + } + intentMap.get(intentType)!.push(btn); + } + + const groups = [] as Array<{ model: string; intents: IntentGroup[] }>; + for (const [model, intentMap] of modelMap) { + // 按固定顺序排列 CRUD 意图 + const intentTypes = (Object.keys(INTENT_CONFIG) as IntentType[]) + .filter(type => intentMap.has(type)) + .sort((a, b) => INTENT_CONFIG[a].order - INTENT_CONFIG[b].order); + const intents = intentTypes.map(type => ({ + type, + label: i18n.global.t(INTENT_CONFIG[type].labelKey), + buttons: intentMap.get(type)!, + })); + groups.push({ model, intents }); + } + + // 按 model 名称排序 + groups.sort((a, b) => a.model.localeCompare(b.model)); + return groups; }); -/** - * 勾选按钮 - */ -const handleCheckChange = async (btn: RoleMenuBtnType) => { - selectBtn.value = default_selectBtn.value; - const put_data = { - isCheck: btn.isCheck, - roleId: RoleDrawer.roleId, - menuId: RoleMenuTree.id, - btnId: btn.id, + +/** 展开/折叠全部 */ +const expandAll = () => { + expandedModels.value = groupedButtons.value.map(g => g.model); +}; +const collapseAll = () => { + expandedModels.value = []; +}; + +/** Model 全选/半选 */ +const isModelAllSelected = (modelGroup: ModelGroup) => { + const allBtns = modelGroup.intents.flatMap(i => i.buttons); + return allBtns.length > 0 && allBtns.every(btn => btn.isCheck); +}; + +const isModelIndeterminate = (modelGroup: ModelGroup) => { + const allBtns = modelGroup.intents.flatMap(i => i.buttons); + const selected = allBtns.filter(btn => btn.isCheck).length; + return selected > 0 && selected < allBtns.length; +}; + +const toggleModel = (modelGroup: ModelGroup, checked: boolean) => { + for (const intent of modelGroup.intents) { + for (const btn of intent.buttons) { + btn.isCheck = checked; + markChange(btn); + } + } +}; + +/** CRUD 意图全选/半选 */ +const isIntentAllSelected = (intent: IntentGroup) => { + return intent.buttons.length > 0 && intent.buttons.every(btn => btn.isCheck); +}; + +const isIntentIndeterminate = (intent: IntentGroup) => { + const selected = intent.buttons.filter(btn => btn.isCheck).length; + return selected > 0 && selected < intent.buttons.length; +}; + +const toggleIntent = (_modelGroup: ModelGroup, intent: IntentGroup, checked: boolean) => { + for (const btn of intent.buttons) { + btn.isCheck = checked; + markChange(btn); + } +}; + +/** 获取 Model 分组的总按钮数 */ +const getTotalCount = (modelGroup: ModelGroup) => { + return modelGroup.intents.reduce((sum, i) => sum + i.buttons.length, 0); +}; + +/** 防抖 + 批量更新 */ +const pendingChanges = new Map(); +const flushTimer = ref | null>(null); +const isFlushing = ref(false); + +const markChange = (btn: RoleMenuBtnType) => { + pendingChanges.set(btn.id, { btn, isCheck: btn.isCheck }); + scheduleFlush(); +}; + +const scheduleFlush = () => { + if (flushTimer.value !== null) { + clearTimeout(flushTimer.value); + } + flushTimer.value = setTimeout(() => { + flushChanges(); + }, 300); +}; + +const flushChanges = async () => { + if (isFlushing.value || pendingChanges.size === 0) return; + isFlushing.value = true; + const changes = Array.from(pendingChanges.values()); + pendingChanges.clear(); + flushTimer.value = null; + + const buttons = changes.map(c => ({ + btnId: c.btn.id, + isCheck: c.isCheck, data_range: default_selectBtn.value.data_range, dept: default_selectBtn.value.dept, - }; - const { data, msg } = await setRoleMenuBtn(put_data); - RoleMenuBtn.updateState(data); - ElMessage({ message: msg, type: 'success' }); + })); + + try { + const res: any = await batchSetRoleMenuBtn({ + roleId: RoleDrawer.roleId, + menuId: RoleMenuTree.id, + buttons, + }); + ElMessage({ message: res.msg || `已更新 ${buttons.length} 项`, type: 'success' }); + } catch (err: any) { + ElMessage({ message: err?.message || '更新失败', type: 'error' }); + } finally { + isFlushing.value = false; + } +}; + +/** 单个 checkbox 变更 */ +const handleCheckChange = (btn: RoleMenuBtnType) => { + markChange(btn); }; -/** - * 按钮-数据范围确定 - */ const handleDialogConfirm = async () => { const { data, msg } = await setRoleMenuBtnDataRange(selectBtn.value); selectBtn.value = data; dialogVisible.value = false; ElMessage({ message: msg, type: 'success' }); }; -/** - * 数据范围关闭 - */ + const handleDialogClose = () => { dialogVisible.value = false; }; -/** - * 齿轮点击 - */ const handleSettingClick = async (btn: RoleMenuBtnType) => { selectBtn.value = btn; dialogVisible.value = true; }; -/** - * 部门数据 - * - */ +const defaulthandlePermissionRangeChange = async (val: number) => { + default_selectBtn.value.data_range = val; + Local.set('role_default_data_range', val); +}; + +const customhandlePermissionRangeChange = async (dept: Array) => { + default_selectBtn.value.dept = dept; + Local.set('role_default_custom_dept', dept); +}; + +const handlePermissionRangeChange = async (val: number) => { + if (val < 4) { + selectBtn.value.dept = []; + } +}; + const deptData = ref([]); -// 页面打开后获取列表数据 + onMounted(async () => { const res = await getRoleToDeptAll({ role: RoleDrawer.roleId, menu_button: selectBtn.value.id }); const depts = XEUtils.toArrayTree(res.data, { parentKey: 'parent', strict: false }); deptData.value = depts; + expandedModels.value = groupedButtons.value.map(g => g.model); }); @@ -248,19 +434,82 @@ onMounted(async () => { border-radius: 4px; background-color: var(--el-color-primary); } + .alert-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + span { + color: #fff; + } + } } -.el-checkbox { - width: 20%; +.btn-group-collapse { + border: 1px solid var(--el-border-color); + border-radius: 4px; + overflow: hidden; } + +.model-group-header { + display: flex; + align-items: center; + font-size: 14px; + font-weight: 500; + width: 100%; + padding-right: 12px; + + .model-label { + margin-left: 4px; + font-weight: 600; + } + + .group-count { + margin-left: auto; + font-size: 12px; + color: #909399; + font-weight: normal; + } +} + +.intent-groups { + padding: 8px 0 8px 8px; +} + +.intent-group { + margin-bottom: 12px; + &:last-child { + margin-bottom: 0; + } + + .intent-header { + display: flex; + align-items: center; + margin-bottom: 6px; + padding-bottom: 4px; + border-bottom: 1px solid var(--el-border-color-lighter); + } +} + +.btn-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding-left: 20px; +} + +.btn-checkbox-item { + min-width: 120px; +} + .btn-item { display: flex; align-items: center; - justify-content: center; /* 水平居中 */ + gap: 4px; .el-icon { - margin-left: 5px; + color: var(--el-color-primary); } } + .dialog-tree { width: 100%; margin-top: 20px; diff --git a/web/src/views/system/role/components/api.ts b/web/src/views/system/role/components/api.ts index 7d98b6a..e1fb8f4 100644 --- a/web/src/views/system/role/components/api.ts +++ b/web/src/views/system/role/components/api.ts @@ -37,6 +37,18 @@ export function getRoleMenuBtnField(query: object) { }); } +/** + * 批量设置 角色-菜单-按钮 + * @param data { roleId, menuId, buttons: [{btnId, isCheck, data_range, dept}] } + */ +export function batchSetRoleMenuBtn(data: object) { + return request({ + url: '/api/system/role_menu_button_permission/batch_set_role_menu_btn/', + method: 'put', + data, + }); +} + /** * 设置 角色-菜单-按钮 * @param data diff --git a/web/src/views/system/role/types.ts b/web/src/views/system/role/types.ts index 4005073..1bd7b9c 100644 --- a/web/src/views/system/role/types.ts +++ b/web/src/views/system/role/types.ts @@ -66,6 +66,8 @@ export interface RoleMenuBtnType { data_range: number | null; /** 自定义部门 */ dept: number[]; + /** 权限值 (格式: app:model:action,用于分组) */ + value?: string; } /**