feat(core): 核心框架功能增强 - initApp 初始化流程优化 - router 增加路由守卫与权限处理 - 新增 useSections 分区段组合式函数 - 新增 menuRoutes / pageSections 工具函数 - 新增 DefaultEntity 通用实体页面 - DataSet 数据集基类更新笑笑 authored at 2026-05-05 22:33:39
diff --git a/core/client.d.ts b/core/client.d.ts
index 62f0688..9e4899d 100644
--- a/core/client.d.ts
+++ b/core/client.d.ts
@@ -11,6 +11,7 @@ declare module 'virtual:cube-front-routes' {
}
declare module 'virtual:cube-front-micro-apps' {
+ import type { MicroAppConfigItem } from './microAppRouter';
const appConfigs: MicroAppConfigItem[];
export default appConfigs;
}
@@ -18,11 +19,20 @@ declare module 'virtual:cube-front-micro-apps' {
declare module 'virtual:cube-front-config' {
const configData: Record<string, string>;
const currentEnv: string;
- const config: { configData: Record<string, string>; currentEnv: string; };
+ const config: { configData: Record<string, string>; currentEnv: string };
export { configData, currentEnv };
export default config;
}
+declare module 'virtual:cube-front-sections' {
+ /** 子应用 views 目录中扫描到的 Section 覆盖组件懒加载映射。
+ * key 格式:`./views/<folderPath>/<SectionName>.vue`
+ * 由 vite:cube-front-sections 插件在构建时自动生成,开发模式下支持 HMR。
+ */
+ const modules: Record<string, () => Promise<{ default: unknown }>>;
+ export default modules;
+}
+
interface Window {
router: import('vue-router').Router;
store: import('pinia').Pinia;
diff --git a/core/composables/useSections.ts b/core/composables/useSections.ts
new file mode 100644
index 0000000..5558be1
--- /dev/null
+++ b/core/composables/useSections.ts
@@ -0,0 +1,38 @@
+import type { InjectionKey, Component } from 'vue';
+
+// ─── 页面级默认组件 Keys ───────────────────────────────────────
+export const DefaultListPageKey: InjectionKey<Component> = Symbol('DefaultListPage');
+export const PageNotFoundKey: InjectionKey<Component> = Symbol('PageNotFound');
+
+// ─── 列表页 Section Keys ──────────────────────────────────────────
+export const ListPageHeaderKey: InjectionKey<Component> = Symbol('ListPageHeader');
+export const ListSearchBarKey: InjectionKey<Component> = Symbol('ListSearchBar');
+export const ListToolbarKey: InjectionKey<Component> = Symbol('ListToolbar');
+export const ListTableContentKey: InjectionKey<Component> = Symbol('ListTableContent');
+export const ListPaginationKey: InjectionKey<Component> = Symbol('ListPagination');
+export const ListPageFooterKey: InjectionKey<Component> = Symbol('ListPageFooter');
+
+// ─── 表单页 Section Keys ──────────────────────────────────────────
+export const FormPageHeaderKey: InjectionKey<Component> = Symbol('FormPageHeader');
+export const FormContentKey: InjectionKey<Component> = Symbol('FormContent');
+export const FormActionsKey: InjectionKey<Component> = Symbol('FormActions');
+
+// ─── 可被约定式自动发现的名称 → InjectionKey 映射 ───────────────
+export const SectionKeyMap: Record<string, InjectionKey<Component>> = {
+ DefaultListPage: DefaultListPageKey,
+ PageNotFound: PageNotFoundKey,
+ ListPageHeader: ListPageHeaderKey,
+ ListSearchBar: ListSearchBarKey,
+ ListToolbar: ListToolbarKey,
+ ListTableContent: ListTableContentKey,
+ ListPagination: ListPaginationKey,
+ ListPageFooter: ListPageFooterKey,
+ FormPageHeader: FormPageHeaderKey,
+ FormContent: FormContentKey,
+ FormActions: FormActionsKey,
+};
+
+// ─── 全局页面 Section 注册表(约定式自动发现用)──────────────────
+export const PageSectionRegistryKey: InjectionKey<
+ Record<string, Record<string, () => Promise<{ default: unknown }>>>
+> = Symbol('PageSectionRegistry');
diff --git a/core/dataset/data-set/DataSet.ts b/core/dataset/data-set/DataSet.ts
index f901a5a..d9991d3 100644
--- a/core/dataset/data-set/DataSet.ts
+++ b/core/dataset/data-set/DataSet.ts
@@ -470,11 +470,11 @@ export class DataSet<T, Q> {
}
/**
- * 更新记录
+ * 远程更新记录
* @param data 要更新的数据
* @returns Promise包含更新结果
*/
- async update(data: T): Promise<T> {
+ async remoteUpdate(data: T): Promise<T> {
if (!this._transport?.update) {
throw new Error('Update transport not configured');
}
diff --git a/core/initApp.ts b/core/initApp.ts
index 5ea91a4..c27878d 100644
--- a/core/initApp.ts
+++ b/core/initApp.ts
@@ -43,8 +43,14 @@ import router from './router';
import i18n from './i18n';
import './global.css';
import MainLayout from './layouts/MainLayout/index.vue';
-import { appProvide, hasProvided, LayoutKey, getProvidedKeys } from './composables/useProvideInject';
-
+import {
+ appProvide,
+ hasProvided,
+ LayoutKey,
+ getProvidedKeys,
+} from './composables/useProvideInject';
+import { registerPageSections } from './utils/pageSections';
+import autoSectionModules from 'virtual:cube-front-sections';
/**
* 应用配置选项接口
@@ -73,11 +79,43 @@ export interface AppConfigOptions {
* @param utils.hasProvided - 检查某个键是否已经被提供的函数
* @param utils.getProvidedKeys - 获取所有已提供键的函数
*/
-export type ConfigureFunction = (app: App2<Element>, utils: {
- provide: typeof appProvide;
- hasProvided: typeof hasProvided;
- getProvidedKeys: typeof getProvidedKeys;
-}) => void;
+export type ConfigureFunction = (
+ app: App2<Element>,
+ utils: {
+ provide: typeof appProvide;
+ hasProvided: typeof hasProvided;
+ getProvidedKeys: typeof getProvidedKeys;
+ },
+) => void;
+
+/**
+ * initApp 选项接口
+ *
+ * @property configure - 可选的应用配置函数,在插件安装后、挂载前执行
+ * @property sections - 可选的额外 Section 模块映射(优先级高于插件自动扫描结果)。
+ * 通常无需手动传入;Vite 插件 `vite:cube-front-sections` 会自动
+ * 扫描子应用的 `src/views/` 目录并通过虚拟模块 `virtual:cube-front-sections`
+ * 提供给框架。仅在需要手动覆盖时使用此字段。
+ *
+ * @example 标准子应用入口(无需配置,插件自动处理)
+ * ```typescript
+ * import { initApp } from '@core/initApp';
+ * initApp();
+ * ```
+ *
+ * @example 手动补充额外 Section(与插件自动发现合并)
+ * ```typescript
+ * initApp({
+ * sections: {
+ * './views/special/ListToolbar.vue': () => import('./views/special/ListToolbar.vue'),
+ * },
+ * });
+ * ```
+ */
+export interface InitAppOptions {
+ configure?: ConfigureFunction;
+ sections?: Record<string, () => Promise<{ default: unknown }>>;
+}
/**
* 初始化 Vue 应用
@@ -138,7 +176,14 @@ export type ConfigureFunction = (app: App2<Element>, utils: {
* });
* ```
*/
-export const initApp = async (configure?: ConfigureFunction) => {
+export const initApp = async (optionsOrConfigure?: InitAppOptions | ConfigureFunction) => {
+ // 向后兼容:支持直接传入 configure 函数
+ const options: InitAppOptions =
+ typeof optionsOrConfigure === 'function'
+ ? { configure: optionsOrConfigure }
+ : (optionsOrConfigure ?? {});
+
+ const { configure, sections } = options;
const pinia = createPinia();
@@ -150,12 +195,19 @@ export const initApp = async (configure?: ConfigureFunction) => {
app.use(i18n); // 添加 i18n 插件
app.use(ElementPlus);
+ // 注册页面 Section 覆盖组件
+ // 优先级:options.sections(手动指定)> virtual:cube-front-sections(插件自动扫描)
+ const mergedSections = { ...autoSectionModules, ...(sections ?? {}) };
+ if (Object.keys(mergedSections).length > 0) {
+ registerPageSections(app, mergedSections);
+ }
+
// 执行外部配置函数,允许外部覆盖内部配置
// 这里外部可以提供自定义的依赖注入值,优先于默认值
await configure?.(app, {
provide: appProvide,
hasProvided,
- getProvidedKeys
+ getProvidedKeys,
});
// 提供默认的依赖注入值
@@ -171,4 +223,3 @@ export const initApp = async (configure?: ConfigureFunction) => {
window.router = router;
window.store = pinia;
};
-
diff --git a/core/main.ts b/core/main.ts
index 74814fe..ba089b0 100644
--- a/core/main.ts
+++ b/core/main.ts
@@ -1,3 +1,7 @@
import { initApp } from './initApp';
+import TopMenuLayout from './layouts/TopMenu/index.vue';
+import { LayoutKey } from './composables/useProvideInject';
-initApp();
+initApp((app, { provide }) => {
+ provide(app, LayoutKey, TopMenuLayout, { override: true });
+});
diff --git a/core/pages/DefaultEntity.vue b/core/pages/DefaultEntity.vue
new file mode 100644
index 0000000..5dbe2ea
--- /dev/null
+++ b/core/pages/DefaultEntity.vue
@@ -0,0 +1,59 @@
+<template>
+ <component
+ :is="matchedMenu ? resolvedDefaultListPageComponent : resolvedPageNotFoundComponent"
+ v-bind="matchedMenu ? { title: matchedMenu.title ?? matchedMenu.name } : {}"
+ />
+</template>
+
+<script setup lang="ts">
+import { computed, defineAsyncComponent, inject, watchEffect } from 'vue';
+import type { Component } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useMenuStore, type FlatMenuItem } from '@core/stores/menu';
+import {
+ DefaultListPageKey,
+ PageNotFoundKey,
+ PageSectionRegistryKey,
+} from '@core/composables/useSections';
+import { registerMenuRoutes } from '@core/utils/menuRoutes';
+import FrameworkDefaultListPage from '@core/views/index.vue';
+import FrameworkPageNotFound from './PageNotFound.vue';
+
+type SectionRegistry = Record<string, Record<string, () => Promise<{ default: unknown }>>>;
+
+const route = useRoute();
+const router = useRouter();
+const menuStore = useMenuStore();
+
+const injectedDefaultListPageComponent = inject(DefaultListPageKey, FrameworkDefaultListPage);
+const injectedPageNotFoundComponent = inject(PageNotFoundKey, FrameworkPageNotFound);
+const sectionRegistry = inject(PageSectionRegistryKey, {} as SectionRegistry);
+
+const matchedMenu = computed<FlatMenuItem | undefined>(() =>
+ menuStore.flatMenus?.find((item) => item.path === route.path),
+);
+
+const resolvedDefaultListPageComponent = computed<Component>(() => {
+ const loader = sectionRegistry[route.path]?.DefaultListPage;
+ return loader
+ ? defineAsyncComponent(loader as () => Promise<{ default: Component }>)
+ : injectedDefaultListPageComponent;
+});
+
+const resolvedPageNotFoundComponent = computed<Component>(() => {
+ const loader = sectionRegistry[route.path]?.PageNotFound;
+ return loader
+ ? defineAsyncComponent(loader as () => Promise<{ default: Component }>)
+ : injectedPageNotFoundComponent;
+});
+
+watchEffect(() => {
+ const menus = menuStore.flatMenus;
+ if (!menus?.length || !matchedMenu.value) {
+ return;
+ }
+
+ registerMenuRoutes(router, menus);
+ menuStore.setActiveMenuByPath(route.path);
+});
+</script>
diff --git a/core/plugin/index.ts b/core/plugin/index.ts
index 5cde98f..2644d85 100644
--- a/core/plugin/index.ts
+++ b/core/plugin/index.ts
@@ -1,5 +1,5 @@
import path from 'path';
-import { type PluginOption, type ResolvedConfig } from 'vite';
+import { type PluginOption, type ResolvedConfig, type ViteDevServer } from 'vite';
import { type ConfigRoute } from '../typings.d';
import fs from 'fs';
import type { MicroAppConfig } from '../microAppRouter';
@@ -13,6 +13,7 @@ export default function vitePluginCubeFront() {
const appName = 'app';
const microAppsName = 'micro-apps';
const configName = 'config';
+ const sectionsName = 'sections';
/** 配置信息 */
let config: ResolvedConfig & { routes: ConfigRoute[] };
@@ -146,5 +147,131 @@ export default { configData, currentEnv };
},
};
- return [viteCubeApp, viteCubeAppNames, viteCubeConfig];
+ // Section 组件自动发现虚拟模块插件
+ // 虚拟模块 ID: virtual:cube-front-sections
+ // 运行时在 initApp.ts 中自动导入并调用 registerPageSections
+ let sectionsConfig: (ResolvedConfig & { routes: ConfigRoute[] }) | null = null;
+
+ const viteCubeSections: PluginOption = {
+ name: `vite:${virtualModuleNamePrefix}-${sectionsName}`,
+ enforce: 'pre',
+ configResolved: (cfg) => {
+ sectionsConfig = cfg as ResolvedConfig & { routes: ConfigRoute[] };
+ },
+ resolveId(id: string) {
+ if (id === virtualModuleIdPrefix + sectionsName) {
+ return resolvedVirtualModuleIdPrefix + sectionsName;
+ }
+ },
+ load(id: string) {
+ if (id === resolvedVirtualModuleIdPrefix + sectionsName) {
+ if (!sectionsConfig) return generateSectionsCode([]);
+ const viewsDir = resolveAppViewsDir(sectionsConfig.configFile, sectionsConfig.root);
+ if (!viewsDir) return generateSectionsCode([]);
+ const sections = scanSectionFiles(viewsDir);
+ return generateSectionsCode(sections);
+ }
+ },
+ // 开发服务器:监听 views 目录变化,新增/删除 Section 文件时失效虚拟模块
+ configureServer(server: ViteDevServer) {
+ const onFileChange = (filePath: string) => {
+ // 只响应 views/ 目录下的 PascalCase Vue 文件变化
+ if (/views[/\\][^/\\]+[/\\][A-Z][A-Za-z]+\.vue$/.test(filePath)) {
+ const mod = server.moduleGraph.getModuleById(
+ resolvedVirtualModuleIdPrefix + sectionsName,
+ );
+ if (mod) {
+ server.moduleGraph.invalidateModule(mod);
+ server.ws.send({ type: 'full-reload', path: '*' });
+ }
+ }
+ };
+ server.watcher.on('add', onFileChange);
+ server.watcher.on('unlink', onFileChange);
+ },
+ };
+
+ return [viteCubeApp, viteCubeAppNames, viteCubeConfig, viteCubeSections];
+}
+
+// ─── 工具函数(模块顶层,供插件复用)──────────────────────────────────────
+
+/**
+ * 根据当前 configFile 路径推断子应用的 views 目录。
+ *
+ * 约定:子应用的 vite.config.ts 位于 `<monorepo>/apps/<appName>/` 目录下,
+ * 其 views 视图目录为 `<monorepo>/apps/<appName>/src/views/`。
+ *
+ * @param configFile Vite 解析后的 config.configFile(绝对路径)
+ * @param root Vite 解析后的 config.root(monorepo 根目录)
+ * @returns 子应用 views 目录的绝对路径,若无法推断则返回 null
+ */
+function resolveAppViewsDir(configFile: string | undefined, root: string): string | null {
+ if (!configFile) return null;
+ const configDir = path.dirname(configFile);
+ // 判断 vite.config.ts 是否位于 apps/<xxx>/ 目录
+ const rel = path.relative(root, configDir);
+ // rel 在 Windows: "apps\cube-admin" / POSIX: "apps/cube-admin"
+ const parts = rel.split(path.sep);
+ if (parts.length >= 2 && parts[0] === 'apps') {
+ return path.join(configDir, 'src', 'views');
+ }
+ return null;
+}
+
+/**
+ * 递归扫描 views 目录,收集符合 Section 命名约定的 Vue 文件。
+ *
+ * 约定:文件名须为 PascalCase(首字母大写,仅含字母),例如 `ListSearchBar.vue`。
+ * 排除 `index.vue`、`form.vue` 等小写开头的框架保留文件。
+ *
+ * @param viewsDir views 目录绝对路径
+ * @returns 所有符合条件文件的 { key, absPath } 列表
+ * key: 模块映射键(如 `./views/user/ListSearchBar.vue`)
+ * absPath: 绝对路径(POSIX 风格,用于生成 import() 语句)
+ */
+function scanSectionFiles(viewsDir: string): Array<{ key: string; absPath: string }> {
+ if (!fs.existsSync(viewsDir)) return [];
+
+ const result: Array<{ key: string; absPath: string }> = [];
+
+ function walk(dir: string, relPath: string) {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(fullPath, `${relPath}/${entry.name}`);
+ } else if (entry.isFile() && entry.name.endsWith('.vue')) {
+ const nameWithoutExt = entry.name.slice(0, -4);
+ // 只收集 PascalCase 文件名(首字母大写,仅含字母)
+ if (/^[A-Z][A-Za-z]+$/.test(nameWithoutExt)) {
+ result.push({
+ key: `./views${relPath}/${entry.name}`,
+ // 统一为 POSIX 正斜杠,Windows 环境下 import() 也可识别
+ absPath: fullPath.replace(/\\/g, '/'),
+ });
+ }
+ }
+ }
+ }
+
+ walk(viewsDir, '');
+ return result;
+}
+
+/**
+ * 生成 virtual:cube-front-sections 的模块代码。
+ * 导出一个 Record<string, () => Promise<{default:unknown}>> 对象,
+ * 可直接传入 registerPageSections(app, modules)。
+ */
+function generateSectionsCode(sections: Array<{ key: string; absPath: string }>): string {
+ if (sections.length === 0) {
+ return `// virtual:cube-front-sections — no section overrides found\nconst modules = {};\nexport default modules;\n`;
+ }
+ const lines = sections
+ .map(
+ ({ key, absPath }) => ` ${JSON.stringify(key)}: () => import(${JSON.stringify(absPath)}),`,
+ )
+ .join('\n');
+ return `// virtual:cube-front-sections — auto-generated\nconst modules = {\n${lines}\n};\nexport default modules;\n`;
}
diff --git a/core/router/index.ts b/core/router/index.ts
index 44aaa14..bca5f48 100644
--- a/core/router/index.ts
+++ b/core/router/index.ts
@@ -5,6 +5,7 @@ import { useUserStore } from '../stores/user';
import { useMenuStore } from '../stores/menu';
import { getAccessToken } from '../utils/token';
import { getUrlHashToken } from '../utils/token';
+import { registerMenuRoutes } from '../utils/menuRoutes';
// 创建路由实例
const router: Router = createRouter({
@@ -14,7 +15,7 @@ const router: Router = createRouter({
// 先初始化微前端应用路由(异步操作)
// 我们不会等待其完成再导出router,但会在导航守卫中检查初始化状态
-initAppRoutes(router).catch(error => {
+initAppRoutes(router).catch((error) => {
console.error('初始化微应用路由失败:', error);
});
@@ -93,6 +94,10 @@ router.beforeEach(async (to, from, next) => {
if (!menuStore.hasMenus) {
try {
await menuStore.fetchMenuAsync();
+ // 菜单加载完成后,批量注册菜单叶子节点路由
+ if (menuStore.flatMenus) {
+ registerMenuRoutes(router, menuStore.flatMenus);
+ }
} catch (error) {
console.error('获取菜单信息失败:', error);
// 获取菜单失败但不影响导航,继续放行
diff --git a/core/routes/index.ts b/core/routes/index.ts
index 42b3b70..bf8246e 100644
--- a/core/routes/index.ts
+++ b/core/routes/index.ts
@@ -39,6 +39,15 @@ const routes: ConfigRoute[] = [
auth: false, // 不需要认证
},
},
+ {
+ path: '/:pathMatch(.*)*',
+ name: 'DefaultEntity',
+ component: () => import('../pages/DefaultEntity.vue'),
+ meta: {
+ title: '默认页面',
+ auth: true,
+ },
+ },
];
export default routes;
diff --git a/core/utils/menuRoutes.ts b/core/utils/menuRoutes.ts
new file mode 100644
index 0000000..8eb57f2
--- /dev/null
+++ b/core/utils/menuRoutes.ts
@@ -0,0 +1,51 @@
+import type { Router } from 'vue-router';
+import type { FlatMenuItem } from '@core/stores/menu';
+
+/**
+ * 根据路径约定推断页面组件。
+ *
+ * 约定规则(纯前端约定,无需后端改造):
+ * - /create、/new、/add 结尾 → form.vue(新建表单)
+ * - /edit/:id、/update/:id → form.vue(编辑表单)
+ * - 其余路径 → index.vue(列表页)
+ */
+function resolvePageComponent(path: string) {
+ if (/\/(create|new|add)$/.test(path) || /\/(edit|update)(\/|$)/.test(path)) {
+ return () => import('@core/views/form.vue');
+ }
+ return () => import('@core/views/index.vue');
+}
+
+/**
+ * 将菜单叶子节点批量注册为动态路由。
+ *
+ * 优先级说明:
+ * - 已存在路径(应用级预注册)不会被覆盖
+ * - 每个菜单叶子节点默认使用框架 index.vue / form.vue
+ *
+ * @param router Vue Router 实例
+ * @param menus 已拍平的菜单列表(来自 menuStore.flatMenus)
+ */
+export function registerMenuRoutes(router: Router, menus: FlatMenuItem[]): void {
+ // 获取已注册路径,保护应用级预注册路由
+ const existingPaths = new Set(router.getRoutes().map((r) => r.path));
+
+ // 筛选叶子节点:没有其他 menu 以其 id 为 parentId 的即为叶子
+ const parentIds = new Set(menus.map((m) => m.parentId).filter(Boolean));
+ const leafMenus = menus.filter((m) => !parentIds.has(m.id) && m.path);
+
+ for (const menu of leafMenus) {
+ if (existingPaths.has(menu.path)) continue; // 应用级路由优先,跳过
+
+ router.addRoute({
+ path: menu.path,
+ name: `menu-${menu.name || menu.id}`,
+ component: resolvePageComponent(menu.path),
+ meta: {
+ auth: true,
+ menuId: menu.id,
+ title: menu.title ?? menu.name,
+ },
+ });
+ }
+}
diff --git a/core/utils/pageSections.ts b/core/utils/pageSections.ts
new file mode 100644
index 0000000..e356f1e
--- /dev/null
+++ b/core/utils/pageSections.ts
@@ -0,0 +1,89 @@
+import type { App } from 'vue';
+import { defineAsyncComponent } from 'vue';
+import { PageSectionRegistryKey, SectionKeyMap } from '@core/composables/useSections';
+import type { Component } from 'vue';
+
+type GlobLoader = () => Promise<{ default: unknown }>;
+type GlobModule = Record<string, GlobLoader>;
+type SectionRegistry = Record<string, Record<string, GlobLoader>>;
+
+/**
+ * 解析 glob key,提取路由路径和 Section 名称。
+ *
+ * 规则:
+ * - 匹配 `./views/<folderPath>/<PascalCaseName>.vue`(与 core/views 保持一致)
+ * - 文件名必须大写字母开头(PascalCase),否则跳过(排除 index.vue、form.vue 等)
+ * - 文件名去掉 .vue 后须在 SectionKeyMap 中存在,否则跳过
+ *
+ * 示例:
+ * './views/user/ListSearchBar.vue' → { routePath: '/user', sectionName: 'ListSearchBar' }
+ * './views/order/list/FormContent.vue' → { routePath: '/order/list', sectionName: 'FormContent' }
+ */
+function parseGlobKey(key: string): { routePath: string; sectionName: string } | null {
+ const match = key.match(/^\.\/views\/(.+)\/([A-Z][A-Za-z]+)\.vue$/);
+ if (!match) return null;
+ const [, folderPath, sectionName] = match;
+ return { routePath: '/' + folderPath, sectionName };
+}
+
+/**
+ * 将 import.meta.glob 结果注册为全局页面 Section 覆盖注册表。
+ *
+ * 在应用启动时(initApp 回调中)调用,一次性注册所有页面的覆盖组件:
+ *
+ * ```typescript
+ * import { registerPageSections } from '@core/utils/pageSections';
+ * const viewModules = import.meta.glob('./views/**\/*.vue');
+ * initApp((app) => {
+ * registerPageSections(app, viewModules);
+ * });
+ * ```
+ *
+ * 注意:`import.meta.glob` 调用必须写在应用代码中(Vite 静态分析限制),
+ * 不能在框架核心代码中自动执行。
+ *
+ * 目录约定:各子应用使用 `src/views/` 文件夹存放页面 Section 覆盖组件,
+ * 与框架的 `core/views/` 保持一致的约定。
+ *
+ * @param app Vue App 实例
+ * @param modules import.meta.glob 结果
+ */
+export function registerPageSections(app: App, modules: GlobModule): void {
+ const registry: SectionRegistry = {};
+
+ for (const [key, loader] of Object.entries(modules)) {
+ const parsed = parseGlobKey(key);
+ if (!parsed) continue;
+
+ const { routePath, sectionName } = parsed;
+ if (!SectionKeyMap[sectionName]) continue; // 非 Section 文件,跳过
+
+ registry[routePath] ??= {};
+ registry[routePath][sectionName] = loader;
+ }
+
+ app.provide(PageSectionRegistryKey, registry);
+}
+
+/**
+ * (供 DefaultListPage / DefaultFormPage 内部使用)
+ * 将注册表中当前路由对应的覆盖组件 provide 给子组件树。
+ *
+ * 须在 <script setup> 中调用,且在 inject 语句之前执行。
+ */
+export function applyPageSectionOverrides(
+ routePath: string,
+ registry: Record<string, Record<string, GlobLoader>>,
+ provideKey: (key: symbol, comp: Component) => void,
+): void {
+ const overrides = registry[routePath] ?? {};
+ for (const [name, loader] of Object.entries(overrides)) {
+ const injectionKey = SectionKeyMap[name];
+ if (injectionKey) {
+ provideKey(
+ injectionKey as unknown as symbol,
+ defineAsyncComponent(loader as () => Promise<{ default: Component }>),
+ );
+ }
+ }
+}