This commit is contained in:
2026-09-11 11:15:04 +08:00
parent f702a819fa
commit 23e991722c
8 changed files with 115 additions and 29 deletions
+2 -1
View File
@@ -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')
+5 -7
View File
@@ -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',
+3 -11
View File
@@ -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' },
+4 -2
View File
@@ -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', () => {
+12 -6
View File
@@ -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; }
+22 -1
View File
@@ -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')
})
})
+66
View File
@@ -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 -1
View File
@@ -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' } },