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

1192 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 声明 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 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 Schemaadmin 配置面板自动渲染表单。
**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 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 分享、浏览器前进后退**的全局上下文:
```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.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/` 中封装统一的 `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 切换时自动路由到对应 BFFuniversal 插件天然支持多角色)
- ✅ 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 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 接口扩展
```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 个 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
```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 代理(第三方不直接持有 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. 参考资料
- [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.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 迁移 | 并行运行 | 并行运行 | 并行运行 |