70 KiB
Portal Shell 插件化仪表盘设计(Modular Monolith + Micro-kernel)
版本:v2.1(应用 React 单体哲学优化:URL 状态 + RSC 预取 + SWR 刷新 + 统一 Hook + 沙箱防御) 日期:2026-07-14 状态:待评审 关联:
- 0010 架构蓝图 §4 微前端
- 004 架构影响地图
- UI 设计系统
- 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),存在以下问题:
- 拆分粒度错误:后端按"领域"拆微服务,前端按"角色"拆 4 个粗 portal,边界不对齐
- 重复度高:4 套独立 Dockerfile、i18n、auth-provider、layout、design tokens
- 复用性差:跨角色复用功能(grades / homework / schedule)在 4 个 portal 各实现一遍
- 耦合度高:每个 portal 既当 Shell 又内置业务,职责混淆
- 配置驱动缺失:角色变更需改 4 个 portal 代码,无法后台动态配置
- 扩展性差:新增功能需修改多个 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 设计目标
- 页面框架可配置:TopBar + SideNav + ContentArea 三 region,预置 5 种内置 Layout 模板,admin 配置角色默认,用户可切换
- 插件即卡片:所有功能单元统一为"插件"(含单角色专属功能如备课画布、错题本),分类管理,admin 可启用/禁用/配置
- 配置驱动可见性:admin 改配置 → 用户刷新生效,无需重新部署
- 声明式 props:插件通过 manifest 声明 propsSchema,admin 配置默认 props,用户可调整,三层合并
- 强隔离:插件间禁止直接 import,仅通过 EventBus 通信;插件与 Shell 仅通过 PluginProps 契约交互
- 设计系统一致:所有插件强制遵守纸感编辑器设计风格,使用 @edu/ui-tokens
- 单服务部署:一个 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 关键设计原则
- Shell 是纯宿主:只负责 Layout / Config / Registry / Loader,不含任何业务逻辑
- 插件即卡片:所有功能单元统一为"插件",无"卡片"与"插件"之分。含单角色专属功能(备课画布、错题本等)
- RSC 服务端预取:Shell 在 RSC(Server Component)中直接调 IAM gRPC 获取 Config,按需并发调 BFF 预取插件初始数据,Config + initialData 随 HTML 直出,客户端水合后 dynamic import 加载插件组件(initialData 已在 HTML 中),消除 CSR 瀑布流,实现仪表盘"秒开"
- dynamic import 懒加载:插件按需加载,首屏只加载可见 slot 的插件,非可见插件滚动到视口再加载
- 强隔离:插件间禁止直接 import,通过 URL Search Params + Zustand Store 共享状态;插件与 Shell 仅通过 PluginProps 契约交互
- 配置驱动:admin 改配置 → SWR 静默后台刷新(revalidateOnFocus + refreshInterval)→ Toast 提示用户刷新,无需重新部署(内置插件增删仍需重新构建)
- 单服务部署:一个 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。
// PropsMerger 三层合并(深合并 + 数组覆盖)
const finalProps = deepMerge(
registry.defaultProps, // 系统默认(plugin_registry 表)
roleMapping.widgetProps, // 角色默认(role_plugin_mapping 表)
userPlacement.props, // 用户调整(user_layout_override 表)
);
5. 插件契约
5.1 PluginProps 契约
// 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 分享、浏览器前进后退的全局上下文:
// 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() 自动响应,触发重新渲染
// 示例: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 状态的全局上下文:
// 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 直出。
// 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 查询语句。
// 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;
};
}
插件使用示例:
// 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 表结构
-- 插件注册表(系统级,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 接口扩展
// 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 静默后台刷新机制:
// 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 };
}
生效路径:
- Admin 通过 REST API 修改
plugin_registry/role_plugin_mapping/role_layout_default - 用户侧 SWR 静默刷新触发(切回 Tab / 5 分钟轮询 / 网络恢复)
- SWR 检测到配置变化,Toast 提示"发现新布局配置,点击刷新生效"
- 用户点击刷新或下次刷新页面时,RSC 重新预取配置生效
- 无需 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
// 二期:第三方插件通过 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 - 单应用 widget 配置驱动参考
- 0010 架构蓝图 §4 微前端 - 统一 UI 库
- UI 设计系统 - 纸感编辑器设计风格
- 项目规则 - DDD 分层、设计令牌、多 AI 协作规范
- Halo 插件机制 - JAR 分发 · 扩展点注入 · 完整生命周期 · 插件市场参考
- Next.js Dynamic Import - dynamic import + ssr:false
- Next.js Server Components - RSC 服务端预取参考
- Zustand - React 全局状态管理(替代 EventBus)
- SWR - 数据请求 + 静默后台刷新(替代 WebSocket 推送)
- Figma QuickJS 沙箱 - 第三方插件隔离参考
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 迁移 | 并行运行 | 并行运行 | 并行运行 |