From 7e4b07e124b35ba8bfee9d70528b61dce57b7643 Mon Sep 17 00:00:00 2001 From: zhanglei Date: Tue, 17 Mar 2026 16:02:56 +0800 Subject: [PATCH] init --- vue-vben-admin/packages/utils/README.md | 19 ++ vue-vben-admin/packages/utils/package.json | 27 ++ .../__tests__/find-menu-by-path.test.ts | 88 +++++++ .../helpers/__tests__/generate-menus.test.ts | 233 ++++++++++++++++++ .../generate-routes-frontend.test.ts | 105 ++++++++ .../__tests__/merge-route-modules.test.ts | 68 +++++ .../utils/src/helpers/find-menu-by-path.ts | 37 +++ .../utils/src/helpers/generate-menus.ts | 90 +++++++ .../src/helpers/generate-routes-backend.ts | 108 ++++++++ .../src/helpers/generate-routes-frontend.ts | 58 +++++ .../utils/src/helpers/get-popup-container.ts | 10 + .../packages/utils/src/helpers/index.ts | 8 + .../utils/src/helpers/merge-route-modules.ts | 28 +++ .../utils/src/helpers/reset-routes.ts | 31 +++ .../src/helpers/unmount-global-loading.ts | 31 +++ vue-vben-admin/packages/utils/src/index.ts | 4 + vue-vben-admin/packages/utils/tsconfig.json | 9 + 17 files changed, 954 insertions(+) create mode 100644 vue-vben-admin/packages/utils/README.md create mode 100644 vue-vben-admin/packages/utils/package.json create mode 100644 vue-vben-admin/packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/__tests__/generate-menus.test.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/__tests__/merge-route-modules.test.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/find-menu-by-path.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/generate-menus.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/generate-routes-backend.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/generate-routes-frontend.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/get-popup-container.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/index.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/merge-route-modules.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/reset-routes.ts create mode 100644 vue-vben-admin/packages/utils/src/helpers/unmount-global-loading.ts create mode 100644 vue-vben-admin/packages/utils/src/index.ts create mode 100644 vue-vben-admin/packages/utils/tsconfig.json diff --git a/vue-vben-admin/packages/utils/README.md b/vue-vben-admin/packages/utils/README.md new file mode 100644 index 0000000..f06068a --- /dev/null +++ b/vue-vben-admin/packages/utils/README.md @@ -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'; +``` diff --git a/vue-vben-admin/packages/utils/package.json b/vue-vben-admin/packages/utils/package.json new file mode 100644 index 0000000..5fe93f6 --- /dev/null +++ b/vue-vben-admin/packages/utils/package.json @@ -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:" + } +} diff --git a/vue-vben-admin/packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts b/vue-vben-admin/packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts new file mode 100644 index 0000000..fc0d76b --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/__tests__/find-menu-by-path.test.ts @@ -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(); + }); +}); diff --git a/vue-vben-admin/packages/utils/src/helpers/__tests__/generate-menus.test.ts b/vue-vben-admin/packages/utils/src/helpers/__tests__/generate-menus.test.ts new file mode 100644 index 0000000..c02cc9d --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/__tests__/generate-menus.test.ts @@ -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([]); + }); +}); diff --git a/vue-vben-admin/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts b/vue-vben-admin/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts new file mode 100644 index 0000000..8e01853 --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts @@ -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' }, + ]); + }); +}); diff --git a/vue-vben-admin/packages/utils/src/helpers/__tests__/merge-route-modules.test.ts b/vue-vben-admin/packages/utils/src/helpers/__tests__/merge-route-modules.test.ts new file mode 100644 index 0000000..3615556 --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/__tests__/merge-route-modules.test.ts @@ -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 = { + './dynamic-routes/about.ts': { + default: [ + { + component: () => Promise.resolve({ template: '
About
' }), + name: 'About', + path: '/about', + }, + ], + }, + './dynamic-routes/home.ts': { + default: [ + { + component: () => Promise.resolve({ template: '
Home
' }), + 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 = {}; + const expectedRoutes: RouteRecordRaw[] = []; + + const mergedRoutes = mergeRouteModules(routeModules); + expect(mergedRoutes).toEqual(expectedRoutes); + }); + + it('should handle modules with no default export', () => { + const routeModules: Record = { + './dynamic-routes/empty.ts': { + default: [], + }, + }; + const expectedRoutes: RouteRecordRaw[] = []; + + const mergedRoutes = mergeRouteModules(routeModules); + expect(mergedRoutes).toEqual(expectedRoutes); + }); +}); diff --git a/vue-vben-admin/packages/utils/src/helpers/find-menu-by-path.ts b/vue-vben-admin/packages/utils/src/helpers/find-menu-by-path.ts new file mode 100644 index 0000000..747cc7f --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/find-menu-by-path.ts @@ -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 }; diff --git a/vue-vben-admin/packages/utils/src/helpers/generate-menus.ts b/vue-vben-admin/packages/utils/src/helpers/generate-menus.ts new file mode 100644 index 0000000..f13d599 --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/generate-menus.ts @@ -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(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 }; diff --git a/vue-vben-admin/packages/utils/src/helpers/generate-routes-backend.ts b/vue-vben-admin/packages/utils/src/helpers/generate-routes-backend.ts new file mode 100644 index 0000000..3f8693c --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/generate-routes-backend.ts @@ -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 { + 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 }; diff --git a/vue-vben-admin/packages/utils/src/helpers/generate-routes-frontend.ts b/vue-vben-admin/packages/utils/src/helpers/generate-routes-frontend.ts new file mode 100644 index 0000000..dafc8a7 --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/generate-routes-frontend.ts @@ -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 { + // 根据角色标识过滤路由表,判断当前用户是否拥有指定权限 + 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 }; diff --git a/vue-vben-admin/packages/utils/src/helpers/get-popup-container.ts b/vue-vben-admin/packages/utils/src/helpers/get-popup-container.ts new file mode 100644 index 0000000..6aa84d6 --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/get-popup-container.ts @@ -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 + ); +} diff --git a/vue-vben-admin/packages/utils/src/helpers/index.ts b/vue-vben-admin/packages/utils/src/helpers/index.ts new file mode 100644 index 0000000..da2cd8d --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/index.ts @@ -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'; diff --git a/vue-vben-admin/packages/utils/src/helpers/merge-route-modules.ts b/vue-vben-admin/packages/utils/src/helpers/merge-route-modules.ts new file mode 100644 index 0000000..53e21f3 --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/merge-route-modules.ts @@ -0,0 +1,28 @@ +import type { RouteRecordRaw } from 'vue-router'; + +// 定义模块类型 +interface RouteModuleType { + default: RouteRecordRaw[]; +} + +/** + * 合并动态路由模块的默认导出 + * @param routeModules 动态导入的路由模块对象 + * @returns 合并后的路由配置数组 + */ +function mergeRouteModules( + routeModules: Record, +): 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 }; diff --git a/vue-vben-admin/packages/utils/src/helpers/reset-routes.ts b/vue-vben-admin/packages/utils/src/helpers/reset-routes.ts new file mode 100644 index 0000000..0d53a00 --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/reset-routes.ts @@ -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); + } + }); +} diff --git a/vue-vben-admin/packages/utils/src/helpers/unmount-global-loading.ts b/vue-vben-admin/packages/utils/src/helpers/unmount-global-loading.ts new file mode 100644 index 0000000..10b88ea --- /dev/null +++ b/vue-vben-admin/packages/utils/src/helpers/unmount-global-loading.ts @@ -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 }, + ); // 确保事件只触发一次 + } +} diff --git a/vue-vben-admin/packages/utils/src/index.ts b/vue-vben-admin/packages/utils/src/index.ts new file mode 100644 index 0000000..80263b6 --- /dev/null +++ b/vue-vben-admin/packages/utils/src/index.ts @@ -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'; diff --git a/vue-vben-admin/packages/utils/tsconfig.json b/vue-vben-admin/packages/utils/tsconfig.json new file mode 100644 index 0000000..255148a --- /dev/null +++ b/vue-vben-admin/packages/utils/tsconfig.json @@ -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"] +}