f
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
2025-11-27 19:08:41 +08:00
commit 0668eea99b
1450 changed files with 125078 additions and 0 deletions

19
packages/utils/README.md Normal file
View File

@@ -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';
```

View File

@@ -0,0 +1,27 @@
{
"name": "@vben/utils",
"version": "5.5.4",
"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:"
}
}

View File

@@ -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 };

View File

@@ -0,0 +1,81 @@
import type { Router, RouteRecordRaw } from 'vue-router';
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings';
import { filterTree, mapTree } from '@vben-core/shared/utils';
/**
* 根据 routes 生成菜单列表
* @param routes
*/
async function generateMenus(
routes: RouteRecordRaw[],
router: Router,
): Promise<MenuRecordRaw[]> {
// 将路由列表转换为一个以 name 为键的对象映射
// 获取所有router最终的path及name
const finalRoutesMap: { [key: string]: string } = Object.fromEntries(
router.getRoutes().map(({ name, path }) => [name, path]),
);
let menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => {
// 路由表的路径写法有多种这里从router获取到最终的path并赋值
const path = finalRoutesMap[route.name as string] ?? route.path;
// 转换为菜单结构
// const path = matchRoute?.path ?? route.path;
const { meta, name: routeName, redirect, children } = route;
const {
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 && 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 as string,
show: !route?.meta?.hideInMenu,
children: resultChildren || [],
};
});
// 对菜单进行排序避免order=0时被替换成999的问题
menus = menus.sort((a, b) => (a?.order ?? 999) - (b?.order ?? 999));
const finalMenus = filterTree(menus, (menu) => {
return !!menu.show;
});
return finalMenus;
}
export { generateMenus };

View File

@@ -0,0 +1,86 @@
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(menuRoutes, layoutMap, normalizePageMap);
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 };

View File

@@ -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 };

View File

@@ -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
);
}

View File

@@ -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';

View File

@@ -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 };

View File

@@ -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);
}
});
}

View File

@@ -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 },
); // 确保事件只触发一次
}
}

View File

@@ -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';

View File

@@ -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"]
}