From fb9ee94bbc893366494a3fa9aa3ea7c1ea514f0e Mon Sep 17 00:00:00 2001 From: DaxPay Dev Date: Sat, 11 Jul 2026 12:24:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E8=8F=9C=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=AD=90=E9=A1=B5=E9=9D=A2=E5=88=86=E7=BB=84=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=B1=BB=E5=9E=8B=E7=BC=96=E8=BE=91=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 subpage_group 类型与路由/列表/编辑支持;编辑表单按类型展示功能说明,且编辑/查看时锁定菜单类型(全量展示对照)。 --- apps/daxpay-admin/src/api/core/menu.api.ts | 86 ++++++++++++--- .../daxpay-admin/src/api/iam/perm/menu.api.ts | 90 +++++++++++++--- apps/daxpay-admin/src/enums/menuType.ts | 17 +++ .../src/locales/langs/en-US/iam/menu.json | 12 +++ .../src/locales/langs/zh-CN/iam/menu.json | 12 +++ .../views/iam/perm/menu/MenuChildrenPanel.vue | 41 +++++-- .../src/views/iam/perm/menu/MenuEdit.vue | 102 ++++++++++++------ .../src/views/iam/perm/menu/MenuList.vue | 42 +++++++- .../src/views/iam/perm/menu/menu-tree.util.ts | 60 ++++++++--- .../@core/base/typings/src/vue-router.d.ts | 5 + .../layouts/src/widgets/breadcrumb.vue | 26 +++++ 11 files changed, 410 insertions(+), 83 deletions(-) diff --git a/apps/daxpay-admin/src/api/core/menu.api.ts b/apps/daxpay-admin/src/api/core/menu.api.ts index 0efb492db..a0e8ffa21 100644 --- a/apps/daxpay-admin/src/api/core/menu.api.ts +++ b/apps/daxpay-admin/src/api/core/menu.api.ts @@ -44,6 +44,41 @@ function resolveMenuTitle(menu: PermMenuResult): string { return menu.titleCn || ''; } +/** + * 面包屑链项 + */ +interface BreadcrumbChainItem { + icon?: string; + path?: string; + title?: string; +} + +/** + * 构建 id → 祖先链(含自身)的映射 + * 子页面路由提升后会丢失 menu 父级,提升前用原始树构建完整层级链,供面包屑补全 + */ +function buildBreadcrumbChainMap(menus: PermMenuResult[]): Map { + const map = new Map(); + function walk(nodes: PermMenuResult[], ancestors: BreadcrumbChainItem[]) { + for (const node of nodes) { + const item: BreadcrumbChainItem = { + title: resolveMenuTitle(node), + path: node.path || undefined, + icon: node.icon || undefined, + }; + const chain = [...ancestors, item]; + if (node.id) { + map.set(node.id, chain); + } + if (node.children?.length) { + walk(node.children, chain); + } + } + } + walk(menus, []); + return map; +} + /** * 根据 menuType 过滤按钮类型节点(按钮不生成可导航路由,仅作权限标识) */ @@ -76,8 +111,8 @@ function filterPermissionAnchorMenus(menus: PermMenuResult[]): PermMenuResult[] } /** - * 处理单个菜单节点,提取其子菜单中的子页面并提升到当前层级 - * 中文:子页面在数据库中作为菜单的子节点维护,但路由生成时需要提升到目录级别 + * 处理单个菜单节点,提取其子菜单中的子页面/子页面分组并提升到当前层级 + * 中文:子页面/子页面分组在数据库中作为菜单的子节点维护,但路由生成时需要提升到目录级别 */ function processMenuNode(menu: PermMenuResult): PermMenuResult { if (!menu.children || menu.children.length === 0) { @@ -85,24 +120,30 @@ function processMenuNode(menu: PermMenuResult): PermMenuResult { } const processedChildren: PermMenuResult[] = []; - const liftedSubpages: PermMenuResult[] = []; + // 从 menu 类型子节点中提升出来的子页面类节点(subpage / subpage_group) + const lifted: PermMenuResult[] = []; for (const child of menu.children) { const processedChild = processMenuNode(child); - if (processedChild.children && processedChild.children.length > 0) { - const subpages = processedChild.children.filter((c) => c.menuType === 'subpage'); - const nonSubpages = processedChild.children.filter((c) => c.menuType !== 'subpage'); + // 仅 menu 类型节点下的子页面类需要提升到目录层级; + // subpage_group 下的 subpage 保留不动(分组整体提升,保持 group>subpage 嵌套用于面包屑) + if (processedChild.menuType === 'menu' && processedChild.children && processedChild.children.length > 0) { + const liftable = processedChild.children.filter( + (c) => c.menuType === 'subpage' || c.menuType === 'subpage_group', + ); + const remaining = processedChild.children.filter( + (c) => c.menuType !== 'subpage' && c.menuType !== 'subpage_group', + ); - liftedSubpages.push(...subpages); - - processedChild.children = nonSubpages.length > 0 ? nonSubpages : undefined; + lifted.push(...liftable); + processedChild.children = remaining.length > 0 ? remaining : undefined; } processedChildren.push(processedChild); } - const finalChildren = [...processedChildren, ...liftedSubpages]; + const finalChildren = [...processedChildren, ...lifted]; return { ...menu, @@ -128,7 +169,10 @@ function extractAndLiftSubpages(menus: PermMenuResult[]): PermMenuResult[] { * - link:外链路由,写入 meta.link + meta.external * - button:不过滤(已在入参层移除),保留子级处理 */ -function convertMenuToRoute(menu: PermMenuResult): RouteRecordStringComponent { +function convertMenuToRoute( + menu: PermMenuResult, + breadcrumbMap?: Map, +): RouteRecordStringComponent { const title = resolveMenuTitle(menu); const menuType = menu.menuType || 'menu'; @@ -150,7 +194,7 @@ function convertMenuToRoute(menu: PermMenuResult): RouteRecordStringComponent { badgeType: (menu.badgeType as 'dot' | 'normal') || 'normal', badgeVariants: menu.badgeVariants || 'subtle', }, - children: menu.children ? menu.children.map((child) => convertMenuToRoute(child)) : undefined, + children: menu.children ? menu.children.map((child) => convertMenuToRoute(child, breadcrumbMap)) : undefined, }; // 根据 menuType 分支处理 @@ -182,6 +226,20 @@ function convertMenuToRoute(menu: PermMenuResult): RouteRecordStringComponent { // 子页面:使用 component,强制隐藏菜单 route.component = menu.component || ''; route.meta!.hideInMenu = true; + // 注入完整面包屑链(含 catalog/menu/group),弥补路由提升后 matched 缺失 menu 层 + if (menu.id && breadcrumbMap?.has(menu.id)) { + route.meta!.customBreadcrumb = breadcrumbMap.get(menu.id); + } + break; + } + + case 'subpage_group': { + // 子页面分组:透明容器(无组件),强制隐藏菜单 + // 分组无 path,用 id 派生唯一 name/path(有 children 时 component 会被框架移除成为透明容器) + route.name = `subpage-group-${menu.id}`; + route.component = ''; + route.path = `/_subpage-group/${menu.id}`; + route.meta!.hideInMenu = true; break; } @@ -201,8 +259,10 @@ function convertMenuToRoute(menu: PermMenuResult): RouteRecordStringComponent { */ export function convertMenuListToRoutes(menus: PermMenuResult[]): RouteRecordStringComponent[] { const filtered = filterPermissionAnchorMenus(filterButtonMenus(menus)); + // 提升前构建面包屑祖先链映射(提升会破坏 menu→subpage 父子关系,必须在提升前建立) + const breadcrumbMap = buildBreadcrumbChainMap(filtered); const processed = extractAndLiftSubpages(filtered); - return processed.map((menu) => convertMenuToRoute(menu)); + return processed.map((menu) => convertMenuToRoute(menu, breadcrumbMap)); } /** diff --git a/apps/daxpay-admin/src/api/iam/perm/menu.api.ts b/apps/daxpay-admin/src/api/iam/perm/menu.api.ts index 911e2c8f9..1b298fbd0 100644 --- a/apps/daxpay-admin/src/api/iam/perm/menu.api.ts +++ b/apps/daxpay-admin/src/api/iam/perm/menu.api.ts @@ -77,8 +77,43 @@ function filterPermissionAnchorMenus(menus: PermMenu[]): PermMenu[] { } /** - * 处理单个菜单节点,提取其子菜单中的子页面并提升到当前层级 - * 中文:子页面在数据库中作为菜单的子节点维护,但路由生成时需要提升到目录级别 + * 面包屑链项 + */ +interface BreadcrumbChainItem { + icon?: string; + path?: string; + title?: string; +} + +/** + * 构建 id → 祖先链(含自身)的映射 + * 子页面路由提升后会丢失 menu 父级,提升前用原始树构建完整层级链,供面包屑补全 + */ +function buildBreadcrumbChainMap(menus: PermMenu[]): Map { + const map = new Map(); + function walk(nodes: PermMenu[], ancestors: BreadcrumbChainItem[]) { + for (const node of nodes) { + const item: BreadcrumbChainItem = { + title: resolveMenuTitle(node), + path: node.path || undefined, + icon: node.icon || undefined, + }; + const chain = [...ancestors, item]; + if (node.id) { + map.set(node.id, chain); + } + if (node.children?.length) { + walk(node.children, chain); + } + } + } + walk(menus, []); + return map; +} + +/** + * 处理单个菜单节点,提取其子菜单中的子页面/子页面分组并提升到当前层级 + * 中文:子页面/子页面分组在数据库中作为菜单的子节点维护,但路由生成时需要提升到目录级别 */ function processMenuNode(menu: PermMenu): PermMenu { if (!menu.children || menu.children.length === 0) { @@ -86,24 +121,34 @@ function processMenuNode(menu: PermMenu): PermMenu { } const processedChildren: PermMenu[] = []; - const liftedSubpages: PermMenu[] = []; + // 从 menu 类型子节点中提升出来的子页面类节点(subpage / subpage_group) + const lifted: PermMenu[] = []; for (const child of menu.children) { const processedChild = processMenuNode(child); - if (processedChild.children && processedChild.children.length > 0) { - const subpages = processedChild.children.filter((c) => c.menuType === MenuTypeEnum.SUBPAGE); - const nonSubpages = processedChild.children.filter((c) => c.menuType !== MenuTypeEnum.SUBPAGE); + // 仅 menu 类型节点下的子页面类需要提升到目录层级; + // subpage_group 下的 subpage 保留不动(分组整体提升,保持 group>subpage 嵌套用于面包屑) + if ( + processedChild.menuType === MenuTypeEnum.MENU && + processedChild.children && + processedChild.children.length > 0 + ) { + const liftable = processedChild.children.filter( + (c) => c.menuType === MenuTypeEnum.SUBPAGE || c.menuType === MenuTypeEnum.SUBPAGE_GROUP, + ); + const remaining = processedChild.children.filter( + (c) => c.menuType !== MenuTypeEnum.SUBPAGE && c.menuType !== MenuTypeEnum.SUBPAGE_GROUP, + ); - liftedSubpages.push(...subpages); - - processedChild.children = nonSubpages.length > 0 ? nonSubpages : undefined; + lifted.push(...liftable); + processedChild.children = remaining.length > 0 ? remaining : undefined; } processedChildren.push(processedChild); } - const finalChildren = [...processedChildren, ...liftedSubpages]; + const finalChildren = [...processedChildren, ...lifted]; return { ...menu, @@ -129,7 +174,10 @@ function extractAndLiftSubpages(menus: PermMenu[]): PermMenu[] { * - link:外链路由,写入 meta.link + meta.external * - button:不过滤(已在入参层移除),保留子级处理 */ -function convertMenuToRoute(menu: PermMenu): RouteRecordStringComponent { +function convertMenuToRoute( + menu: PermMenu, + breadcrumbMap?: Map, +): RouteRecordStringComponent { const title = resolveMenuTitle(menu); const menuType = menu.menuType || MenuTypeEnum.MENU; @@ -151,7 +199,7 @@ function convertMenuToRoute(menu: PermMenu): RouteRecordStringComponent { badgeType: (menu.badgeType as 'dot' | 'normal') || 'normal', badgeVariants: menu.badgeVariants || 'subtle', }, - children: menu.children ? menu.children.map((child) => convertMenuToRoute(child)) : undefined, + children: menu.children ? menu.children.map((child) => convertMenuToRoute(child, breadcrumbMap)) : undefined, }; // 根据 menuType 分支处理 @@ -183,6 +231,20 @@ function convertMenuToRoute(menu: PermMenu): RouteRecordStringComponent { // 子页面:使用 component,强制隐藏菜单 route.component = menu.component || ''; route.meta!.hideInMenu = true; + // 注入完整面包屑链(含 catalog/menu/group),弥补路由提升后 matched 缺失 menu 层 + if (menu.id && breadcrumbMap?.has(menu.id)) { + route.meta!.customBreadcrumb = breadcrumbMap.get(menu.id); + } + break; + } + + case MenuTypeEnum.SUBPAGE_GROUP: { + // 子页面分组:透明容器(无组件),强制隐藏菜单 + // 分组无 path,用 id 派生唯一 name/path(有 children 时 component 会被框架移除成为透明容器) + route.name = `subpage-group-${menu.id}`; + route.component = ''; + route.path = `/_subpage-group/${menu.id}`; + route.meta!.hideInMenu = true; break; } @@ -202,8 +264,10 @@ function convertMenuToRoute(menu: PermMenu): RouteRecordStringComponent { */ export function convertMenuListToRoutes(menus: PermMenu[]): RouteRecordStringComponent[] { const filtered = filterPermissionAnchorMenus(filterButtonMenus(menus)); + // 提升前构建面包屑祖先链映射(提升会破坏 menu→subpage 父子关系,必须在提升前建立) + const breadcrumbMap = buildBreadcrumbChainMap(filtered); const processed = extractAndLiftSubpages(filtered); - return processed.map((menu) => convertMenuToRoute(menu)); + return processed.map((menu) => convertMenuToRoute(menu, breadcrumbMap)); } /** diff --git a/apps/daxpay-admin/src/enums/menuType.ts b/apps/daxpay-admin/src/enums/menuType.ts index b39393d66..bdb620300 100644 --- a/apps/daxpay-admin/src/enums/menuType.ts +++ b/apps/daxpay-admin/src/enums/menuType.ts @@ -12,6 +12,8 @@ export const MenuTypeEnum = { MENU: 'menu', /** 子页面 */ SUBPAGE: 'subpage', + /** 子页面分组 */ + SUBPAGE_GROUP: 'subpage_group', } as const; export type MenuTypeEnum = (typeof MenuTypeEnum)[keyof typeof MenuTypeEnum]; @@ -23,6 +25,7 @@ export const menuTypeOptions: { label: string; value: string }[] = [ { label: 'iam.menu.typeCatalog', value: MenuTypeEnum.CATALOG }, { label: 'iam.menu.typeMenu', value: MenuTypeEnum.MENU }, { label: 'iam.menu.typeSubpage', value: MenuTypeEnum.SUBPAGE }, + { label: 'iam.menu.typeSubpageGroup', value: MenuTypeEnum.SUBPAGE_GROUP }, { label: 'iam.menu.typeEmbedded', value: MenuTypeEnum.EMBEDDED }, { label: 'iam.menu.typeLink', value: MenuTypeEnum.LINK }, ]; @@ -34,6 +37,7 @@ export const menuTypeColorMap: Record = { [MenuTypeEnum.CATALOG]: 'blue', [MenuTypeEnum.MENU]: 'green', [MenuTypeEnum.SUBPAGE]: 'cyan', + [MenuTypeEnum.SUBPAGE_GROUP]: 'geekblue', [MenuTypeEnum.EMBEDDED]: 'orange', [MenuTypeEnum.LINK]: 'purple', }; @@ -45,6 +49,19 @@ export const menuTypeI18nMap: Record = { [MenuTypeEnum.CATALOG]: 'iam.menu.typeCatalog', [MenuTypeEnum.MENU]: 'iam.menu.typeMenu', [MenuTypeEnum.SUBPAGE]: 'iam.menu.typeSubpage', + [MenuTypeEnum.SUBPAGE_GROUP]: 'iam.menu.typeSubpageGroup', [MenuTypeEnum.EMBEDDED]: 'iam.menu.typeEmbedded', [MenuTypeEnum.LINK]: 'iam.menu.typeLink', }; + +/** + * 菜单类型功能说明国际化Key映射(编辑表单顶部 Alert) + */ +export const menuTypeTipI18nMap: Record = { + [MenuTypeEnum.CATALOG]: 'iam.menu.typeCatalogTip', + [MenuTypeEnum.MENU]: 'iam.menu.typeMenuTip', + [MenuTypeEnum.SUBPAGE]: 'iam.menu.typeSubpageTip', + [MenuTypeEnum.SUBPAGE_GROUP]: 'iam.menu.typeSubpageGroupTip', + [MenuTypeEnum.EMBEDDED]: 'iam.menu.typeEmbeddedTip', + [MenuTypeEnum.LINK]: 'iam.menu.typeLinkTip', +}; diff --git a/apps/daxpay-admin/src/locales/langs/en-US/iam/menu.json b/apps/daxpay-admin/src/locales/langs/en-US/iam/menu.json index e825850de..117a7090f 100644 --- a/apps/daxpay-admin/src/locales/langs/en-US/iam/menu.json +++ b/apps/daxpay-admin/src/locales/langs/en-US/iam/menu.json @@ -19,22 +19,34 @@ "subpageOverviewTab": "Subpage Overview", "subpageOverviewSearch": "Search subpage, parent menu or catalog", "subpageList": "Subpages", + "subpageManage": "Subpage Management", "childMenuList": "Child Menus", "selectNodeHint": "Select a catalog or menu on the left to view its children", "addSubpage": "Add Subpage", "editSubpage": "Edit Subpage", "viewSubpage": "View Subpage", + "addSubpageGroup": "Add Group", + "editSubpageGroup": "Edit Group", + "viewSubpageGroup": "View Group", "subpagePathExtra": "Usually inherits the parent menu path prefix; adjust as needed for routing", "subpageComponentExtra": "Vue component path rendered for this subpage", "subpageCount": "Subpages({count})", + "subpageGroupCount": "Groups({count})", "parentCatalog": "Catalog", "confirmDelete": "Confirm delete this record?", "menuType": "Menu Type", "typeCatalog": "Catalog", "typeMenu": "Menu", "typeSubpage": "Subpage", + "typeSubpageGroup": "Subpage Group", "typeEmbedded": "Embedded", "typeLink": "Link", + "typeCatalogTip": "Sidebar group container; does not render a page. Can hold catalogs, menus, embedded pages, or links", + "typeMenuTip": "Clickable page entry with path and component. Can hold subpages or subpage groups", + "typeSubpageTip": "Hidden route under a menu or group; not shown in the sidebar (e.g. detail/edit pages)", + "typeSubpageGroupTip": "Logical group for subpages; no page render and not shown in the sidebar", + "typeEmbeddedTip": "Embed an external page via iframe inside the system; requires an iframe URL", + "typeLinkTip": "Open an external URL in a new window; requires a link URL", "parentMenu": "Parent Menu", "selectParent": "Please select parent menu", "titleCn": "Title", diff --git a/apps/daxpay-admin/src/locales/langs/zh-CN/iam/menu.json b/apps/daxpay-admin/src/locales/langs/zh-CN/iam/menu.json index 1e472689d..c99bf1339 100644 --- a/apps/daxpay-admin/src/locales/langs/zh-CN/iam/menu.json +++ b/apps/daxpay-admin/src/locales/langs/zh-CN/iam/menu.json @@ -19,22 +19,34 @@ "subpageOverviewTab": "子页面总览", "subpageOverviewSearch": "搜索子页面、所属菜单或目录", "subpageList": "子页面列表", + "subpageManage": "子页面管理", "childMenuList": "子菜单列表", "selectNodeHint": "请在左侧选择目录或菜单,查看其子项", "addSubpage": "新增子页面", "editSubpage": "编辑子页面", "viewSubpage": "查看子页面", + "addSubpageGroup": "新增分组", + "editSubpageGroup": "编辑分组", + "viewSubpageGroup": "查看分组", "subpagePathExtra": "通常继承父菜单路径前缀,可按实际路由调整", "subpageComponentExtra": "子页面实际渲染的 Vue 组件路径", "subpageCount": "子页面({count})", + "subpageGroupCount": "分组({count})", "parentCatalog": "所属目录", "confirmDelete": "是否删除该条数据", "menuType": "菜单类型", "typeCatalog": "目录", "typeMenu": "菜单", "typeSubpage": "子页面", + "typeSubpageGroup": "子页面分组", "typeEmbedded": "内嵌页", "typeLink": "外链", + "typeCatalogTip": "侧栏分组容器,本身不渲染页面;可挂下级目录、菜单、内嵌页或外链", + "typeMenuTip": "可点击的页面入口,需配置路径与组件;下级可挂子页面或子页面分组", + "typeSubpageTip": "挂在菜单或分组下的隐藏路由页,不出现在侧栏,常用于详情、编辑等", + "typeSubpageGroupTip": "子页面的逻辑分组容器,不渲染页面、不显示在侧栏", + "typeEmbeddedTip": "系统内通过 iframe 嵌入外部页面,需填写内嵌地址", + "typeLinkTip": "新窗口打开外部链接,需填写外链地址", "parentMenu": "上级菜单", "selectParent": "请选择父级菜单", "titleCn": "标题", diff --git a/apps/daxpay-admin/src/views/iam/perm/menu/MenuChildrenPanel.vue b/apps/daxpay-admin/src/views/iam/perm/menu/MenuChildrenPanel.vue index b7e8fc126..3853e606d 100644 --- a/apps/daxpay-admin/src/views/iam/perm/menu/MenuChildrenPanel.vue +++ b/apps/daxpay-admin/src/views/iam/perm/menu/MenuChildrenPanel.vue @@ -11,7 +11,12 @@ import { menuTypeColorMap, MenuTypeEnum, menuTypeI18nMap } from '#/enums/menuType'; import { usePermission } from '#/hooks/usePermission'; - import { getDirectMenusUnderCatalog, getDirectSubpages, matchesMenuKeyword } from './menu-tree.util'; + import { + getDirectMenuChildren, + getDirectMenusUnderCatalog, + getDirectSubpages, + matchesMenuKeyword, + } from './menu-tree.util'; const props = defineProps<{ clientCode: string; @@ -24,6 +29,7 @@ }>(); const emits = defineEmits<{ + addGroup: [parent: Menu]; addSubpage: [parent: Menu]; delete: [row: Menu]; edit: [row: Menu]; @@ -43,13 +49,16 @@ total: 0, }); - // 面板模式:menu 下子页面 / catalog 下直属菜单 - const panelMode = computed<'catalogMenus' | 'menuSubpages' | 'none'>(() => { + // 面板模式:catalog 下直属菜单 / menu 下分组+子页面 / 分组下子页面 + const panelMode = computed<'catalogMenus' | 'groupSubpages' | 'menuChildren' | 'none'>(() => { if (!props.selectedNode?.menuType) { return 'none'; } if (props.selectedNode.menuType === MenuTypeEnum.MENU) { - return 'menuSubpages'; + return 'menuChildren'; + } + if (props.selectedNode.menuType === MenuTypeEnum.SUBPAGE_GROUP) { + return 'groupSubpages'; } if (props.selectedNode.menuType === MenuTypeEnum.CATALOG) { return 'catalogMenus'; @@ -62,7 +71,10 @@ return ''; } const name = getDisplayTitle(props.selectedNode); - if (panelMode.value === 'menuSubpages') { + if (panelMode.value === 'menuChildren') { + return `${$t('iam.menu.subpageManage')} - ${name}`; + } + if (panelMode.value === 'groupSubpages') { return `${$t('iam.menu.subpageList')} - ${name}`; } if (panelMode.value === 'catalogMenus') { @@ -75,7 +87,10 @@ if (!props.selectedNode || panelMode.value === 'none') { return [] as Menu[]; } - if (panelMode.value === 'menuSubpages') { + if (panelMode.value === 'menuChildren') { + return getDirectMenuChildren(props.selectedNode, props.menuMap); + } + if (panelMode.value === 'groupSubpages') { return getDirectSubpages(props.selectedNode, props.menuMap); } return getDirectMenusUnderCatalog(props.selectedNode, props.menuMap); @@ -212,7 +227,7 @@