refactor: 大规模代码整理与功能优化

1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码
2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑
3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件
4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
This commit is contained in:
zhangtao
2026-06-17 01:56:31 +08:00
parent 17b3cd0a4c
commit 73f2823692
500 changed files with 40763 additions and 26616 deletions
+24 -85
View File
@@ -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 "";
+25 -42
View File
@@ -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("/");
}
}
+5
View File
@@ -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";
-470
View File
@@ -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;
}
}
+2 -2
View File
@@ -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";
+22 -20
View File
@@ -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,
},
},
],
},
{