优化
This commit is contained in:
@@ -60,7 +60,8 @@ describe('AdminSidebarMenu', () => {
|
||||
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
|
||||
|
||||
expect(groupTitles).toContain('客户管理')
|
||||
expect(groupTitles).toContain('供应商中心')
|
||||
expect(groupTitles).not.toContain('供应商中心')
|
||||
expect(groupTitles).toContain('通用')
|
||||
expect(groupTitles).toContain('系统管理')
|
||||
expect(groupTitles).not.toContain('财务管理')
|
||||
expect(paths).toContain('/dashboard')
|
||||
|
||||
@@ -14,7 +14,6 @@ describe('adminNavigation', () => {
|
||||
'客户管理',
|
||||
'国内仓',
|
||||
'海外派送',
|
||||
'供应商中心',
|
||||
'货柜管理',
|
||||
'海外业务',
|
||||
'海外仓',
|
||||
@@ -38,11 +37,7 @@ describe('adminNavigation', () => {
|
||||
'整柜业务',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
|
||||
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
|
||||
'供应商列表',
|
||||
'快递公司',
|
||||
'往来单位',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined()
|
||||
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
|
||||
'订舱列表',
|
||||
'拖车列表',
|
||||
@@ -63,6 +58,9 @@ describe('adminNavigation', () => {
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
|
||||
'产品信息',
|
||||
'供应商列表',
|
||||
'快递公司',
|
||||
'往来单位',
|
||||
'费用项目',
|
||||
'汇率管理',
|
||||
'仓库管理',
|
||||
@@ -211,7 +209,7 @@ describe('adminNavigation', () => {
|
||||
]))
|
||||
const paths = getNavigationLeaves(filtered).map((item) => item.path)
|
||||
|
||||
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
|
||||
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '通用'])
|
||||
expect(paths).toEqual([
|
||||
'/dashboard',
|
||||
'/orders',
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
PrototypeOverseasBusinessIcon,
|
||||
PrototypeOverseasWarehouseIcon,
|
||||
PrototypeSalesIcon,
|
||||
PrototypeSupplierIcon,
|
||||
PrototypeSystemIcon,
|
||||
} from './prototypeMenuIcons'
|
||||
|
||||
@@ -85,16 +84,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'supplier-center',
|
||||
title: '供应商中心',
|
||||
icon: PrototypeSupplierIcon,
|
||||
children: [
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
||||
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
|
||||
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'container-management',
|
||||
title: '货柜管理',
|
||||
@@ -171,6 +160,9 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
icon: PrototypeGeneralIcon,
|
||||
children: [
|
||||
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
|
||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
||||
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
|
||||
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
|
||||
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
|
||||
{ key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' },
|
||||
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
|
||||
|
||||
@@ -93,10 +93,12 @@ describe('RolesView', () => {
|
||||
vi.mocked(createPlatformRole).mockReset()
|
||||
})
|
||||
|
||||
it('keeps directory, menu, and action permissions independently checkable', () => {
|
||||
it('links menu groups and permissions so partial selections use the half-checked state', () => {
|
||||
const wrapper = mountView()
|
||||
|
||||
expect(wrapper.get('[data-testid="permission-tree"]').attributes('data-check-strictly')).toBe('true')
|
||||
expect(wrapper.get('[data-testid="permission-tree"]').attributes('data-check-strictly')).toBe('false')
|
||||
expect(rolesSource).not.toContain('check-strictly')
|
||||
expect(rolesSource).not.toContain("disabled: 'disabled'")
|
||||
})
|
||||
|
||||
it('shows the role code in the default list layout', () => {
|
||||
|
||||
@@ -24,8 +24,9 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { adminNavigation } from '@/core/navigation/adminNavigation'
|
||||
import type { Permission, Role } from '@/modules/iam/types'
|
||||
import { buildPermissionTree, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
|
||||
import { buildNavigationPermissionTree, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
|
||||
import { mapPlatformPermission } from '@/modules/iam/utils/platformPermission'
|
||||
|
||||
interface TreeController {
|
||||
@@ -88,7 +89,9 @@ const roleColumns: DataTableColumn[] = [
|
||||
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
|
||||
]
|
||||
const availablePermissions = computed(() => serverPermissions.value)
|
||||
const permissionTree = computed(() => buildPermissionTree(availablePermissions.value))
|
||||
const permissionTree = computed(() => buildNavigationPermissionTree(availablePermissions.value, adminNavigation))
|
||||
const permissionGroupIds = computed(() => permissionTree.value.map((node) => node.id))
|
||||
const assignablePermissionIds = computed(() => new Set(availablePermissions.value.map((permission) => permission.id)))
|
||||
|
||||
function applyFilters() {
|
||||
appliedFilters.value = { ...filters.value }
|
||||
@@ -144,8 +147,8 @@ function openEdit(row: Role) {
|
||||
}
|
||||
|
||||
function selectedPermissionIds() {
|
||||
const checked = treeRef.value?.getCheckedKeys(false).map(String) || []
|
||||
const halfChecked = treeRef.value?.getHalfCheckedKeys().map(String) || []
|
||||
const checked = treeRef.value?.getCheckedKeys(false).map(String).filter((id) => assignablePermissionIds.value.has(id)) || []
|
||||
const halfChecked = treeRef.value?.getHalfCheckedKeys().map(String).filter((id) => assignablePermissionIds.value.has(id)) || []
|
||||
return [...new Set([...checked, ...halfChecked])]
|
||||
}
|
||||
|
||||
@@ -258,7 +261,7 @@ onMounted(loadRoles)
|
||||
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
|
||||
</FormSection>
|
||||
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`保存时按后端契约全量替换当前角色权限;当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></FormSection>
|
||||
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选,当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span><code v-if="data.code">{{ data.code }}</code></div></template></el-tree></FormSection>
|
||||
</div>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
@@ -268,7 +271,10 @@ onMounted(loadRoles)
|
||||
<style scoped>
|
||||
.role-editor-layout { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); gap: 20px; }
|
||||
.role-editor-permissions { min-width: 0; padding-left: 20px; border-left: 1px solid var(--cc-border); }
|
||||
.permission-tree { max-height: 380px; overflow-y: auto; }
|
||||
.permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; }
|
||||
.permission-node span { min-width: 0; }
|
||||
.permission-node code { max-width: 58%; white-space: normal; text-align: right; }
|
||||
.permission-node.is-navigation-group span { font-weight: 650; }
|
||||
:global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.role-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Permission } from '@/modules/iam/types'
|
||||
import { buildPermissionTree, getDescendantIds, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
|
||||
import { buildNavigationPermissionTree, buildPermissionTree, getDescendantIds, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
|
||||
import { adminNavigation } from '@/core/navigation/adminNavigation'
|
||||
|
||||
const permissions: Permission[] = [
|
||||
{ id: 'action-b', parentId: 'menu', name: '操作 B', code: 'demo:b', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
|
||||
@@ -52,4 +53,24 @@ describe('permissionTree', () => {
|
||||
actionCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('groups assignable permissions by the current admin navigation and hides technical containers', () => {
|
||||
const rolePermissions: Permission[] = [
|
||||
{ id: 'supplier', parentId: null, name: '供应商旧名称', code: 'platform:supplier:list', type: '菜单', path: '/suppliers', method: 'GET', status: '启用', sort: 10 },
|
||||
{ id: 'supplier-create', parentId: 'supplier', name: '新增供应商', code: 'platform:supplier:create', type: '按钮', path: '', method: 'POST', status: '启用', sort: 10 },
|
||||
{ id: 'express', parentId: null, name: '快递公司', code: 'platform:express:list', type: '菜单', path: '/master-data/express-companies', method: 'GET', status: '启用', sort: 20 },
|
||||
{ id: 'counterparty', parentId: null, name: '往来单位', code: 'platform:counterparty:list', type: '菜单', path: '/master-data/counterparties', method: 'GET', status: '启用', sort: 30 },
|
||||
{ id: 'technical', parentId: null, name: '服务接口', code: 'platform:technical:invoke', type: '接口', path: '/internal', method: 'POST', status: '启用', sort: 40 },
|
||||
]
|
||||
|
||||
const tree = buildNavigationPermissionTree(rolePermissions, adminNavigation)
|
||||
const general = tree.find((node) => node.name === '通用')
|
||||
|
||||
expect(tree.some((node) => node.name === '供应商中心')).toBe(false)
|
||||
expect(general?.permissionId).toBeUndefined()
|
||||
expect(general?.children?.map((node) => node.name)).toEqual(['供应商列表', '快递公司', '往来单位'])
|
||||
expect(general?.children?.[0]?.children?.map((node) => node.name)).toEqual(['供应商旧名称', '新增供应商'])
|
||||
expect(tree.some((node) => node.name === '其他权限')).toBe(false)
|
||||
expect(JSON.stringify(tree)).not.toContain('platform:technical:invoke')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { Permission } from '@/modules/iam/types'
|
||||
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||
|
||||
export type PermissionTreeNode = Permission & { children?: PermissionTreeNode[] }
|
||||
export type PermissionMatcher = (permission: Permission) => boolean
|
||||
export interface RolePermissionTreeNode {
|
||||
id: string
|
||||
permissionId?: string
|
||||
name: string
|
||||
code: string
|
||||
children?: RolePermissionTreeNode[]
|
||||
}
|
||||
|
||||
function groupByParent(permissions: Permission[]) {
|
||||
const childrenByParent = new Map<string | null, Permission[]>()
|
||||
@@ -73,3 +81,61 @@ export function summarizePermissionSelection(permissions: Permission[], selected
|
||||
actionCount: selectedPermissions.filter((permission) => ['按钮', '接口'].includes(permission.type)).length,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNavigationPermissionTree(
|
||||
permissions: Permission[],
|
||||
navigation: AdminNavigationNode[],
|
||||
): RolePermissionTreeNode[] {
|
||||
const childrenByParent = groupByParent(permissions)
|
||||
const permissionByCode = new Map(permissions.map((permission) => [permission.code, permission]))
|
||||
const usedPermissionIds = new Set<string>()
|
||||
|
||||
function permissionLeaves(permission: Permission, ancestors: Set<string>): RolePermissionTreeNode[] {
|
||||
if (ancestors.has(permission.id)) return []
|
||||
|
||||
usedPermissionIds.add(permission.id)
|
||||
const nextAncestors = new Set(ancestors).add(permission.id)
|
||||
const children = (childrenByParent.get(permission.id) ?? [])
|
||||
.flatMap((child) => permissionLeaves(child, nextAncestors))
|
||||
|
||||
return [{ id: permission.id, permissionId: permission.id, name: permission.name, code: permission.code }, ...children]
|
||||
}
|
||||
|
||||
function navigationNode(node: AdminNavigationNode): RolePermissionTreeNode | null {
|
||||
if (node.hidden) return null
|
||||
|
||||
if (node.children?.length) {
|
||||
const children = node.children
|
||||
.map(navigationNode)
|
||||
.filter((child): child is RolePermissionTreeNode => child !== null)
|
||||
if (!children.length) return null
|
||||
return {
|
||||
id: `navigation-group:${node.key}`,
|
||||
name: node.title,
|
||||
code: '',
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
const codes = [...new Set([node.permission, ...(node.permissionAnyOf ?? [])].filter(Boolean))] as string[]
|
||||
const permissionNodes = codes
|
||||
.map((code) => permissionByCode.get(code))
|
||||
.filter((permission): permission is Permission => permission !== undefined)
|
||||
.flatMap((permission) => usedPermissionIds.has(permission.id) ? [] : permissionLeaves(permission, new Set()))
|
||||
|
||||
if (permissionNodes.length) {
|
||||
return {
|
||||
id: `navigation-menu:${node.key}`,
|
||||
name: node.title,
|
||||
code: '',
|
||||
children: permissionNodes,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const navigationGroups = navigation
|
||||
.map(navigationNode)
|
||||
.filter((node): node is RolePermissionTreeNode => node !== null)
|
||||
return navigationGroups
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const supplierRoutes = [
|
||||
{ path: 'suppliers', name: 'supplier-list', component: () => import('@/modules/suppliers/pages/SuppliersView.vue'), meta: { title: '供应商中心', description: '维护平台公共订舱、报关、拖车和证书供应商', permission: 'platform:supplier:list' } },
|
||||
{ path: 'suppliers', name: 'supplier-list', component: () => import('@/modules/suppliers/pages/SuppliersView.vue'), meta: { title: '供应商列表', description: '维护平台公共订舱、报关、拖车和证书供应商', permission: 'platform:supplier:list' } },
|
||||
{ path: 'suppliers/new', name: 'supplier-create', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '新增供应商', description: '新增平台公共供应商档案', permission: 'platform:supplier:create' } },
|
||||
{ path: 'suppliers/:id', name: 'supplier-detail', component: () => import('@/modules/suppliers/pages/SupplierDetailView.vue'), meta: { title: '供应商详情', description: '查看平台公共供应商完整档案', permission: 'platform:supplier:list' } },
|
||||
{ path: 'suppliers/:id/edit', name: 'supplier-edit', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '编辑供应商', description: '编辑平台公共供应商档案', permission: 'platform:supplier:edit' } },
|
||||
|
||||
Reference in New Issue
Block a user