1192 lines
70 KiB
Markdown
1192 lines
70 KiB
Markdown
# 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/<category>/` 下:
|
||
|
||
```
|
||
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<TProps = Record<string, unknown>> {
|
||
/** 插件实例 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<PluginProps>;
|
||
/** 插件元数据 */
|
||
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<string, unknown>;
|
||
};
|
||
}
|
||
```
|
||
|
||
### 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 <Select value={currentClassId} onSelect={handleSelect} />;
|
||
}
|
||
|
||
// 示例:grades-widget 插件响应班级切换
|
||
import { useSearchParams } from "next/navigation";
|
||
|
||
export function GradesWidget() {
|
||
const classId = useSearchParams().get("classId");
|
||
// classId 变化自动触发重新渲染和重新查询
|
||
const { data } = useWidgetQuery(GET_GRADES, { classId });
|
||
return <GradesTable data={data} />;
|
||
}
|
||
```
|
||
|
||
#### 5.2.2 Zustand Store(低频/不可分享状态)
|
||
|
||
适合**不需要 URL 分享、纯 UI 状态**的全局上下文:
|
||
|
||
```typescript
|
||
// packages/shared-ts/src/contracts/plugin-store.ts
|
||
|
||
import { create } from "zustand";
|
||
|
||
interface PluginStore {
|
||
/** 主题模式(light/dark) */
|
||
theme: "light" | "dark";
|
||
setTheme: (theme: "light" | "dark") => void;
|
||
|
||
/** i18n locale */
|
||
locale: "zh-CN" | "en";
|
||
setLocale: (locale: "zh-CN" | "en") => void;
|
||
|
||
/** Sidebar 折叠状态 */
|
||
sidebarCollapsed: boolean;
|
||
toggleSidebar: () => void;
|
||
|
||
/** 通知已读标记(纯 UI 状态,不进 URL) */
|
||
unreadNotificationIds: string[];
|
||
markNotificationsRead: (ids: string[]) => void;
|
||
}
|
||
|
||
export const usePluginStore = create<PluginStore>((set) => ({
|
||
theme: "light",
|
||
setTheme: (theme) => set({ theme }),
|
||
locale: "zh-CN",
|
||
setLocale: (locale) => set({ locale }),
|
||
sidebarCollapsed: false,
|
||
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
|
||
unreadNotificationIds: [],
|
||
markNotificationsRead: (ids) =>
|
||
set((s) => ({
|
||
unreadNotificationIds: s.unreadNotificationIds.filter(
|
||
(id) => !ids.includes(id),
|
||
),
|
||
})),
|
||
}));
|
||
```
|
||
|
||
#### 5.2.3 URL vs Zustand 选型标准
|
||
|
||
| 状态类型 | 存储 | 示例 | 理由 |
|
||
| ------------------ | ------------------------------ | --------------------------------- | -------------------------------------- |
|
||
| 可分享、可前进后退 | URL Search Params | classId / termId / view | 用户可复制链接分享,浏览器前进后退生效 |
|
||
| 纯 UI、不可分享 | Zustand Store | theme / locale / sidebarCollapsed | 不需要 URL 体现,避免 URL 污染 |
|
||
| 插件内部私有 | 插件内部 useState / useReducer | 表单临时值、弹窗开关 | 最小作用域原则 |
|
||
|
||
#### 5.2.4 弃用 EventBus 的收益
|
||
|
||
- ✅ 数据流向清晰:URL 是单一数据源,DevTools Network/URL 栏可见
|
||
- ✅ 支持 React DevTools 追踪:Zustand 状态可被 DevTools 检视
|
||
- ✅ 符合 React 单向数据流:状态变更 → 自动重渲染,无发布订阅黑盒
|
||
- ✅ URL 可分享:用户可分享带 classId 的链接,直接定位到特定班级视图
|
||
- ✅ 浏览器前进后退:URL 变更天然支持历史导航
|
||
- ✅ 减少 Debug 成本:无需维护事件名常量、订阅/取消订阅生命周期
|
||
|
||
### 5.3 插件加载与错误处理
|
||
|
||
- **加载态**:PluginLoader 显示 `<PluginSkeleton variant={slot.skeletonVariant} />`,骨架样式由 `@edu/ui-components` 提供
|
||
- **错误态**:插件加载失败时显示 `<PluginErrorFallback instanceId={instanceId} onRetry={...} />`,ErrorBoundary 隔离,单个插件失败不影响其他
|
||
- **超时**:dynamic import 超时 10s 后降级显示错误态
|
||
- **版本兼容**:插件 manifest 携带 `requiredShellVersion`,Shell 启动时校验,不兼容则拒绝加载并提示 admin 升级
|
||
- **懒加载**:首屏只加载可见 slot 的插件,非可见插件使用 IntersectionObserver 滚动到视口再加载
|
||
|
||
### 5.4 插件生命周期
|
||
|
||
| 阶段 | 内置插件 | 第三方插件(二期) |
|
||
| ----------- | --------------------- | ------------------ |
|
||
| registered | 编译时登记到 Registry | 安装时登记到 DB |
|
||
| enabled | admin 启用 | admin 启用 |
|
||
| loaded | dynamic import 加载 | script 注入加载 |
|
||
| active | 渲染并挂载 | 渲染并挂载 |
|
||
| disabled | admin 禁用,不渲染 | admin 禁用,不渲染 |
|
||
| uninstalled | 不可卸载(内置) | admin 卸载,删除包 |
|
||
|
||
### 5.5 RSC 服务端预取流程(消除 CSR 瀑布流)
|
||
|
||
**v2.0 瀑布流问题**:Shell SSR 骨架 → 客户端水合 → 客户端拉 Config → 客户端 dynamic import 插件 → 插件拉 BFF 数据。4 层串行,LCP 差。
|
||
|
||
**v2.1 优化**:利用 Next.js App Router 的 Server Components,在服务端完成 Config 拉取 + initialData 预取,随 HTML 直出。
|
||
|
||
```typescript
|
||
// src/app/shell/[[...route]]/page.tsx(Server Component)
|
||
import { headers } from "next/headers";
|
||
import { iamClient } from "@/lib/iam-client";
|
||
import { bffClients } from "@/lib/bff-clients";
|
||
import { PropsMerger } from "@/shell/PropsMerger";
|
||
import ClientShell from "@/shell/ClientShell";
|
||
|
||
export default async function ShellPage() {
|
||
// ① 从请求头获取 JWT,解析出 userId 和 role
|
||
const headerList = headers();
|
||
const userId = headerList.get("x-user-id")!;
|
||
const role = headerList.get("x-user-role") as "admin" | "teacher" | "student" | "parent";
|
||
|
||
// ② 服务端调 IAM gRPC 获取三层合并后的 PluginConfigResponse
|
||
const config = await iamClient.getPluginConfig({ userId });
|
||
|
||
// ③ 服务端并发预取各插件的 initialData
|
||
const pluginsWithData = await Promise.all(
|
||
config.plugins.map(async (placement) => {
|
||
const initialData = await prefetchPluginData(placement, role, bffClients);
|
||
return { ...placement, initialData };
|
||
})
|
||
);
|
||
|
||
// ④ Config + initialData 作为 props 传给 Client Shell
|
||
return <ClientShell config={{ ...config, plugins: pluginsWithData }} role={role} />;
|
||
}
|
||
|
||
/** 按插件类型预取初始数据(仅首屏可见插件) */
|
||
async function prefetchPluginData(placement, role, bffClients): Promise<unknown> {
|
||
const bffClient = bffClients[role]; // 自动按 role 路由
|
||
switch (placement.pluginId) {
|
||
case "grades-widget":
|
||
return bffClient.query(GET_GRADES, { classId: placement.props.classId });
|
||
case "notifications-widget":
|
||
return bffClient.query(GET_NOTIFICATIONS, { limit: 10 });
|
||
// ... 其他插件
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
**数据流对比:**
|
||
|
||
```
|
||
v2.0 瀑布流(4 层串行,LCP 差):
|
||
HTML 骨架 → 水合 → 拉 Config → import 插件 → 插件拉数据 → 渲染
|
||
[SSR] [client] [client] [client] [client] [client]
|
||
──────────┴────────┴───────────┴────────────┴───────────┴────────
|
||
总耗时 = SSR + 水合 + Config RTT + import RTT + BFF RTT
|
||
|
||
v2.1 RSC 预取(2 层并行,LCP 秒开):
|
||
服务端:RSC 拉 Config + 并发预取 initialData → HTML 直出(含数据)
|
||
客户端:水合 → dynamic import 插件组件 → 用 initialData 渲染
|
||
[RSC 并行] [client]
|
||
───────────────────────────────────────────────┴────────────────
|
||
总耗时 = max(Config RTT, BFF RTT) + 水合 + import RTT
|
||
(Config 和 BFF 在服务端并行,不阻塞客户端)
|
||
```
|
||
|
||
**约束:**
|
||
|
||
- `prefetchPluginData` 仅预取**首屏可见 slot** 的插件数据(非可见插件滚动到视口再加载)
|
||
- 插件组件仍用 `dynamic(() => import(...), { ssr: false })` 懒加载(避免插件 JS 阻塞首屏)
|
||
- 插件接收 `initialData` 作为 SWR/useWidgetQuery 的 `fallbackData`,首次渲染无需客户端请求
|
||
- 后续数据刷新(如手动刷新、轮询)仍走客户端 useWidgetQuery
|
||
|
||
### 5.6 统一 BFF 请求 Hook(Data Fetching Abstraction)
|
||
|
||
**痛点**:universal 分类插件(grades/homework/schedule 等)跨角色复用,如果每个插件自行判断角色并拼接 BFF URL,会导致大量重复样板代码。
|
||
|
||
**方案**:在 `src/lib/` 中封装统一的 `useWidgetQuery` 和 `useWidgetMutation`,内部自动读取 `AuthProvider` 中的 role,自动路由到对应 BFF。插件开发者只关心写 GraphQL 查询语句。
|
||
|
||
```typescript
|
||
// src/lib/useWidgetQuery.ts
|
||
import useSWR from "swr";
|
||
import { useAuth } from "@/providers/AuthProvider";
|
||
|
||
const BFF_ROUTE_MAP = {
|
||
admin: "/api/admin-bff/graphql",
|
||
teacher: "/api/teacher-bff/graphql",
|
||
student: "/api/student-bff/graphql",
|
||
parent: "/api/parent-bff/graphql",
|
||
} as const;
|
||
|
||
/**
|
||
* 统一 BFF GraphQL 查询 Hook
|
||
* 自动按当前用户 role 路由到对应 BFF
|
||
* 插件开发者只需提供 GraphQL 查询语句和变量
|
||
*/
|
||
export function useWidgetQuery<
|
||
TData = unknown,
|
||
TVars = Record<string, unknown>,
|
||
>(
|
||
query: string,
|
||
variables?: TVars,
|
||
options?: {
|
||
fallbackData?: TData; // RSC 预取的 initialData
|
||
refreshInterval?: number;
|
||
enabled?: boolean;
|
||
},
|
||
) {
|
||
const { role } = useAuth();
|
||
const bffUrl = BFF_ROUTE_MAP[role];
|
||
|
||
return useSWR<TData>(
|
||
options?.enabled === false ? null : [bffUrl, query, variables],
|
||
async ([url, q, vars]) => {
|
||
const res = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ query: q, variables: vars }),
|
||
credentials: "include",
|
||
});
|
||
const json = await res.json();
|
||
if (json.errors) throw new Error(json.errors[0].message);
|
||
return json.data;
|
||
},
|
||
{
|
||
fallbackData: options?.fallbackData, // RSC 预取数据直出
|
||
revalidateOnFocus: false, // 业务数据不自动刷新(配置才自动刷新)
|
||
refreshInterval: options?.refreshInterval,
|
||
},
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 统一 BFF GraphQL 变更 Hook
|
||
*/
|
||
export function useWidgetMutation<
|
||
TData = unknown,
|
||
TVars = Record<string, unknown>,
|
||
>(mutation: string) {
|
||
const { role } = useAuth();
|
||
const bffUrl = BFF_ROUTE_MAP[role];
|
||
|
||
return async (variables: TVars): Promise<TData> => {
|
||
const res = await fetch(bffUrl, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ query: mutation, variables }),
|
||
credentials: "include",
|
||
});
|
||
const json = await res.json();
|
||
if (json.errors) throw new Error(json.errors[0].message);
|
||
return json.data;
|
||
};
|
||
}
|
||
```
|
||
|
||
**插件使用示例:**
|
||
|
||
```typescript
|
||
// grades-widget 插件(无需关心路由到哪个 BFF)
|
||
import { useWidgetQuery, useWidgetMutation } from "@/lib/useWidgetQuery";
|
||
|
||
const GET_GRADES = `
|
||
query GetGrades($classId: ID!, $termId: ID!) {
|
||
grades(classId: $classId, termId: $termId) { studentId score }
|
||
}
|
||
`;
|
||
|
||
export function GradesWidget({ initialData }: PluginProps) {
|
||
const classId = useSearchParams().get("classId");
|
||
const termId = useSearchParams().get("termId");
|
||
|
||
const { data, isLoading } = useWidgetQuery(GET_GRADES, { classId, termId }, {
|
||
fallbackData: initialData, // RSC 预取的数据
|
||
});
|
||
|
||
if (isLoading) return <PluginSkeleton variant="table" />;
|
||
return <GradesTable data={data.grades} />;
|
||
}
|
||
```
|
||
|
||
**收益:**
|
||
|
||
- ✅ 插件开发者无需关心 BFF 路由策略
|
||
- ✅ role 切换时自动路由到对应 BFF(universal 插件天然支持多角色)
|
||
- ✅ RSC 预取数据通过 `fallbackData` 无缝衔接,首次渲染无瀑布流
|
||
- ✅ 统一错误处理、统一 credentials、统一 TypeScript 类型推导
|
||
|
||
---
|
||
|
||
## 6. 数据模型(IAM 扩展)
|
||
|
||
### 6.1 表结构
|
||
|
||
```sql
|
||
-- 插件注册表(系统级,admin 维护;内置插件由构建脚本同步)
|
||
plugin_registry (
|
||
plugin_id TEXT PRIMARY KEY, -- 'grades-widget'
|
||
category TEXT, -- 'universal'|'sidebar'|'topbar'|'teacher'|'student'|'parent'|'admin'
|
||
version TEXT, -- semver
|
||
display_name TEXT,
|
||
description TEXT,
|
||
required_roles TEXT[], -- ['teacher','student','parent']
|
||
default_slot TEXT, -- 'main-top'
|
||
default_size JSONB, -- {colSpan:2,rowSpan:1}
|
||
default_props JSONB, -- 系统默认 props
|
||
props_schema JSONB, -- JSON Schema(admin 配置面板用)
|
||
is_builtin BOOLEAN, -- true=内置,false=第三方
|
||
is_active BOOLEAN, -- 全局启用/禁用
|
||
package_url TEXT, -- 二期:第三方插件包 URL
|
||
created_at TIMESTAMP,
|
||
updated_at TIMESTAMP
|
||
);
|
||
|
||
-- 角色-插件映射(admin 配置角色可用插件集 + 角色 Layout 默认)
|
||
role_plugin_mapping (
|
||
role TEXT, -- 'teacher'
|
||
plugin_id TEXT, -- 'grades-widget'
|
||
slot TEXT, -- 'main-top'(角色默认 slot)
|
||
sort_order INT, -- 显示顺序
|
||
is_enabled BOOLEAN, -- 角色是否启用
|
||
widget_props JSONB, -- 角色默认 props(覆盖系统默认)
|
||
PRIMARY KEY (role, plugin_id)
|
||
);
|
||
|
||
-- 角色 Layout 默认(admin 配置角色默认模板)
|
||
role_layout_default (
|
||
role TEXT PRIMARY KEY,
|
||
layout_id TEXT, -- 'classic'|'focus'|'split'|'triple'|'canvas'
|
||
slot_overrides JSONB -- 角色级 slot 默认
|
||
);
|
||
|
||
-- Layout 模板(系统预置,不可改)
|
||
layout_templates (
|
||
layout_id TEXT PRIMARY KEY, -- 'classic'|'focus'|'split'|'triple'|'canvas'
|
||
display_name TEXT,
|
||
description TEXT,
|
||
available_slots JSONB, -- ["top","side","main","right"]
|
||
layout_schema JSONB, -- {grid:{rows,cols,areas},resizable,draggable}
|
||
is_active BOOLEAN
|
||
);
|
||
|
||
-- 用户布局覆盖(用户自定义)
|
||
user_layout_override (
|
||
user_id TEXT PRIMARY KEY,
|
||
active_layout TEXT, -- 当前选用模板
|
||
slot_overrides JSONB, -- 用户自定义 slot 项
|
||
plugin_placements JSONB, -- [{plugin_id,slot,sort_order,size,props}]
|
||
hidden_plugins TEXT[] -- 用户隐藏的插件
|
||
);
|
||
|
||
-- 二期:第三方插件包存储
|
||
plugin_packages (
|
||
package_id TEXT PRIMARY KEY,
|
||
plugin_id TEXT, -- 关联 plugin_registry
|
||
version TEXT,
|
||
manifest_json JSONB, -- 解析后的 manifest
|
||
storage_path TEXT, -- ZIP 包存储路径
|
||
status TEXT, -- 'uploaded'|'installed'|'error'
|
||
uploaded_by TEXT,
|
||
uploaded_at TIMESTAMP
|
||
);
|
||
```
|
||
|
||
### 6.2 IAM gRPC 接口扩展
|
||
|
||
```protobuf
|
||
// packages/shared-proto/proto/iam.proto 新增
|
||
|
||
message GetPluginConfigRequest {
|
||
string user_id = 1;
|
||
}
|
||
|
||
message PluginConfigResponse {
|
||
LayoutTemplate active_layout = 1;
|
||
repeated SlotConfig slots = 2;
|
||
repeated PluginPlacement plugins = 3;
|
||
repeated PluginRegistryItem registry = 4;
|
||
}
|
||
|
||
message LayoutTemplate {
|
||
string layout_id = 1;
|
||
string display_name = 2;
|
||
string description = 3;
|
||
repeated string available_slots = 4;
|
||
string layout_schema_json = 5;
|
||
}
|
||
|
||
message SlotConfig {
|
||
string slot_name = 1;
|
||
repeated string nav_items = 2;
|
||
}
|
||
|
||
message PluginPlacement {
|
||
string plugin_id = 1;
|
||
string slot = 2;
|
||
int32 sort_order = 3;
|
||
string size_json = 4;
|
||
string props_json = 5; // 三层合并后的最终 props
|
||
bool is_visible = 6;
|
||
}
|
||
|
||
message PluginRegistryItem {
|
||
string plugin_id = 1;
|
||
string category = 2;
|
||
string version = 3;
|
||
string display_name = 4;
|
||
string description = 5;
|
||
repeated string required_roles = 6;
|
||
bool is_builtin = 7;
|
||
bool is_active = 8;
|
||
}
|
||
```
|
||
|
||
### 6.3 Admin CRUD API
|
||
|
||
| 方法 | 路径 | 用途 | MVP |
|
||
| ------ | ------------------------------------- | ------------------------------------ | ------- |
|
||
| GET | `/admin/plugins` | 列出所有 plugin_registry 项 | ✅ |
|
||
| PUT | `/admin/plugins/:id` | 更新插件配置(启用/禁用/默认 props) | ✅ |
|
||
| GET | `/admin/role-plugin-mapping` | 列出角色-插件映射 | ✅ |
|
||
| PUT | `/admin/role-plugin-mapping/:role` | 批量更新角色插件配置 | ✅ |
|
||
| GET | `/admin/layout-templates` | 列出 5 种内置模板 | ✅ |
|
||
| PUT | `/admin/role-layout-default/:role` | 配置角色默认 Layout | ✅ |
|
||
| GET | `/admin/user-layout-override/:userId` | 查看用户自定义 | ✅ |
|
||
| DELETE | `/admin/user-layout-override/:userId` | 重置用户自定义 | ✅ |
|
||
| POST | `/admin/plugins/upload` | 上传第三方插件包(ZIP) | ⏸️ 二期 |
|
||
| DELETE | `/admin/plugins/:id/uninstall` | 卸载第三方插件 | ⏸️ 二期 |
|
||
|
||
### 6.4 配置变更生效路径(SWR 静默刷新)
|
||
|
||
**抛弃 Kafka + WebSocket 推送链路**(对 Dashboard 布局这种低频变更,引入 Kafka 和 WebSocket 的研发和运维成本过高,属于过度设计)。
|
||
|
||
改用 **SWR 静默后台刷新**机制:
|
||
|
||
```typescript
|
||
// src/lib/usePluginConfig.ts
|
||
import useSWR from "swr";
|
||
|
||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||
|
||
export function usePluginConfig(initialConfig: PluginConfigResponse) {
|
||
const { data, mutate } = useSWR<PluginConfigResponse>(
|
||
"/api/plugin-config",
|
||
fetcher,
|
||
{
|
||
fallbackData: initialConfig, // RSC 直出的初始配置作为 fallback
|
||
revalidateOnFocus: true, // 用户切回 Tab 时静默刷新
|
||
revalidateOnReconnect: true, // 网络恢复时静默刷新
|
||
refreshInterval: 300_000, // 每 5 分钟静默刷新一次
|
||
dedupingInterval: 60_000, // 1 分钟内去重
|
||
onSuccess: (newData, key, config) => {
|
||
// 检测到配置变化时,Toast 提示用户刷新
|
||
if (hasConfigChanged(initialConfig, newData)) {
|
||
showToast("发现新布局配置,点击刷新生效", {
|
||
action: { label: "刷新", onClick: () => window.location.reload() },
|
||
duration: 0, // 常驻直到用户操作
|
||
});
|
||
}
|
||
},
|
||
},
|
||
);
|
||
|
||
return { config: data, refresh: mutate };
|
||
}
|
||
```
|
||
|
||
**生效路径:**
|
||
|
||
1. Admin 通过 REST API 修改 `plugin_registry` / `role_plugin_mapping` / `role_layout_default`
|
||
2. 用户侧 SWR 静默刷新触发(切回 Tab / 5 分钟轮询 / 网络恢复)
|
||
3. SWR 检测到配置变化,Toast 提示"发现新布局配置,点击刷新生效"
|
||
4. 用户点击刷新或下次刷新页面时,RSC 重新预取配置生效
|
||
5. **无需 Kafka、无需 WebSocket、无需 push-gateway 改动**
|
||
|
||
**收益:**
|
||
|
||
- ✅ 砍掉 Kafka Outbox 事件链路(IAM 侧)
|
||
- ✅ 砍掉 msg 服务消费 + push-gateway WebSocket 推送链路
|
||
- ✅ 砍掉前端 WebSocket 客户端订阅逻辑
|
||
- ✅ 利用 SWR 成熟机制,代码量减少 80%+
|
||
- ✅ 配置变更感知延迟 ≤ 5 分钟(refreshInterval)或即时(revalidateOnFocus),对低频变更完全够用
|
||
|
||
---
|
||
|
||
## 7. 设计系统一致性
|
||
|
||
### 7.1 强制约束
|
||
|
||
- 所有插件必须使用 `@edu/ui-tokens` 的设计令牌(`hsl(var(--*))` / `var(--font-*)` / `var(--space-*)`)
|
||
- 所有插件必须使用 `@edu/ui-components` 的基础组件
|
||
- 禁止硬编码颜色(`#hex`)、字体(`'Inter'`)、字号(`font-size: Npx`)、Tailwind 任意值(`w-[Npx]`)
|
||
- ESLint 规则 `no-restricted-syntax` + `design-tokens/no-hardcoded-fonts` 强制约束
|
||
- 插件必须使用 `PluginCard` 包装组件(纸感风格容器)
|
||
|
||
### 7.2 新增"插件视觉规范"章节
|
||
|
||
补充到 `docs/standards/ui-design-system.md`:
|
||
|
||
- **插件容器**:使用 `PluginCard`,纸感背景 `hsl(var(--card))`,圆角 `var(--radius-md)`,内边距 `var(--space-5)`
|
||
- **插件标题**:`font-family: var(--font-family-sans)`,`font-size: var(--font-size-4)`,`font-weight: var(--weight-semibold)`
|
||
- **插件加载态**:`<PluginSkeleton variant="card|list|chart|stats|table" />`
|
||
- **插件错误态**:`<PluginErrorFallback />`,居中显示错误图标 + 重试按钮
|
||
- **插件间距**:slot 内插件间距 `var(--space-4)`,slot 间距 `var(--space-6)`
|
||
- **响应式**:mobile 单列,tablet 2 列,desktop 按 layout 配置
|
||
|
||
### 7.3 新增 UI 组件
|
||
|
||
- `PluginCard`:纸感卡片容器,强制设计令牌
|
||
- `PluginSkeleton`:5 种 skeleton 变体(card/list/chart/stats/table)
|
||
- `PluginErrorFallback`:错误兜底组件
|
||
- `SlotPlaceholder`:空 slot 占位(admin 模式下显示"添加插件"按钮)
|
||
- `PropsConfigForm`:根据 propsSchema 自动渲染的配置表单(admin 配置面板用)
|
||
|
||
---
|
||
|
||
## 8. 迁移路径
|
||
|
||
### 8.1 阶段划分
|
||
|
||
| 阶段 | 内容 | 验收标准 |
|
||
| ----------- | -------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||
| P1 | IAM 扩展(5 表 + gRPC + admin API)+ proto 契约 + 共享包契约 | gRPC 调用返回正确配置,admin CRUD 可用 |
|
||
| P2 | Shell 宿主(apps/portal-shell)+ 5 种 Layout 模板 + ConfigProvider + Registry + PluginLoader | Shell 启动、登录、渲染空 Layout、切换模板 |
|
||
| P3 | 首张插件(grades-widget)+ dynamic import 跑通 + 三层 props 合并 | grades-widget 在 Shell 中渲染,props 合并正确 |
|
||
| P4 | 剩余 6 张 universal 插件 + sidebar/topbar 插件 | 所有 universal/sidebar/topbar 插件可用 |
|
||
| P5 | 单角色专属插件(lesson-plan-editor / error-book / ai-tutor / user-management 等) | 单角色专属插件可用 |
|
||
| P6 | Admin 配置面板(plugin-manager 插件)+ 用户自定义 UI | admin 可配置角色插件,用户可切换 Layout |
|
||
| P7 | api-gateway 路由 + docker-compose 部署 + CI/CD | 容器化部署,CI 流水线通过 |
|
||
| P8 | E2E + 视觉回归测试 | 测试全通过 |
|
||
| P9 | 用户逐步迁移,旧 portal 下线 | 旧 portal 流量为 0 |
|
||
| P10(二期) | 第三方插件上传 + 运行时加载 + 沙箱隔离 | 第三方插件可上传安装运行 |
|
||
|
||
### 8.2 旧 Portal 处理
|
||
|
||
- 现有 4 个 portal(teacher / student / parent / admin)**保留并行运行**
|
||
- 新 Shell 使用路由前缀 `/shell/*` 避免冲突
|
||
- 用户通过入口链接逐步引导到新 Shell
|
||
- 迁移完成后,4 个旧 portal 下线,portal-shell 路由改为 `/`
|
||
|
||
### 8.3 回滚策略
|
||
|
||
- 任意阶段失败,回滚到上一阶段
|
||
- 旧 portal 始终可用,新 Shell 失败不影响现有用户
|
||
- IAM 新增表与现有表无外键依赖,可独立回滚
|
||
|
||
---
|
||
|
||
## 9. 完整对接清单
|
||
|
||
### 9.1 前端新建
|
||
|
||
| 路径 | 职责 | 动作 |
|
||
| ------------------------------------------------------- | ------------------------------------------------------------------------ | ---- |
|
||
| `apps/portal-shell/` | Shell 宿主应用(单 Next.js App Router) | 新建 |
|
||
| `apps/portal-shell/src/app/layout.tsx` | RootLayout,挂载 Providers | 新建 |
|
||
| `apps/portal-shell/src/app/shell/[[...route]]/page.tsx` | Shell 入口(RSC Server Component),服务端拉取 Config + 预取 initialData | 新建 |
|
||
| `apps/portal-shell/src/shell/ClientShell.tsx` | Client Component,接收 RSC props,渲染 Layout + Plugins | 新建 |
|
||
| `apps/portal-shell/src/shell/Shell.tsx` | Layout 框架 + Slots | 新建 |
|
||
| `apps/portal-shell/src/shell/Registry.ts` | 插件清单(plugin_id → dynamic import) | 新建 |
|
||
| `apps/portal-shell/src/shell/PluginLoader.tsx` | dynamic import + 骨架屏 + ErrorBoundary | 新建 |
|
||
| `apps/portal-shell/src/shell/PluginLifecycle.ts` | install/enable/disable 管理 | 新建 |
|
||
| `apps/portal-shell/src/shell/SlotRenderer.tsx` | 按 Config 渲染插件列表 | 新建 |
|
||
| `apps/portal-shell/src/shell/PropsMerger.ts` | 三层 props 合并 | 新建 |
|
||
| `apps/portal-shell/src/shell/LayoutManager.tsx` | 5 种 Layout 模板渲染器 | 新建 |
|
||
| `apps/portal-shell/src/shell/PluginStore.ts` | Zustand 全局状态(theme/locale/sidebar) | 新建 |
|
||
| `apps/portal-shell/src/lib/iam-client.ts` | IAM gRPC 客户端(服务端调用) | 新建 |
|
||
| `apps/portal-shell/src/lib/bff-clients.ts` | BFF GraphQL 客户端(按 role 路由) | 新建 |
|
||
| `apps/portal-shell/src/lib/useWidgetQuery.ts` | 统一 BFF 查询 Hook(自动按 role 路由 + fallbackData) | 新建 |
|
||
| `apps/portal-shell/src/lib/useWidgetMutation.ts` | 统一 BFF 变更 Hook | 新建 |
|
||
| `apps/portal-shell/src/lib/usePluginConfig.ts` | SWR 静默刷新配置(revalidateOnFocus + refreshInterval) | 新建 |
|
||
| `apps/portal-shell/src/providers/AuthProvider.tsx` | JWT + RBAC,提供 useAuth() | 新建 |
|
||
| `apps/portal-shell/src/providers/ThemeI18nProvider.tsx` | 主题 + i18n | 新建 |
|
||
| `apps/portal-shell/next.config.js` | Next.js 配置(standalone 模式) | 新建 |
|
||
| `apps/portal-shell/Dockerfile` | 独立容器构建(单服务) | 新建 |
|
||
| `apps/portal-shell/vitest.config.ts` | Shell 单元测试 | 新建 |
|
||
|
||
### 9.2 内置插件源码
|
||
|
||
| 路径 | 分类 | 动作 |
|
||
| --------------------------------------------------------------- | ---------------------------- | ---- |
|
||
| `apps/portal-shell/src/widgets/universal/grades-widget/` | universal | 新建 |
|
||
| `apps/portal-shell/src/widgets/universal/homework-widget/` | universal | 新建 |
|
||
| `apps/portal-shell/src/widgets/universal/schedule-widget/` | universal | 新建 |
|
||
| `apps/portal-shell/src/widgets/universal/attendance-widget/` | universal | 新建 |
|
||
| `apps/portal-shell/src/widgets/universal/exams-widget/` | universal | 新建 |
|
||
| `apps/portal-shell/src/widgets/universal/notifications-widget/` | universal | 新建 |
|
||
| `apps/portal-shell/src/widgets/universal/announcements-widget/` | universal | 新建 |
|
||
| `apps/portal-shell/src/widgets/sidebar/class-selector/` | sidebar | 新建 |
|
||
| `apps/portal-shell/src/widgets/sidebar/child-selector/` | sidebar | 新建 |
|
||
| `apps/portal-shell/src/widgets/sidebar/term-switcher/` | sidebar | 新建 |
|
||
| `apps/portal-shell/src/widgets/topbar/global-search/` | topbar | 新建 |
|
||
| `apps/portal-shell/src/widgets/topbar/notification-bell/` | topbar | 新建 |
|
||
| `apps/portal-shell/src/widgets/topbar/user-menu/` | topbar | 新建 |
|
||
| `apps/portal-shell/src/widgets/topbar/locale-switcher/` | topbar | 新建 |
|
||
| `apps/portal-shell/src/widgets/teacher/lesson-plan-editor/` | teacher | 新建 |
|
||
| `apps/portal-shell/src/widgets/teacher/question-bank/` | teacher | 新建 |
|
||
| `apps/portal-shell/src/widgets/student/error-book/` | student | 新建 |
|
||
| `apps/portal-shell/src/widgets/student/ai-tutor/` | student | 新建 |
|
||
| `apps/portal-shell/src/widgets/parent/child-overview/` | parent | 新建 |
|
||
| `apps/portal-shell/src/widgets/admin/user-management/` | admin | 新建 |
|
||
| `apps/portal-shell/src/widgets/admin/rbac-manager/` | admin | 新建 |
|
||
| `apps/portal-shell/src/widgets/admin/plugin-manager/` | admin(配置面板本身) | 新建 |
|
||
| 每个 `*/plugin.manifest.ts` | 插件元数据 + propsSchema | 新建 |
|
||
| 每个 `*/index.tsx` | 插件入口(接收 PluginProps) | 新建 |
|
||
|
||
### 9.3 共享包扩展
|
||
|
||
| 路径 | 变更 | 动作 |
|
||
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------- |
|
||
| `packages/shared-ts/src/contracts/plugin.ts` | 新增 PluginProps / PluginManifest 类型契约 | 新增导出 |
|
||
| `packages/shared-ts/src/contracts/layout.ts` | 新增 LayoutTemplate / SlotConfig / PluginPlacement 类型 | 新增导出 |
|
||
| `packages/shared-ts/src/contracts/plugin-store.ts` | Zustand 全局状态 schema(theme/locale/sidebar) | 新增导出 |
|
||
| `packages/shared-ts/src/contracts/plugin-context.ts` | URL Search Params 上下文 schema(classId/childId/termId/view) | 新增导出 |
|
||
| `packages/ui-components/src/` | 新增 PluginCard / PluginSkeleton / PluginErrorFallback / SlotPlaceholder / PropsConfigForm | 新增组件 |
|
||
| `packages/ui-tokens/src/` | 无需变更(现有令牌足够) | 复用 |
|
||
| `packages/hooks/src/` | 新增 usePluginConfig(SWR)/ usePluginStore(Zustand) | 新增 hooks |
|
||
| `pnpm-workspace.yaml` | 新增 `apps/portal-shell` | 修改 |
|
||
|
||
### 9.4 后端 IAM 扩展
|
||
|
||
| 路径 | 变更 | 动作 |
|
||
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------- |
|
||
| `services/iam/src/iam/iam.schema.ts` | 新增 plugin_registry / role_plugin_mapping / role_layout_default / layout_templates / user_layout_override / plugin_packages 表 | 新增 schema |
|
||
| `services/iam/src/iam/iam.service.ts` | 新增 getPluginRegistry / getRolePluginMapping / getUserLayoutOverride / mergePluginConfig / mergeProps 方法 | 新增方法 |
|
||
| `services/iam/src/iam/iam.grpc.controller.ts` | 新增 GetPluginConfig RPC(合并三层配置返回) | 新增 RPC |
|
||
| `packages/shared-proto/proto/iam.proto` | 新增 PluginConfigRequest/Response、LayoutTemplate、PluginRegistryItem 等 message | 新增 proto |
|
||
| `services/iam/scripts/seed-plugins.ts` | 初始化 5 个 Layout 模板 + 内置插件注册项 | 新建 seed |
|
||
| `services/iam/src/iam/admin.controller.ts` | 新增 admin CRUD plugin_registry / role_plugin_mapping REST API | 新增 controller |
|
||
| `services/iam/scripts/sync-builtin-plugins.ts` | 构建时从 portal-shell 的 manifest 同步到 plugin_registry 表 | 新建脚本 |
|
||
|
||
### 9.5 BFF 层
|
||
|
||
| 路径 | 变更 | 动作 |
|
||
| ----------------------- | ---------------------------------------------------------------- | -------- |
|
||
| `services/teacher-bff/` | 无需变更,插件直接调用现有 GraphQL | 复用 |
|
||
| `services/student-bff/` | 无需变更 | 复用 |
|
||
| `services/parent-bff/` | 无需变更 | 复用 |
|
||
| 插件配置查询 | 由 Shell 直接调 IAM gRPC(不经 BFF),保持配置查询与业务查询分离 | 路由策略 |
|
||
|
||
### 9.6 网关层
|
||
|
||
| 路径 | 变更 | 动作 |
|
||
| ------------------------------------------------ | -------------------------------------------------------------- | -------- |
|
||
| `services/api-gateway/internal/proxy/proxy.go` | 新增 `/portal-shell` 路由(单服务) | 新增路由 |
|
||
| `services/api-gateway/internal/config/config.go` | 新增 portal-shell 服务地址配置 | 新增配置 |
|
||
| `services/push-gateway/` | 无需变更(配置刷新改用 SWR 客户端轮询,不依赖 WebSocket 推送) | 复用 |
|
||
|
||
### 9.7 部署与基础设施
|
||
|
||
| 路径 | 变更 | 动作 |
|
||
| --------------------------------- | -------------------------------------------------------- | -------- |
|
||
| `infra/docker-compose.deploy.yml` | 新增 portal-shell 服务(单服务,无独立 widget 容器) | 新增服务 |
|
||
| `infra/port-allocation.md` | 新增 portal-shell 端口(4010) | 新增 |
|
||
| `infra/init-sql/01-init.sql` | 新增 IAM 6 张表的 DDL | 新增 |
|
||
| `apps/portal-shell/Dockerfile` | standalone 模式构建(单容器) | 新建 |
|
||
| `.github/workflows/ci.yml` | 新增 portal-shell 构建作业(单作业,无 widget 独立构建) | 修改 |
|
||
|
||
### 9.8 设计系统对接
|
||
|
||
| 路径 | 变更 | 动作 |
|
||
| ------------------------------------ | --------------------------------- | ----------- |
|
||
| `docs/standards/ui-design-system.md` | 新增"插件视觉规范"章节 | 补充章节 |
|
||
| `packages/ui-tokens/` | 无需变更 | 复用 |
|
||
| `packages/ui-components/` | 新增 PluginCard 等组件 | 新增组件 |
|
||
| `apps/portal-shell/src/styles/` | 复用现有 globals.css + tokens | 复用 |
|
||
| 插件内部样式 | 必须使用 @edu/ui-tokens,禁硬编码 | ESLint 强制 |
|
||
|
||
### 9.9 测试
|
||
|
||
| 路径 | 内容 | 动作 |
|
||
| -------------------------------------------- | --------------------------------------------------------------------------------- | ---- |
|
||
| `apps/portal-shell/vitest.config.ts` | Shell 单元测试(Shell/ConfigProvider/Registry/PluginLoader/EventBus/PropsMerger) | 新建 |
|
||
| `apps/portal-shell/src/widgets/*/__tests__/` | 每个插件单元测试 | 新建 |
|
||
| `tests/e2e/portal-shell.spec.ts` | E2E:登录 → 加载 layout → 渲染插件 → 切换 layout | 新建 |
|
||
| `tests/e2e/plugin-config.spec.ts` | E2E:admin 改配置 → 用户刷新生效 | 新建 |
|
||
| `tests/visual/portal-shell.spec.ts` | 视觉回归:5 种 layout 截图对比 | 新建 |
|
||
|
||
### 9.10 架构元数据
|
||
|
||
| 路径 | 变更 | 动作 |
|
||
| -------------------------------------------------- | -------------------------------------- | -------- |
|
||
| `scripts/arch-scan/` | 扫描 `apps/portal-shell`,更新 arch.db | 扫描更新 |
|
||
| `docs/architecture/004_architecture_impact_map.md` | 新增 portal-shell 模块章节 | 补充章节 |
|
||
| `docs/troubleshooting/known-issues.md` | 新增 portal-shell 分区 | 补充分区 |
|
||
| `apps/portal-shell/README.md` | Shell 架构文档 | 新建 |
|
||
|
||
### 9.11 Proto 契约
|
||
|
||
见 §6.2 `iam.proto` 新增 message 定义。
|
||
|
||
### 9.12 现有 4 个 Portal 处理
|
||
|
||
| 路径 | 处理 | 动作 |
|
||
| ---------------------- | --------------------------------------------- | -------- |
|
||
| `apps/teacher-portal/` | 保留并行运行,逐步迁移 | 保留 |
|
||
| `apps/student-portal/` | 保留并行运行 | 保留 |
|
||
| `apps/parent-portal/` | 保留并行运行 | 保留 |
|
||
| `apps/admin-portal/` | 保留并行运行 | 保留 |
|
||
| `apps/portal-shell/` | 新 Shell,路由前缀 `/shell/*` | 新建 |
|
||
| 迁移完成后 | 4 个旧 portal 下线,portal-shell 路由改为 `/` | 最终切换 |
|
||
|
||
---
|
||
|
||
## 10. 风险与缓解
|
||
|
||
| 风险 | 影响 | 缓解 |
|
||
| ----------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| 插件数量增长导致首屏 bundle 过大 | 首屏加载慢 | dynamic import 按需加载 + IntersectionObserver 滚动加载 + 首屏只加载可见 slot |
|
||
| 单体架构插件间隐式耦合 | 维护困难 | ESLint 禁止跨 widgets 目录 import + 架构扫描检测违规 + 强制通过 URL/Zustand 共享状态(禁止直接 import 其他插件) |
|
||
| 单角色专属功能(如备课画布)作为插件受 props 契约约束 | 复杂功能实现受限 | PluginProps 设计足够灵活(initialData + props 任意 JSON);复杂功能可在插件内部自行组织,仅对外暴露必要契约;跨插件状态通过 URL/Zustand 共享,不通过 props 注入 |
|
||
| IAM 配置查询压力 | RSC 每次请求查询 6 张表 | 复用 IAM 现有 cacheFn(ttl=60s)+ Redis 缓存;RSC 侧使用 React cache() 对同一请求内的重复查询去重;配置变更后 SWR 客户端轮询自然刷新 |
|
||
| 插件 props 三层合并逻辑复杂 | props 不一致 | PropsMerger 集中实现 + 单元测试覆盖;合并算法:深合并 + 数组覆盖 |
|
||
| admin 配置面板表单自动渲染 | propsSchema 复杂时表单体验差 | 基于 JSON Schema 的表单库(react-jsonschema-form 或自研)+ 支持 uiSchema 自定义 |
|
||
| 二期第三方插件沙箱隔离 | 安全风险 | 在单体应用中直接 script 注入第三方不信任代码等于交出主站最高权限(XSS、Token 窃取)。**MVP 不实现第三方插件**;二期必须采用以下方案之一:① **iframe + postMessage**(最安全,完全隔离 DOM/JS/CSS,通过 postMessage 通信,但样式/高度自适应复杂);② **QuickJS 隔离沙箱**(参考 Figma,在 Web Worker 中运行第三方 JS,通过 RPC 通信,性能好但实现复杂);③ **Shadow DOM + Proxy 限定 API 表面**(折中,样式隔离 + JS API 白名单代理,但安全性弱于 iframe)。**推荐 iframe + postMessage**,详见 §10.1 |
|
||
|
||
### 10.1 第三方插件沙箱方案(二期规划)
|
||
|
||
针对 v2.1 §5.4 中"二期第三方插件 script 注入加载"的风险,详细规划沙箱方案:
|
||
|
||
#### 方案对比
|
||
|
||
| 方案 | 隔离强度 | 性能 | 实现复杂度 | 通信方式 | 推荐度 |
|
||
| -------------------- | -------------- | ---------------------- | ---------- | ------------------ | ---------------------- |
|
||
| iframe + postMessage | 强(完全隔离) | 中(每个插件独立进程) | 中 | postMessage 序列化 | ✅ 推荐 |
|
||
| QuickJS Web Worker | 强(JS 隔离) | 好(Worker 并行) | 高 | RPC 代理 | △ 备选 |
|
||
| Shadow DOM + Proxy | 中(样式隔离) | 好(同进程) | 低 | 直接调用 + Proxy | ✗ 不推荐(安全性不足) |
|
||
| 动态 script 注入 | 弱(无隔离) | 好(同进程) | 低 | window 挂载 | ✗ 弃用 |
|
||
|
||
#### 推荐方案:iframe + postMessage
|
||
|
||
```typescript
|
||
// 二期:第三方插件通过 iframe 加载
|
||
// 1. 第三方插件 ZIP 包含 manifest.json + index.html + JS bundle
|
||
// 2. 后端解压到 /plugins/{pluginId}/ 静态服务
|
||
// 3. Shell 通过 iframe 加载 /plugins/{pluginId}/index.html
|
||
// 4. 通过 postMessage 通信,限定 API 表面
|
||
|
||
<iframe
|
||
src={`/plugins/${pluginId}/index.html?instanceId=${instanceId}`}
|
||
sandbox="allow-scripts" // 禁止 same-origin,强制跨域隔离
|
||
referrerPolicy="no-referrer"
|
||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||
/>
|
||
|
||
// Shell 侧 API 表面(通过 postMessage 暴露给第三方插件)
|
||
const SANDBOXED_API = {
|
||
'getPluginContext': () => ({ role, userId, classId, termId }),
|
||
'queryBFF': (query, vars) => bffClient.query(query, vars), // 代理 BFF 请求
|
||
'mutateBFF': (mutation, vars) => bffClient.mutate(mutation, vars),
|
||
'navigate': (url) => router.push(url), // 代理路由
|
||
'setHeight': (height) => resizeIframe(height), // 高度自适应
|
||
// 注意:禁止暴露 localStorage、cookies、window 对象
|
||
};
|
||
```
|
||
|
||
**安全约束:**
|
||
|
||
- iframe `sandbox="allow-scripts"` 禁止 same-origin,第三方无法访问主站 cookie/localStorage
|
||
- 所有 API 通过 postMessage 代理,白名单机制限定可调用 API
|
||
- BFF 请求由 Shell 代理(第三方不直接持有 JWT),Shell 可做权限校验
|
||
- CSP 头限制第三方插件只能加载自身域名的资源
|
||
|
||
---
|
||
|
||
## 11. 验收标准
|
||
|
||
### 11.1 功能验收
|
||
|
||
- [ ] 5 种 Layout 模板可切换,slot 正确渲染
|
||
- [ ] 7 张 universal 插件 + sidebar/topbar 插件 + 单角色专属插件加载正常
|
||
- [ ] admin 可通过 REST API 配置角色插件集合
|
||
- [ ] admin 可配置角色默认 Layout 模板
|
||
- [ ] admin 可配置插件默认 props(基于 propsSchema 自动渲染表单)
|
||
- [ ] admin 可查看/重置用户自定义布局
|
||
- [ ] 用户可切换 Layout 模板(在可用集内)
|
||
- [ ] 用户可隐藏/显示插件
|
||
- [ ] 用户可调整插件 props(在 propsSchema 允许范围内)
|
||
- [ ] admin 改配置 → SWR 静默刷新检测到变化 → Toast 提示用户刷新生效
|
||
- [ ] 插件加载失败显示错误兜底,不影响其他插件
|
||
- [ ] 跨插件状态共享正常(class-selector 切换班级 → grades-widget 自动响应)
|
||
- [ ] 插件 props 三层合并正确
|
||
- [ ] RSC 服务端预取正常:首屏 HTML 直出 Config + initialData
|
||
- [ ] useWidgetQuery 自动按 role 路由到对应 BFF
|
||
- [ ] URL Search Params 可分享、可前进后退
|
||
|
||
### 11.2 非功能验收
|
||
|
||
- [ ] `pnpm run lint` + `pnpm run typecheck` 零错误
|
||
- [ ] Shell 首屏 LCP < 2s(SSR 骨架 + client 水合 + 按需加载)
|
||
- [ ] 插件加载耗时 < 500ms(dynamic import 缓存命中后)
|
||
- [ ] 单元测试覆盖率 ≥ 80%
|
||
- [ ] E2E 测试通过
|
||
- [ ] 视觉回归测试通过(5 种 layout 截图)
|
||
- [ ] 所有插件遵守设计令牌(ESLint 强制)
|
||
- [ ] arch.db 更新,004 文档同步
|
||
|
||
---
|
||
|
||
## 12. 参考资料
|
||
|
||
- [CICD widget-configs.ts](file:///e:/Desktop/CICD/src/modules/dashboard/config/widget-configs.ts) - 单应用 widget 配置驱动参考
|
||
- [0010 架构蓝图](../../architecture/0010_architecture.md) §4 微前端 - 统一 UI 库
|
||
- [UI 设计系统](../../standards/ui-design-system.md) - 纸感编辑器设计风格
|
||
- [项目规则](../../.trae/rules/project_rules.md) - DDD 分层、设计令牌、多 AI 协作规范
|
||
- Halo 插件机制 - JAR 分发 · 扩展点注入 · 完整生命周期 · 插件市场参考
|
||
- [Next.js Dynamic Import](https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading) - dynamic import + ssr:false
|
||
- [Next.js Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) - RSC 服务端预取参考
|
||
- [Zustand](https://github.com/pmndrs/zustand) - React 全局状态管理(替代 EventBus)
|
||
- [SWR](https://swr.vercel.app/) - 数据请求 + 静默后台刷新(替代 WebSocket 推送)
|
||
- [Figma QuickJS 沙箱](https://www.figma.com/blog/an-update-on-plugin-security/) - 第三方插件隔离参考
|
||
|
||
---
|
||
|
||
## 13. 版本演进对比
|
||
|
||
| 维度 | v1.0(MF 微前端) | v2.0(Modular Monolith) | v2.1(React 单体哲学优化) |
|
||
| -------------- | --------------------------- | --------------------------- | ------------------------------------- |
|
||
| 架构 | MF 2.0 + 7 独立 widget 容器 | 单 Next.js + dynamic import | 单 Next.js App Router + RSC |
|
||
| 部署 | 8+ 个容器 | 1 个容器 | 1 个容器 |
|
||
| 加载 | 运行时 MF 远程加载 | 编译时打包 + dynamic import | RSC 服务端预取 + dynamic import |
|
||
| 首屏 | MF 远程加载慢 | CSR 瀑布流(4 层串行) | RSC 直出 Config + initialData(秒开) |
|
||
| 跨插件通信 | EventBus 发布订阅 | EventBus 发布订阅 | URL Search Params + Zustand |
|
||
| 配置刷新 | Kafka + WebSocket 推送 | Kafka + WebSocket 推送 | SWR 静默刷新(revalidateOnFocus) |
|
||
| BFF 请求 | 插件自行判断 role 路由 | 插件自行判断 role 路由 | useWidgetQuery 统一 Hook 自动路由 |
|
||
| 第三方插件沙箱 | script 注入(不安全) | script 注入(不安全) | iframe + postMessage(二期) |
|
||
| 复杂度 | 高 | 低 | 最低 |
|
||
| 旧 portal 迁移 | 并行运行 | 并行运行 | 并行运行 |
|