P1: 31 widget 旧纸感令牌批量迁移到 shadcn 标准(1104 次替换) - bg-paper→bg-background / bg-surface→bg-card / text-ink→text-foreground - 保留 button.tsx 中 bg-accent(shadcn 标准 hover 语义令牌) P2: v2.0 新增组件单元测试补齐(5 文件 81 用例) - permission-bitmap: 24 用例(含 GRADE_READ 重复去重) - route-permissions: 26 用例(4 张表优先级 + AND/OR 语义) - notify: 12 用例(sonner toast 双重性质 vi.hoisted mock) - use-error-report: 9 用例(jsdom Blob vi.stubGlobal mock) - plugin-boundary: 10 用例(错误边界 + 骨架变体) P3: 错误上报端点生产替换(后端 /api/v1/log) - api-gateway: internal/log/handler.go(slog 结构化日志,64KB 限制,204 返回) - main.go: 注册 POST /api/v1/log 路由 - useErrorReport: 环境感知端点(prod→/api/v1/log,dev→/api/log) P4: E2E 测试(3 文件 30 用例) - streaming: 4 用例(React 19 use() + Suspense,act 包裹 render) - error-boundaries: 6 用例(三级错误边界层级 L1/L2/L3) - security-boundaries: 20 用例(L1 角色门禁 + L2 权限点 + L3 数据范围) - vitest setup: IS_REACT_ACT_ENVIRONMENT + jest-dom matchers 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由 / 206 测试全部通过
135 KiB
portal-shell 模块架构文档
版本:2.0 日期:2026-07-17 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0-P4 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功 / 206 测试全部通过) 架构范式:Modular Monolith + Micro-kernel(单 Next.js App Router 容器 + 插件化仪表盘) 关联文档:
- 004 架构影响地图 §1.2/§3/§4/§11.7/§16
- portal-shell 仪表盘 spec v2.1
- portal-shell 数据抽象与 GraphQL 加固 spec v1.0
- 项目规则 §3.10 设计令牌、§14 多 AI 协作
- UI 设计系统
- known-issues §1.11/§2.17
- GraphQL @auth 审计报告
目录
- 引言与目标
- 架构约束
- 系统范围与上下文(C4 L1)
- 解决方案策略
- 构建块视图(C4 L2/L3)
- 插件目录与分类
- 运行时视图(关键场景)
- 部署视图
- 横切概念
- 架构决策记录(ADR 索引)
- 质量要求与验收
- 风险、技术债与演进路线
- 数据访问层与 GraphQL 安全栈
- v2.0 安全边界与错误处理
- 术语表
1. 引言与目标
1.1 模块定位
portal-shell 是 Edu 平台 v2.1 架构重设计后的唯一前端入口,以单 Next.js App Router 容器替代 v1.0 的 4 个独立 portal(teacher / student / parent / admin)+ Module Federation 微前端方案。它承载教师、学生、家长、管理员四类角色的全部教学场景 UI,通过 Micro-kernel + 插件化 机制实现功能扩展。
- 服务端口:4010(HTTP)
- 技术栈(v2.0):
- 核心:TypeScript 5.6 + Next.js 16 App Router(Turbopack 默认)+ React 19(含
use()Hook 流式渲染) - 样式:Tailwind v4(
@import "tailwindcss"+@theme inline,无tailwind.config.js)+ shadcn/ui 标准令牌(--background/--foreground/--card/--primary等语义令牌) - 状态:Zustand(UI 状态)+ SWR(配置静默刷新)+ Apollo Client(GraphQL)
- 组件库:shadcn/ui(Radix UI + cva + tailwind-merge + clsx)
- 字体:Inter 单一字体族(
next/font/googleself-host →--font-interCSS 变量)
- 核心:TypeScript 5.6 + Next.js 16 App Router(Turbopack 默认)+ React 19(含
- 架构风格:Modular Monolith(单体)+ Micro-kernel(微内核插件)
- 部署形态:单 Docker 容器(
output: "standalone") - 上游依赖:api-gateway(JWT 校验 + 反向代理)、apollo-router(GraphQL 联邦入口)
- 下游契约:通过 apollo-router 查询 7 个业务子图(iam / config-service / core-edu / content / msg / data-ana / ai)
1.2 设计目标
| # | 目标 | 衡量标准 |
|---|---|---|
| G1 | 单服务部署:一个 Dockerfile、一个容器,无 MF 远程加载 | 容器数 = 1;无运行时远程 bundle |
| G2 | 配置驱动可见性:admin 改配置 → 用户刷新生效,无需重新部署 | 配置变更感知延迟 ≤ 5 分钟(SWR refreshInterval) |
| G3 | 首屏秒开:RSC 服务端预取 Config + initialData,消除 CSR 瀑布流 | LCP < 2s(本地 Docker) |
| G4 | 插件强隔离:插件间禁止直接 import,仅通过 URL/Zustand 共享状态 | ESLint no-restricted-imports 强制;arch:scan 违规检测 |
| G5 | 设计系统一致:所有插件使用 shadcn 标准令牌,禁硬编码颜色/字体/字号 | ESLint no-restricted-syntax + design-tokens/no-hardcoded-fonts 零违规 |
| G6 | 按需加载:dynamic import 懒加载,首屏只加载可见 slot 插件 | 首屏 JS bundle ≤ 300KB(gzip) |
| G7 | 跨角色复用:universal 插件按 role 渲染不同视图,一套代码服务多角色 | universal 插件复用率 100% |
| G8 | 流式渲染(v2.0):RSC 返回 Promise → React 19 use() 消费 + 三层 Suspense |
首屏 HTML 直出 Layout 骨架,Config 等数据流式注入 |
| G9 | 三级错误兜底(v2.0):Route → Section → Widget 三级 ErrorBoundary | 单插件崩溃不污染同 Slot 其他插件;整页崩溃有 Route 兜底 |
| G10 | 三层安全边界(v2.0):L1 角色门禁 / L2 权限点门禁 / L3 数据范围 | 路由级 + 插件级 + 数据范围三级过滤,权限位图压缩 JWT 体积 ≥ 99% |
| G11 | shadcn 标准化(v2.0):废弃纸感令牌(paper/ink/accent),统一 bg-card/text-foreground 等 | 所有新代码 100% 使用 shadcn 标准令牌;旧 widget 令牌迁移在 P1 完成 |
1.3 非目标
- 不重写后端微服务、BFF、网关
- 不实现插件拖拽编辑器(canvas 模板预留接口,MVP 不做拖拽)
- MVP 不实现第三方插件上传(二期,预留 iframe + postMessage 沙箱方案)
- 不实现插件市场在线商店(二期仅支持本地 ZIP 上传)
2. 架构约束
2.1 全局约束(来自项目规则)
| 约束 | 来源 | portal-shell 落地方式 |
|---|---|---|
禁硬编码颜色(#hex) |
project_rules §3.10 | ESLint no-restricted-syntax + shadcn Tailwind 类(bg-* / text-*) |
禁硬编码字体('Inter') |
project_rules §3.10 | next/font/google self-host → --font-inter CSS 变量 |
禁 Tailwind 任意值(w-[Npx]) |
project_rules §3.10 | 映射到 Tailwind 默认阶梯(p-4 / gap-2 / rounded-xl 等) |
| 函数返回值显式标注 | project_rules §3.4 | 所有插件 React.ReactElement / Promise<T> |
禁 any / as 断言 |
project_rules §3.4 | unknown + 类型守卫;测试外不写 as |
| Controller 权限装饰器 | project_rules §3.8 | portal-shell 无 Controller,权限由 api-gateway 注入 x-user-role |
ESM .js 后缀导入 |
project_rules §3.4 | next.config.js webpack extensionAlias 映射 |
| Tailwind v4 + shadcn 标准(v2.0) | project_rules §3.10 | @import "tailwindcss" + @theme inline,无 tailwind.config.js,使用 shadcn 语义令牌 |
2.2 模块边界(多 AI 协作)
- 只修改:
apps/portal-shell/目录 - 只读引用:
packages/shared-ts/src/contracts/(plugin.ts / layout.ts / plugin-store.ts / plugin-context.ts)packages/ui-components/、packages/ui-tokens/、packages/hooks/packages/shared-proto/proto/(仅查询字段命名对齐)
- 跨模块契约:通过 apollo-router GraphQL 子图(不直接调 gRPC,不直接访问业务服务 DB)
- 禁止:
- 直接 import 其他
apps/*或services/*的源码 - 直接访问 MySQL / Redis / Kafka
- 在插件间直接 import(必须通过 URL Search Params 或 Zustand Store 共享状态)
- 直接 import 其他
2.3 通信约束
| 调用方 → 被调用方 | 协议 | 场景 |
|---|---|---|
| portal-shell → api-gateway | HTTP 反向代理 | /api/v1/* 透传(JWT 校验 + 注入 x-user-id/x-user-role) |
| portal-shell → apollo-router | HTTP / GraphQL | 所有业务查询与配置查询(M8 验收点) |
| portal-shell → realtime-gateway | SSE | 通知推送(默认) |
| portal-shell → realtime-gateway | WebSocket | 监考双向场景(仅此场景用 WS) |
禁止:portal-shell 直连业务服务 gRPC 或 DB。
3. 系统范围与上下文(C4 L1)
3.1 Context 图
graph TB
subgraph Users["用户"]
Teacher["教师"]
Student["学生"]
Parent["家长"]
Admin["管理员"]
end
subgraph PortalShell["portal-shell :4010"]
Shell["插件化仪表盘<br/>Modular Monolith + Micro-kernel"]
end
subgraph Upstream["上游服务"]
Gateway["api-gateway :8080<br/>JWT 校验 + 反向代理"]
Router["apollo-router :3000<br/>GraphQL 联邦入口"]
Realtime["realtime-gateway :8081<br/>SSE 推送"]
end
subgraph Subgraphs["业务子图(经 Router 聚合)"]
IAM["iam"]
Config["config-service"]
CoreEdu["core-edu"]
Content["content"]
Msg["msg"]
DataAna["data-ana"]
AI["ai"]
end
Teacher --> Shell
Student --> Shell
Parent --> Shell
Admin --> Shell
Shell -->|HTTP /api/v1/*| Gateway
Shell -->|GraphQL 查询| Router
Shell -->|SSE 通知| Realtime
Gateway -->|JWT 校验 + 注入头| Shell
Router --> IAM
Router --> Config
Router --> CoreEdu
Router --> Content
Router --> Msg
Router --> DataAna
Router --> AI
3.2 外部契约
| 契约 | 提供方 | 消费方式 | 说明 |
|---|---|---|---|
pluginConfig(userId, role) |
config-service 子图 | RSC 服务端查询 + SWR 客户端静默刷新 | 三层合并后的插件配置 |
pluginRegistry / rolePluginMapping / layoutTemplates / roleLayoutDefault |
config-service 子图 | admin 配置面板(plugin-manager 插件) | admin CRUD |
resetUserLayoutOverride(userId) |
config-service 子图 | admin 重置用户布局 | mutation |
| 业务查询(grades / homework / schedule 等) | core-edu / content / msg 等子图 | 各 widget 插件 useWidgetQuery |
经 apollo-router 自动路由 |
x-user-id / x-user-role 请求头 |
api-gateway | RSC headers() 读取 |
JWT 校验后注入 |
x-user-permissions(v2.0 位图头) |
api-gateway | RSC headers() 读取 |
67 权限点 base36 压缩串 |
| JWT | iam 签发 → api-gateway 校验 | Apollo Client Authorization: Bearer |
localStorage + cookie 双通道 |
3.3 三层安全边界(v2.0 新增)
graph LR
subgraph Request["用户请求"]
R["路由进入<br/>/shell/*"]
end
subgraph L1["L1 角色门禁"]
L1Check["checkRoutePermission<br/>requiredRoles?"]
end
subgraph L2["L2 权限点门禁"]
L2Check["位图校验<br/>requiredPermissions?"]
end
subgraph L3["L3 数据范围"]
L3Check["DataScope 6 级<br/>config-service 过滤"]
end
subgraph Plugin["插件渲染"]
P["SlotRenderer 信任输入<br/>不再二次过滤"]
end
R --> L1Check
L1Check -->|角色通过| L2Check
L1Check -->|角色拒绝| Deny["403 Forbidden"]
L2Check -->|权限通过| L3Check
L2Check -->|权限拒绝| Deny
L3Check -->|数据范围过滤| Plugin
L3Check -->|无可见数据| Empty["空状态"]
三层职责矩阵:
| 层 | 位置 | 实现机制 | 触发时机 | 失败行为 |
|---|---|---|---|---|
| L1 角色门禁 | src/shared/lib/route-permissions.ts |
EXACT_ROUTE_PERMISSIONS / PREFIX_ROUTE_PERMISSIONS 等四张表按优先级匹配,requiredRoles?: Role[] |
路由进入(middleware 或 RSC) | 重定向 403 |
| L2 权限点门禁 | src/shared/lib/route-permissions.ts + @edu/shared-ts/permission-bitmap |
requiredPermissions?: string[](AND 语义)/ anyOfPermissions?: string[](OR 语义),从 JWT 头读取 base36 位图解码 |
路由进入 + 插件 Manifest 校验 | 重定向 403 或不渲染该插件 |
| L3 数据范围 | config-service GraphQL 子图 | DataScope 6 级(school / grade / class / subject / student / self),三层合并时过滤可见插件集 | RSC 服务端拉取 Config | 不渲染无可见数据的插件 |
关键约束:
- 路由权限配置集中管理:4 张表(精确路由 / 前缀路由 / 仪表盘路由 / API 路由)按优先级匹配,新增路由必须在表中登记
- L2 权限点必须来自 PERMISSION_BITMAP_ORDER(67 个权限点):开发时通过
validateRoutePermissionConfigs()校验合法性 - SlotRenderer 信任输入:L1/L2/L3 三层过滤在 RSC 服务端完成,客户端 SlotRenderer 不再二次过滤(性能优化)
- 权限位图压缩 JWT 体积:67 权限点 → base36 字符串(~14 字符)替代 JSON 数组(~600 字符),体积减少 ≥ 99%
- 批量检查 API:
batchCheckRoutePermission(paths, bitmap, role)用于侧边栏导航批量过滤
路由权限配置示例:
// src/shared/lib/route-permissions.ts
export const EXACT_ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
{
path: "/shell/admin/users",
requiredRoles: ["admin"],
requiredPermissions: ["user.read"], // AND 语义:必须同时拥有
},
{
path: "/shell/teacher/grades",
requiredRoles: ["teacher", "admin"],
anyOfPermissions: ["grade.read", "grade.write"], // OR 语义:拥有其一即可
},
];
export const PREFIX_ROUTE_PERMISSIONS: RoutePermissionConfig[] = [
{
path: "/shell/admin/",
requiredRoles: ["admin"],
},
];
4. 解决方案策略
4.1 核心策略:Micro-kernel + Plugin Registry
┌──────────────────────────────────────────────────────────┐
│ Shell(微内核,纯宿主,无业务逻辑) │
│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
│ │ LayoutMgr │ │ SlotRend │ │ PluginLoader │ │
│ │ 5 模板 │→ │ 按slot分发 │→ │ dynamic import │ │
│ └────────────┘ └────────────┘ │ + ErrorBoundary │ │
│ └────────────────────┘ │
│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
│ │ Registry │ │ PropsMerge │ │ PluginStore │ │
│ │ 31 插件 │ │ 三层合并 │ │ Zustand UI 状态 │ │
│ └────────────┘ └────────────┘ └────────────────────┘ │
└──────────────────────────────────────────────────────────┘
↑ PluginProps 契约 ↑
┌──────────────────────────────────────────────────────────┐
│ Widgets(31 内置插件,按 7 分类组织) │
│ universal / sidebar / topbar / teacher / student / ... │
└──────────────────────────────────────────────────────────┘
关键决策:
- Shell 是纯宿主:不含任何业务逻辑,只负责 Layout 框架、Config 下发、Registry 查表、Loader 挂载
- 插件即卡片:所有功能单元统一为"插件",无"卡片"与"插件"之分
- 编译时 Registry + 运行时 Config:Registry 是
plugin_id → dynamic import静态映射(编译时登记),Config 是"当前用户在哪些 slot 渲染哪些插件"的运行时配置(DB 三层合并) - RSC 服务端预取:Shell 在 Server Component 中完成 Config 拉取 + initialData 预取,随 HTML 直出
- dynamic import 懒加载:插件按需加载,首屏只加载可见 slot 的插件
- 强隔离:插件间禁止直接 import,通过 URL Search Params(可分享)+ Zustand(纯 UI)共享状态
4.2 数据流分层
URL Search Params ← 可分享、可前进后退的全局上下文(classId/childId/termId/view)
Zustand Store ← 纯 UI、不可分享(theme/locale/sidebarCollapsed)
插件内部 useState ← 插件私有状态(表单临时值、弹窗开关)
PluginProps.props ← 三层合并后的配置 props(系统默认 < 角色默认 < 用户调整)
PluginProps.initialData ← RSC 服务端预取的初始数据(SWR fallbackData)
4.3 配置刷新策略
抛弃 Kafka + WebSocket 推送链路(对低频布局变更属于过度设计),改用 SWR 静默后台刷新:
revalidateOnFocus: true:用户切回 Tab 时静默刷新refreshInterval: 300_000:每 5 分钟轮询dedupingInterval: 60_000:1 分钟内去重- 检测到配置变化时回调上层 Toast 提示"发现新布局配置,刷新后生效"
5. 构建块视图(C4 L2/L3)
5.1 Container 图(C4 L2)
graph TB
subgraph App["apps/portal-shell(单 Next.js 容器)"]
subgraph AppRouter["app/(Next.js App Router)"]
Layout["layout.tsx<br/>RootLayout + Inter 字体"]
RootPage["page.tsx<br/>重定向 /shell"]
ShellPage["shell/[[...route]]/page.tsx<br/>RSC 入口 + 流式 Promise"]
ShellError["shell/error.tsx<br/>Route 错误兜底(v2.0)"]
ShellLoading["shell/loading.tsx<br/>Route 加载骨架(v2.0)"]
HealthAPI["api/health/route.ts"]
ReadyAPI["api/ready/route.ts"]
LogAPI["api/log/route.ts<br/>错误上报 mock 端点(v2.0)"]
end
subgraph ShellCore["shell/(微内核)"]
Shell["Shell.tsx"]
ClientShell["ClientShell.tsx<br/>use(configPromise) 流式"]
LayoutMgr["LayoutManager.tsx<br/>5 Layout 模板"]
SlotRend["SlotRenderer.tsx<br/>PluginBoundary 包裹"]
Loader["PluginLoader.tsx<br/>re-export 向后兼容"]
Registry["Registry.tsx<br/>31 插件"]
Lifecycle["PluginLifecycle.ts"]
Store["PluginStore.ts<br/>Zustand"]
Merger["PropsMerger.ts<br/>三层合并"]
end
subgraph Lib["lib/(数据抽象)"]
Apollo["apollo-client.ts<br/>APQ + sha256"]
ConfigFetch["config-fetcher.ts"]
UseConfig["usePluginConfig.ts<br/>SWR 静默刷新"]
UseQuery["useWidgetQuery.ts"]
UseMut["useWidgetMutation.ts"]
Types["types.ts"]
end
subgraph ApiLayer["lib/api/(数据访问层)"]
ApiDomain["<domain>.ts ×7<br/>parent/teacher/admin/student/<br/>universal/sidebar/topbar"]
ApiOps["operations/*.graphql.ts<br/>51 DocumentNode"]
ApiTypes["operations/types.ts<br/>codegen 生成"]
ApiErrors["errors.ts<br/>ApiError 归一化"]
end
subgraph Shared["shared/(v2.0 共享层)"]
SharedLib["shared/lib/<br/>route-permissions.ts<br/>notify.ts / utils.ts"]
SharedComp["shared/components/<br/>plugin-boundary.tsx<br/>route-error-boundary.tsx<br/>section-error-boundary.tsx<br/>dashboard/* / layout/* / ui/*"]
SharedHooks["@edu/hooks<br/>use-error-report.ts"]
end
subgraph Providers["providers/"]
ApolloProv["ApolloProvider.tsx"]
AuthProv["AuthProvider.tsx"]
ThemeProv["ThemeI18nProvider.tsx"]
end
subgraph Widgets["widgets/(31 内置插件)"]
Universal["universal/<br/>7 插件"]
Sidebar["sidebar/<br/>4 插件"]
Topbar["topbar/<br/>4 插件"]
Teacher["teacher/<br/>4 插件"]
Student["student/<br/>4 插件"]
Parent["parent/<br/>2 插件"]
Admin["admin/<br/>6 插件"]
end
end
ShellPage -->|RSC 调用| ConfigFetch
ShellPage -->|configPromise| ClientShell
ClientShell -->|use()| Shell
Shell --> LayoutMgr
LayoutMgr --> SlotRend
SlotRend --> SharedComp
SlotRend --> Registry
SharedComp --> Widgets
ClientShell --> UseConfig
UseConfig --> Apollo
Widgets --> ApiDomain
ApiDomain --> ApiOps
ApiDomain --> UseQuery
ApiDomain --> UseMut
ApiOps --> ApiTypes
UseQuery --> Apollo
UseMut --> Apollo
SharedComp -.->|onError| LogAPI
SharedLib -.->|权限校验| SharedLib
5.2 组件职责矩阵
| 层 | 文件 | 职责 | 关键契约 |
|---|---|---|---|
| app/ | shell/[[...route]]/page.tsx |
RSC 入口,服务端拉 Config + 流式注入 Promise | fetchPluginConfig(userId, role)(v2.0 不 await,直接传 Promise) |
| app/ | shell/error.tsx(v2.0) |
Route 级错误兜底 | Next.js error.tsx + RouteErrorBoundary |
| app/ | shell/loading.tsx(v2.0) |
Route 级加载骨架 | 整页骨架(顶栏 + 侧栏 + 主区仪表盘骨架) |
| app/ | api/log/route.ts(v2.0) |
错误上报 mock 端点 | POST sendBeacon 数据,开发态日志输出 |
| app/ | layout.tsx |
RootLayout,挂载 next/font/google Inter 字体变量 |
--font-inter CSS 变量 |
| app/ | page.tsx |
根路径重定向到 /shell |
redirect("/shell") |
| shell/ | Shell.tsx |
微内核入口,选择 LayoutManager 模板 | config.activeLayout.layoutId |
| shell/ | ClientShell.tsx |
客户端入口,use(configPromise) 流式 + SWR 刷新 |
ShellContent + LegacyShell 双模式(v2.0) |
| shell/ | LayoutManager.tsx |
5 种 Layout 模板渲染器 | classic / focus / split / triple / canvas |
| shell/ | SlotRenderer.tsx |
按 slot 过滤+排序+查表+PluginBoundary 包裹 | PluginPlacement[](v2.0 信任输入,不再二次过滤权限) |
| shell/ | PluginLoader.tsx |
re-export 文件,向后兼容(v2.0 已迁移到 PluginBoundary) | PluginSkeleton 5 变体 |
| shell/ | Registry.tsx |
编译时登记 31 内置插件 | plugin_id → { Component, metadata } |
| shell/ | PluginLifecycle.ts |
版本兼容性 + 激活路径 + 可渲染判断 | checkVersionCompatibility / isPluginRenderable |
| shell/ | PluginStore.ts |
Zustand 全局 UI 状态 | theme / locale / sidebarCollapsed |
| shell/ | PropsMerger.ts |
三层 props 深合并 | mergeProps(sys, role, user) |
| shared/lib/ | route-permissions.ts(v2.0) |
路由权限配置表 + L1/L2 检查 | checkRoutePermission / batchCheckRoutePermission / validateRoutePermissionConfigs |
| shared/lib/ | notify.ts(v2.0) |
统一 Toast 封装(禁止业务直接 import sonner) | notify.info/success/warning/error |
| shared/lib/ | utils.ts(v2.0) |
cn() 工具函数(clsx + tailwind-merge) |
shadcn/ui 标准工具 |
| shared/components/ | plugin-boundary.tsx(v2.0) |
插件级错误边界 + Suspense + Skeleton 三件套 | PluginBoundary / PluginSkeleton(5 变体)/ PluginErrorFallback |
| shared/components/ | route-error-boundary.tsx(v2.0) |
Route 级错误边界 | Next.js error.tsx 内部使用 |
| shared/components/ | section-error-boundary.tsx(v2.0) |
Section 区块级错误边界 | DashboardSection 内部使用 |
| shared/components/dashboard/ | dashboard-shell.tsx / dashboard-section.tsx(v2.0) |
仪表盘外壳 + 分区组件 | 仪表盘场景专用 |
| shared/components/layout/ | sidebar-provider.tsx / app-sidebar.tsx / site-header.tsx(v2.0) |
布局组件 | SidebarContext + 桌面折叠 + 移动 Sheet |
| shared/components/ui/ | button.tsx / card.tsx / badge.tsx / skeleton.tsx / input.tsx / tooltip.tsx / sonner.tsx / page-header.tsx / stat-card.tsx / stats-grid.tsx / empty-state.tsx / filter-bar.tsx(v2.0) |
shadcn/ui 组件库 | 基于 Radix UI + cva + tailwind-merge |
| lib/ | apollo-client.ts |
Apollo Client 单例(客户端)+ 工厂(服务端)+ APQ 链 | getApolloClient() / createApolloClient(),createPersistedQueryLink({ sha256 }) |
| lib/ | config-fetcher.ts |
RSC 服务端拉 Config,含三级降级 | apollo-router → config-service 直连 → 空默认 |
| lib/ | usePluginConfig.ts |
SWR 静默刷新配置 + 变更检测回调 | revalidateOnFocus + refreshInterval: 300_000 |
| lib/ | useWidgetQuery.ts |
统一 GraphQL 查询 Hook,支持 fallbackData | useQuery + skip + pollInterval |
| lib/ | useWidgetMutation.ts |
统一 GraphQL 变更 Hook | useMutation + errorPolicy: "all" |
| lib/ | types.ts |
共享类型契约 | PluginProps / PluginManifest / PluginConfigResponse |
| lib/api/ | <domain>.ts ×7 |
语义化函数 API 层 | useMyChildren() / saveLessonPlan() 等,禁止 widget 直接写 gql |
| lib/api/ | operations/*.graphql.ts |
gql DocumentNode 集中存放 | 51 个 query/mutation 常量 |
| lib/api/ | operations/types.ts |
codegen 生成的 TS 类型 | 从 7 子图 schema.graphql 生成 |
| lib/api/ | errors.ts |
ApiError 归一化 | toApiError(graphQLErrors) 统一错误模型 |
| providers/ | ApolloProvider.tsx |
注入 Apollo Client 单例 | getApolloClient(readToken) |
| providers/ | AuthProvider.tsx |
提供 useAuth(),从 RSC props 下发用户身份 |
AuthUser context |
| providers/ | ThemeI18nProvider.tsx |
主题类名同步到 <html> + 简易 i18n |
usePluginStore theme/locale |
| widgets/ | */index.tsx |
插件入口,接收 PluginProps,default export |
PluginProps 契约 |
| widgets/ | */plugin.manifest.ts |
插件元数据声明 | manifestMeta: Omit<PluginManifest, "Component">(v2.0 含 requiredPermissions?) |
5.3 Layout 模板清单
| layoutId | 显示名 | 布局 | 可用 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 + 自由摆放(MVP 按 grid 排列,不实现拖拽) | top / canvas-grid | 个性化 |
5.4 三层配置模型
优先级:用户覆盖 > 角色模板 > 系统默认
Layer 1: 系统默认(plugin_registry 表)
← 开发者在 plugin.manifest.ts 声明 defaultProps
← 构建时由 sync-builtin-plugins.ts 同步到 DB
Layer 2: 角色模板(role_plugin_mapping 表)
← admin 通过 plugin-manager 插件配置角色可用插件集 + 角色级 props
Layer 3: 用户覆盖(user_layout_override 表)
← 用户切换 Layout、调整插件位置、隐藏插件、调整 props
← 仅限角色可用集内操作
Props 合并算法(PropsMerger.mergeProps):
- 普通对象递归合并(深合并)
- 数组、原始值后者覆盖前者
null/undefined跳过
5.5 流式渲染(v2.0 新增)
来源:React 19 use() + Suspense 流式渲染 关联:
apps/portal-shell/src/app/shell/[[...route]]/page.tsx+apps/portal-shell/src/shell/ClientShell.tsx
sequenceDiagram
participant U as 用户浏览器
participant N as Next.js RSC
participant R as apollo-router
participant C as config-service
U->>N: GET /shell
N->>N: headers() 读取 x-user-id / x-user-role / x-user-permissions
N->>R: query pluginConfig(userId, role)【不 await】
Note over N: RSC 返回 Promise,立即返回 HTML 骨架
N-->>U: HTML 流式输出(layout 骨架 + Suspense 占位)
Note over U: 浏览器开始渲染骨架,不阻塞
R->>C: 路由到 config-service
C-->>R: 三层合并后的 PluginConfigResponse
R-->>N: Config Promise resolve
N->>N: use(configPromise) 解析
N-->>U: 流式注入 Config 后的内容(SlotRenderer + PluginBoundary)
Note over U: 插件按 dynamic import 流式加载(PluginBoundary 内 Suspense)
U->>U: 每个插件独立 Suspense,加载完成后逐个渲染
三层 Suspense 边界:
| 层 | 位置 | Suspense fallback | 触发场景 |
|---|---|---|---|
| Route | shell/[[...route]]/page.tsx |
shell/loading.tsx 整页骨架 |
Config Promise 未 resolve |
| Slot | LayoutManager.tsx 内 |
Slot 级骨架(按 slot 类型推导) | 整个 slot 数据未就绪 |
| Widget | PluginBoundary 内 |
PluginSkeleton 5 变体 |
单插件 dynamic import 未完成 |
ClientShell 流式拆分:
// apps/portal-shell/src/shell/ClientShell.tsx
function ShellContent({ configPromise, userId, role }: ShellContentProps): ReactNode {
// use() 在 Suspense 边界内消费 Promise,自动暂停直到 resolve
const resolvedConfig = use(configPromise);
// SWR 后续静默刷新
const { config: liveConfig } = usePluginConfig({
initialConfig: resolvedConfig,
userId, role,
onChanged: () => { notify.info("发现新布局配置,刷新后生效"); },
});
return <Shell config={liveConfig} userId={userId} role={role} />;
}
export function ClientShell(props: ClientShellProps): ReactNode {
return (
<ApolloProvider>
<AuthProvider user={props.user}>
<ThemeI18nProvider>
{/* Suspense 包裹 ShellContent,Provider 不被暂停 */}
<Suspense fallback={<RouteSkeleton />}>
<ShellContent {...props} />
</Suspense>
</ThemeI18nProvider>
</AuthProvider>
</ApolloProvider>
);
}
关键约束:
- Provider 必须在 Suspense 外:ApolloProvider / AuthProvider / ThemeI18nProvider 不能被
use()暂停,否则子树丢失 Context - RSC 不 await Promise:
fetchPluginConfig返回 Promise 直接传给客户端,服务端不阻塞 - 客户端
use()消费:React 19 的use()Hook 可在 Suspense 边界内消费 Promise - 单插件独立 Suspense:每个插件用
<PluginBoundary>包裹,加载失败或慢不影响其他插件
5.6 三级错误处理(v2.0 新增)
关联:
shared/components/route-error-boundary.tsx/section-error-boundary.tsx/plugin-boundary.tsx+@edu/hooks/use-error-report.ts
graph TB
subgraph Route["Route 级(最高优先级)"]
REB["RouteErrorBoundary<br/>app/shell/error.tsx"]
RFallback["整页错误页<br/>含错误 digest + 重试按钮"]
end
subgraph Section["Section 级"]
SEB["SectionErrorBoundary<br/>DashboardSection 内"]
SFallback["区块级错误卡片<br/>显示 Section 标题 + 重试"]
end
subgraph Widget["Widget 级(最细粒度)"]
WEB["PluginBoundary<br/>SlotRenderer 内每个插件"]
WFallback["PluginErrorFallback<br/>显示 instanceId + 重试"]
end
subgraph Report["错误上报链路"]
Hook["useErrorReport<br/>sendBeacon + sessionStorage 节流"]
API["/api/log<br/>mock 端点"]
Store["sessionStorage<br/>1 分钟同 digest 去重"]
end
REB --> RFallback
SEB --> SFallback
WEB --> WFallback
RFallback -.->|onError| Hook
SFallback -.->|onError| Hook
WFallback -.->|onError| Hook
Hook --> Store
Hook -->|sendBeacon| API
三级职责矩阵:
| 级别 | 组件 | 位置 | 触发场景 | Fallback |
|---|---|---|---|---|
| Route | RouteErrorBoundary |
app/shell/error.tsx |
整页崩溃(Shell 渲染失败、Provider 错误) | 整页错误页 + digest + 重试按钮 |
| Section | SectionErrorBoundary |
DashboardSection 内 |
区块级崩溃(Slot 渲染失败、聚合数据错误) | 区块级错误卡片(含 Section 标题) |
| Widget | PluginBoundary |
SlotRenderer 内每个插件 |
单插件崩溃(dynamic import 失败、组件抛错) | PluginErrorFallback(instanceId + 重试) |
错误上报链路:
// @edu/hooks/use-error-report.ts
export function useErrorReport() {
return useCallback((error: Error, context?: Record<string, unknown>) => {
const digest = computeDigest(error); // sha256(message + stack)
const key = `err:${digest}`;
// 1. sessionStorage 节流:1 分钟内同 digest 不重复上报
if (sessionStorage.getItem(key)) return;
sessionStorage.setItem(key, String(Date.now() + 60_000));
// 2. sendBeacon 异步上报(页面卸载也能发出)
navigator.sendBeacon(
"/api/log",
JSON.stringify({
level: "error",
message: error.message,
stack: error.stack,
digest,
url: location.href,
timestamp: Date.now(),
context,
}),
);
}, []);
}
关键约束:
- 三级边界不可跳过:每个 widget 必须用
<PluginBoundary>包裹,Section 必须用<SectionErrorBoundary>,Route 必须有error.tsx - 错误上报节流:
sessionStorage1 分钟同 digest 去重,避免崩溃循环刷爆日志端点 - sendBeacon 优先:页面卸载时也能发出请求,不阻塞 unload
- digest 唯一标识:基于
message + stack的 sha256,便于后端聚合相同错误 - mock 端点:
/api/log当前为 Next.js API Route,仅开发态日志输出,生产由后端/api/v1/log替换
6. 插件目录与分类
6.1 内置插件全览(31 个)
| 分类 | 数量 | 插入 slot | 跨角色 | 插件清单 |
|---|---|---|---|---|
universal |
7 | main-* | 是 | grades-widget / homework-widget / schedule-widget / attendance-widget / exams-widget / notifications-widget / announcements-widget |
sidebar |
4 | side | 部分 | class-selector / child-selector / term-switcher / quick-actions |
topbar |
4 | top | 是 | notification-bell / user-menu / global-search / locale-switcher |
teacher |
4 | main | 否 | lesson-plan-editor / question-bank / textbook-manager / scheduling-rules |
student |
4 | main | 否 | error-book / learning-path / elective-selector / ai-tutor |
parent |
2 | main | 否 | child-overview / leave-approval |
admin |
6 | main | 否 | user-management / rbac-manager / plugin-manager / school-settings / audit-logs / invitation-codes |
6.2 插件契约(PluginProps)
interface PluginProps<TProps = Record<string, unknown>> {
instanceId: string; // 插件实例 ID(同插件多实例时区分)
role: "admin" | "teacher" | "student" | "parent";
user: {
id: string;
name: string;
email: string;
dataScope: string; // IAM DataScope 6 级
};
slot: {
name: string; // 'main' | 'side' | 'top' | 'main-left' | ...
layoutId: string; // 'classic' | 'focus' | ...
size?: { colSpan: number; rowSpan: number };
};
props: TProps; // 三层合并后的最终 props
initialData?: unknown; // RSC 服务端预取的初始数据
}
6.3 插件 Manifest 契约
每个插件通过 plugin.manifest.ts 声明元数据(v2.0 新增 requiredPermissions 字段):
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "grades-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0", // semver range,Shell 启动时校验
metadata: {
displayName: "成绩",
description: "查看班级成绩",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
requiredPermissions: ["grade.read"], // v2.0 新增:L2 权限点门禁(AND 语义)
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: { limit: 20 },
propsSchema: {
// JSON Schema,admin 配置面板自动渲染表单
type: "object",
properties: {
limit: { type: "number", description: "显示条数" },
},
},
},
};
v2.0 requiredPermissions 字段说明:
- 空数组或 undefined:仅 L1 角色门禁生效
- 非空数组:用户必须同时拥有所有权限点(AND 语义)
- 权限点必须来自
PERMISSION_BITMAP_ORDER(67 个权限点),运行时由isValidPermission校验 - 配合路由权限表:路由级 L2 由
route-permissions.ts检查,插件级 L2 由 Manifest 检查(config-service 三层合并时过滤)
6.4 插件生命周期
| 阶段 | 内置插件 | 第三方插件(二期) |
|---|---|---|
registered |
编译时登记到 Registry | 安装时登记到 DB(plugin_packages 表) |
enabled |
admin 通过 config-service 启用 | admin 启用 |
loaded |
dynamic import 加载 | iframe + postMessage 沙箱加载 |
active |
渲染并挂载 | 渲染并挂载 |
disabled |
admin 禁用,不渲染 | admin 禁用,不渲染 |
uninstalled |
不可卸载(内置) | admin 卸载,删除包 |
7. 运行时视图(关键场景)
7.1 场景一:首屏加载(RSC 服务端预取 + 流式渲染)
目标:消除 CSR 瀑布流,实现仪表盘"秒开",首屏 HTML 直出骨架
sequenceDiagram
participant U as 用户浏览器
participant N as Next.js RSC
participant R as apollo-router
participant C as config-service
participant S as 业务子图
U->>N: GET /shell
N->>N: headers() 读取 x-user-id / x-user-role / x-user-permissions
N->>R: query pluginConfig(userId, role)【不 await,返回 Promise】
Note over N: RSC 立即返回 HTML 骨架 + Suspense 占位
N-->>U: HTML 流式输出(layout 骨架)
Note over U: 浏览器开始渲染骨架,不阻塞
R->>C: 路由到 config-service 子图
C-->>R: 三层合并后的 PluginConfigResponse
R-->>N: Config Promise resolve
N->>N: PropsMerger 解析 propsJson / sizeJson
N-->>U: 流式注入 SlotRenderer + PluginBoundary
Note over U: 每个插件独立 Suspense,dynamic import 流式加载
U->>U: 插件用 initialData 作为 SWR fallbackData 渲染
U->>U: 加载完成的插件立即渲染,不影响其他插件
对比 v1.0 CSR 瀑布流:
v1.0(4 层串行,LCP 差):
HTML 骨架 → 水合 → 拉 Config → import 插件 → 插件拉数据 → 渲染
总耗时 = SSR + 水合 + Config RTT + import RTT + BFF RTT
v2.0 RSC 流式渲染(首屏秒开 + 流式注入):
服务端:RSC 返回 Promise → 立即输出 HTML 骨架 → Config resolve 后流式注入
客户端:水合骨架 → use(configPromise) 解析 → dynamic import → 用 initialData 渲染
总耗时 = max(骨架渲染, Config RTT) + 水合 + import RTT
收益:用户提前看到骨架,感知性能大幅提升
7.2 场景二:配置变更生效(SWR 静默刷新)
sequenceDiagram
participant A as Admin
participant PM as plugin-manager 插件
participant R as apollo-router
participant C as config-service
participant U as 用户浏览器
participant SWR as SWR 缓存
A->>PM: 修改角色插件映射
PM->>R: mutation updateRolePluginMapping
R->>C: 路由到 config-service
C-->>R: 更新成功
R-->>PM: 返回结果
Note over U,SWR: 用户侧(异步感知)
U->>U: 切回 Tab(revalidateOnFocus)<br/>或 5 分钟轮询触发
SWR->>R: query pluginConfig(userId, role)
R->>C: 路由到 config-service
C-->>R: 新配置
R-->>SWR: 返回新配置
SWR->>SWR: hasConfigChanged(prev, next) 检测变化
SWR->>U: 回调 onChanged → Toast 提示
U->>U: 用户点击"刷新" → window.location.reload()
U->>N: 重新走 RSC 预取流程,加载新配置
7.3 场景三:跨插件状态共享(URL 驱动)
示例:class-selector 切换班级 → grades-widget 自动响应
sequenceDiagram
participant CS as class-selector 插件
participant URL as URL Search Params
participant GW as grades-widget 插件
participant R as apollo-router
CS->>CS: 用户选择班级 "三年二班"
CS->>URL: router.push('?classId=cls-123')
Note over URL: URL 变更触发 React 重渲染
URL->>GW: useSearchParams() 返回新 classId
GW->>GW: useWidgetQuery(GET_GRADES, { classId })
GW->>R: query grades(classId: "cls-123")
R-->>GW: 返回成绩数据
GW->>GW: 重新渲染表格
收益:
- 无 EventBus 发布订阅黑盒
- URL 可分享(用户可复制链接定位特定班级视图)
- 浏览器前进后退天然支持
- React DevTools 可追踪状态变更
7.4 场景四:插件加载失败(PluginBoundary 三件套隔离)
graph TB
A[SlotRenderer 渲染插件] --> B[PluginBoundary 包裹]
B --> C{dynamic import + 渲染成功?}
C -- 是 --> D[组件渲染]
C -- 否 --> E[ErrorBoundary 捕获]
E --> F[PluginErrorFallback 显示]
F --> G[显示错误图标 + instanceId]
F --> H[重试按钮]
H --> I[重置 hasError=false 重新加载]
E -.->|onError| J[useErrorReport 上报]
J --> K[sendBeacon → /api/log]
L[同 Slot 其他插件] -.->|不受影响| M[正常渲染]
N[其他 Slot] -.->|不受影响| O[正常渲染]
P[整页] -.->|Route 兜底| Q[RouteErrorBoundary]
关键约束:
- 单个插件失败只影响自身,同 Slot 其他插件 / 其他 Slot / 整页均不受影响
- 三级错误边界(Route → Section → Widget)层层兜底,最坏情况整页崩溃也有
app/shell/error.tsx兜底 - 错误自动上报到
/api/log(开发态 mock)//api/v1/log(生产态,待后端实现) sessionStorage节流避免崩溃循环刷爆日志端点
8. 部署视图
8.1 容器化
graph LR
subgraph Build["构建阶段"]
Src["源码 apps/portal-shell/"]
Docker["Dockerfile<br/>multi-stage"]
Img["本地镜像<br/>(不推送 registry)"]
end
subgraph Runtime["运行时(Docker Compose)"]
Container["portal-shell 容器 :4010"]
Next["Next.js standalone server"]
end
subgraph Deps["依赖容器"]
Gateway["api-gateway :8080"]
Router["apollo-router :3000"]
Realtime["realtime-gateway :8081"]
end
Src --> Docker
Docker --> Img
Img --> Container
Container --> Next
Next -->|HTTP /api/v1/*| Gateway
Next -->|GraphQL| Router
Next -->|SSE| Realtime
8.2 Dockerfile 要点
- 基础镜像:
node:22-alpine(CI 预拉,见infra/docker-compose.tools.yml) - 构建模式:
output: "standalone"(Next.js 内置,自动追踪依赖,产物在.next/standalone/) - 多阶段构建:
deps→builder→runner - 依赖处理:
COPY tsconfig.base.json*确保 TS 类型解析transpilePackages: ["@edu/ui-components", "@edu/ui-tokens", "@edu/hooks", "@edu/shared-ts"]编译 workspace 包pnpm install --no-frozen-lockfile解决 workspace 符号链接问题
- 端口:4010
- 健康检查:
/api/health(liveness)+/api/ready(readiness)
8.3 环境变量
| 变量 | 用途 | 默认值 |
|---|---|---|
NEXT_PUBLIC_APOLLO_ROUTER_URL |
apollo-router GraphQL 端点(客户端) | http://localhost:3000/graphql |
APOLLO_ROUTER_URL |
apollo-router GraphQL 端点(服务端 RSC) | 同上 |
API_GATEWAY_URL |
api-gateway 反向代理目标 | http://localhost:8080 |
CONFIG_SERVICE_URL |
开发态降级:直连 config-service GraphQL(生产不应设置) | 未设置 |
NEXT_PUBLIC_DEV_MODE |
开发模式:绕过 JWT,接受 dev-token + 预定义角色 |
false |
8.4 CI/CD
- 流水线:
.github/workflows/ci.yml的quality-tsjob - 触发:分支 push / PR(质量检查)、合并到 main(部署)
- 质量门禁:
pnpm lint+tsc --noEmit+vitest run+next build - 部署:
docker compose up -d --build(no-push 本地构建模式,见 project_rules §15) - 回滚:
git revert + push或workflow_dispatch指定commit_sha
9. 横切概念
9.1 设计令牌(shadcn 标准化,v2.0)
三层令牌模型(project_rules §3.10):
| Layer | 用途 | 位置 | 业务代码引用 |
|---|---|---|---|
| L1 Primitive | 原始色板/字号/间距/阴影 | packages/ui-tokens/src/primitive.css |
❌ 禁止直接引用 |
| L2 Semantic | shadcn 标准语义令牌(light/dark) | packages/ui-tokens/src/semantic-light.css / semantic-dark.css |
✅ 唯一引用入口(hsl(var(--*))) |
| L3 Tailwind Theme | @theme inline 暴露为 Tailwind 类 |
packages/ui-tokens/src/tailwind-theme.css |
✅ bg-background / bg-card / text-foreground / border / rounded-xl 等 |
shadcn 标准令牌清单(v2.0 废弃纸感命名 paper/ink/accent/rule):
| 语义令牌 | Light 值 | Dark 值 | Tailwind 类 | 用途 |
|---|---|---|---|---|
--background |
0 0% 100% |
240 10% 3.9% |
bg-background |
页面背景 |
--foreground |
240 10% 3.9% |
0 0% 98% |
text-foreground |
主文本 |
--card |
0 0% 100% |
240 10% 3.9% |
bg-card |
卡片背景 |
--card-foreground |
240 10% 3.9% |
0 0% 98% |
text-card-foreground |
卡片内文本 |
--primary |
240 5.9% 10% |
0 0% 98% |
bg-primary / text-primary |
主要按钮/强调 |
--primary-foreground |
0 0% 98% |
240 5.9% 10% |
text-primary-foreground |
主要按钮上文本 |
--muted |
240 4.8% 95.9% |
240 3.7% 15.9% |
bg-muted |
静默背景(旧 bg-subtle) |
--muted-foreground |
240 3.8% 46.1% |
240 5% 64.9% |
text-muted-foreground |
静默文本(旧 text-ink-muted) |
--border |
240 5.9% 90% |
240 3.7% 15.9% |
border |
边框(旧 border-rule) |
--destructive |
0 84.2% 60.2% |
0 62.8% 30.6% |
bg-destructive / text-destructive |
危险/错误 |
--radius |
0.5rem |
0.5rem |
rounded-xl / rounded-md |
圆角(旧 rounded-card) |
令牌迁移映射表(v1.x → v2.0):
| v1.x 纸感令牌 | v2.0 shadcn 标准 |
|---|---|
bg-paper |
bg-background |
bg-surface |
bg-card |
bg-subtle |
bg-muted |
bg-accent |
bg-primary |
bg-accent-hover |
bg-primary/90 |
text-ink |
text-foreground |
text-ink-muted |
text-muted-foreground |
text-ink-on-accent |
text-primary-foreground |
text-ink-onAccent |
text-primary-foreground |
border-rule |
border |
rounded-card |
rounded-xl |
rounded-button |
rounded-md |
p-md / gap-md |
p-4 / gap-4 |
p-sm / gap-sm |
p-2 / gap-2 |
p-lg |
p-6 |
space-y-md |
space-y-4 |
text-small |
text-sm |
text-tiny |
text-xs |
ESLint 强制约束:
no-restricted-syntax:禁止#hex字面量design-tokens/no-hardcoded-fonts:禁止'Inter'字面量- 白名单:
primitive.css、semantic-*.css、tailwind-theme.css、email-channel、manifest.ts
portal-shell v2.0 落地:
- 字体通过
next/font/googleself-host Inter,CSS 变量暴露为--font-inter - 所有新代码使用 shadcn 标准 Tailwind 类(
bg-background/text-foreground/bg-card/border/rounded-xl等) - 无
tailwind.config.js(Tailwind v4 使用@theme inline替代) - 31 个 widget 仍使用旧纸感令牌(P1 阶段批量迁移)
9.2 字体策略(v2.0 简化)
| 字体族 | 用途 | CSS 变量 | Tailwind 类 |
|---|---|---|---|
| Inter | UI 文本 + 主标题 + 等宽 | --font-inter |
font-sans |
v2.0 简化字体策略:仅使用 Inter 单一字体族(对齐 CICD 项目风格),废弃 Fraunces / JetBrains Mono 双字体方案。如需等宽,使用 Tailwind 默认
font-mono。
9.3 shadcn 标准化设计风格(v2.0)
参考 CICD 项目风格 + shadcn/ui 官方规范:
- 背景:
bg-background/bg-card/bg-muted三层语义 - 圆角:
rounded-xl(卡片)/rounded-md(按钮)/rounded-full(头像) - 间距:Tailwind 默认阶梯(
p-2/p-4/p-6/gap-2/gap-4) - 插件容器:
<section className="rounded-xl border bg-card p-4"> - 插件标题:
text-lg text-foreground - 加载态:
<PluginSkeleton variant="card|list|chart|stats|table" />(5 种骨架变体) - 错误态:
<PluginErrorFallback instanceId={...} onRetry={...} /> - 统一 Toast:
notify.info/success/warning/error(禁止业务直接import { toast } from "sonner")
9.4 可观测性
| 维度 | 实现 | 端点 |
|---|---|---|
| 健康检查 | liveness + readiness | /api/health + /api/ready |
| 错误日志 | console.error + 三级 ErrorBoundary + useErrorReport(v2.0) |
浏览器控制台 + /api/log(v2.0 mock) |
| 性能 | Next.js 内置 Web Vitals(可接 OTel) | Next.js 自动采集 |
| 请求追踪 | Apollo Client 自动携带 traceparent | 经 api-gateway 注入 X-Request-Id |
| 错误上报(v2.0) | sendBeacon + sessionStorage 节流(1 分钟同 digest 去重) |
/api/log(mock)/ /api/v1/log(生产,待后端实现) |
9.5 国际化(MVP)
- locale 管理:
PluginStore.locale(zh-CN/en) - locale 切换:
locale-switcher插件(topbar) - 翻译函数:
useT()从ThemeI18nProvider导出 - 翻译范围:MVP 仅覆盖 Shell 框架文案(toggleSidebar / layout / empty),插件文案暂硬编码中文(二期接 next-intl)
9.6 安全
| 维度 | 实现 |
|---|---|
| JWT 注入 | Apollo Client setContext 从 localStorage 读取,注入 Authorization: Bearer |
| Cookie | credentials: "include" 携带 httpOnly cookie |
| XSS 防护 | React 自动转义 + 禁止 dangerouslySetInnerHTML |
| CSRF | 依赖 SameSite=Strict cookie(api-gateway 配置) |
| 开发模式 | NEXT_PUBLIC_DEV_MODE=true 绕过 JWT,仅本地开发 |
| APQ | createPersistedQueryLink({ sha256 }) 前端只发 query hash,env NEXT_PUBLIC_APOLLO_APQ=false 关闭 |
| PQ Manifest | public/pq-manifest.json 51 query 的 sha256 → query 白名单,由 scripts/generate-pq-manifest.ts 生成 |
| Router 强制 manifest | APOLLO_REQUIRE_PQ_MANIFEST=true 时 router 拒绝未知 hash,entrypoint.sh 启动前校验文件存在性 |
| 深度/成本/批量限制 | apollo-router limits.max_depth=10 / max_cost=1000 / max_batch_size=5 |
| Introspection 控制 | APOLLO_ROUTER_INTROSPECTION=false 生产关闭 schema 内省 |
| Router-Authorization | 子图 RouterAuthGuard 校验 header,拒绝非 Router 的直接 GraphQL 请求 |
| Resolver 字段级权限 | 每个 GraphQL Resolver 必须用 @RequirePermission('perm') 声明权限点 |
| 权限位图(v2.0) | 67 权限点 → base36 字符串(~14 字符),JWT 头 x-user-permissions 注入,体积减少 ≥ 99% |
| 三层安全边界(v2.0) | L1 角色门禁 / L2 权限点门禁 / L3 数据范围,详见 §3.3 |
| 路由权限配置表(v2.0) | 4 张表(精确 / 前缀 / 仪表盘 / API)按优先级匹配,checkRoutePermission 主函数 |
| PluginManifest 权限点(v2.0) | metadata.requiredPermissions?: string[](AND 语义),config-service 三层合并时过滤 |
| 错误上报节流(v2.0) | sessionStorage 1 分钟同 digest 去重,避免崩溃循环刷爆日志端点 |
9.7 统一 Toast 封装(v2.0 新增)
关联:
apps/portal-shell/src/shared/lib/notify.ts
强制规则:业务代码禁止直接 import { toast } from "sonner",必须统一走 notify 封装。
import { notify } from "@/shared/lib/notify";
// ✅ 正确:统一封装
notify.success("保存成功");
notify.error("保存失败", { description: error.message });
notify.info("发现新布局配置,刷新后生效");
notify.warning("该操作不可撤销");
// ❌ 禁止:直接 import sonner
import { toast } from "sonner"; // ESLint no-restricted-imports 拦截
toast.success("保存成功");
封装收益:
- 统一 Toast 样式与位置(Toaster 在 RootLayout 挂载一次)
- 便于后续替换底层库(sonner → react-hot-toast 或自研)
- 集中添加埋点 / 错误上报 / 国际化等横切逻辑
- ESLint
no-restricted-imports强制约束
10. 架构决策记录(ADR 索引)
portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为索引:
| ADR | 决策 | 状态 | 关联 |
|---|---|---|---|
| ADR-033 | portal-shell 单容器 Modular Monolith 替代 4 portal + MF | ✅ 已落地 | M8-M10 |
| ADR-023 | apollo-router 替代 3 BFF 手写聚合 | ✅ 已落地 | M2/M9 |
| ADR-026 | config-service 从 iam 拆分,三层配置合并 | ✅ 已落地 | M3 |
| ADR-029 | SSE 优先替代 WebSocket 单向推送 | ✅ 已落地 | M7 |
| ADR-034 | Redis Pub/Sub 作为推送背板 | ✅ 已落地 | M7 |
| ADR-036 | Router-Authorization 信任凭证 | ✅ 已落地 | M1 |
| ADR-041 | ScopeToken 优化大规模 ID 列表 | ✅ 已落地 | M4 |
| ADR-042 | portal-shell 前端数据访问四层分层(2026-07-17) | ✅ 已落地 | v1.1 M1 |
| ADR-043 | PQ Manifest + APQ 安全加固(2026-07-17) | ✅ 已落地 | v1.1 M3 |
| ADR-044 | shadcn/ui 标准化 + Tailwind v4(2026-07-17) | ✅ 已落地 | v2.0 |
| ADR-045 | React 19 use() + Suspense 流式渲染(2026-07-17) | ✅ 已落地 | v2.0 |
| ADR-046 | 三级错误边界 + 错误上报链路(2026-07-17) | ✅ 已落地 | v2.0 |
| ADR-047 | 权限位图 base36 压缩 + 三层安全边界(2026-07-17) | ✅ 已落地 | v2.0 |
10.1 模块级决策(未单独编号)
| 决策 | 理由 |
|---|---|
| dynamic import 替代 Module Federation | 单体部署,无运行时远程加载,研发与运维复杂度最低 |
| URL Search Params + Zustand 替代 EventBus | 数据流向清晰、支持 React DevTools、URL 可分享、浏览器前进后退天然支持 |
| SWR 静默刷新替代 Kafka+WebSocket 推送 | 低频布局变更无需实时推送,SWR 5 分钟轮询 + 切回 Tab 触发足够 |
| RSC 服务端预取替代客户端拉取 | 消除 4 层 CSR 瀑布流,HTML 直出 Config + initialData,LCP 秒开 |
| 三层 props 合并(系统 < 角色 < 用户) | 配置驱动可见性,admin 改配置无需重新部署 |
| 编译时 Registry + 运行时 Config 分离 | Registry 是静态映射(plugin_id → lazy 组件),Config 是动态配置(DB 三层合并) |
| ErrorBoundary 隔离单插件失败 | 单个插件加载/渲染失败不影响其他插件 |
| MVP 不实现第三方插件沙箱 | 单体应用直接 script 注入不信任代码等于交出主站权限,二期用 iframe + postMessage |
| 4 层数据访问分层 | Widget 内联 gql 字面量暴露 schema、难审计、难重构;抽取到 lib/api/ 四层架构集中管理(ADR-042) |
| APQ + PQ Manifest | 前端只发 query hash 防止 schema 探测;Router 白名单 manifest 拒绝未知 hash 任意查询(ADR-043) |
| Router 深度/成本/批量限制 | 防止深度嵌套 / 高成本 / 批量查询 DoS,配置 max_depth=10 / max_cost=1000 / max_batch_size=5 |
| Resolver 字段级 @RequirePermission | 防止越权访问字段,每个 Resolver 必须声明权限点;50 resolver 审计后补齐 19 个 TS 守卫 |
| shadcn/ui 标准化替代纸感令牌(v2.0) | 对齐 CICD 项目风格,统一生态,降低 UI 维护成本,废弃 paper/ink/accent/rule 命名(ADR-044) |
| Tailwind v4 + @theme inline(v2.0) | 移除 tailwind.config.js,使用 CSS-first 配置,对齐 shadcn/ui 官方推荐(ADR-044) |
| React 19 use() + Suspense 流式渲染(v2.0) | RSC 返回 Promise → 客户端 use() 消费,首屏骨架秒出,数据流式注入(ADR-045) |
| 三级错误边界(v2.0) | Route → Section → Widget 层层兜底,单插件崩溃不污染整页,最坏情况整页有 error.tsx 兜底(ADR-046) |
| sendBeacon + sessionStorage 节流(v2.0) | 页面卸载也能上报,1 分钟同 digest 去重避免崩溃循环刷爆日志端点(ADR-046) |
| 权限位图 base36 压缩(v2.0) | 67 权限点 → ~14 字符 base36 字符串,替代 JSON 数组(~600 字符),JWT 体积减少 ≥ 99%(ADR-047) |
| 三层安全边界(v2.0) | L1 角色门禁 / L2 权限点门禁 / L3 数据范围,路由级 + 插件级 + 数据范围层层过滤(ADR-047) |
| notify 统一 Toast 封装(v2.0) | 禁止业务直接 import sonner,统一封装便于替换底层库与添加横切逻辑(ESLint 强制) |
| Provider 在 Suspense 外(v2.0) | use() 暂停子树时 Provider 不能被暂停,否则 Context 丢失,ClientShell 拆分 ShellContent |
| SlotRenderer 信任输入(v2.0) | L1/L2/L3 三层过滤在 RSC 服务端完成,客户端 SlotRenderer 不再二次过滤(性能优化) |
11. 质量要求与验收
11.1 功能验收
- 5 种 Layout 模板可切换,slot 正确渲染
- 31 个内置插件全部加载正常(7 universal + 4 sidebar + 4 topbar + 4 teacher + 4 student + 2 parent + 6 admin)
- admin 可通过 plugin-manager 插件配置角色插件集合(registry / mapping / layout / user 四 Tab)
- admin 可配置角色默认 Layout 模板
- admin 可配置插件默认 props(JSON 编辑)
- admin 可重置用户自定义布局
- admin 改配置 → SWR 静默刷新检测到变化 → Toast 提示用户刷新生效
- 插件加载失败显示错误兜底,不影响其他插件(PluginBoundary 三件套,v2.0)
- 跨插件状态共享正常(class-selector 切换班级 → grades-widget 自动响应)
- 插件 props 三层合并正确(PropsMerger)
- RSC 服务端预取正常:首屏 HTML 直出 Config + initialData
- useWidgetQuery 自动经 Apollo Client 路由到 apollo-router
- URL Search Params 可分享、可前进后退
- config-fetcher 三级降级:apollo-router → config-service 直连 → 空默认
- 流式渲染(v2.0):RSC 返回 Promise → use() 消费 → 首屏骨架秒出 → Config 流式注入
- 三级错误边界(v2.0):Route(error.tsx)→ Section(SectionErrorBoundary)→ Widget(PluginBoundary)层层兜底
- 错误上报链路(v2.0):useErrorReport → sendBeacon → /api/log mock 端点,sessionStorage 1 分钟同 digest 去重
- 三层安全边界(v2.0):L1 角色门禁 + L2 权限点门禁 + L3 数据范围,路由权限配置表 4 张按优先级匹配
- 权限位图(v2.0):67 权限点 base36 编解码 + hasPermissionInBitmap / hasAllPermissionsInBitmap / hasAnyPermissionInBitmap
- PluginManifest requiredPermissions(v2.0):插件级 L2 权限点门禁,config-service 三层合并时过滤
- notify 统一 Toast 封装(v2.0):禁止业务直接 import sonner,ESLint no-restricted-imports 强制
- shadcn 标准化(v2.0):packages/ui-components 13 个文件令牌迁移完成(bg-paper→bg-background 等)
- shadcn/ui 组件库(v2.0):button / card / badge / skeleton / input / tooltip / sonner / page-header / stat-card / stats-grid / empty-state / filter-bar
11.2 非功能验收
pnpm run lint+pnpm run typecheck零错误(v2.0:含 2 个 auto-generated 文件警告,可忽略)pnpm run build通过(v2.0:Next.js 16 Turbopack,6 路由生成成功:///_not-found//api/health//api/log//api/ready//shell/[[...route]])pnpm run test206/206 通过(lib/api 7 domain 55 用例 + 安全栈 10 用例 + Shell/Lifecycle/Context 30 用例 + v2.0 新增组件单元测试 81 用例 + v2.0 E2E 测试 30 用例)- 0 处 widget 内联 gql 字面量(强制,arch:scan 违规检测)
- 所有新代码遵守 shadcn 标准令牌(v2.0:ESLint 强制,无硬编码颜色/字体/任意值)
- 31 个 widget 旧纸感令牌全部迁移到 shadcn 标准(P1:1104 次替换,arch:scan 零违规)
- apollo-router 启用 APQ + PQ Manifest + 深度/成本/批量限制
- 50 个 GraphQL resolver @auth 审计完成(35 TS + 15 Python),19 个 TS resolver 补齐 @RequirePermission
- arch.db 更新,004 文档同步
- v2.0 README 同步:本文件 v2.0,004 同步更新 ADR-044/045/046/047
- v2.0 流式渲染验证:首屏 HTML 直出骨架,Config resolve 后流式注入(本地 Docker 验证)
- v2.0 P2 单元测试:5 文件 81 用例(权限位图 24 + 路由权限 26 + notify 12 + useErrorReport 9 + PluginBoundary 10)
- v2.0 P3 生产端点:api-gateway POST /api/v1/log + useErrorReport 环境感知端点切换
- v2.0 P4 E2E 测试:3 文件 30 用例(流式渲染 4 + 三级错误边界 6 + 三层安全边界 20)
- Shell 首屏 LCP < 2s(需真实环境压测验证)
- 插件加载耗时 < 500ms(dynamic import 缓存命中后,需真实环境验证)
- 单元测试覆盖率 ≥ 80%(当前覆盖核心纯函数 + lib/api 全量 + v2.0 新增组件全量,admin domain 仅 4 用例待补,插件组件渲染测试待补)
- E2E 测试(tests/e2e/ 真实浏览器场景,含 admin 改配置 → 用户刷新生效、apollo-router 深度限制)
- 视觉回归测试(5 种 layout 截图,待补,含 v2.0 shadcn 标准化对比)
- 生产部署前 APOLLO_REQUIRE_PQ_MANIFEST=true + APOLLO_ROUTER_INTROSPECTION=false 写入部署 env
11.3 测试矩阵
| 测试类型 | 范围 | 文件 | 状态 |
|---|---|---|---|
| 单元测试 | PluginLifecycle 纯函数 | src/shell/__tests__/PluginLifecycle.test.ts |
✅ 12 用例 |
| 单元测试 | Registry 插件注册 | src/shell/__tests__/Registry.test.ts |
✅ 6 用例 |
| 单元测试 | plugin-context URL 上下文 | src/lib/__tests__/plugin-context.test.ts |
✅ 12 用例 |
| 单元测试 | lib/api universal domain | src/lib/api/__tests__/universal.test.ts |
✅ 6 用例 |
| 单元测试 | lib/api sidebar domain | src/lib/api/__tests__/sidebar.test.tsx |
✅ 9 用例 |
| 单元测试 | lib/api topbar domain | src/lib/api/__tests__/topbar.test.tsx |
✅ 9 用例 |
| 单元测试 | lib/api teacher domain | src/lib/api/__tests__/teacher.test.tsx |
✅ 7 用例 |
| 单元测试 | lib/api student domain | src/lib/api/__tests__/student.test.tsx |
✅ 9 用例 |
| 单元测试 | lib/api parent domain | src/lib/api/__tests__/parent.test.tsx |
✅ 11 用例 |
| 单元测试 | lib/api admin domain | src/lib/api/__tests__/admin.test.tsx |
✅ 4 用例(待补) |
| 单元测试 | PQ Manifest + APQ + 深度限制 | src/lib/api/__tests__/security.test.ts |
✅ 10 用例 |
| 单元测试 | 权限位图 base36 编解码(v2.0) | src/shared/lib/__tests__/permission-bitmap.test.ts |
✅ 24 用例 |
| 单元测试 | 路由权限配置表 + checkRoutePermission(v2.0) | src/shared/lib/__tests__/route-permissions.test.ts |
✅ 26 用例 |
| 单元测试 | notify 统一封装(v2.0) | src/shared/lib/__tests__/notify.test.ts |
✅ 12 用例 |
| 单元测试 | useErrorReport 节流逻辑(v2.0) | src/shared/lib/__tests__/use-error-report.test.ts |
✅ 9 用例 |
| 单元测试 | PluginBoundary 三件套(v2.0) | src/shared/components/__tests__/plugin-boundary.test.tsx |
✅ 10 用例 |
| E2E | 流式渲染(React 19 use() + Suspense)(v2.0) | src/__tests__/e2e/streaming.test.tsx |
✅ 4 用例 |
| E2E | 三级错误边界层级(v2.0) | src/__tests__/e2e/error-boundaries.test.tsx |
✅ 6 用例 |
| E2E | 三层安全边界(L1 角色 + L2 权限 + L3 范围)(v2.0) | src/__tests__/e2e/security-boundaries.test.ts |
✅ 20 用例 |
| 单元测试 | PropsMerger 三层合并 | 待补 | 🚧 |
| 单元测试 | 各插件组件渲染 | 待补 | 🚧 |
| E2E | 登录 → 加载 layout → 渲染插件 → 切换 layout | tests/e2e/portal-shell.spec.ts |
⏳ |
| E2E | admin 改配置 → 用户刷新生效 | tests/e2e/plugin-config.spec.ts |
⏳ |
| E2E | apollo-router 拒绝 11 层嵌套查询 | tests/e2e/graphql-depth-limit.spec.ts |
⏳ |
| E2E | apollo-router 拒绝未知 PQ hash | tests/e2e/graphql-pq-manifest.spec.ts |
⏳ |
| E2E | 流式渲染 + 三级错误边界(v2.0) | tests/e2e/streaming-and-error-boundary.spec.ts |
⏳(v2.0) |
| E2E | 三层安全边界 + 权限位图(v2.0) | tests/e2e/security-boundary.spec.ts |
⏳(v2.0) |
| 视觉回归 | 5 种 layout 截图对比 | tests/visual/portal-shell.spec.ts |
⏳ |
| 视觉回归 | shadcn 标准化对比(v2.0) | tests/visual/shadcn-migration.spec.ts |
⏳(v2.0) |
12. 风险、技术债与演进路线
12.1 风险与缓解
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 插件数量增长导致首屏 bundle 过大 | 首屏加载慢 | dynamic import 按需加载 + IntersectionObserver 滚动加载 + 首屏只加载可见 slot |
| 单体架构插件间隐式耦合 | 维护困难 | ESLint 禁止跨 widgets 目录 import + arch:scan 违规检测 + 强制 URL/Zustand 共享状态 |
| 单角色专属功能受 props 契约约束 | 复杂功能实现受限 | PluginProps 设计灵活(initialData + props 任意 JSON);复杂功能在插件内部自行组织 |
| config-service 配置查询压力 | RSC 每次请求查询多表 | 复用 config-service Redis 缓存 + RSC cache() 去重 + SWR 客户端轮询自然刷新 |
| 插件 props 三层合并逻辑复杂 | props 不一致 | PropsMerger 集中实现 + 单元测试覆盖(待补) |
| admin 配置面板 propsSchema 复杂 | 表单体验差 | MVP 用 JSON textarea 编辑,二期接 react-jsonschema-form |
| 二期第三方插件沙箱 | 安全风险 | iframe + postMessage(最安全),禁止 same-origin,BFF 请求由 Shell 代理 |
| v2.0 流式渲染 Provider 暂停(v2.0) | Context 丢失导致子树崩溃 | 严格约束 Provider 必须在 Suspense 外,ClientShell 拆分 ShellContent 隔离 use() |
| v2.0 权限位图 BigInt 兼容性(v2.0) | 旧浏览器不支持 BigInt | 目标浏览器为现代浏览器(Chrome 67+ / Firefox 68+ / Safari 14+),不兼容 IE |
| v2.0 错误上报端点 mock(v2.0) | 生产环境无后端端点 | 当前 /api/log 为 Next.js API Route mock,生产由后端 /api/v1/log 替换 |
| v2.0 旧令牌混用(v2.0) | 31 widget 仍用纸感令牌 | P1 阶段批量迁移,arch:scan 违规检测,新代码强制 shadcn 标准 |
12.2 技术债
| # | 技术债 | 优先级 | 计划 |
|---|---|---|---|
| TD-1 | PluginLifecycle 版本校验仅 major,未引入 semver 库 | 低 | MVP 够用,二期按需引入 semver |
| TD-2 | ThemeI18nProvider 仅覆盖 Shell 框架文案,插件文案硬编码中文 | 中 | 二期接 next-intl,提取到 messages/{en,zh-CN}.json |
| TD-3 | PropsMerger 单元测试待补 | 中 | 补充深合并 + 数组覆盖 + null 跳过用例 |
| TD-4 | 插件组件单元测试待补(仅 Registry 与 Lifecycle 有测试) | 中 | 补充各插件渲染 + loading + error 用例 |
| TD-5 | E2E 测试与视觉回归测试待补 | 高 | 接 Playwright + 5 种 layout 截图 |
| TD-6 | prefetchPluginData 服务端并发预取未实现(仅拉 Config,未预取 initialData) |
中 | RSC 中按 pluginId 分发预取,传入 fallbackData |
| TD-7 | canvas layout 仅按 grid 排列,未实现拖拽 | 低 | MVP 预留接口,二期按需实现 |
| TD-8 | 第三方插件沙箱未实现 | 低 | 二期 iframe + postMessage |
| TD-9 | v2.0:31 widget 旧纸感令牌批量迁移 | 高 | P1 阶段,arch:scan 违规检测,bg-paper→bg-background 等 |
| TD-10 | v2.0:权限位图单元测试待补 | 中 | 补 base36 编解码 + hasAll/hasAny + isValidPermission |
| TD-11 | v2.0:路由权限配置表单元测试待补 | 中 | 补 4 张表匹配 + checkRoutePermission + batch + validate |
| TD-12 | v2.0:notify 封装单元测试待补 | 低 | 补 4 个方法调用 + ESLint no-restricted-imports 验证 |
| TD-13 | v2.0:useErrorReport 节流逻辑测试待补 | 中 | 补 sessionStorage 1 分钟同 digest 去重 + sendBeacon |
| TD-14 | v2.0:PluginBoundary 三件套测试待补 | 中 | 补 ErrorBoundary + Suspense + Skeleton 5 变体 |
| TD-15 | v2.0:错误上报端点生产替换 | 中 | 后端实现 /api/v1/log 后,移除 Next.js API Route mock |
| TD-16 | v2.0:TS 子图 AuthMiddleware 覆盖 /graphql | 中 | 4 个 TS 子图补齐字段级守卫(不依赖 RouterAuthGuard 兜底) |
12.3 演进路线
| 阶段 | 内容 | 状态 |
|---|---|---|
| M8 | portal-shell 接入 apollo-router(RSC 预取 Config) | ✅ 完成 |
| M9 | 旧 BFF 下线(teacher/student/parent-bff) | ✅ 完成 |
| M10 | 旧 portal 下线(teacher/student/parent/admin-portal) | ✅ 完成 |
| v1.1 M1 | lib/api 四层架构 + 31 widget 迁移 | ✅ 完成(2026-07-17) |
| v1.1 M3 | GraphQL 安全加固(APQ + PQ Manifest + router limits) | ✅ 完成(2026-07-17) |
| v1.1 M4 | Resolver @RequirePermission 审计 + 补齐 | ✅ 完成(2026-07-17) |
| v2.0 P0 | shadcn 标准化 + 三层安全 + 流式渲染 + 三级错误处理 | ✅ 完成(2026-07-17) |
| v1.1 FU-1 | 4 个 TS 子图 AuthMiddleware 覆盖 /graphql 路径 | ⏳ Follow-up |
| v1.1 FU-2 | Python 子图(data-ana/ai)补 @RequirePermission 基础设施 | ⏳ Follow-up |
| v1.1 FU-3 | admin domain 测试用例补齐(当前仅 4 用例) | ⏳ Follow-up |
| v2.0 P1 | 31 widget 旧纸感令牌批量迁移到 shadcn 标准 | ✅ 完成(2026-07-17) |
| v2.0 P2 | v2.0 新增组件单元测试补齐(权限位图 / 路由权限 / notify / useErrorReport / PluginBoundary) | ✅ 完成(2026-07-17) |
| v2.0 P3 | 错误上报端点生产替换(后端 /api/v1/log) | ✅ 完成(2026-07-17) |
| v2.0 P4 | E2E 测试(流式渲染 + 三级错误边界 + 三层安全边界) | ✅ 完成(2026-07-17) |
| P5(二期) | 第三方插件上传 + iframe 沙箱 | ⏳ 规划 |
| P6(二期) | 插件市场在线商店 | ⏳ 规划 |
| P7(二期) | canvas 拖拽编辑器 | ⏳ 规划 |
| P8(二期) | next-intl 完整 i18n | ⏳ 规划 |
| P9(二期) | 视觉回归测试自动化 | ⏳ 规划 |
13. 数据访问层与 GraphQL 安全栈
来源:portal-shell 数据抽象与 GraphQL 加固 spec v1.0 关联 ADR:ADR-042(前端数据访问四层分层)、ADR-043(PQ Manifest + APQ 安全加固) 关联 004:§11.7 portal-shell 前端数据访问层 + GraphQL 安全栈
13.1 问题背景
v1.0 portal-shell 的 widget 直接内联 gql\...`` 字面量查询,存在三个核心问题:
- Schema 泄露:前端 bundle 包含明文 GraphQL 查询,攻击者可通过 DevTools 构造任意查询探测 schema
- 查询碎片化:31 个 widget 各自维护 gql 字符串,难审计、难重构、难统一优化
- 缺抽象:widget 直接 import Apollo hooks,业务逻辑与传输层耦合
13.2 解决方案:4 层数据访问分层(ADR-042)
graph TD
subgraph Widget["Widget 层(UI)"]
W[widgets/*.tsx<br/>只关心渲染]
end
subgraph API["API 层(语义化函数)"]
A1[lib/api/parent.ts]
A2[lib/api/teacher.ts]
A3[lib/api/admin.ts]
A4[lib/api/student.ts]
A5[lib/api/universal.ts]
A6[lib/api/sidebar.ts]
A7[lib/api/topbar.ts]
end
subgraph Ops["Operations 层(gql 文档集中)"]
O[lib/api/operations/*.graphql.ts<br/>51 个 DocumentNode 常量]
end
subgraph Hook["Hook 层(Apollo 封装)"]
H1[useWidgetQuery]
H2[useWidgetMutation]
end
subgraph Codegen["类型生成"]
C[graphql-codegen<br/>7 子图 schema.graphql → types.ts]
end
W --> A1 & A2 & A3 & A4 & A5 & A6 & A7
A1 & A2 & A3 & A4 & A5 & A6 & A7 --> O
A1 & A2 & A3 & A4 & A5 & A6 & A7 --> H1 & H2
O --> C
层职责矩阵:
| 层 | 位置 | 职责 | 禁止 |
|---|---|---|---|
| Widget | src/widgets/*.tsx |
UI 渲染、用户交互 | 内联 gql 字面量、直接 import Apollo hooks |
| API | src/lib/api/<domain>.ts |
语义化函数(useMyChildren() / saveLessonPlan() 等) |
写 gql 字符串、直接操作 Apollo cache |
| Operations | src/lib/api/operations/*.graphql.ts |
gql DocumentNode 常量集中存放(51 个 query/mutation) | 包含业务逻辑 |
| Hook | src/lib/useWidgetQuery/useWidgetMutation.ts |
Apollo useQuery/useMutation 封装 + ApiError 归一化 | 引用具体 domain |
7 个 domain API 文件:
| 文件 | Widget 数 | 主要场景 |
|---|---|---|
universal.ts |
7 | 公告 / 通知 / 课表 / 作业 / 考试 / 成绩 / 考勤(多角色复用) |
sidebar.ts |
3 | 当前用户 / 我的班级 / 学期列表(侧边栏) |
topbar.ts |
2 | 搜索 / 通知铃铛(顶栏,useNotificationBell 限 N 条) |
teacher.ts |
5 | 教材 / 教案 / 题目 / 课表规则 / 教案保存 / 课表更新 |
student.ts |
5 | AI Tutor / 选修课 / 错题本 / 学习路径 / 错题掌握标记 |
parent.ts |
3 | 我的子女 / 请假审批 / 请假驳回 |
admin.ts |
11 | 用户 / 角色 / 权限 / 学校 / 插件注册 / 角色插件映射 / 布局模板 / 邀请码 / 审计日志 |
类型生成(graphql-codegen):
- 配置:
apps/portal-shell/codegen.yml - schema 来源:7 个子图的
services/<svc>/src/graphql/generated/schema.graphql - 生成产物:
src/lib/api/operations/types.ts(仅类型,无运行时代码) - 关键配置:
skipDocumentsValidation: true(避免子图未启动时 codegen 失败) - schema 归一化:
scripts/normalize-schema.ts移除 federation 指令(@key/@requires/@extends)防止 codegen 误解析
13.3 GraphQL 安全栈(ADR-043)
graph LR
subgraph FE["portal-shell(前端)"]
APQ[createPersistedQueryLink<br/>sha256 query → hash]
end
subgraph Router["apollo-router :3000"]
Manifest[pq-manifest.json<br/>hash → query 白名单]
Limits[limits.max_depth=10<br/>max_cost=1000<br/>max_batch_size=5]
Intro[introspection<br/>环境变量控制]
end
subgraph Sub["子图 /graphql"]
Guard[RouterAuthGuard<br/>+ @RequirePermission]
end
APQ -->|只发 hash| Manifest
Manifest -->|未知 hash 拒绝| APQ
Manifest --> Limits
Limits --> Intro
Intro --> Guard
安全机制矩阵:
| 机制 | 位置 | 防御目标 | 配置 |
|---|---|---|---|
| APQ(Automatic Persisted Queries) | apps/portal-shell/src/lib/apollo-client.ts |
前端只发 query hash,不发明文 query | createPersistedQueryLink({ sha256 }),env NEXT_PUBLIC_APOLLO_APQ=false 关闭 |
| PQ Manifest | apps/portal-shell/public/pq-manifest.json |
Router 仅解析白名单 hash,拒绝未知 hash 任意查询 | 51 个 query 的 sha256 → query 映射,由 scripts/generate-pq-manifest.ts 生成 |
| Router 强制 manifest | infra/apollo-router/router.yaml |
生产模式(require_manifest: true)拒绝未注册查询 |
env APOLLO_REQUIRE_PQ_MANIFEST=true,entrypoint.sh 启动前检查文件存在性 |
| 深度限制 | router.yaml limits.max_depth=10 |
防止深度嵌套查询 DoS | 11 层嵌套被 router 拒绝(QUERY_DEPTH_EXCEEDED) |
| 成本限制 | router.yaml limits.max_cost=1000 |
防止高成本查询 DoS | 按字段复杂度评分累加 |
| 批量限制 | router.yaml limits.max_batch_size=5 |
防止批量查询 DoS | 单次请求最多 5 个 query |
| Introspection 控制 | router.yaml supergraph.introspection |
生产关闭 introspection 防止 schema 泄露 | env APOLLO_ROUTER_INTROSPECTION=false(开发默认 true) |
| Router-Authorization 信任 | 子图 RouterAuthGuard(见 §5.5) |
防止绕过 Router 直接访问子图 | 子图校验 Router-Authorization header,拒绝非 Router 请求(ADR-036) |
| Resolver 字段级权限 | 子图 @RequirePermission() 装饰器 |
防止越权访问字段 | 每个 Resolver 必须声明权限点(见 §3.8) |
13.4 PQ Manifest 生成流程
pnpm --filter @edu/portal-shell run codegen→ 从 7 子图 schema 生成 TS 类型pnpm --filter @edu/portal-shell run generate-pq-manifest→ 遍历lib/api/operations/index.ts中所有 DocumentNode,print(doc)后sha256(query)生成映射,写入public/pq-manifest.jsonprebuild钩子自动串联 codegen + generate-pq-manifest- Docker compose 挂载
pq-manifest.json到 apollo-router/etc/apollo-router/pq-manifest.json:ro - apollo-router entrypoint.sh 启动前校验 manifest 存在性(
require_manifest=true时缺失即 exit 1)
13.5 Resolver @RequirePermission 审计(M4)
已审计范围:50 个 resolver(35 TS + 15 Python)
| 状态 | 数量 | 说明 |
|---|---|---|
已有 @RequirePermission |
37 | 18 原有 + 19 M4 补齐 |
| 缺失守卫 | 13 | 4 个 TS(/graphql 路径未覆盖,由 RouterAuthGuard 兜底)+ 9 个 Python(待补基础设施) |
Follow-up(不阻断 M3 验收):
- 4 个 TS 子图(iam / core-edu / content / msg)的
AuthMiddleware仅覆盖 REST 路径,未覆盖/graphql(由RouterAuthGuard兜底,仍建议补齐字段级守卫) - Python 子图(data-ana / ai)缺
@RequirePermission基础设施,需补 Strawberry / Ariadne 中间件
13.6 常用命令
# 类型生成(从 7 子图 schema 生成 TS 类型)
pnpm --filter @edu/portal-shell run codegen
# PQ Manifest 生成
pnpm --filter @edu/portal-shell run generate-pq-manifest
# 生产构建(prebuild 自动串联 codegen + generate-pq-manifest)
pnpm --filter @edu/portal-shell run build
# 验证 0 处内联 gql 字面量
pnpm --filter @edu/portal-shell run lint
# 安全栈测试
npx vitest run src/lib/api/__tests__/security.test.ts
14. v2.0 安全边界与错误处理
本章汇总 v2.0 新增的安全边界、流式渲染、错误处理机制,详细设计见各章节交叉引用。
14.1 三层安全边界(详见 §3.3)
graph TD
User["用户请求<br/>/shell/*"] --> L1
L1["L1 角色门禁<br/>route-permissions.ts<br/>requiredRoles?"] -->|通过| L2
L1 -->|拒绝| Deny403["403 Forbidden"]
L2["L2 权限点门禁<br/>位图校验<br/>requiredPermissions?<br/>anyOfPermissions?"] -->|通过| L3
L2 -->|拒绝| Deny403
L3["L3 数据范围<br/>config-service<br/>DataScope 6 级"] -->|过滤| Plugin["插件渲染<br/>SlotRenderer 信任输入"]
L3 -->|无可见数据| Empty["空状态"]
关键文件:
| 文件 | 职责 |
|---|---|
packages/shared-ts/src/permission-bitmap.ts |
67 权限点定义 + base36 编解码 + 校验 |
apps/portal-shell/src/shared/lib/route-permissions.ts |
4 张路由权限配置表 + checkRoutePermission |
packages/shared-ts/src/contracts/plugin.ts |
PluginManifest.metadata.requiredPermissions 字段 |
权限位图 API:
// 编码:权限点数组 → base36 字符串
const bitmap = encodePermissionsBitmap(["user.read", "grade.write"]);
// → "1a2b3c..."
// 解码:base36 字符串 → 权限点数组
const permissions = decodePermissionsBitmap(bitmap);
// → ["user.read", "grade.write"]
// 校验
hasPermissionInBitmap(bitmap, "user.read"); // → true
hasAllPermissionsInBitmap(bitmap, ["user.read", "grade.write"]); // → true
hasAnyPermissionInBitmap(bitmap, ["user.read", "user.delete"]); // → true
isValidPermission("user.read"); // → true(必须在 PERMISSION_BITMAP_ORDER 中)
14.2 流式渲染(详见 §5.5)
graph LR
subgraph RSC["RSC 服务端"]
Fetch["fetchPluginConfig<br/>不 await"]
Promise["返回 Promise"]
end
subgraph Client["客户端"]
Suspense["Suspense 边界"]
Use["use(configPromise)"]
Render["ShellContent 渲染"]
end
subgraph Loading["加载态"]
Skeleton["整页骨架<br/>shell/loading.tsx"]
PluginSk["PluginSkeleton<br/>5 变体"]
end
Fetch --> Promise
Promise --> Suspense
Suspense -->|Promise 未 resolve| Skeleton
Suspense -->|Promise resolve| Use
Use --> Render
Render -->|单插件加载| PluginSk
Render -->|插件加载完成| Done["渲染完成"]
关键文件:
| 文件 | 职责 |
|---|---|
apps/portal-shell/src/app/shell/[[...route]]/page.tsx |
RSC 入口,返回 Promise 不 await |
apps/portal-shell/src/shell/ClientShell.tsx |
use() 消费 + Provider 在 Suspense 外 |
apps/portal-shell/src/app/shell/loading.tsx |
Route 级加载骨架 |
apps/portal-shell/src/shared/components/plugin-boundary.tsx |
Widget 级 Suspense + Skeleton |
14.3 三级错误处理(详见 §5.6)
graph TB
subgraph Route["Route 级"]
REB["RouteErrorBoundary<br/>app/shell/error.tsx"]
end
subgraph Section["Section 级"]
SEB["SectionErrorBoundary<br/>DashboardSection 内"]
end
subgraph Widget["Widget 级"]
PB["PluginBoundary<br/>SlotRenderer 内"]
end
subgraph Report["错误上报"]
Hook["useErrorReport"]
Beacon["sendBeacon"]
API["/api/log mock"]
Store["sessionStorage<br/>1 分钟去重"]
end
REB --> SEB --> PB
REB -.->|onError| Hook
SEB -.->|onError| Hook
PB -.->|onError| Hook
Hook --> Store
Hook --> Beacon --> API
关键文件:
| 文件 | 职责 |
|---|---|
apps/portal-shell/src/app/shell/error.tsx |
Route 级 Next.js error.tsx |
apps/portal-shell/src/shared/components/route-error-boundary.tsx |
Route 错误边界组件 |
apps/portal-shell/src/shared/components/section-error-boundary.tsx |
Section 错误边界组件 |
apps/portal-shell/src/shared/components/plugin-boundary.tsx |
Widget 错误边界 + Suspense + Skeleton 三件套 |
packages/hooks/src/use-error-report.ts |
错误上报 Hook(sendBeacon + sessionStorage 节流) |
apps/portal-shell/src/app/api/log/route.ts |
错误上报 mock 端点 |
5 种骨架变体:
| 变体 | 用途 | 适用场景 |
|---|---|---|
card |
卡片骨架 | 通用卡片插件 |
list |
列表骨架 | 通知/公告/作业列表 |
chart |
图表骨架 | 数据分析图表 |
stats |
统计骨架 | 数字统计卡片 |
table |
表格骨架 | 成绩/考勤表格 |
14.4 v2.0 关键决策汇总
| 决策 | 理由 | 关联 ADR |
|---|---|---|
| shadcn/ui 标准化替代纸感令牌 | 对齐 CICD 项目风格,统一生态 | ADR-044 |
| Tailwind v4 + @theme inline | CSS-first 配置,对齐 shadcn 官方推荐 | ADR-044 |
| React 19 use() + Suspense 流式渲染 | 首屏骨架秒出,数据流式注入 | ADR-045 |
| 三级错误边界(Route/Section/Widget) | 层层兜底,单插件崩溃不污染整页 | ADR-046 |
| sendBeacon + sessionStorage 节流 | 页面卸载也能上报,避免崩溃循环 | ADR-046 |
| 权限位图 base36 压缩 | 67 权限点 → ~14 字符,JWT 体积减少 ≥ 99% | ADR-047 |
| 三层安全边界(L1/L2/L3) | 路由级 + 插件级 + 数据范围层层过滤 | ADR-047 |
| notify 统一 Toast 封装 | 禁止业务直接 import sonner,便于替换 | 模块级决策 |
| Provider 在 Suspense 外 | use() 暂停子树时 Context 不丢失 | 模块级决策 |
| SlotRenderer 信任输入 | L1/L2/L3 服务端过滤,客户端不再二次过滤 | 模块级决策 |
14.5 v2.0 文件清单
apps/portal-shell/src/
├─ app/
│ ├─ api/log/route.ts # v2.0 错误上报 mock 端点
│ ├─ shell/error.tsx # v2.0 Route 级错误兜底
│ └─ shell/loading.tsx # v2.0 Route 级加载骨架
├─ shared/
│ ├─ lib/
│ │ ├─ route-permissions.ts # v2.0 路由权限配置表(4 张表 + checkRoutePermission)
│ │ ├─ notify.ts # v2.0 统一 Toast 封装
│ │ └─ utils.ts # v2.0 cn() 工具函数
│ └─ components/
│ ├─ plugin-boundary.tsx # v2.0 插件级错误边界 + Suspense + Skeleton
│ ├─ route-error-boundary.tsx # v2.0 Route 级错误边界
│ ├─ section-error-boundary.tsx # v2.0 Section 级错误边界
│ ├─ dashboard/
│ │ ├─ dashboard-shell.tsx # v2.0 仪表盘外壳
│ │ └─ dashboard-section.tsx # v2.0 仪表盘分区
│ ├─ layout/
│ │ ├─ sidebar-provider.tsx # v2.0 侧边栏状态
│ │ ├─ app-sidebar.tsx # v2.0 应用侧边栏
│ │ └─ site-header.tsx # v2.0 顶部头部
│ └─ ui/
│ ├─ button.tsx / card.tsx / badge.tsx # v2.0 shadcn/ui 组件
│ ├─ skeleton.tsx / input.tsx / tooltip.tsx
│ ├─ sonner.tsx # v2.0 Toaster
│ ├─ page-header.tsx / stat-card.tsx
│ ├─ stats-grid.tsx / empty-state.tsx
│ └─ filter-bar.tsx
packages/shared-ts/src/
├─ permission-bitmap.ts # v2.0 权限位图工具(67 权限点 + base36)
└─ contracts/plugin.ts # v2.0 PluginManifest.metadata.requiredPermissions
packages/hooks/src/
└─ use-error-report.ts # v2.0 错误上报 Hook
packages/ui-components/src/ # v2.0 13 个文件令牌迁移完成
├─ plugin-error-fallback.tsx / plugin-skeleton.tsx
├─ plugin-card.tsx / slot-placeholder.tsx / status-badge.tsx
├─ props-config-form.tsx / form.tsx / modal.tsx
├─ data-table.tsx / filter-bar.tsx / chart.tsx
├─ calendar.tsx / rich-text-editor.tsx
packages/ui-tokens/src/
├─ primitive.css # v2.0 zinc/stone/indigo 色板
├─ semantic-light.css / semantic-dark.css # v2.0 shadcn 标准语义令牌
└─ tailwind-theme.css # v2.0 @theme inline
15. 术语表
| 术语 | 定义 |
|---|---|
| Shell | 微内核宿主,渲染 Layout 框架 + Slots + PluginBoundary,不含业务逻辑 |
| Registry | 编译时登记的插件清单,plugin_id → dynamic import 组件 静态映射 |
| Config | 运行时配置 JSON,决定当前用户在哪些 Slots 渲染哪些插件(DB 三层合并) |
| PluginProps | 插件契约,Shell 与插件之间的唯一交互接口 |
| Slot | Layout 模板预定义的插件放置区域(top / side / main / main-left / main-right / right / canvas-grid) |
| Layout 模板 | 5 种内置布局(classic / focus / split / triple / canvas) |
| 三层配置 | 系统默认(plugin_registry) < 角色模板(role_plugin_mapping) < 用户覆盖(user_layout_override) |
| 三层 props 合并 | 系统默认 defaultProps < 角色默认 widgetProps < 用户调整 plugin_placements[].props |
| RSC | React Server Component,Next.js App Router 的服务端组件,可异步获取数据 |
| dynamic import | next/dynamic 懒加载,ssr: false 仅客户端渲染 |
| SWR | stale-while-revalidate 数据请求库,支持静默后台刷新 |
| Zustand | React 全局状态管理库,替代 EventBus |
| apollo-router | Apollo Federation 聚合层,替代 3 BFF 手写聚合 |
| config-service | 从 iam 拆分的配置服务,管理插件配置 + 布局 + 用户偏好 |
| DataScope | IAM 6 级数据范围(school / grade / class / subject / student / self) |
| ScopeToken | 大规模 ID 列表的轻量令牌(Redis 存储),替代 GraphQL 联邦全数组传递 |
| Modular Monolith | 单体应用 + 模块化组织,介于单进程与微服务之间 |
| Micro-kernel | 微内核架构,核心仅含基础框架,功能以插件形式扩展 |
| APQ | Automatic Persisted Queries,前端只发 query hash(sha256),不发明文 query |
| PQ Manifest | sha256(query) → query 文本的白名单 JSON,apollo-router 据此解析未知 hash |
| 4 层数据访问分层 | Widget → API → Operations → Hook,废弃 widget 内联 gql 字面量(ADR-042) |
| @RequirePermission | NestJS GraphQL Resolver 字段级权限装饰器,每个 Resolver 必须声明权限点 |
| RouterAuthGuard | 子图 Guard,校验 Router-Authorization header,拒绝非 Router 的直接 GraphQL 请求 |
| shadcn/ui(v2.0) | 基于 Radix UI + cva + tailwind-merge + clsx 的组件库,对齐 CICD 项目风格(ADR-044) |
| Tailwind v4(v2.0) | Tailwind CSS v4,使用 @import "tailwindcss" + @theme inline CSS-first 配置,无 tailwind.config.js |
| use() Hook(v2.0) | React 19 新 Hook,在 Suspense 边界内消费 Promise,实现流式渲染(ADR-045) |
| 流式渲染(v2.0) | RSC 返回 Promise → 客户端 use() 消费 → 首屏骨架秒出 → Config resolve 后流式注入(ADR-045) |
| 三级错误边界(v2.0) | Route(error.tsx)→ Section(SectionErrorBoundary)→ Widget(PluginBoundary)层层兜底(ADR-046) |
| PluginBoundary(v2.0) | 插件级错误边界 + Suspense + Skeleton 三件套,替代旧 PluginLoader(ADR-046) |
| useErrorReport(v2.0) | 错误上报 Hook,sendBeacon + sessionStorage 1 分钟同 digest 去重(ADR-046) |
| 权限位图(v2.0) | 67 权限点 → base36 字符串(~14 字符),压缩 JWT 体积 ≥ 99%(ADR-047) |
| PERMISSION_BITMAP_ORDER(v2.0) | 67 个权限点的有序数组,权限位图的唯一合法来源 |
| 三层安全边界(v2.0) | L1 角色门禁 / L2 权限点门禁 / L3 数据范围,路由级 + 插件级 + 数据范围层层过滤(ADR-047) |
| 路由权限配置表(v2.0) | 4 张表(精确 / 前缀 / 仪表盘 / API)按优先级匹配,checkRoutePermission 主函数 |
| notify(v2.0) | 统一 Toast 封装,禁止业务直接 import sonner,ESLint no-restricted-imports 强制 |
| cn()(v2.0) | shadcn/ui 标准工具函数(clsx + tailwind-merge),管理条件类名 |
| SlotRenderer 信任输入(v2.0) | L1/L2/L3 三层过滤在 RSC 服务端完成,客户端 SlotRenderer 不再二次过滤(性能优化) |
附录 A:文件清单
apps/portal-shell/
├─ src/
│ ├─ app/
│ │ ├─ api/
│ │ │ ├─ health/route.ts # liveness
│ │ │ ├─ log/route.ts # v2.0 错误上报 mock 端点
│ │ │ └─ ready/route.ts # readiness
│ │ ├─ shell/
│ │ │ ├─ [[...route]]/page.tsx # RSC 入口(v2.0 流式 Promise)
│ │ │ ├─ error.tsx # v2.0 Route 级错误兜底
│ │ │ └─ loading.tsx # v2.0 Route 级加载骨架
│ │ ├─ globals.css # v2.0 全局样式 + Tailwind v4 + shadcn 令牌
│ │ ├─ layout.tsx # RootLayout + Inter 字体(v2.0)
│ │ └─ page.tsx # 重定向 /shell
│ ├─ lib/
│ │ ├─ __tests__/plugin-context.test.ts
│ │ ├─ api/ # 数据访问层
│ │ │ ├─ __tests__/
│ │ │ │ ├─ admin.test.tsx # 4 用例(待补)
│ │ │ │ ├─ parent.test.tsx # 11 用例
│ │ │ │ ├─ security.test.ts # 10 用例(PQ Manifest + APQ + 深度限制)
│ │ │ │ ├─ sidebar.test.tsx # 9 用例
│ │ │ │ ├─ student.test.tsx # 9 用例
│ │ │ │ ├─ teacher.test.tsx # 7 用例
│ │ │ │ ├─ topbar.test.tsx # 9 用例
│ │ │ │ └─ universal.test.ts # 6 用例
│ │ │ ├─ operations/
│ │ │ │ ├─ *.graphql.ts # 51 个 DocumentNode 常量
│ │ │ │ ├─ index.ts # barrel
│ │ │ │ └─ types.ts # codegen 生成
│ │ │ ├─ admin.ts # 11 widget API
│ │ │ ├─ errors.ts # ApiError 归一化
│ │ │ ├─ internal.ts # 内部工具
│ │ │ ├─ parent.ts # 3 widget API
│ │ │ ├─ sidebar.ts # 3 widget API
│ │ │ ├─ student.ts # 5 widget API
│ │ │ ├─ teacher.ts # 5 widget API
│ │ │ ├─ topbar.ts # 2 widget API
│ │ │ ├─ types.ts # 共享类型
│ │ │ └─ universal.ts # 7 widget API
│ │ ├─ apollo-client.ts # Apollo Client 工厂 + 单例 + APQ 链
│ │ ├─ config-fetcher.ts # RSC 服务端拉 Config(三级降级)
│ │ ├─ types.ts # 共享类型契约(v2.0 含 requiredPermissions)
│ │ ├─ usePluginConfig.ts # SWR 静默刷新
│ │ ├─ useWidgetQuery.ts # 统一查询 Hook
│ │ └─ useWidgetMutation.ts # 统一变更 Hook
│ ├─ providers/
│ │ ├─ ApolloProvider.tsx
│ │ ├─ AuthProvider.tsx
│ │ └─ ThemeI18nProvider.tsx
│ ├─ shared/ # v2.0 共享层
│ │ ├─ lib/
│ │ │ ├─ route-permissions.ts # v2.0 路由权限配置表(4 张表 + checkRoutePermission)
│ │ │ ├─ notify.ts # v2.0 统一 Toast 封装
│ │ │ └─ utils.ts # v2.0 cn() 工具函数
│ │ └─ components/
│ │ ├─ plugin-boundary.tsx # v2.0 插件级错误边界 + Suspense + Skeleton
│ │ ├─ route-error-boundary.tsx # v2.0 Route 级错误边界
│ │ ├─ section-error-boundary.tsx # v2.0 Section 级错误边界
│ │ ├─ dashboard/
│ │ │ ├─ dashboard-shell.tsx # v2.0 仪表盘外壳
│ │ │ └─ dashboard-section.tsx # v2.0 仪表盘分区
│ │ ├─ layout/
│ │ │ ├─ sidebar-provider.tsx # v2.0 侧边栏状态
│ │ │ ├─ app-sidebar.tsx # v2.0 应用侧边栏
│ │ │ └─ site-header.tsx # v2.0 顶部头部
│ │ └─ ui/ # v2.0 shadcn/ui 组件库
│ │ ├─ button.tsx / card.tsx / badge.tsx
│ │ ├─ skeleton.tsx / input.tsx / tooltip.tsx
│ │ ├─ sonner.tsx # Toaster
│ │ ├─ page-header.tsx / stat-card.tsx
│ │ ├─ stats-grid.tsx / empty-state.tsx
│ │ └─ filter-bar.tsx
│ ├─ shell/
│ │ ├─ __tests__/
│ │ │ ├─ PluginLifecycle.test.ts # 12 用例
│ │ │ └─ Registry.test.ts # 6 用例
│ │ ├─ ClientShell.tsx # v2.0 use(configPromise) 流式 + ShellContent 拆分
│ │ ├─ LayoutManager.tsx # 5 Layout 模板(v2.0 令牌迁移)
│ │ ├─ PluginLifecycle.ts # 生命周期管理
│ │ ├─ PluginLoader.tsx # v2.0 re-export(向后兼容)
│ │ ├─ PluginStore.ts # Zustand 全局状态
│ │ ├─ PropsMerger.ts # 三层合并
│ │ ├─ Registry.tsx # 31 插件注册表
│ │ ├─ Shell.tsx # 微内核入口
│ │ └─ SlotRenderer.tsx # v2.0 PluginBoundary 包裹 + 信任输入
│ ├─ styles/
│ │ └─ tokens.css # 设计令牌映射(旧,待 P1 移除)
│ └─ widgets/
│ ├─ admin/ # 6 插件
│ │ ├─ audit-logs/
│ │ ├─ invitation-codes/
│ │ ├─ plugin-manager/
│ │ ├─ rbac-manager/
│ │ ├─ school-settings/
│ │ └─ user-management/
│ ├─ parent/ # 2 插件
│ │ ├─ child-overview/
│ │ └─ leave-approval/
│ ├─ sidebar/ # 4 插件
│ │ ├─ child-selector/
│ │ ├─ class-selector/
│ │ ├─ quick-actions/
│ │ └─ term-switcher/
│ ├─ student/ # 4 插件
│ │ ├─ ai-tutor/
│ │ ├─ elective-selector/
│ │ ├─ error-book/
│ │ └─ learning-path/
│ ├─ teacher/ # 4 插件
│ │ ├─ lesson-plan-editor/
│ │ ├─ question-bank/
│ │ ├─ scheduling-rules/
│ │ └─ textbook-manager/
│ ├─ topbar/ # 4 插件
│ │ ├─ global-search/
│ │ ├─ locale-switcher/
│ │ ├─ notification-bell/
│ │ └─ user-menu/
│ └─ universal/ # 7 插件
│ ├─ announcements-widget/
│ ├─ attendance-widget/
│ ├─ exams-widget/
│ ├─ grades-widget/
│ ├─ homework-widget/
│ ├─ notifications-widget/
│ └─ schedule-widget/
├─ .env.example
├─ components.json # v2.0 shadcn CLI 配置
├─ codegen.yml # graphql-codegen 配置
├─ Dockerfile # standalone 构建
├─ eslint.config.js # v2.0 含 no-restricted-imports(notify 强制)
├─ next.config.js # transpilePackages + 反向代理 + Turbopack
├─ package.json
├─ postcss.config.js
├─ public/
│ └─ pq-manifest.json # PQ Manifest(51 query hash → query 白名单)
├─ scripts/
│ ├─ generate-pq-manifest.ts # PQ Manifest 生成脚本
│ └─ normalize-schema.ts # schema 归一化(移除 federation 指令)
├─ tsconfig.json # paths 别名(v2.0 含 @edu/shared-ts/permission-bitmap)
├─ vitest.config.ts # jsdom + 别名
└─ README.md # 本文件
关联包文件清单:
packages/
├─ shared-ts/src/
│ ├─ permission-bitmap.ts # v2.0 权限位图工具(67 权限点 + base36)
│ └─ contracts/plugin.ts # v2.0 PluginManifest.metadata.requiredPermissions
├─ hooks/src/
│ └─ use-error-report.ts # v2.0 错误上报 Hook
├─ ui-components/src/ # v2.0 13 个文件令牌迁移完成
│ ├─ plugin-error-fallback.tsx
│ ├─ plugin-skeleton.tsx
│ ├─ plugin-card.tsx
│ ├─ slot-placeholder.tsx
│ ├─ status-badge.tsx
│ ├─ props-config-form.tsx
│ ├─ form.tsx
│ ├─ modal.tsx
│ ├─ data-table.tsx
│ ├─ filter-bar.tsx
│ ├─ chart.tsx
│ ├─ calendar.tsx
│ └─ rich-text-editor.tsx
└─ ui-tokens/src/
├─ primitive.css # v2.0 zinc/stone/indigo 色板
├─ semantic-light.css # v2.0 shadcn 标准语义令牌(light)
├─ semantic-dark.css # v2.0 shadcn 标准语义令牌(dark)
└─ tailwind-theme.css # v2.0 @theme inline
附录 B:常用命令
# 开发
pnpm --filter @edu/portal-shell run dev # 启动 dev server :4010(Turbopack)
pnpm --filter @edu/portal-shell run build # 生产构建(prebuild 自动 codegen + generate-pq-manifest)
pnpm --filter @edu/portal-shell run start # 生产启动
# 质量校验
pnpm --filter @edu/portal-shell run lint # ESLint(含 v2.0 no-restricted-imports: notify 强制)
pnpm --filter @edu/portal-shell run lint:tokens # 设计令牌专项
pnpm --filter @edu/portal-shell run typecheck # tsc --noEmit
pnpm --filter @edu/portal-shell run test # vitest run(95 用例)
# 数据层与安全栈
pnpm --filter @edu/portal-shell run codegen # graphql-codegen 生成 TS 类型
pnpm --filter @edu/portal-shell run generate-pq-manifest # 生成 public/pq-manifest.json
npx vitest run src/lib/api/__tests__/security.test.ts # 安全栈测试(10 用例)
# v2.0 验证(待补测试文件)
# npx vitest run packages/shared-ts/__tests__/permission-bitmap.test.ts # 权限位图(待补)
# npx vitest run src/shared/lib/__tests__/route-permissions.test.ts # 路由权限(待补)
# npx vitest run src/shared/lib/__tests__/notify.test.ts # notify 封装(待补)
# npx vitest run packages/hooks/__tests__/use-error-report.test.ts # 错误上报(待补)
# npx vitest run src/shared/components/__tests__/plugin-boundary.test.tsx # PluginBoundary(待补)
# 架构扫描
pnpm run arch:scan # 更新 arch.db
pnpm run arch:query -- module-deps # 查模块依赖
pnpm run arch:query -- violations # 查架构违规(含旧纸感令牌检测)
附录 C:关联文档索引
| 文档 | 用途 |
|---|---|
| 004 架构影响地图 | 架构设计意图唯一源(§11.7 数据访问层 + 安全栈、§16.5 子阶段、v2.0 ADR-044/045/046/047) |
| portal-shell 仪表盘 spec | 模块设计源(v1.0) |
| portal-shell 数据抽象与 GraphQL 加固 spec | 数据访问层 + 安全栈设计源(含 v2.0 三层安全边界 + 流式渲染 + 三级错误处理) |
| portal-shell 数据抽象与 GraphQL 加固 plan | 20-task 实施 plan |
| GraphQL @auth 审计报告 | 50 resolver @auth 审计(M4) |
| 项目规则 | 强制约束(§3.10 设计令牌、§14 多 AI 协作) |
| UI 设计系统 | 设计风格(v2.0 已迁移到 shadcn 标准) |
| known-issues §1.11/§2.17 | Apollo Router + portal-shell 已知问题速查 |
| local-stack runbook | 本地运维手册 |
| 端口分配 | 端口唯一源 |
本文件是 portal-shell 模块的架构文档(v2.0,2026-07-17),遵循 arc42 模板结构 + C4 模型可视化。后续代码变更须按 项目规则 §1 同步更新本文件 + 运行
pnpm run arch:scan更新 arch.db。v2.0 变更摘要:shadcn/ui 标准化 + Tailwind v4 + React 19 use() 流式渲染 + 三级错误边界 + 三层安全边界(权限位图 base36 压缩)+ notify 统一封装 + PluginBoundary 替代 PluginLoader。