Files
Edu/docs/superpowers/specs/2026-07-14-portal-shell-widget-dashboard-design.md

70 KiB
Raw Blame History

Portal Shell 插件化仪表盘设计Modular Monolith + Micro-kernel

版本v2.1(应用 React 单体哲学优化URL 状态 + RSC 预取 + SWR 刷新 + 统一 Hook + 沙箱防御) 日期2026-07-14 状态:待评审 关联:


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 声明 propsSchemaadmin 配置默认 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   ← RSCServer Component│
│    ① 服务端调 IAM gRPC 获取 PluginConfigResponse              │
│    ② 服务端并发调 BFF 预取各插件 initialDataPromise.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 在 RSCServer 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 slotTopBar 区域,可插入多个 topbar 类插件logo / search / notification-bell / user-menu / locale-switcher
  • side slotSideNav 区域,可插入 sidebar 类插件class-selector / term-switcher / 导航项)
  • main slot:主内容区,可插入 universal / teacher / student / parent / admin 类插件
  • main-left / main-right slotsplit 模板的左右等分
  • right slottriple 模板的右侧栏
  • canvas-grid slotcanvas 模板的自由摆放区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 声明 propsSchemaJSON Schemaadmin 配置面板自动渲染表单。

props 合并优先级:用户调整 > 角色默认 > 系统默认

  • 系统默认:开发者在 plugin.manifest.tsdefaultProps 声明,构建时由 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 schemaJSON 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 携带 requiredShellVersionShell 启动时校验,不兼容则拒绝加载并提示 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.tsxServer 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 请求 HookData Fetching Abstraction

痛点universal 分类插件grades/homework/schedule 等)跨角色复用,如果每个插件自行判断角色并拼接 BFF URL会导致大量重复样板代码。

方案:在 src/lib/ 中封装统一的 useWidgetQueryuseWidgetMutation,内部自动读取 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 切换时自动路由到对应 BFFuniversal 插件天然支持多角色)
  • 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 Schemaadmin 配置面板用)
  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 };
}

生效路径:

  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:纸感卡片容器,强制设计令牌
  • PluginSkeleton5 种 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 个 portalteacher / 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 全局状态 schematheme/locale/sidebar 新增导出
packages/shared-ts/src/contracts/plugin-context.ts URL Search Params 上下文 schemaclassId/childId/termId/view 新增导出
packages/ui-components/src/ 新增 PluginCard / PluginSkeleton / PluginErrorFallback / SlotPlaceholder / PropsConfigForm 新增组件
packages/ui-tokens/src/ 无需变更(现有令牌足够) 复用
packages/hooks/src/ 新增 usePluginConfigSWR/ usePluginStoreZustand 新增 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 E2Eadmin 改配置 → 用户刷新生效 新建
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 现有 cacheFnttl=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 代理(第三方不直接持有 JWTShell 可做权限校验
  • 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 < 2sSSR 骨架 + client 水合 + 按需加载)
  • 插件加载耗时 < 500msdynamic import 缓存命中后)
  • 单元测试覆盖率 ≥ 80%
  • E2E 测试通过
  • 视觉回归测试通过5 种 layout 截图)
  • 所有插件遵守设计令牌ESLint 强制)
  • arch.db 更新004 文档同步

12. 参考资料


13. 版本演进对比

维度 v1.0MF 微前端) v2.0Modular Monolith v2.1React 单体哲学优化)
架构 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 迁移 并行运行 并行运行 并行运行