feat: Dynamically get the menu from the back end

This commit is contained in:
vben
2024-06-30 15:03:37 +08:00
parent 1d70d71537
commit 9572d1a1c5
71 changed files with 1033 additions and 509 deletions

View File

@@ -1,230 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { generatorMenus } from './generator-menus'; // 替换为您的实际路径
import {
type RouteRecordRaw,
type Router,
createRouter,
createWebHistory,
} from 'vue-router';
// Nested route setup to test child inclusion and hideChildrenInMenu functionality
describe('generatorMenus', () => {
// 模拟路由数据
const mockRoutes = [
{
meta: { icon: 'home-icon', title: '首页' },
name: 'home',
path: '/home',
},
{
meta: { hideChildrenInMenu: true, icon: 'about-icon', title: '关于' },
name: 'about',
path: '/about',
children: [
{
path: 'team',
name: 'team',
meta: { icon: 'team-icon', title: '团队' },
},
],
},
] as RouteRecordRaw[];
// 模拟 Vue 路由器实例
const mockRouter = {
getRoutes: vi.fn(() => [
{ name: 'home', path: '/home' },
{ name: 'about', path: '/about' },
{ name: 'team', path: '/about/team' },
]),
};
it('the correct menu list should be generated according to the route', async () => {
const expectedMenus = [
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'home-icon',
name: '首页',
order: undefined,
parent: undefined,
parents: undefined,
path: '/home',
children: [],
},
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'about-icon',
name: '关于',
order: undefined,
parent: undefined,
parents: undefined,
path: '/about',
children: [],
},
];
const menus = await generatorMenus(mockRoutes, mockRouter as any);
expect(menus).toEqual(expectedMenus);
});
it('includes additional meta properties in menu items', async () => {
const mockRoutesWithMeta = [
{
meta: { icon: 'user-icon', order: 1, title: 'Profile' },
name: 'profile',
path: '/profile',
},
] as RouteRecordRaw[];
const menus = await generatorMenus(mockRoutesWithMeta, mockRouter as any);
expect(menus).toEqual([
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'user-icon',
name: 'Profile',
order: 1,
parent: undefined,
parents: undefined,
path: '/profile',
children: [],
},
]);
});
it('handles dynamic route parameters correctly', async () => {
const mockRoutesWithParams = [
{
meta: { icon: 'details-icon', title: 'User Details' },
name: 'userDetails',
path: '/users/:userId',
},
] as RouteRecordRaw[];
const menus = await generatorMenus(mockRoutesWithParams, mockRouter as any);
expect(menus).toEqual([
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'details-icon',
name: 'User Details',
order: undefined,
parent: undefined,
parents: undefined,
path: '/users/:userId',
children: [],
},
]);
});
it('processes routes with redirects correctly', async () => {
const mockRoutesWithRedirect = [
{
name: 'redirectedRoute',
path: '/old-path',
redirect: '/new-path',
},
{
meta: { icon: 'path-icon', title: 'New Path' },
name: 'newPath',
path: '/new-path',
},
] as RouteRecordRaw[];
const menus = await generatorMenus(
mockRoutesWithRedirect,
mockRouter as any,
);
expect(menus).toEqual([
// Assuming your generatorMenus function excludes redirect routes from the menu
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: undefined,
name: 'redirectedRoute',
order: undefined,
parent: undefined,
parents: undefined,
path: '/old-path',
children: [],
},
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'path-icon',
name: 'New Path',
order: undefined,
parent: undefined,
parents: undefined,
path: '/new-path',
children: [],
},
]);
});
const routes: any = [
{
meta: { order: 2, title: 'Home' },
name: 'home',
path: '/',
},
{
meta: { order: 1, title: 'About' },
name: 'about',
path: '/about',
},
];
const router: Router = createRouter({
history: createWebHistory(),
routes,
});
it('should generate menu list with correct order', async () => {
const menus = await generatorMenus(routes, router);
const expectedMenus = [
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: undefined,
name: 'About',
order: 1,
parent: undefined,
parents: undefined,
path: '/about',
children: [],
},
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: undefined,
name: 'Home',
order: 2,
parent: undefined,
parents: undefined,
path: '/',
children: [],
},
];
expect(menus).toEqual(expectedMenus);
});
it('should handle empty routes', async () => {
const emptyRoutes: any[] = [];
const menus = await generatorMenus(emptyRoutes, router);
expect(menus).toEqual([]);
});
});

View File

@@ -1,73 +0,0 @@
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings';
import type { RouteRecordRaw, Router } from 'vue-router';
import { mapTree } from '@vben-core/toolkit';
/**
* 根据 routes 生成菜单列表
* @param routes
*/
async function generatorMenus(
routes: RouteRecordRaw[],
router: Router,
): Promise<MenuRecordRaw[]> {
// 将路由列表转换为一个以 name 为键的对象映射
// 获取所有router最终的path及name
const finalRoutesMap: { [key: string]: string } = Object.fromEntries(
router.getRoutes().map(({ name, path }) => [name, path]),
);
let menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => {
// 路由表的路径写法有多种这里从router获取到最终的path并赋值
const path = finalRoutesMap[route.name as string] ?? route.path;
// 转换为菜单结构
// const path = matchRoute?.path ?? route.path;
const { meta, name: routeName, redirect, children } = route;
const {
badge,
badgeType,
badgeVariants,
hideChildrenInMenu = false,
icon,
link,
order,
title = '',
} = meta || {};
const name = (title || routeName || '') as string;
// 隐藏子菜单
const resultChildren = hideChildrenInMenu
? []
: (children as MenuRecordRaw[]);
// 将菜单的所有父级和父级菜单记录到菜单项内
if (resultChildren && resultChildren.length > 0) {
resultChildren.forEach((child) => {
child.parents = [...(route.parents || []), path];
child.parent = path;
});
}
// 隐藏子菜单
const resultPath = hideChildrenInMenu ? redirect || path : link || path;
return {
badge,
badgeType,
badgeVariants,
icon,
name,
order,
parent: route.parent,
parents: route.parents,
path: resultPath as string,
children: resultChildren || [],
};
});
// 对菜单进行排序
menus = menus.sort((a, b) => (a.order || 999) - (b.order || 999));
return menus;
}
export { generatorMenus };

View File

@@ -1,128 +0,0 @@
import type { RouteRecordRaw } from 'vue-router';
import { describe, expect, it } from 'vitest';
import { generatorRoutes, hasAuthority, hasVisible } from './generator-routes';
// Mock 路由数据
const mockRoutes = [
{
meta: {
authority: ['admin', 'user'],
hideInMenu: false,
},
path: '/dashboard',
children: [
{
path: '/dashboard/overview',
meta: { authority: ['admin'], hideInMenu: false },
},
{
path: '/dashboard/stats',
meta: { authority: ['user'], hideInMenu: true },
},
],
},
{
meta: { authority: ['admin'], hideInMenu: false },
path: '/settings',
},
{
meta: { hideInMenu: false },
path: '/profile',
},
] as RouteRecordRaw[];
describe('hasAuthority', () => {
it('should return true if there is no authority defined', () => {
expect(hasAuthority(mockRoutes[2], ['admin'])).toBe(true);
});
it('should return true if the user has the required authority', () => {
expect(hasAuthority(mockRoutes[0], ['admin'])).toBe(true);
});
it('should return false if the user does not have the required authority', () => {
expect(hasAuthority(mockRoutes[1], ['user'])).toBe(false);
});
});
describe('hasVisible', () => {
it('should return true if hideInMenu is not set or false', () => {
expect(hasVisible(mockRoutes[0])).toBe(true);
expect(hasVisible(mockRoutes[2])).toBe(true);
});
it('should return false if hideInMenu is true', () => {
expect(hasVisible(mockRoutes[0].children?.[1])).toBe(false);
});
});
describe('generatorRoutes', () => {
it('should filter routes based on authority and visibility', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, ['user']);
// The user should have access to /dashboard/stats, but it should be filtered out because it's not visible
expect(generatedRoutes).toEqual([
{
meta: { authority: ['admin', 'user'], hideInMenu: false },
path: '/dashboard',
children: [],
},
// Note: We expect /settings to be filtered out because the user does not have 'admin' authority
{
meta: { hideInMenu: false },
path: '/profile',
},
]);
});
it('should handle routes without children', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, ['user']);
expect(generatedRoutes).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '/profile', // This route has no children and should be included
}),
]),
);
});
it('should handle empty roles array', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, []);
expect(generatedRoutes).toEqual(
expect.arrayContaining([
// Only routes without authority should be included
expect.objectContaining({
path: '/profile',
}),
]),
);
expect(generatedRoutes).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '/dashboard',
}),
expect.objectContaining({
path: '/settings',
}),
]),
);
});
it('should handle missing meta fields', async () => {
const routesWithMissingMeta = [
{ path: '/path1' }, // No meta
{ meta: {}, path: '/path2' }, // Empty meta
{ meta: { authority: ['admin'] }, path: '/path3' }, // Only authority
];
const generatedRoutes = await generatorRoutes(
routesWithMissingMeta as RouteRecordRaw[],
['admin'],
);
expect(generatedRoutes).toEqual([
{ path: '/path1' },
{ meta: {}, path: '/path2' },
{ meta: { authority: ['admin'] }, path: '/path3' },
]);
});
});

View File

@@ -1,63 +0,0 @@
import type { RouteRecordRaw } from 'vue-router';
import { filterTree, mapTree } from '@vben-core/toolkit';
/**
* 动态生成路由
*/
async function generatorRoutes(
routes: RouteRecordRaw[],
roles: string[],
forbiddenPage?: RouteRecordRaw['component'],
): Promise<RouteRecordRaw[]> {
// 根据角色标识过滤路由表,判断当前用户是否拥有指定权限
const finalRoutes = filterTree(routes, (route) => {
return hasVisible(route) && hasAuthority(route, roles);
});
if (!forbiddenPage) {
return finalRoutes;
}
// 如果有禁止访问的页面将禁止访问的页面替换为403页面
return mapTree(finalRoutes, (route) => {
if (menuHasVisibleWithForbidden(route)) {
route.component = forbiddenPage;
}
return route;
});
}
/**
* 判断路由是否有权限访问
* @param route
* @param access
*/
function hasAuthority(route: RouteRecordRaw, access: string[]) {
const authority = route.meta?.authority;
if (!authority) {
return true;
}
return (
access.some((value) => authority.includes(value)) ||
menuHasVisibleWithForbidden(route)
);
}
/**
* 判断路由是否需要在菜单中显示
* @param route
*/
function hasVisible(route?: RouteRecordRaw) {
return !route?.meta?.hideInMenu;
}
/**
* 判断路由是否在菜单中显示但是访问会被重定向到403
* @param route
*/
function menuHasVisibleWithForbidden(route: RouteRecordRaw) {
return !!route.meta?.menuVisibleWithForbidden;
}
export { generatorRoutes, hasAuthority, hasVisible };

View File

@@ -1,6 +1,4 @@
export * from './find-menu-by-path';
export * from './flatten-object';
export * from './generator-menus';
export * from './generator-routes';
export * from './merge-route-modules';
export * from './nested-object';

View File

@@ -2,6 +2,7 @@ import type { Preferences } from './types';
const defaultPreferences: Preferences = {
app: {
accessMode: 'frontend',
aiAssistant: true,
authPageLayout: 'panel-right',
colorGrayMode: false,

View File

@@ -9,6 +9,8 @@ import type {
type BreadcrumbStyleType = 'background' | 'normal';
type accessModeType = 'allow-all' | 'backend' | 'frontend';
type NavigationStyleType = 'plain' | 'rounded';
type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
@@ -16,6 +18,8 @@ type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
interface AppPreferences {
/** 权限模式 */
accessMode: accessModeType;
/** 是否开启vben助手 */
aiAssistant: boolean;
/** 登录注册页面布局 */
@@ -208,4 +212,5 @@ export type {
ThemeModeType,
ThemePreferences,
TransitionPreferences,
accessModeType,
};

View File

@@ -1,2 +1,2 @@
export * from './access';
export * from './tabs';
export * from './tabbar';

View File

@@ -3,7 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTabsStore } from './tabs';
import { useTabbarStore } from './tabbar';
describe('useAccessStore', () => {
const router = createRouter({
@@ -18,7 +18,7 @@ describe('useAccessStore', () => {
});
it('adds a new tab', () => {
const store = useTabsStore();
const store = useTabbarStore();
const tab: any = {
fullPath: '/home',
meta: {},
@@ -31,7 +31,7 @@ describe('useAccessStore', () => {
});
it('adds a new tab if it does not exist', () => {
const store = useTabsStore();
const store = useTabbarStore();
const newTab: any = {
fullPath: '/new',
meta: {},
@@ -43,7 +43,7 @@ describe('useAccessStore', () => {
});
it('updates an existing tab instead of adding a new one', () => {
const store = useTabsStore();
const store = useTabbarStore();
const initialTab: any = {
fullPath: '/existing',
meta: {},
@@ -59,7 +59,7 @@ describe('useAccessStore', () => {
});
it('closes all tabs', async () => {
const store = useTabsStore();
const store = useTabbarStore();
store.tabs = [
{ fullPath: '/home', meta: {}, name: 'Home', path: '/home' },
] as any;
@@ -72,7 +72,7 @@ describe('useAccessStore', () => {
});
it('returns all tabs including affix tabs', () => {
const store = useTabsStore();
const store = useTabbarStore();
store.tabs = [
{ fullPath: '/home', meta: {}, name: 'Home', path: '/home' },
] as any;
@@ -86,7 +86,7 @@ describe('useAccessStore', () => {
});
it('closes a non-affix tab', () => {
const store = useTabsStore();
const store = useTabbarStore();
const tab: any = {
fullPath: '/closable',
meta: {},
@@ -99,7 +99,7 @@ describe('useAccessStore', () => {
});
it('does not close an affix tab', () => {
const store = useTabsStore();
const store = useTabbarStore();
const affixTab: any = {
fullPath: '/affix',
meta: { affixTab: true },
@@ -112,14 +112,14 @@ describe('useAccessStore', () => {
});
it('returns all cache tabs', () => {
const store = useTabsStore();
const store = useTabbarStore();
store.cacheTabs.add('Home');
store.cacheTabs.add('About');
expect(store.getCacheTabs).toEqual(['Home', 'About']);
});
it('returns all tabs, including affix tabs', () => {
const store = useTabsStore();
const store = useTabbarStore();
const normalTab: any = {
fullPath: '/normal',
meta: {},
@@ -139,7 +139,7 @@ describe('useAccessStore', () => {
});
it('navigates to a specific tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
const tab: any = { meta: {}, name: 'Dashboard', path: '/dashboard' };
await store._goToTab(tab, router);
@@ -152,7 +152,7 @@ describe('useAccessStore', () => {
});
it('closes multiple tabs by paths', async () => {
const store = useTabsStore();
const store = useTabbarStore();
store.addTab({
fullPath: '/home',
meta: {},
@@ -179,7 +179,7 @@ describe('useAccessStore', () => {
});
it('closes all tabs to the left of the specified tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
store.addTab({
fullPath: '/home',
meta: {},
@@ -207,7 +207,7 @@ describe('useAccessStore', () => {
});
it('closes all tabs except the specified tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
store.addTab({
fullPath: '/home',
meta: {},
@@ -235,7 +235,7 @@ describe('useAccessStore', () => {
});
it('closes all tabs to the right of the specified tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
const targetTab: any = {
fullPath: '/home',
meta: {},
@@ -263,7 +263,7 @@ describe('useAccessStore', () => {
});
it('closes the tab with the specified key', async () => {
const store = useTabsStore();
const store = useTabbarStore();
const keyToClose = '/about';
store.addTab({
fullPath: '/home',
@@ -293,7 +293,7 @@ describe('useAccessStore', () => {
});
it('refreshes the current tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
const currentTab: any = {
fullPath: '/dashboard',
meta: { name: 'Dashboard' },

View File

@@ -62,7 +62,7 @@ interface TabsState {
/**
* @zh_CN 访
*/
const useTabsStore = defineStore('tabs', {
const useTabbarStore = defineStore('tabbar', {
actions: {
/**
* Close tabs in bulk
@@ -395,7 +395,7 @@ const useTabsStore = defineStore('tabs', {
// 解决热更新问题
const hot = import.meta.hot;
if (hot) {
hot.accept(acceptHMRUpdate(useTabsStore, hot));
hot.accept(acceptHMRUpdate(useTabbarStore, hot));
}
export { useTabsStore };
export { useTabbarStore };

View File

@@ -36,7 +36,7 @@
}
},
"dependencies": {
"@ant-design/colors": "^7.0.2",
"@ant-design/colors": "^7.1.0",
"@ctrl/tinycolor": "4.1.0"
}
}

View File

@@ -36,4 +36,8 @@
.outline-box:not(.outline-box-active):hover::after {
@apply outline-primary left-0 top-0 h-full w-full p-1 opacity-100;
}
.card-box {
@apply bg-card text-card-foreground border-border rounded-xl border shadow;
}
}

View File

@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { generateUUID } from './hash';
describe('generateUUID', () => {
it('should return a string', () => {
const uuid = generateUUID();
expect(typeof uuid).toBe('string');
});
it('should be length 32', () => {
const uuid = generateUUID();
expect(uuid.length).toBe(36);
});
it('should have the correct format', () => {
const uuid = generateUUID();
const uuidRegex =
/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i;
expect(uuidRegex.test(uuid)).toBe(true);
});
});

View File

@@ -1,31 +0,0 @@
/**
* 生成一个UUID通用唯一标识符
*
* UUID是一种用于软件构建的标识符其目的是能够生成一个唯一的ID以便在全局范围内标识信息。
* 此函数用于生成一个符合version 4的UUID这种UUID是随机生成的。
*
* 生成的UUID的格式为xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
* 其中x是任意16进制数字y是一个16进制数字取值范围为[8, b]。
*
* @returns {string} 生成的UUID。
*/
function generateUUID(): string {
let d = Date.now();
if (
typeof performance !== 'undefined' &&
typeof performance.now === 'function'
) {
d += performance.now(); // use high-precision timer if available
}
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAll(
/[xy]/g,
(c) => {
const r = Math.trunc((d + Math.random() * 16) % 16);
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
},
);
return uuid;
}
export { generateUUID };

View File

@@ -1,7 +1,6 @@
export * from './cn';
export * from './diff';
export * from './dom';
export * from './hash';
export * from './inference';
export * from './letter';
export * from './merge';