mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-13 04:39:41 +00:00
refactor: 大规模代码整理与功能优化
1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码 2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑 3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件 4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { AppRouteRecord, RouteMeta } from "@/types/router";
|
||||
import type { UserInfo } from "@/api/module_system/user";
|
||||
import type { MenuTable } from "@/api/module_system/menu";
|
||||
import type { MenuTable } from "@/api/module_platform/menu";
|
||||
import { useUserStore } from "@stores";
|
||||
import { useAppMode } from "@/hooks/core/useAppMode";
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
ROUTE_COMPONENT_LAYOUT,
|
||||
ROUTE_COMPONENT_NESTED_PARENT,
|
||||
} from "./staticRoutes";
|
||||
import { formatMenuTitle } from "@utils";
|
||||
import { MenuTypeEnum } from "@/enums/system/menu.enum";
|
||||
|
||||
/**
|
||||
@@ -20,18 +19,22 @@ import { MenuTypeEnum } from "@/enums/system/menu.enum";
|
||||
/** 前端模式并入菜单的内置路由(扩展点,默认空) */
|
||||
export const builtinFrontendRoutes: AppRouteRecord[] = [];
|
||||
|
||||
function normalizeMenuNestedPaths(items: MenuTable[], parentAbsolutePath?: string): MenuTable[] {
|
||||
function normalizeMenuNestedPaths(items: MenuTable[], parentAbsolutePath = ""): MenuTable[] {
|
||||
return items.map((node) => {
|
||||
const raw = (node.route_path ?? "").trim();
|
||||
let routePath: string | null | undefined = node.route_path;
|
||||
if (parentAbsolutePath && raw && raw === parentAbsolutePath) {
|
||||
routePath = "";
|
||||
}
|
||||
const canonical = raw.startsWith("/") ? raw : parentAbsolutePath;
|
||||
// 计算当前节点的绝对路径传给子节点递归使用
|
||||
const canonical = raw
|
||||
? raw.startsWith("/")
|
||||
? raw
|
||||
: parentAbsolutePath
|
||||
? joinAbsolutePath(parentAbsolutePath, raw)
|
||||
: `/${raw}`
|
||||
: parentAbsolutePath;
|
||||
|
||||
const children = node.children?.length
|
||||
? normalizeMenuNestedPaths(node.children, canonical)
|
||||
: undefined;
|
||||
return { ...node, route_path: routePath, children };
|
||||
return { ...node, children };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,7 +50,7 @@ function normalizeAppRouteChildPaths(
|
||||
parentAbsolutePath = ""
|
||||
): AppRouteRecord[] {
|
||||
return routes.map((route) => {
|
||||
let path = (route.path ?? "").trim();
|
||||
const path = (route.path ?? "").trim();
|
||||
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return {
|
||||
@@ -58,38 +61,17 @@ function normalizeAppRouteChildPaths(
|
||||
};
|
||||
}
|
||||
|
||||
if (parentAbsolutePath && path.startsWith("/")) {
|
||||
const p = parentAbsolutePath.replace(/\/$/, "");
|
||||
if (path.startsWith(`${p}/`)) {
|
||||
path = path.slice(p.length + 1);
|
||||
} else if (path === p) {
|
||||
path = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
parentAbsolutePath &&
|
||||
path.startsWith("/") &&
|
||||
!/^https?:\/\//i.test(path) &&
|
||||
!path.startsWith("/outside/iframe/")
|
||||
) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
if (segments.length) path = segments[segments.length - 1]!;
|
||||
}
|
||||
|
||||
const currentAbs = !parentAbsolutePath
|
||||
? path.startsWith("/")
|
||||
const currentAbs = parentAbsolutePath
|
||||
? joinAbsolutePath(parentAbsolutePath, path)
|
||||
: path.startsWith("/")
|
||||
? path
|
||||
: path
|
||||
? `/${path.replace(/^\/+/, "")}`
|
||||
: ""
|
||||
: joinAbsolutePath(parentAbsolutePath, path);
|
||||
: `/${path}`;
|
||||
|
||||
const children = route.children?.length
|
||||
? normalizeAppRouteChildPaths(route.children, currentAbs)
|
||||
: route.children;
|
||||
|
||||
return { ...route, path, children };
|
||||
return { ...route, children };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,6 +110,12 @@ function mapMenuNode(item: MenuTable, depth = 0): AppRouteRecord {
|
||||
fixedTab: !!item.affix,
|
||||
alwaysShow: !!item.always_show,
|
||||
isHide: !!item.hidden,
|
||||
isHideTab: !!item.is_hide_tab,
|
||||
link: item.link || undefined,
|
||||
isIframe: !!item.is_iframe,
|
||||
activePath: item.active_path || undefined,
|
||||
showBadge: !!item.show_badge,
|
||||
showTextBadge: item.show_text_badge || undefined,
|
||||
client: item.client,
|
||||
};
|
||||
|
||||
@@ -161,8 +149,6 @@ export class MenuProcessor {
|
||||
menuList = await this.processBackendMenu();
|
||||
}
|
||||
|
||||
this.validateMenuPaths(menuList);
|
||||
|
||||
return this.normalizeMenuPaths(menuList);
|
||||
}
|
||||
|
||||
@@ -320,53 +306,6 @@ export class MenuProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
private validateMenuPaths(menuList: AppRouteRecord[], level = 1): void {
|
||||
menuList.forEach((route) => {
|
||||
if (!route.children?.length) return;
|
||||
|
||||
const parentName = String(route.name || route.path || "未知路由");
|
||||
|
||||
route.children.forEach((child) => {
|
||||
const childPath = child.path || "";
|
||||
|
||||
if (this.isValidAbsolutePath(childPath)) return;
|
||||
|
||||
if (childPath.startsWith("/")) {
|
||||
this.logPathError(child, childPath, parentName, level);
|
||||
}
|
||||
});
|
||||
|
||||
this.validateMenuPaths(route.children, level + 1);
|
||||
});
|
||||
}
|
||||
|
||||
private isValidAbsolutePath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith("http://") ||
|
||||
path.startsWith("https://") ||
|
||||
path.startsWith("/outside/iframe/")
|
||||
);
|
||||
}
|
||||
|
||||
private logPathError(
|
||||
route: AppRouteRecord,
|
||||
path: string,
|
||||
parentName: string,
|
||||
level: number
|
||||
): void {
|
||||
const routeName = String(route.name || path || "未知路由");
|
||||
const menuTitle = route.meta?.title || routeName;
|
||||
const suggestedPath = path.split("/").pop() || path.slice(1);
|
||||
|
||||
console.error(
|
||||
`[路由配置错误] 菜单 "${formatMenuTitle(menuTitle)}" (name: ${routeName}, path: ${path}) 配置错误\n` +
|
||||
` 位置: ${parentName} > ${routeName}\n` +
|
||||
` 问题: ${level + 1}级菜单的 path 不能以 / 开头\n` +
|
||||
` 当前配置: path: '${path}'\n` +
|
||||
` 应该改为: path: '${suggestedPath}'`
|
||||
);
|
||||
}
|
||||
|
||||
private buildFullPath(path: string, parentPath: string): string {
|
||||
if (!path) return "";
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
resetStorageInvalidated,
|
||||
checkStorageInvalidated,
|
||||
} from "@utils";
|
||||
import { RouteRegistry } from "./dynamicRoutes";
|
||||
import { RouteRegistry } from "./core";
|
||||
import { MenuProcessor } from "./MenuProcessor";
|
||||
|
||||
// --- 模块级单例与守卫状态 ---
|
||||
@@ -154,10 +154,6 @@ async function handleRouteGuard(
|
||||
const loginRedirect = handleLoginStatus(to, userStore);
|
||||
if (loginRedirect) return loginRedirect;
|
||||
|
||||
// 多租户:已登录但未选租户时重定向到租户选择页
|
||||
const tenantRedirect = handleTenantGuard(to, userStore);
|
||||
if (tenantRedirect) return tenantRedirect;
|
||||
|
||||
if (routeInitFailed) {
|
||||
return to.matched.length > 0 ? true : { name: "500", replace: true };
|
||||
}
|
||||
@@ -204,41 +200,6 @@ function handleLoginStatus(
|
||||
return { name: "Login", query: { redirect: to.fullPath }, replace: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 多租户守卫:已登录但尚未选择租户时,重定向到租户选择页。
|
||||
* 仅在用户有多个租户时生效;单租户时自动选择。
|
||||
*/
|
||||
function handleTenantGuard(
|
||||
to: RouteLocationNormalized,
|
||||
userStore: ReturnType<typeof useUserStore>
|
||||
): Record<string, unknown> | undefined {
|
||||
// 未登录或已在租户选择页,不处理
|
||||
if (!userStore.isLogin || to.path === "/tenant-select" || to.name === "TenantSelect") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 匿名公开路径不检查租户
|
||||
if (isAnonymousPublicPath(to.path)) return undefined;
|
||||
|
||||
const tenants = userStore.tenantList;
|
||||
const current = userStore.currentTenant;
|
||||
|
||||
// 已有选中租户,放行
|
||||
if (current) return undefined;
|
||||
|
||||
// 未加载租户列表:先放行,由动态路由初始化后加载
|
||||
if (tenants.length === 0) return undefined;
|
||||
|
||||
// 单租户:自动选择
|
||||
if (tenants.length === 1) {
|
||||
userStore.setCurrentTenant(tenants[0]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 多租户:跳转选择页
|
||||
return { name: "TenantSelect", query: { redirect: to.fullPath }, replace: true };
|
||||
}
|
||||
|
||||
/** 登录页(项目里同时存在 `/login` 与 `/auth/login` 等多套入口) */
|
||||
function isLoginRoute(to: RouteLocationNormalized): boolean {
|
||||
return to.path === "/login" || to.path === ROUTE_PATH_LOGIN_ALT || to.name === "Login";
|
||||
@@ -324,8 +285,11 @@ async function handleDynamicRoutes(to: RouteLocationNormalized, router: Router)
|
||||
loadingService.showLoading();
|
||||
|
||||
try {
|
||||
// 1. 获取用户信息
|
||||
await fetchUserInfo();
|
||||
// 1. 获取用户信息(若 login() 已拉取则跳过,避免重复请求)
|
||||
const userStore = useUserStore();
|
||||
if (!userStore.hasGetRoute) {
|
||||
await fetchUserInfo();
|
||||
}
|
||||
|
||||
// 2. 获取菜单数据
|
||||
const menuList = await menuProcessor.getMenuList();
|
||||
@@ -424,6 +388,25 @@ export function resetDynamicRoutesSync(): void {
|
||||
resetRouteInitState();
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新动态路由和菜单:卸载旧路由 → 重新拉取菜单 → 注册新路由。
|
||||
* 不拉取用户信息(由调用方并行完成),避免覆盖前端本地状态(主题色等)。
|
||||
*/
|
||||
export async function refreshMenuAndRoutes(): Promise<void> {
|
||||
routeRegistry?.unregister();
|
||||
IframeRouteManager.getInstance().clear();
|
||||
|
||||
const menuStore = useMenuStore();
|
||||
menuStore.removeAllDynamicRoutes();
|
||||
menuStore.setMenuList([]);
|
||||
|
||||
const menuList = await menuProcessor.getMenuList();
|
||||
routeRegistry?.register(menuList);
|
||||
menuStore.setMenuList(menuList);
|
||||
menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || []);
|
||||
IframeRouteManager.getInstance().save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟重置路由相关状态(登出等场景避免与导航竞态)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 组件加载器 —— 菜单字符串路径 → views 懒加载。
|
||||
* 支持 module_system→module_platform 回退、param→params 重命名回退。
|
||||
*/
|
||||
import { defineComponent, h, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import {
|
||||
IframeRouteManager,
|
||||
NestedRouterParent,
|
||||
ROUTE_COMPONENT_LAYOUT,
|
||||
ROUTE_COMPONENT_NESTED_PARENT,
|
||||
} from "../staticRoutes";
|
||||
|
||||
export class ComponentLoader {
|
||||
private modules: Record<string, () => Promise<any>>;
|
||||
|
||||
constructor() {
|
||||
this.modules = import.meta.glob("../../views/**/*.vue");
|
||||
}
|
||||
|
||||
load(componentPath: string): () => Promise<any> {
|
||||
if (!componentPath) {
|
||||
return this.createEmptyComponent();
|
||||
}
|
||||
if (componentPath === ROUTE_COMPONENT_LAYOUT || componentPath === "/layouts/index") {
|
||||
return this.loadLayout();
|
||||
}
|
||||
if (componentPath === ROUTE_COMPONENT_NESTED_PARENT) {
|
||||
return this.loadNestedParent();
|
||||
}
|
||||
|
||||
const normalized = componentPath.startsWith("/")
|
||||
? componentPath
|
||||
: `/${componentPath.replace(/^\/+/, "")}`;
|
||||
const fullPath = `../../views${normalized}.vue`;
|
||||
const fullPathWithIndex = `../../views${normalized}/index.vue`;
|
||||
let module = this.modules[fullPath] || this.modules[fullPathWithIndex];
|
||||
|
||||
// Fallback: component moved from module_system to module_platform
|
||||
if (!module && normalized.includes("/module_system/")) {
|
||||
const altPath = normalized.replace("/module_system/", "/module_platform/");
|
||||
module =
|
||||
this.modules[`../../views${altPath}.vue`] ||
|
||||
this.modules[`../../views${altPath}/index.vue`];
|
||||
}
|
||||
// Fallback: renamed view directories (param→params)
|
||||
if (!module) {
|
||||
const renames: Record<string, string> = { "/param/": "/params/" };
|
||||
for (const [oldP, newP] of Object.entries(renames)) {
|
||||
const alt = normalized.replace(oldP, newP);
|
||||
if (alt !== normalized) {
|
||||
const fallbackModule =
|
||||
this.modules[`../../views${alt}.vue`] || this.modules[`../../views${alt}/index.vue`];
|
||||
if (fallbackModule) {
|
||||
module = fallbackModule;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!module) {
|
||||
console.error(
|
||||
`[ComponentLoader] 未找到组件: ${componentPath},尝试过的路径: ${fullPath} 和 ${fullPathWithIndex}`
|
||||
);
|
||||
return this.createErrorComponent(componentPath);
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
loadLayout(): () => Promise<any> {
|
||||
return () => import("@/components/layouts/index.vue");
|
||||
}
|
||||
|
||||
loadIframe(): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve(
|
||||
defineComponent({
|
||||
name: "IframeView",
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const isLoading = ref(true);
|
||||
const iframeUrl = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path);
|
||||
if (iframeRoute?.meta) {
|
||||
iframeUrl.value = iframeRoute.meta.link || "";
|
||||
}
|
||||
});
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
return () =>
|
||||
h("div", { class: "box-border w-full h-full", "v-loading": isLoading.value }, [
|
||||
h("iframe", {
|
||||
src: iframeUrl.value,
|
||||
frameborder: "0",
|
||||
class: "w-full h-full min-h-[calc(100vh-120px)] border-none",
|
||||
onLoad: handleIframeLoad,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
loadNestedParent(): () => Promise<any> {
|
||||
return () => Promise.resolve(NestedRouterParent);
|
||||
}
|
||||
|
||||
private createEmptyComponent(): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
render() {
|
||||
return h("div", {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createErrorComponent(componentPath: string): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
render() {
|
||||
return h("div", { class: "route-error" }, `组件未找到: ${componentPath}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 路由注册表 —— 校验通过后批量 addRoute,支持注册/注销/重入判断。
|
||||
*/
|
||||
import type { RouteRecordRaw, Router } from "vue-router";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import { ComponentLoader } from "./ComponentLoader";
|
||||
import { RouteValidator } from "./RouteValidator";
|
||||
import { RouteTransformer } from "./RouteTransformer";
|
||||
import { ROOT_LAYOUT_ROUTE_NAME } from "../staticRoutes";
|
||||
|
||||
/** 与静态壳层冲突的一级 path 段,动态注册时跳过以免覆盖首页 / 仪表盘等 */
|
||||
const RESERVED_SHELL_SEGMENTS = new Set(["home", "profile", "changelog", "dashboard"]);
|
||||
|
||||
function pathFirstSegment(path: string): string {
|
||||
return (
|
||||
path
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.split("/")
|
||||
.filter(Boolean)[0] ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function registrationName(route: AppRouteRecord, index: number): AppRouteRecord {
|
||||
const explicit = typeof route.name === "string" ? route.name.trim() : "";
|
||||
if (explicit) return route;
|
||||
const seg = pathFirstSegment(route.path || "") || "route";
|
||||
const safe = seg.replace(/[^\w]/g, "_");
|
||||
return { ...route, name: `Dyn_${index}_${safe}` };
|
||||
}
|
||||
|
||||
function routeNameKey(route: AppRouteRecord): string {
|
||||
return typeof route.name === "string" ? route.name : String(route.name ?? "");
|
||||
}
|
||||
|
||||
export class RouteRegistry {
|
||||
private router: Router;
|
||||
private componentLoader: ComponentLoader;
|
||||
private validator: RouteValidator;
|
||||
private transformer: RouteTransformer;
|
||||
private removeRouteFns: (() => void)[] = [];
|
||||
private registered = false;
|
||||
|
||||
constructor(router: Router) {
|
||||
this.router = router;
|
||||
this.componentLoader = new ComponentLoader();
|
||||
this.validator = new RouteValidator();
|
||||
this.transformer = new RouteTransformer(this.componentLoader, { shellChild: true });
|
||||
}
|
||||
|
||||
register(menuList: AppRouteRecord[]): void {
|
||||
if (this.registered) {
|
||||
console.warn("[RouteRegistry] 路由已注册,跳过重复注册");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = this.validator.validate(menuList);
|
||||
if (!validationResult.valid) {
|
||||
throw new Error(`路由配置验证失败: ${validationResult.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
const removeRouteFns: (() => void)[] = [];
|
||||
|
||||
menuList.forEach((route, index) => {
|
||||
const seg = pathFirstSegment(route.path || "");
|
||||
if (seg && RESERVED_SHELL_SEGMENTS.has(seg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const namedRoute = registrationName(route, index);
|
||||
if (this.router.hasRoute(routeNameKey(namedRoute))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const routeConfig = this.transformer.transform(namedRoute);
|
||||
removeRouteFns.push(
|
||||
this.router.addRoute(ROOT_LAYOUT_ROUTE_NAME, routeConfig as RouteRecordRaw)
|
||||
);
|
||||
});
|
||||
|
||||
this.removeRouteFns = removeRouteFns;
|
||||
this.registered = true;
|
||||
}
|
||||
|
||||
unregister(): void {
|
||||
this.removeRouteFns.forEach((fn) => fn());
|
||||
this.removeRouteFns = [];
|
||||
this.registered = false;
|
||||
}
|
||||
|
||||
isRegistered(): boolean {
|
||||
return this.registered;
|
||||
}
|
||||
|
||||
getRemoveRouteFns(): (() => void)[] {
|
||||
return this.removeRouteFns;
|
||||
}
|
||||
|
||||
markAsRegistered(): void {
|
||||
this.registered = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 路由转换器 —— 菜单树 → vue-router 记录。
|
||||
* 处理 iframe、一级叶子路由、普通路由三种形态,支持 shellChild 模式。
|
||||
*/
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import { ComponentLoader } from "./ComponentLoader";
|
||||
import { IframeRouteManager, ROUTE_COMPONENT_LAYOUT } from "../staticRoutes";
|
||||
|
||||
interface ConvertedRoute extends Omit<RouteRecordRaw, "children"> {
|
||||
id?: number;
|
||||
children?: ConvertedRoute[];
|
||||
component?: RouteRecordRaw["component"] | (() => Promise<any>);
|
||||
}
|
||||
|
||||
export class RouteTransformer {
|
||||
private componentLoader: ComponentLoader;
|
||||
private iframeManager: IframeRouteManager;
|
||||
private readonly shellChild: boolean;
|
||||
|
||||
constructor(componentLoader: ComponentLoader, options?: { shellChild?: boolean }) {
|
||||
this.componentLoader = componentLoader;
|
||||
this.shellChild = options?.shellChild ?? false;
|
||||
this.iframeManager = IframeRouteManager.getInstance();
|
||||
}
|
||||
|
||||
transform(route: AppRouteRecord, depth = 0, parentAbsPath = ""): ConvertedRoute {
|
||||
const absPath = (route.path || "").trim();
|
||||
const pathOut = this.routerPath(depth, parentAbsPath, absPath);
|
||||
const { component, children, ...routeConfig } = route;
|
||||
|
||||
const converted: ConvertedRoute = { ...routeConfig, path: pathOut, component: undefined };
|
||||
|
||||
if (route.meta.isIframe) {
|
||||
this.handleIframeRoute(converted, route, depth);
|
||||
} else if (this.isFirstLevelLeaf(route, depth)) {
|
||||
this.handleFirstLevelLeaf(converted, route, component as string);
|
||||
} else {
|
||||
this.handleNormalRoute(converted, component as string, depth);
|
||||
}
|
||||
|
||||
converted.path = pathOut;
|
||||
|
||||
if (children?.length) {
|
||||
converted.children = children.map((c) => this.transform(c, depth + 1, absPath));
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
private routerPath(depth: number, parentAbsPath: string, absPath: string): string {
|
||||
const firstSeg = absPath.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!this.shellChild) {
|
||||
if (depth === 0) {
|
||||
return firstSeg ? `/${firstSeg}` : "/";
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
if (depth === 0) {
|
||||
return firstSeg;
|
||||
}
|
||||
if (!parentAbsPath || !absPath) return absPath;
|
||||
const p = parentAbsPath.replace(/\/$/, "");
|
||||
if (absPath.startsWith(`${p}/`)) return absPath.slice(p.length + 1);
|
||||
return absPath.split("/").filter(Boolean).pop() ?? absPath;
|
||||
}
|
||||
|
||||
private isFirstLevelLeaf(route: AppRouteRecord, depth: number): boolean {
|
||||
return depth === 0 && (!route.children || route.children.length === 0);
|
||||
}
|
||||
|
||||
private handleIframeRoute(
|
||||
targetRoute: ConvertedRoute,
|
||||
sourceRoute: AppRouteRecord,
|
||||
depth: number
|
||||
): void {
|
||||
if (depth === 0) {
|
||||
targetRoute.component = this.shellChild
|
||||
? this.componentLoader.loadNestedParent()
|
||||
: this.componentLoader.loadLayout();
|
||||
targetRoute.name = "";
|
||||
const leafPath = this.shellChild ? "" : sourceRoute.path || "";
|
||||
targetRoute.children = [
|
||||
{
|
||||
...sourceRoute,
|
||||
path: leafPath,
|
||||
component: this.componentLoader.loadIframe(),
|
||||
} as ConvertedRoute,
|
||||
];
|
||||
} else {
|
||||
targetRoute.component = this.componentLoader.loadIframe();
|
||||
}
|
||||
this.iframeManager.add(sourceRoute);
|
||||
}
|
||||
|
||||
private handleFirstLevelLeaf(
|
||||
converted: ConvertedRoute,
|
||||
route: AppRouteRecord,
|
||||
component: string | undefined
|
||||
): void {
|
||||
converted.component = this.shellChild
|
||||
? this.componentLoader.loadNestedParent()
|
||||
: this.componentLoader.loadLayout();
|
||||
converted.name = "";
|
||||
route.meta.isFirstLevel = true;
|
||||
const leafPath = this.shellChild ? "" : route.path || "";
|
||||
converted.children = [
|
||||
{
|
||||
...route,
|
||||
path: leafPath,
|
||||
component: component ? this.componentLoader.load(component) : undefined,
|
||||
} as ConvertedRoute,
|
||||
];
|
||||
}
|
||||
|
||||
private handleNormalRoute(
|
||||
converted: ConvertedRoute,
|
||||
component: string | undefined,
|
||||
depth: number
|
||||
): void {
|
||||
if (!component) return;
|
||||
if (this.shellChild && depth === 0 && component === ROUTE_COMPONENT_LAYOUT) {
|
||||
converted.component = this.componentLoader.loadNestedParent();
|
||||
return;
|
||||
}
|
||||
converted.component = this.componentLoader.load(component);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 路由验证器 —— 菜单注册前校验:重名、缺少 component、深层误用 layout 占位等。
|
||||
* 校验结果分 errors(阻止注册)和 warnings(仅控制台提醒)两级。
|
||||
*/
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import { ROUTE_COMPONENT_LAYOUT } from "../staticRoutes";
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class RouteValidator {
|
||||
/** 已警告过的路由集合,避免重复打印校验警告 */
|
||||
private warnedRoutes = new Set<string>();
|
||||
|
||||
validate(routes: AppRouteRecord[]): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
this.checkDuplicates(routes, errors, warnings);
|
||||
this.checkComponents(routes, errors, warnings);
|
||||
this.checkNestedIndexComponent(routes);
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: [],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private checkDuplicates(
|
||||
routes: AppRouteRecord[],
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
parentPath = ""
|
||||
): void {
|
||||
const routeNameMap = new Map<string, string>();
|
||||
const componentPathMap = new Map<string, string>();
|
||||
|
||||
const checkRoutes = (items: AppRouteRecord[], pPath = "") => {
|
||||
items.forEach((route) => {
|
||||
const currentPath = route.path || "";
|
||||
const fullPath = this.resolvePath(pPath, currentPath);
|
||||
|
||||
if (route.name) {
|
||||
const routeName = String(route.name);
|
||||
if (routeNameMap.has(routeName)) {
|
||||
warnings.push(`路由名称重复: "${routeName}" (${fullPath})`);
|
||||
} else {
|
||||
routeNameMap.set(routeName, fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (route.component) {
|
||||
const componentPath = route.component;
|
||||
if (componentPath !== ROUTE_COMPONENT_LAYOUT) {
|
||||
const componentKey = `${pPath}:${componentPath}`;
|
||||
if (componentPathMap.has(componentKey)) {
|
||||
warnings.push(`组件路径重复: "${componentPath}" (${fullPath})`);
|
||||
} else {
|
||||
componentPathMap.set(componentKey, fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
checkRoutes(route.children, fullPath);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkRoutes(routes, parentPath);
|
||||
}
|
||||
|
||||
private checkComponents(
|
||||
routes: AppRouteRecord[],
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
parentPath = ""
|
||||
): void {
|
||||
routes.forEach((route) => {
|
||||
const hasExternalLink = !!route.meta?.link?.trim();
|
||||
const hasChildren = Array.isArray(route.children) && route.children.length > 0;
|
||||
const routePath = route.path || "[未定义路径]";
|
||||
const isIframe = route.meta?.isIframe;
|
||||
|
||||
if (route.component) {
|
||||
if (route.children?.length) {
|
||||
const fullPath = this.resolvePath(parentPath, route.path || "");
|
||||
this.checkComponents(route.children, errors, warnings, fullPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentPath === "" && !hasExternalLink && !isIframe) {
|
||||
errors.push(`一级菜单(${routePath}) 缺少 component,必须指向 ${ROUTE_COMPONENT_LAYOUT}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasExternalLink && !isIframe && !hasChildren) {
|
||||
errors.push(`路由(${routePath}) 缺少 component 配置`);
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
const fullPath = this.resolvePath(parentPath, route.path || "");
|
||||
this.checkComponents(route.children, errors, warnings, fullPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private checkNestedIndexComponent(routes: AppRouteRecord[], level = 1): void {
|
||||
routes.forEach((route) => {
|
||||
if (level > 1 && route.component === ROUTE_COMPONENT_LAYOUT) {
|
||||
this.logLayoutError(route, level);
|
||||
}
|
||||
if (route.children?.length) {
|
||||
this.checkNestedIndexComponent(route.children, level + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private logLayoutError(route: AppRouteRecord, level: number): void {
|
||||
const routeName = String(route.name || route.path || "未知路由");
|
||||
const routeKey = `${routeName}_${route.path}`;
|
||||
if (this.warnedRoutes.has(routeKey)) return;
|
||||
this.warnedRoutes.add(routeKey);
|
||||
|
||||
const menuTitle = route.meta?.title || routeName;
|
||||
const routePath = route.path || "/";
|
||||
|
||||
console.error(
|
||||
`[路由配置错误] 菜单 "${menuTitle}" (name: ${routeName}, path: ${routePath}) 配置错误\n` +
|
||||
` 问题: ${level}级菜单不能使用 ${ROUTE_COMPONENT_LAYOUT} 作为 component\n` +
|
||||
` 说明: 只有一级菜单才能使用 ${ROUTE_COMPONENT_LAYOUT},二级及以下菜单应该指向具体的组件路径\n` +
|
||||
` 当前配置: component: '${ROUTE_COMPONENT_LAYOUT}'\n` +
|
||||
` 应该改为: component: '/your/component/path' 或留空 ''(如果是目录菜单)`
|
||||
);
|
||||
}
|
||||
|
||||
private resolvePath(parent: string, child: string): string {
|
||||
return [parent.replace(/\/$/, ""), child.replace(/^\//, "")].filter(Boolean).join("/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ComponentLoader } from "./ComponentLoader";
|
||||
export { RouteTransformer } from "./RouteTransformer";
|
||||
export { RouteValidator } from "./RouteValidator";
|
||||
export { RouteRegistry } from "./RouteRegistry";
|
||||
export type { ValidationResult } from "./RouteValidator";
|
||||
@@ -1,470 +0,0 @@
|
||||
/**
|
||||
* 动态路由:`AppRouteRecord` → `RouteTransformer`(字符串路径 → import.glob)→ `router.addRoute(RootLayout)`。
|
||||
* 含校验(RouteValidator)、组件加载(ComponentLoader)、注册表(RouteRegistry)。
|
||||
*/
|
||||
import { defineComponent, h, onMounted, ref } from "vue";
|
||||
import type { RouteRecordRaw, Router } from "vue-router";
|
||||
import { useRoute } from "vue-router";
|
||||
import type { AppRouteRecord } from "@/types/router";
|
||||
import {
|
||||
IframeRouteManager,
|
||||
NestedRouterParent,
|
||||
ROOT_LAYOUT_ROUTE_NAME,
|
||||
ROUTE_COMPONENT_LAYOUT,
|
||||
ROUTE_COMPONENT_NESTED_PARENT,
|
||||
} from "./staticRoutes";
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单注册前校验:重名、缺少 component、深层误用 layout 占位等。
|
||||
* 校验结果分 errors(阻止注册)和 warnings(仅控制台提醒)两级。
|
||||
*/
|
||||
export class RouteValidator {
|
||||
/** 已警告过的路由集合,避免重复打印校验警告 */
|
||||
private warnedRoutes = new Set<string>();
|
||||
|
||||
validate(routes: AppRouteRecord[]): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
this.checkDuplicates(routes, errors, warnings);
|
||||
this.checkComponents(routes, errors, warnings);
|
||||
this.checkNestedIndexComponent(routes);
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private checkDuplicates(
|
||||
routes: AppRouteRecord[],
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
parentPath = ""
|
||||
): void {
|
||||
const routeNameMap = new Map<string, string>();
|
||||
const componentPathMap = new Map<string, string>();
|
||||
|
||||
const checkRoutes = (items: AppRouteRecord[], pPath = "") => {
|
||||
items.forEach((route) => {
|
||||
const currentPath = route.path || "";
|
||||
const fullPath = this.resolvePath(pPath, currentPath);
|
||||
|
||||
if (route.name) {
|
||||
const routeName = String(route.name);
|
||||
if (routeNameMap.has(routeName)) {
|
||||
warnings.push(`路由名称重复: "${routeName}" (${fullPath})`);
|
||||
} else {
|
||||
routeNameMap.set(routeName, fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (route.component && typeof route.component === "string") {
|
||||
const componentPath = route.component;
|
||||
if (componentPath !== ROUTE_COMPONENT_LAYOUT) {
|
||||
const componentKey = `${pPath}:${componentPath}`;
|
||||
if (componentPathMap.has(componentKey)) {
|
||||
warnings.push(`组件路径重复: "${componentPath}" (${fullPath})`);
|
||||
} else {
|
||||
componentPathMap.set(componentKey, fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
checkRoutes(route.children, fullPath);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
checkRoutes(routes, parentPath);
|
||||
}
|
||||
|
||||
private checkComponents(
|
||||
routes: AppRouteRecord[],
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
parentPath = ""
|
||||
): void {
|
||||
routes.forEach((route) => {
|
||||
const hasExternalLink = !!route.meta?.link?.trim();
|
||||
const hasChildren = Array.isArray(route.children) && route.children.length > 0;
|
||||
const routePath = route.path || "[未定义路径]";
|
||||
const isIframe = route.meta?.isIframe;
|
||||
|
||||
if (route.component) {
|
||||
if (route.children?.length) {
|
||||
const fullPath = this.resolvePath(parentPath, route.path || "");
|
||||
this.checkComponents(route.children, errors, warnings, fullPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentPath === "" && !hasExternalLink && !isIframe) {
|
||||
errors.push(`一级菜单(${routePath}) 缺少 component,必须指向 ${ROUTE_COMPONENT_LAYOUT}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasExternalLink && !isIframe && !hasChildren) {
|
||||
errors.push(`路由(${routePath}) 缺少 component 配置`);
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
const fullPath = this.resolvePath(parentPath, route.path || "");
|
||||
this.checkComponents(route.children, errors, warnings, fullPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private checkNestedIndexComponent(routes: AppRouteRecord[], level = 1): void {
|
||||
routes.forEach((route) => {
|
||||
if (level > 1 && route.component === ROUTE_COMPONENT_LAYOUT) {
|
||||
this.logLayoutError(route, level);
|
||||
}
|
||||
if (route.children?.length) {
|
||||
this.checkNestedIndexComponent(route.children, level + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private logLayoutError(route: AppRouteRecord, level: number): void {
|
||||
const routeName = String(route.name || route.path || "未知路由");
|
||||
const routeKey = `${routeName}_${route.path}`;
|
||||
if (this.warnedRoutes.has(routeKey)) return;
|
||||
this.warnedRoutes.add(routeKey);
|
||||
|
||||
const menuTitle = route.meta?.title || routeName;
|
||||
const routePath = route.path || "/";
|
||||
|
||||
console.error(
|
||||
`[路由配置错误] 菜单 "${menuTitle}" (name: ${routeName}, path: ${routePath}) 配置错误\n` +
|
||||
` 问题: ${level}级菜单不能使用 ${ROUTE_COMPONENT_LAYOUT} 作为 component\n` +
|
||||
` 说明: 只有一级菜单才能使用 ${ROUTE_COMPONENT_LAYOUT},二级及以下菜单应该指向具体的组件路径\n` +
|
||||
` 当前配置: component: '${ROUTE_COMPONENT_LAYOUT}'\n` +
|
||||
` 应该改为: component: '/your/component/path' 或留空 ''(如果是目录菜单)`
|
||||
);
|
||||
}
|
||||
|
||||
private resolvePath(parent: string, child: string): string {
|
||||
return [parent.replace(/\/$/, ""), child.replace(/^\//, "")].filter(Boolean).join("/");
|
||||
}
|
||||
}
|
||||
|
||||
// --- ComponentLoader:菜单字符串路径 → views 懒加载 ---
|
||||
export class ComponentLoader {
|
||||
private modules: Record<string, () => Promise<any>>;
|
||||
|
||||
constructor() {
|
||||
this.modules = import.meta.glob("../views/**/*.vue");
|
||||
}
|
||||
|
||||
load(componentPath: string): () => Promise<any> {
|
||||
if (!componentPath) {
|
||||
return this.createEmptyComponent();
|
||||
}
|
||||
if (componentPath === ROUTE_COMPONENT_LAYOUT || componentPath === "/layouts/index") {
|
||||
return this.loadLayout();
|
||||
}
|
||||
if (componentPath === ROUTE_COMPONENT_NESTED_PARENT) {
|
||||
return this.loadNestedParent();
|
||||
}
|
||||
|
||||
const normalized = componentPath.startsWith("/")
|
||||
? componentPath
|
||||
: `/${componentPath.replace(/^\/+/, "")}`;
|
||||
const fullPath = `../views${normalized}.vue`;
|
||||
const fullPathWithIndex = `../views${normalized}/index.vue`;
|
||||
const module = this.modules[fullPath] || this.modules[fullPathWithIndex];
|
||||
|
||||
if (!module) {
|
||||
console.error(
|
||||
`[ComponentLoader] 未找到组件: ${componentPath},尝试过的路径: ${fullPath} 和 ${fullPathWithIndex}`
|
||||
);
|
||||
return this.createErrorComponent(componentPath);
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
loadLayout(): () => Promise<any> {
|
||||
return () => import("@/components/layouts/index.vue");
|
||||
}
|
||||
|
||||
loadIframe(): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve(
|
||||
defineComponent({
|
||||
name: "IframeView",
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const isLoading = ref(true);
|
||||
const iframeUrl = ref("");
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path);
|
||||
if (iframeRoute?.meta) {
|
||||
iframeUrl.value = iframeRoute.meta.link || "";
|
||||
}
|
||||
});
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
return () =>
|
||||
h("div", { class: "box-border w-full h-full", "v-loading": isLoading.value }, [
|
||||
h("iframe", {
|
||||
ref: iframeRef,
|
||||
src: iframeUrl.value,
|
||||
frameborder: "0",
|
||||
class: "w-full h-full min-h-[calc(100vh-120px)] border-none",
|
||||
onLoad: handleIframeLoad,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
loadNestedParent(): () => Promise<any> {
|
||||
return () => Promise.resolve(NestedRouterParent);
|
||||
}
|
||||
|
||||
private createEmptyComponent(): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
render() {
|
||||
return h("div", {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private createErrorComponent(componentPath: string): () => Promise<any> {
|
||||
return () =>
|
||||
Promise.resolve({
|
||||
render() {
|
||||
return h("div", { class: "route-error" }, `组件未找到: ${componentPath}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- RouteTransformer:菜单树 → vue-router 记录 ---
|
||||
interface ConvertedRoute extends Omit<RouteRecordRaw, "children"> {
|
||||
id?: number;
|
||||
children?: ConvertedRoute[];
|
||||
component?: RouteRecordRaw["component"] | (() => Promise<any>);
|
||||
}
|
||||
|
||||
export class RouteTransformer {
|
||||
private componentLoader: ComponentLoader;
|
||||
private iframeManager: IframeRouteManager;
|
||||
private readonly shellChild: boolean;
|
||||
|
||||
constructor(componentLoader: ComponentLoader, options?: { shellChild?: boolean }) {
|
||||
this.componentLoader = componentLoader;
|
||||
this.shellChild = options?.shellChild ?? false;
|
||||
this.iframeManager = IframeRouteManager.getInstance();
|
||||
}
|
||||
|
||||
transform(route: AppRouteRecord, depth = 0, parentAbsPath = ""): ConvertedRoute {
|
||||
const absPath = (route.path || "").trim();
|
||||
const pathOut = this.routerPath(depth, parentAbsPath, absPath);
|
||||
const { component, children, ...routeConfig } = route;
|
||||
|
||||
const converted: ConvertedRoute = { ...routeConfig, path: pathOut, component: undefined };
|
||||
|
||||
if (route.meta.isIframe) {
|
||||
this.handleIframeRoute(converted, route, depth);
|
||||
} else if (this.isFirstLevelLeaf(route, depth)) {
|
||||
this.handleFirstLevelLeaf(converted, route, component as string);
|
||||
} else {
|
||||
this.handleNormalRoute(converted, component as string, depth);
|
||||
}
|
||||
|
||||
converted.path = pathOut;
|
||||
|
||||
if (children?.length) {
|
||||
converted.children = children.map((c) => this.transform(c, depth + 1, absPath));
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
private routerPath(depth: number, parentAbsPath: string, absPath: string): string {
|
||||
const firstSeg = absPath.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!this.shellChild) {
|
||||
if (depth === 0) {
|
||||
return firstSeg ? `/${firstSeg}` : "/";
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
if (depth === 0) {
|
||||
return firstSeg;
|
||||
}
|
||||
if (!parentAbsPath || !absPath) return absPath;
|
||||
const p = parentAbsPath.replace(/\/$/, "");
|
||||
if (absPath.startsWith(`${p}/`)) return absPath.slice(p.length + 1);
|
||||
return absPath.split("/").filter(Boolean).pop() ?? absPath;
|
||||
}
|
||||
|
||||
private isFirstLevelLeaf(route: AppRouteRecord, depth: number): boolean {
|
||||
return depth === 0 && (!route.children || route.children.length === 0);
|
||||
}
|
||||
|
||||
private handleIframeRoute(
|
||||
targetRoute: ConvertedRoute,
|
||||
sourceRoute: AppRouteRecord,
|
||||
depth: number
|
||||
): void {
|
||||
if (depth === 0) {
|
||||
targetRoute.component = this.shellChild
|
||||
? this.componentLoader.loadNestedParent()
|
||||
: this.componentLoader.loadLayout();
|
||||
targetRoute.name = "";
|
||||
const leafPath = this.shellChild ? "" : sourceRoute.path || "";
|
||||
targetRoute.children = [
|
||||
{
|
||||
...sourceRoute,
|
||||
path: leafPath,
|
||||
component: this.componentLoader.loadIframe(),
|
||||
} as ConvertedRoute,
|
||||
];
|
||||
} else {
|
||||
targetRoute.component = this.componentLoader.loadIframe();
|
||||
}
|
||||
this.iframeManager.add(sourceRoute);
|
||||
}
|
||||
|
||||
private handleFirstLevelLeaf(
|
||||
converted: ConvertedRoute,
|
||||
route: AppRouteRecord,
|
||||
component: string | undefined
|
||||
): void {
|
||||
converted.component = this.shellChild
|
||||
? this.componentLoader.loadNestedParent()
|
||||
: this.componentLoader.loadLayout();
|
||||
converted.name = "";
|
||||
route.meta.isFirstLevel = true;
|
||||
const leafPath = this.shellChild ? "" : route.path || "";
|
||||
converted.children = [
|
||||
{
|
||||
...route,
|
||||
path: leafPath,
|
||||
component: component ? this.componentLoader.load(component) : undefined,
|
||||
} as ConvertedRoute,
|
||||
];
|
||||
}
|
||||
|
||||
private handleNormalRoute(
|
||||
converted: ConvertedRoute,
|
||||
component: string | undefined,
|
||||
depth: number
|
||||
): void {
|
||||
if (!component) return;
|
||||
if (this.shellChild && depth === 0 && component === ROUTE_COMPONENT_LAYOUT) {
|
||||
converted.component = this.componentLoader.loadNestedParent();
|
||||
return;
|
||||
}
|
||||
converted.component = this.componentLoader.load(component);
|
||||
}
|
||||
}
|
||||
|
||||
// --- RouteRegistry:校验通过后批量 addRoute ---
|
||||
/** 与静态壳层冲突的一级 path 段,动态注册时跳过以免覆盖首页 / 仪表盘等 */
|
||||
const RESERVED_SHELL_SEGMENTS = new Set(["home", "profile", "changelog", "dashboard"]);
|
||||
|
||||
function pathFirstSegment(path: string): string {
|
||||
return (
|
||||
path
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.split("/")
|
||||
.filter(Boolean)[0] ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function registrationName(route: AppRouteRecord, index: number): AppRouteRecord {
|
||||
const explicit = typeof route.name === "string" ? route.name.trim() : "";
|
||||
if (explicit) return route;
|
||||
const seg = pathFirstSegment(route.path || "") || "route";
|
||||
const safe = seg.replace(/[^\w]/g, "_");
|
||||
return { ...route, name: `Dyn_${index}_${safe}` };
|
||||
}
|
||||
|
||||
function routeNameKey(route: AppRouteRecord): string {
|
||||
return typeof route.name === "string" ? route.name : String(route.name ?? "");
|
||||
}
|
||||
|
||||
export class RouteRegistry {
|
||||
private router: Router;
|
||||
private componentLoader: ComponentLoader;
|
||||
private validator: RouteValidator;
|
||||
private transformer: RouteTransformer;
|
||||
private removeRouteFns: (() => void)[] = [];
|
||||
private registered = false;
|
||||
|
||||
constructor(router: Router) {
|
||||
this.router = router;
|
||||
this.componentLoader = new ComponentLoader();
|
||||
this.validator = new RouteValidator();
|
||||
this.transformer = new RouteTransformer(this.componentLoader, { shellChild: true });
|
||||
}
|
||||
|
||||
register(menuList: AppRouteRecord[]): void {
|
||||
if (this.registered) {
|
||||
console.warn("[RouteRegistry] 路由已注册,跳过重复注册");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = this.validator.validate(menuList);
|
||||
if (!validationResult.valid) {
|
||||
throw new Error(`路由配置验证失败: ${validationResult.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
const removeRouteFns: (() => void)[] = [];
|
||||
|
||||
menuList.forEach((route, index) => {
|
||||
const seg = pathFirstSegment(route.path || "");
|
||||
if (seg && RESERVED_SHELL_SEGMENTS.has(seg)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const namedRoute = registrationName(route, index);
|
||||
if (this.router.hasRoute(routeNameKey(namedRoute))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const routeConfig = this.transformer.transform(namedRoute);
|
||||
removeRouteFns.push(
|
||||
this.router.addRoute(ROOT_LAYOUT_ROUTE_NAME, routeConfig as RouteRecordRaw)
|
||||
);
|
||||
});
|
||||
|
||||
this.removeRouteFns = removeRouteFns;
|
||||
this.registered = true;
|
||||
}
|
||||
|
||||
unregister(): void {
|
||||
this.removeRouteFns.forEach((fn) => fn());
|
||||
this.removeRouteFns = [];
|
||||
this.registered = false;
|
||||
}
|
||||
|
||||
isRegistered(): boolean {
|
||||
return this.registered;
|
||||
}
|
||||
|
||||
getRemoveRouteFns(): (() => void)[] {
|
||||
return this.removeRouteFns;
|
||||
}
|
||||
|
||||
markAsRegistered(): void {
|
||||
this.registered = true;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export const HOME_PAGE_PATH = "/home";
|
||||
export { HOME_ROUTE_NAME, ROOT_LAYOUT_ROUTE_NAME };
|
||||
|
||||
/** 动态路由注册与菜单转换(一般从 `@/router` 按需导入) */
|
||||
export { RouteRegistry, ComponentLoader, RouteTransformer, RouteValidator } from "./dynamicRoutes";
|
||||
export type { ValidationResult } from "./dynamicRoutes";
|
||||
export { RouteRegistry, ComponentLoader, RouteTransformer, RouteValidator } from "./core";
|
||||
export type { ValidationResult } from "./core";
|
||||
export { IframeRouteManager } from "./staticRoutes";
|
||||
export { MenuProcessor, builtinFrontendRoutes } from "./MenuProcessor";
|
||||
|
||||
@@ -332,12 +332,6 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
meta: { hidden: true, isHideTab: true, title: "menus.login.title" },
|
||||
component: () => import("@views/module_system/auth/login/index.vue"),
|
||||
},
|
||||
{
|
||||
path: "/tenant-select",
|
||||
name: "TenantSelect",
|
||||
meta: { hidden: true, isHideTab: true, title: "选择租户" },
|
||||
component: () => import("@views/module_system/auth/tenant-select/index.vue"),
|
||||
},
|
||||
/** 无 Layout 全屏异常页;守卫与白名单跳转使用(勿再在 RootLayout 下重复挂载同组件) */
|
||||
{
|
||||
path: "/401",
|
||||
@@ -385,20 +379,6 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
meta: DASHBOARD_PARENT_META,
|
||||
children: dashboardLayoutChildren,
|
||||
},
|
||||
/** 工单管理 */
|
||||
{
|
||||
path: "ticket",
|
||||
name: "ModuleTicket",
|
||||
component: () => import("@views/module_system/ticket/index.vue"),
|
||||
meta: { title: "工单管理", icon: "ri:feedback-line", keepAlive: true },
|
||||
},
|
||||
/** 插件市场 */
|
||||
{
|
||||
path: "plugin-market",
|
||||
name: "PluginMarket",
|
||||
component: () => import("@/views/module_system/plugin/index.vue"),
|
||||
meta: { title: "插件市场", icon: "ri:store-2-line", keepAlive: false },
|
||||
},
|
||||
/** 快速链接:统一父级,不在菜单中展示,通过 URL 或 fastEnter 直接访问 */
|
||||
{
|
||||
path: "fastlink",
|
||||
@@ -469,6 +449,28 @@ export const staticRoutes: AppRouteRecordRaw[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
/** 支付页面(订单模块子组件) */
|
||||
{
|
||||
path: "payment/:orderId",
|
||||
name: "Payment",
|
||||
component: () => import("@views/module_platform/order/components/PaymentPage.vue"),
|
||||
meta: {
|
||||
title: "订单支付",
|
||||
hidden: true,
|
||||
keepAlive: false,
|
||||
},
|
||||
},
|
||||
/** 租户工作台概览 — 复用自助服务页面 */
|
||||
{
|
||||
path: "workspace",
|
||||
name: "TenantWorkspace",
|
||||
component: () => import("@views/module_platform/self_service/index.vue"),
|
||||
meta: {
|
||||
title: "工作台",
|
||||
hidden: true,
|
||||
keepAlive: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user