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