17 changed files with 954 additions and 0 deletions
@ -0,0 +1,19 @@ |
|||||
|
# @vben/utils |
||||
|
|
||||
|
用于多个 `app` 公用的工具包,继承了 `@vben-core/shared/utils` 的所有能力。业务上有通用的工具函数可以放在这里。 |
||||
|
|
||||
|
## 用法 |
||||
|
|
||||
|
### 添加依赖 |
||||
|
|
||||
|
```bash |
||||
|
# 进入目标应用目录,例如 apps/xxxx-app |
||||
|
# cd apps/xxxx-app |
||||
|
pnpm add @vben/utils |
||||
|
``` |
||||
|
|
||||
|
### 使用 |
||||
|
|
||||
|
```ts |
||||
|
import { isString } from '@vben/utils'; |
||||
|
``` |
||||
@ -0,0 +1,27 @@ |
|||||
|
{ |
||||
|
"name": "@vben/utils", |
||||
|
"version": "5.6.0", |
||||
|
"homepage": "https://github.com/vbenjs/vue-vben-admin", |
||||
|
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", |
||||
|
"repository": { |
||||
|
"type": "git", |
||||
|
"url": "git+https://github.com/vbenjs/vue-vben-admin.git", |
||||
|
"directory": "packages/utils" |
||||
|
}, |
||||
|
"license": "MIT", |
||||
|
"type": "module", |
||||
|
"sideEffects": [ |
||||
|
"**/*.css" |
||||
|
], |
||||
|
"exports": { |
||||
|
".": { |
||||
|
"types": "./src/index.ts", |
||||
|
"default": "./src/index.ts" |
||||
|
} |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@vben-core/shared": "workspace:*", |
||||
|
"@vben-core/typings": "workspace:*", |
||||
|
"vue-router": "catalog:" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,88 @@ |
|||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { findMenuByPath, findRootMenuByPath } from '../find-menu-by-path'; |
||||
|
|
||||
|
// 示例菜单数据
|
||||
|
const menus: any[] = [ |
||||
|
{ path: '/', children: [] }, |
||||
|
{ path: '/about', children: [] }, |
||||
|
{ |
||||
|
path: '/contact', |
||||
|
children: [ |
||||
|
{ path: '/contact/email', children: [] }, |
||||
|
{ path: '/contact/phone', children: [] }, |
||||
|
], |
||||
|
}, |
||||
|
{ |
||||
|
path: '/services', |
||||
|
children: [ |
||||
|
{ path: '/services/design', children: [] }, |
||||
|
{ |
||||
|
path: '/services/development', |
||||
|
children: [{ path: '/services/development/web', children: [] }], |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
describe('menu Finder Tests', () => { |
||||
|
it('finds a top-level menu', () => { |
||||
|
const menu = findMenuByPath(menus, '/about'); |
||||
|
expect(menu).toBeDefined(); |
||||
|
expect(menu?.path).toBe('/about'); |
||||
|
}); |
||||
|
|
||||
|
it('finds a nested menu', () => { |
||||
|
const menu = findMenuByPath(menus, '/services/development/web'); |
||||
|
expect(menu).toBeDefined(); |
||||
|
expect(menu?.path).toBe('/services/development/web'); |
||||
|
}); |
||||
|
|
||||
|
it('returns null for a non-existent path', () => { |
||||
|
const menu = findMenuByPath(menus, '/non-existent'); |
||||
|
expect(menu).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('handles empty menus list', () => { |
||||
|
const menu = findMenuByPath([], '/about'); |
||||
|
expect(menu).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('handles menu items without children', () => { |
||||
|
const menu = findMenuByPath( |
||||
|
[{ path: '/only', children: undefined }] as any[], |
||||
|
'/only', |
||||
|
); |
||||
|
expect(menu).toBeDefined(); |
||||
|
expect(menu?.path).toBe('/only'); |
||||
|
}); |
||||
|
|
||||
|
it('finds root menu by path', () => { |
||||
|
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath( |
||||
|
menus, |
||||
|
'/services/development/web', |
||||
|
); |
||||
|
|
||||
|
expect(findMenu).toBeDefined(); |
||||
|
expect(rootMenu).toBeUndefined(); |
||||
|
expect(rootMenuPath).toBeUndefined(); |
||||
|
expect(findMenu?.path).toBe('/services/development/web'); |
||||
|
}); |
||||
|
|
||||
|
it('returns null for undefined or empty path', () => { |
||||
|
const menuUndefinedPath = findMenuByPath(menus); |
||||
|
const menuEmptyPath = findMenuByPath(menus, ''); |
||||
|
expect(menuUndefinedPath).toBeNull(); |
||||
|
expect(menuEmptyPath).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
it('checks for root menu when path does not exist', () => { |
||||
|
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath( |
||||
|
menus, |
||||
|
'/non-existent', |
||||
|
); |
||||
|
expect(findMenu).toBeNull(); |
||||
|
expect(rootMenu).toBeUndefined(); |
||||
|
expect(rootMenuPath).toBeUndefined(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,233 @@ |
|||||
|
import type { Router, RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
import { createRouter, createWebHistory } from 'vue-router'; |
||||
|
|
||||
|
import { describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
import { generateMenus } from '../generate-menus'; |
||||
|
|
||||
|
// Nested route setup to test child inclusion and hideChildrenInMenu functionality
|
||||
|
|
||||
|
describe('generateMenus', () => { |
||||
|
// 模拟路由数据
|
||||
|
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', |
||||
|
show: true, |
||||
|
children: [], |
||||
|
}, |
||||
|
{ |
||||
|
badge: undefined, |
||||
|
badgeType: undefined, |
||||
|
badgeVariants: undefined, |
||||
|
icon: 'about-icon', |
||||
|
name: '关于', |
||||
|
order: undefined, |
||||
|
parent: undefined, |
||||
|
parents: undefined, |
||||
|
path: '/about', |
||||
|
show: true, |
||||
|
children: [], |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
const menus = generateMenus(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 = generateMenus(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', |
||||
|
show: true, |
||||
|
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 = generateMenus(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', |
||||
|
show: true, |
||||
|
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 = generateMenus(mockRoutesWithRedirect, mockRouter as any); |
||||
|
expect(menus).toEqual([ |
||||
|
// Assuming your generateMenus 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', |
||||
|
show: true, |
||||
|
children: [], |
||||
|
}, |
||||
|
{ |
||||
|
badge: undefined, |
||||
|
badgeType: undefined, |
||||
|
badgeVariants: undefined, |
||||
|
icon: 'path-icon', |
||||
|
name: 'New Path', |
||||
|
order: undefined, |
||||
|
parent: undefined, |
||||
|
parents: undefined, |
||||
|
path: '/new-path', |
||||
|
show: true, |
||||
|
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 = generateMenus(routes, router); |
||||
|
const expectedMenus = [ |
||||
|
{ |
||||
|
badge: undefined, |
||||
|
badgeType: undefined, |
||||
|
badgeVariants: undefined, |
||||
|
icon: undefined, |
||||
|
name: 'About', |
||||
|
order: 1, |
||||
|
parent: undefined, |
||||
|
parents: undefined, |
||||
|
path: '/about', |
||||
|
show: true, |
||||
|
children: [], |
||||
|
}, |
||||
|
{ |
||||
|
badge: undefined, |
||||
|
badgeType: undefined, |
||||
|
badgeVariants: undefined, |
||||
|
icon: undefined, |
||||
|
name: 'Home', |
||||
|
order: 2, |
||||
|
parent: undefined, |
||||
|
parents: undefined, |
||||
|
path: '/', |
||||
|
show: true, |
||||
|
children: [], |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
expect(menus).toEqual(expectedMenus); |
||||
|
}); |
||||
|
|
||||
|
it('should handle empty routes', async () => { |
||||
|
const emptyRoutes: any[] = []; |
||||
|
const menus = generateMenus(emptyRoutes, router); |
||||
|
expect(menus).toEqual([]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,105 @@ |
|||||
|
import type { RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { |
||||
|
generateRoutesByFrontend, |
||||
|
hasAuthority, |
||||
|
} from '../generate-routes-frontend'; |
||||
|
|
||||
|
// 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('generateRoutesByFrontend', () => { |
||||
|
it('should handle routes without children', async () => { |
||||
|
const generatedRoutes = await generateRoutesByFrontend(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 generateRoutesByFrontend(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 generateRoutesByFrontend( |
||||
|
routesWithMissingMeta as RouteRecordRaw[], |
||||
|
['admin'], |
||||
|
); |
||||
|
expect(generatedRoutes).toEqual([ |
||||
|
{ path: '/path1' }, |
||||
|
{ meta: {}, path: '/path2' }, |
||||
|
{ meta: { authority: ['admin'] }, path: '/path3' }, |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,68 @@ |
|||||
|
import type { RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
import type { RouteModuleType } from '../merge-route-modules'; |
||||
|
|
||||
|
import { describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { mergeRouteModules } from '../merge-route-modules'; |
||||
|
|
||||
|
describe('mergeRouteModules', () => { |
||||
|
it('should merge route modules correctly', () => { |
||||
|
const routeModules: Record<string, RouteModuleType> = { |
||||
|
'./dynamic-routes/about.ts': { |
||||
|
default: [ |
||||
|
{ |
||||
|
component: () => Promise.resolve({ template: '<div>About</div>' }), |
||||
|
name: 'About', |
||||
|
path: '/about', |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
'./dynamic-routes/home.ts': { |
||||
|
default: [ |
||||
|
{ |
||||
|
component: () => Promise.resolve({ template: '<div>Home</div>' }), |
||||
|
name: 'Home', |
||||
|
path: '/', |
||||
|
}, |
||||
|
], |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
const expectedRoutes: RouteRecordRaw[] = [ |
||||
|
{ |
||||
|
component: expect.any(Function), |
||||
|
name: 'About', |
||||
|
path: '/about', |
||||
|
}, |
||||
|
{ |
||||
|
component: expect.any(Function), |
||||
|
name: 'Home', |
||||
|
path: '/', |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
const mergedRoutes = mergeRouteModules(routeModules); |
||||
|
expect(mergedRoutes).toEqual(expectedRoutes); |
||||
|
}); |
||||
|
|
||||
|
it('should handle empty modules', () => { |
||||
|
const routeModules: Record<string, RouteModuleType> = {}; |
||||
|
const expectedRoutes: RouteRecordRaw[] = []; |
||||
|
|
||||
|
const mergedRoutes = mergeRouteModules(routeModules); |
||||
|
expect(mergedRoutes).toEqual(expectedRoutes); |
||||
|
}); |
||||
|
|
||||
|
it('should handle modules with no default export', () => { |
||||
|
const routeModules: Record<string, RouteModuleType> = { |
||||
|
'./dynamic-routes/empty.ts': { |
||||
|
default: [], |
||||
|
}, |
||||
|
}; |
||||
|
const expectedRoutes: RouteRecordRaw[] = []; |
||||
|
|
||||
|
const mergedRoutes = mergeRouteModules(routeModules); |
||||
|
expect(mergedRoutes).toEqual(expectedRoutes); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,37 @@ |
|||||
|
import type { MenuRecordRaw } from '@vben-core/typings'; |
||||
|
|
||||
|
function findMenuByPath( |
||||
|
list: MenuRecordRaw[], |
||||
|
path?: string, |
||||
|
): MenuRecordRaw | null { |
||||
|
for (const menu of list) { |
||||
|
if (menu.path === path) { |
||||
|
return menu; |
||||
|
} |
||||
|
const findMenu = menu.children && findMenuByPath(menu.children, path); |
||||
|
if (findMenu) { |
||||
|
return findMenu; |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 查找根菜单 |
||||
|
* @param menus |
||||
|
* @param path |
||||
|
*/ |
||||
|
function findRootMenuByPath(menus: MenuRecordRaw[], path?: string, level = 0) { |
||||
|
const findMenu = findMenuByPath(menus, path); |
||||
|
const rootMenuPath = findMenu?.parents?.[level]; |
||||
|
const rootMenu = rootMenuPath |
||||
|
? menus.find((item) => item.path === rootMenuPath) |
||||
|
: undefined; |
||||
|
return { |
||||
|
findMenu, |
||||
|
rootMenu, |
||||
|
rootMenuPath, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
export { findMenuByPath, findRootMenuByPath }; |
||||
@ -0,0 +1,90 @@ |
|||||
|
import type { Router, RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
import type { |
||||
|
ExRouteRecordRaw, |
||||
|
MenuRecordRaw, |
||||
|
RouteMeta, |
||||
|
} from '@vben-core/typings'; |
||||
|
|
||||
|
import { filterTree, mapTree, sortTree } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
/** |
||||
|
* 根据 routes 生成菜单列表 |
||||
|
* @param routes - 路由配置列表 |
||||
|
* @param router - Vue Router 实例 |
||||
|
* @returns 生成的菜单列表 |
||||
|
*/ |
||||
|
function generateMenus( |
||||
|
routes: RouteRecordRaw[], |
||||
|
router: Router, |
||||
|
): MenuRecordRaw[] { |
||||
|
// 将路由列表转换为一个以 name 为键的对象映射
|
||||
|
const finalRoutesMap: { [key: string]: string } = Object.fromEntries( |
||||
|
router.getRoutes().map(({ name, path }) => [name, path]), |
||||
|
); |
||||
|
|
||||
|
let menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => { |
||||
|
// 获取最终的路由路径
|
||||
|
const path = finalRoutesMap[route.name as string] ?? route.path ?? ''; |
||||
|
|
||||
|
const { |
||||
|
meta = {} as RouteMeta, |
||||
|
name: routeName, |
||||
|
redirect, |
||||
|
children = [], |
||||
|
} = route; |
||||
|
const { |
||||
|
activeIcon, |
||||
|
badge, |
||||
|
badgeType, |
||||
|
badgeVariants, |
||||
|
hideChildrenInMenu = false, |
||||
|
icon, |
||||
|
link, |
||||
|
order, |
||||
|
title = '', |
||||
|
} = meta; |
||||
|
|
||||
|
// 确保菜单名称不为空
|
||||
|
const name = (title || routeName || '') as string; |
||||
|
|
||||
|
// 处理子菜单
|
||||
|
const resultChildren = hideChildrenInMenu |
||||
|
? [] |
||||
|
: ((children as MenuRecordRaw[]) ?? []); |
||||
|
|
||||
|
// 设置子菜单的父子关系
|
||||
|
if (resultChildren.length > 0) { |
||||
|
resultChildren.forEach((child) => { |
||||
|
child.parents = [...(route.parents ?? []), path]; |
||||
|
child.parent = path; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 确定最终路径
|
||||
|
const resultPath = hideChildrenInMenu ? redirect || path : link || path; |
||||
|
|
||||
|
return { |
||||
|
activeIcon, |
||||
|
badge, |
||||
|
badgeType, |
||||
|
badgeVariants, |
||||
|
icon, |
||||
|
name, |
||||
|
order, |
||||
|
parent: route.parent, |
||||
|
parents: route.parents, |
||||
|
path: resultPath, |
||||
|
show: !meta.hideInMenu, |
||||
|
children: resultChildren, |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
// 对菜单进行排序,避免order=0时被替换成999的问题
|
||||
|
menus = sortTree(menus, (a, b) => (a?.order ?? 999) - (b?.order ?? 999)); |
||||
|
|
||||
|
// 过滤掉隐藏的菜单项
|
||||
|
return filterTree(menus, (menu) => !!menu.show); |
||||
|
} |
||||
|
|
||||
|
export { generateMenus }; |
||||
@ -0,0 +1,108 @@ |
|||||
|
import type { RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
import type { |
||||
|
ComponentRecordType, |
||||
|
GenerateMenuAndRoutesOptions, |
||||
|
RouteRecordStringComponent, |
||||
|
} from '@vben-core/typings'; |
||||
|
|
||||
|
import { mapTree } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
/** |
||||
|
* 判断路由是否在菜单中显示但访问时展示 403(让用户知悉功能并申请权限) |
||||
|
*/ |
||||
|
function menuHasVisibleWithForbidden(route: RouteRecordRaw): boolean { |
||||
|
return !!route.meta?.menuVisibleWithForbidden; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 动态生成路由 - 后端方式 |
||||
|
* 对 meta.menuVisibleWithForbidden 为 true 的项直接替换为 403 组件,让用户知悉功能并申请权限。 |
||||
|
*/ |
||||
|
async function generateRoutesByBackend( |
||||
|
options: GenerateMenuAndRoutesOptions, |
||||
|
): Promise<RouteRecordRaw[]> { |
||||
|
const { |
||||
|
fetchMenuListAsync, |
||||
|
layoutMap = {}, |
||||
|
pageMap = {}, |
||||
|
forbiddenComponent, |
||||
|
} = options; |
||||
|
|
||||
|
try { |
||||
|
const menuRoutes = await fetchMenuListAsync?.(); |
||||
|
if (!menuRoutes) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
const normalizePageMap: ComponentRecordType = {}; |
||||
|
|
||||
|
for (const [key, value] of Object.entries(pageMap)) { |
||||
|
normalizePageMap[normalizeViewPath(key)] = value; |
||||
|
} |
||||
|
|
||||
|
let routes = convertRoutes(menuRoutes, layoutMap, normalizePageMap); |
||||
|
|
||||
|
if (forbiddenComponent) { |
||||
|
routes = mapTree(routes, (route) => { |
||||
|
if (menuHasVisibleWithForbidden(route)) { |
||||
|
route.component = forbiddenComponent; |
||||
|
} |
||||
|
return route; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return routes; |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function convertRoutes( |
||||
|
routes: RouteRecordStringComponent[], |
||||
|
layoutMap: ComponentRecordType, |
||||
|
pageMap: ComponentRecordType, |
||||
|
): RouteRecordRaw[] { |
||||
|
return mapTree(routes, (node) => { |
||||
|
const route = node as unknown as RouteRecordRaw; |
||||
|
const { component, name } = node; |
||||
|
|
||||
|
if (!name) { |
||||
|
console.error('route name is required', route); |
||||
|
} |
||||
|
|
||||
|
// layout转换
|
||||
|
if (component && layoutMap[component]) { |
||||
|
route.component = layoutMap[component]; |
||||
|
// 页面组件转换
|
||||
|
} else if (component) { |
||||
|
const normalizePath = normalizeViewPath(component); |
||||
|
const pageKey = normalizePath.endsWith('.vue') |
||||
|
? normalizePath |
||||
|
: `${normalizePath}.vue`; |
||||
|
if (pageMap[pageKey]) { |
||||
|
route.component = pageMap[pageKey]; |
||||
|
} else { |
||||
|
console.error(`route component is invalid: ${pageKey}`, route); |
||||
|
route.component = pageMap['/_core/fallback/not-found.vue']; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return route; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function normalizeViewPath(path: string): string { |
||||
|
// 去除相对路径前缀
|
||||
|
const normalizedPath = path.replace(/^(\.\/|\.\.\/)+/, ''); |
||||
|
|
||||
|
// 确保路径以 '/' 开头
|
||||
|
const viewPath = normalizedPath.startsWith('/') |
||||
|
? normalizedPath |
||||
|
: `/${normalizedPath}`; |
||||
|
|
||||
|
// 这里耦合了vben-admin的目录结构
|
||||
|
return viewPath.replace(/^\/views/, ''); |
||||
|
} |
||||
|
export { generateRoutesByBackend }; |
||||
@ -0,0 +1,58 @@ |
|||||
|
import type { RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
import { filterTree, mapTree } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
/** |
||||
|
* 动态生成路由 - 前端方式 |
||||
|
*/ |
||||
|
async function generateRoutesByFrontend( |
||||
|
routes: RouteRecordRaw[], |
||||
|
roles: string[], |
||||
|
forbiddenComponent?: RouteRecordRaw['component'], |
||||
|
): Promise<RouteRecordRaw[]> { |
||||
|
// 根据角色标识过滤路由表,判断当前用户是否拥有指定权限
|
||||
|
const finalRoutes = filterTree(routes, (route) => { |
||||
|
return hasAuthority(route, roles); |
||||
|
}); |
||||
|
|
||||
|
if (!forbiddenComponent) { |
||||
|
return finalRoutes; |
||||
|
} |
||||
|
|
||||
|
// 如果有禁止访问的页面,将禁止访问的页面替换为403页面
|
||||
|
return mapTree(finalRoutes, (route) => { |
||||
|
if (menuHasVisibleWithForbidden(route)) { |
||||
|
route.component = forbiddenComponent; |
||||
|
} |
||||
|
return route; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 判断路由是否有权限访问 |
||||
|
* @param route |
||||
|
* @param access |
||||
|
*/ |
||||
|
function hasAuthority(route: RouteRecordRaw, access: string[]) { |
||||
|
const authority = route.meta?.authority; |
||||
|
if (!authority) { |
||||
|
return true; |
||||
|
} |
||||
|
const canAccess = access.some((value) => authority.includes(value)); |
||||
|
|
||||
|
return canAccess || (!canAccess && menuHasVisibleWithForbidden(route)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 判断路由是否在菜单中显示,但是访问会被重定向到403 |
||||
|
* @param route |
||||
|
*/ |
||||
|
function menuHasVisibleWithForbidden(route: RouteRecordRaw) { |
||||
|
return ( |
||||
|
!!route.meta?.authority && |
||||
|
Reflect.has(route.meta || {}, 'menuVisibleWithForbidden') && |
||||
|
!!route.meta?.menuVisibleWithForbidden |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
export { generateRoutesByFrontend, hasAuthority }; |
||||
@ -0,0 +1,10 @@ |
|||||
|
/** |
||||
|
* If the node is holding inside a form, return the form element, |
||||
|
* otherwise return the parent node of the given element or |
||||
|
* the document body if the element is not provided. |
||||
|
*/ |
||||
|
export function getPopupContainer(node?: HTMLElement): HTMLElement { |
||||
|
return ( |
||||
|
node?.closest('form') ?? (node?.parentNode as HTMLElement) ?? document.body |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
export * from './find-menu-by-path'; |
||||
|
export * from './generate-menus'; |
||||
|
export * from './generate-routes-backend'; |
||||
|
export * from './generate-routes-frontend'; |
||||
|
export * from './get-popup-container'; |
||||
|
export * from './merge-route-modules'; |
||||
|
export * from './reset-routes'; |
||||
|
export * from './unmount-global-loading'; |
||||
@ -0,0 +1,28 @@ |
|||||
|
import type { RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
// 定义模块类型
|
||||
|
interface RouteModuleType { |
||||
|
default: RouteRecordRaw[]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 合并动态路由模块的默认导出 |
||||
|
* @param routeModules 动态导入的路由模块对象 |
||||
|
* @returns 合并后的路由配置数组 |
||||
|
*/ |
||||
|
function mergeRouteModules( |
||||
|
routeModules: Record<string, unknown>, |
||||
|
): RouteRecordRaw[] { |
||||
|
const mergedRoutes: RouteRecordRaw[] = []; |
||||
|
|
||||
|
for (const routeModule of Object.values(routeModules)) { |
||||
|
const moduleRoutes = (routeModule as RouteModuleType)?.default ?? []; |
||||
|
mergedRoutes.push(...moduleRoutes); |
||||
|
} |
||||
|
|
||||
|
return mergedRoutes; |
||||
|
} |
||||
|
|
||||
|
export { mergeRouteModules }; |
||||
|
|
||||
|
export type { RouteModuleType }; |
||||
@ -0,0 +1,31 @@ |
|||||
|
import type { Router, RouteRecordName, RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
import { traverseTreeValues } from '@vben-core/shared/utils'; |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 重置所有路由,如有指定白名单除外 |
||||
|
*/ |
||||
|
export function resetStaticRoutes(router: Router, routes: RouteRecordRaw[]) { |
||||
|
// 获取静态路由所有节点包含子节点的 name,并排除不存在 name 字段的路由
|
||||
|
const staticRouteNames = traverseTreeValues< |
||||
|
RouteRecordRaw, |
||||
|
RouteRecordName | undefined |
||||
|
>(routes, (route) => { |
||||
|
// 这些路由需要指定 name,防止在路由重置时,不能删除没有指定 name 的路由
|
||||
|
if (!route.name) { |
||||
|
console.warn( |
||||
|
`The route with the path ${route.path} needs to have the field name specified.`, |
||||
|
); |
||||
|
} |
||||
|
return route.name; |
||||
|
}); |
||||
|
|
||||
|
const { getRoutes, hasRoute, removeRoute } = router; |
||||
|
const allRoutes = getRoutes(); |
||||
|
allRoutes.forEach(({ name }) => { |
||||
|
// 存在于路由表且非白名单才需要删除
|
||||
|
if (name && !staticRouteNames.includes(name) && hasRoute(name)) { |
||||
|
removeRoute(name); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
/** |
||||
|
* 移除并销毁loading |
||||
|
* 放在这里是而不是放在 index.html 的app标签内,是因为这样比较不会生硬,渲染过快可能会有闪烁 |
||||
|
* 通过先添加css动画隐藏,在动画结束后在移除loading节点来改善体验 |
||||
|
* 不好的地方是会增加一些代码量 |
||||
|
* 自定义loading可以见:https://doc.vben.pro/guide/in-depth/loading.html
|
||||
|
*/ |
||||
|
export function unmountGlobalLoading() { |
||||
|
// 查找全局 loading 元素
|
||||
|
const loadingElement = document.querySelector('#__app-loading__'); |
||||
|
|
||||
|
if (loadingElement) { |
||||
|
// 添加隐藏类,触发过渡动画
|
||||
|
loadingElement.classList.add('hidden'); |
||||
|
|
||||
|
// 查找所有需要移除的注入 loading 元素
|
||||
|
const injectLoadingElements = document.querySelectorAll( |
||||
|
'[data-app-loading^="inject"]', |
||||
|
); |
||||
|
|
||||
|
// 当过渡动画结束时,移除 loading 元素和所有注入的 loading 元素
|
||||
|
loadingElement.addEventListener( |
||||
|
'transitionend', |
||||
|
() => { |
||||
|
loadingElement.remove(); // 移除 loading 元素
|
||||
|
injectLoadingElements.forEach((el) => el.remove()); // 移除所有注入的 loading 元素
|
||||
|
}, |
||||
|
{ once: true }, |
||||
|
); // 确保事件只触发一次
|
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
export * from './helpers'; |
||||
|
export * from '@vben-core/shared/cache'; |
||||
|
export * from '@vben-core/shared/color'; |
||||
|
export * from '@vben-core/shared/utils'; |
||||
@ -0,0 +1,9 @@ |
|||||
|
{ |
||||
|
"$schema": "https://json.schemastore.org/tsconfig", |
||||
|
"extends": "@vben/tsconfig/library.json", |
||||
|
"compilerOptions": { |
||||
|
"types": ["@vben-core/typings/vue-router"] |
||||
|
}, |
||||
|
"include": ["src"], |
||||
|
"exclude": ["node_modules"] |
||||
|
} |
||||
Loading…
Reference in new issue