| | |
| | | <template> |
| | | <div class="page"> |
| | | <div class="page-title"> |
| | | <div><h2>菜单权限管理</h2><p>同一菜单必须同时获得租户授权和角色授权,用户才能访问;第一版 admin 直接显示全部菜单</p></div> |
| | | <div><h2>菜单权限管理</h2><p>按角色设置菜单和按钮权限,保存后切换角色会自动反显。</p></div> |
| | | </div> |
| | | <div class="toolbar"> |
| | | <span>租户</span> |
| | | <el-select v-model="tenantId" style="width:220px" @change="loadPermissions"><el-option v-for="item in tenants" :key="item.id" :label="item.name" :value="item.id"></el-option></el-select> |
| | | <span>角色</span> |
| | | <el-select v-model="roleId" style="width:220px" @change="loadPermissions"><el-option v-for="item in roles" :key="item.id" :label="item.name" :value="item.id"></el-option></el-select> |
| | | <el-select v-model="roleId" style="width:260px" @change="loadPermissions"><el-option v-for="item in roles" :key="item.id" :label="item.name" :value="item.id"></el-option></el-select> |
| | | <div class="spacer"></div> |
| | | <el-tag type="success">最终可用菜单:{{ effectiveCount }} 个</el-tag> |
| | | <el-button type="primary" :loading="saving" @click="save">保存双授权</el-button> |
| | | <el-tag type="success">已选权限:{{ selectedRoleIds.length }} 个</el-tag> |
| | | <el-button type="primary" :loading="saving" @click="save">保存角色权限</el-button> |
| | | </div> |
| | | <div class="permission-grid"> |
| | | <section class="permission-panel"> |
| | | <div class="panel-head"><h3>租户菜单授权</h3><el-checkbox v-model="tenantCheckAll" @change="checkAll('tenant')">全选</el-checkbox></div> |
| | | <el-tree ref="tenantTree" :data="menus" node-key="id" show-checkbox default-expand-all :render-content="renderContent" :props="{ label: 'name', children: 'children' }" @check="syncCheckAllState('tenant')"></el-tree> |
| | | <div class="panel-head"><h3>角色菜单及按钮权限</h3><el-checkbox v-model="roleCheckAll" @change="checkAll">全选</el-checkbox></div> |
| | | <el-tree ref="roleTree" :data="menus" node-key="id" show-checkbox default-expand-all :render-content="renderContent" :props="{ label: 'name', children: 'children' }" @check="syncCheckAllState"></el-tree> |
| | | </section> |
| | | <section class="permission-panel"> |
| | | <div class="panel-head"><h3>角色菜单授权</h3><el-checkbox v-model="roleCheckAll" @change="checkAll('role')">全选</el-checkbox></div> |
| | | <el-tree ref="roleTree" :data="menus" node-key="id" show-checkbox default-expand-all :render-content="renderContent" :props="{ label: 'name', children: 'children' }" @check="syncCheckAllState('role')"></el-tree> |
| | | </section> |
| | | <section class="permission-panel intersection-panel"> |
| | | <div class="panel-head"><h3>交集预览</h3></div> |
| | | <div class="intersection-list"><el-tag v-for="item in effectiveMenus" :key="item.id" size="small">{{ item.name }}</el-tag></div> |
| | | </section> |
| | | </div> |
| | | </div> |
| | | </template> |
| | | <script> |
| | | import api from '@/api' |
| | | export default { |
| | | name: 'MenuPermission', |
| | | data() { return { menus: [], flatMenus: [], roles: [], tenants: [], tenantId: 1, roleId: null, saving: false, tenantCheckAll: false, roleCheckAll: false, selectedTenantIds: [], selectedRoleIds: [] } }, |
| | | data() { |
| | | return { |
| | | menus: [], |
| | | flatMenus: [], |
| | | roles: [], |
| | | roleId: null, |
| | | saving: false, |
| | | roleCheckAll: false, |
| | | selectedRoleIds: [] |
| | | } |
| | | }, |
| | | computed: { |
| | | allMenuIds() { return this.flatMenus.map(item => String(item.id)) }, |
| | | parentMenuIds() { const ids = new Set(); this.flatMenus.forEach(item => ids.add(String(item.pid))); return ids }, |
| | | leafMenuIds() { return this.flatMenus.filter(item => !this.parentMenuIds.has(String(item.id))).map(item => String(item.id)) }, |
| | | effectiveCount() { return this.effectiveMenus.length }, |
| | | effectiveMenus() { return this.flatMenus.filter(item => this.selectedTenantIds.map(String).includes(String(item.id)) && this.selectedRoleIds.map(String).includes(String(item.id))) } |
| | | allMenuIds() { return this.flatMenus.map(item => String(item.id)) } |
| | | }, |
| | | created() { this.loadBase() }, |
| | | methods: { |
| | | loadBase() { |
| | | Promise.all([api.get('/system/menu/tree'), api.get('/system/menu/list'), api.get('/system/roles'), api.get('/system/tenants')]).then(([tree, flat, roles, tenants]) => { |
| | | this.menus = tree.data || []; this.flatMenus = flat.data || []; this.roles = roles.data || []; this.tenants = tenants.data || [] |
| | | Promise.all([api.get('/system/menu/tree'), api.get('/system/menu/list'), api.get('/system/roles')]).then(([tree, flat, roles]) => { |
| | | this.menus = tree.data || [] |
| | | this.flatMenus = flat.data || [] |
| | | this.roles = roles.data || [] |
| | | if (!this.roleId && this.roles.length) this.roleId = this.roles[0].id |
| | | this.$nextTick(this.loadPermissions) |
| | | }) |
| | | }, |
| | | loadPermissions() { |
| | | if (!this.roleId || !this.tenantId) return |
| | | api.get('/system/menu-permissions', { params: { roleId: this.roleId, tenantId: this.tenantId } }).then(res => { |
| | | if (!this.roleId) { |
| | | this.selectedRoleIds = [] |
| | | this.roleCheckAll = false |
| | | if (this.$refs.roleTree) this.$refs.roleTree.setCheckedKeys([]) |
| | | return |
| | | } |
| | | api.get('/system/menu-permissions', { params: { roleId: this.roleId } }).then(res => { |
| | | const data = res.data || {} |
| | | this.selectedTenantIds = data.tenantMenuIds || [] |
| | | this.selectedRoleIds = data.roleMenuIds || [] |
| | | this.selectedRoleIds = (data.roleMenuIds || []).map(String) |
| | | this.$nextTick(() => { |
| | | this.$refs.tenantTree.setCheckedKeys(this.leafKeys(this.selectedTenantIds)) |
| | | this.$refs.roleTree.setCheckedKeys(this.leafKeys(this.selectedRoleIds)) |
| | | this.syncCheckAllState('tenant'); this.syncCheckAllState('role') |
| | | this.$refs.roleTree.setCheckedKeys(this.displayCheckedKeys(this.selectedRoleIds)) |
| | | this.syncCheckAllState() |
| | | }) |
| | | }) |
| | | }, |
| | |
| | | const label = data.menuType === 2 ? data.name + ' [按钮 ' + (data.permissions || '') + ']' : data.name |
| | | return h('span', { class: { 'perm-button-node': data.menuType === 2 } }, label) |
| | | }, |
| | | leafKeys(ids) { const leafIds = new Set(this.leafMenuIds); return (ids || []).map(String).filter(id => leafIds.has(id)) }, |
| | | displayCheckedKeys(ids) { |
| | | const selected = new Set((ids || []).map(String)) |
| | | const children = {} |
| | | const menuIds = new Set(this.flatMenus.map(item => String(item.id))) |
| | | this.flatMenus.forEach(item => { |
| | | const id = String(item.id) |
| | | const pid = item.pid == null ? '0' : String(item.pid) |
| | | if (!children[pid]) children[pid] = [] |
| | | children[pid].push(id) |
| | | }) |
| | | const result = [] |
| | | const collectFullySelected = id => { |
| | | if (!selected.has(id)) return false |
| | | const childIds = children[id] || [] |
| | | if (!childIds.length) { |
| | | result.push(id) |
| | | return true |
| | | } |
| | | const allChildrenSelected = childIds.map(collectFullySelected).every(Boolean) |
| | | if (allChildrenSelected) result.push(id) |
| | | return allChildrenSelected |
| | | } |
| | | this.flatMenus.filter(item => item.pid == null || String(item.pid) === '0' || !menuIds.has(String(item.pid))).forEach(item => collectFullySelected(String(item.id))) |
| | | return result |
| | | }, |
| | | checkedMenuIds(ref) { return [...new Set([...(ref.getCheckedKeys(false) || []), ...(ref.getHalfCheckedKeys() || [])].map(String))] }, |
| | | syncCheckAllState(target) { const ref = target === 'tenant' ? this.$refs.tenantTree : this.$refs.roleTree; if (!ref) return; const checked = this.checkedMenuIds(ref).length === this.allMenuIds.length; if (target === 'tenant') this.tenantCheckAll = checked; if (target === 'role') this.roleCheckAll = checked }, |
| | | checkAll(target) { |
| | | const checked = target === 'tenant' ? this.tenantCheckAll : this.roleCheckAll |
| | | const ref = target === 'tenant' ? this.$refs.tenantTree : this.$refs.roleTree |
| | | ref.setCheckedKeys(checked ? this.allMenuIds : []) |
| | | this.$nextTick(() => this.syncCheckAllState(target)) |
| | | syncCheckAllState() { |
| | | const ref = this.$refs.roleTree |
| | | if (!ref) return |
| | | this.roleCheckAll = this.checkedMenuIds(ref).length === this.allMenuIds.length |
| | | }, |
| | | checkAll() { |
| | | this.$refs.roleTree.setCheckedKeys(this.roleCheckAll ? this.allMenuIds : []) |
| | | this.$nextTick(this.syncCheckAllState) |
| | | }, |
| | | save() { |
| | | const tenantMenuIds = this.checkedMenuIds(this.$refs.tenantTree) |
| | | if (!this.roleId) { |
| | | this.$message.warning('请先选择角色') |
| | | return |
| | | } |
| | | const roleMenuIds = this.checkedMenuIds(this.$refs.roleTree) |
| | | this.saving = true |
| | | api.post('/system/menu-permissions', { tenantId: this.tenantId, roleId: this.roleId, tenantMenuIds, roleMenuIds }).then(() => { |
| | | this.selectedTenantIds = tenantMenuIds; this.selectedRoleIds = roleMenuIds; this.$message.success('菜单权限已保存') |
| | | api.post('/system/menu-permissions', { roleId: this.roleId, roleMenuIds }).then(() => { |
| | | this.selectedRoleIds = roleMenuIds |
| | | this.$message.success('角色菜单权限已保存') |
| | | }).finally(() => { this.saving = false }) |
| | | } |
| | | } |
| | |
| | | <style scoped> |
| | | ::v-deep .perm-button-node { color: #b8791f; } |
| | | |
| | | .permission-grid { display: grid; grid-template-columns: 1fr 1fr 320px; gap: 12px; } |
| | | .permission-panel { min-height: calc(100vh - 220px); padding: 14px; border: 1px solid #dce4ec; border-radius: 4px; background: #fff; overflow: auto; } |
| | | .panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } |
| | | .panel-head h3 { margin: 0; color: #31465d; font-size: 15px; } |
| | | .intersection-list { display: flex; flex-wrap: wrap; gap: 6px; } |
| | | @media (max-width: 1100px) { .permission-grid { grid-template-columns: 1fr; } } |
| | | .permission-panel { |
| | | min-height: calc(100vh - 220px); |
| | | padding: 14px; |
| | | border: 1px solid #dce4ec; |
| | | border-radius: 4px; |
| | | background: #fff; |
| | | overflow: auto; |
| | | } |
| | | .panel-head { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | margin-bottom: 12px; |
| | | } |
| | | .panel-head h3 { |
| | | margin: 0; |
| | | color: #31465d; |
| | | font-size: 15px; |
| | | } |
| | | </style> |