feat(composables): 提取菜单选项生成逻辑到独立 composables
All checks were successful
CI/CD Pipeline / playwright (push) Successful in 4m0s
CI/CD Pipeline / build-and-deploy (push) Successful in 4m33s

This commit is contained in:
严浩
2025-10-24 12:14:46 +08:00
parent 990f2811ae
commit c11473edb5
5 changed files with 159 additions and 143 deletions

View File

@@ -0,0 +1,150 @@
import enUS from '@/pages/_page-title-locales/en-US';
import zhCN from '@/pages/_page-title-locales/zh-CN';
import type { Ref } from 'vue';
import type { MenuInst, MenuOption } from 'naive-ui';
import { createGetRoutes } from 'virtual:meta-layouts';
import { RouterLink, type RouteRecordRaw } from 'vue-router';
import IconMenuRounded from '~icons/material-symbols/menu-rounded';
export function useMetaLayoutsNMenuOptions({ menuInstRef }: { menuInstRef: Ref<MenuInst | null> }) {
// 路由转换为菜单树的辅助函数
function convertRoutesToNMenuOptions(routes: Readonly<RouteRecordRaw[]>): MenuOption[] {
const menuMap = new Map<string, MenuOption>();
const rootMenus: MenuOption[] = [];
// 过滤和排序路由
const validRoutes = routes
.filter((route) => {
// 过滤掉不需要显示的路由
if (route.meta?.hideInMenu === true || route.meta?.layout === false) {
return false;
}
// 过滤掉通配符路径
if (route.path.includes('*')) {
return false;
}
// 根据环境变量判断是否显示 /demos 开头的路由
if (route.path.startsWith('/demos') && import.meta.env.VITE_MENU_SHOW_DEMOS !== 'true') {
return false;
}
return true;
})
// 排序路由,确保父路由总是在子路由之前,同级路由则根据 `meta.order` 排序
.sort((a: RouteRecordRaw, b: RouteRecordRaw) => {
const pathA = a.path;
const pathB = b.path;
const segmentsA = pathA.split('/').filter(Boolean);
const segmentsB = pathB.split('/').filter(Boolean);
const parentAPath = `/${segmentsA.slice(0, -1).join('/')}`;
const parentBPath = `/${segmentsB.slice(0, -1).join('/')}`;
// 如果不是同级路由,则按路径排序,确保父路由在前
if (parentAPath !== parentBPath) {
return pathA.localeCompare(pathB);
}
// 同级路由,处理 `meta.order`
const orderA = a.meta?.order;
const orderB = b.meta?.order;
const hasOrderA = orderA !== undefined;
const hasOrderB = orderB !== undefined;
// 当一个有 order 而另一个没有时,有 order 的排在前面
if (hasOrderA !== hasOrderB) {
return hasOrderA ? -1 : 1;
}
// 当两个都有 order 时,按 order 值升序排序
if (hasOrderA && hasOrderB) {
const orderDiff = orderA - orderB;
if (orderDiff !== 0) {
return orderDiff;
}
}
// order 相同或都没有 order按路径字母顺序排序
return pathA.localeCompare(pathB);
});
// 构建菜单树
for (const route of validRoutes) {
const pathSegments = route.path.split('/').filter(Boolean);
const routeName = route.name as string;
const text = te(routeName) ? t(routeName) : route.meta?.title || routeName;
const menuOption: MenuOption = {
label: () =>
route.meta?.link === false ? text : <RouterLink to={route}>{text}</RouterLink>,
key: route.path,
icon: () => <IconMenuRounded style="width: 1em; height: 1em;" />,
};
// 如果是根路径或只有一级路径,直接添加到根菜单
if (pathSegments.length === 0 || pathSegments.length === 1) {
rootMenus.push(menuOption);
menuMap.set(route.path, menuOption);
} else {
// 多级路径,需要创建或找到父菜单
let currentPath = '';
for (let i = 0; i < pathSegments.length - 1; i++) {
currentPath += `/${pathSegments[i]}`;
}
// 将当前菜单项添加到父菜单
const parentPath = currentPath;
const parent = menuMap.get(parentPath);
if (parent) {
if (!parent.children) {
parent.children = [];
}
parent.children.push(menuOption);
} else {
consola.warn(`未找到父菜单项: ${parentPath},无法将子菜单项添加到其下。`);
}
menuMap.set(route.path, menuOption);
}
}
return rootMenus;
}
const router = useRouter();
const { t, te } = useI18n({
inheritLocale: true,
useScope: 'local',
missing: (locale, key) => {
consola.warn(`菜单翻译缺失: locale=${locale}, key=${key}`);
return key;
},
fallbackRoot: true,
messages: {
'zh-CN': zhCN,
'en-US': enUS,
},
});
// 获取路由表但是不包含布局路由
const routes = createGetRoutes(router)();
const options = computed(() => convertRoutesToNMenuOptions(routes));
const selectedKey = ref('');
watch(
() => router.currentRoute.value.path,
(newPath) => {
menuInstRef.value?.showOption(newPath);
selectedKey.value = newPath;
},
{ immediate: true },
);
// console.debug('原始路由:', JSON.stringify(routes, null, 0));
// console.debug('转换后的菜单:', JSON.stringify(menuOptions.value, null, 0));
return {
options,
selectedKey,
};
}

View File

@@ -1,146 +1,10 @@
<script setup lang="tsx">
import { createGetRoutes, router } from '@/plugins/00.router-plugin';
import type { MenuOption } from 'naive-ui';
import { RouterLink, type RouteRecordRaw } from 'vue-router';
import IconMenuRounded from '~icons/material-symbols/menu-rounded';
import { useAppStore } from '@/stores/app-store';
import zhCN from '@/pages/_page-title-locales/zh-CN';
import enUS from '@/pages/_page-title-locales/en-US';
const { t, te } = useI18n({
inheritLocale: true,
useScope: 'local',
missing: (locale, key) => {
consola.warn(`菜单翻译缺失: locale=${locale}, key=${key}`);
return key;
},
fallbackRoot: true,
messages: {
'zh-CN': zhCN,
'en-US': enUS,
},
});
// 路由转换为菜单树的辅助函数
function convertRoutesToMenuOptions(routes: Readonly<RouteRecordRaw[]>): MenuOption[] {
const menuMap = new Map<string, MenuOption>();
const rootMenus: MenuOption[] = [];
// 过滤和排序路由
const validRoutes = routes
.filter((route) => {
// 过滤掉不需要显示的路由
if (route.meta?.hideInMenu === true || route.meta?.layout === false) {
return false;
}
// 过滤掉通配符路径
if (route.path.includes('*')) {
return false;
}
// 根据环境变量判断是否显示 /demos 开头的路由
if (route.path.startsWith('/demos') && import.meta.env.VITE_MENU_SHOW_DEMOS !== 'true') {
return false;
}
return true;
})
// 排序路由,确保父路由总是在子路由之前,同级路由则根据 `meta.order` 排序
.sort((a: RouteRecordRaw, b: RouteRecordRaw) => {
const pathA = a.path;
const pathB = b.path;
const segmentsA = pathA.split('/').filter(Boolean);
const segmentsB = pathB.split('/').filter(Boolean);
const parentAPath = `/${segmentsA.slice(0, -1).join('/')}`;
const parentBPath = `/${segmentsB.slice(0, -1).join('/')}`;
// 如果不是同级路由,则按路径排序,确保父路由在前
if (parentAPath !== parentBPath) {
return pathA.localeCompare(pathB);
}
// 同级路由,处理 `meta.order`
const orderA = a.meta?.order;
const orderB = b.meta?.order;
const hasOrderA = orderA !== undefined;
const hasOrderB = orderB !== undefined;
// 当一个有 order 而另一个没有时,有 order 的排在前面
if (hasOrderA !== hasOrderB) {
return hasOrderA ? -1 : 1;
}
// 当两个都有 order 时,按 order 值升序排序
if (hasOrderA && hasOrderB) {
const orderDiff = orderA - orderB;
if (orderDiff !== 0) {
return orderDiff;
}
}
// order 相同或都没有 order按路径字母顺序排序
return pathA.localeCompare(pathB);
});
// 构建菜单树
for (const route of validRoutes) {
const pathSegments = route.path.split('/').filter(Boolean);
const routeName = route.name as string;
const text = te(routeName) ? t(routeName) : route.meta?.title || routeName;
const menuOption: MenuOption = {
label: () => (route.meta?.link === false ? text : <RouterLink to={route}>{text}</RouterLink>),
key: route.path,
icon: () => <IconMenuRounded style="width: 1em; height: 1em;" />,
};
// 如果是根路径或只有一级路径,直接添加到根菜单
if (pathSegments.length === 0 || pathSegments.length === 1) {
rootMenus.push(menuOption);
menuMap.set(route.path, menuOption);
} else {
// 多级路径,需要创建或找到父菜单
let currentPath = '';
for (let i = 0; i < pathSegments.length - 1; i++) {
currentPath += `/${pathSegments[i]}`;
}
// 将当前菜单项添加到父菜单
const parentPath = currentPath;
const parent = menuMap.get(parentPath);
if (parent) {
if (!parent.children) {
parent.children = [];
}
parent.children.push(menuOption);
} else {
console.warn(`未找到父菜单项: ${parentPath},无法将子菜单项添加到其下。`);
}
menuMap.set(route.path, menuOption);
}
}
return rootMenus;
}
// 获取路由表但是不包含布局路由
const routes = createGetRoutes(router)();
const menuOptions = computed(() => convertRoutesToMenuOptions(routes));
// console.debug('原始路由:', JSON.stringify(routes, null, 0));
// console.debug('转换后的菜单:', JSON.stringify(menuOptions.value, null, 0));
const menuInstRef = useTemplateRef('menuInstRef');
const selectedKey = ref('');
watch(
() => router.currentRoute.value.path,
(newPath) => {
menuInstRef.value?.showOption(newPath);
selectedKey.value = newPath;
},
{ immediate: true },
);
const { options, selectedKey } = useMetaLayoutsNMenuOptions({
menuInstRef,
});
const appStore = useAppStore();
</script>
@@ -148,14 +12,14 @@ const appStore = useAppStore();
<template>
<!-- @update:value="handleMenuUpdate" -->
<NMenu
ref="menuInstRef"
mode="vertical"
ref="menuInstRef"
:collapsed="appStore.sidebarCollapsed"
:collapsed-width="64"
:icon-size="20"
:collapsed-icon-size="24"
v-model:value="selectedKey"
:options="menuOptions"
:options="options"
:inverted="false"
:root-indent="32"
:indent="32"

View File

@@ -70,7 +70,7 @@ declare module 'vue-router' {
}
export { router, setupLayoutsResult };
export { createGetRoutes } from 'virtual:meta-layouts';
declare global {
type PageTitleLocalizations = Record<keyof RouteNamedMap, string>;
}