feat(admin): 菜单支持子页面分组并完善类型编辑体验

新增 subpage_group 类型与路由/列表/编辑支持;编辑表单按类型展示功能说明,且编辑/查看时锁定菜单类型(全量展示对照)。
This commit is contained in:
DaxPay Dev
2026-07-11 12:24:13 +08:00
parent 162bfd02e5
commit fb9ee94bbc
11 changed files with 410 additions and 83 deletions

View File

@@ -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<string, BreadcrumbChainItem[]> {
const map = new Map<string, BreadcrumbChainItem[]>();
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<string, BreadcrumbChainItem[]>,
): 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));
}
/**

View File

@@ -77,8 +77,43 @@ function filterPermissionAnchorMenus(menus: PermMenu[]): PermMenu[] {
}
/**
* 处理单个菜单节点,提取其子菜单中的子页面并提升到当前层级
* 中文:子页面在数据库中作为菜单的子节点维护,但路由生成时需要提升到目录级别
* 面包屑链项
*/
interface BreadcrumbChainItem {
icon?: string;
path?: string;
title?: string;
}
/**
* 构建 id → 祖先链(含自身)的映射
* 子页面路由提升后会丢失 menu 父级,提升前用原始树构建完整层级链,供面包屑补全
*/
function buildBreadcrumbChainMap(menus: PermMenu[]): Map<string, BreadcrumbChainItem[]> {
const map = new Map<string, BreadcrumbChainItem[]>();
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<string, BreadcrumbChainItem[]>,
): 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));
}
/**

View File

@@ -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<string, string> = {
[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<string, string> = {
[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<string, string> = {
[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',
};

View File

@@ -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",

View File

@@ -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": "标题",

View File

@@ -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 @@
<template>
<div class="h-full min-h-0 flex flex-col">
<template v-if="panelMode === 'none'">
<a-empty class="mt-16" :description="$t('iam.menu.selectNodeHint')" />
<a-empty class="!mt-16" :description="$t('iam.menu.selectNodeHint')" />
</template>
<template v-else>
<div class="mb-3 shrink-0 font-medium">{{ panelTitle }}</div>
@@ -226,7 +241,17 @@
style="width: 280px"
/>
<a-button
v-if="panelMode === 'menuSubpages' && hasPermission(PermCodes.Iam.PermMenu.MANAGE)"
v-if="panelMode === 'menuChildren' && hasPermission(PermCodes.Iam.PermMenu.MANAGE)"
type="primary"
@click="emits('addGroup', selectedNode!)"
>
{{ $t('iam.menu.addSubpageGroup') }}
</a-button>
<a-button
v-if="
(panelMode === 'menuChildren' || panelMode === 'groupSubpages') &&
hasPermission(PermCodes.Iam.PermMenu.MANAGE)
"
type="primary"
@click="emits('addSubpage', selectedNode!)"
>

View File

@@ -6,7 +6,7 @@
import { type Menu, MenuApi } from '#/api/iam/perm/menu.api';
import { IconPicker } from '#/components/icon-picker';
import { FormEditType } from '#/enums/formEditType';
import { MenuTypeEnum, menuTypeOptions } from '#/enums/menuType';
import { MenuTypeEnum, menuTypeOptions, menuTypeTipI18nMap } from '#/enums/menuType';
import { useFormEdit } from '#/hooks/useFormEdit';
import { useMessage } from '#/hooks/useMessage';
import { useValidate } from '#/hooks/useValidate';
@@ -23,6 +23,8 @@
clientCode?: string;
parentRow?: Menu;
parentMenuType?: string;
/** 外部预设菜单类型(如"添加分组"按钮强制 subpage_group */
defaultMenuType?: string;
}
const { initFormEditType, handleCancel, visible, title, confirmLoading, showable, formEditType } = useFormEdit();
@@ -42,9 +44,10 @@
// 父级菜单树数据
const treeData = ref<any[]>([]);
// 可用的菜单类型选项(根据父菜单类型动态计算)
// 可用的菜单类型选项(编辑/查看展示全部并锁定;新增时根据父菜单类型动态计算)
const availableMenuTypeOptions = computed(() => {
if (formEditType.value === FormEditType.Edit && form.value.menuType === MenuTypeEnum.SUBPAGE) {
// 编辑/查看展示全部类型disabled 锁定),便于对照理解
if (formEditType.value !== FormEditType.Add) {
return menuTypeOptions.map((o) => ({ label: $t(o.label), value: o.value }));
}
if (extraParams.value?.parentMenuType) {
@@ -62,12 +65,16 @@
);
}
case MenuTypeEnum.MENU: {
return (
menuTypeOptions
.filter((o) => o.value === MenuTypeEnum.SUBPAGE)
// 国际化:获取菜单类型选项的显示标签
.map((o) => ({ label: $t(o.label), value: o.value }))
);
// menu 下可选子页面或子页面分组
return menuTypeOptions
.filter((o) => o.value === MenuTypeEnum.SUBPAGE || o.value === MenuTypeEnum.SUBPAGE_GROUP)
.map((o) => ({ label: $t(o.label), value: o.value }));
}
case MenuTypeEnum.SUBPAGE_GROUP: {
// 分组下只能子页面
return menuTypeOptions
.filter((o) => o.value === MenuTypeEnum.SUBPAGE)
.map((o) => ({ label: $t(o.label), value: o.value }));
}
default: {
return [];
@@ -76,7 +83,7 @@
}
return (
menuTypeOptions
.filter((o) => o.value !== MenuTypeEnum.SUBPAGE)
.filter((o) => o.value !== MenuTypeEnum.SUBPAGE && o.value !== MenuTypeEnum.SUBPAGE_GROUP)
// 国际化:获取菜单类型选项的显示标签
.map((o) => ({ label: $t(o.label), value: o.value }))
);
@@ -128,19 +135,23 @@
);
const menuCodeRequired = computed(() => form.value.menuType === MenuTypeEnum.MENU);
const showSortNo = computed(() => form.value.menuType !== MenuTypeEnum.SUBPAGE);
const showHidden = computed(() => form.value.menuType !== MenuTypeEnum.SUBPAGE);
const showHidden = computed(
() => form.value.menuType !== MenuTypeEnum.SUBPAGE && form.value.menuType !== MenuTypeEnum.SUBPAGE_GROUP,
);
const showAffixTab = computed(() => form.value.menuType === MenuTypeEnum.MENU);
const showBadge = computed(() => form.value.menuType !== MenuTypeEnum.SUBPAGE);
const showBadge = computed(
() => form.value.menuType !== MenuTypeEnum.SUBPAGE && form.value.menuType !== MenuTypeEnum.SUBPAGE_GROUP,
);
// 有可选类型时展示菜单类型区(含子页面仅一项场景)
const showMenuType = computed(() => availableMenuTypeOptions.value.length > 0);
// 子页面场景锁定类型不可切换
const menuTypeLocked = computed(
() =>
showable.value ||
form.value.menuType === MenuTypeEnum.SUBPAGE ||
extraParams.value?.parentMenuType === MenuTypeEnum.MENU,
);
// 仅新增时可切换类型;编辑/查看一律锁定
const menuTypeLocked = computed(() => formEditType.value !== FormEditType.Add);
const isSubpage = computed(() => form.value.menuType === MenuTypeEnum.SUBPAGE);
// 当前菜单类型的功能说明(编辑表单顶部 Alert
const menuTypeTip = computed(() => {
const key = form.value.menuType && menuTypeTipI18nMap[form.value.menuType];
return key ? $t(key) : '';
});
// 过滤后的上级菜单树数据 - 使用 disabled 标记方式显示完整树
const parentTreeData = computed(() => {
return markDisabledNodes(treeData.value);
@@ -244,6 +255,16 @@
form.value.hidden = true;
break;
}
case MenuTypeEnum.SUBPAGE_GROUP: {
// 子页面分组:纯容器,清空路由/组件/编码相关字段
form.value.component = '';
form.value.path = '';
form.value.menuCode = '';
form.value.iframeSrc = '';
form.value.link = '';
form.value.hidden = true;
break;
}
}
},
);
@@ -290,9 +311,13 @@
if (params?.parentRow) {
// 根据父菜单类型设置默认的子菜单类型
let defaultMenuType: string = MenuTypeEnum.MENU;
if (params.parentMenuType === MenuTypeEnum.MENU) {
if (params.parentMenuType === MenuTypeEnum.MENU || params.parentMenuType === MenuTypeEnum.SUBPAGE_GROUP) {
defaultMenuType = MenuTypeEnum.SUBPAGE;
}
// 外部预设类型优先(如"添加分组"按钮强制 subpage_group
if (params.defaultMenuType) {
defaultMenuType = params.defaultMenuType;
}
form.value = {
...form.value,
clientCode: params.clientCode,
@@ -311,22 +336,23 @@
*/
function applySubpageDrawerTitle(menuType?: string) {
const type = menuType ?? form.value.menuType;
if (type !== MenuTypeEnum.SUBPAGE) {
if (type !== MenuTypeEnum.SUBPAGE && type !== MenuTypeEnum.SUBPAGE_GROUP) {
return;
}
const isGroup = type === MenuTypeEnum.SUBPAGE_GROUP;
switch (formEditType.value) {
case FormEditType.Add: {
title.value = $t('iam.menu.addSubpage');
title.value = isGroup ? $t('iam.menu.addSubpageGroup') : $t('iam.menu.addSubpage');
break;
}
case FormEditType.Edit: {
title.value = $t('iam.menu.editSubpage');
title.value = isGroup ? $t('iam.menu.editSubpageGroup') : $t('iam.menu.editSubpage');
break;
}
case FormEditType.Show: {
title.value = $t('iam.menu.viewSubpage');
title.value = isGroup ? $t('iam.menu.viewSubpageGroup') : $t('iam.menu.viewSubpage');
break;
}
@@ -382,15 +408,23 @@
* 标记不可选的节点(显示完整树,但禁用不符合条件的节点)
*/
function markDisabledNodes(nodes: any[]): any[] {
return nodes.map((node) => ({
...node,
// 子页面只能选择菜单类型,其他类型只能选择目录类型
disabled:
form.value.menuType === MenuTypeEnum.SUBPAGE
? node.menuType !== MenuTypeEnum.MENU
: node.menuType !== MenuTypeEnum.CATALOG,
children: node.children ? markDisabledNodes(node.children) : undefined,
}));
const currentType = form.value.menuType;
return nodes.map((node) => {
// 各菜单类型可选的父级类型subpage→menu/groupsubpage_group→menu其余→catalog
let disabled: boolean;
if (currentType === MenuTypeEnum.SUBPAGE) {
disabled = node.menuType !== MenuTypeEnum.MENU && node.menuType !== MenuTypeEnum.SUBPAGE_GROUP;
} else if (currentType === MenuTypeEnum.SUBPAGE_GROUP) {
disabled = node.menuType !== MenuTypeEnum.MENU;
} else {
disabled = node.menuType !== MenuTypeEnum.CATALOG;
}
return {
...node,
disabled,
children: node.children ? markDisabledNodes(node.children) : undefined,
};
});
}
/**
@@ -456,6 +490,8 @@
{{ item.label }}
</a-radio-button>
</a-radio-group>
<!-- 当前类型功能说明 -->
<a-alert v-if="menuTypeTip" class="!mt-3" type="info" show-icon :message="menuTypeTip" />
</a-form-item>
<!-- 国际化菜单编码 -->

View File

@@ -75,7 +75,10 @@
*/
function getActionMenu(row: Menu) {
const items = [];
if (row.menuType && ([MenuTypeEnum.CATALOG, MenuTypeEnum.MENU] as string[]).includes(row.menuType)) {
if (
row.menuType &&
([MenuTypeEnum.CATALOG, MenuTypeEnum.MENU, MenuTypeEnum.SUBPAGE_GROUP] as string[]).includes(row.menuType)
) {
items.push({ key: 'addChild', label: $t('iam.menu.addChild') });
}
items.push({ key: 'delete', label: $t('common.delete'), danger: true });
@@ -193,7 +196,11 @@
if (!row?.menuType) {
return;
}
if (row.menuType === MenuTypeEnum.CATALOG || row.menuType === MenuTypeEnum.MENU) {
if (
row.menuType === MenuTypeEnum.CATALOG ||
row.menuType === MenuTypeEnum.MENU ||
row.menuType === MenuTypeEnum.SUBPAGE_GROUP
) {
selectedNode.value = row;
panelSearchKeyword.value = '';
highlightSubpageId.value = undefined;
@@ -234,13 +241,25 @@
}
/**
* 从右侧面板添加子页面
* 从右侧面板添加子页面(父可能是 menu 直挂或 subpage_group
*/
function handleAddSubpage(parentMenu: Menu) {
menuEdit.value.init(undefined, FormEditType.Add, {
clientCode: clientCode.value,
parentRow: parentMenu,
parentMenuType: parentMenu.menuType,
});
}
/**
* 从右侧面板添加子页面分组
*/
function handleAddGroup(parentMenu: Menu) {
menuEdit.value.init(undefined, FormEditType.Add, {
clientCode: clientCode.value,
parentRow: parentMenu,
parentMenuType: MenuTypeEnum.MENU,
defaultMenuType: MenuTypeEnum.SUBPAGE_GROUP,
});
}
@@ -384,7 +403,21 @@
<template #default="{ row }">
<IconifyIcon v-if="row.icon" :icon="row.icon" class="text-lg inline-block align-middle mr-2" />
<span>{{ getDisplayTitle(row) }}</span>
<a-tag v-if="row.menuType === MenuTypeEnum.MENU && row.subpageCount" class="ml-2" color="blue">
<a-tag
v-if="row.menuType === MenuTypeEnum.MENU && row.subpageGroupCount"
class="!ml-2"
color="blue"
>
{{ $t('iam.menu.subpageGroupCount', { count: row.subpageGroupCount }) }}
</a-tag>
<a-tag v-if="row.menuType === MenuTypeEnum.MENU && row.subpageCount" class="!ml-2" color="cyan">
{{ $t('iam.menu.subpageCount', { count: row.subpageCount }) }}
</a-tag>
<a-tag
v-if="row.menuType === MenuTypeEnum.SUBPAGE_GROUP && row.subpageCount"
class="!ml-2"
color="cyan"
>
{{ $t('iam.menu.subpageCount', { count: row.subpageCount }) }}
</a-tag>
</template>
@@ -453,6 +486,7 @@
:highlight-row-id="highlightSubpageId"
:menu-map="menuMap"
:selected-node="selectedNode"
@add-group="handleAddGroup"
@add-subpage="handleAddSubpage"
@delete="handleDeleteConfirm"
@edit="handleEdit"

View File

@@ -7,10 +7,12 @@ import { MenuTypeEnum } from '#/enums/menuType';
* 路由侧由 convertMenuListToRoutes 提升子页面,不在管理端开放多级 subpage。
*/
/** 骨架树节点:不含 subpage 子节点,附带子页面数量 */
/** 骨架树节点:不含 subpage 子节点,附带子页面/分组数量 */
export interface MenuSkeletonNode extends Menu {
/** 直属子页面数量 */
/** 直属子页面数量menu 为未分组直挂数subpage_group 为其下子页面数) */
subpageCount?: number;
/** 直属子页面分组数量(仅 menu 有值) */
subpageGroupCount?: number;
children?: MenuSkeletonNode[];
}
@@ -34,17 +36,26 @@ export function countDirectSubpages(node: Menu): number {
}
/**
* 从树中剥离 subpage构建骨架树catalog / menu / embedded / link
* 从树中剥离 subpage构建骨架树catalog / menu / embedded / link / subpage_group
* subpage_group 作为可展开分组节点保留,其下 subpage 仍剥离
*/
export function buildSkeletonTree(data: Menu[]): MenuSkeletonNode[] {
return data.map((item) => {
const subpages = (item.children || []).filter((child) => child.menuType === MenuTypeEnum.SUBPAGE);
const nonSubpageChildren = (item.children || []).filter((child) => child.menuType !== MenuTypeEnum.SUBPAGE);
const skeletonChildren = buildSkeletonTree(nonSubpageChildren);
const directSubpages = (item.children || []).filter((c) => c.menuType === MenuTypeEnum.SUBPAGE);
const directGroups = (item.children || []).filter((c) => c.menuType === MenuTypeEnum.SUBPAGE_GROUP);
// 骨架子节点:排除 subpage由右侧面板管理保留 subpage_group 作为可展开分组节点
const skeletonChildrenRaw = (item.children || []).filter((c) => c.menuType !== MenuTypeEnum.SUBPAGE);
const builtChildren = buildSkeletonTree(skeletonChildrenRaw);
return {
...item,
children: skeletonChildren.length > 0 ? skeletonChildren : undefined,
subpageCount: item.menuType === MenuTypeEnum.MENU ? subpages.length : undefined,
children: builtChildren.length > 0 ? builtChildren : undefined,
// menu 记直属分组数
subpageGroupCount: item.menuType === MenuTypeEnum.MENU ? directGroups.length : undefined,
// menu 记未分组直挂子页面数subpage_group 记其下子页面数
subpageCount:
item.menuType === MenuTypeEnum.MENU || item.menuType === MenuTypeEnum.SUBPAGE_GROUP
? directSubpages.length
: undefined,
};
});
}
@@ -65,7 +76,7 @@ export function flattenMenuMap(data: Menu[], map = new Map<string, Menu>()): Map
}
/**
* 获取某节点下直属子页面列表
* 获取某节点下直属子页面列表(适用于 menu 直挂 / subpage_group 下)
*/
export function getDirectSubpages(parent: Menu, allMap: Map<string, Menu>): Menu[] {
const node = parent.id ? allMap.get(parent.id) : parent;
@@ -75,6 +86,30 @@ export function getDirectSubpages(parent: Menu, allMap: Map<string, Menu>): Menu
return node.children.filter((child) => child.menuType === MenuTypeEnum.SUBPAGE);
}
/**
* 获取 menu 下直属子页面分组 + 直挂子页面(未分组的老数据)
*/
export function getDirectMenuChildren(menu: Menu, allMap: Map<string, Menu>): Menu[] {
const node = menu.id ? allMap.get(menu.id) : menu;
if (!node?.children?.length) {
return [];
}
return node.children.filter(
(child) => child.menuType === MenuTypeEnum.SUBPAGE_GROUP || child.menuType === MenuTypeEnum.SUBPAGE,
);
}
/**
* 获取 menu 下直属子页面分组列表
*/
export function getDirectSubpageGroups(menu: Menu, allMap: Map<string, Menu>): Menu[] {
const node = menu.id ? allMap.get(menu.id) : menu;
if (!node?.children?.length) {
return [];
}
return node.children.filter((child) => child.menuType === MenuTypeEnum.SUBPAGE_GROUP);
}
/**
* 获取目录下直属 menu / embedded / link不含 subpage不递归
*/
@@ -101,14 +136,15 @@ export function matchesMenuKeyword(node: Menu, keyword: string): boolean {
}
/**
* 在全量菜单树中查找匹配关键字的子页面
* 在全量菜单树中查找匹配关键字的子页面(递归进入子页面分组)
*/
export function findMatchingSubpages(fullTree: Menu[], keyword: string): SubpageMatch[] {
const matches: SubpageMatch[] = [];
function walk(nodes: Menu[]) {
for (const node of nodes) {
if (node.menuType === MenuTypeEnum.MENU) {
// 子页面可挂在 menu 或 subpage_group 下
if (node.menuType === MenuTypeEnum.MENU || node.menuType === MenuTypeEnum.SUBPAGE_GROUP) {
for (const child of node.children || []) {
if (child.menuType === MenuTypeEnum.SUBPAGE && matchesMenuKeyword(child, keyword)) {
matches.push({ subpage: child, parentMenu: node });
@@ -169,7 +205,7 @@ function collectIdsInTree(tree: MenuSkeletonNode[]): Set<string> {
/**
* 在骨架树中查找目标节点的祖先路径(含自身)
*/
function findPathToNode(tree: MenuSkeletonNode[], targetId: string, path: string[] = []): string[] | null {
function findPathToNode(tree: MenuSkeletonNode[], targetId: string, path: string[] = []): null | string[] {
for (const node of tree) {
const currentPath = node.id ? [...path, node.id] : path;
if (node.id === targetId) {

View File

@@ -43,6 +43,11 @@ interface RouteMeta {
| 'success'
| 'warning'
| string;
/**
* 自定义面包屑链
* 优先于 route.matched 渲染,用于子页面路由提升后补全完整层级(catalog>menu>group>subpage)
*/
customBreadcrumb?: { icon?: string; path?: string; title?: string }[];
/**
* 路由对应dom是否缓存起来
*/

View File

@@ -31,6 +31,32 @@ const breadcrumbs = computed((): IBreadcrumb[] => {
const resultBreadcrumb: IBreadcrumb[] = [];
// 优先使用叶子路由自定义面包屑链(子页面路由提升后补全完整层级)
const leaf = matched[matched.length - 1];
if (leaf?.meta?.customBreadcrumb?.length) {
for (const item of leaf.meta.customBreadcrumb) {
if (!item.path) {
continue;
}
resultBreadcrumb.push({
icon: item.icon as any,
path: item.path || route.path,
title: item.title ? $t(item.title) : '',
});
}
if (props.showHome) {
resultBreadcrumb.unshift({
icon: 'mdi:home-outline',
isHome: true,
path: '/',
});
}
if (props.hideWhenOnlyOne && resultBreadcrumb.length === 1) {
return [];
}
return resultBreadcrumb;
}
for (const match of matched) {
const { meta, path } = match;
const { hideChildrenInMenu, hideInBreadcrumb, icon, name, title } =