feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling

- 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 路由生成成功
This commit is contained in:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

View File

@@ -727,33 +727,34 @@
> v2.1 架构:单 Next.js App Router 容器 + Micro-kernel 插件系统,替代旧 4 端微前端。关联 spec `2026-07-14-portal-shell-widget-dashboard-design.md`。
| 场景 | 技术/规则 |
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 插件契约跨包共享 | `packages/shared-ts/src/contracts/` 定义 PluginProps/PluginManifest/Layout 类型;`PluginManifest.Component` 用 `unknown`shared-ts 不依赖 React前端包引用时断言为 `React.ComponentType<PluginProps>` |
| TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下widget 文件用 `interface XxxVars` 会报 TS2344改用 `type XxxVars = {...}` 类型别名type alias 满足 index signatureinterface 不满足) |
| shared-ts contracts 子路径导出 | `package.json` exports 新增 `"./contracts"` 指向 `./dist/contracts/index.js`hooks 包通过 `@edu/shared-ts/contracts` 引用typecheck 前需 `pnpm --filter @edu/shared-ts run build` 编译到 dist/ |
| Vitest 解析 workspace 子路径导出失败 | vitest.config.ts resolve.alias 需显式添加 `"@edu/shared-ts/contracts"` → 源码路径Vite 不自动解析 package.json exports 字段 |
| portal-shell tsconfig paths 补全 | tsconfig.json paths 需添加 `"@edu/shared-ts/contracts": ["../../packages/shared-ts/src/contracts/index.ts"]`,否则 tsc 报 TS2307 |
| 插件 dynamic import 注册 | `Registry.tsx` 用 `next/dynamic`ssr:false懒加载 31 个内置插件loading 展示 `PluginSkeleton` 变体card/list/table/stats/chart |
| URL Search Params + Zustand 状态分层 | URL 驱动可分享上下文classId/childId/termId/view/examId/subjectIdZustand 管理纯 UI 状态theme/locale/sidebarCollapsed插件间禁止直接 import |
| useWidgetMutation API 签名 | 返回 `{ run, ...result }``run` 是 `async (variables: TVars): Promise<TData \| undefined>`;消费者用 `const { run } = useWidgetMutation(...)` 解构 |
| usePluginConfig 不直接调 API | fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 原则支持轮询刷新refreshInterval 默认 5 分钟)+ 网络恢复刷新 + visibilitychange 刷新 |
| usePluginStore 注入模式 | `useSyncExternalStore` + `injectPluginStore()` 让 hooks 包不直接依赖特定 store 实例;未注入 store 时返回 DEFAULT_STATE 空操作 |
| 28 个内置插件分类 | universal(7) + sidebar(4) + topbar(4) + teacher(4) + student(4) + parent(2) + admin(3);每个插件含 `index.tsx` + `plugin.manifest.ts`manifest 导出 `manifestMeta: Omit<PluginManifest, "Component">` |
| PluginLifecycle 版本兼容 | MVP 只校验 major 版本(`checkVersionCompatibility` 解析 `^`/`~`/`>=` 前缀 + major 数字);完整 semver range 校验待引入 semver 库 |
| pnpm install --no-frozen-lockfile | parent-portal package.json 新增 `@opentelemetry/instrumentation-document-load` 后 lockfile 过期;开发环境用 `--no-frozen-lockfile` 安装CI 用 frozen |
| Widget 内联 gql 字面量废弃 | 禁止 `const Q = gql\`...\``直接写在 widget统一抽取到`lib/api/operations/*.graphql.ts`widget 只 import `lib/api/<domain>.ts` 的语义化函数ADR-042 |
| 4 层数据访问分层 | WidgetUI→ API语义化函数→ Operationsgql DocumentNode 集中)→ HookuseWidgetQuery/useWidgetMutation 封装 Apollocodegen 从 7 子图 schema 生成类型 |
| graphql-codegen skipDocumentsValidation | codegen.yml 必须设 `skipDocumentsValidation: true`,避免子图未启动时校验 operations 失败schema 归一化脚本移除 federation 指令(@key/@requires/@extends防止误解析 |
| useNotifications 命名冲突 | `universal.ts` 与 `topbar.ts` 同时导出 `useNotifications` 在 barrel `index.ts` 冲突TS2308topbar 改名 `useNotificationBell`(铃铛专用,限 N 条) |
| APQAutomatic Persisted Queries | `apollo-client.ts` 用 `createPersistedQueryLink({ sha256 })` + `crypto-hash`env `NEXT_PUBLIC_APOLLO_APQ=false` 关闭Link 链顺序authLink → pqLink → httpLink |
| PQ Manifest 生成 | `scripts/generate-pq-manifest.ts` 遍历 `operations/index.ts` 中 DocumentNode`print(doc)` + `sha256(query)` 写入 `public/pq-manifest.json``prebuild` 钩子串联 codegen + generate |
| Windows ESM 动态 import 路径 | Node ESM 动态 `import()` 不支持 Windows 盘符路径(`e:\...`),必须 `url.pathToFileURL(path).href` 转 `file://` URL 再 import |
| apollo-router PQ manifest 挂载 | docker-compose 把 `apps/portal-shell/public/pq-manifest.json` 挂载到 router `/etc/apollo-router/pq-manifest.json:ro`entrypoint.sh 启动前校验 `APOLLO_REQUIRE_PQ_MANIFEST=true` 时文件存在性 |
| apollo-router 安全限制 | `router.yaml` 配置 `limits.max_depth=10` / `max_cost=1000` / `max_batch_size=5``supergraph.introspection` 由 env `APOLLO_ROUTER_INTROSPECTION` 控制(生产 false |
| Resolver @RequirePermission 字段级守卫 | 每个 GraphQL Resolver 必须用 `@RequirePermission('perm')` 声明权限点50 resolver 审计后补齐 19 个 TS resolverPython 子图待补 Strawberry/Ariadne 中间件 |
| TS interface 不满足 Record 约束 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下 widget 用 `interface XxxVars` 报 TS2344改 `type XxxVars = {...}` 别名(满足 index signature |
| parent.test.tsx 可选链 | `data?.[0].name` 报 TS2532`data?.[0]` 可能为 undefined改 `data?.[0]?.name` 双重可选链 |
| PowerShell 不支持 heredoc | `git commit -m "$(cat <<'EOF'...)"` 在 PowerShell 报错commit 消息写临时文件 `.git/COMMIT_MSG.txt`,用 `git commit -F .git/COMMIT_MSG.txt` |
| commitlint body-max-line-length | commit body 每行 ≤100 字符Plan/Spec 路径过长会超限;移除 URL 行或换行简化 |
| commitlint scope-enum | `security` / `graphql` 不在允许 scope 列表;用 `docs` scope 提交审计报告,或用无 scope commit |
| 场景 | 技术/规则 |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 插件契约跨包共享 | `packages/shared-ts/src/contracts/` 定义 PluginProps/PluginManifest/Layout 类型;`PluginManifest.Component` 用 `unknown`shared-ts 不依赖 React前端包引用时断言为 `React.ComponentType<PluginProps>` |
| TVars 泛型约束与 TS interface 不兼容 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下widget 文件用 `interface XxxVars` 会报 TS2344改用 `type XxxVars = {...}` 类型别名type alias 满足 index signatureinterface 不满足) |
| shared-ts contracts 子路径导出 | `package.json` exports 新增 `"./contracts"` 指向 `./dist/contracts/index.js`hooks 包通过 `@edu/shared-ts/contracts` 引用typecheck 前需 `pnpm --filter @edu/shared-ts run build` 编译到 dist/ |
| Vitest 解析 workspace 子路径导出失败 | vitest.config.ts resolve.alias 需显式添加 `"@edu/shared-ts/contracts"` → 源码路径Vite 不自动解析 package.json exports 字段 |
| portal-shell tsconfig paths 补全 | tsconfig.json paths 需添加 `"@edu/shared-ts/contracts": ["../../packages/shared-ts/src/contracts/index.ts"]`,否则 tsc 报 TS2307 |
| 插件 dynamic import 注册 | `Registry.tsx` 用 `next/dynamic`ssr:false懒加载 31 个内置插件loading 展示 `PluginSkeleton` 变体card/list/table/stats/chart |
| URL Search Params + Zustand 状态分层 | URL 驱动可分享上下文classId/childId/termId/view/examId/subjectIdZustand 管理纯 UI 状态theme/locale/sidebarCollapsed插件间禁止直接 import |
| useWidgetMutation API 签名 | 返回 `{ run, ...result }``run` 是 `async (variables: TVars): Promise<TData \| undefined>`;消费者用 `const { run } = useWidgetMutation(...)` 解构 |
| usePluginConfig 不直接调 API | fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 原则支持轮询刷新refreshInterval 默认 5 分钟)+ 网络恢复刷新 + visibilitychange 刷新 |
| usePluginStore 注入模式 | `useSyncExternalStore` + `injectPluginStore()` 让 hooks 包不直接依赖特定 store 实例;未注入 store 时返回 DEFAULT_STATE 空操作 |
| 28 个内置插件分类 | universal(7) + sidebar(4) + topbar(4) + teacher(4) + student(4) + parent(2) + admin(3);每个插件含 `index.tsx` + `plugin.manifest.ts`manifest 导出 `manifestMeta: Omit<PluginManifest, "Component">` |
| PluginLifecycle 版本兼容 | MVP 只校验 major 版本(`checkVersionCompatibility` 解析 `^`/`~`/`>=` 前缀 + major 数字);完整 semver range 校验待引入 semver 库 |
| pnpm install --no-frozen-lockfile | parent-portal package.json 新增 `@opentelemetry/instrumentation-document-load` 后 lockfile 过期;开发环境用 `--no-frozen-lockfile` 安装CI 用 frozen |
| Widget 内联 gql 字面量废弃 | 禁止 `const Q = gql\`...\``直接写在 widget统一抽取到`lib/api/operations/*.graphql.ts`widget 只 import `lib/api/<domain>.ts` 的语义化函数ADR-042 |
| 4 层数据访问分层 | WidgetUI→ API语义化函数→ Operationsgql DocumentNode 集中)→ HookuseWidgetQuery/useWidgetMutation 封装 Apollocodegen 从 7 子图 schema 生成类型 |
| graphql-codegen skipDocumentsValidation | codegen.yml 必须设 `skipDocumentsValidation: true`,避免子图未启动时校验 operations 失败schema 归一化脚本移除 federation 指令(@key/@requires/@extends防止误解析 |
| useNotifications 命名冲突 | `universal.ts` 与 `topbar.ts` 同时导出 `useNotifications` 在 barrel `index.ts` 冲突TS2308topbar 改名 `useNotificationBell`(铃铛专用,限 N 条) |
| APQAutomatic Persisted Queries | `apollo-client.ts` 用 `createPersistedQueryLink({ sha256 })` + `crypto-hash`env `NEXT_PUBLIC_APOLLO_APQ=false` 关闭Link 链顺序authLink → pqLink → httpLink |
| PQ Manifest 生成 | `scripts/generate-pq-manifest.ts` 遍历 `operations/index.ts` 中 DocumentNode`print(doc)` + `sha256(query)` 写入 `public/pq-manifest.json``prebuild` 钩子串联 codegen + generate |
| Windows ESM 动态 import 路径 | Node ESM 动态 `import()` 不支持 Windows 盘符路径(`e:\...`),必须 `url.pathToFileURL(path).href` 转 `file://` URL 再 import |
| apollo-router PQ manifest 挂载 | docker-compose 把 `apps/portal-shell/public/pq-manifest.json` 挂载到 router `/etc/apollo-router/pq-manifest.json:ro`entrypoint.sh 启动前校验 `APOLLO_REQUIRE_PQ_MANIFEST=true` 时文件存在性 |
| apollo-router 安全限制 | `router.yaml` 配置 `limits.max_depth=10` / `max_cost=1000` / `max_batch_size=5``supergraph.introspection` 由 env `APOLLO_ROUTER_INTROSPECTION` 控制(生产 false |
| Resolver @RequirePermission 字段级守卫 | 每个 GraphQL Resolver 必须用 `@RequirePermission('perm')` 声明权限点50 resolver 审计后补齐 19 个 TS resolverPython 子图待补 Strawberry/Ariadne 中间件 |
| TS interface 不满足 Record 约束 | `useWidgetQuery<TData, TVars extends Record<string, unknown>>` 约束下 widget 用 `interface XxxVars` 报 TS2344改 `type XxxVars = {...}` 别名(满足 index signature |
| parent.test.tsx 可选链 | `data?.[0].name` 报 TS2532`data?.[0]` 可能为 undefined改 `data?.[0]?.name` 双重可选链 |
| PowerShell 不支持 heredoc | `git commit -m "$(cat <<'EOF'...)"` 在 PowerShell 报错commit 消息写临时文件 `.git/COMMIT_MSG.txt`,用 `git commit -F .git/COMMIT_MSG.txt` |
| commitlint body-max-line-length | commit body 每行 ≤100 字符Plan/Spec 路径过长会超限;移除 URL 行或换行简化 |
| commitlint scope-enum | `security` / `graphql` 不在允许 scope 列表;用 `docs` scope 提交审计报告,或用无 scope commit |
| Turbopack 不支持 .js 后缀 import | Next 16 默认 Turbopack 无法像 webpack 那样通过 `resolve.extensionAlias` 将 `.js` 映射到 `.ts/.tsx``packages/ui-components` 和 `packages/hooks` 源码内部 import 必须去掉 `.js` 后缀shared-ts 是 NestJS ESM 模式按规则 §3.4 保留 `.js` 后缀,不修改) |