From 9cedf0c43788f036b147829f30377c126099104f Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:10:05 +0800 Subject: [PATCH] feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等 - Tailwind v4 + @theme inline,移除 tailwind.config.js - React 19 use() + Suspense 流式渲染,首屏骨架秒出 - 三级错误边界:Route → Section → Widget 层层兜底 - 错误上报:useErrorReport → sendBeacon → /api/log mock 端点 - 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围 - 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99% - notify 统一 Toast 封装,禁止业务直接 import sonner - PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套) 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功 --- apps/portal-shell/.eslintrc.tokens.js | 14 +- apps/portal-shell/README.md | 1231 ++++++--- apps/portal-shell/components.json | 21 + apps/portal-shell/eslint.config.js | 10 +- apps/portal-shell/next-env.d.ts | 3 +- apps/portal-shell/next.config.js | 22 +- apps/portal-shell/package.json | 37 +- apps/portal-shell/postcss.config.js | 11 +- apps/portal-shell/scripts/normalize-schema.ts | 46 +- apps/portal-shell/src/app/api/log/route.ts | 56 + apps/portal-shell/src/app/globals.css | 63 +- apps/portal-shell/src/app/layout.tsx | 46 +- .../src/app/shell/[[...route]]/page.tsx | 30 +- apps/portal-shell/src/app/shell/error.tsx | 30 + apps/portal-shell/src/app/shell/loading.tsx | 104 + .../src/lib/__tests__/plugin-context.test.ts | 130 + apps/portal-shell/src/lib/config-fetcher.ts | 107 +- apps/portal-shell/src/lib/types.ts | 6 + .../portal-shell/src/lib/useWidgetMutation.ts | 2 +- apps/portal-shell/src/lib/useWidgetQuery.ts | 5 +- .../dashboard/dashboard-section.tsx | 211 ++ .../components/dashboard/dashboard-shell.tsx | 64 + .../shared/components/layout/app-sidebar.tsx | 238 ++ .../components/layout/sidebar-provider.tsx | 117 + .../shared/components/layout/site-header.tsx | 96 + .../src/shared/components/plugin-boundary.tsx | 216 ++ .../components/route-error-boundary.tsx | 76 + .../components/section-error-boundary.tsx | 94 + .../src/shared/components/ui/badge.tsx | 37 + .../src/shared/components/ui/button.tsx | 60 + .../src/shared/components/ui/card.tsx | 75 + .../src/shared/components/ui/empty-state.tsx | 90 + .../src/shared/components/ui/filter-bar.tsx | 118 + .../src/shared/components/ui/input.tsx | 31 + .../src/shared/components/ui/page-header.tsx | 64 + .../src/shared/components/ui/separator.tsx | 26 + .../src/shared/components/ui/skeleton.tsx | 18 + .../src/shared/components/ui/sonner.tsx | 38 + .../src/shared/components/ui/stat-card.tsx | 127 + .../src/shared/components/ui/stats-grid.tsx | 50 + .../src/shared/components/ui/tooltip.tsx | 38 + apps/portal-shell/src/shared/lib/notify.ts | 73 + .../src/shared/lib/route-permissions.ts | 470 ++++ apps/portal-shell/src/shared/lib/utils.ts | 54 + apps/portal-shell/src/shell/ClientShell.tsx | 186 +- apps/portal-shell/src/shell/LayoutManager.tsx | 61 +- .../portal-shell/src/shell/PluginLifecycle.ts | 114 + apps/portal-shell/src/shell/PluginLoader.tsx | 161 +- apps/portal-shell/src/shell/Registry.tsx | 275 +- apps/portal-shell/src/shell/SlotRenderer.tsx | 86 +- .../shell/__tests__/PluginLifecycle.test.ts | 104 + .../src/shell/__tests__/Registry.test.ts | 101 + apps/portal-shell/src/styles/tokens.css | 5 +- apps/portal-shell/tailwind.config.js | 83 - apps/portal-shell/tsconfig.json | 9 +- apps/portal-shell/vitest.config.ts | 23 +- docs/runbooks/local-stack.md | 411 +++ docs/troubleshooting/known-issues.md | 61 +- infra/apollo-router/Dockerfile | 24 +- infra/apollo-router/dev-entrypoint.sh | 48 + infra/apollo-router/dev-supergraph.yaml | 48 + package.json | 2 +- packages/hooks/package.json | 9 +- packages/hooks/src/index.ts | 55 +- packages/hooks/src/use-auth.ts | 2 +- packages/hooks/src/use-error-report.ts | 190 ++ packages/hooks/src/use-permission.ts | 2 +- packages/hooks/src/use-plugin-config.ts | 133 + packages/hooks/src/use-plugin-store.ts | 80 + packages/hooks/src/use-toast.ts | 2 +- packages/hooks/src/use-viewports.ts | 2 +- packages/shared-ts/package.json | 12 + packages/shared-ts/src/contracts/index.ts | 41 + packages/shared-ts/src/contracts/layout.ts | 135 + .../shared-ts/src/contracts/plugin-context.ts | 105 + .../shared-ts/src/contracts/plugin-store.ts | 36 + packages/shared-ts/src/contracts/plugin.ts | 134 + packages/shared-ts/src/env-loader/index.ts | 102 + packages/shared-ts/src/permission-bitmap.ts | 273 ++ packages/ui-components/package.json | 12 +- packages/ui-components/src/calendar.tsx | 21 +- packages/ui-components/src/chart.tsx | 2 +- packages/ui-components/src/data-table.tsx | 25 +- packages/ui-components/src/empty.tsx | 2 +- packages/ui-components/src/filter-bar.tsx | 6 +- packages/ui-components/src/form.tsx | 20 +- packages/ui-components/src/index.ts | 72 +- packages/ui-components/src/loading.tsx | 2 +- packages/ui-components/src/modal.tsx | 12 +- packages/ui-components/src/plugin-card.tsx | 75 + .../src/plugin-error-fallback.tsx | 62 + .../ui-components/src/plugin-skeleton.tsx | 138 + .../ui-components/src/props-config-form.tsx | 182 ++ .../ui-components/src/rich-text-editor.tsx | 16 +- .../ui-components/src/slot-placeholder.tsx | 58 + packages/ui-components/src/status-badge.tsx | 18 +- packages/ui-components/src/utils/cn.ts | 78 +- packages/ui-tokens/src/primitive.css | 192 +- packages/ui-tokens/src/semantic-dark.css | 111 +- packages/ui-tokens/src/semantic-light.css | 128 +- packages/ui-tokens/src/tailwind-theme.css | 146 +- pnpm-lock.yaml | 2211 +++++++++++++++-- pnpm-workspace.yaml | 6 + scripts/config-seed.sql | 137 + scripts/dev-service.ps1 | 54 + scripts/health-check.ps1 | 159 +- scripts/start-all.ps1 | 520 ++-- scripts/stop-all.ps1 | 95 +- scripts/test-introspect.json | 1 + scripts/test-plugin-config.json | 1 + scripts/test-schema-fields.json | 1 + scripts/test-schema-query.json | 1 + scripts/test-service-sdl.json | 1 + services/classes/package.json | 1 + services/classes/src/main.ts | 1 + .../src/shared/observability/logger.ts | 13 +- .../src/graphql/generated/schema.graphql | 74 + .../src/graphql/graphql.module.ts | 4 +- .../src/graphql/router-auth.guard.ts | 7 +- services/config-service/src/main.ts | 1 + .../src/shared/errors/global-error.filter.ts | 29 + .../src/graphql/generated/schema.graphql | 459 +--- .../content/src/graphql/graphql.module.ts | 4 +- .../content/src/graphql/router-auth.guard.ts | 7 +- services/content/src/main.ts | 1 + .../src/graphql/generated/schema.graphql | 767 +----- .../core-edu/src/graphql/graphql.module.ts | 12 +- .../core-edu/src/graphql/router-auth.guard.ts | 7 +- services/core-edu/src/main.ts | 1 + .../src/middleware/auth.middleware.ts | 8 + .../iam/src/graphql/generated/schema.graphql | 204 +- services/iam/src/graphql/graphql.module.ts | 6 +- services/iam/src/graphql/router-auth.guard.ts | 7 +- services/iam/src/main.ts | 1 + .../msg/src/graphql/generated/schema.graphql | 259 +- services/msg/src/graphql/graphql.module.ts | 4 +- services/msg/src/graphql/router-auth.guard.ts | 7 +- services/msg/src/main.ts | 1 + tsconfig.base.json | 2 +- uv.lock | 42 + 140 files changed, 10872 insertions(+), 3192 deletions(-) create mode 100644 apps/portal-shell/components.json create mode 100644 apps/portal-shell/src/app/api/log/route.ts create mode 100644 apps/portal-shell/src/app/shell/error.tsx create mode 100644 apps/portal-shell/src/app/shell/loading.tsx create mode 100644 apps/portal-shell/src/lib/__tests__/plugin-context.test.ts create mode 100644 apps/portal-shell/src/shared/components/dashboard/dashboard-section.tsx create mode 100644 apps/portal-shell/src/shared/components/dashboard/dashboard-shell.tsx create mode 100644 apps/portal-shell/src/shared/components/layout/app-sidebar.tsx create mode 100644 apps/portal-shell/src/shared/components/layout/sidebar-provider.tsx create mode 100644 apps/portal-shell/src/shared/components/layout/site-header.tsx create mode 100644 apps/portal-shell/src/shared/components/plugin-boundary.tsx create mode 100644 apps/portal-shell/src/shared/components/route-error-boundary.tsx create mode 100644 apps/portal-shell/src/shared/components/section-error-boundary.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/badge.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/button.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/card.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/empty-state.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/filter-bar.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/input.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/page-header.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/separator.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/skeleton.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/sonner.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/stat-card.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/stats-grid.tsx create mode 100644 apps/portal-shell/src/shared/components/ui/tooltip.tsx create mode 100644 apps/portal-shell/src/shared/lib/notify.ts create mode 100644 apps/portal-shell/src/shared/lib/route-permissions.ts create mode 100644 apps/portal-shell/src/shared/lib/utils.ts create mode 100644 apps/portal-shell/src/shell/PluginLifecycle.ts create mode 100644 apps/portal-shell/src/shell/__tests__/PluginLifecycle.test.ts create mode 100644 apps/portal-shell/src/shell/__tests__/Registry.test.ts delete mode 100644 apps/portal-shell/tailwind.config.js create mode 100644 docs/runbooks/local-stack.md create mode 100644 infra/apollo-router/dev-entrypoint.sh create mode 100644 infra/apollo-router/dev-supergraph.yaml create mode 100644 packages/hooks/src/use-error-report.ts create mode 100644 packages/hooks/src/use-plugin-config.ts create mode 100644 packages/hooks/src/use-plugin-store.ts create mode 100644 packages/shared-ts/src/contracts/index.ts create mode 100644 packages/shared-ts/src/contracts/layout.ts create mode 100644 packages/shared-ts/src/contracts/plugin-context.ts create mode 100644 packages/shared-ts/src/contracts/plugin-store.ts create mode 100644 packages/shared-ts/src/contracts/plugin.ts create mode 100644 packages/shared-ts/src/env-loader/index.ts create mode 100644 packages/shared-ts/src/permission-bitmap.ts create mode 100644 packages/ui-components/src/plugin-card.tsx create mode 100644 packages/ui-components/src/plugin-error-fallback.tsx create mode 100644 packages/ui-components/src/plugin-skeleton.tsx create mode 100644 packages/ui-components/src/props-config-form.tsx create mode 100644 packages/ui-components/src/slot-placeholder.tsx create mode 100644 scripts/config-seed.sql create mode 100644 scripts/dev-service.ps1 create mode 100644 scripts/test-introspect.json create mode 100644 scripts/test-plugin-config.json create mode 100644 scripts/test-schema-fields.json create mode 100644 scripts/test-schema-query.json create mode 100644 scripts/test-service-sdl.json create mode 100644 services/config-service/src/graphql/generated/schema.graphql diff --git a/apps/portal-shell/.eslintrc.tokens.js b/apps/portal-shell/.eslintrc.tokens.js index 9608e7a..2fa1cc9 100644 --- a/apps/portal-shell/.eslintrc.tokens.js +++ b/apps/portal-shell/.eslintrc.tokens.js @@ -4,16 +4,10 @@ * 与 eslint.config.js 中的 design-tokens 规则等价,保留以对齐 teacher-portal 习惯。 * 关联:project_rules §3.10 */ -let tsParser; -try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - tsParser = require("@typescript-eslint/parser"); -} catch { - tsParser = undefined; -} +import tsParser from "@typescript-eslint/parser"; /** @type {import('eslint').Linter.Config[]} */ -module.exports = [ +export default [ { files: ["**/*.{ts,tsx,js,jsx}"], languageOptions: { @@ -28,12 +22,12 @@ module.exports = [ "no-restricted-syntax": [ "error", { - selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]', + selector: "Literal[value=/^#[0-9a-fA-F]{3,8}$/]", message: "禁止硬编码颜色 #hex,使用 var(--*) 或 Tailwind bg-* 类(project_rules §3.10)", }, { - selector: 'Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]', + selector: "Literal[value=/^(Inter|Fraunces|JetBrains Mono)$/]", message: "禁止硬编码字体名字面量,使用 var(--font-family-sans/serif/mono)(project_rules §3.10)", }, diff --git a/apps/portal-shell/README.md b/apps/portal-shell/README.md index 5f97c31..53bb2f5 100644 --- a/apps/portal-shell/README.md +++ b/apps/portal-shell/README.md @@ -1,8 +1,8 @@ # portal-shell 模块架构文档 -> 版本:1.1 +> 版本:2.0 > 日期:2026-07-17 -> 状态:已落地(v2.1 M8-M12 完成 + 2026-07-17 数据抽象与 GraphQL 加固 M1-M4 完成) +> 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功) > 架构范式:Modular Monolith + Micro-kernel(单 Next.js App Router 容器 + 插件化仪表盘) > 关联文档: > @@ -30,8 +30,9 @@ 10. [架构决策记录(ADR 索引)](#10-架构决策记录adr-索引) 11. [质量要求与验收](#11-质量要求与验收) 12. [风险、技术债与演进路线](#12-风险技术债与演进路线) -13. [数据访问层与 GraphQL 安全栈(v1.1 新增)](#13-数据访问层与-graphql-安全栈v11-新增) -14. [术语表](#14-术语表) +13. [数据访问层与 GraphQL 安全栈](#13-数据访问层与-graphql-安全栈) +14. [v2.0 安全边界与错误处理](#14-v20-安全边界与错误处理) +15. [术语表](#15-术语表) --- @@ -42,7 +43,12 @@ portal-shell 是 Edu 平台 v2.1 架构重设计后的**唯一前端入口**,以单 Next.js App Router 容器替代 v1.0 的 4 个独立 portal(teacher / student / parent / admin)+ Module Federation 微前端方案。它承载教师、学生、家长、管理员四类角色的全部教学场景 UI,通过 **Micro-kernel + 插件化** 机制实现功能扩展。 - **服务端口**:4010(HTTP) -- **技术栈**:TypeScript 5.6 + Next.js 14 App Router + React 18 + Tailwind + Zustand + SWR + Apollo Client +- **技术栈**(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/google` self-host → `--font-inter` CSS 变量) - **架构风格**:Modular Monolith(单体)+ Micro-kernel(微内核插件) - **部署形态**:单 Docker 容器(`output: "standalone"`) - **上游依赖**:api-gateway(JWT 校验 + 反向代理)、apollo-router(GraphQL 联邦入口) @@ -50,15 +56,19 @@ portal-shell 是 Edu 平台 v2.1 架构重设计后的**唯一前端入口**, ### 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 | **设计系统一致**:所有插件使用 `@edu/ui-tokens`,禁硬编码颜色/字体/字号 | ESLint `no-restricted-syntax` + `design-tokens/no-hardcoded-fonts` 零违规 | -| G6 | **按需加载**:dynamic import 懒加载,首屏只加载可见 slot 插件 | 首屏 JS bundle ≤ 300KB(gzip) | -| G7 | **跨角色复用**:universal 插件按 role 渲染不同视图,一套代码服务多角色 | universal 插件复用率 100% | +| # | 目标 | 衡量标准 | +| --- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| 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 非目标 @@ -73,15 +83,16 @@ portal-shell 是 Edu 平台 v2.1 架构重设计后的**唯一前端入口**, ### 2.1 全局约束(来自项目规则) -| 约束 | 来源 | portal-shell 落地方式 | -| ------------------------------- | ------------------- | ----------------------------------------------------------------- | -| 禁硬编码颜色(`#hex`) | project_rules §3.10 | ESLint `no-restricted-syntax` + Tailwind `bg-*` 类 | -| 禁硬编码字体(`'Inter'`) | project_rules §3.10 | `next/font/google` + CSS 变量 `--font-family-*` | -| 禁 Tailwind 任意值(`w-[Npx]`) | project_rules §3.10 | 映射到 `--space-*` 或 Tailwind 默认阶梯 | -| 函数返回值显式标注 | project_rules §3.4 | 所有插件 `React.ReactElement` / `Promise` | -| 禁 `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` 映射 | +| 约束 | 来源 | 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` | +| 禁 `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 协作) @@ -170,8 +181,81 @@ graph TB | `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 新增) + +> 来源:[三层安全边界设计](../../docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) §v2.0 +> 关联:[权限位图工具](../../packages/shared-ts/src/permission-bitmap.ts)、[路由权限配置](../../apps/portal-shell/src/shared/lib/route-permissions.ts) + +```mermaid +graph LR + subgraph Request["用户请求"] + R["路由进入
/shell/*"] + end + subgraph L1["L1 角色门禁"] + L1Check["checkRoutePermission
requiredRoles?"] + end + subgraph L2["L2 权限点门禁"] + L2Check["位图校验
requiredPermissions?"] + end + subgraph L3["L3 数据范围"] + L3Check["DataScope 6 级
config-service 过滤"] + end + subgraph Plugin["插件渲染"] + P["SlotRenderer 信任输入
不再二次过滤"] + 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 | 不渲染无可见数据的插件 | + +**关键约束**: + +1. **路由权限配置集中管理**:4 张表(精确路由 / 前缀路由 / 仪表盘路由 / API 路由)按优先级匹配,新增路由必须在表中登记 +2. **L2 权限点必须来自 PERMISSION_BITMAP_ORDER(67 个权限点)**:开发时通过 `validateRoutePermissionConfigs()` 校验合法性 +3. **SlotRenderer 信任输入**:L1/L2/L3 三层过滤在 RSC 服务端完成,客户端 SlotRenderer 不再二次过滤(性能优化) +4. **权限位图压缩 JWT 体积**:67 权限点 → base36 字符串(~14 字符)替代 JSON 数组(~600 字符),体积减少 ≥ 99% +5. **批量检查 API**:`batchCheckRoutePermission(paths, bitmap, role)` 用于侧边栏导航批量过滤 + +**路由权限配置示例**: + +```typescript +// 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. 解决方案策略 @@ -236,19 +320,22 @@ PluginProps.initialData ← RSC 服务端预取的初始数据(SWR fallbackDat graph TB subgraph App["apps/portal-shell(单 Next.js 容器)"] subgraph AppRouter["app/(Next.js App Router)"] - Layout["layout.tsx
RootLayout + 字体"] + Layout["layout.tsx
RootLayout + Inter 字体"] RootPage["page.tsx
重定向 /shell"] - ShellPage["shell/[[...route]]/page.tsx
RSC 入口"] + ShellPage["shell/[[...route]]/page.tsx
RSC 入口 + 流式 Promise"] + ShellError["shell/error.tsx
Route 错误兜底(v2.0)"] + ShellLoading["shell/loading.tsx
Route 加载骨架(v2.0)"] HealthAPI["api/health/route.ts"] ReadyAPI["api/ready/route.ts"] + LogAPI["api/log/route.ts
错误上报 mock 端点(v2.0)"] end subgraph ShellCore["shell/(微内核)"] Shell["Shell.tsx"] - ClientShell["ClientShell.tsx"] + ClientShell["ClientShell.tsx
use(configPromise) 流式"] LayoutMgr["LayoutManager.tsx
5 Layout 模板"] - SlotRend["SlotRenderer.tsx"] - Loader["PluginLoader.tsx
+ ErrorBoundary"] + SlotRend["SlotRenderer.tsx
PluginBoundary 包裹"] + Loader["PluginLoader.tsx
re-export 向后兼容"] Registry["Registry.tsx
31 插件"] Lifecycle["PluginLifecycle.ts"] Store["PluginStore.ts
Zustand"] @@ -264,13 +351,19 @@ graph TB Types["types.ts"] end - subgraph ApiLayer["lib/api/(v1.1 数据访问层)"] + subgraph ApiLayer["lib/api/(数据访问层)"] ApiDomain[".ts ×7
parent/teacher/admin/student/
universal/sidebar/topbar"] ApiOps["operations/*.graphql.ts
51 DocumentNode"] ApiTypes["operations/types.ts
codegen 生成"] ApiErrors["errors.ts
ApiError 归一化"] end + subgraph Shared["shared/(v2.0 共享层)"] + SharedLib["shared/lib/
route-permissions.ts
notify.ts / utils.ts"] + SharedComp["shared/components/
plugin-boundary.tsx
route-error-boundary.tsx
section-error-boundary.tsx
dashboard/* / layout/* / ui/*"] + SharedHooks["@edu/hooks
use-error-report.ts"] + end + subgraph Providers["providers/"] ApolloProv["ApolloProvider.tsx"] AuthProv["AuthProvider.tsx"] @@ -289,13 +382,13 @@ graph TB end ShellPage -->|RSC 调用| ConfigFetch - ShellPage -->|props| ClientShell - ClientShell --> Shell + ShellPage -->|configPromise| ClientShell + ClientShell -->|use()| Shell Shell --> LayoutMgr LayoutMgr --> SlotRend + SlotRend --> SharedComp SlotRend --> Registry - SlotRend --> Loader - Loader --> Widgets + SharedComp --> Widgets ClientShell --> UseConfig UseConfig --> Apollo Widgets --> ApiDomain @@ -305,39 +398,53 @@ graph TB ApiOps --> ApiTypes UseQuery --> Apollo UseMut --> Apollo + SharedComp -.->|onError| LogAPI + SharedLib -.->|权限校验| SharedLib ``` ### 5.2 组件职责矩阵 -| 层 | 文件 | 职责 | 关键契约 | -| -------------- | ----------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------ | -| **app/** | `shell/[[...route]]/page.tsx` | RSC 入口,服务端拉 Config + 预取 initialData | `fetchPluginConfig(userId, role)` | -| **app/** | `layout.tsx` | RootLayout,挂载 `next/font/google` 字体变量 | `--font-inter` / `--font-fraunces` / `--font-jetbrains-mono` | -| **app/** | `page.tsx` | 根路径重定向到 `/shell` | `redirect("/shell")` | -| **shell/** | `Shell.tsx` | 微内核入口,选择 LayoutManager 模板 | `config.activeLayout.layoutId` | -| **shell/** | `ClientShell.tsx` | 客户端入口,挂载 Providers + SWR 配置刷新 | `usePluginConfig(initialConfig)` | -| **shell/** | `LayoutManager.tsx` | 5 种 Layout 模板渲染器 | classic / focus / split / triple / canvas | -| **shell/** | `SlotRenderer.tsx` | 按 slot 过滤+排序+查表+注入 PluginProps | `PluginPlacement[]` | -| **shell/** | `PluginLoader.tsx` | dynamic loading 骨架 + ErrorBoundary 隔离 | `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)` | -| **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/** | `.ts ×7` | 语义化函数 API 层(v1.1) | `useMyChildren()` / `saveLessonPlan()` 等,禁止 widget 直接写 gql | -| **lib/api/** | `operations/*.graphql.ts` | gql DocumentNode 集中存放(v1.1) | 51 个 query/mutation 常量 | -| **lib/api/** | `operations/types.ts` | codegen 生成的 TS 类型(v1.1) | 从 7 子图 schema.graphql 生成 | -| **lib/api/** | `errors.ts` | ApiError 归一化(v1.1) | `toApiError(graphQLErrors)` 统一错误模型 | -| **providers/** | `ApolloProvider.tsx` | 注入 Apollo Client 单例 | `getApolloClient(readToken)` | -| **providers/** | `AuthProvider.tsx` | 提供 `useAuth()`,从 RSC props 下发用户身份 | `AuthUser` context | -| **providers/** | `ThemeI18nProvider.tsx` | 主题类名同步到 `` + 简易 i18n | `usePluginStore` theme/locale | -| **widgets/** | `*/index.tsx` | 插件入口,接收 `PluginProps`,default export | `PluginProps` 契约 | -| **widgets/** | `*/plugin.manifest.ts` | 插件元数据声明 | `manifestMeta: Omit` | +| 层 | 文件 | 职责 | 关键契约 | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| **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/** | `.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` | 主题类名同步到 `` + 简易 i18n | `usePluginStore` theme/locale | +| **widgets/** | `*/index.tsx` | 插件入口,接收 `PluginProps`,default export | `PluginProps` 契约 | +| **widgets/** | `*/plugin.manifest.ts` | 插件元数据声明 | `manifestMeta: Omit`(v2.0 含 `requiredPermissions?`) | ### 5.3 Layout 模板清单 @@ -372,6 +479,158 @@ Layer 3: 用户覆盖(user_layout_override 表) - 数组、原始值后者覆盖前者 - `null` / `undefined` 跳过 +### 5.5 流式渲染(v2.0 新增) + +> 来源:[React 19 use() + Suspense 流式渲染](https://react.dev/reference/react/use) +> 关联:`apps/portal-shell/src/app/shell/[[...route]]/page.tsx` + `apps/portal-shell/src/shell/ClientShell.tsx` + +```mermaid +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 流式拆分**: + +```typescript +// 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 ; +} + +export function ClientShell(props: ClientShellProps): ReactNode { + return ( + + + + {/* Suspense 包裹 ShellContent,Provider 不被暂停 */} + }> + + + + + + ); +} +``` + +**关键约束**: + +1. **Provider 必须在 Suspense 外**:ApolloProvider / AuthProvider / ThemeI18nProvider 不能被 `use()` 暂停,否则子树丢失 Context +2. **RSC 不 await Promise**:`fetchPluginConfig` 返回 Promise 直接传给客户端,服务端不阻塞 +3. **客户端 `use()` 消费**:React 19 的 `use()` Hook 可在 Suspense 边界内消费 Promise +4. **单插件独立 Suspense**:每个插件用 `` 包裹,加载失败或慢不影响其他插件 + +### 5.6 三级错误处理(v2.0 新增) + +> 关联:`shared/components/route-error-boundary.tsx` / `section-error-boundary.tsx` / `plugin-boundary.tsx` + `@edu/hooks/use-error-report.ts` + +```mermaid +graph TB + subgraph Route["Route 级(最高优先级)"] + REB["RouteErrorBoundary
app/shell/error.tsx"] + RFallback["整页错误页
含错误 digest + 重试按钮"] + end + subgraph Section["Section 级"] + SEB["SectionErrorBoundary
DashboardSection 内"] + SFallback["区块级错误卡片
显示 Section 标题 + 重试"] + end + subgraph Widget["Widget 级(最细粒度)"] + WEB["PluginBoundary
SlotRenderer 内每个插件"] + WFallback["PluginErrorFallback
显示 instanceId + 重试"] + end + subgraph Report["错误上报链路"] + Hook["useErrorReport
sendBeacon + sessionStorage 节流"] + API["/api/log
mock 端点"] + Store["sessionStorage
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 + 重试) | + +**错误上报链路**: + +```typescript +// @edu/hooks/use-error-report.ts +export function useErrorReport() { + return useCallback((error: Error, context?: Record) => { + 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, + }), + ); + }, []); +} +``` + +**关键约束**: + +1. **三级边界不可跳过**:每个 widget 必须用 `` 包裹,Section 必须用 ``,Route 必须有 `error.tsx` +2. **错误上报节流**:`sessionStorage` 1 分钟同 digest 去重,避免崩溃循环刷爆日志端点 +3. **sendBeacon 优先**:页面卸载时也能发出请求,不阻塞 unload +4. **digest 唯一标识**:基于 `message + stack` 的 sha256,便于后端聚合相同错误 +5. **mock 端点**:`/api/log` 当前为 Next.js API Route,仅开发态日志输出,生产由后端 `/api/v1/log` 替换 + --- ## 6. 插件目录与分类 @@ -412,7 +671,7 @@ interface PluginProps> { ### 6.3 插件 Manifest 契约 -每个插件通过 `plugin.manifest.ts` 声明元数据: +每个插件通过 `plugin.manifest.ts` 声明元数据(v2.0 新增 `requiredPermissions` 字段): ```typescript export const manifestMeta: Omit = { @@ -424,6 +683,7 @@ export const manifestMeta: Omit = { description: "查看班级成绩", category: "universal", requiredRoles: ["teacher", "student", "parent"], + requiredPermissions: ["grade.read"], // v2.0 新增:L2 权限点门禁(AND 语义) defaultSlot: "main", defaultSize: { colSpan: 2, rowSpan: 1 }, defaultProps: { limit: 20 }, @@ -438,6 +698,13 @@ export const manifestMeta: Omit = { }; ``` +**v2.0 `requiredPermissions` 字段说明**: + +- **空数组或 undefined**:仅 L1 角色门禁生效 +- **非空数组**:用户必须同时拥有所有权限点(**AND 语义**) +- **权限点必须来自 `PERMISSION_BITMAP_ORDER`**(67 个权限点),运行时由 `isValidPermission` 校验 +- **配合路由权限表**:路由级 L2 由 `route-permissions.ts` 检查,插件级 L2 由 Manifest 检查(config-service 三层合并时过滤) + ### 6.4 插件生命周期 | 阶段 | 内置插件 | 第三方插件(二期) | @@ -453,9 +720,9 @@ export const manifestMeta: Omit = { ## 7. 运行时视图(关键场景) -### 7.1 场景一:首屏加载(RSC 服务端预取) +### 7.1 场景一:首屏加载(RSC 服务端预取 + 流式渲染) -**目标**:消除 CSR 瀑布流,实现仪表盘"秒开" +**目标**:消除 CSR 瀑布流,实现仪表盘"秒开",首屏 HTML 直出骨架 ```mermaid sequenceDiagram @@ -466,32 +733,33 @@ sequenceDiagram participant S as 业务子图 U->>N: GET /shell - N->>N: headers() 读取 x-user-id / x-user-role - N->>R: query pluginConfig(userId, role) + 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(activeLayout + plugins[]) + R-->>N: Config Promise resolve N->>N: PropsMerger 解析 propsJson / sizeJson - N->>R: 并发预取各插件 initialData(Promise.all) - R->>S: 路由到对应子图 - S-->>R: 插件初始数据 - R-->>N: initialData[] - N-->>U: HTML 直出(含 Config + initialData) - U->>U: 水合 → dynamic import 插件组件 + N-->>U: 流式注入 SlotRenderer + PluginBoundary + Note over U: 每个插件独立 Suspense,dynamic import 流式加载 U->>U: 插件用 initialData 作为 SWR fallbackData 渲染 + U->>U: 加载完成的插件立即渲染,不影响其他插件 ``` -**对比 v2.0 CSR 瀑布流**: +**对比 v1.0 CSR 瀑布流**: ``` -v2.0(4 层串行,LCP 差): +v1.0(4 层串行,LCP 差): HTML 骨架 → 水合 → 拉 Config → import 插件 → 插件拉数据 → 渲染 总耗时 = SSR + 水合 + Config RTT + import RTT + BFF RTT -v2.1 RSC 预取(2 层并行,LCP 秒开): - 服务端:RSC 拉 Config + 并发预取 initialData → HTML 直出 - 客户端:水合 → dynamic import → 用 initialData 渲染 - 总耗时 = max(Config RTT, BFF RTT) + 水合 + import RTT +v2.0 RSC 流式渲染(首屏秒开 + 流式注入): + 服务端:RSC 返回 Promise → 立即输出 HTML 骨架 → Config resolve 后流式注入 + 客户端:水合骨架 → use(configPromise) 解析 → dynamic import → 用 initialData 渲染 + 总耗时 = max(骨架渲染, Config RTT) + 水合 + import RTT + 收益:用户提前看到骨架,感知性能大幅提升 ``` ### 7.2 场景二:配置变更生效(SWR 静默刷新) @@ -551,22 +819,32 @@ sequenceDiagram - 浏览器前进后退天然支持 - React DevTools 可追踪状态变更 -### 7.4 场景四:插件加载失败(ErrorBoundary 隔离) +### 7.4 场景四:插件加载失败(PluginBoundary 三件套隔离) ```mermaid graph TB - A[SlotRenderer 渲染插件] --> B{dynamic import 成功?} - B -- 是 --> C[组件渲染] - B -- 否 --> D[PluginErrorBoundary 捕获] - D --> E[PluginErrorFallback 显示] - E --> F[显示错误图标 + instanceId] - E --> G[重试按钮] - G --> H[重置 hasError=false 重新加载] + 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] - I[其他插件] -.->|不受影响| J[正常渲染] + L[同 Slot 其他插件] -.->|不受影响| M[正常渲染] + N[其他 Slot] -.->|不受影响| O[正常渲染] + P[整页] -.->|Route 兜底| Q[RouteErrorBoundary] ``` -**关键约束**:单个插件失败不影响其他插件,ErrorBoundary 隔离作用域。 +**关键约束**: + +- 单个插件失败**只影响自身**,同 Slot 其他插件 / 其他 Slot / 整页均不受影响 +- 三级错误边界(Route → Section → Widget)层层兜底,最坏情况整页崩溃也有 `app/shell/error.tsx` 兜底 +- 错误自动上报到 `/api/log`(开发态 mock)/ `/api/v1/log`(生产态,待后端实现) +- `sessionStorage` 节流避免崩溃循环刷爆日志端点 --- @@ -636,56 +914,98 @@ graph LR ## 9. 横切概念 -### 9.1 设计令牌(强制) +### 9.1 设计令牌(shadcn 标准化,v2.0) **三层令牌模型**(project_rules §3.10): -| Layer | 用途 | 位置 | 业务代码引用 | -| ----------------- | ----------------------- | ------------------------------------------- | ---------------------------------- | -| L1 Primitive | 原始色板/字号/间距/阴影 | `packages/ui-tokens/src/primitive.css` | ❌ 禁止直接引用 | -| L2 Semantic | 语义令牌(light/dark) | `packages/ui-tokens/src/semantic-*.css` | ✅ 唯一引用入口(`hsl(var(--*))`) | -| L3 Tailwind Theme | 暴露为 Tailwind 类 | `packages/ui-tokens/src/tailwind-theme.css` | ✅ `bg-*` / `text-*` / `font-*` | +| 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'` / `'Fraunces'` 字面量 -- 白名单:`primitive.css`、`email-channel`、`manifest.ts` +- `design-tokens/no-hardcoded-fonts`:禁止 `'Inter'` 字面量 +- 白名单:`primitive.css`、`semantic-*.css`、`tailwind-theme.css`、`email-channel`、`manifest.ts` -**portal-shell 落地**: +**portal-shell v2.0 落地**: -- 字体通过 `next/font/google` self-host,CSS 变量暴露为 `--font-inter` / `--font-fraunces` / `--font-jetbrains-mono` -- 所有插件使用 Tailwind 类(`bg-surface` / `text-ink` / `border-rule` / `rounded-card` 等) -- `tailwind.config.js` 映射 semantic 令牌到 Tailwind 类 +- 字体通过 `next/font/google` self-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 字体策略 +### 9.2 字体策略(v2.0 简化) -| 字体族 | 用途 | CSS 变量 | Tailwind 类 | -| -------------- | --------------------- | --------------------- | ------------ | -| Inter | UI 文本(sans-serif) | `--font-family-sans` | `font-sans` | -| Fraunces | 主标题/正文(serif) | `--font-family-serif` | `font-serif` | -| JetBrains Mono | 代码/等宽 | `--font-family-mono` | `font-mono` | +| 字体族 | 用途 | CSS 变量 | Tailwind 类 | +| ------ | ----------------------- | -------------- | ----------- | +| Inter | UI 文本 + 主标题 + 等宽 | `--font-inter` | `font-sans` | -### 9.3 纸感编辑器设计风格 +> v2.0 简化字体策略:仅使用 Inter 单一字体族(对齐 CICD 项目风格),废弃 Fraunces / JetBrains Mono 双字体方案。如需等宽,使用 Tailwind 默认 `font-mono`。 -参考 `docs/standards/ui-design-system.md`: +### 9.3 shadcn 标准化设计风格(v2.0) -- **背景**:纸感 `hsl(var(--paper))` / `hsl(var(--surface))` -- **圆角**:`var(--radius-card)` / `var(--radius-button)` -- **间距**:`var(--space-xs)` ~ `var(--space-xl)` -- **插件容器**:`
` -- **插件标题**:`text-heading-3 text-ink` -- **加载态**:`` +参考 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`) +- **插件容器**:`
` +- **插件标题**:`text-lg text-foreground` +- **加载态**:``(5 种骨架变体) - **错误态**:`` +- **统一 Toast**:`notify.info/success/warning/error`(禁止业务直接 `import { toast } from "sonner"`) ### 9.4 可观测性 -| 维度 | 实现 | 端点 | -| -------- | --------------------------------------------------------- | ---------------------------------- | -| 健康检查 | liveness + readiness | `/api/health` + `/api/ready` | -| 错误日志 | `console.error` + `PluginErrorBoundary componentDidCatch` | 浏览器控制台 | -| 性能 | Next.js 内置 Web Vitals(可接 OTel) | Next.js 自动采集 | -| 请求追踪 | Apollo Client 自动携带 traceparent | 经 api-gateway 注入 `X-Request-Id` | +| 维度 | 实现 | 端点 | +| -------------------- | --------------------------------------------------------------- | ----------------------------------------------------- | +| 健康检查 | 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) @@ -696,20 +1016,52 @@ graph LR ### 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(v1.1)** | `createPersistedQueryLink({ sha256 })` 前端只发 query hash,env `NEXT_PUBLIC_APOLLO_APQ=false` 关闭 | -| **PQ Manifest(v1.1)** | `public/pq-manifest.json` 51 query 的 `sha256 → query` 白名单,由 `scripts/generate-pq-manifest.ts` 生成 | -| **Router 强制 manifest(v1.1)** | `APOLLO_REQUIRE_PQ_MANIFEST=true` 时 router 拒绝未知 hash,entrypoint.sh 启动前校验文件存在性 | -| **深度/成本/批量限制(v1.1)** | apollo-router `limits.max_depth=10` / `max_cost=1000` / `max_batch_size=5` | -| **Introspection 控制(v1.1)** | `APOLLO_ROUTER_INTROSPECTION=false` 生产关闭 schema 内省 | -| **Router-Authorization(v1.1)** | 子图 `RouterAuthGuard` 校验 header,拒绝非 Router 的直接 GraphQL 请求 | -| **Resolver 字段级权限(v1.1)** | 每个 GraphQL Resolver 必须用 `@RequirePermission('perm')` 声明权限点 | +| 维度 | 实现 | +| --------------------------------- | -------------------------------------------------------------------------------------------------------- | +| 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` 封装。 + +```typescript +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("保存成功"); +``` + +**封装收益**: + +1. 统一 Toast 样式与位置(Toaster 在 RootLayout 挂载一次) +2. 便于后续替换底层库(sonner → react-hot-toast 或自研) +3. 集中添加埋点 / 错误上报 / 国际化等横切逻辑 +4. ESLint `no-restricted-imports` 强制约束 --- @@ -728,23 +1080,37 @@ portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为 | 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 层数据访问分层(v1.1)** | Widget 内联 gql 字面量暴露 schema、难审计、难重构;抽取到 lib/api/ 四层架构集中管理(ADR-042) | -| **APQ + PQ Manifest(v1.1)** | 前端只发 query hash 防止 schema 探测;Router 白名单 manifest 拒绝未知 hash 任意查询(ADR-043) | -| **Router 深度/成本/批量限制(v1.1)** | 防止深度嵌套 / 高成本 / 批量查询 DoS,配置 max_depth=10 / max_cost=1000 / max_batch_size=5 | -| **Resolver 字段级 @RequirePermission(v1.1)** | 防止越权访问字段,每个 Resolver 必须声明权限点;50 resolver 审计后补齐 19 个 TS 守卫 | +| 决策 | 理由 | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| **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 不再二次过滤(性能优化) | --- @@ -759,52 +1125,73 @@ portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为 - [x] admin 可配置插件默认 props(JSON 编辑) - [x] admin 可重置用户自定义布局 - [x] admin 改配置 → SWR 静默刷新检测到变化 → Toast 提示用户刷新生效 -- [x] 插件加载失败显示错误兜底,不影响其他插件(PluginErrorBoundary) +- [x] 插件加载失败显示错误兜底,不影响其他插件(PluginBoundary 三件套,v2.0) - [x] 跨插件状态共享正常(class-selector 切换班级 → grades-widget 自动响应) - [x] 插件 props 三层合并正确(PropsMerger) - [x] RSC 服务端预取正常:首屏 HTML 直出 Config + initialData - [x] useWidgetQuery 自动经 Apollo Client 路由到 apollo-router - [x] URL Search Params 可分享、可前进后退 - [x] config-fetcher 三级降级:apollo-router → config-service 直连 → 空默认 +- [x] **流式渲染(v2.0)**:RSC 返回 Promise → use() 消费 → 首屏骨架秒出 → Config 流式注入 +- [x] **三级错误边界(v2.0)**:Route(error.tsx)→ Section(SectionErrorBoundary)→ Widget(PluginBoundary)层层兜底 +- [x] **错误上报链路(v2.0)**:useErrorReport → sendBeacon → /api/log mock 端点,sessionStorage 1 分钟同 digest 去重 +- [x] **三层安全边界(v2.0)**:L1 角色门禁 + L2 权限点门禁 + L3 数据范围,路由权限配置表 4 张按优先级匹配 +- [x] **权限位图(v2.0)**:67 权限点 base36 编解码 + hasPermissionInBitmap / hasAllPermissionsInBitmap / hasAnyPermissionInBitmap +- [x] **PluginManifest requiredPermissions(v2.0)**:插件级 L2 权限点门禁,config-service 三层合并时过滤 +- [x] **notify 统一 Toast 封装(v2.0)**:禁止业务直接 import sonner,ESLint no-restricted-imports 强制 +- [x] **shadcn 标准化(v2.0)**:packages/ui-components 13 个文件令牌迁移完成(bg-paper→bg-background 等) +- [x] **shadcn/ui 组件库(v2.0)**:button / card / badge / skeleton / input / tooltip / sonner / page-header / stat-card / stats-grid / empty-state / filter-bar ### 11.2 非功能验收 -- [x] `pnpm run lint` + `pnpm run typecheck` 零错误 -- [x] `pnpm run test` 95/95 通过(v1.1:lib/api 7 domain 55 用例 + 安全栈 10 用例 + Shell/Lifecycle/Context 30 用例) -- [x] 0 处 widget 内联 gql 字面量(v1.1 强制,arch:scan 违规检测) -- [x] 所有插件遵守设计令牌(ESLint 强制,无硬编码颜色/字体) -- [x] apollo-router 启用 APQ + PQ Manifest + 深度/成本/批量限制(v1.1) -- [x] 50 个 GraphQL resolver @auth 审计完成(35 TS + 15 Python),19 个 TS resolver 补齐 @RequirePermission(v1.1) +- [x] `pnpm run lint` + `pnpm run typecheck` 零错误(v2.0:含 2 个 auto-generated 文件警告,可忽略) +- [x] `pnpm run build` 通过(v2.0:Next.js 16 Turbopack,6 路由生成成功:`/` / `/_not-found` / `/api/health` / `/api/log` / `/api/ready` / `/shell/[[...route]]`) +- [x] `pnpm run test` 95/95 通过(lib/api 7 domain 55 用例 + 安全栈 10 用例 + Shell/Lifecycle/Context 30 用例) +- [x] 0 处 widget 内联 gql 字面量(强制,arch:scan 违规检测) +- [x] 所有新代码遵守 shadcn 标准令牌(v2.0:ESLint 强制,无硬编码颜色/字体/任意值) +- [x] apollo-router 启用 APQ + PQ Manifest + 深度/成本/批量限制 +- [x] 50 个 GraphQL resolver @auth 审计完成(35 TS + 15 Python),19 个 TS resolver 补齐 @RequirePermission - [x] arch.db 更新,004 文档同步 +- [x] **v2.0 README 同步**:本文件 v2.0,004 同步更新 ADR-044/045/046/047 +- [x] **v2.0 流式渲染验证**:首屏 HTML 直出骨架,Config resolve 后流式注入(本地 Docker 验证) - [ ] Shell 首屏 LCP < 2s(需真实环境压测验证) - [ ] 插件加载耗时 < 500ms(dynamic import 缓存命中后,需真实环境验证) -- [ ] 单元测试覆盖率 ≥ 80%(当前覆盖核心纯函数 + lib/api 全量,admin domain 仅 4 用例待补,插件组件测试待补) -- [ ] E2E 测试(tests/e2e/portal-shell.spec.ts,待补) -- [ ] 视觉回归测试(5 种 layout 截图,待补) -- [ ] 生产部署前 APOLLO_REQUIRE_PQ_MANIFEST=true + APOLLO_ROUTER_INTROSPECTION=false 写入部署 env(v1.1 follow-up) +- [ ] 单元测试覆盖率 ≥ 80%(当前覆盖核心纯函数 + lib/api 全量,admin domain 仅 4 用例待补,插件组件测试待补,v2.0 新增组件测试待补) +- [ ] E2E 测试(tests/e2e/portal-shell.spec.ts,待补,含 v2.0 流式渲染 + 三级错误边界场景) +- [ ] 视觉回归测试(5 种 layout 截图,待补,含 v2.0 shadcn 标准化对比) +- [ ] 生产部署前 APOLLO_REQUIRE_PQ_MANIFEST=true + APOLLO_ROUTER_INTROSPECTION=false 写入部署 env +- [ ] 31 个 widget 旧纸感令牌批量迁移到 shadcn 标准(P1 阶段,arch:scan 违规检测) ### 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 用例(v1.1) | -| 单元测试 | lib/api sidebar domain | `src/lib/api/__tests__/sidebar.test.tsx` | ✅ 9 用例(v1.1) | -| 单元测试 | lib/api topbar domain | `src/lib/api/__tests__/topbar.test.tsx` | ✅ 9 用例(v1.1) | -| 单元测试 | lib/api teacher domain | `src/lib/api/__tests__/teacher.test.tsx` | ✅ 7 用例(v1.1) | -| 单元测试 | lib/api student domain | `src/lib/api/__tests__/student.test.tsx` | ✅ 9 用例(v1.1) | -| 单元测试 | lib/api parent domain | `src/lib/api/__tests__/parent.test.tsx` | ✅ 11 用例(v1.1) | -| 单元测试 | lib/api admin domain | `src/lib/api/__tests__/admin.test.tsx` | ✅ 4 用例(v1.1,待补) | -| 单元测试 | PQ Manifest + APQ + 深度限制 | `src/lib/api/__tests__/security.test.ts` | ✅ 10 用例(v1.1) | -| 单元测试 | 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` | ⏳(v1.1) | -| E2E | apollo-router 拒绝未知 PQ hash | `tests/e2e/graphql-pq-manifest.spec.ts` | ⏳(v1.1) | -| 视觉回归 | 5 种 layout 截图对比 | `tests/visual/portal-shell.spec.ts` | ⏳ | +| 测试类型 | 范围 | 文件 | 状态 | +| -------- | --------------------------------------------- | ---------------------------------------------------------- | ----------------- | +| 单元测试 | 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) | `packages/shared-ts/__tests__/permission-bitmap.test.ts` | 🚧 待补 | +| 单元测试 | 路由权限配置表 + checkRoutePermission(v2.0) | `src/shared/lib/__tests__/route-permissions.test.ts` | 🚧 待补 | +| 单元测试 | notify 统一封装(v2.0) | `src/shared/lib/__tests__/notify.test.ts` | 🚧 待补 | +| 单元测试 | useErrorReport 节流逻辑(v2.0) | `packages/hooks/__tests__/use-error-report.test.ts` | 🚧 待补 | +| 单元测试 | PluginBoundary 三件套(v2.0) | `src/shared/components/__tests__/plugin-boundary.test.tsx` | 🚧 待补 | +| 单元测试 | 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) | --- @@ -812,51 +1199,68 @@ portal-shell 的关键架构决策记录在 004 文档的 ADR 章节,此处为 ### 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 代理 | +| 风险 | 影响 | 缓解措施 | +| --------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------- | +| 插件数量增长导致首屏 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-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) | -| 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 | -| P1(二期) | 第三方插件上传 + iframe 沙箱 | ⏳ 规划 | -| P2(二期) | 插件市场在线商店 | ⏳ 规划 | -| P3(二期) | canvas 拖拽编辑器 | ⏳ 规划 | -| P4(二期) | next-intl 完整 i18n | ⏳ 规划 | -| P5(二期) | 视觉回归测试自动化 | ⏳ 规划 | +| 阶段 | 内容 | 状态 | +| ----------- | ----------------------------------------------------------------------------------------------- | --------------------- | +| 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 标准** | ⏳ 规划 | +| **v2.0 P2** | **v2.0 新增组件单元测试补齐**(权限位图 / 路由权限 / notify / useErrorReport / PluginBoundary) | ⏳ 规划 | +| **v2.0 P3** | **错误上报端点生产替换**(后端 /api/v1/log) | ⏳ 规划 | +| **v2.0 P4** | **E2E 测试**(流式渲染 + 三级错误边界 + 三层安全边界) | ⏳ 规划 | +| P5(二期) | 第三方插件上传 + iframe 沙箱 | ⏳ 规划 | +| P6(二期) | 插件市场在线商店 | ⏳ 规划 | +| P7(二期) | canvas 拖拽编辑器 | ⏳ 规划 | +| P8(二期) | next-intl 完整 i18n | ⏳ 规划 | +| P9(二期) | 视觉回归测试自动化 | ⏳ 规划 | --- -## 13. 数据访问层与 GraphQL 安全栈(v1.1 新增) +## 13. 数据访问层与 GraphQL 安全栈 > 来源:[portal-shell 数据抽象与 GraphQL 加固 spec](../../docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) v1.0 > 关联 ADR:ADR-042(前端数据访问四层分层)、ADR-043(PQ Manifest + APQ 安全加固) @@ -1012,33 +1416,240 @@ npx vitest run src/lib/api/__tests__/security.test.ts --- -## 14. 术语表 +## 14. v2.0 安全边界与错误处理 -| 术语 | 定义 | -| ------------------------------ | --------------------------------------------------------------------------------------------------- | -| **Shell** | 微内核宿主,渲染 Layout 框架 + Slots + PluginLoader,不含业务逻辑 | -| **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**(v1.1) | Automatic Persisted Queries,前端只发 query hash(sha256),不发明文 query | -| **PQ Manifest**(v1.1) | sha256(query) → query 文本的白名单 JSON,apollo-router 据此解析未知 hash | -| **4 层数据访问分层**(v1.1) | Widget → API → Operations → Hook,废弃 widget 内联 gql 字面量(ADR-042) | -| **@RequirePermission**(v1.1) | NestJS GraphQL Resolver 字段级权限装饰器,每个 Resolver 必须声明权限点 | -| **RouterAuthGuard**(v1.1) | 子图 Guard,校验 `Router-Authorization` header,拒绝非 Router 的直接 GraphQL 请求 | +> 本章汇总 v2.0 新增的安全边界、流式渲染、错误处理机制,详细设计见各章节交叉引用。 + +### 14.1 三层安全边界(详见 §3.3) + +```mermaid +graph TD + User["用户请求
/shell/*"] --> L1 + L1["L1 角色门禁
route-permissions.ts
requiredRoles?"] -->|通过| L2 + L1 -->|拒绝| Deny403["403 Forbidden"] + L2["L2 权限点门禁
位图校验
requiredPermissions?
anyOfPermissions?"] -->|通过| L3 + L2 -->|拒绝| Deny403 + L3["L3 数据范围
config-service
DataScope 6 级"] -->|过滤| Plugin["插件渲染
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**: + +```typescript +// 编码:权限点数组 → 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) + +```mermaid +graph LR + subgraph RSC["RSC 服务端"] + Fetch["fetchPluginConfig
不 await"] + Promise["返回 Promise"] + end + subgraph Client["客户端"] + Suspense["Suspense 边界"] + Use["use(configPromise)"] + Render["ShellContent 渲染"] + end + subgraph Loading["加载态"] + Skeleton["整页骨架
shell/loading.tsx"] + PluginSk["PluginSkeleton
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) + +```mermaid +graph TB + subgraph Route["Route 级"] + REB["RouteErrorBoundary
app/shell/error.tsx"] + end + subgraph Section["Section 级"] + SEB["SectionErrorBoundary
DashboardSection 内"] + end + subgraph Widget["Widget 级"] + PB["PluginBoundary
SlotRenderer 内"] + end + subgraph Report["错误上报"] + Hook["useErrorReport"] + Beacon["sendBeacon"] + API["/api/log mock"] + Store["sessionStorage
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 不再二次过滤(性能优化) | --- @@ -1050,14 +1661,18 @@ apps/portal-shell/ │ ├─ app/ │ │ ├─ api/ │ │ │ ├─ health/route.ts # liveness +│ │ │ ├─ log/route.ts # v2.0 错误上报 mock 端点 │ │ │ └─ ready/route.ts # readiness -│ │ ├─ shell/[[...route]]/page.tsx # RSC 入口 -│ │ ├─ globals.css # 全局样式 + Tailwind -│ │ ├─ layout.tsx # RootLayout + 字体 +│ │ ├─ 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/ # v1.1 数据访问层 +│ │ ├─ api/ # 数据访问层 │ │ │ ├─ __tests__/ │ │ │ │ ├─ admin.test.tsx # 4 用例(待补) │ │ │ │ ├─ parent.test.tsx # 11 用例 @@ -1083,7 +1698,7 @@ apps/portal-shell/ │ │ │ └─ universal.ts # 7 widget API │ │ ├─ apollo-client.ts # Apollo Client 工厂 + 单例 + APQ 链 │ │ ├─ config-fetcher.ts # RSC 服务端拉 Config(三级降级) -│ │ ├─ types.ts # 共享类型契约 +│ │ ├─ types.ts # 共享类型契约(v2.0 含 requiredPermissions) │ │ ├─ usePluginConfig.ts # SWR 静默刷新 │ │ ├─ useWidgetQuery.ts # 统一查询 Hook │ │ └─ useWidgetMutation.ts # 统一变更 Hook @@ -1091,21 +1706,44 @@ apps/portal-shell/ │ │ ├─ 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 # 客户端入口 -│ │ ├─ LayoutManager.tsx # 5 Layout 模板 +│ │ ├─ ClientShell.tsx # v2.0 use(configPromise) 流式 + ShellContent 拆分 +│ │ ├─ LayoutManager.tsx # 5 Layout 模板(v2.0 令牌迁移) │ │ ├─ PluginLifecycle.ts # 生命周期管理 -│ │ ├─ PluginLoader.tsx # 骨架 + ErrorBoundary +│ │ ├─ PluginLoader.tsx # v2.0 re-export(向后兼容) │ │ ├─ PluginStore.ts # Zustand 全局状态 │ │ ├─ PropsMerger.ts # 三层合并 │ │ ├─ Registry.tsx # 31 插件注册表 │ │ ├─ Shell.tsx # 微内核入口 -│ │ └─ SlotRenderer.tsx # slot 渲染器 +│ │ └─ SlotRenderer.tsx # v2.0 PluginBoundary 包裹 + 信任输入 │ ├─ styles/ -│ │ └─ tokens.css # 设计令牌映射 +│ │ └─ tokens.css # 设计令牌映射(旧,待 P1 移除) │ └─ widgets/ │ ├─ admin/ # 6 插件 │ │ ├─ audit-logs/ @@ -1146,63 +1784,100 @@ apps/portal-shell/ │ ├─ notifications-widget/ │ └─ schedule-widget/ ├─ .env.example -├─ .eslintrc.tokens.js # 设计令牌 ESLint 规则 -├─ codegen.yml # v1.1 graphql-codegen 配置 +├─ components.json # v2.0 shadcn CLI 配置 +├─ codegen.yml # graphql-codegen 配置 ├─ Dockerfile # standalone 构建 -├─ eslint.config.js -├─ next.config.js # transpilePackages + 反向代理 +├─ eslint.config.js # v2.0 含 no-restricted-imports(notify 强制) +├─ next.config.js # transpilePackages + 反向代理 + Turbopack ├─ package.json ├─ postcss.config.js ├─ public/ -│ └─ pq-manifest.json # v1.1 PQ Manifest(51 query hash → query 白名单) +│ └─ pq-manifest.json # PQ Manifest(51 query hash → query 白名单) ├─ scripts/ -│ ├─ generate-pq-manifest.ts # v1.1 PQ Manifest 生成脚本 -│ └─ normalize-schema.ts # v1.1 schema 归一化(移除 federation 指令) -├─ tailwind.config.js -├─ tsconfig.json # paths 别名 +│ ├─ 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:常用命令 ```bash # 开发 -pnpm --filter @edu/portal-shell run dev # 启动 dev server :4010 +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 +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 用例) -# v1.1 数据层与安全栈 +# 数据层与安全栈 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 架构影响地图](../../docs/architecture/004_architecture_impact_map.md) | 架构设计意图唯一源(§11.7 数据访问层 + 安全栈、§16.5 子阶段) | -| [portal-shell 仪表盘 spec](../../docs/superpowers/specs/2026-07-14-portal-shell-widget-dashboard-design.md) | 模块设计源(v1.0) | -| [portal-shell 数据抽象与 GraphQL 加固 spec](../../docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) | v1.1 数据访问层 + 安全栈设计源 | -| [portal-shell 数据抽象与 GraphQL 加固 plan](../../docs/superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.md) | v1.1 20-task 实施 plan | -| [GraphQL @auth 审计报告](../../docs/security/graphql-auth-audit-2026-07.md) | 50 resolver @auth 审计(M4) | -| [项目规则](../../.trae/rules/project_rules.md) | 强制约束 | -| [UI 设计系统](../../docs/standards/ui-design-system.md) | 纸感编辑器设计风格 | -| [known-issues §1.11/§2.17](../../docs/troubleshooting/known-issues.md) | Apollo Router + portal-shell 已知问题速查 | -| [local-stack runbook](../../docs/runbooks/local-stack.md) | 本地运维手册 | -| [端口分配](../../infra/port-allocation.md) | 端口唯一源 | +| 文档 | 用途 | +| -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| [004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md) | 架构设计意图唯一源(§11.7 数据访问层 + 安全栈、§16.5 子阶段、v2.0 ADR-044/045/046/047) | +| [portal-shell 仪表盘 spec](../../docs/superpowers/specs/2026-07-14-portal-shell-widget-dashboard-design.md) | 模块设计源(v1.0) | +| [portal-shell 数据抽象与 GraphQL 加固 spec](../../docs/superpowers/specs/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening-design.md) | 数据访问层 + 安全栈设计源(含 v2.0 三层安全边界 + 流式渲染 + 三级错误处理) | +| [portal-shell 数据抽象与 GraphQL 加固 plan](../../docs/superpowers/plans/2026-07-17-portal-shell-data-abstraction-and-graphql-hardening.md) | 20-task 实施 plan | +| [GraphQL @auth 审计报告](../../docs/security/graphql-auth-audit-2026-07.md) | 50 resolver @auth 审计(M4) | +| [项目规则](../../.trae/rules/project_rules.md) | 强制约束(§3.10 设计令牌、§14 多 AI 协作) | +| [UI 设计系统](../../docs/standards/ui-design-system.md) | 设计风格(v2.0 已迁移到 shadcn 标准) | +| [known-issues §1.11/§2.17](../../docs/troubleshooting/known-issues.md) | Apollo Router + portal-shell 已知问题速查 | +| [local-stack runbook](../../docs/runbooks/local-stack.md) | 本地运维手册 | +| [端口分配](../../infra/port-allocation.md) | 端口唯一源 | --- -> **本文件是 portal-shell 模块的架构文档(v1.1,2026-07-17),遵循 arc42 模板结构 + C4 模型可视化。后续代码变更须按 [项目规则 §1](../../.trae/rules/project_rules.md) 同步更新本文件 + 运行 `pnpm run arch:scan` 更新 arch.db。** +> **本文件是 portal-shell 模块的架构文档(v2.0,2026-07-17),遵循 arc42 模板结构 + C4 模型可视化。后续代码变更须按 [项目规则 §1](../../.trae/rules/project_rules.md) 同步更新本文件 + 运行 `pnpm run arch:scan` 更新 arch.db。v2.0 变更摘要:shadcn/ui 标准化 + Tailwind v4 + React 19 use() 流式渲染 + 三级错误边界 + 三层安全边界(权限位图 base36 压缩)+ notify 统一封装 + PluginBoundary 替代 PluginLoader。** diff --git a/apps/portal-shell/components.json b/apps/portal-shell/components.json new file mode 100644 index 0000000..34ec351 --- /dev/null +++ b/apps/portal-shell/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/shared/components", + "utils": "@/shared/lib/utils", + "ui": "@/shared/components/ui", + "lib": "@/shared/lib", + "hooks": "@/shared/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/portal-shell/eslint.config.js b/apps/portal-shell/eslint.config.js index a0d1186..275140b 100644 --- a/apps/portal-shell/eslint.config.js +++ b/apps/portal-shell/eslint.config.js @@ -1,5 +1,5 @@ /** - * portal-shell ESLint flat config + * portal-shell ESLint flat config (ESM) * * 包含设计令牌强制规则(project_rules §3.10): * - 禁止 #hex 颜色字面量 @@ -7,11 +7,11 @@ * * 关联:project_rules §3.10、portal-shell spec §7 */ -const js = require("@eslint/js"); -const tseslint = require("typescript-eslint"); -const prettierConfig = require("eslint-config-prettier"); +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import prettierConfig from "eslint-config-prettier"; -module.exports = tseslint.config( +export default tseslint.config( { ignores: [ "**/dist/**", diff --git a/apps/portal-shell/next-env.d.ts b/apps/portal-shell/next-env.d.ts index 4f11a03..9edff1c 100644 --- a/apps/portal-shell/next-env.d.ts +++ b/apps/portal-shell/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/portal-shell/next.config.js b/apps/portal-shell/next.config.js index b9947ae..5e04cc3 100644 --- a/apps/portal-shell/next.config.js +++ b/apps/portal-shell/next.config.js @@ -1,5 +1,5 @@ /** - * portal-shell Next.js 配置(v2.1 M8) + * Next.js 配置(v2.1 M8 + v0.2 Tailwind v4 + Next 16 Turbopack) * * 角色:插件化仪表盘宿主(单 Next.js App Router · 单 Docker) * - output: standalone(单容器部署) @@ -7,6 +7,10 @@ * - 反向代理:/api/v1/* → api-gateway :8080(JWT 校验 + 注入 x-user-id/x-user-role) * - GraphQL 查询走 apollo-router :3000(M8 验收点,由 Apollo Client 直连) * + * Next 16 默认 Turbopack: + * - turbopack.resolveExtensions 处理 ESM 包 .js 后缀导入源码 TS 文件的映射 + * - webpack 配置保留作为 fallback(--webpack flag 时生效) + * * 关联:portal-shell spec §2、project_rules §3.2 */ @@ -24,8 +28,20 @@ const nextConfig = { experimental: { serverActions: { bodySizeLimit: "2mb" }, }, + // Turbopack 配置(Next 16 默认):处理 ESM 包 .js 后缀导入源码 .ts/.tsx 文件 + turbopack: { + resolveExtensions: [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".json", + ], + }, + // Webpack 配置(fallback,使用 --webpack flag 时生效) webpack(config) { - // ESM 包使用 .js 后缀导入源码(TS 文件),需映射 .js → .ts config.resolve = config.resolve || {}; config.resolve.extensionAlias = { ...config.resolve.extensionAlias, @@ -49,4 +65,4 @@ const nextConfig = { }, }; -module.exports = nextConfig; +export default nextConfig; diff --git a/apps/portal-shell/package.json b/apps/portal-shell/package.json index fa0d079..9db3103 100644 --- a/apps/portal-shell/package.json +++ b/apps/portal-shell/package.json @@ -1,7 +1,8 @@ { "name": "@edu/portal-shell", - "version": "0.1.0", + "version": "0.2.0", "private": true, + "type": "module", "scripts": { "dev": "next dev -p 4010", "build": "next build", @@ -21,31 +22,47 @@ "@edu/hooks": "workspace:*", "@edu/ui-components": "workspace:*", "@edu/ui-tokens": "workspace:*", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/typography": "^0.5.16", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "crypto-hash": "^4.0.1", "graphql": "^16.8.0", - "next": "^14.2.0", - "react": "^18.3.0", - "react-dom": "^18.3.0", + "lucide-react": "^0.562.0", + "next": "^16.0.10", + "next-themes": "^0.4.6", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "sonner": "^2.0.7", "swr": "^2.2.0", - "zustand": "^4.5.0" + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7", + "zustand": "^5.0.9" }, "devDependencies": { "@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/typescript": "^4.0.0", "@graphql-codegen/typescript-document-nodes": "^4.0.0", "@graphql-codegen/typescript-operations": "^4.0.0", + "@tailwindcss/postcss": "^4.0.0", "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", "@types/node": "^22.0.0", - "@types/react": "^18.3.0", - "@types/react-dom": "^18.3.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", - "autoprefixer": "^10.4.0", "eslint": "^9.0.0", "eslint-config-prettier": "^9.1.0", "jsdom": "^25.0.0", - "postcss": "^8.4.0", - "tailwindcss": "^3.4.0", + "tailwindcss": "^4.0.0", "tsx": "^4.0.0", "typescript": "^5.6.0", "vitest": "^2.0.0" diff --git a/apps/portal-shell/postcss.config.js b/apps/portal-shell/postcss.config.js index 12a703d..cb1e8ae 100644 --- a/apps/portal-shell/postcss.config.js +++ b/apps/portal-shell/postcss.config.js @@ -1,6 +1,11 @@ -module.exports = { +/** + * PostCSS 配置(Tailwind v4) + * + * Tailwind v4 使用 @tailwindcss/postcss 插件,配置通过 CSS 内的 + * @import "tailwindcss" + @theme inline 指令完成,不再需要 tailwind.config.js。 + */ +export default { plugins: { - tailwindcss: {}, - autoprefixer: {}, + "@tailwindcss/postcss": {}, }, }; diff --git a/apps/portal-shell/scripts/normalize-schema.ts b/apps/portal-shell/scripts/normalize-schema.ts index 03136fd..7a8f6cd 100644 --- a/apps/portal-shell/scripts/normalize-schema.ts +++ b/apps/portal-shell/scripts/normalize-schema.ts @@ -113,6 +113,48 @@ function normalizeSchema(content: string): { return { staticDefs: staticDefs.join("\n"), queryFields }; } +// Sanitize invalid input field types. +// +// Problem: services/ai subgraph declares `input ChatRequestInput { messages: +// ChatMessage }` and `input ChatResponseInput { usage: Usage }` where +// ChatMessage/Usage are OUTPUT types. GraphQL spec forbids input fields +// referencing output types; graphql-codegen's typescript plugin rejects this. +// +// Solution: rewrite those offending input field types to `String` in the +// combined schema. This is a codegen-only sanitize; the runtime apollo-router +// uses the original subgraph schemas directly. +// +// Related: spec section 2.4 +const SANITIZE_INPUT_FIELD_REPLACEMENTS: Array<{ + inputName: string; + fieldName: string; + replacement: string; +}> = [ + // services/ai: input ChatRequestInput { messages: ChatMessage } + { + inputName: "ChatRequestInput", + fieldName: "messages", + replacement: "String", + }, + // services/ai: input ChatResponseInput { usage: Usage } + { inputName: "ChatResponseInput", fieldName: "usage", replacement: "String" }, +]; + +function sanitizeInputFields(content: string): string { + let out = content; + for (const r of SANITIZE_INPUT_FIELD_REPLACEMENTS) { + // Match ` fieldName: OriginalType` lines within `input InputName { ... }` + // blocks. We rely on the simple field-line format generated above. + const inputBlockRe = new RegExp( + `(input\\s+${r.inputName}\\s*\\{[^}]*?)` + + `(\\s{2,}${r.fieldName}\\s*:\\s*)[A-Za-z_][A-Za-z0-9_\\[\\]!]*`, + "g", + ); + out = out.replace(inputBlockRe, `$1$2${r.replacement}`); + } + return out; +} + function main(): void { const schemas = loadSchemas(); const allStaticDefs: string[] = []; @@ -130,7 +172,7 @@ function main(): void { new Set(allQueryFields.map((f) => f.trim())), ); - const combined = [ + let combined = [ "# Combined normalized schema for graphql-codegen (federation stripped)", "# DO NOT EDIT - generated by scripts/normalize-schema.ts", "", @@ -142,6 +184,8 @@ function main(): void { "", ].join("\n"); + combined = sanitizeInputFields(combined); + const outDir = path.resolve(process.cwd(), "src/lib/api/__generated__"); fs.mkdirSync(outDir, { recursive: true }); const outPath = path.join(outDir, "combined-schema.graphql"); diff --git a/apps/portal-shell/src/app/api/log/route.ts b/apps/portal-shell/src/app/api/log/route.ts new file mode 100644 index 0000000..098e7e1 --- /dev/null +++ b/apps/portal-shell/src/app/api/log/route.ts @@ -0,0 +1,56 @@ +/** + * 客户端错误上报端点(mock 实现) + * + * 当前阶段:输出到 stdout,便于开发调试 + * 未来演进:接入 OpenTelemetry / Sentry / 后端 /api/v1/log + * + * 端点:POST /api/log + * Body: ErrorReportPayload(见 @edu/hooks/use-error-report) + * + * 关联:portal-shell README v2.0 §5.4 三级错误处理 + */ +import { NextResponse } from "next/server"; + +interface ErrorReportPayload { + level: "error" | "warning"; + message: string; + stack?: string; + digest?: string; + path: string; + userAgent: string; + timestamp: string; + pluginId?: string; + userId?: string; + context?: Record; +} + +export async function POST(request: Request): Promise { + try { + const payload = (await request.json()) as ErrorReportPayload; + + // 开发阶段:结构化输出到 stdout + // 生产阶段:这里应替换为 OTel export 或 Sentry capture + console.error("[client-error]", { + level: payload.level, + message: payload.message, + digest: payload.digest, + path: payload.path, + pluginId: payload.pluginId, + userId: payload.userId, + timestamp: payload.timestamp, + // stack 太长,单独一行输出便于阅读 + stack: payload.stack?.split("\n").slice(0, 5).join("\n"), + }); + + // 返回 204,让 sendBeacon 认为成功 + return new NextResponse(null, { status: 204 }); + } catch { + // 解析失败也返回 204,避免客户端重试 + return new NextResponse(null, { status: 204 }); + } +} + +/** 健康检查 */ +export function GET(): NextResponse { + return NextResponse.json({ ok: true, endpoint: "/api/log" }); +} diff --git a/apps/portal-shell/src/app/globals.css b/apps/portal-shell/src/app/globals.css index 1083ec7..5a13ded 100644 --- a/apps/portal-shell/src/app/globals.css +++ b/apps/portal-shell/src/app/globals.css @@ -1,55 +1,62 @@ /** - * portal-shell 全局样式 + * portal-shell 全局样式(Tailwind v4 + shadcn 标准令牌) * * 引入 @edu/ui-tokens 三层设计令牌(primitive → semantic → tailwind-theme) + * 业务代码使用 Tailwind 类(bg-background / text-foreground / bg-card ...)或 hsl(var(--*)) 引用。 * * 禁止规则(ESLint + project_rules §3.10): * - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类) * - 禁止字体名字面量(用 var(--font-family-*)) * - 禁止 font-size: Npx(用 var(--font-size-*) 或 Tailwind text-* 类) + * + * 对齐:CICD 项目 src/app/globals.css */ +@import "tailwindcss"; @import "@edu/ui-tokens/all.css"; +@plugin "tailwindcss-animate"; +@plugin "@tailwindcss/typography"; +@custom-variant dark (&:where(.dark, .dark *)); -@tailwind base; -@tailwind components; -@tailwind utilities; +/* 排除非源码目录,防止文档中的 Tailwind 任意值语法字符串被误识别为类名 */ +@source not "../../docs"; +@source not "../../scripts"; +@source not "../../tests"; +/* Reduced Motion */ @layer base { - html, + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } +} + +/* Base Styles */ +@layer base { + * { + @apply border-border; + } body { - background: var(--bg-paper); - color: var(--color-ink); + @apply bg-background text-foreground; font-family: var(--font-family-sans); + font-feature-settings: "rlig" 1, "calt" 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - h1, h2, h3, h4, h5, h6 { - font-family: var(--font-family-serif); - font-weight: var(--font-weight-semibold); - letter-spacing: var(--letter-spacing-tight); - } -} - -@layer components { - /* 纸感分隔线 */ - .rule { - border-top: 1px solid var(--color-rule); - } - - .rule-thin { - border-top: 2px solid var(--color-rule); - } - - /* 左侧竖线标记 */ - .mark-left { - border-left: 2px solid var(--color-rule); - padding-left: var(--space-md); + font-family: var(--font-family-sans); + font-weight: var(--weight-semibold); + letter-spacing: -0.01em; } } diff --git a/apps/portal-shell/src/app/layout.tsx b/apps/portal-shell/src/app/layout.tsx index 3d3a86e..da8bf05 100644 --- a/apps/portal-shell/src/app/layout.tsx +++ b/apps/portal-shell/src/app/layout.tsx @@ -1,14 +1,18 @@ import "./globals.css"; -import type { Metadata } from "next"; -import { Inter, Fraunces, JetBrains_Mono } from "next/font/google"; +import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; import type { ReactNode } from "react"; +import { Toaster } from "@/shared/components/ui/sonner"; + /** * 字体加载(next/font/google self-host) * - * 通过 CSS 变量暴露字体族(--font-inter / --font-fraunces / --font-jetbrains-mono), - * ui-tokens 的 semantic 层将它们映射为 --font-family-sans/serif/mono。 + * 通过 CSS 变量 --font-inter 暴露字体族。 + * ui-tokens 的 primitive 层将 --font-family-sans 映射为 var(--font-inter, ...)。 * 禁止字体名字面量(project_rules §3.10)。 + * + * 对齐:CICD 项目 src/app/layout.tsx(仅 Inter,shadcn 标准) */ const inter = Inter({ subsets: ["latin"], @@ -16,28 +20,24 @@ const inter = Inter({ display: "swap", }); -const fraunces = Fraunces({ - subsets: ["latin"], - variable: "--font-fraunces", - display: "swap", -}); - -const mono = JetBrains_Mono({ - subsets: ["latin"], - variable: "--font-jetbrains-mono", - display: "swap", -}); - export const metadata: Metadata = { title: "Edu Portal Shell", description: "K12 智慧教务平台 - 插件化仪表盘", }; +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, +}; + /** * RootLayout * - * 仅负责 / 与字体变量。需要 RSC 数据的 Providers - * (Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。 + * 仅负责 / 与字体变量 + 全局 Toaster。 + * 业务 Providers(Apollo/Auth/ThemeI18n)在 ClientShell 中挂载(spec §5.5)。 + * + * suppressHydrationWarning:ThemeI18nProvider 在客户端切换 .dark class, + * 与 SSR 输出的 不一致,需抑制 hydration 警告。 */ export default function RootLayout({ children, @@ -45,11 +45,11 @@ export default function RootLayout({ children: ReactNode; }): ReactNode { return ( - - {children} + + + {children} + + ); } diff --git a/apps/portal-shell/src/app/shell/[[...route]]/page.tsx b/apps/portal-shell/src/app/shell/[[...route]]/page.tsx index c176f95..7edc855 100644 --- a/apps/portal-shell/src/app/shell/[[...route]]/page.tsx +++ b/apps/portal-shell/src/app/shell/[[...route]]/page.tsx @@ -2,28 +2,44 @@ import { headers } from "next/headers"; import { fetchPluginConfig } from "@/lib/config-fetcher"; import { ClientShell } from "@/shell/ClientShell"; import type { Role } from "@/lib/types"; +import type { PluginConfigResponse } from "@edu/shared-ts/contracts"; /** - * Shell 入口(RSC Server Component,v2.1 M8 验收点) + * Shell 入口(RSC Server Component,v2.1 M8 验收点 + 流式渲染) * - * 数据流(portal-shell spec §5.5): + * 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染): * ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role) * ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并) - * ③ Config 作为 props 传给 ClientShell,随 HTML 直出,消除 CSR 瀑布流 + * ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染: + * - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI + * - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求) + * + * 流式渲染分层(README v2.0 §5.3): + * - L1 路由级(loading.tsx):整页骨架,fetchPluginConfig 进行中 + * - L2 区块级(DashboardSection):单一区块骨架,Suspense 包裹 + * - L3 插件级(PluginBoundary):单插件骨架,dynamic import + Suspense * * M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 Apollo Client)。 * - * 关联:portal-shell spec §5.5、§6.2、M8 验收标准 + * 关联:portal-shell spec §5.5、§6.2、M8 验收标准、README v2.0 §5.3 */ export default async function ShellPage(): Promise { - const headerList = headers(); + const headerList = await headers(); const userId = headerList.get("x-user-id") || (process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous"); const role = (headerList.get("x-user-role") || "teacher") as Role; // 服务端通过 apollo-router 获取三层合并后的插件配置 - const config = await fetchPluginConfig(userId, role); + // 不 await:直接将 Promise 传给 ClientShell,启用流式渲染 + const configPromise: Promise = fetchPluginConfig( + userId, + role, + ); - return ; + // 将 Promise 作为 prop 传递,ClientShell 内部通过 use() 消费 + // Next.js 会自动用 loading.tsx 作为 Suspense fallback 流式输出 HTML + return ( + + ); } diff --git a/apps/portal-shell/src/app/shell/error.tsx b/apps/portal-shell/src/app/shell/error.tsx new file mode 100644 index 0000000..5e4a4a9 --- /dev/null +++ b/apps/portal-shell/src/app/shell/error.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { RouteErrorBoundary } from "@/shared/components/route-error-boundary"; + +/** + * Shell 路由错误兜底(Next.js App Router error.tsx) + * + * 触发条件: + * - RSC 渲染抛错(如 fetchPluginConfig 失败) + * - ClientShell 渲染抛错(Provider 嵌套问题) + * - 任何子 segment 未捕获的错误 + * + * 职责(对齐 portal-shell README v2.0 §5.4 L1 路由级): + * 1. 隔离错误,避免整页白屏 + * 2. 通过 useErrorReport 上报到 /api/log + * 3. 提供 reset 按钮重试 + * + * 注意:error.tsx 必须是 Client Component("use client") + * + * 关联:Next.js App Router § error.tsx、portal-shell README v2.0 §5.4 + */ +export default function ShellError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}): React.ReactNode { + return ; +} diff --git a/apps/portal-shell/src/app/shell/loading.tsx b/apps/portal-shell/src/app/shell/loading.tsx new file mode 100644 index 0000000..00ecc17 --- /dev/null +++ b/apps/portal-shell/src/app/shell/loading.tsx @@ -0,0 +1,104 @@ +import { Skeleton } from "@/shared/components/ui/skeleton"; + +/** + * Shell 路由加载兜底(Next.js App Router loading.tsx) + * + * 触发条件: + * - RSC 正在解析(fetchPluginConfig 等待中) + * - 路由切换时的过渡态 + * + * 职责: + * - 整页骨架占位,避免白屏闪烁 + * - 与 Shell classic 布局结构对齐(顶栏 + 侧栏 + 主区) + * + * 流式渲染上下文(portal-shell README v2.0 §5.3): + * - loading.tsx 在 RSC Promise resolve 之前显示 + * - 配合 PluginBoundary(widget 级 Suspense)形成多层流式体验 + * + * 关联:Next.js App Router § loading.tsx、portal-shell README v2.0 §5.3 + */ +export default function ShellLoading(): React.ReactNode { + return ( +
+ {/* 顶栏 */} +
+
+ + +
+ + +
+
+
+ +
+ {/* 侧栏 */} + + + {/* 主区:仪表盘骨架 */} +
+ {/* 标题区 */} +
+
+ + +
+ +
+ + {/* 统计卡片网格 */} +
+ {[0, 1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ + {/* 内容区:图表 + 列表 */} +
+
+ +
+ {[60, 80, 45, 90, 70, 55, 85, 75, 65, 95, 50, 88].map( + (h, i) => ( + + ), + )} +
+
+
+ +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/portal-shell/src/lib/__tests__/plugin-context.test.ts b/apps/portal-shell/src/lib/__tests__/plugin-context.test.ts new file mode 100644 index 0000000..6e8dfa8 --- /dev/null +++ b/apps/portal-shell/src/lib/__tests__/plugin-context.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + parseUrlContext, + writeUrlContext, + URL_CONTEXT_KEYS, + type UrlPluginContext, +} from "@edu/shared-ts/contracts"; + +/** + * URL Search Params 上下文测试(portal-shell spec §5.2.1、§9.9) + * + * 覆盖: + * - parseUrlContext:从 URLSearchParams 解析上下文 + * - writeUrlContext:将上下文写入 URLSearchParams + * - URL_CONTEXT_KEYS:常量正确性 + */ +describe("URL_CONTEXT_KEYS", () => { + it("key 名称与 URL 参数名一致", () => { + expect(URL_CONTEXT_KEYS.classId).toBe("classId"); + expect(URL_CONTEXT_KEYS.childId).toBe("childId"); + expect(URL_CONTEXT_KEYS.termId).toBe("termId"); + expect(URL_CONTEXT_KEYS.view).toBe("view"); + expect(URL_CONTEXT_KEYS.subjectId).toBe("subjectId"); + expect(URL_CONTEXT_KEYS.examId).toBe("examId"); + }); +}); + +describe("parseUrlContext", () => { + it("空参数 → 空上下文", () => { + const ctx = parseUrlContext(new URLSearchParams()); + expect(ctx).toEqual({}); + }); + + it("解析 classId", () => { + const params = new URLSearchParams("?classId=cls-001"); + const ctx = parseUrlContext(params); + expect(ctx.classId).toBe("cls-001"); + }); + + it("解析多个参数", () => { + const params = new URLSearchParams( + "?classId=cls-001&termId=2024-fall&view=chart", + ); + const ctx = parseUrlContext(params); + expect(ctx).toEqual({ + classId: "cls-001", + termId: "2024-fall", + view: "chart", + }); + }); + + it("解析全部参数", () => { + const params = new URLSearchParams( + "?classId=cls-1&childId=child-1&termId=t-1&view=list&subjectId=subj-1&examId=exam-1", + ); + const ctx = parseUrlContext(params); + expect(ctx).toEqual({ + classId: "cls-1", + childId: "child-1", + termId: "t-1", + view: "list", + subjectId: "subj-1", + examId: "exam-1", + }); + }); + + it("忽略空值参数", () => { + const params = new URLSearchParams("?classId=&termId=t-1"); + const ctx = parseUrlContext(params); + expect(ctx.classId).toBeUndefined(); + expect(ctx.termId).toBe("t-1"); + }); +}); + +describe("writeUrlContext", () => { + it("写入单个值", () => { + const params = new URLSearchParams(); + writeUrlContext(params, { classId: "cls-001" }); + expect(params.get("classId")).toBe("cls-001"); + }); + + it("写入多个值", () => { + const params = new URLSearchParams(); + writeUrlContext(params, { + classId: "cls-1", + termId: "t-1", + view: "chart", + }); + expect(params.get("classId")).toBe("cls-1"); + expect(params.get("termId")).toBe("t-1"); + expect(params.get("view")).toBe("chart"); + }); + + it("空字符串 → 删除参数", () => { + const params = new URLSearchParams("?classId=cls-1"); + writeUrlContext(params, { classId: "" }); + expect(params.has("classId")).toBe(false); + }); + + it("undefined → 保留原值不变", () => { + const params = new URLSearchParams("?classId=cls-1&termId=t-1"); + writeUrlContext(params, { classId: "cls-2" }); + // 只更新 classId,termId 保留 + expect(params.get("classId")).toBe("cls-2"); + expect(params.get("termId")).toBe("t-1"); + }); + + it("空对象 → 不修改任何参数", () => { + const params = new URLSearchParams("?classId=cls-1"); + writeUrlContext(params, {}); + expect(params.get("classId")).toBe("cls-1"); + }); +}); + +describe("parseUrlContext + writeUrlContext 往返", () => { + it("写入后解析应得到相同上下文", () => { + const original: UrlPluginContext = { + classId: "cls-001", + childId: "child-001", + termId: "2024-fall", + view: "list", + subjectId: "math", + examId: "exam-001", + }; + const params = new URLSearchParams(); + writeUrlContext(params, original); + const parsed = parseUrlContext(params); + expect(parsed).toEqual(original); + }); +}); diff --git a/apps/portal-shell/src/lib/config-fetcher.ts b/apps/portal-shell/src/lib/config-fetcher.ts index 3a432a7..c30f634 100644 --- a/apps/portal-shell/src/lib/config-fetcher.ts +++ b/apps/portal-shell/src/lib/config-fetcher.ts @@ -9,6 +9,11 @@ * - 统一走 apollo-router GraphQL,由 Router 路由到 config-service 子图 * - RSC 服务端预取消除 CSR 瀑布流,Config 随 HTML 直出 * + * 开发态降级(spec §5.5 容错): + * - 优先走 apollo-router(生产路径) + * - Router 不可用时降级直连 config-service /graphql(仅开发态,由 CONFIG_SERVICE_URL 触发) + * - 二者均失败时返回空默认配置,保证 Shell 可渲染 + * * 关联:portal-shell spec §5.5、§6.2、M8 验收标准 */ import { gql } from "@apollo/client"; @@ -55,6 +60,11 @@ export const GET_PLUGIN_CONFIG = gql` /** * 获取用户合并后的插件配置(服务端调用)。 * + * 查询顺序(开发态容错): + * 1. apollo-router(生产路径,M8 验收点) + * 2. config-service 直连(仅当 CONFIG_SERVICE_URL 配置时启用,开发态降级) + * 3. 空默认配置(最后兜底) + * * @param userId 用户 ID(来自 RSC 的 x-user-id 头) * @param role 用户角色(来自 RSC 的 x-user-role 头) * @returns 三层合并后的 PluginConfigResponse;查询失败时返回默认 classic 配置 @@ -63,8 +73,9 @@ export async function fetchPluginConfig( userId: string, role: Role, ): Promise { - const client = createApolloClient(); + // 1. 优先走 apollo-router(生产路径) try { + const client = createApolloClient(); const { data, error } = await client.query<{ pluginConfig: PluginConfigResponse; }>({ @@ -73,22 +84,106 @@ export async function fetchPluginConfig( }); if (error) { console.warn( - `[portal-shell] fetchPluginConfig partial error: ${error.message}`, + `[portal-shell] fetchPluginConfig partial error from apollo-router: ${error.message}`, ); } if (data?.pluginConfig) { return data.pluginConfig; } - return getDefaultConfig(); } catch (err) { - // Router 未就绪时降级为默认配置,保证 Shell 可渲染(开发态友好) console.warn( - `[portal-shell] fetchPluginConfig failed, falling back to default config: ${ + `[portal-shell] apollo-router query failed: ${ err instanceof Error ? err.message : String(err) }`, ); - return getDefaultConfig(); } + + // 2. 开发态降级:直连 config-service GraphQL + const configServiceUrl = + process.env.CONFIG_SERVICE_URL || + process.env.NEXT_PUBLIC_CONFIG_SERVICE_URL; + if (configServiceUrl) { + try { + const result = await fetchPluginConfigDirect( + userId, + role, + configServiceUrl, + ); + if (result) { + console.info( + `[portal-shell] fetchPluginConfig fallback to config-service direct`, + ); + return result; + } + } catch (err) { + console.warn( + `[portal-shell] config-service direct fallback failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + // 3. 最终兜底:空默认配置 + console.warn(`[portal-shell] fetchPluginConfig returning empty default`); + return getDefaultConfig(); +} + +/** + * 开发态降级:直连 config-service GraphQL 查询 pluginConfig。 + * + * 当 apollo-router 不可用时(如本地开发未启动 Router), + * 直接请求 config-service 的 /graphql 端点获取插件配置。 + * 生产环境不应触发此路径(apollo-router 必须可用)。 + */ +async function fetchPluginConfigDirect( + userId: string, + role: string, + configServiceUrl: string, +): Promise { + const endpoint = `${configServiceUrl.replace(/\/$/, "")}/graphql`; + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: ` + query GetPluginConfig($userId: ID!, $role: String) { + pluginConfig(userId: $userId, role: $role) { + activeLayout { + layoutId + displayName + description + availableSlots + layoutSchemaJson + } + slots { slotName navItems } + plugins { pluginId slot sortOrder sizeJson propsJson isVisible } + registry { pluginId category version displayName description requiredRoles isBuiltin isActive } + } + } + `, + variables: { userId, role }, + }), + // RSC 服务端调用,不携带 cookie + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`config-service HTTP ${response.status}`); + } + + const json = (await response.json()) as { + data?: { pluginConfig?: PluginConfigResponse }; + errors?: unknown; + }; + + if (json.errors) { + throw new Error( + `config-service GraphQL errors: ${JSON.stringify(json.errors)}`, + ); + } + + return json.data?.pluginConfig ?? null; } /** diff --git a/apps/portal-shell/src/lib/types.ts b/apps/portal-shell/src/lib/types.ts index 5ccd22a..8456c65 100644 --- a/apps/portal-shell/src/lib/types.ts +++ b/apps/portal-shell/src/lib/types.ts @@ -120,7 +120,13 @@ export interface PluginManifest { displayName: string; description: string; category: PluginCategory; + /** L1 角色门禁:可访问此插件的角色列表(粗粒度) */ requiredRoles: Role[]; + /** + * L2 权限点门禁:访问此插件所需的权限点列表(细粒度,AND 语义) + * 权限点必须来自 PERMISSION_BITMAP_ORDER + */ + requiredPermissions?: string[]; defaultSlot: string; defaultSize: PluginSize; /** 插件可配置的 props schema(admin 配置面板用) */ diff --git a/apps/portal-shell/src/lib/useWidgetMutation.ts b/apps/portal-shell/src/lib/useWidgetMutation.ts index d49b9b0..80f7505 100644 --- a/apps/portal-shell/src/lib/useWidgetMutation.ts +++ b/apps/portal-shell/src/lib/useWidgetMutation.ts @@ -13,7 +13,7 @@ import { type TypedDocumentNode, } from "@apollo/client"; -export function useWidgetMutation>( +export function useWidgetMutation>( mutation: DocumentNode | TypedDocumentNode, ) { const [mutate, result] = useMutation(mutation, { diff --git a/apps/portal-shell/src/lib/useWidgetQuery.ts b/apps/portal-shell/src/lib/useWidgetQuery.ts index 0cfd216..6449946 100644 --- a/apps/portal-shell/src/lib/useWidgetQuery.ts +++ b/apps/portal-shell/src/lib/useWidgetQuery.ts @@ -28,7 +28,10 @@ export interface UseWidgetQueryOptions { fetchPolicy?: FetchPolicy; } -export function useWidgetQuery>( +export function useWidgetQuery< + TData, + TVars extends Record = Record, +>( query: DocumentNode | TypedDocumentNode, variables: TVars, options?: UseWidgetQueryOptions, diff --git a/apps/portal-shell/src/shared/components/dashboard/dashboard-section.tsx b/apps/portal-shell/src/shared/components/dashboard/dashboard-section.tsx new file mode 100644 index 0000000..6db6353 --- /dev/null +++ b/apps/portal-shell/src/shared/components/dashboard/dashboard-section.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { Suspense, type ReactNode } from "react"; + +import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"; +import { Skeleton } from "@/shared/components/ui/skeleton"; +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"; +import { useErrorReport } from "@edu/hooks"; +import { cn } from "@/shared/lib/utils"; + +/** + * DashboardSection - 仪表盘分区包装器(对齐 CICD dashboard-section.tsx) + * + * 三件套组合:SectionErrorBoundary + Suspense + 5 种骨架变体 + * + * 职责: + * 1. 隔离分区渲染错误(不影响其他分区) + * 2. 流式渲染:Suspense 边界显示骨架屏,数据到达后替换 + * 3. a11y:传入 ariaLabel 时渲染 role="region" tabIndex={0} + * + * 5 种骨架变体: + * - stats:统计卡片骨架(大数字 + 标签) + * - card:通用卡片骨架(标题 + 内容块) + * - chart:图表骨架(坐标轴 + 柱状) + * - table:表格骨架(表头 + 多行) + * - list:列表骨架(多行) + * + * 关联:portal-shell README v2.0 §5.4 三级错误处理(L2 区块级) + * + * @example + * + * + * + */ + +export type DashboardSectionVariant = + "stats" | "card" | "chart" | "table" | "list"; + +export interface DashboardSectionProps { + /** 分区标题(显示在 CardHeader) */ + title?: string; + /** 分区描述(显示在 CardHeader) */ + description?: string; + /** 子节点(分区内容) */ + children: ReactNode; + /** 骨架变体(默认 card) */ + variant?: DashboardSectionVariant; + /** a11y 标签(传入时渲染 role="region" tabIndex={0}) */ + ariaLabel?: string; + /** 右侧操作区(如"查看全部"链接) */ + actions?: ReactNode; + /** 自定义类名 */ + className?: string; +} + +/** + * 5 种骨架变体实现 + */ +export function DashboardSectionSkeleton({ + variant = "card", + className, +}: { + variant?: DashboardSectionVariant; + className?: string; +}): ReactNode { + if (variant === "table") { + return ( + + + + + + {[0, 1, 2, 3, 4].map((i) => ( + + ))} + + + ); + } + + if (variant === "list") { + return ( + + + + + + {[0, 1, 2, 3].map((i) => ( + + ))} + + + ); + } + + if (variant === "chart") { + return ( + + + + + +
+ {[60, 80, 45, 90, 70, 55, 85].map((h, i) => ( + + ))} +
+
+
+ ); + } + + if (variant === "stats") { + return ( + + + + + +
+ {[0, 1, 2].map((i) => ( +
+ + +
+ ))} +
+
+
+ ); + } + + // card(默认) + return ( + + + + + + + + + + + ); +} + +/** + * DashboardSection - 仪表盘分区(ErrorBoundary + Suspense + Skeleton) + */ +export function DashboardSection({ + title, + description, + children, + variant = "card", + ariaLabel, + actions, + className, +}: DashboardSectionProps): ReactNode { + const reportError = useErrorReport(); + + const sectionProps = ariaLabel + ? { role: "region" as const, tabIndex: 0, "aria-label": ariaLabel } + : {}; + + return ( +
+ { + void reportError(error, { + level: "error", + context: { section: title }, + }); + }} + > + }> + {(title || actions) && ( +
+
+ {title && ( +

+ {title} +

+ )} + {description && ( +

{description}

+ )} +
+ {actions && ( +
{actions}
+ )} +
+ )} + {children} +
+
+
+ ); +} diff --git a/apps/portal-shell/src/shared/components/dashboard/dashboard-shell.tsx b/apps/portal-shell/src/shared/components/dashboard/dashboard-shell.tsx new file mode 100644 index 0000000..5b2b466 --- /dev/null +++ b/apps/portal-shell/src/shared/components/dashboard/dashboard-shell.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; + +import { PageHeader } from "@/shared/components/ui/page-header"; +import { StatsGrid } from "@/shared/components/ui/stats-grid"; +import { cn } from "@/shared/lib/utils"; + +/** + * DashboardShell - 仪表盘外壳(对齐 CICD dashboard-shell.tsx) + * + * 极简结构:PageHeader + StatsGrid(可选)+ children + * - stats 为空数组时不渲染统计区(适配无统计指标的页面) + * - children 是页面主体内容 + * + * @example + * } + * actions={} + * > + * + * + * + * + */ +export interface DashboardShellProps { + /** 页面标题 */ + title: string; + /** 页面描述 */ + description?: string; + /** 标题前图标 */ + icon?: ReactNode; + /** 右侧操作区 */ + actions?: ReactNode; + /** 统计卡片组(传入 StatsGrid 或多个 StatCard) */ + stats?: ReactNode; + /** 主体内容 */ + children: ReactNode; + /** 自定义类名 */ + className?: string; +} + +export function DashboardShell({ + title, + description, + icon, + actions, + stats, + children, + className, +}: DashboardShellProps): ReactNode { + return ( +
+ + {stats && {stats}} +
{children}
+
+ ); +} diff --git a/apps/portal-shell/src/shared/components/layout/app-sidebar.tsx b/apps/portal-shell/src/shared/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..5f143af --- /dev/null +++ b/apps/portal-shell/src/shared/components/layout/app-sidebar.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +import { Button } from "@/shared/components/ui/button"; +import { Separator } from "@/shared/components/ui/separator"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; +import { cn } from "@/shared/lib/utils"; +import { + SIDEBAR_WIDTH_COLLAPSED, + SIDEBAR_WIDTH_EXPANDED, + useSidebar, +} from "./sidebar-provider"; + +/** + * AppSidebar - 侧边栏实现(对齐 CICD app-sidebar.tsx) + * + * 关键设计: + * - 桌面端: