chore: 并入 iotlcm-admin-ui 历史到 admin-ui/(原提交 f305eab)

This commit is contained in:
MiFaZhan
2026-11-02 13:27:59 +08:00
1629 changed files with 189934 additions and 0 deletions
@@ -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,236 @@
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 = await 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 = await 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 = await 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 = await 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 = await 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 = await generateMenus(emptyRoutes, router);
expect(menus).toEqual([]);
});
});
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest';
import { generateRoutesByBackend } from '../generate-routes-backend';
// 布局组件与页面组件必须是不同对象:实现里用「引用是否相同」判断是不是布局
const NullComponent = { render: () => null };
const NullLayout = { render: () => null };
const NullPage = { render: () => null };
const layoutMap = {
BasicLayout: NullLayout,
IFrameView: NullLayout,
LAYOUT: NullLayout,
};
const pageMap = { '/iotlcm/tDashboard/index.vue': NullPage };
/**
* 后端菜单里可能存在指向已下线页面的菜单(前端无对应视图)。
* 这类路由不应注册、也不应出现在菜单里,否则点进去就是一整页 404。
*/
describe('generateRoutesByBackend 丢弃无对应视图的路由', () => {
it('丢弃解析不到视图的路由,并保留同级正常路由', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const routes = await generateRoutesByBackend({
fetchMenuListAsync: async () => [
{
children: [
{
component: 'common/tDivisionCode/index',
name: 'menu_137',
path: 'index',
},
{
component: 'iotlcm/tDashboard/index',
name: 'menu_ok',
path: 'dashboard',
},
],
component: 'LAYOUT',
name: 'Root',
path: '/',
},
],
layoutMap,
pageMap,
} as never);
expect(routes).toHaveLength(1);
const children = routes[0]?.children ?? [];
expect(children.map((c) => c.name)).toEqual(['menu_ok']);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('route component is invalid, route skipped'),
expect.objectContaining({ name: 'menu_137' }),
);
} finally {
warn.mockRestore();
}
});
it('子路由被丢弃后,父目录若变空也一并去掉', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const routes = await generateRoutesByBackend({
fetchMenuListAsync: async () => [
{
children: [
{
component: 'common/tDivisionCode/index',
name: 'menu_137',
path: 'index',
},
],
component: 'LAYOUT',
name: 'EmptyDir',
path: '/empty',
},
{
component: 'iotlcm/tDashboard/index',
name: 'menu_ok',
path: '/ok',
},
],
layoutMap,
pageMap,
} as never);
expect(routes.map((r) => r.name)).toEqual(['menu_ok']);
} finally {
warn.mockRestore();
}
});
it('外链菜单(无组件但有 link)不会被丢弃', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const routes = await generateRoutesByBackend({
fetchMenuListAsync: async () => [
{
children: [
{
meta: {
link: 'https://example.com/docs',
openInNewWindow: true,
},
name: 'menu_link',
path: 'https://example.com/docs',
},
],
component: 'LAYOUT',
name: 'LinkDir',
path: '/links',
},
],
layoutMap,
pageMap,
} as never);
expect(routes).toHaveLength(1);
expect(routes[0]?.children?.[0]?.name).toBe('menu_link');
} finally {
warn.mockRestore();
}
});
});
@@ -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,40 @@
// 将base64转换为blob
export function dataURLtoBlob(base64Buf: string): Blob {
const arr = base64Buf.split(',');
const typeItem = arr[0];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mime = typeItem!.match(/:(.*?);/)![1];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const bstr = window.atob(arr[1]!);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
u8arr[n] = bstr.codePointAt(n)!;
}
return new Blob([u8arr], { type: mime });
}
// // 将url转换为base64
// export function urlToBase64(url: string, mineType?: string): Promise<string> {
// return new Promise((resolve, reject) => {
// let canvas = document.createElement('CANVAS') as HTMLCanvasElement | null;
// // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// const ctx = canvas!.getContext('2d');
// const img = new Image();
// img.crossOrigin = '';
// img.addEventListener('load', () => {
// if (!canvas || !ctx) {
// // eslint-disable-next-line prefer-promise-reject-errors
// return reject();
// }
// canvas.height = img.height;
// canvas.width = img.width;
// ctx.drawImage(img, 0, 0);
// const dataURL = canvas.toDataURL(mineType || 'image/png');
// canvas = null;
// resolve(dataURL);
// });
// img.src = url;
// });
// }
@@ -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,157 @@
import type { RouteRecordRaw } from 'vue-router';
import type {
ComponentRecordType,
GenerateMenuAndRoutesOptions,
RouteRecordStringComponent,
} from '@vben-core/typings';
import { mapTree } from '@vben-core/shared/utils';
/**
* 动态生成路由 - 后端方式
*/
async function generateRoutesByBackend(
options: GenerateMenuAndRoutesOptions,
): Promise<RouteRecordRaw[]> {
const { fetchMenuListAsync, layoutMap = {}, pageMap = {} } = options;
try {
const menuRoutes = await fetchMenuListAsync?.();
if (!menuRoutes) {
return [];
}
const normalizePageMap: ComponentRecordType = {};
for (const [key, value] of Object.entries(pageMap)) {
normalizePageMap[normalizeViewPath(key)] = value;
}
const routes = convertRoutes(
removePathlessRoutes(menuRoutes),
layoutMap,
normalizePageMap,
);
return routes;
} catch (error) {
console.error(error);
throw error;
}
}
function removePathlessRoutes(
routes: RouteRecordStringComponent[],
): RouteRecordStringComponent[] {
return routes.flatMap((route) => {
const children = removePathlessRoutes(route.children ?? []);
if (!route.path?.trim()) {
console.warn('Skipping backend menu route with an empty path', {
name: route.name,
});
return children;
}
return [{ ...route, children }];
});
}
function convertRoutes(
routes: RouteRecordStringComponent[],
layoutMap: ComponentRecordType,
pageMap: ComponentRecordType,
): RouteRecordRaw[] {
// 前端无对应视图、需丢弃的路由
const invalidRoutes = new WeakSet<object>();
const converted = 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 {
// 前端没有对应视图(后端菜单指向了已不存在的页面)。
// 这里不再替换成 not-found 组件——那样菜单还在、点进去却是 404 页面。
// 直接标记丢弃:路由不注册、菜单里也不出现这个死链。
console.warn(`route component is invalid, route skipped: ${pageKey}`, {
name,
path: node.path,
});
invalidRoutes.add(route);
}
}
return route;
});
return pruneInvalidRoutes(converted, layoutMap, invalidRoutes);
}
/**
* 丢弃解析不到视图的路由;并去掉因此变空的目录。
*/
function pruneInvalidRoutes(
routes: RouteRecordRaw[],
layoutMap: ComponentRecordType,
invalidRoutes: WeakSet<object>,
): RouteRecordRaw[] {
return routes
.filter((route) => !invalidRoutes.has(route))
.map((route) => {
if (!route.children) {
return route;
}
return {
...route,
children: pruneInvalidRoutes(route.children, layoutMap, invalidRoutes),
};
})
.filter((route) => {
const children = route.children ?? [];
if (children.length > 0 || route.redirect) {
return true;
}
const meta = route.meta as Record<string, unknown> | undefined;
// 外链菜单(新窗口打开)没有组件是正常的,不能丢
if (meta?.openInNewWindow || meta?.link) {
return true;
}
// 只剩布局组件 = 变成了空目录,丢掉
const isLayout = Object.values(layoutMap).includes(
route.component as never,
);
if (isLayout) {
return false;
}
return Boolean(route.component);
});
}
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,28 @@
/**
* 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
);
}
export function getVxePopupContainer(
_node?: HTMLElement,
id?: string,
): HTMLElement {
let selector = 'div.vxe-table--body-wrapper.body--wrapper';
if (id) {
selector = `div#${id} ${selector}`;
}
// 挂载到vxe-table的滚动区域
const vxeTableContainerNode = document.querySelector(selector);
if (!vxeTableContainerNode) {
console.warn('无法找到vxe-table元素, 将会挂载到body.');
return document.body;
}
return vxeTableContainerNode as HTMLElement;
}
@@ -0,0 +1,12 @@
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';
export * from './file';
export * from './mitt';
export * from './tree';
export * from './uuid';
@@ -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,135 @@
/**
* copy to https://github.com/developit/mitt
* Expand clear method
*/
export type EventType = string | symbol;
// An event handler can take an optional event argument
// and should not return a value
export type Handler<T = unknown> = (event: T) => void;
export type WildcardHandler<T = Record<string, unknown>> = (
type: keyof T,
event: T[keyof T],
) => void;
// An array of all currently registered event handlers for a type
export type EventHandlerList<T = unknown> = Array<Handler<T>>;
export type WildCardEventHandlerList<T = Record<string, unknown>> = Array<
WildcardHandler<T>
>;
// A map of event types and their corresponding event handlers.
export type EventHandlerMap<Events extends Record<EventType, unknown>> = Map<
'*' | keyof Events,
EventHandlerList<Events[keyof Events]> | WildCardEventHandlerList<Events>
>;
export interface Emitter<Events extends Record<EventType, unknown>> {
all: EventHandlerMap<Events>;
clear(): void;
emit<Key extends keyof Events>(
type: undefined extends Events[Key] ? Key : never,
): void;
emit<Key extends keyof Events>(type: Key, event: Events[Key]): void;
off(type: '*', handler: WildcardHandler<Events>): void;
off<Key extends keyof Events>(
type: Key,
handler?: Handler<Events[Key]>,
): void;
on(type: '*', handler: WildcardHandler<Events>): void;
on<Key extends keyof Events>(type: Key, handler: Handler<Events[Key]>): void;
}
/**
* Mitt: Tiny (~200b) functional event emitter / pubsub.
* @name mitt
* @returns {Mitt} any
*/
export function mitt<Events extends Record<EventType, unknown>>(
all?: EventHandlerMap<Events>,
): Emitter<Events> {
type GenericEventHandler =
| Handler<Events[keyof Events]>
| WildcardHandler<Events>;
all = all || new Map();
return {
/**
* A Map of event names to registered handler functions.
*/
all,
/**
* Clear all
*/
clear() {
this.all.clear();
},
/**
* Invoke all handlers for the given type.
* If present, `'*'` handlers are invoked after type-matched handlers.
*
* Note: Manually firing '*' handlers is not supported.
*
* @param {string|symbol} type The event type to invoke
* @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler
* @memberOf mitt
*/
emit<Key extends keyof Events>(type: Key, evt?: Events[Key]) {
let handlers = all?.get(type);
if (handlers) {
[...(handlers as EventHandlerList<Events[keyof Events]>)].forEach(
(handler) => {
handler(evt as Events[Key]);
},
);
}
handlers = all?.get('*');
if (handlers) {
[...(handlers as WildCardEventHandlerList<Events>)].forEach(
(handler) => {
handler(type, evt as Events[Key]);
},
);
}
},
/**
* Remove an event handler for the given type.
* If `handler` is omitted, all handlers of the given type are removed.
* @param {string|symbol} type Type of event to unregister `handler` from (`'*'` to remove a wildcard handler)
* @param {Function} [handler] Handler function to remove
* @memberOf mitt
*/
off<Key extends keyof Events>(type: Key, handler?: GenericEventHandler) {
const handlers: Array<GenericEventHandler> | undefined = all?.get(type);
if (handlers) {
if (handler) {
handlers.splice(handlers.indexOf(handler) >>> 0, 1);
} else {
all?.set(type, []);
}
}
},
/**
* Register an event handler for the given type.
* @param {string|symbol} type Type of event to listen for, or `'*'` for all events
* @param {Function} handler Function to call in response to given event
* @memberOf mitt
*/
on<Key extends keyof Events>(type: Key, handler: GenericEventHandler) {
const handlers: Array<GenericEventHandler> | undefined = all?.get(type);
if (handlers) {
handlers.push(handler);
} else {
all?.set(type, [handler] as EventHandlerList<Events[keyof Events]>);
}
},
};
}
@@ -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,400 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
interface TreeHelperConfig {
children: string;
id: string;
pid: string;
}
type Fn = (node: any, parentNode?: any) => any;
// 默认配置
const DEFAULT_CONFIG: TreeHelperConfig = {
id: 'id',
pid: 'parentId',
children: 'children',
};
// 获取配置。 Object.assign 从一个或多个源对象复制到目标对象
const getConfig = (config: Partial<TreeHelperConfig>) =>
Object.assign({}, DEFAULT_CONFIG, config);
// tree from list
// 列表中的树
export function listToTree<T = any>(
list: any[],
config: Partial<TreeHelperConfig> = {},
): T[] {
const conf = getConfig(config) as TreeHelperConfig;
const nodeMap = new Map();
const result: T[] = [];
const { id, pid, children } = conf;
for (const node of list) {
node[children] = node[children] || [];
nodeMap.set(node[id], node);
}
for (const node of list) {
const parent = nodeMap.get(node[pid]);
(parent ? parent[children] : result).push(node);
}
return result;
}
export function treeToList<T = any>(
tree: any,
config: Partial<TreeHelperConfig> = {},
): T {
config = getConfig(config);
const { children } = config;
const result: any = [...tree];
for (let i = 0; i < result.length; i++) {
if (!result[i][children!]) continue;
result.splice(i + 1, 0, ...result[i][children!]);
}
return result;
}
export function findNode<T = any>(
tree: any,
func: Fn,
config: Partial<TreeHelperConfig> = {},
): null | T {
config = getConfig(config);
const { children } = config;
const list = [...tree];
for (const node of list) {
if (func(node)) return node;
node[children!] && list.push(...node[children!]);
}
return null;
}
export function findNodeAll<T = any>(
tree: any,
func: Fn,
config: Partial<TreeHelperConfig> = {},
): T[] {
config = getConfig(config);
const { children } = config;
const list = [...tree];
const result: T[] = [];
for (const node of list) {
func(node) && result.push(node);
node[children!] && list.push(...node[children!]);
}
return result;
}
export function findPath<T = any>(
tree: any,
func: Fn,
config: Partial<TreeHelperConfig> = {},
): null | T | T[] {
config = getConfig(config);
const path: T[] = [];
const list = [...tree];
const visitedSet = new Set();
const { children } = config;
while (list.length > 0) {
const node = list[0];
if (visitedSet.has(node)) {
path.pop();
list.shift();
} else {
visitedSet.add(node);
node[children!] && list.unshift(...node[children!]);
path.push(node);
if (func(node)) {
return path;
}
}
}
return null;
}
export function findPathAll(
tree: any,
func: Fn,
config: Partial<TreeHelperConfig> = {},
) {
config = getConfig(config);
const path: any[] = [];
const list = [...tree];
const result: any[] = [];
const { children } = config;
const visitedSet = new Set();
while (list.length > 0) {
const node = list[0];
if (visitedSet.has(node)) {
path.pop();
list.shift();
} else {
visitedSet.add(node);
node[children!] && list.unshift(...node[children!]);
path.push(node);
func(node) && result.push([...path]);
}
}
return result;
}
export function filter<T = any>(
tree: T[],
func: (n: T) => boolean,
// Partial 将 T 中的所有属性设为可选
config: Partial<TreeHelperConfig> = {},
): T[] {
// 获取配置
config = getConfig(config);
const children = config.children as string;
function listFilter(list: T[]) {
return list
.map((node: any) => ({ ...node }))
.filter((node) => {
// 递归调用 对含有children项 进行再次调用自身函数 listFilter
node[children] = node[children] && listFilter(node[children]);
// 执行传入的回调 func 进行过滤
return func(node) || (node[children] && node[children].length > 0);
});
}
return listFilter(tree);
}
export function forEach<T = any>(
tree: T[],
func: (n: T) => any,
config: Partial<TreeHelperConfig> = {},
): void {
config = getConfig(config);
const list: any[] = [...tree];
const { children } = config;
for (let i = 0; i < list.length; i++) {
// func 返回true就终止遍历,避免大量节点场景下无意义循环,引起浏览器卡顿
if (func(list[i])) {
return;
}
children &&
list[i][children] &&
list.splice(i + 1, 0, ...list[i][children]);
}
}
/**
* @description: Extract tree specified structure
* @description: 提取树指定结构
*/
export function treeMap<T = any>(
treeData: T[],
opt: { children?: string; conversion: Fn },
): T[] {
return treeData.map((item) => treeMapEach(item, opt));
}
/**
* @description: Extract tree specified structure
* @description: 提取树指定结构
*/
export function treeMapEach(
data: any,
{ conversion, children = 'children' }: { children?: string; conversion: Fn },
) {
const haveChildren =
Array.isArray(data[children]) && data[children].length > 0;
const conversionData = conversion(data) || {};
return haveChildren
? {
...conversionData,
[children]: data[children].map((i: number) =>
treeMapEach(i, {
children,
conversion,
}),
),
}
: {
...conversionData,
};
}
/**
* 递归遍历树结构
* @param treeDatas 树
* @param callBack 回调
* @param parentNode 父节点
*/
export function eachTree(treeDatas: any[], callBack: Fn, parentNode = {}) {
treeDatas.forEach((element) => {
const newNode = callBack(element, parentNode) || element;
if (element.children) {
eachTree(element.children, callBack, newNode);
}
});
}
// 如果节点的children为空, 则删除children属性
export function removeEmptyChildren(data: any[], childrenField = 'children') {
data.forEach((item) => {
if (!item[childrenField]) {
return;
}
if (item[childrenField].length > 0) {
removeEmptyChildren(item[childrenField]);
} else {
Reflect.deleteProperty(item, childrenField);
}
});
}
// eslint-disable-next-line jsdoc/require-returns-check
/**
*
* 添加全名 如 祖先节点-父节点-子节点
* @param treeData 已经是tree数据
* @param labelName 标签的字段名称
* @param splitStr 分隔符
* @returns void 无返回值 会修改原始数据
*/
export function addFullName(
treeData: any[],
labelName = 'label',
splitStr = '-',
) {
function addFullNameProperty(node: any, parentNames: any[] = []) {
const fullNameParts = [...parentNames, node[labelName]];
node.fullName = fullNameParts.join(splitStr);
if (node.children && node.children.length > 0) {
node.children.forEach((childNode: any) => {
addFullNameProperty(childNode, fullNameParts);
});
}
}
treeData.forEach((item: any) => {
addFullNameProperty(item);
});
}
/**
* https://blog.csdn.net/Web_J/article/details/129281329
* 给出节点nodeId 找到所有父节点ID
* @param treeList 树形结构list
* @param nodeId 要寻找的节点ID
* @param config config
* @returns 父节点ID数组
*/
export function findParentsIds(
treeList: any[],
nodeId: number,
config: Partial<TreeHelperConfig> = {},
) {
const conf = getConfig(config) as TreeHelperConfig;
const { id, children } = conf;
// 用于存储所有父节点ID的数组
const parentIds: number[] = [];
function traverse(node: any, nodeId: number) {
if (node[id] === nodeId) {
return true;
}
if (node[children]) {
// 如果当前节点有子节点,则继续遍历子节点
for (const childNode of node[children]) {
if (traverse(childNode, nodeId)) {
// 如果在子节点中找到了子节点的父节点,则将当前节点的ID添加到父节点ID数组中,并返回true表示已经找到了子节点
parentIds.push(node[id]);
return true;
}
}
}
return false;
}
for (const node of treeList) {
if (traverse(node, nodeId)) {
// 如果在当前节点的子树中找到了子节点的父节点,则直接退出循环
break;
}
}
return parentIds.sort();
}
/**
* 给出节点数组 找到所有父节点ID
* @param treeList 树形结构list
* @param nodeIds 要寻找的节点ID list
* @param config config
* @returns 父节点ID数组
*/
export function findGroupParentIds(
treeList: any[],
nodeIds: number[],
config: Partial<TreeHelperConfig> = {},
) {
// 用于存储所有父节点ID的Set 主要为了去重
const parentIds = new Set<number>();
nodeIds.forEach((nodeId) => {
findParentsIds(treeList, nodeId, config).forEach((parentId) => {
parentIds.add(parentId);
});
});
return [...parentIds].sort();
}
/**
* 找到所有ID 返回数组
* @param treeList list
* @param config
* @returns ID数组
*/
export function findAllIds(
treeList: any[],
config: Partial<TreeHelperConfig> = DEFAULT_CONFIG,
) {
const conf = getConfig(config) as TreeHelperConfig;
const { id, children } = conf;
const ids: number[] = [];
treeList.forEach((item) => {
if (item[children]) {
const tempIds = findAllIds(item[children], config);
ids.push(...tempIds);
}
ids.push(item[id]);
});
return [...ids].sort();
}
/**
* @description 这里抄的filterByLevel函数
* @description 主要用于获取指定层级的节点数组
*/
export function findIdsByLevel(
level = 1,
list?: any[],
config: Partial<TreeHelperConfig> = DEFAULT_CONFIG,
currentLevel = 1,
) {
if (!level) {
return [];
}
const res: (number | string)[] = [];
const data = list || [];
for (const item of data) {
const { id: keyField, children: childrenField } = config;
const key = keyField ? item[keyField] : '';
const children = childrenField ? item[childrenField] : [];
res.push(key);
if (children && children.length > 0 && currentLevel < level) {
currentLevel += 1;
res.push(...findIdsByLevel(level, children, config, currentLevel));
}
}
return res as number[] | string[];
}
@@ -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,42 @@
const hexList: string[] = [];
for (let i = 0; i <= 15; i++) {
hexList[i] = i.toString(16);
}
export function buildUUID(): string {
let uuid = '';
for (let i = 1; i <= 36; i++) {
switch (i) {
case 9:
case 14:
case 19:
case 24: {
uuid += '-';
break;
}
case 15: {
uuid += 4;
break;
}
case 20: {
uuid += hexList[(Math.random() * 4) | 8];
break;
}
default: {
uuid += hexList[Math.trunc(Math.random() * 16)];
}
}
}
return uuid.replaceAll('-', '');
}
let unique = 0;
export function buildShortUUID(prefix = ''): string {
const time = Date.now();
const random = Math.floor(Math.random() * 1_000_000_000);
unique++;
return `${prefix}_${random}${unique}${String(time)}`;
}
+5
View File
@@ -0,0 +1,5 @@
export * from './helpers';
export * from '@vben-core/shared/cache';
export * from '@vben-core/shared/color';
export * from '@vben-core/shared/utils';
export { fileTypeFromBlob } from 'file-type';