sync: 同步管理前端 v1.0.4 并脱敏

This commit is contained in:
MiFaZhan
2026-10-29 14:43:14 +08:00
parent f754b27ac2
commit 59b307ca75
236 changed files with 5013 additions and 708 deletions
+29 -1
View File
@@ -28,7 +28,7 @@ async function generateAccessible(
// 生成路由
const accessibleRoutes = await generateRoutes(mode, options);
const root = router.getRoutes().find((item) => item.path === '/');
const root = findRootRoute(router);
// 获取已有的路由名称列表
const names = root?.children?.map((item) => item.name) ?? [];
@@ -71,6 +71,34 @@ async function generateAccessible(
return { accessibleMenus, accessibleRoutes };
}
function findRootRoute(router: GenerateMenuAndRoutesOptions['router']) {
const routes = router.getRoutes();
// Route paths are not unique: an empty-path backend child can normalize to '/'.
const namedRoot = routes.find((item) => item.name === 'Root');
if (namedRoot) {
return namedRoot;
}
// Keep a path-independent fallback for applications that rename the core route.
const titledRoot = routes.find((item) => item.meta.title === 'Root');
if (titledRoot) {
return titledRoot;
}
const childNames = new Set(
routes.flatMap((item) => item.children?.map((child) => child.name) ?? []),
);
const topLevelLayouts = routes.filter(
(item) =>
item.children?.length &&
!item.aliasOf &&
(item.name === undefined || !childNames.has(item.name)),
);
return topLevelLayouts.length === 1 ? topLevelLayouts[0] : undefined;
}
/**
* Generate routes
* @param mode
@@ -1,8 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router';
import { startProgress, stopProgress } from '@vben-core/shared/utils';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@vben-core/shared/utils', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@vben-core/shared/utils')>();
return { ...actual, startProgress: vi.fn(), stopProgress: vi.fn() };
});
import { useTabbarStore } from './tabbar';
describe('useAccessStore', () => {
@@ -321,5 +329,7 @@ describe('useAccessStore', () => {
expect(store.excludeCachedTabs.has('Dashboard')).toBe(false);
expect(store.renderRouteView).toBe(true);
expect(startProgress).toHaveBeenCalledTimes(1);
expect(stopProgress).toHaveBeenCalledTimes(1);
});
});
@@ -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();
}
});
});
@@ -28,7 +28,11 @@ async function generateRoutesByBackend(
normalizePageMap[normalizeViewPath(key)] = value;
}
const routes = convertRoutes(menuRoutes, layoutMap, normalizePageMap);
const routes = convertRoutes(
removePathlessRoutes(menuRoutes),
layoutMap,
normalizePageMap,
);
return routes;
} catch (error) {
@@ -37,12 +41,31 @@ async function generateRoutesByBackend(
}
}
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[] {
return mapTree(routes, (node) => {
// 前端无对应视图、需丢弃的路由
const invalidRoutes = new WeakSet<object>();
const converted = mapTree(routes, (node) => {
const route = node as unknown as RouteRecordRaw;
const { component, name } = node;
@@ -62,13 +85,61 @@ function convertRoutes(
if (pageMap[pageKey]) {
route.component = pageMap[pageKey];
} else {
console.error(`route component is invalid: ${pageKey}`, route);
route.component = pageMap['/_core/fallback/not-found.vue'];
// 前端没有对应视图(后端菜单指向了已不存在的页面)。
// 这里不再替换成 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 {