NewLife/cube-front

feat(menuRoutes): 增强视图组件解析逻辑,添加视图组件缓存和路径归一化处理
何炳宏 authored at 2026-05-23 10:35:26
1309930
Tree
1 Parent(s) 9ec5c87
Summary: 6 changed files with 252 additions and 16 deletions.
Modified +12 -0
Modified +1 -1
Modified +1 -1
Modified +192 -13
Modified +5 -1
Modified +41 -0
Modified +12 -0
diff --git a/components.d.ts b/components.d.ts
index 6828632..9a02219 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -12,8 +12,15 @@ declare module 'vue' {
     ElAvatar: typeof import('element-plus/es')['ElAvatar']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
+    ElCol: typeof import('element-plus/es')['ElCol']
+    ElCollapse: typeof import('element-plus/es')['ElCollapse']
+    ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
+    ElDivider: typeof import('element-plus/es')['ElDivider']
+    ElEmpty: typeof import('element-plus/es')['ElEmpty']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
     ElIcon: typeof import('element-plus/es')['ElIcon']
@@ -23,11 +30,16 @@ declare module 'vue' {
     ElPagination: typeof import('element-plus/es')['ElPagination']
     ElRadio: typeof import('element-plus/es')['ElRadio']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
     ElTable: typeof import('element-plus/es')['ElTable']
     ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+    ElTabPane: typeof import('element-plus/es')['ElTabPane']
+    ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElTree: typeof import('element-plus/es')['ElTree']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
   }
Modified +1 -1
diff --git a/core/pages/DefaultEntity.vue b/core/pages/DefaultEntity.vue
index c478768..cdb0040 100644
--- a/core/pages/DefaultEntity.vue
+++ b/core/pages/DefaultEntity.vue
@@ -53,7 +53,7 @@ watchEffect(() => {
     return;
   }
 
-  registerMenuRoutes(router, menus);
+  registerMenuRoutes(router, menus, route.path);
   menuStore.setActiveMenuByPath(route.path);
 });
 </script>
Modified +1 -1
diff --git a/core/router/index.ts b/core/router/index.ts
index bca5f48..bfad817 100644
--- a/core/router/index.ts
+++ b/core/router/index.ts
@@ -96,7 +96,7 @@ router.beforeEach(async (to, from, next) => {
           await menuStore.fetchMenuAsync();
           // 菜单加载完成后,批量注册菜单叶子节点路由
           if (menuStore.flatMenus) {
-            registerMenuRoutes(router, menuStore.flatMenus);
+            registerMenuRoutes(router, menuStore.flatMenus, to.path);
           }
         } catch (error) {
           console.error('获取菜单信息失败:', error);
Modified +192 -13
diff --git a/core/utils/menuRoutes.ts b/core/utils/menuRoutes.ts
index c365e28..af34788 100644
--- a/core/utils/menuRoutes.ts
+++ b/core/utils/menuRoutes.ts
@@ -1,19 +1,169 @@
 import type { Router } from 'vue-router';
 import type { FlatMenuItem } from 'cube-front/core/stores/menu';
+import { normalizeMenuUrl } from './url';
 
 /**
- * 根据路径约定推断页面组件。
+ * 视图组件缓存
+ * key: 视图目录路径(如 '/apps/iot/src/views/device/product')
+ * value: 懒加载的 index.vue 组件
+ */
+const viewComponentCache = new Map<string, () => Promise<any>>();
+
+/**
+ * 预加载所有框架项目视图目录
+ * 路径格式:
+ *   /apps/&#42;/src/views/xxx/yyy/index.vue
+ *   /cube-front/core/apps/&#42;/src/views/xxx/yyy/index.vue
  *
- * 约定规则(纯前端约定,无需后端改造):
- *   - /create、/new、/add 结尾 → form.vue(新建表单)
- *   - /edit/:id、/update/:id  → form.vue(编辑表单)
- *   - 其余路径               → index.vue(列表页)
+ * Vite import.meta.glob 要求使用字面量,不能拼接变量,
+ * 所以这里预先 glob 所有视图,再在运行时按需查找。
+ */
+const allViewModules = import.meta.glob([
+  '/apps/*/src/views/**/index.vue',
+  '/cube-front/core/apps/*/src/views/**/index.vue',
+], { eager: false });
+
+/**
+ * 归一化路径数组(统一小写、反斜杠转正斜杠)
+ */
+const normalizedModulePaths = Object.keys(allViewModules).map((p) =>
+  p.replace(/\\/g, '/').toLowerCase(),
+);
+
+/**
+ * 将 PascalCase/camelCase 转为短横线风格
+ * 例: MyDeviceName → my-device-name
+ */
+function toKebabCase(str: string): string {
+  return str
+    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
+    .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
+    .toLowerCase();
+}
+
+/**
+ * 根据路由路径解析对应的视图组件。
+ *
+ * 约定规则:
+ *   - 路径第一层作为应用名,后面的部分映射到 views 文件夹结构
+ *   - 匹配优先级:
+ *     1. 短横线风格: /apps/iot/src/views/my-device/my-logs/index.vue
+ *     2. 原始风格:   /apps/IoT/src/views/MyDevice/MyLogs/index.vue
+ *   - 路径优先级:
+ *     1. /apps/&#42;/src/views/** (框架项目,如 IoT、EMS 等)
+ *     2. /cube-front/core/apps/&#42;/src/views/** (cube-admin 等核心应用)
+ *   - 逐级匹配:先匹配 apps/xxx,再匹配 src/views/xxx,逐级构建路径
+ *
+ * @param path 路由路径,如 /IoT/Device/Product
  */
 function resolvePageComponent(path: string) {
-  if (/\/(create|new|add)$/.test(path) || /\/(edit|update)(\/|$)/.test(path)) {
-    return () => import('cube-front/core/views/form.vue');
+  // 解析路径:去掉开头的 /,分割各层级
+  const segments = path.replace(/^\/+/, '').split('/').filter(Boolean);
+  if (segments.length === 0) {
+    return getFallbackComponent('form');
   }
-  return () => import('cube-front/core/views/index.vue');
+
+  const [appName, ...viewPathSegments] = segments;
+
+  // 缓存 key 使用短横线风格的路径
+  const cacheKey = `/apps/${appName.toLowerCase()}/src/views/${viewPathSegments
+    .map((s) => toKebabCase(s))
+    .join('/')}`;
+
+  // 检查缓存
+  if (viewComponentCache.has(cacheKey)) {
+    return viewComponentCache.get(cacheKey)!;
+  }
+
+  // 尝试匹配目标视图
+  const componentLoader = findMatchingView(appName, viewPathSegments);
+
+  // 缓存并返回
+  viewComponentCache.set(cacheKey, componentLoader);
+  return componentLoader;
+}
+
+/**
+ * 匹配结果
+ */
+interface MatchResult {
+  loader: () => Promise<any>;
+  priority: number; // 1=框架项目短横线, 2=框架项目原始, 3=核心应用短横线, 4=核心应用原始
+}
+
+/**
+ * 逐级匹配视图路径
+ *
+ * @param appName           应用名,如 IoT
+ * @param viewPathSegments  视图路径分段,如 ['Device', 'Product']
+ */
+function findMatchingView(
+  appName: string,
+  viewPathSegments: string[],
+): () => Promise<any> {
+  // 构建目标路径风格
+  const kebabViewDir = viewPathSegments.map((s) => toKebabCase(s)).join('/');
+  const originalViewDir = viewPathSegments.join('/');
+
+  const matches: MatchResult[] = [];
+
+  // 遍历所有归一化的模块路径,寻找匹配
+  for (const normalizedPath of normalizedModulePaths) {
+    let isFrameworkProject = false;
+
+    // 检查路径类型
+    if (normalizedPath.includes('/apps/')) {
+      isFrameworkProject = true;
+    } else if (!normalizedPath.includes('/cube-front/core/apps/')) {
+      continue; // 不匹配任何已知路径格式
+    }
+
+    // 提取相对于 views/ 的路径
+    const viewsIndex = normalizedPath.indexOf('/views/');
+    if (viewsIndex === -1) continue;
+
+    const viewRelativeDir = normalizedPath
+      .substring(viewsIndex + '/views/'.length)
+      .replace('/index.vue', '');
+
+    // 检查是否匹配
+    let matched = false;
+    let priority = 0;
+
+    if (viewRelativeDir === kebabViewDir) {
+      matched = true;
+      priority = isFrameworkProject ? 1 : 3;
+    } else if (viewRelativeDir === originalViewDir) {
+      matched = true;
+      priority = isFrameworkProject ? 2 : 4;
+    }
+
+    if (matched) {
+      matches.push({
+        loader: allViewModules[normalizedPath] as () => Promise<any>,
+        priority,
+      });
+    }
+  }
+
+  // 按优先级排序,返回最高优先级匹配
+  if (matches.length > 0) {
+    matches.sort((a, b) => a.priority - b.priority);
+    const best = matches[0];
+    console.log(`[ViewResolver] Matched (priority ${best.priority}): ${appName}/${viewPathSegments.join('/')}`);
+    return best.loader;
+  }
+
+  // 无匹配,使用后备组件
+  console.log(`[ViewResolver] No match for /${appName}/${viewPathSegments.join('/')}, using fallback`);
+  return getFallbackComponent('index');
+}
+
+/**
+ * 获取后备组件(无匹配视图时使用)
+ */
+function getFallbackComponent(type: 'index' | 'form') {
+  return () => import(`cube-front/core/views/${type}.vue`);
 }
 
 /**
@@ -23,10 +173,21 @@ function resolvePageComponent(path: string) {
  *   - 已存在路径(应用级预注册)不会被覆盖
  *   - 每个菜单叶子节点默认使用框架 index.vue / form.vue
  *
- * @param router  Vue Router 实例
- * @param menus   已拍平的菜单列表(来自 menuStore.flatMenus)
+ * 路由刷新说明:
+ *   - 动态添加的路由需要重新导航才能生效
+ *   - 如果当前正在访问刚添加的路由,会触发重新导航
+ *
+ * @param router        Vue Router 实例
+ * @param menus         已拍平的菜单列表(来自 menuStore.flatMenus)
+ * @param currentPath   可选:当前访问的路径,用于刷新动态路由
  */
-export function registerMenuRoutes(router: Router, menus: FlatMenuItem[]): void {
+export function registerMenuRoutes(
+  router: Router,
+  menus: FlatMenuItem[],
+  currentPath?: string,
+): void {
+  console.log('Registering menu routes...');
+
   // 获取已注册路径,保护应用级预注册路由
   const existingPaths = new Set(router.getRoutes().map((r) => r.path));
 
@@ -34,18 +195,36 @@ export function registerMenuRoutes(router: Router, menus: FlatMenuItem[]): void 
   const parentIds = new Set(menus.map((m) => m.parentId).filter(Boolean));
   const leafMenus = menus.filter((m) => !parentIds.has(m.id) && m.path);
 
+  // 需要重新导航的路径(刚添加的动态路由)
+  const pendingNavigations: string[] = [];
+
   for (const menu of leafMenus) {
-    if (existingPaths.has(menu.path)) continue; // 应用级路由优先,跳过
+    // 转换路径为短横线风格
+    const normalizedPath = normalizeMenuUrl(menu.path);
+    if (existingPaths.has(normalizedPath)) continue; // 应用级路由优先,跳过
 
     router.addRoute({
-      path: menu.path,
+      path: normalizedPath,
       name: `menu-${menu.name || menu.id}`,
       component: resolvePageComponent(menu.path),
       meta: {
         auth: true,
         menuId: menu.id,
         title: menu.title ?? menu.name,
+        originalPath: menu.path, // 保留原始路径用于调试
       },
     });
+
+    // 记录刚添加的动态路由
+    pendingNavigations.push(menu.path);
+  }
+
+  // 如果当前访问的路径是刚添加的动态路由,需要重新导航才能生效
+  if (currentPath && pendingNavigations.includes(currentPath)) {
+    console.log(`Refreshing dynamic route: ${currentPath}`);
+    // 使用 replace 重新导航到当前路径,刷新路由匹配
+    router.replace(currentPath).catch(() => {
+      // 忽略导航错误(可能是路由已存在等)
+    });
   }
 }
Modified +5 -1
diff --git a/core/utils/menuTab.ts b/core/utils/menuTab.ts
index 8a43e26..c7a3637 100644
--- a/core/utils/menuTab.ts
+++ b/core/utils/menuTab.ts
@@ -1,4 +1,5 @@
 import { gotoPage } from './router';
+import { normalizeMenuUrl } from './url';
 
 /**
  * 打开菜单对应的标签页
@@ -9,8 +10,11 @@ import { gotoPage } from './router';
 export function openMenuTab(options: { url: string; title?: string }): void {
   const { url, title } = options;
 
+  // 转换 URL 为短横线风格,确保与注册的路由匹配
+  const normalizedUrl = normalizeMenuUrl(url);
+
   // 使用路由跳转到指定的页面
-  gotoPage(url);
+  gotoPage(normalizedUrl);
 
   // 可以在这里添加标签页的其他处理逻辑
   // 例如保存打开的标签页到本地存储,或者更新标签页状态
Modified +41 -0
diff --git a/core/utils/url.ts b/core/utils/url.ts
index daf01d2..de706f4 100644
--- a/core/utils/url.ts
+++ b/core/utils/url.ts
@@ -1,4 +1,45 @@
 import qs from 'query-string';
+
+/**
+ * 将 PascalCase/camelCase 转为短横线风格
+ * 例: MyDeviceName → my-device-name
+ */
+export function toKebabCase(str: string): string {
+  return str
+    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
+    .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
+    .toLowerCase();
+}
+
+/**
+ * 将后端返回的菜单 URL 路径转换为短横线风格
+ * 示例:
+ *   /IoT/Device/Product → /iot/device/product
+ *   /EMS/EnergyReport → /ems/energy-report
+ *
+ * @param url 后端返回的原始 URL
+ * @returns 短横线风格的 URL
+ */
+export function normalizeMenuUrl(url: string): string {
+  if (!url || typeof url !== 'string') return url;
+
+  // 分离路径和查询参数
+  const [path, query] = url.split('?');
+
+  // 转换路径段为短横线风格
+  const normalizedPath = path
+    .split('/')
+    .filter(Boolean)
+    .map((segment) => toKebabCase(segment))
+    .join('/');
+
+  // 重新拼接路径(保留前导斜杠)
+  const result = '/' + normalizedPath;
+
+  // 附加查询参数
+  return query ? `${result}?${query}` : result;
+}
+
 export function isUrl(path: string) {
   /* eslint no-useless-escape:0 */
   const reg =