# Portal Shell 插件化仪表盘设计(Modular Monolith + Micro-kernel) > 版本:v2.1(应用 React 单体哲学优化:URL 状态 + RSC 预取 + SWR 刷新 + 统一 Hook + 沙箱防御) > 日期:2026-07-14 > 状态:待评审 > 关联: > > - [0010 架构蓝图](../../architecture/0010_architecture.md) §4 微前端 > - [004 架构影响地图](../../architecture/004_architecture_impact_map.md) > - [UI 设计系统](../../standards/ui-design-system.md) > - CICD 参考:`e:\Desktop\CICD\src\modules\dashboard\config\widget-configs.ts` > - Halo 插件机制参考:JAR 分发 · 扩展点注入 · 完整生命周期 --- ## 1. 背景与目标 ### 1.1 问题诊断 当前 Edu 前端由 4 个独立 Next.js portal 组成(teacher / student / parent / admin),存在以下问题: 1. **拆分粒度错误**:后端按"领域"拆微服务,前端按"角色"拆 4 个粗 portal,边界不对齐 2. **重复度高**:4 套独立 Dockerfile、i18n、auth-provider、layout、design tokens 3. **复用性差**:跨角色复用功能(grades / homework / schedule)在 4 个 portal 各实现一遍 4. **耦合度高**:每个 portal 既当 Shell 又内置业务,职责混淆 5. **配置驱动缺失**:角色变更需改 4 个 portal 代码,无法后台动态配置 6. **扩展性差**:新增功能需修改多个 portal,无法像 Halo 那样插件化扩展 ### 1.2 方向选择 抛弃 v1.0 的 Module Federation 微前端方案(7 个独立 widget 容器、运行时远程加载、部署运维复杂),采用 **Modular Monolith + Micro-kernel** 架构: - **单 Next.js 项目**,单服务部署,单 Dockerfile - **dynamic import 懒加载**,插件按需加载 - **前端 Registry + 后端 JSON Config**,配置驱动渲染 - **插件即卡片**,统一术语,MVP 内置插件,二期支持第三方上传(参考 Halo) ### 1.3 设计目标 1. **页面框架可配置**:TopBar + SideNav + ContentArea 三 region,预置 5 种内置 Layout 模板,admin 配置角色默认,用户可切换 2. **插件即卡片**:所有功能单元统一为"插件"(含单角色专属功能如备课画布、错题本),分类管理,admin 可启用/禁用/配置 3. **配置驱动可见性**:admin 改配置 → 用户刷新生效,无需重新部署 4. **声明式 props**:插件通过 manifest 声明 propsSchema,admin 配置默认 props,用户可调整,三层合并 5. **强隔离**:插件间禁止直接 import,仅通过 EventBus 通信;插件与 Shell 仅通过 PluginProps 契约交互 6. **设计系统一致**:所有插件强制遵守纸感编辑器设计风格,使用 @edu/ui-tokens 7. **单服务部署**:一个 Dockerfile,一个容器,无 MF 远程加载 ### 1.4 非目标 - 不重写后端微服务、BFF、网关 - 不替换现有 4 个 portal(并行运行,逐步迁移) - 不实现插件拖拽编辑器(canvas 模板预留接口,但不做拖拽实现) - MVP 不实现第三方插件上传(二期,预留接口与 UI 入口) - 不实现插件市场在线商店(二期仅支持本地 ZIP 上传) --- ## 2. 整体架构 ### 2.1 Modular Monolith + Micro-kernel ``` ┌──────────────────────────────────────────────────────────────┐ │ apps/portal-shell/(单 Next.js App Router · 单 Docker) │ │ │ │ src/app/shell/[[...route]]/page.tsx ← RSC(Server Component)│ │ ① 服务端调 IAM gRPC 获取 PluginConfigResponse │ │ ② 服务端并发调 BFF 预取各插件 initialData(Promise.all) │ │ ③ 三层 props 合并(PropsMerger) │ │ ④ 将 Config + initialData 作为 props 传给 Client Shell │ │ │ │ src/shell/(Client Component) ← 微内核 │ │ Shell.tsx Layout 框架 + Slots │ │ Registry.ts 插件清单(plugin_id → lazy) │ │ PluginLoader.tsx dynamic import + ssr:false + 骨架屏 │ │ SlotRenderer.tsx 按 Config 渲染插件列表(含 initialData)│ │ PropsMerger.ts 三层 props 合并 │ │ PluginStore.ts Zustand 全局状态(theme/locale/sidebar)│ │ │ │ src/widgets/ ← 内置插件源码 │ │ universal/ sidebar/ topbar/ │ │ teacher/ student/ parent/ admin/ │ │ │ │ src/lib/ ← 数据请求抽象 │ │ useWidgetQuery.ts 统一 BFF GraphQL 查询(自动按 role 路由)│ │ useWidgetMutation.ts 统一 BFF GraphQL 变更 │ │ usePluginConfig.ts SWR 静默刷新配置(revalidateOnFocus) │ │ │ └──────────────────────────────────────────────────────────────┘ ↕ RSC 服务端预取(消除 CSR 瀑布流)↕ ┌──────────────────────────────────────────────────────────────┐ │ IAM DB(配置存储) │ │ plugin_registry / role_plugin_mapping / user_layout_override│ │ layout_templates / plugin_packages(二期) │ └──────────────────────────────────────────────────────────────┘ ↕ RSC 服务端并发预取 initialData ↕ ┌──────────────────────────────────────────────────────────────┐ │ BFF 层(已就绪,无需改动) │ │ teacher-bff / student-bff / parent-bff │ │ useWidgetQuery 自动按 role 路由到对应 BFF │ └──────────────────────────────────────────────────────────────┘ ``` ### 2.2 核心三元组:Shell + Registry + Config | 组件 | 职责 | 位置 | 时机 | | ------------ | -------------------------------------------------------------------------- | ----------------------- | ---------- | | **Shell** | 微内核,渲染 Layout 框架 + Slots + ConfigProvider + PluginLoader,不含业务 | `src/shell/` | 运行时 | | **Registry** | 插件清单,`plugin_id → dynamic import 组件`映射。内置插件编译时登记 | `src/shell/Registry.ts` | 编译时 | | **Config** | 渲染配置 JSON,决定当前用户在哪些 Slots 渲染哪些插件。三层合并 | IAM DB | 运行时拉取 | ### 2.3 关键设计原则 1. **Shell 是纯宿主**:只负责 Layout / Config / Registry / Loader,不含任何业务逻辑 2. **插件即卡片**:所有功能单元统一为"插件",无"卡片"与"插件"之分。含单角色专属功能(备课画布、错题本等) 3. **RSC 服务端预取**:Shell 在 RSC(Server Component)中直接调 IAM gRPC 获取 Config,按需并发调 BFF 预取插件初始数据,Config + initialData 随 HTML 直出,客户端水合后 dynamic import 加载插件组件(initialData 已在 HTML 中),消除 CSR 瀑布流,实现仪表盘"秒开" 4. **dynamic import 懒加载**:插件按需加载,首屏只加载可见 slot 的插件,非可见插件滚动到视口再加载 5. **强隔离**:插件间禁止直接 import,通过 URL Search Params + Zustand Store 共享状态;插件与 Shell 仅通过 PluginProps 契约交互 6. **配置驱动**:admin 改配置 → SWR 静默后台刷新(revalidateOnFocus + refreshInterval)→ Toast 提示用户刷新,无需重新部署(内置插件增删仍需重新构建) 7. **单服务部署**:一个 Dockerfile,一个容器,无 MF 远程加载,无独立 widget 容器 --- ## 3. 插件目录分类 内置插件源码按**用途分类**组织在 `src/widgets//` 下: ``` src/widgets/ ├─ universal/ # 通用插件(跨角色复用,按角色渲染不同视图) │ ├─ grades-widget/ │ │ ├─ index.tsx # 插件入口(接收 PluginProps) │ │ ├─ plugin.manifest.ts # 插件元数据(id/分类/版本/slot/size/propsSchema) │ │ ├─ TeacherView.tsx # 教师视图 │ │ ├─ StudentView.tsx # 学生视图 │ │ ├─ ParentView.tsx # 家长视图 │ │ └─ __tests__/ │ ├─ homework-widget/ │ ├─ schedule-widget/ │ ├─ attendance-widget/ │ ├─ exams-widget/ │ ├─ notifications-widget/ │ └─ announcements-widget/ │ ├─ sidebar/ # 侧栏插件(插入 SideNav slot) │ ├─ class-selector/ # 班级选择器(教师) │ ├─ child-selector/ # 孩子选择器(家长) │ ├─ term-switcher/ # 学期切换 │ └─ quick-actions/ # 快捷操作 │ ├─ topbar/ # 顶栏插件(插入 TopBar slot) │ ├─ global-search/ # 全局搜索 │ ├─ notification-bell/ # 通知铃铛 │ ├─ user-menu/ # 用户菜单 │ └─ locale-switcher/ # 语言切换 │ ├─ teacher/ # 教师专属插件(仅教师可见) │ ├─ lesson-plan-editor/ # 备课画布(V4 锚点/inline-node) │ ├─ question-bank/ # 题库管理 │ ├─ textbook-manager/ # 教材管理 │ └─ scheduling-rules/ # 排课规则 │ ├─ student/ # 学生专属插件 │ ├─ error-book/ # 错题本 │ ├─ learning-path/ # 学习路径 │ ├─ elective-selector/ # 选课 │ └─ ai-tutor/ # AI 辅导 │ ├─ parent/ # 家长专属插件 │ ├─ child-overview/ # 多孩子总览 │ └─ leave-approval/ # 请假审批 │ └─ admin/ # 管理员专属插件 ├─ user-management/ # 用户管理 ├─ rbac-manager/ # 角色权限 ├─ school-settings/ # 学校设置 ├─ audit-logs/ # 审计日志 ├─ invitation-codes/ # 邀请码 └─ plugin-manager/ # 插件管理(admin 配置面板本身也是插件) ``` | 分类 | 用途 | 插入 slot | 跨角色 | 示例 | | ----------- | ---------------------------- | --------- | ------ | ---------------------------------- | | `universal` | 通用功能,按角色渲染不同视图 | main-* | 是 | grades / homework / schedule | | `sidebar` | 侧栏功能(选择器/切换器) | side | 部分 | class-selector / term-switcher | | `topbar` | 顶栏功能(搜索/通知/菜单) | top | 是 | global-search / notification-bell | | `teacher` | 教师专属,不跨角色复用 | main-* | 否 | lesson-plan-editor / question-bank | | `student` | 学生专属 | main-* | 否 | error-book / ai-tutor | | `parent` | 家长专属 | main-* | 否 | child-overview / leave-approval | | `admin` | 管理员专属 | main-* | 否 | user-management / plugin-manager | --- ## 4. Layout + Slot 模型 ### 4.1 5 种内置 Layout 模板 | 模板 ID | 显示名 | 布局描述 | 可用 slots | 适用场景 | | --------- | -------- | ----------------------------------- | ---------------------------- | -------------- | | `classic` | 经典三栏 | TopBar + SideNav + Main | top / side / main | 默认(仪表盘) | | `focus` | 聚焦 | TopBar + 全宽 Main | top / main | 备课/编辑器 | | `split` | 双栏 | TopBar + 左右等分 Main | top / main-left / main-right | 对比/批改 | | `triple` | 三栏内容 | TopBar + SideNav + Main + RightRail | top / side / main / right | 数据分析 | | `canvas` | 自由画布 | TopBar + 自由摆放(拖拽预留) | top / canvas-grid | 个性化 | ### 4.2 Slot 系统 每个 Layout 模板预定义一组 slot,插件配置到 slot 中渲染: - **top slot**:TopBar 区域,可插入多个 topbar 类插件(logo / search / notification-bell / user-menu / locale-switcher) - **side slot**:SideNav 区域,可插入 sidebar 类插件(class-selector / term-switcher / 导航项) - **main slot**:主内容区,可插入 universal / teacher / student / parent / admin 类插件 - **main-left / main-right slot**:split 模板的左右等分 - **right slot**:triple 模板的右侧栏 - **canvas-grid slot**:canvas 模板的自由摆放区(MVP 不实现拖拽,仅按 grid 排列) ### 4.3 三层配置模型 **优先级**:用户覆盖 > 角色模板 > 系统默认 #### Layer 1: 系统默认(plugin_registry 表) - 插件元数据:id / 分类 / 版本 / 默认 slot / 默认 size / 默认 props - 由开发者在 `plugin.manifest.ts` 声明,构建时同步到 DB #### Layer 2: 角色模板(role_plugin_mapping 表) - admin 配置:角色可用哪些插件、角色默认 Layout、角色级 props 默认值 - 如:教师角色启用 grades-widget + homework-widget,默认 classic 模板,grades-widget 默认显示本学期 #### Layer 3: 用户覆盖(user_layout_override 表) - 用户自定义:切换 Layout 模板、调整插件位置、隐藏插件、调整插件 props - 仅限角色可用集内操作 ### 4.4 插件 props 三层合并 插件通过 `plugin.manifest.ts` 声明 `propsSchema`(JSON Schema),admin 配置面板自动渲染表单。 **props 合并优先级**:用户调整 > 角色默认 > 系统默认 - **系统默认**:开发者在 `plugin.manifest.ts` 的 `defaultProps` 声明,构建时由 `sync-builtin-plugins.ts` 脚本同步到 `plugin_registry.default_props` 字段。运行时 Shell 从 DB 读取(DB 是唯一源,避免 manifest 与 DB 不一致)。 - **角色默认**:admin 通过配置面板写入 `role_plugin_mapping.widget_props`。 - **用户调整**:用户在插件内调整,写入 `user_layout_override.plugin_placements[].props`。 ```typescript // PropsMerger 三层合并(深合并 + 数组覆盖) const finalProps = deepMerge( registry.defaultProps, // 系统默认(plugin_registry 表) roleMapping.widgetProps, // 角色默认(role_plugin_mapping 表) userPlacement.props, // 用户调整(user_layout_override 表) ); ``` --- ## 5. 插件契约 ### 5.1 PluginProps 契约 ```typescript // packages/shared-ts/src/contracts/plugin.ts export interface PluginProps> { /** 插件实例 ID(同一插件多实例时区分) */ instanceId: string; /** 当前用户角色 */ role: "admin" | "teacher" | "student" | "parent"; /** 当前用户信息(来自 IAM) */ user: { id: string; name: string; email: string; dataScope: string; }; /** 当前 slot 信息 */ slot: { name: string; // 'main-top' | 'side' | 'top' | ... layoutId: string; // 'classic' | 'focus' | ... size?: { colSpan: number; rowSpan: number }; }; /** 插件自定义 props(三层合并后的最终值) */ props: TProps; /** 服务端预取的初始数据(RSC 直出,避免客户端瀑布流) */ initialData?: unknown; } // 注:跨插件状态不通过 props 传递,而是插件自行调用: // - useSearchParams() 读取 URL 上下文(classId/childId/termId/view) // - usePluginStore() 读取 Zustand 全局状态(theme/locale/sidebarCollapsed) // - useWidgetQuery() 读取 BFF 业务数据(自动按 role 路由) // 这样插件无需接收 eventBus/ctx 等"注入"依赖,符合 React 函数式哲学 export interface PluginManifest { pluginId: string; version: string; /** 兼容的 Shell 版本范围(semver range,如 "^1.0.0") */ requiredShellVersion: string; /** React 组件(默认导出) */ Component: React.ComponentType; /** 插件元数据 */ metadata: { displayName: string; description: string; category: | "universal" | "sidebar" | "topbar" | "teacher" | "student" | "parent" | "admin"; requiredRoles: string[]; defaultSlot: string; defaultSize: { colSpan: number; rowSpan: number }; /** 插件可配置的 props schema(JSON Schema,用于 admin 配置面板自动渲染表单) */ propsSchema?: JSONSchema; /** 系统默认 props(与 propsSchema 配合) */ defaultProps?: Record; }; } ``` ### 5.2 跨插件状态管理(URL Params + Zustand) 抛弃 EventBus 发布订阅模式(微前端跨框架通信的无奈之举,在 React 单体中会导致"状态黑盒"、极难 Debug、不支持 React DevTools 追踪)。采用 React 单向数据流哲学:**URL 驱动 + Zustand 全局状态**。 #### 5.2.1 URL Search Params(高频/可分享上下文) 适合**需要 URL 分享、浏览器前进后退**的全局上下文: ```typescript // packages/shared-ts/src/contracts/plugin-context.ts /** URL 驱动的全局上下文(可分享、可前进后退) */ export interface UrlPluginContext { /** 当前选中的班级 ID(教师视角,class-selector 切换时更新 URL) */ classId?: string; /** 当前选中的孩子 ID(家长视角) */ childId?: string; /** 当前选中的学期 */ termId?: string; /** 当前视图模式(如 grades-widget 的 'list' | 'chart') */ view?: string; } // 读取:useSearchParams() // 写入:router.push('?classId=xxx') // 响应:其他插件通过 useSearchParams() 自动响应,触发重新渲染 ``` ```typescript // 示例:class-selector 插件切换班级 import { useRouter, useSearchParams } from "next/navigation"; export function ClassSelector() { const router = useRouter(); const searchParams = useSearchParams(); const currentClassId = searchParams.get("classId"); const handleSelect = (classId: string) => { const params = new URLSearchParams(searchParams); params.set("classId", classId); router.push(`?${params.toString()}`); // grades-widget 等插件自动响应,无需 EventBus 广播 }; return