feat(portal-shell): v2.1 P0 auth + middleware + login + graphql proxy
- 新增 ARCHITECTURE.md v3.0:portal-shell 架构权威文档 涵盖 §3.4 V3-A2/A3 认证链、§4 GraphQL 联邦、§5 安全、 §6 部署、§10 P0-P3 验收清单 - 新增 middleware.ts:认证 + 路由门禁 httpOnly cookie edu_session(JWT)读取 DEV_MODE 合成 dev-user/teacher 身份(NODE_ENV!=production && NEXT_PUBLIC_DEV_MODE=true) 生产模式 jose JWKS RS256 验签(iss/aud 校验) 路由权限位图注入 x-user-id/x-user-role/x-user-permissions 头 /shell/** 强制 checkRoutePermission,拒绝跳 /shell/forbidden - 新增 instrumentation.ts:生产环境 DEV_MODE 强制 false 防止生产环境误开 DEV_MODE 合成身份 - 新增 app/api/auth/login/route.ts + logout/route.ts 登录走 api-gateway /v1/iam/login 设置 httpOnly + Secure + SameSite=Strict cookie - 新增 app/api/graphql/route.ts:同域 GraphQL 代理 转发到 apollo-router,注入 router-authorization 头 - 新增 app/login/page.tsx + login-form.tsx zod 表单校验,next 参数支持 - 新增 app/shell/forbidden/page.tsx:403 页面 - 更新 route-permissions.ts:补全 P0 路由权限映射 - 更新 permission-bitmap.ts(shared-ts):位图编码/解码 - 更新 apollo-client.ts:DEV_MODE APQ 关闭,错误处理 - 更新 config-fetcher.ts:config-service 直连降级 - 更新 ApolloProvider.tsx:SSR/RSC 兼容 - 更新 eslint.config.js:design-tokens/no-hardcoded-fonts 白名单调整
This commit is contained in:
15
apps/portal-shell/.gitignore
vendored
15
apps/portal-shell/.gitignore
vendored
@@ -1,2 +1,17 @@
|
|||||||
# graphql-codegen 产物(构建时生成)
|
# graphql-codegen 产物(构建时生成)
|
||||||
src/lib/api/__generated__/
|
src/lib/api/__generated__/
|
||||||
|
|
||||||
|
# 本地环境变量(应永远在本地,不入库;根 .gitignore 已覆盖,此处冗余声明)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# TypeScript 增量构建缓存(286KB,不应入库;根 .gitignore 已 *.tsbuildinfo 覆盖)
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Next.js 构建产物
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# 测试覆盖率
|
||||||
|
coverage/
|
||||||
|
|||||||
960
apps/portal-shell/ARCHITECTURE.md
Normal file
960
apps/portal-shell/ARCHITECTURE.md
Normal file
@@ -0,0 +1,960 @@
|
|||||||
|
# portal-shell 前端架构总纲(v3.0)
|
||||||
|
|
||||||
|
> 版本:3.0
|
||||||
|
> 日期:2026-07-20
|
||||||
|
> 状态:**架构审计完成 + 重设计方案定稿,待实施**
|
||||||
|
> 本文档地位:**portal-shell 前端工作的唯一权威指导文档**。所有后续 AI/人工在此模块的工作必须先读本文件,以其为准。
|
||||||
|
>
|
||||||
|
> 关联文档(按效力排序):
|
||||||
|
>
|
||||||
|
> 1. **本文件**(v3.0 总纲:审计结论 + 目标架构 + 路线图 + 工作规范)
|
||||||
|
> 2. [README.md](./README.md)(v2.0 模块文档:微内核仪表盘子系统的详细设计,仅其"插件仪表盘"部分继续有效)
|
||||||
|
> 3. [004 架构影响地图](../../docs/architecture/004_architecture_impact_map.md)(后端架构唯一源:§5.4 视口四层模型、§11.7 前端数据层)
|
||||||
|
> 4. [项目规则](../../.trae/rules/project_rules.md)(强制约束:§3.10 设计令牌、§4 安全、§14 多 AI 协作)
|
||||||
|
> 5. [MIGRATION_GUIDE.md](../../MIGRATION_GUIDE.md)(CICD → Edu 迁移背景)
|
||||||
|
>
|
||||||
|
> **效力声明**:README v2.0 中与本文件冲突的表述(完成度声明、设计方向、"旧 portal 已下线"等)以本文件为准;`docs/standards/ui-design-system.md`(v1.0,面向已废弃的 4 微前端方案)自本文件发布之日起**废止**,其有效内容已并入本文件 §8;`docs/architecture/0020_portal_shell_architecture.md`(v1.0)为历史评审稿,仅作背景参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [现状审计(2026-07-20 快照)](#1-现状审计2026-07-20-快照)
|
||||||
|
2. [问题根因分析](#2-问题根因分析)
|
||||||
|
3. [目标架构 v3.0](#3-目标架构-v30)
|
||||||
|
4. [认证与身份链](#4-认证与身份链)
|
||||||
|
5. [数据层架构](#5-数据层架构)
|
||||||
|
6. [安全架构(安全边际)](#6-安全架构安全边际)
|
||||||
|
7. [信息架构与页面体系](#7-信息架构与页面体系)
|
||||||
|
8. [前端设计规范(强制)](#8-前端设计规范强制)
|
||||||
|
9. [页面迁移总表(~140 页)](#9-页面迁移总表140-页)
|
||||||
|
10. [实施路线图(P0–P6)](#10-实施路线图p0p6)
|
||||||
|
11. [后续 AI 工作规范(强制)](#11-后续-ai-工作规范强制)
|
||||||
|
12. [风险登记册](#12-风险登记册)
|
||||||
|
13. [附录](#13-附录)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 现状审计(2026-07-20 快照)
|
||||||
|
|
||||||
|
> 审计方法:全部结论均可复现。每项发现标注证据(文件路径 + 行号 / 可执行命令)。审计环境:Windows 11 + Node 24,分支 `feat/architecture-v2.1`,工作区干净。
|
||||||
|
|
||||||
|
### 1.1 验收声明 vs 实测结果
|
||||||
|
|
||||||
|
README v2.0 声称"P0–P4 全部验证通过、功能验收 23 项全部 [x]"。实测:**构建级声明属实,功能级声明不成立**——现有门禁只验证"骨架不塌",从不验证"功能可用"。
|
||||||
|
|
||||||
|
| README 声明 | 实测结果 | 结论 |
|
||||||
|
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
||||||
|
| `typecheck` 0 错误 | `node node_modules/typescript/bin/tsc --noEmit` 无输出退出 | ✅ 属实 |
|
||||||
|
| `vitest` 206/206 通过 | 实测 19 文件 206 用例全部通过(8.12s) | ✅ 属实 |
|
||||||
|
| `build` 6 路由生成成功 | 实测成功:`/`、`/_not-found`、`/api/health`、`/api/log`、`/api/ready`、`/shell/[[...route]]` | ✅ 属实——但**全应用只有 1 条业务路由** |
|
||||||
|
| "31 个内置插件全部加载正常" | 31 插件全部注册并能渲染(30 REAL + 1 PARTIAL,无 STUB/MOCK);但其 GraphQL 查询**严格匹配 0/50**(见 §1.3-F2),运行时全部返回错误/空数据 | ⚠️ 代码真实,数据全断 |
|
||||||
|
| "31 个 widget 旧纸感令牌全部迁移(1104 次替换,零违规)" | `src/widgets/` 仍残留 **271 处**旧令牌类(`text-heading-*`/`mt-sm`/`py-xs`/`p-md` 等),这些类在 Tailwind v4 主题中**不存在**,渲染时无样式 | ❌ **不属实** |
|
||||||
|
| "M10 旧 portal 下线完成" | 4 个旧 portal 完整保留在 `apps/`(teacher 143 文件 / student 118 / parent 190 / admin 84),含全部页面源码 | ❌ **不属实**(未删 ≠ 已下线;但也未迁移) |
|
||||||
|
| "三层安全边界 L1/L2/L3 已落地" | `checkRoutePermission` 仅被**测试文件**引用;无 `middleware.ts`;`shell/[[...route]]/page.tsx` 不调用任何权限检查。**L1/L2 在生产请求路径上是死代码** | ❌ 代码存在但从未接线 |
|
||||||
|
| "admin 改配置 → 用户刷新生效" | 无后端时 `fetchPluginConfig` 返回**空插件集**,仪表盘渲染"暂无可见插件";本地无种子数据/无 mock,**开发态开箱即为空壳** | ❌ 链路存在,起点即断 |
|
||||||
|
| ESLint 强制插件隔离与 notify 封装(`no-restricted-imports`) | `eslint.config.js` 仅有 hex 颜色 + 字体名两条规则;**无任何 import 限制规则** | ❌ 不属实 |
|
||||||
|
|
||||||
|
### 1.2 一句话诊断
|
||||||
|
|
||||||
|
**portal-shell 当前是一个"质量门禁全绿、但没有任何可用页面"的空壳**:微内核仪表盘骨架(Shell/Registry/SlotRenderer/PluginBoundary/数据层封装)工程质量合格,但它之上**既没有登录认证链,也没有一个真实业务页面**;31 个卡片插件查询的是后端并不存在的 GraphQL 字段;距离 CICD 原版(35 模块、4 端完整页面体系)的功能差距约为 **140 个页面**。
|
||||||
|
|
||||||
|
### 1.3 十大断裂点(按严重度排序,证据化)
|
||||||
|
|
||||||
|
#### F1.【致命】没有任何真实业务页面 —— "前端不可用"的第一根因
|
||||||
|
|
||||||
|
- 全应用仅 1 条业务路由:`src/app/shell/[[...route]]/page.tsx`(catch-all)。`ShellPage` **不读取 route 参数**(`src/app/shell/[[...route]]/page.tsx:26-45`),`/shell/teacher/grades` 与 `/shell/admin/users` 渲染完全相同的内容;业务区分被压缩到 query params(`?classId`/`?termId` 等)。
|
||||||
|
- `route-permissions.ts` 声明的 24 条路由(如 `/shell/admin/users`、`/shell/teacher/lesson-plans`)在文件系统中**一条都不存在**——权限表保护的是幽灵路由(该表从 CICD 项目平移,本意给网关 middleware 用,Next.js 侧从未接线)。
|
||||||
|
- 对照资产:CICD 原版 **155 个页面**(auth 4 + onboarding 1 + dashboard 公共 8 + admin 41 + teacher 54 + student 25 + parent 14 + management/grade 5,含 register/privacy/terms);Edu 旧 4 portal 共 **140 个 page.tsx**(teacher 56 / student 36 / parent 24 / admin 24)已在仓库内但从未接入 portal-shell(旧 portal 本身缺 management/grade 与 register/onboarding 体系,属缺口待补)。
|
||||||
|
|
||||||
|
#### F2.【致命】GraphQL 契约前后端全面脱节 —— 50 个操作严格匹配 0 个
|
||||||
|
|
||||||
|
- 前端 `operations/*.graphql.ts` 定义 **50 个操作**(32 query + 18 mutation;README 写 51,其中 GetMyChildrenOverview 改名去重后为 50)。
|
||||||
|
- 合并 schema(`src/lib/api/__generated__/combined-schema.graphql`,700 行)仅有 **38 个 Query 字段,且完全没有 Mutation 类型**。
|
||||||
|
- **严格匹配数:0/50**。唯一 root 字段名命中的是 `GetLayoutTemplates`→`layoutTemplates`,但其选择的 `availableSlots` 字段在 `LayoutTemplateGql` 上不存在,仍然校验失败。
|
||||||
|
- 按域不匹配统计:**admin 19/19、teacher 6/6、student 8/8、parent 4/4、sidebar 3/3、topbar 3/3、universal 7/7,全部不匹配**。典型样例:
|
||||||
|
- 列表查询不存在:`grades(classId)`/`homeworks(classId)`/`exams(classId)` → 后端仅有 `grade(id)`/`homework(id)`/`exam(id)` 单查;`schedule`/`attendance`/`announcements`/`myClasses`/`terms`/`myChildren`/`users`/`roles`/`permissions`/`auditLogs`/`invitationCodes`/`school`/`lessonPlans`/`schedulingRules`/`myLearningPath`/`electiveCourses`/`aiTutorSessions`/`leaveRequests`/`search` 在 schema 中**全部不存在**。
|
||||||
|
- 形状不符:`me{id,name,email,role}` → iam `User` 实为 `userId/email/name/status/dataScope`;`exams` 选 `name/subject/maxScore` → `Exam` 实为 `title/totalScore`;`notifications(limit,offset)` 期望 `{items,total}` 包装 → msg 实为 `notifications(userId: ID!)` 平铺数组;`errorBookItems` 底层为 snake_case(`question_id` 等)。
|
||||||
|
- **18 个 mutation 全部无契约**(schema 无 Mutation 类型):SaveLessonPlan/ApproveLeave/RejectLeave/UpdateUserStatus/UpdateUserRole/UpdateRolePermissions/CreateInvitationCode/RevokeInvitationCode/UpdateSchool/SaveSchedulingRule/EnrollCourse/DropCourse/MarkErrorMastered/SendAiTutorMessage 及 plugin-manager 的 4 个配置 mutation。
|
||||||
|
- **config-service resolver 实测**(唯一声称支撑全架构配置的后端):仅实现 `plugin/plugins/layoutTemplates/userLayoutOverride/pluginConfig` 5 个 Query;**plugin-manager 面板依赖的 `rolePluginMapping`/`roleLayoutDefault` 及全部 update\*/reset mutation 在 schema 与 resolver 双层均未实现**——也就是说,即使后端全栈启动,"admin 改配置"这个插件架构的旗舰功能也**无法保存**。
|
||||||
|
- 唯一验证通过的查询:仪表盘启动用的 `pluginConfig`(不经 operations/,内联于 `config-fetcher.ts:24-58`;`PluginConfigLayoutGql` 含 `availableSlots`/`layoutSchemaJson`,形状吻合)——这就是"后端就绪时仪表盘能出框架、但所有卡片空转"的原因。
|
||||||
|
- 编译期防线自毁:`codegen.yml:49-56` 注释自述"forward-looking spec fields … not yet present in services subgraph SDL … 44 个 'Cannot query field X' 错误",被迫 `skipDocumentsValidation: true`;同时 codegen 未配置 `typescript-operations`,**没有生成任何 per-operation 类型**,lib/api 的返回类型全部手写——编译期 0 报错,运行期全报错。
|
||||||
|
- 后端真正可用但**没有任何 widget 使用**的资产:data-ana 的 4 个仪表盘聚合查询(`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard`)+ `warnings`/`mastery*`/`diagnosticReports`/`errorBook*` + config-service 5 个配置查询 + iam `user/role/dataScope` + core-edu/content 按 id 单查。
|
||||||
|
- 无 GraphQL schema 的服务:`classes`、`student-bff`、`teacher-bff`、`parent-bff`、`push-gateway`(`api-gateway` 为 router 除外)——`classes` 域查询当前**无处落地**。
|
||||||
|
|
||||||
|
#### F3.【致命】认证与身份链断裂 —— 没有登录页,角色写死 teacher
|
||||||
|
|
||||||
|
- 无登录/注册页面;`src/app/page.tsx` 直接 `redirect("/shell")`。
|
||||||
|
- `ShellPage` 从 `x-user-id`/`x-user-role` 请求头取身份,**缺失时默认 `dev-user` / `teacher`**(`page.tsx:28-31`)——任何人直达 portal-shell 都是"教师"。
|
||||||
|
- Apollo Client 期望从 `localStorage["edu_token"]` 读 JWT(`ApolloProvider.tsx:18-27`),但**全应用没有任何代码写入这个 token**(没有登录流程)→ 所有 GraphQL 请求永远匿名。
|
||||||
|
- 浏览器直连 apollo-router :3000,**绕过 api-gateway**(JWT 校验在 gateway,而 gateway 不在浏览器→router 路径上)。
|
||||||
|
|
||||||
|
#### F4.【严重】安全边界是"纸面合规" —— L1/L2 从未接入请求路径
|
||||||
|
|
||||||
|
- 无 `middleware.ts`;`checkRoutePermission`/`batchCheckRoutePermission` 仅被 `*.test.ts` 引用(grep 实测 60+ 处引用全部在 `__tests__`)。
|
||||||
|
- 后果:`/shell/admin/users` 对 teacher/student/parent 角色**直接放行**;唯一的真实过滤发生在 config-service(L3),而它过滤的只是"仪表盘上显示哪些卡片"。
|
||||||
|
- 权限位图 `PERMISSION_BITMAP_ORDER` 中 **`GRADE_READ` 重复定义两次**(`permission-bitmap.ts:62` 与 `:79`)——按"顺序不可变"铁律属数据缺陷,趁未签发真实 JWT 前必须去重。
|
||||||
|
- `infra/docker-compose.yml` 的 portal-shell 服务(含生产 profile)环境变量写入 **`NEXT_PUBLIC_DEV_MODE: "true"`**——生产部署也以 dev 模式运行,等于永久绕过认证。
|
||||||
|
- `infra/apollo-router/router.yaml` 硬编码 `router-authorization: dev-router-secret`(明文入库)。
|
||||||
|
|
||||||
|
#### F5.【严重】设计系统三方冲突 + 令牌断裂
|
||||||
|
|
||||||
|
- **三个互相矛盾的"权威"**:`docs/standards/ui-design-system.md`(纸感米白 + Fraunces/Inter/JetBrains Mono 三字体 + 学术深蓝,面向已废弃的 4 微前端方案);README v2.0(shadcn 标准 + Inter 单字体);实际实现(`packages/ui-tokens` = shadcn zinc 默认色板)。
|
||||||
|
- 31 个 widget 残留 **271 处**旧令牌类(grep:`text-heading-3`/`mt-sm`/`py-xs`/`p-md`/`space-y-xs` 等),这些类在 `@theme` 中**没有定义** → 相关间距/字号渲染为零值,widget 视觉上是坏的。
|
||||||
|
- 另有批量出现的 `border border` 重复类(如 `grades-widget/index.tsx:32,42`)。
|
||||||
|
- `src/styles/tokens.css`(README 附录自称"旧,待 P1 移除")仍在源码树中。
|
||||||
|
|
||||||
|
#### F6.【严重】31 个插件 ≈ 31 张信息卡片,不是功能
|
||||||
|
|
||||||
|
- 全量审计结论(31/31):**30 REAL + 1 PARTIAL,0 STUB / 0 MOCK**——插件代码是"真"的,问题是**薄**且数据断供。平均 139 行/个,形态为"一张卡片 + 列表/表单局部"。
|
||||||
|
- 典型缺陷(审计实测):
|
||||||
|
- `question-bank`(唯一 PARTIAL):新建题目**不接 mutation**,仅 push 进本地 state(`local-${Date.now()}`),刷新即失。
|
||||||
|
- `lesson-plan-editor`:仅 标题/目标/内容/资源 四字段表单(216 行);对照 CICD 同功能:lesson-preparation 是全仓最大模块(113 文件:结构树 + 纸面编辑器 + AI 建议 + 版本/审核/发布 + xyflow 画布);错误处理 `catch { /* toast */ }` 空吞(违反 notify 强制)。
|
||||||
|
- `quick-actions`:导航目标 `/homework/new`、`/schedule`、`/grades` 等**全部不存在**(缺 `/shell` 前缀且无对应路由),点击即掉进 catch-all 仪表盘。
|
||||||
|
- `global-search`:跳转目标 `/students/:id` 等不存在;`user-menu` 无登出项;`locale-switcher` 只切本地 state 不加载任何 i18n;`ai-tutor` 切会话不加载历史;`notifications-widget` 分页 offset 固定 0。
|
||||||
|
- 对比 CICD 同功能页面量级:考试模块 11 页(all/create/new/[id]/build/edit-rich/analytics/proctoring/grading)、作业模块含扫描阅卷、成绩模块 5 页、教案模块 7 页。
|
||||||
|
- 插件间无页面级导航目标(没有可跳转的详情页),"点击卡片进详情"无从谈起。
|
||||||
|
|
||||||
|
#### F7.【严重】开发体验不可用 —— 无后端 = 空壳
|
||||||
|
|
||||||
|
- `fetchPluginConfig` 三级降级的终点是 `getDefaultConfig()`:**`plugins: []`**(`config-fetcher.ts:193-212`)→ 无后端/无种子数据时,仪表盘渲染空壳"暂无可见插件"。
|
||||||
|
- 仓库内**没有**本地 mock/seed/MSW 任何一条让前端独立可运行的路径;旧 portal 里的 MSW handlers(`teacher-portal/src/mocks/handlers-p*.ts`,覆盖 P4/P5/P7 场景)未被复用。
|
||||||
|
|
||||||
|
#### F8.【中】路由权限命名双轨制混乱
|
||||||
|
|
||||||
|
- README 示例用点号权限(`grade.read`),实际 `PERMISSION_BITMAP_ORDER` 用 `<RESOURCE>_<ACTION>` 大写下划线(`GRADE_READ`);widget manifest 的 `requiredPermissions` 字段 31 个插件**无一使用**(L2 插件级门禁名存实亡)。
|
||||||
|
|
||||||
|
#### F9.【中】基础设施与文档脱节
|
||||||
|
|
||||||
|
- README §6.1 说 7 domain API 文件 "Widget 数" 与 §13.2 表格不一致(topbar 4 插件 vs 2 API 的统计口径混乱,实际 admin.ts 有 20 个 hook);§11.3 测试矩阵与附录 B "95 用例" 自相矛盾(实际 206)。
|
||||||
|
- `src/shell/Registry.tsx` 头注释写"内置插件共 28 个…admin(3)",实际注册 31 个(admin 6)——代码对、注释陈旧。
|
||||||
|
- `docs/standards/ui-design-system.md` 全文面向"4 个微前端 + Module Federation"——该架构已被 ADR-033 废弃。
|
||||||
|
- `MIGRATION_GUIDE.md`/根 README 仍写"前端:Next.js + Module Federation(4 微前端)"。
|
||||||
|
|
||||||
|
#### F10.【低】工程细节
|
||||||
|
|
||||||
|
- `next.config.js` 同时保留 Turbopack 与 webpack 双份配置(注释已说明,可接受)。
|
||||||
|
- `.env.local` 入库(含本地配置,虽无密钥,但 `.env.example` 已存在时应 gitignore)。
|
||||||
|
- `tsconfig.tsbuildinfo`(286KB 构建缓存)入库。
|
||||||
|
|
||||||
|
### 1.4 可复用资产盘点(不要把婴儿和洗澡水一起倒掉)
|
||||||
|
|
||||||
|
| 资产 | 位置 | 状态 | 处置 |
|
||||||
|
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| 微内核仪表盘骨架(Shell/LayoutManager/SlotRenderer/Registry/PluginBoundary/PluginLifecycle/PropsMerger) | `src/shell/` + `src/shared/components/plugin-boundary.tsx` | ✅ 工程质量合格,测试覆盖 | **保留**,收缩为"角色首页仪表盘"专用 |
|
||||||
|
| 数据层封装(apollo-client/useWidgetQuery/useWidgetMutation/ApiError) | `src/lib/` | ✅ 合格 | **保留**,改走同域代理(§5.2) |
|
||||||
|
| lib/api 7 domain + 50 operations | `src/lib/api/` | ⚠️ 结构合格;**50 操作严格匹配 0 个**,类型全部手写 | **保留结构**,按真实 schema 逐域重写操作并恢复 codegen 全量类型(§5.3) |
|
||||||
|
| 三级错误边界 + useErrorReport + notify | `src/shared/` + `packages/hooks/` | ✅ 合格 | **保留**,扩展到页面级 |
|
||||||
|
| 权限位图 + route-permissions 表 | `packages/shared-ts/` + `src/shared/lib/` | ⚠️ 未接线 + GRADE_READ 重复 | **接线到 middleware**,位图去重 |
|
||||||
|
| shadcn 令牌三层 + ui-components 19 件 | `packages/ui-tokens/` + `packages/ui-components/` | ✅ 合格 | **保留为唯一设计系统** |
|
||||||
|
| **4 个旧 portal 的 140 个页面实现** | `apps/{teacher,student,parent,admin}-portal/` | ⚠️ 完整但栈不同(urql + next-intl + MSW + 纸感令牌) | **迁移源**:逐页移植 + 换数据层 + 换令牌 |
|
||||||
|
| 旧 portal 的 MSW handlers(P4/P5/P7 全覆盖) | `apps/teacher-portal/src/mocks/` 等 | ✅ 可用 | **迁移为 portal-shell 开发兜底层** |
|
||||||
|
| next-intl messages(zh-CN/en) | 旧 portal `src/messages/` | ✅ 可用 | **随页面迁移** |
|
||||||
|
| PQ Manifest + APQ + Router limits 安全栈 | `public/pq-manifest.json` + `infra/apollo-router/router.yaml` | ✅ 链路完整 | **保留**,manifest 随操作清单更新重新生成 |
|
||||||
|
| 测试基座(vitest + 19 文件 206 用例) | `src/**/__tests__/` | ✅ 合格 | **保留**,新增页面级测试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 问题根因分析
|
||||||
|
|
||||||
|
为什么"质量门禁全绿"的系统会完全不可用?后续工作必须避免重蹈覆辙,四条根因:
|
||||||
|
|
||||||
|
1. **验收指标错位**:门禁只覆盖 typecheck/lint/单测/build 这些"骨架指标",从未把"页面数、契约匹配率、关键用户流 E2E"纳入验收。于是"206 测试全过"与"没有一个可用页面"同时成立。**对策**:§10 的每个阶段退出标准必须包含功能指标 + 可复现证据。
|
||||||
|
2. **契约逆向编写**:前端 operations 按 spec 文档"超前"编写(50 个操作按设计意图而非后端实现,**0 个通过严格校验**),后端子图只落地了 38 个只读查询、0 个 mutation。`skipDocumentsValidation: true` + 不生成 per-operation 类型,让这种脱节编译期不可见。**对策**:§5.3 契约纪律——operations 只允许引用真实 schema 字段,mock 数据走 MSW 而不是"假契约"。
|
||||||
|
3. **范围误判**:v2.1 把"统一前端"收缩成"统一仪表盘",默认了"页面以后再说",但没有文档记录这个范围缺口,README 反而把仪表盘骨架的完成写成了整个前端的完成。**对策**:本文件 §7/§9 把页面体系定义为 portal-shell 的一等公民职责。
|
||||||
|
4. **文档激励扭曲**:多处"已完成"声明与实测不符(令牌迁移、旧 portal 下线、安全边界),说明验收只看了 PR 描述没看运行态。**对策**:§11.6 文档同步纪律——验收声明必须附可复现命令输出,Reviewer 运行命令复核。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 目标架构 v3.0
|
||||||
|
|
||||||
|
### 3.1 设计原则
|
||||||
|
|
||||||
|
| # | 原则 | 含义 |
|
||||||
|
| --- | ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P1 | **页面一等公民** | 业务功能落在真实 Next.js 路由页面上(可寻址、可分享、可深链);插件仪表盘只承担"角色首页聚合" |
|
||||||
|
| P2 | **契约真实** | 前端 GraphQL 操作只允许引用后端真实 schema;后端未就绪的功能用 MSW mock 数据,禁止"假契约" |
|
||||||
|
| P3 | **fail-closed** | 无身份 → 登录页;无权限 → 403 页;配置缺失 → 内置默认(而非空壳);错误 → 显式兜底(而非静默) |
|
||||||
|
| P4 | **单一设计系统** | shadcn 标准令牌(@edu/ui-tokens)为唯一视觉权威;任何页面/插件不引入第二套令牌 |
|
||||||
|
| P5 | **微内核收敛** | Shell 插件系统收缩为仪表盘专用机制,不再承载"整个应用"的野心;新增功能默认建页面而非建插件 |
|
||||||
|
| P6 | **迁移优先于重写** | 140 个旧页面是迁移源而非重写对象;移植时换数据层(urql→Apollo hooks)、换令牌(纸感→shadcn)、保留业务交互逻辑 |
|
||||||
|
| P7 | **证据化验收** | 每个里程碑以可复现命令 + 运行态截图/录屏验收,禁止"声明即完成" |
|
||||||
|
|
||||||
|
### 3.2 总体结构(混合路由模型)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 浏览器 │
|
||||||
|
└──────────────┬─────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼─────────────────────────────────────────────────┐
|
||||||
|
│ portal-shell :4010(Next.js 16 App Router 单容器) │
|
||||||
|
│ │
|
||||||
|
│ middleware.ts(新) │
|
||||||
|
│ ├─ 公共路径放行(/login、/api/health、静态资源) │
|
||||||
|
│ ├─ JWT cookie 校验 → 注入 x-user-id/x-user-role/x-perms │
|
||||||
|
│ └─ checkRoutePermission(L1 角色 + L2 权限点,fail-closed) │
|
||||||
|
│ │
|
||||||
|
│ /login 登录页(新) │
|
||||||
|
│ /shell/forbidden 403 页(新) │
|
||||||
|
│ /shell ───────────────────── ┐ │
|
||||||
|
│ layout.tsx(新框架) │ AppFrame:TopBar + Sidebar │
|
||||||
|
│ ├─ page.tsx(catch-all) │ + 导航菜单(按角色/权限过滤) │
|
||||||
|
│ │ = 角色仪表盘 │ │
|
||||||
|
│ │ (微内核插件区,保留) │ │
|
||||||
|
│ ├─ teacher/exams/page.tsx │ 真实业务页面(迁移) │
|
||||||
|
│ ├─ teacher/exams/[id]/… │ │
|
||||||
|
│ ├─ student/my-grades/… ┘ │
|
||||||
|
│ └─ … ~140 页面 │
|
||||||
|
│ │
|
||||||
|
│ /api/graphql(新) GraphQL 同域代理 │
|
||||||
|
│ └─ 从 httpOnly cookie 取 JWT → Authorization → apollo-router │
|
||||||
|
│ /api/auth/login(新) 登录代理:调 iam → Set-Cookie │
|
||||||
|
│ /api/health /api/ready /api/log 保留 │
|
||||||
|
└──────────────┬─────────────────────────────────────────────────┘
|
||||||
|
│ 仅两条出站:
|
||||||
|
├─ /api/graphql → apollo-router :3000(业务查询,APQ)
|
||||||
|
└─ /api/v1/* → api-gateway :8080(REST:认证/上传/SSE 等)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键决策(v3.0 ADR 摘要,详见 §3.4):**
|
||||||
|
|
||||||
|
- **V3-A1 混合路由模型**:真实页面 + 保留仪表盘微内核。废除"一切皆卡片、单 catch-all 承载全站"的极端;catch-all 仅保留给仪表盘(可选路由参数如 `?layout=`),新页面一律走显式路由。
|
||||||
|
- **V3-A2 认证链闭环**:登录页 + `/api/auth/login` 代理 + httpOnly cookie + middleware 校验 + `/api/graphql` 代理。**删除 localStorage JWT 方案**。
|
||||||
|
- **V3-A3 门禁接线**:`checkRoutePermission` 移入 `middleware.ts` 真实执行;新增路由必须在 `route-permissions.ts` 登记(CI 校验路由表与文件系统一致性)。
|
||||||
|
- **V3-A4 契约纪律**:operations 与真实 schema 强绑定;codegen 恢复 `typescript-operations` 全量类型;`skipDocumentsValidation` 随后端补齐分域关闭(§5.3)。
|
||||||
|
- **V3-A5 设计系统单源**:shadcn 标准(zinc)为唯一方向,废止纸感全局方案;备课编辑器二期可用模块命名空间(`--lp-*`)局部纸感化。
|
||||||
|
- **V3-A6 接入 next-intl**:复用旧 portal 的 messages,页面迁移不丢失已翻译文案。
|
||||||
|
- **V3-A7 MSW 开发兜底**:`NEXT_PUBLIC_MSW=1` 时启用,前端无后端可开发/演示/跑 E2E;生产构建永不包含。
|
||||||
|
- **V3-A8 验收门禁改革**:CI 增加契约匹配率检查、路由表一致性检查、页面计数、Playwright E2E(P6)。
|
||||||
|
|
||||||
|
### 3.3 与现有架构的关系(保留什么、改变什么)
|
||||||
|
|
||||||
|
| 现有(v2.0) | v3.0 处置 | 理由 |
|
||||||
|
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| 单 catch-all `/shell/[[...route]]` 承载一切 | **收缩**:仅渲染角色仪表盘;新增显式路由优先于 catch-all(Next.js 路由规则天然支持) | 页面可寻址、RSC 预取、loading/error 按路由生效 |
|
||||||
|
| LayoutManager 5 模板(classic/focus/split/triple/canvas) | **保留**为仪表盘区布局;页面区用固定 AppFrame(TopBar+Sidebar+Main) | 模板化价值在仪表盘;页面需要稳定框架 |
|
||||||
|
| 31 个 widget 插件 | **保留骨架**,内容逐步替换/下线:与页面功能重复的卡片改为"页面入口卡 + 关键摘要",数据接通真实 schema | 卡片应该是页面的摘要与入口,不是功能的全部 |
|
||||||
|
| config-service 三层插件配置 | **保留**,职责收缩为"仪表盘 + 导航可见性"配置 | 页面不再依赖插件配置,配置挂了不影响页面 |
|
||||||
|
| SWR 配置静默刷新 | **保留**于仪表盘 | 低频配置变更无需推送 |
|
||||||
|
| URL Search Params 跨插件共享 | **保留**并扩展为页面间状态契约(classId/childId/termId) | 已验证有效 |
|
||||||
|
| RSC 流式渲染(use() + Suspense) | **保留**并推广到页面级(页面 RSC 预取 + 流式注入) | 收益真实 |
|
||||||
|
| 三级错误边界 | **保留**,扩展:Route(页面 error.tsx)→ Section(页面区块)→ Widget(卡片) | 与页面体系对齐 |
|
||||||
|
|
||||||
|
### 3.4 v3.0 ADR 全文
|
||||||
|
|
||||||
|
#### V3-A1:混合路由模型(真实页面 + 仪表盘微内核)
|
||||||
|
|
||||||
|
- **背景**:v2.0 用单 catch-all + 插件配置承载全站,导致页面不可寻址(F1)、权限表空转(F4)、功能止步于卡片(F6)。
|
||||||
|
- **决策**:业务功能落在显式 Next.js 路由页面(`/shell/<role>/<module>/...`),共享 `AppFrame` 布局;插件系统收缩为 `/shell` 角色首页的聚合仪表盘。
|
||||||
|
- **取舍**:放弃"admin 可配置一切页面"的幻想——页面是代码、受版本控制、可 code review;admin 可配置的收缩为"导航可见性 + 仪表盘卡片 + 卡片 props"。这换来:真实 URL、浏览器前进后退、RSC 流式预取、路由级 loading/error、中间件门禁——全部是 Next.js 原生能力,不再自造。
|
||||||
|
- **插件与页面的关系**:卡片 = 页面的摘要 + 入口。点击卡片 → `router.push` 到对应页面。universal 插件按 role 渲染不同摘要视图的机制保留。
|
||||||
|
|
||||||
|
#### V3-A2:认证链闭环 + 删除 localStorage JWT
|
||||||
|
|
||||||
|
- **背景**:F3(无登录页、localStorage token 无人写入、默认角色 teacher)。
|
||||||
|
- **决策**:
|
||||||
|
1. `/login` 页面(迁移自旧 portal login,换 shadcn 令牌)。
|
||||||
|
2. `/api/auth/login` Route Handler:转发凭证到 iam(经 api-gateway),成功后把 JWT 写入 **httpOnly + Secure + SameSite=Strict cookie**(project_rules §4);JS 永远接触不到 token。
|
||||||
|
3. `middleware.ts`:读取 cookie → 校验(开发态 decode 验签跳过,生产用 iam JWKS RS256 验签)→ 注入 `x-user-id`/`x-user-role`/`x-user-permissions` 请求头供 RSC 读取;无 token → redirect `/login?next=<pathname>`。
|
||||||
|
4. `/api/graphql` Route Handler:Apollo Client 的 HttpLink 指向同域 `/api/graphql`;Handler 从 cookie 取 token 加 `Authorization: Bearer` 转发 apollo-router,响应原样回传(含 APQ hash 请求)。
|
||||||
|
- **取舍**:多一跳同域代理(<5ms 本地开销),换来 token 全程不出 httpOnly cookie——消除 XSS 窃取凭证面;同时修复"浏览器绕过 api-gateway"问题(代理在服务端调 router,与 gateway 同信任域)。
|
||||||
|
- **DEV_MODE**:仅本地开发;提供 `dev-token` 合成身份(middleware 生成 x-user-* 头)。生产环境变量缺失/为 true 时启动直接拒绝(`instrumentation.ts` 启动校验)。
|
||||||
|
|
||||||
|
#### V3-A3:路由门禁 middleware 接线 + 权限模型修正
|
||||||
|
|
||||||
|
- **背景**:F4(checkRoutePermission 死代码)、F8(命名双轨)。
|
||||||
|
- **决策**:
|
||||||
|
1. `middleware.ts` 对每个 `/shell/**` 请求执行 `checkRoutePermission(pathname, bitmap, role)`;拒绝 → `/shell/forbidden`(403 页,新)。
|
||||||
|
2. `route-permissions.ts` 四表保留,**新增/移动路由必须同步登记**;CI 运行一致性脚本:扫描 `src/app/**/page.tsx` 路由 ⇄ 权限表双向核对,未登记即失败。
|
||||||
|
3. 权限点唯一来源 `PERMISSION_BITMAP_ORDER`;README 的点号示例作废;31 个 manifest 补登 `requiredPermissions`(L2 插件级,config-service 过滤用)。
|
||||||
|
4. `GRADE_READ` 重复项去重:保留首次出现位(索引 26),删除第二次(索引 31 处),该位标记 `_RESERVED_31` 占位永不复用;趁系统未签发真实 JWT,现在就改(P0)。
|
||||||
|
- **边界说明**:middleware 是 L1/L2 的**第一道**防线(性能/体验),不是唯一防线——resolver 级 `@RequirePermission`(后端)仍是权威。前端门禁防"走错门",后端门禁防"恶意请求"。
|
||||||
|
|
||||||
|
#### V3-A4:GraphQL 契约纪律
|
||||||
|
|
||||||
|
- **背景**:F2(50 操作 vs 后端 38 查询/0 mutation,严格匹配 0/50)。
|
||||||
|
- **决策**:
|
||||||
|
1. **操作清单真实化**:`operations/*.graphql.ts` 只允许引用 `combined-schema.graphql` 真实存在的字段。每新增一个页面,先确认 schema 有所需字段;没有 → 走"契约工单"(§11.4)推动后端补齐,**同时**用 MSW mock 让页面先行。
|
||||||
|
2. **codegen 恢复强校验**:分域关闭 `skipDocumentsValidation`(哪个域后端补齐了就关哪个域),恢复 `typescript-operations` 生成操作级类型,**删除 lib/api 手写 inline 类型**(漂移源)。
|
||||||
|
3. **schema 同步自动化**:`scripts/normalize-schema.ts` 保留;新增 CI 步骤:子图 SDL 变更 → codegen diff 检查,operations 引用不存在字段即失败。
|
||||||
|
4. **PQ Manifest 随构建更新**:`prebuild` 已串联,保留。
|
||||||
|
- **后端已就绪可立即使用的资产**:config-service 全量配置查询;data-ana 的 4 个 `*Dashboard` 聚合查询 + `warnings`/`mastery*`/`diagnosticReports`/`errorBook*`;iam 的 `user/role/dataScope`;core-edu/content 的按 id 单查。**仪表盘首页应优先改用这些真实查询**(P1 任务)。
|
||||||
|
|
||||||
|
#### V3-A5:设计系统单源决议
|
||||||
|
|
||||||
|
- **背景**:F5(三方冲突 + 271 处断裂类)。
|
||||||
|
- **决策**:
|
||||||
|
1. **shadcn 标准令牌(@edu/ui-tokens,zinc 色板)为唯一设计系统**,全局适用(dashboard + 全部页面 + 登录页)。
|
||||||
|
2. `docs/standards/ui-design-system.md` 废止;其仍有价值的内容并入本文件 §8(布局/组件/动效/a11y 规范,按 shadcn 令牌重写)。
|
||||||
|
3. 纸感编辑器方向(Fraunces 主文 + 米白纸面)**降级为二期备课编辑器模块命名空间**(`--lp-*`),仅 lesson-plan 工作台内部使用;现在不做。
|
||||||
|
4. 31 widget 令牌清债(271 处)列入 P1:机械替换(`text-heading-3`→`text-lg font-semibold`、`mt-sm`→`mt-2`、`py-xs`→`py-1`、`px-sm`→`px-2`、`space-y-xs`→`space-y-1`、`p-md`→`p-4`、`gap-xs`→`gap-1`、`border border`→`border`),arch:scan 增加"未知 Tailwind 类"检测规则。
|
||||||
|
- **理由**:CICD 是已验证的 UX 基线,shadcn 生态(组件、文档、AI 协作默契度)最成熟;纸感全局化在旧 portal 实践中与 shadcn 组件冲突成本高,收敛为局部模块更现实。
|
||||||
|
|
||||||
|
#### V3-A6:next-intl 正式接入
|
||||||
|
|
||||||
|
- **背景**:旧页面用 `useTranslations`,messages 资产完整;portal-shell 现有自造 `useT()` 只有 3 条文案。
|
||||||
|
- **决策**:接入 next-intl(App Router 模式),`messages/zh-CN.json` + `messages/en.json` 从旧 portal 合并迁移;`ThemeI18nProvider` 删除自造 i18n,保留主题切换;新代码文案一律走 `useTranslations`,禁止硬编码中文字符串(ESLint 规则 P1 后启用,迁移期 warn)。
|
||||||
|
- **取舍**:增加一个依赖与少量配置,换取不重写全部文案 + 与 CICD/旧 portal 一致的 i18n 习惯。
|
||||||
|
|
||||||
|
#### V3-A7:MSW 开发兜底层
|
||||||
|
|
||||||
|
- **背景**:F7(无后端 = 空壳)。
|
||||||
|
- **决策**:迁移旧 portal 的 MSW handlers 为 portal-shell 的 `src/mocks/`(按 domain 分文件);`NEXT_PUBLIC_MSW=1` 时 browser worker 拦截 `/api/graphql` 与 `/api/v1/*`;`src/instrumentation.ts`(或 middleware 旁路)在 dev + MSW 开启时给 RSC 侧也注入 mock fetcher。**生产构建通过 env 条件 import 保证 mocks 零字节进 bundle**。
|
||||||
|
- **双重收益**:前端不依赖后端就绪即可开发页面;Playwright E2E 直接复用 MSW 场景数据,测试稳定。
|
||||||
|
|
||||||
|
#### V3-A8:验收门禁改革
|
||||||
|
|
||||||
|
- **背景**:根因 1(验收指标错位)。
|
||||||
|
- **决策**:CI 质量门禁从 4 项扩为 8 项:`lint` + `typecheck` + `vitest` + `build` + **契约校验**(codegen diff)+ **路由表一致性** + **页面计数回归**(pages 数不得下降)+ **E2E 冒烟**(P6 起:登录 → 仪表盘 → 每角色 1 条核心流)。每个阶段的 README/本文件验收勾选必须附**命令输出或截图链接**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 认证与身份链
|
||||||
|
|
||||||
|
### 4.1 目标流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 浏览器
|
||||||
|
participant PS as portal-shell
|
||||||
|
participant GW as api-gateway :8080
|
||||||
|
participant IAM as iam
|
||||||
|
participant RT as apollo-router :3000
|
||||||
|
|
||||||
|
U->>PS: POST /api/auth/login {email, password}
|
||||||
|
PS->>GW: POST /api/v1/iam/auth/login
|
||||||
|
GW->>IAM: 验证凭证
|
||||||
|
IAM-->>GW: JWT (RS256) + user + permissions bitmap
|
||||||
|
GW-->>PS: 200 {token, user}
|
||||||
|
PS-->>U: Set-Cookie: edu_session=<JWT>; httpOnly; Secure; SameSite=Strict
|
||||||
|
Note over U: JS 永远拿不到 token
|
||||||
|
|
||||||
|
U->>PS: GET /shell/teacher/exams
|
||||||
|
PS->>PS: middleware: 校验 cookie → 注入 x-user-* 头 → checkRoutePermission
|
||||||
|
alt 无/坏 token
|
||||||
|
PS-->>U: 302 /login?next=/shell/teacher/exams
|
||||||
|
else 无权限
|
||||||
|
PS-->>U: 302 /shell/forbidden
|
||||||
|
else 放行
|
||||||
|
PS->>PS: RSC 读 x-user-* 头渲染页面
|
||||||
|
end
|
||||||
|
|
||||||
|
U->>PS: POST /api/graphql (Apollo, APQ hash)
|
||||||
|
PS->>PS: 从 cookie 取 token
|
||||||
|
PS->>RT: POST /graphql + Authorization: Bearer <JWT>
|
||||||
|
RT-->>PS: data
|
||||||
|
PS-->>U: data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 组件清单
|
||||||
|
|
||||||
|
| 组件 | 文件 | 职责 |
|
||||||
|
| ------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 登录页 | `src/app/login/page.tsx` | 表单 → `/api/auth/login`;已登录跳转 next;迁移自旧 portal,换 shadcn 令牌 |
|
||||||
|
| 登录代理 | `src/app/api/auth/login/route.ts` | 调 gateway iam 登录 → Set-Cookie;失败归一化错误;限流透传 |
|
||||||
|
| 登出 | `src/app/api/auth/logout/route.ts` | 清 cookie + 调 iam 注销 |
|
||||||
|
| 中间件 | `src/middleware.ts` | 公共路径白名单 → cookie 校验 → 注入身份头 → `checkRoutePermission` → 重定向 |
|
||||||
|
| GraphQL 代理 | `src/app/api/graphql/route.ts` | cookie→Bearer 转发;APQ 透传;错误归一化;`cache: "no-store"` |
|
||||||
|
| RSC 身份工具 | `src/lib/auth/server.ts` | `getServerIdentity(): Promise<{userId, role, permissions}>`(读 middleware 注入的头,缺失即抛 → error boundary 跳登录) |
|
||||||
|
| DEV_MODE 守卫 | `src/instrumentation.ts` | `NODE_ENV=production && NEXT_PUBLIC_DEV_MODE=true` → 启动即抛错 |
|
||||||
|
| 403 页 | `src/app/shell/forbidden/page.tsx` | 说明 + 返回仪表盘链接 |
|
||||||
|
|
||||||
|
### 4.3 会话细节决策
|
||||||
|
|
||||||
|
- **cookie 名**:`edu_session`;`Path=/`;`Max-Age` 与 JWT exp 对齐;`Secure` 仅生产(本地 http 开发豁免,用 env 控制)。
|
||||||
|
- **验签**:middleware 用 `jose` 的 `createRemoteJWKSet(iam JWKS endpoint)` 验 RS256;JWKS 端点不可达时 **fail-closed**(跳登录),dev 模式降级为仅 decode(日志警告)。
|
||||||
|
- **权限位图来源**:登录响应中 iam 返回 permissions → login route 计算 base36 位图 → 写入第二个非 httpOnly cookie `edu_perms`(JS 可读,用于客户端按钮级显隐;**仅为 UX,不作为安全依据**——安全判断一律走后端 resolver + middleware 的 httpOnly 通道)。middleware 同样把位图注入 `x-user-permissions` 头。
|
||||||
|
- **角色切换/多角色**:iam 返回单主角色(与现 `x-user-role` 对齐);多角色支持属后端二期,前端不预留。
|
||||||
|
- **旧 portal 的 `lib/auth.ts`(localStorage 方案)**:迁移时**删除**,不按原样搬运。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据层架构
|
||||||
|
|
||||||
|
### 5.1 分层(保留 v2.0 四层,修正两端)
|
||||||
|
|
||||||
|
```
|
||||||
|
页面/插件(UI)
|
||||||
|
→ lib/api/<domain>.ts(语义化 hooks:useExams(classId))
|
||||||
|
→ lib/api/operations/*.graphql.ts(gql 文档,真实 schema 子集)
|
||||||
|
→ lib/useWidgetQuery / useWidgetMutation(Apollo 封装 + ApiError)
|
||||||
|
→ Apollo Client(APQ link)→ /api/graphql(同域代理)
|
||||||
|
→ apollo-router :3000 → 子图
|
||||||
|
```
|
||||||
|
|
||||||
|
- **RSC 服务端**:页面 RSC 用 `createApolloClient()`(已有)经内网直连 router(服务端不经代理,直接带 middleware 解析出的身份头);客户端组件经 `/api/graphql` 代理。
|
||||||
|
- **SWR/轮询**:仪表盘配置保留 SWR;业务数据默认 Apollo `fetchPolicy: "cache-first"`,需要实时性的(通知铃铛)用 `pollInterval`;SSE 通知二期经 realtime-gateway。
|
||||||
|
|
||||||
|
### 5.2 GraphQL 同域代理(/api/graphql)
|
||||||
|
|
||||||
|
- 单文件 Route Handler,`POST` only;透传 body(APQ hash 或 query);注入 `Authorization`;响应状态/JSON 原样返回。
|
||||||
|
- 不缓存(`export const dynamic = "force-dynamic"`)。
|
||||||
|
- 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }`,客户端 `ApiError` 统一。
|
||||||
|
- 保留直连开关:`APOLLO_ROUTER_URL`(服务端 RSC 用),客户端永远只用 `/api/graphql`。
|
||||||
|
|
||||||
|
### 5.3 契约纪律(frontend ⇄ backend)
|
||||||
|
|
||||||
|
1. **schema 唯一源**:`src/lib/api/__generated__/combined-schema.graphql` 由 `scripts/normalize-schema.ts` 从 `services/*/src/graphql/generated/schema.graphql` 生成;**后端 SDL 变更后必须重跑 codegen**,CI 做 diff 检查。
|
||||||
|
2. **operations 真实性**:每个 operation 引用字段必须存在于 combined schema;恢复 `typescript-operations` 生成类型;lib/api 的手写 interface 逐步删除(P1 起按域推进:config → data-ana → core-edu → content → msg → iam)。
|
||||||
|
3. **后端未就绪的功能**:页面允许先上,数据走 MSW(`src/mocks/`),并在页面头部注释 `@contract-pending: <工单号>`;契约工单(§11.4)登记后端待补字段,**每周同步一次状态**。
|
||||||
|
4. **禁止事项**:禁止在 widget/页面内联 gql;禁止手写与 schema 冲突的类型;禁止为迁就前端瞎改 normalize 脚本(当前对 ai 子图的 String 改写保留,单独登记 TD)。
|
||||||
|
|
||||||
|
### 5.4 MSW 兜底层
|
||||||
|
|
||||||
|
- 位置:`src/mocks/`(`browser.ts`/`server.ts`/`handlers/<domain>.ts`),自旧 portal `src/mocks/handlers-p*.ts` 迁移并按 50+ 操作重组。
|
||||||
|
- 启用:`NEXT_PUBLIC_MSW=1`(仅 dev/test);`src/app/layout.tsx` 条件 `import("@/mocks/browser")`(动态 import,production env 下该 import 永不执行 → tree-shaken)。
|
||||||
|
- 数据原则:使用与种子用户一致的语义数据(teacher2@edu.test 的班级/学生/成绩),数据量足够展示分页/空态/超长文本三种边界。
|
||||||
|
- E2E 复用:Playwright webServer 以 `NEXT_PUBLIC_MSW=1` 启动,用例不依赖真实后端。
|
||||||
|
|
||||||
|
### 5.5 后端已就绪查询的立即利用(P1 必做)
|
||||||
|
|
||||||
|
| 真实查询(schema 存在且 resolver 已实现) | 用于 | 注意 |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
||||||
|
| `pluginConfig(userId, role)` / `plugins` / `plugin(pluginId)` / `userLayoutOverride(userId)` | 仪表盘配置(已接,保留) | ✅ 全字段吻合 |
|
||||||
|
| `layoutTemplates` | admin 模板列表 | ⚠️ 仅 `layoutId/displayName/description/isActive`,**无 `availableSlots`**——前端选择集必须裁剪 |
|
||||||
|
| `teacherDashboard` / `studentDashboard` / `parentDashboard` / `adminDashboard` | **角色首页仪表盘主数据源**(替换现有卡片的假契约查询) | ✅,底层字段 snake_case 需在 lib/api 层映射 |
|
||||||
|
| `warnings` / `masterySummary` / `studentMastery` / `masteryDistribution` | 学情预警卡片 | ✅,同上 |
|
||||||
|
| `diagnosticReports` / `errorBookItems` / `errorBookStats` | 诊断/错题卡片与页面 | ✅,字段形状与现 widget 期望不同,需重写 hook |
|
||||||
|
| `user(userId)` / `role(roleId)` / `dataScope(userId)` | 用户菜单、权限显隐 | ⚠️ `User` 字段为 `userId/email/name/status/dataScope`(无 `id`/`role`) |
|
||||||
|
| `exam(id)` / `homework(id)` / `grade(id)` / `classInfo(id)` / `textbook(id)` / `question(id)` / `chapter(id)` / `knowledgePoint(id)` | 详情页首版(列表点击带 id 进入时可真实查询) | ⚠️ 字段名需按真实类型重写(如 `Exam.title/totalScore` 而非 `name/maxScore`) |
|
||||||
|
| `notifications(userId)` | 通知中心首版 | ⚠️ 平铺数组,无分页包装 |
|
||||||
|
| `lessonPlanStatus` | ai 子图唯一查询 | 仅状态轮询可用 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安全架构(安全边际)
|
||||||
|
|
||||||
|
### 6.1 威胁模型与防线矩阵
|
||||||
|
|
||||||
|
| 威胁 | 防线 | 位置 | 现状 | v3.0 处置 |
|
||||||
|
| ----------------------------------- | ----------------------------------------------------- | --------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------- |
|
||||||
|
| 未认证访问业务页 | middleware cookie 校验 | `src/middleware.ts` | ❌ 不存在 | P0 实现,fail-closed |
|
||||||
|
| 越权访问路由(teacher 进 admin 页) | L1 角色 + L2 权限点 | middleware + route-permissions 表 | ❌ 死代码 | P0 接线 + CI 一致性 |
|
||||||
|
| 越权访问数据 | resolver `@RequirePermission` + DataScope | 后端子图 | ✅(审计过 50 resolver) | 保持;前端不兜底也不替代 |
|
||||||
|
| 凭证被 XSS 窃取 | httpOnly cookie,JS 零接触 | `/api/auth/login` | ❌ localStorage 方案 | P0 替换 |
|
||||||
|
| GraphQL 任意查询探测 | APQ + PQ Manifest 白名单 | Apollo link + router | ✅ 链路完整(`require_manifest` 生产需开) | 保持;部署 env 列入 P6 检查单 |
|
||||||
|
| 深度/成本/批量 DoS | router limits | `router.yaml` | ✅ 已配 | 保持 |
|
||||||
|
| 绕过 router 直调子图 | RouterAuthGuard + `router-authorization` | 子图 | ⚠️ 密钥明文 `dev-router-secret` 入库 | P6 换 env 注入;生产 secret 轮换 |
|
||||||
|
| CSRF | SameSite=Strict cookie + 自定义头要求 | cookie + 代理 | ⚠️ 依赖 cookie 属性 | `/api/graphql` 要求 `content-type: application/json`(预检),登录接口 SameSite=Strict |
|
||||||
|
| 生产 DEV_MODE 误开 | 启动自检 | `instrumentation.ts` | ❌ compose 生产 profile 写 true | P0:compose 改 false + 启动校验抛错 |
|
||||||
|
| 错误上报刷爆 | sessionStorage digest 节流 | `useErrorReport` | ✅ 已实现 | 保持 |
|
||||||
|
| XSS 富文本 | 禁 `dangerouslySetInnerHTML`(grep 实测 src 内 0 处) | ESLint + 规范 | ✅ | 保持规则;富文本编辑器(二期)必须 DOMPurify |
|
||||||
|
| 敏感信息入仓 | `.env.local` 已入库(无密钥) | 仓库 | ⚠️ | P0:移出 + gitignore 补规则 |
|
||||||
|
|
||||||
|
### 6.2 三层安全边界(接线后真实形态)
|
||||||
|
|
||||||
|
| 层 | 机制 | 执行点 | 失败行为 |
|
||||||
|
| ----------- | ------------------------------------------------------------ | -------------------------------------------------- | ------------------------- |
|
||||||
|
| L1 角色门禁 | `requiredRoles` | **middleware**(每个 /shell/** 请求) | 302 → `/shell/forbidden` |
|
||||||
|
| L2 权限点 | `requiredPermissions`/`anyOfPermissions`(AND/OR,位图校验) | **middleware**(路由级)+ config-service(卡片级) | 302 → 403 页 / 卡片不渲染 |
|
||||||
|
| L3 数据范围 | DataScope 6 级 | 后端 resolver(权威);前端仅 UX 显隐 | 后端拒绝 / 空态 |
|
||||||
|
|
||||||
|
**边界纪律(防"安全边际"幻觉)**:
|
||||||
|
|
||||||
|
1. 前端门禁是**体验层**,后端 resolver 是**权威层**;任何"前端已挡"不得成为后端不加 `@RequirePermission` 的理由。
|
||||||
|
2. `edu_perms` 可读 cookie 只用于按钮显隐;**禁止**用它做任何数据请求的放行判断。
|
||||||
|
3. middleware 校验失败一律 fail-closed;`checkRoutePermission` 未匹配的路由**默认拒绝**(当前实现默认放行,P0 改为:/shell/** 下未登记路由默认拒绝,公共路径显式白名单)。
|
||||||
|
4. JWT 过期统一处理:`/api/graphql` 收到 401 → 清 cookie → 返回 `{ errors: [{extensions:{code:"UNAUTHENTICATED"}}] }`;客户端 Apollo errorLink 拦截 → `location.href = "/login"`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 信息架构与页面体系
|
||||||
|
|
||||||
|
### 7.1 路由树(目标态)
|
||||||
|
|
||||||
|
```
|
||||||
|
/login 登录
|
||||||
|
/shell/forbidden 403
|
||||||
|
/shell 角色仪表盘(按 x-user-role 分发渲染对应仪表盘)
|
||||||
|
/shell/teacher/… 教师功能区(~50 页,见 §9.1)
|
||||||
|
/shell/student/… 学生功能区(~34 页,见 §9.2)
|
||||||
|
/shell/parent/… 家长功能区(~21 页,见 §9.3)
|
||||||
|
/shell/admin/… 管理功能区(~22 页,见 §9.4)
|
||||||
|
/shell/settings 通用设置(全角色)
|
||||||
|
/shell/notifications 通知中心(全角色)
|
||||||
|
```
|
||||||
|
|
||||||
|
- URL 前缀 `/shell/<role>/` 与 route-permissions 表语义对齐(PREFIX 表按前缀批量保护)。
|
||||||
|
- 仪表盘 `/shell` 按角色渲染不同插件集(现有机制),同时是各功能卡的入口枢纽。
|
||||||
|
- 跨角色同构页面(如 notifications/settings)放共享路径,权限表按角色开放。
|
||||||
|
|
||||||
|
### 7.2 AppFrame(页面框架)
|
||||||
|
|
||||||
|
`src/app/shell/layout.tsx`(新):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ TopBar:global-search · notification-bell │
|
||||||
|
│ locale-switcher · user-menu │
|
||||||
|
├──────────┬───────────────────────────────────┤
|
||||||
|
│ Sidebar │ Main({children}) │
|
||||||
|
│ 导航菜单 │ 页面区(显式路由页面) │
|
||||||
|
│ (按角色/ │ 或仪表盘(/shell) │
|
||||||
|
│ 权限过滤)│ │
|
||||||
|
│ + 上下文 │ │
|
||||||
|
│ 选择器 │ │
|
||||||
|
└──────────┴───────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **TopBar**:复用现有 4 个 topbar 插件(它们本就是全站级组件),不再走插件配置(改为静态挂载 + 权限显隐),仪表盘配置不再能"关掉导航"导致页面失联。
|
||||||
|
- **Sidebar 导航菜单(新组件)**:导航项 = 静态注册表 `src/shared/lib/navigation.ts`(label/i18n key/icon/href/requiredRoles/requiredPermissions),渲染前用 `batchCheckRoutePermission` 过滤(对应 004 §5.4 视口 L1);当前路由高亮;收纳现有 4 个 sidebar 插件(class-selector/child-selector/term-switcher/quick-actions)于菜单下方"上下文区"。
|
||||||
|
- **Main**:仪表盘路由渲染微内核仪表盘;业务路由渲染页面。页面内可用 `DashboardSection`/`SectionErrorBoundary` 组织区块。
|
||||||
|
- **RSC 流式**:layout 保持轻量(导航静态),页面各自 RSC 预取 + Suspense;现有 ClientShell 流式机制迁移为页面级工具(`src/lib/streaming.tsx` 导出 `StreamedPage` 封装,避免每页重写 Provider/Suspense 样板)。
|
||||||
|
|
||||||
|
### 7.3 页面四种类型与模板(新页面必须套模板)
|
||||||
|
|
||||||
|
| 类型 | 适用 | 结构骨架 | 示例 |
|
||||||
|
| ------------ | ---------------------- | ------------------------------------------------------------------------- | ---------------------------------------- |
|
||||||
|
| **列表页** | 集合浏览 + 筛选 + 分页 | PageHeader + FilterBar + DataTable/列表 + Pagination + 空态/骨架/错误三态 | exams、homework、users |
|
||||||
|
| **详情页** | 单实体查看 + 关联操作 | PageHeader(标题+操作) + 信息区 + Tabs/分区 + 关联列表 | exams/[id]、textbooks/[id] |
|
||||||
|
| **表单页** | 创建/编辑 | PageHeader + 表单(react-hook-form + zod)+ 提交/取消 + 错误摘要 | exams/new、lesson-plans/new |
|
||||||
|
| **工作台页** | 多栏协同复杂任务 | 三栏(树/画布/属性)复合组件(Workbench 模式) | lesson-plans/[id]/edit、exams/[id]/build |
|
||||||
|
|
||||||
|
模板代码位置:`src/shared/components/page-templates/{list-page,detail-page,form-page,workbench-page}.tsx`(P1 建)。**新页面一律 `import` 模板而非手排布局**——保证 140 页视觉/交互一致,也是 AI 批量迁移时的统一落点。
|
||||||
|
|
||||||
|
### 7.4 页面级数据获取模式(统一)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/app/shell/teacher/exams/page.tsx(RSC 示例骨架)
|
||||||
|
export default async function ExamsPage(): Promise<React.ReactElement> {
|
||||||
|
const { userId, role } = await getServerIdentity();
|
||||||
|
const client = createApolloClient();
|
||||||
|
const dataPromise = client.query({
|
||||||
|
query: GET_EXAMS_DOC,
|
||||||
|
variables: {/* … */},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<ListPageShell titleKey="exams.title">
|
||||||
|
{/* Promise 直传客户端组件,use() 消费(沿用现有流式模式) */}
|
||||||
|
<ExamsList dataPromise={dataPromise} />
|
||||||
|
</ListPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 列表/详情页:RSC 预取首屏 → `use()` 流式注入 → 客户端交互(筛选/翻页)走 Apollo hooks。
|
||||||
|
- 表单页:客户端组件 + `useWidgetMutation`;成功后 `router.refresh()`。
|
||||||
|
- 每页必须实现三态:`loading.tsx`(骨架,复用 PluginSkeleton 变体)/ `error.tsx`(Route 级边界,已有模式)/ 空态(`empty-state.tsx`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 前端设计规范(强制)
|
||||||
|
|
||||||
|
> 本章是 portal-shell 唯一的视觉/交互权威,废止 `docs/standards/ui-design-system.md`。所有新页面/插件/组件必须遵守;迁移页面时按本章改造。违反本节任意"铁律"的 PR 不得合入。
|
||||||
|
|
||||||
|
### 8.1 设计令牌(三层,唯一来源)
|
||||||
|
|
||||||
|
| 层 | 位置 | 业务可用性 |
|
||||||
|
| ------------ | -------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| L1 Primitive | `packages/ui-tokens/src/primitive.css` | ❌ 禁止直接引用 |
|
||||||
|
| L2 Semantic | `packages/ui-tokens/src/semantic-{light,dark}.css` | ✅ 经 `hsl(var(--*))` |
|
||||||
|
| L3 Tailwind | `packages/ui-tokens/src/tailwind-theme.css`(`@theme inline`) | ✅ 首选:`bg-background`/`text-foreground`/`bg-card`/`border` 等 |
|
||||||
|
|
||||||
|
**铁律(ESLint + code review 双重执行)**:
|
||||||
|
|
||||||
|
1. 禁 `#hex` 字面量(现有规则保留)。
|
||||||
|
2. 禁字体名字面量(现有规则保留)。
|
||||||
|
3. 禁 `font-size: Npx`;字号用 Tailwind 阶梯(`text-xs/sm/base/lg/xl/2xl`)或 `text-size-*`。
|
||||||
|
4. 禁 Tailwind 任意值(`w-[137px]`);间距用默认阶梯(`p-2/p-4/p-6/gap-2/gap-4`)或 `--space-*`。
|
||||||
|
5. **禁引入第二套令牌/第二套 CSS 变量体系**(纸感 `--paper-*`/`--ink-*` 一律不得回潮;迁移旧页面时必须清除)。
|
||||||
|
6. 暗色主题:仅经 `.dark` class + 语义令牌响应;禁组件内写死亮色值。
|
||||||
|
7. 圆角:`rounded-xl`(卡)/`rounded-md`(控件)/`rounded-full`(头像徽章);阴影克制:浮层 `shadow-sm`,卡片默认无阴影。
|
||||||
|
|
||||||
|
### 8.2 字体与排版
|
||||||
|
|
||||||
|
- **单一字体族 Inter**(`next/font` 已挂载 `--font-inter` → `font-sans`);数字密集表格列用 `font-mono`(Tailwind 默认等宽)右对齐。
|
||||||
|
- 标题层级:页面标题 `text-2xl font-semibold`;区块标题 `text-lg font-semibold`;卡片标题 `text-base font-medium`;正文 `text-sm`;辅助 `text-xs text-muted-foreground`。
|
||||||
|
- 行高:正文 `leading-relaxed`;标题 `leading-tight`。
|
||||||
|
- 中文界面默认 `lang="zh-CN"`(已配置);i18n 文案经 next-intl。
|
||||||
|
|
||||||
|
### 8.3 组件使用
|
||||||
|
|
||||||
|
- **基础组件一律来自** `src/shared/components/ui/*`(shadcn 标准件)与 `packages/ui-components/*`(data-table/form/modal/chart/calendar 等 19 件);缺组件先扩库再使用,**禁止页面内手写第三套按钮/输入框**。
|
||||||
|
- 业务复合件:`page-templates` 四件套(§7.3)+ `page-header`/`stat-card`/`stats-grid`/`empty-state`/`filter-bar`(已有)。
|
||||||
|
- Toast 一律 `notify.*`(`src/shared/lib/notify.ts`);**禁** `import { toast } from "sonner"`(P0 补 ESLint `no-restricted-imports` 真正落地);**禁空 catch**(现有 `catch { /* toast */ }` 反例必须改)。
|
||||||
|
- 图标:`lucide-react` 单一来源;图标按钮必须 `aria-label`。
|
||||||
|
- 表格:数据列数字 `font-mono` 右对齐;行分隔 `divide-y divide-border`;禁大面积色块。
|
||||||
|
|
||||||
|
### 8.4 布局与间距
|
||||||
|
|
||||||
|
- 页面容器:`px-6 py-6`(桌面),最大宽度不设限(工作台型产品),列表页可 `max-w-7xl`。
|
||||||
|
- 区块间距 `space-y-6`;卡片内边距 `p-4` 或 `p-6`。
|
||||||
|
- 三栏工作台仅在"编辑类"页面使用(备课/组卷),比例 260px / 1fr / 380px(Workbench 模板内置)。
|
||||||
|
|
||||||
|
### 8.5 动效
|
||||||
|
|
||||||
|
- 交互反馈 ≤ 200ms;页面过渡 ≤ 300ms;遵守 `prefers-reduced-motion`(globals.css 已有)。
|
||||||
|
- 加载一律骨架屏(PluginSkeleton 五变体 / 页面 loading.tsx),**禁居中 spinner 长转**。
|
||||||
|
- 禁装饰性持续动画;hover 反馈用 `bg-muted`/`bg-accent`,禁 `scale-105`。
|
||||||
|
|
||||||
|
### 8.6 可访问性(WCAG AA)
|
||||||
|
|
||||||
|
- 对比度:正文 ≥ 4.5:1(zinc 令牌天然满足,禁自调浅色)。
|
||||||
|
- 所有交互元素可 Tab 聚焦;`focus-visible:ring-2 focus-visible:ring-ring`。
|
||||||
|
- 语义化标签(`nav/main/aside/table`);表单控件必须 `<label>` 或 `aria-label`;动态更新区 `aria-live="polite"`(骨架组件已带,页面复用即可)。
|
||||||
|
- 右键/下拉菜单支持键盘(Radix 组件默认满足,勿破坏)。
|
||||||
|
|
||||||
|
### 8.7 文案与 i18n
|
||||||
|
|
||||||
|
- 一律 `useTranslations`;命名空间按页面域(`exams.*`/`homework.*`/`common.*`)。
|
||||||
|
- 占位/空态文案必须给出"下一步行动"(如"暂无考试 → 新建考试"按钮),禁裸"暂无数据"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 页面迁移总表(~140 页)
|
||||||
|
|
||||||
|
> 功能全景基准:CICD 原版 **155 页**(Next.js 16 + Server Actions + Drizzle,无 GraphQL;auth 4 + onboarding 1 + dashboard 公共 8 + admin 41 + teacher 54 + student 25 + parent 14 + management/grade 5)。
|
||||||
|
> 迁移源:仓库内 4 个旧 portal(`apps/*-portal/`,共 140 个 page.tsx),其页面实现完整但栈为 urql + next-intl + MSW + 纸感令牌。
|
||||||
|
> 缺口说明:旧 portal 未覆盖 CICD 的 management/grade 年级组维度(5 页)与 register/privacy/terms/onboarding(4+ 页)——这两块列为**新增(N)**,排在对应批次末尾。
|
||||||
|
> 迁移动作统一定义:**M = 迁移改造**(移植交互逻辑 + 换 Apollo 数据层 + 换 shadcn 令牌 + 套页面模板);**N = 新建**(旧版没有);**R = 替换**(现有 widget 升级为页面)。
|
||||||
|
> 批次即 §10 路线图的阶段:B1=先行骨架(P1)、B2=教师(P2)、B3=学生(P3)、B4=家长(P4)、B5=管理(P5)。
|
||||||
|
> 契约列:✅=schema 已有(注意字段形状差异,见 §5.5);🟡=部分(单查有/列表无);❌=schema 无,需契约工单 + MSW 先行。
|
||||||
|
|
||||||
|
### 9.1 教师域(teacher-portal 56 页 → /shell/teacher/*,B1+B2)
|
||||||
|
|
||||||
|
| 源路由(teacher-portal) | 目标路由 | 动作 | 契约 | 批次 |
|
||||||
|
| ------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------- | ---------------------------------- | ----- |
|
||||||
|
| `login` | `/login` | M(全站唯一登录页) | ✅ | B1 |
|
||||||
|
| `(app)/dashboard` | `/shell`(教师仪表盘) | R(仪表盘改接 `teacherDashboard` 真实查询) | ✅ | B1 |
|
||||||
|
| `(app)/exams` | `/shell/teacher/exams` | M 列表页 | 🟡 | B2 |
|
||||||
|
| `(app)/exams/new` | `/shell/teacher/exams/new` | M 表单页 | ❌ mutation | B2 |
|
||||||
|
| `(app)/exams/[id]` | `/shell/teacher/exams/[id]` | M 详情页 | ✅ `exam(id)` | B2 |
|
||||||
|
| `(app)/exams/[id]/build` | `/shell/teacher/exams/[id]/build` | M 工作台页 | ❌ | B2 |
|
||||||
|
| `(app)/exams/[id]/edit-rich` | `/shell/teacher/exams/[id]/edit` | M 工作台页 | ❌ | B2 |
|
||||||
|
| `(app)/exams/[id]/analytics` | `/shell/teacher/exams/[id]/analytics` | M 详情页(图表) | 🟡 `assignmentAnalysis` | B2 |
|
||||||
|
| `(app)/exams/[id]/proctoring` | `/shell/teacher/exams/[id]/proctoring` | M(二期,WS) | ❌ | B2 末 |
|
||||||
|
| `(app)/homework`、`/new`、`/[id]`、`/submissions`(共 7 页) | `/shell/teacher/homework/*`(同构 7 页) | M | 🟡/❌ | B2 |
|
||||||
|
| `(app)/grades`、`/entry`、`/analytics`、`/stats`、`/report-card`(5 页) | `/shell/teacher/grades/*`(5 页) | M | 🟡/❌ | B2 |
|
||||||
|
| `(app)/lesson-plans`、`/new`、`/library`、`/calendar`、`/heatmap`、`/[planId]/edit`(6 页) | `/shell/teacher/lesson-plans/*`(6 页) | M(edit 为工作台页) | ❌ | B2 |
|
||||||
|
| `(app)/questions` | `/shell/teacher/questions` | M 列表页 | 🟡 `question(id)` | B2 |
|
||||||
|
| `(app)/textbooks`、`/[id]`(2 页) | `/shell/teacher/textbooks/*`(2 页) | M | 🟡 `textbook(id)` | B2 |
|
||||||
|
| `(app)/attendance`、`/sheet`、`/report`、`/stats`(4 页) | `/shell/teacher/attendance/*`(4 页) | M | ❌ | B2 |
|
||||||
|
| `(app)/classes`、`/[id]`、`/schedule`(3 页) | `/shell/teacher/classes/*`(3 页) | M | 🟡 `classInfo(id)` | B2 |
|
||||||
|
| `(app)/students` | `/shell/teacher/students` | M | ❌ | B2 |
|
||||||
|
| `(app)/course-plans`、`/[id]`(2 页) | `/shell/teacher/course-plans/*`(2 页) | M | ❌ | B2 |
|
||||||
|
| `(app)/elective`、`/create`、`/[id]/edit`(3 页) | `/shell/teacher/elective/*`(3 页) | M | ❌ | B2 |
|
||||||
|
| `(app)/error-book` | `/shell/teacher/error-book` | M | ✅ `errorBookItems/Stats` | B2 |
|
||||||
|
| `(app)/diagnostic`、`/class/[classId]`(2 页) | `/shell/teacher/diagnostic/*`(2 页) | M | ✅ `diagnosticReports` | B2 |
|
||||||
|
| `(app)/analytics`、`/[studentId]`(2 页) | `/shell/teacher/analytics/*`(2 页) | M | 🟡 data-ana 多查询 | B2 |
|
||||||
|
| `(app)/ai-assist`、`/ai-lesson-plan`、`/ai-report`(3 页) | `/shell/teacher/ai/*`(3 页) | M | ❌(ai 子图仅 `lessonPlanStatus`) | B2 末 |
|
||||||
|
| `(app)/knowledge-graph` | `/shell/teacher/knowledge-graph` | M | 🟡 `knowledgePoint(id)` | B2 末 |
|
||||||
|
| `(app)/practice` | `/shell/teacher/practice` | M | ❌ | B2 |
|
||||||
|
| `(app)/schedule-changes` | `/shell/teacher/schedule-changes` | M | ❌ | B2 |
|
||||||
|
| `(app)/leave` | `/shell/teacher/leave` | M | ❌ | B2 |
|
||||||
|
| `(app)/notifications` | `/shell/notifications`(共享) | M | ✅ `notifications(userId)` | B1 |
|
||||||
|
| `(app)/settings` | `/shell/settings`(共享) | M | ✅ | B1 |
|
||||||
|
|
||||||
|
### 9.2 学生域(student-portal 36 页 → /shell/student/*,B3)
|
||||||
|
|
||||||
|
| 源路由 | 目标路由 | 动作 | 契约 | 批次 |
|
||||||
|
| ----------------------------------------------- | ------------------------------------------- | ---------------------- | ------------------------------------------------------- | ----- |
|
||||||
|
| `dashboard`、`/trend`、`/weakness` | `/shell`(学生仪表盘)+ 2 详情页 | R + M | ✅ `studentDashboard`/`learningTrend`/`studentWeakness` | B3 |
|
||||||
|
| `my-grades`、`/report-card` | `/shell/student/grades/*`(2 页) | M | ❌(列表) | B3 |
|
||||||
|
| `my-exams`、`/[id]/result`、`/[id]/take` | `/shell/student/exams/*`(3 页) | M(take 为作答工作台) | 🟡/❌ | B3 |
|
||||||
|
| `my-homework`、`/[id]/submit`、`/[id]/analysis` | `/shell/student/homework/*`(3 页) | M | 🟡/❌ | B3 |
|
||||||
|
| `schedule` | `/shell/student/schedule` | M | ❌ | B3 |
|
||||||
|
| `my-attendance` | `/shell/student/attendance` | M | ❌ | B3 |
|
||||||
|
| `my-classes` | `/shell/student/classes` | M | ❌ `myClasses` | B3 |
|
||||||
|
| `courses`、`/[id]` | `/shell/student/courses/*`(2 页) | M | ❌ | B3 |
|
||||||
|
| `course-plans`、`/[id]` | `/shell/student/course-plans/*`(2 页) | M | ❌ | B3 |
|
||||||
|
| `lesson-plans`、`/[id]/view` | `/shell/student/lesson-plans/*`(2 页) | M | ❌ | B3 |
|
||||||
|
| `textbooks`、`/[id]/chapters` | `/shell/student/textbooks/*`(2 页) | M | 🟡 | B3 |
|
||||||
|
| `error-book` | `/shell/student/error-book` | M | ✅ | B3 |
|
||||||
|
| `learning`、`learning-path` | `/shell/student/learning`、`/learning-path` | M | ❌ | B3 |
|
||||||
|
| `practice`、`/[sessionId]` | `/shell/student/practice/*`(2 页) | M | ❌ | B3 |
|
||||||
|
| `elective`、`/[id]` | `/shell/student/elective/*`(2 页) | M | ❌ | B3 |
|
||||||
|
| `ai-tutor` | `/shell/student/ai-tutor` | M | ❌ | B3 末 |
|
||||||
|
| `announcements`、`/[id]` | `/shell/announcements/*`(共享 2 页) | M | ❌(列表) | B3 |
|
||||||
|
| `messages` | `/shell/messages` | M | ❌ | B3 末 |
|
||||||
|
| `notifications`、`settings` | 共享路由 | M | ✅ | B1/B3 |
|
||||||
|
| `leave` | `/shell/student/leave` | M | ❌ | B3 |
|
||||||
|
|
||||||
|
### 9.3 家长域(parent-portal 24 页 → /shell/parent/*,B4)
|
||||||
|
|
||||||
|
| 源路由 | 目标路由 | 动作 | 契约 | 批次 |
|
||||||
|
| --------------------------------------------------- | -------------------------------------- | ----- | -------------------- | ---- |
|
||||||
|
| `parent/dashboard`、`/trend`、`/weakness` | `/shell`(家长仪表盘)+ 2 页 | R + M | ✅ `parentDashboard` | B4 |
|
||||||
|
| `parent/children/[studentId]` | `/shell/parent/children/[studentId]` | M | ❌ `myChildren` | B4 |
|
||||||
|
| `parent/grades`、`/report-card` | `/shell/parent/grades/*`(2 页) | M | ❌ | B4 |
|
||||||
|
| `parent/exams`、`/[id]/result` | `/shell/parent/exams/*`(2 页) | M | 🟡/❌ | B4 |
|
||||||
|
| `parent/homework` | `/shell/parent/homework` | M | ❌ | B4 |
|
||||||
|
| `parent/attendance` | `/shell/parent/attendance` | M | ❌ | B4 |
|
||||||
|
| `parent/classes` | `/shell/parent/classes` | M | ❌ | B4 |
|
||||||
|
| `parent/course-plans`、`/[id]` | `/shell/parent/course-plans/*`(2 页) | M | ❌ | B4 |
|
||||||
|
| `parent/lesson-plans`、`/[planId]/view` | `/shell/parent/lesson-plans/*`(2 页) | M | ❌ | B4 |
|
||||||
|
| `parent/error-book` | `/shell/parent/error-book` | M | ✅ | B4 |
|
||||||
|
| `parent/diagnostic` | `/shell/parent/diagnostic` | M | ✅ | B4 |
|
||||||
|
| `parent/learning-path` | `/shell/parent/learning-path` | M | ❌ | B4 |
|
||||||
|
| `parent/practice` | `/shell/parent/practice` | M | ❌ | B4 |
|
||||||
|
| `parent/elective` | `/shell/parent/elective` | M | ❌ | B4 |
|
||||||
|
| `parent/leave` | `/shell/parent/leave` | M | ❌ | B4 |
|
||||||
|
| `parent/notifications`、`/settings`、`/preferences` | 共享 + `/shell/parent/preferences` | M | ✅/❌ | B4 |
|
||||||
|
|
||||||
|
### 9.4 管理域(admin-portal 24 页 → /shell/admin/*,B5)
|
||||||
|
|
||||||
|
| 源路由 | 目标路由 | 动作 | 契约 | 批次 |
|
||||||
|
| ---------------------------------- | -------------------------------------------- | --------------------------------- | ------------------- | ---- |
|
||||||
|
| `admin/dashboard` | `/shell`(管理仪表盘) | R | ✅ `adminDashboard` | B5 |
|
||||||
|
| `admin/users` | `/shell/admin/users` | R(现有 widget 升级整页) | ❌ `users` 列表 | B5 |
|
||||||
|
| `admin/roles`、`admin/permissions` | `/shell/admin/roles`、`/permissions`(2 页) | R/M | ❌ | B5 |
|
||||||
|
| `admin/audit-logs` + 3 子页 | `/shell/admin/audit-logs/*`(4 页) | M | ❌ | B5 |
|
||||||
|
| `admin/invitation-codes` | `/shell/admin/invitation-codes` | R | ❌ | B5 |
|
||||||
|
| `admin/school` + 5 子页 | `/shell/admin/school/*`(6 页) | M | ❌ | B5 |
|
||||||
|
| `admin/classes` | `/shell/admin/classes` | M | ❌ | B5 |
|
||||||
|
| `admin/students`、`admin/teachers` | `/shell/admin/students`、`/teachers`(2 页) | M | ❌ | B5 |
|
||||||
|
| `admin/organization` | `/shell/admin/organization` | M | ❌ | B5 |
|
||||||
|
| `admin/announcements` | `/shell/admin/announcements` | M | ❌ | B5 |
|
||||||
|
| `admin/files` | `/shell/admin/files` | M | ❌ | B5 |
|
||||||
|
| `admin/ai-settings` | `/shell/admin/ai-settings` | M | ❌ | B5 |
|
||||||
|
| `admin/system` | `/shell/admin/system` | M | ❌ | B5 |
|
||||||
|
| `admin/viewports` | `/shell/admin/viewports` | M(对齐 004 §5.4 视口配置) | ❌ | B5 |
|
||||||
|
| —(新)插件管理 | `/shell/admin/plugins` | R(现有 plugin-manager 升级整页) | ✅ config-service | B5 |
|
||||||
|
|
||||||
|
### 9.5 计数与节奏
|
||||||
|
|
||||||
|
| 域 | 页面数 | 契约已就绪(✅/🟡) | 批次 |
|
||||||
|
| -------------------------------------------------------------------------------------------------- | -------- | ------------------- | ------------- |
|
||||||
|
| 共享(login/notifications/settings/announcements/messages/forbidden) | ~7 | 4 | B1/B3 |
|
||||||
|
| 教师 | ~50 | ~14 | B1+B2 |
|
||||||
|
| 学生 | ~34 | ~8 | B3 |
|
||||||
|
| 家长 | ~21 | ~5 | B4 |
|
||||||
|
| 管理 | ~22 | ~3 | B5 |
|
||||||
|
| 缺口新增(management/grade 5 页 + register/onboarding/privacy/terms ~5 页,CICD 有而旧 portal 无) | ~10 | 0 | B2/B5 末(N) |
|
||||||
|
| **合计** | **~144** | **~34** | — |
|
||||||
|
|
||||||
|
**节奏原则**:契约就绪页先行;❌ 页用 MSW 先上 UI,契约工单跟踪后端补齐后切换真实查询(切换 = 改 hook 的 fetcher 指向,页面不动)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 实施路线图(P0–P6)
|
||||||
|
|
||||||
|
> 每阶段列出:目标 / 范围 / 验收标准(可复现命令 + 运行态证据)。阶段内任务按 §11 规范拆给多 AI 并行。**未完成上一阶段退出标准不得进入下一阶段。**
|
||||||
|
|
||||||
|
### P0 · 地基止血(1 周)
|
||||||
|
|
||||||
|
**目标**:消除"空壳即不可用 + 认证裸奔"两大致命伤。
|
||||||
|
|
||||||
|
| # | 任务 | 验收 |
|
||||||
|
| ---- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| P0-1 | `/login` 页 + `/api/auth/login | logout`+`edu_session` httpOnly cookie | 本地起 iam/gateway,错误密码报错、正确密码进 /shell;cookie 在 DevTools Application 可见且 httpOnly |
|
||||||
|
| P0-2 | `src/middleware.ts`:cookie 校验 + 身份头注入 + `checkRoutePermission` 接线 + `/shell/forbidden` | 无 cookie 访问 /shell/** → 302 /login;student 访问 /shell/admin/users → 302 /shell/forbidden(截图) |
|
||||||
|
| P0-3 | `/api/graphql` 代理 + Apollo Client 指向同域 + 删除 localStorage token 方案 | DevTools Network 面板无 `localhost:3000` 直连;`grep -r "localStorage" src/providers` 无 token 读取 |
|
||||||
|
| P0-4 | 配置兜底改进:`getDefaultConfig()` 返回**内置默认仪表盘插件集**(按角色静态定义),config-service 不可用时仪表盘仍有内容 | 停掉 config-service 与 router,启动前端,/shell 显示默认卡片而非"暂无可见插件"(截图) |
|
||||||
|
| P0-5 | DEV_MODE 生产守卫:`instrumentation.ts` 启动校验 + `docker-compose.yml` 生产 profile 改 `NEXT_PUBLIC_DEV_MODE: "false"` | `NODE_ENV=production NEXT_PUBLIC_DEV_MODE=true pnpm start` 启动即报错退出 |
|
||||||
|
| P0-6 | 权限位图修正:`GRADE_READ` 去重(保留索引位,二见位改 `_RESERVED_`)+ 单测更新 | `vitest run permission-bitmap` 通过;`validateRoutePermissionConfigs()` 返回空 |
|
||||||
|
| P0-7 | ESLint 补齐:`no-restricted-imports`(禁 widget 跨目录 import、禁直 import sonner、禁页面绕过 lib/api 直 import @apollo/client) | `pnpm lint` 对预埋反例报错(附输出) |
|
||||||
|
| P0-8 | 仓库卫生:`.env.local` 移出版本控制 + `.gitignore` 补 `tsconfig.tsbuildinfo` | `git status` 干净 |
|
||||||
|
|
||||||
|
### P1 · 框架与数据源接通(1–2 周)
|
||||||
|
|
||||||
|
**目标**:AppFrame + 导航 + 真实数据仪表盘 + 页面模板 + MSW + i18n,页面迁移的"流水线"建成。
|
||||||
|
|
||||||
|
| # | 任务 | 验收 |
|
||||||
|
| ---- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P1-1 | `src/app/shell/layout.tsx` AppFrame(TopBar 静态挂载 + Sidebar 导航菜单 `navigation.ts` + 权限过滤) | 4 角色各见各菜单;导航项与 route-permissions 表一致(单测) |
|
||||||
|
| P1-2 | 仪表盘改接真实查询:`teacherDashboard`/`studentDashboard`/`parentDashboard`/`adminDashboard` + `warnings`/`errorBookStats` | 起全栈,4 角色仪表盘显示真实聚合数据(截图);widget 假契约查询(grades/homeworks/schedule/attendance/exams/announcements)全部下线或改造 |
|
||||||
|
| P1-3 | 页面模板四件套(list/detail/form/workbench)+ 三态规范 | Storybook 或示例页 4 张(/shell/_dev/templates/*,仅 dev 可见) |
|
||||||
|
| P1-4 | next-intl 接入 + messages 合并迁移 | 切换 locale 页面文案切换(截图);`useT` 自造函数删除 |
|
||||||
|
| P1-5 | MSW 兜底层(迁移旧 handlers,覆盖 dashboard/users/exams/grades 四域起步) | `NEXT_PUBLIC_MSW=1` 无后端启动,仪表盘 + users 页有数据(截图);生产构建 bundle 无 mocks |
|
||||||
|
| P1-6 | 31 widget 令牌清债(271 处机械替换)+ `border border` 去重 + 未知类检测进 arch:scan | `grep -c "text-heading-\|mt-sm\|py-xs\|p-md" src/widgets` = 0;`pnpm lint:tokens` 通过 |
|
||||||
|
| P1-7 | codegen 恢复 typescript-operations(config + data-ana 两域先行关闭 skipDocumentsValidation) | 生成操作级类型;lib/api 对应域删除手写 interface;typecheck 通过 |
|
||||||
|
| P1-8 | CI 增补:路由表一致性脚本 + 页面计数 + codegen diff 检查 | CI 对预埋违规报红(附 pipeline 链接) |
|
||||||
|
|
||||||
|
### P2 · 教师域页面(2–3 周,可与 P3 部分并行)
|
||||||
|
|
||||||
|
- 范围:§9.1 全表(~50 页)。顺序建议:exams → homework → grades → lesson-plans → questions/textbooks → attendance/classes/students → diagnostic/error-book/analytics → elective/course-plans → ai-* → practice/schedule-changes/leave。
|
||||||
|
- 每模块退出:列表/详情/表单页齐 + 三态 + MSW 场景 + 单测(数据变换函数)+ 页面计数更新。
|
||||||
|
- 契约工单:exams CRUD mutation、homework CRUD、grades 录入/列表、lessonPlans CRUD、attendance、classes 列表等,随模块开工即登记。
|
||||||
|
|
||||||
|
### P3 · 学生域页面(1.5–2 周)
|
||||||
|
|
||||||
|
- 范围:§9.2 全表(~34 页)。先行:dashboard 详情(trend/weakness 已 ✅)→ error-book(✅)→ grades/exams/homework 只读 → exams/take 作答台 → practice → ai-tutor。
|
||||||
|
|
||||||
|
### P4 · 家长域页面(1–1.5 周)
|
||||||
|
|
||||||
|
- 范围:§9.3 全表(~21 页)。多为只读视图,复用学生域组件;重点:`myChildren` 契约 + child-overview 聚合。
|
||||||
|
|
||||||
|
### P5 · 管理域页面(1.5–2 周)
|
||||||
|
|
||||||
|
- 范围:§9.4 全表(~22 页)。先行:users(❌ 列表契约优先推)→ roles/permissions → audit-logs → school 体系 → invitation-codes → plugins(✅)→ viewports(对齐 004 §5.4)。
|
||||||
|
- 收尾:旧 4 portal 目录归档(`apps/_archive/`)或删除(决策点:待全量验收后执行,单独 PR)。
|
||||||
|
|
||||||
|
### P6 · 硬化与验收(1–2 周)
|
||||||
|
|
||||||
|
- Playwright E2E:登录 → 各角色核心流 1 条(教师建考试/学生交作业/家长看成绩/管理员建用户)+ 三级错误边界 + 门禁越权。
|
||||||
|
- 视觉回归:5 布局 + 每域代表页截图基线。
|
||||||
|
- 性能:首屏 JS ≤ 300KB(gzip)、LCP < 2s(本地 Docker 压测,附报告)。
|
||||||
|
- 生产检查单:`APOLLO_REQUIRE_PQ_MANIFEST=true`、`APOLLO_ROUTER_INTROSPECTION=false`、`NEXT_PUBLIC_DEV_MODE=false`、`ROUTER_AUTH_SECRET` env 化、CSP 头(`next.config.js headers()`)、`/api/log` 换生产端点。
|
||||||
|
- 文档:README v3.0 重写(本文件内容沉淀回模块 README + 本文件标记"已并入 README")。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 后续 AI 工作规范(强制)
|
||||||
|
|
||||||
|
> 本章是给**所有后续在本模块工作的 AI/人**的操作规程。多 AI 协作同时遵守 project_rules §14(模块单一负责制:本模块只改 `apps/portal-shell/`,跨模块变更按 §14.4 顺序)。
|
||||||
|
|
||||||
|
### 11.1 开工前必读(按序)
|
||||||
|
|
||||||
|
1. 本文件(v3.0 总纲)
|
||||||
|
2. `project_rules.md` §3.4/§3.10/§4/§14
|
||||||
|
3. 所接任务的页面源(旧 portal 对应 page.tsx)与目标模板(§7.3)
|
||||||
|
|
||||||
|
### 11.2 目录与文件约定
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├─ app/
|
||||||
|
│ ├─ login/page.tsx # 登录
|
||||||
|
│ ├─ shell/
|
||||||
|
│ │ ├─ layout.tsx # AppFrame(唯一框架)
|
||||||
|
│ │ ├─ forbidden/page.tsx # 403
|
||||||
|
│ │ ├─ page.tsx # 角色仪表盘(catch-all 保留于仪表盘内部可选)
|
||||||
|
│ │ ├─ <role>/<module>/page.tsx # 业务页面(显式路由)
|
||||||
|
│ │ └─ <role>/<module>/{loading,error}.tsx
|
||||||
|
│ └─ api/{graphql,auth/login,auth/logout,health,ready,log}/route.ts
|
||||||
|
├─ middleware.ts # 认证 + 门禁(唯一入口)
|
||||||
|
├─ features/<domain>/ # 页面私有实现(组件/hooks/类型),按域组织
|
||||||
|
│ └─ exams/{exams-list.tsx, exam-detail.tsx, use-exam-filters.ts}
|
||||||
|
├─ widgets/ # 仅仪表盘卡片(存量,冻结新增——新功能建页面不建插件)
|
||||||
|
├─ shell/ # 微内核(存量,冻结)
|
||||||
|
├─ lib/{api,apollo-client,auth} # 数据层/认证工具
|
||||||
|
├─ mocks/ # MSW(dev/test only)
|
||||||
|
├─ shared/{components,lib} # 共享 UI 与工具
|
||||||
|
└─ messages/{zh-CN,en}.json # i18n
|
||||||
|
```
|
||||||
|
|
||||||
|
- **页面文件瘦身**:`page.tsx` 只做 RSC 预取 + 组装,实现代码放 `features/<domain>/`;单文件 ≤ 300 行(超出即拆分)。
|
||||||
|
- **features 域清单**:exams / homework / grades / lesson-plans / questions / textbooks / attendance / classes / students / course-plans / elective / error-book / diagnostic / analytics / practice / schedule-changes / leave / ai / notifications / settings / users / rbac / audit / school / invitations / plugins / organization / files / system / viewports / children / messages / announcements。新增域先在本文件 §9 登记。
|
||||||
|
|
||||||
|
### 11.3 每页硬性清单(DoD)
|
||||||
|
|
||||||
|
- [ ] 显式路由 + `route-permissions.ts` 已登记(CI 一致性通过)
|
||||||
|
- [ ] 套页面模板(§7.3),未手排布局
|
||||||
|
- [ ] `loading.tsx` + `error.tsx` + 空态三态齐全
|
||||||
|
- [ ] 数据走 `lib/api` hooks;无内联 gql;无手写与 schema 冲突类型
|
||||||
|
- [ ] schema 未就绪的查询走 MSW + 文件头 `@contract-pending: <工单>` 注释 + §11.4 登记
|
||||||
|
- [ ] 文案走 `useTranslations`;无硬编码中文(迁移期 lint warn,P2 末转 error)
|
||||||
|
- [ ] 无禁用类/字面量(§8.1 铁律);`pnpm lint` + `lint:tokens` 通过
|
||||||
|
- [ ] 交互错误有 `notify.error`;无空 catch
|
||||||
|
- [ ] 数据变换/权限判断等纯函数有 vitest 单测
|
||||||
|
- [ ] i18n 两份 messages 同步更新
|
||||||
|
- [ ] 提交信息:`feat(portal-shell): <module> <page> migration`(Conventional Commits)
|
||||||
|
|
||||||
|
### 11.4 契约工单流程(前端缺 schema 字段时)
|
||||||
|
|
||||||
|
1. 在 `docs/architecture/issues/contracts/<service>_contract.md` 追加需求(字段名/类型/权限点/使用页面)。
|
||||||
|
2. 本文件 §9 对应行"契约"列标注工单号。
|
||||||
|
3. 页面用 MSW 先行;后端落地后:重跑 normalize + codegen → 该域关 `skipDocumentsValidation` → 切换 fetcher → 删 mock → 工单关闭。
|
||||||
|
4. **禁止**为绕过校验把查询写得与 schema 不符后开启 skip。
|
||||||
|
|
||||||
|
### 11.5 多 AI 并行分工建议
|
||||||
|
|
||||||
|
| AI 角色 | 负责 | 边界 |
|
||||||
|
| -------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
|
| 框架 AI | P0/P1(middleware/代理/AppFrame/模板/MSW/i18n) | 独占 `src/{app/shell/layout.tsx,middleware.ts,shared/,lib/,mocks/}` |
|
||||||
|
| 教师域 AI | §9.1 | 独占 `src/app/shell/teacher/**` + `src/features/{exams,homework,grades,lesson-plans,...}` |
|
||||||
|
| 学生域 AI | §9.2 | 独占 `src/app/shell/student/**` + 对应 features |
|
||||||
|
| 家长域 AI | §9.3 | 独占 `src/app/shell/parent/**` + 对应 features |
|
||||||
|
| 管理域 AI | §9.4 | 独占 `src/app/shell/admin/**` + 对应 features |
|
||||||
|
| 公共区(login/notifications/settings) | 框架 AI | 防止撞车 |
|
||||||
|
|
||||||
|
- 跨域共享组件必须先提"共享申请"(改 `src/shared/` 或 `packages/ui-components/`),由框架 AI 评审合入;**禁止**各自复制粘贴。
|
||||||
|
- 分支:每域一个 feature 分支(`feat/portal-shell-<domain>`),按 project_rules §14.3。
|
||||||
|
|
||||||
|
### 11.6 文档与验收纪律
|
||||||
|
|
||||||
|
- 每个 PR 更新:本文件 §9 对应行状态 +(如涉及)README 对应章节。
|
||||||
|
- 验收勾选必须附**可复现命令输出**(typecheck/lint/test/build/截图),禁止"已完成"裸声明——本文件 §1.1 的对照表即为反面教材。
|
||||||
|
- 每阶段结束运行 `pnpm run arch:scan` 更新 arch.db,并同步 004 相关章节。
|
||||||
|
|
||||||
|
### 11.7 禁止事项(红线)
|
||||||
|
|
||||||
|
1. 禁止新增 widget 插件实现业务功能(冻结 `src/widgets/` 新增;新功能 = 新页面)。
|
||||||
|
2. 禁止恢复 localStorage 存 token;禁止 JS 读取 `edu_session`。
|
||||||
|
3. 禁止在页面/widget 内联 gql 或绕过 `lib/api` 直接 `useQuery`。
|
||||||
|
4. 禁止引入第二套设计令牌 / 第二套组件库 / 第二套 toast。
|
||||||
|
5. 禁止"默认放行"式的权限代码(未匹配路由必须拒绝)。
|
||||||
|
6. 禁止跳过 `@contract-pending` 登记直接写假查询。
|
||||||
|
7. 禁止删除/弱化现有测试来让门禁变绿。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 风险登记册
|
||||||
|
|
||||||
|
| # | 风险 | 等级 | 缓解 |
|
||||||
|
| --- | ---------------------------------------------- | ---- | ----------------------------------------------------------------------------------- |
|
||||||
|
| R1 | 后端契约补齐速度成为页面切换瓶颈(~100 页 ❌) | 高 | MSW 先行解耦;契约工单周同步;优先推 mutations 框架(后端 0 mutation 是系统性缺口) |
|
||||||
|
| R2 | 140 页迁移量大,AI 并行产出不一致 | 高 | 模板四件套强制 + 共享组件申请制 + 每模块 DoD 清单 + CI 一致性脚本 |
|
||||||
|
| R3 | 旧页面纸感令牌清理遗漏导致视觉混杂 | 中 | 迁移 DoD 含令牌检查;arch:scan 未知类检测;P6 视觉回归 |
|
||||||
|
| R4 | middleware 验签引入 JWKS 可用性依赖 | 中 | JWKS 缓存(5min)+ 故障 fail-closed + 启动自检 |
|
||||||
|
| R5 | next-intl 与 RSC 流式模式冲突 | 低 | P1 先做技术验证(一个页面跑通再推广) |
|
||||||
|
| R6 | MSW 数据与真实 schema 漂移 | 中 | mock 数据类型从 codegen types 导入;契约切换时删 mock |
|
||||||
|
| R7 | 旧 portal 删除时发现有未迁移边角功能 | 低 | P5 删除前做路由 diff 清单评审;先归档后删除 |
|
||||||
|
| R8 | config-service 单点故障影响仪表盘 | 低 | P0-4 内置默认配置已兜底;页面不依赖插件配置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 附录
|
||||||
|
|
||||||
|
### 13.1 命令手册
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发(无后端)
|
||||||
|
NEXT_PUBLIC_MSW=1 NEXT_PUBLIC_DEV_MODE=true node node_modules/next/dist/bin/next dev -p 4010
|
||||||
|
|
||||||
|
# 质量门禁(CI 全量)
|
||||||
|
node node_modules/typescript/bin/tsc --noEmit # typecheck
|
||||||
|
node node_modules/.bin/eslint src # lint(或 pnpm lint)
|
||||||
|
node node_modules/vitest/vitest.mjs run # 单测
|
||||||
|
node node_modules/next/dist/bin/next build # 构建
|
||||||
|
node node_modules/.bin/eslint -c .eslintrc.tokens.js src # 令牌专项
|
||||||
|
|
||||||
|
# 契约
|
||||||
|
node_modules/.bin/tsx scripts/normalize-schema.ts && node_modules/.bin/graphql-codegen --config codegen.yml
|
||||||
|
node_modules/.bin/tsx scripts/generate-pq-manifest.ts
|
||||||
|
|
||||||
|
# 一致性校验(P1 起)
|
||||||
|
node scripts/check-route-permissions.mjs # 路由表 ⇄ 文件系统
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.2 关键文件速查
|
||||||
|
|
||||||
|
| 主题 | 文件 |
|
||||||
|
| -------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 微内核仪表盘 | `src/shell/{Shell,ClientShell,LayoutManager,SlotRenderer,Registry,PropsMerger,PluginLifecycle,PluginStore}.tsx?` |
|
||||||
|
| 数据层 | `src/lib/{apollo-client,useWidgetQuery,useWidgetMutation,config-fetcher,usePluginConfig}.ts` + `src/lib/api/` |
|
||||||
|
| 安全 | `src/shared/lib/route-permissions.ts` + `packages/shared-ts/src/permission-bitmap.ts` +(新)`src/middleware.ts` |
|
||||||
|
| 错误处理 | `src/shared/components/{plugin,route,section}-boundary.tsx` + `packages/hooks/src/use-error-report.ts` |
|
||||||
|
| 设计令牌 | `packages/ui-tokens/src/{primitive,semantic-light,semantic-dark,tailwind-theme}.css` |
|
||||||
|
| 组件库 | `src/shared/components/ui/*` + `packages/ui-components/src/*` |
|
||||||
|
| 迁移源 | `apps/{teacher,student,parent,admin}-portal/src/app/**/page.tsx` + `apps/teacher-portal/src/mocks/handlers-p*.ts` |
|
||||||
|
| 后端 schema 源 | `services/*/src/graphql/generated/schema.graphql` → `src/lib/api/__generated__/combined-schema.graphql` |
|
||||||
|
|
||||||
|
### 13.3 参考文档
|
||||||
|
|
||||||
|
| 文档 | 用途 |
|
||||||
|
| ----------------------------------------------------------------------- | ------------------------------------------ |
|
||||||
|
| [README.md](./README.md) v2.0 | 微内核仪表盘子系统详细设计(保留有效部分) |
|
||||||
|
| [004 §5.4](../../docs/architecture/004_architecture_impact_map.md) | 视口四层模型(导航/路由/组件/数据) |
|
||||||
|
| [004 §11.7](../../docs/architecture/004_architecture_impact_map.md) | 前端数据层 + GraphQL 安全栈设计意图 |
|
||||||
|
| [GraphQL @auth 审计](../../docs/security/graphql-auth-audit-2026-07.md) | 后端 resolver 权限现状 |
|
||||||
|
| [MIGRATION_GUIDE.md](../../MIGRATION_GUIDE.md) | CICD → Edu 迁移背景与资产清单 |
|
||||||
|
| [多 AI 协作规范](../../docs/standards/multi-ai-collaboration.md) | 并行工作规则 |
|
||||||
|
|
||||||
|
### 13.4 术语表
|
||||||
|
|
||||||
|
| 术语 | 定义 |
|
||||||
|
| ------------------- | ------------------------------------------------------------------- |
|
||||||
|
| AppFrame | 全站页面框架(TopBar + Sidebar + Main),`src/app/shell/layout.tsx` |
|
||||||
|
| 微内核仪表盘 | `/shell` 角色首页的插件聚合区(v2.0 遗产,保留收缩) |
|
||||||
|
| 页面模板四件套 | list / detail / form / workbench 四种页面骨架(§7.3) |
|
||||||
|
| 契约工单 | 前端缺 schema 字段时的后端需求登记单(§11.4) |
|
||||||
|
| MSW 兜底层 | `src/mocks/` 开发态模拟后端(§5.4) |
|
||||||
|
| `@contract-pending` | 页面头注释,标记数据来自 MSW 待契约切换 |
|
||||||
|
| fail-closed | 无身份/无权限/服务不可用时一律拒绝并显式提示,禁止静默放行 |
|
||||||
|
| 视口四层 | 004 §5.4:导航 L1 / 路由 L2 / 组件 L3 / 数据 L4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **本文件自发布之日起为 portal-shell 前端工作唯一权威。任何"已完成"声明必须能以 §1.1 同等严格度复现。下一阶段开工前,先完成 P0 并回填本文件 §9/§10 状态。**
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# portal-shell 模块架构文档
|
# portal-shell 模块架构文档
|
||||||
|
|
||||||
|
> **⚠️ 重要(2026-07-20)**:本文件 v2.0 的多处完成度声明与实测不符(详见审计)。**portal-shell 前端工作的唯一权威文档已迁移至 [ARCHITECTURE.md](./ARCHITECTURE.md)(v3.0 总纲:现状审计 + 目标架构 + 页面迁移路线图 + 工作规范)**。本文件仅其"微内核仪表盘子系统"设计部分继续有效,凡与本文件冲突处以 ARCHITECTURE.md 为准。
|
||||||
|
|
||||||
> 版本:2.0
|
> 版本:2.0
|
||||||
> 日期:2026-07-17
|
> 日期:2026-07-17
|
||||||
> 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0-P4 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功 / 206 测试全部通过)
|
> 状态:已落地(v2.1 M8-M12 完成 + v1.1 数据抽象与 GraphQL 加固完成 + v2.0 shadcn 标准化 + 三层安全边界 + 流式渲染 + 三级错误处理完成 + P0-P4 全部验证通过:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功 / 206 测试全部通过)
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
* - 禁止 #hex 颜色字面量
|
* - 禁止 #hex 颜色字面量
|
||||||
* - 禁止 'Inter'/'Fraunces'/'JetBrains Mono' 字体名字面量
|
* - 禁止 'Inter'/'Fraunces'/'JetBrains Mono' 字体名字面量
|
||||||
*
|
*
|
||||||
* 关联:project_rules §3.10、portal-shell spec §7
|
* P0-7:补 no-restricted-imports(ARCHITECTURE.md §3.4 V3-A3 / §8.3 铁律)
|
||||||
|
* - 禁 widget/页面 直接 import sonner(统一走 @/shared/lib/notify)
|
||||||
|
* - 禁 widget/页面 绕过 lib/api 直接 import @apollo/client
|
||||||
|
* - 禁 widget 跨目录 import 其他 widget(插件隔离)
|
||||||
|
*
|
||||||
|
* 关联:project_rules §3.10、portal-shell ARCHITECTURE.md §8.3、§11.7
|
||||||
*/
|
*/
|
||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
@@ -66,6 +71,56 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// P0-7:no-restricted-imports 强制(ARCHITECTURE.md §8.3 / §11.7 红线)
|
||||||
|
// - 禁直接 import sonner(统一走 @/shared/lib/notify 封装)
|
||||||
|
// - 禁页面/widget 绕过 lib/api 直接 import @apollo/client
|
||||||
|
// - 禁 widget 跨目录 import 其他 widget(插件隔离铁律)
|
||||||
|
// 白名单:
|
||||||
|
// - notify 封装本体(src/shared/lib/notify.ts)+ 其测试(__tests__/notify.test.ts)+ Toaster 容器(src/shared/components/ui/sonner.tsx)
|
||||||
|
// - 数据层(src/lib/apollo-client.ts、useWidgetQuery.ts、useWidgetMutation.ts、config-fetcher.ts、src/lib/api/**、src/providers/ApolloProvider.tsx)
|
||||||
|
// - widget 注册中心(src/shell/Registry.tsx)必须 import 各 widget 的 plugin.manifest,是唯一例外
|
||||||
|
{
|
||||||
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
|
ignores: [
|
||||||
|
"src/shared/lib/notify.ts",
|
||||||
|
"src/shared/lib/__tests__/notify.test.ts",
|
||||||
|
"src/shared/components/ui/sonner.tsx",
|
||||||
|
"src/lib/apollo-client.ts",
|
||||||
|
"src/lib/useWidgetQuery.ts",
|
||||||
|
"src/lib/useWidgetMutation.ts",
|
||||||
|
"src/lib/config-fetcher.ts",
|
||||||
|
"src/lib/api/**",
|
||||||
|
"src/providers/ApolloProvider.tsx",
|
||||||
|
"src/shell/Registry.tsx",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
name: "sonner",
|
||||||
|
message:
|
||||||
|
"禁止直接 import sonner,统一使用 @/shared/lib/notify(ARCHITECTURE.md §8.3)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "@apollo/client",
|
||||||
|
message:
|
||||||
|
"禁止绕过 lib/api 直接 import @apollo/client,使用 useWidgetQuery/useWidgetMutation(ARCHITECTURE.md §11.7)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ["@/widgets/*", "../widgets/*", "../../widgets/*"],
|
||||||
|
message:
|
||||||
|
"禁止 widget 跨目录 import 其他 widget(插件隔离铁律,ARCHITECTURE.md §11.7)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// 白名单:令牌原始定义、PWA manifest
|
// 白名单:令牌原始定义、PWA manifest
|
||||||
{
|
{
|
||||||
files: ["**/primitive.css", "**/manifest.ts"],
|
files: ["**/primitive.css", "**/manifest.ts"],
|
||||||
|
|||||||
2
apps/portal-shell/next-env.d.ts
vendored
2
apps/portal-shell/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"crypto-hash": "^4.0.1",
|
"crypto-hash": "^4.0.1",
|
||||||
"graphql": "^16.8.0",
|
"graphql": "^16.8.0",
|
||||||
|
"jose": "^5.9.6",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"swr": "^2.2.0",
|
"swr": "^2.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
239
apps/portal-shell/src/app/api/auth/login/route.ts
Normal file
239
apps/portal-shell/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* 登录代理 Route Handler(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.3)
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* Browser POST /api/auth/login { email, password }
|
||||||
|
* → 本 Route Handler 调 api-gateway /api/v1/iam/login
|
||||||
|
* → 成功后把 accessToken 写入 httpOnly cookie `edu_session`
|
||||||
|
* → 把 permissions 位图写入非 httpOnly cookie `edu_perms`(按钮级 UX 用,非安全依据)
|
||||||
|
* → 返回 { user } 给前端(不返回 token,JS 永不接触 token)
|
||||||
|
*
|
||||||
|
* 安全(§4.3):
|
||||||
|
* - cookie 名 `edu_session`:HttpOnly + Secure(生产) + SameSite=Strict + Path=/
|
||||||
|
* - Max-Age 与 iam 返回的 expiresIn 对齐
|
||||||
|
* - 失败归一化错误:401 / 429 / 5xx 分别处理
|
||||||
|
*
|
||||||
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.3、§6.1、§11.7 红线 #2
|
||||||
|
*/
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
encodePermissionsBitmap,
|
||||||
|
PERMISSION_BITMAP_ORDER,
|
||||||
|
} from "@edu/shared-ts/permission-bitmap";
|
||||||
|
import type { Role } from "@edu/shared-ts/contracts";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "edu_session";
|
||||||
|
const PERMS_COOKIE = "edu_perms";
|
||||||
|
|
||||||
|
const GATEWAY_URL =
|
||||||
|
process.env.API_GATEWAY_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
|
||||||
|
"http://localhost:8080";
|
||||||
|
|
||||||
|
const IAM_LOGIN_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/login`;
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions: string[];
|
||||||
|
dataScope: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenPair {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IamLoginResponse {
|
||||||
|
success: true;
|
||||||
|
data: { user: UserInfo; tokens: TokenPair };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析角色字符串为 portal-shell 4 角色之一。
|
||||||
|
* iam 返回 roles[],取主角色。
|
||||||
|
*/
|
||||||
|
function pickPrimaryRole(roles: string[]): Role {
|
||||||
|
for (const r of roles) {
|
||||||
|
if (r === "admin" || r === "teacher" || r === "student" || r === "parent") {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "teacher";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤出 PERMISSION_BITMAP_ORDER 中存在的权限点(避免位图编码丢失)。
|
||||||
|
*/
|
||||||
|
function filterKnownPermissions(perms: string[]): string[] {
|
||||||
|
const known = new Set<string>(PERMISSION_BITMAP_ORDER);
|
||||||
|
return perms.filter((p) => known.has(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
// ── 1. 解析与校验请求体 ──
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "INVALID_BODY", message: "Request body must be JSON" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = loginSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "INVALID_INPUT",
|
||||||
|
message: "Email and password are required",
|
||||||
|
details: parsed.error.issues,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. 调用 iam 登录 ──
|
||||||
|
let iamResponse: Response;
|
||||||
|
try {
|
||||||
|
iamResponse = await fetch(IAM_LOGIN_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// 透传客户端 IP 与 UA 用于审计
|
||||||
|
...(req.headers.get("x-forwarded-for")
|
||||||
|
? { "X-Forwarded-For": req.headers.get("x-forwarded-for") as string }
|
||||||
|
: {}),
|
||||||
|
...(req.headers.get("user-agent")
|
||||||
|
? { "User-Agent": req.headers.get("user-agent") as string }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(parsed.data),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
console.error(
|
||||||
|
`[portal-shell] /api/auth/login: iam unreachable: ${message} (url=${IAM_LOGIN_ENDPOINT})`,
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "IAM_UNREACHABLE",
|
||||||
|
message: "Authentication service unavailable",
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. 处理 iam 响应 ──
|
||||||
|
if (iamResponse.status === 401) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "INVALID_CREDENTIALS", message: "邮箱或密码错误" },
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (iamResponse.status === 429) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试" },
|
||||||
|
{ status: 429 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!iamResponse.ok) {
|
||||||
|
// 其他错误(403 账户锁定 / 5xx)
|
||||||
|
let message = "登录失败";
|
||||||
|
try {
|
||||||
|
const errJson = (await iamResponse.json()) as { message?: string };
|
||||||
|
if (errJson.message) message = errJson.message;
|
||||||
|
} catch {
|
||||||
|
// 忽略 JSON 解析失败
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "IAM_ERROR", message },
|
||||||
|
{ status: iamResponse.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. 提取 token 与 user ──
|
||||||
|
let iamData: IamLoginResponse;
|
||||||
|
try {
|
||||||
|
iamData = (await iamResponse.json()) as IamLoginResponse;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "IAM_BAD_RESPONSE", message: "登录服务返回数据异常" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, tokens } = iamData.data;
|
||||||
|
if (!tokens?.accessToken || typeof tokens.expiresIn !== "number") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "IAM_BAD_RESPONSE", message: "登录响应缺少 token" },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. 计算 cookie 值 ──
|
||||||
|
const secure = process.env.NODE_ENV === "production";
|
||||||
|
const maxAge = Math.min(tokens.expiresIn, 60 * 60 * 8); // 最长 8 小时
|
||||||
|
const knownPerms = filterKnownPermissions(user.permissions ?? []);
|
||||||
|
const permsBitmap = encodePermissionsBitmap(knownPerms);
|
||||||
|
|
||||||
|
// ── 6. 构建响应(不返回 token 给前端) ──
|
||||||
|
const response = NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: pickPrimaryRole(user.roles),
|
||||||
|
permissions: knownPerms,
|
||||||
|
dataScope: user.dataScope,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置 Set-Cookie 头(多 cookie 用逗号分隔,NextResponse.cookies 更稳)
|
||||||
|
response.cookies.set(SESSION_COOKIE, tokens.accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge,
|
||||||
|
});
|
||||||
|
response.cookies.set(PERMS_COOKIE, permsBitmap, {
|
||||||
|
httpOnly: false,
|
||||||
|
secure,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auth/login → 简单状态端点(不暴露任何敏感信息)。
|
||||||
|
*/
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true, endpoint: "/api/auth/login", method: "POST" },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/portal-shell/src/app/api/auth/logout/route.ts
Normal file
108
apps/portal-shell/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 登出代理 Route Handler(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.2 / §4.3)
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* Browser POST /api/auth/logout(携带 edu_session cookie)
|
||||||
|
* → 本 Route Handler 从 cookie 取 access token
|
||||||
|
* → 清除 edu_session + edu_perms cookie(无论 iam 是否成功)
|
||||||
|
* → best-effort 调 iam /api/v1/iam/logout(带 Authorization)使 refresh token 失效
|
||||||
|
* → 返回 { success: true },前端跳转 /login
|
||||||
|
*
|
||||||
|
* 容错策略:
|
||||||
|
* - iam 不可达 / 返回错误 → 静默忽略,仍然清 cookie(用户体验优先:本地登出必成功)
|
||||||
|
* - iam 端的 edu_refresh httpOnly cookie 由 iam 自行清除(path=/api/v1/iam,本代理无法跨 path 清)
|
||||||
|
*
|
||||||
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.2、§4.3、§11.7 红线 #2
|
||||||
|
*/
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "edu_session";
|
||||||
|
const PERMS_COOKIE = "edu_perms";
|
||||||
|
|
||||||
|
const GATEWAY_URL =
|
||||||
|
process.env.API_GATEWAY_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_API_GATEWAY_URL ||
|
||||||
|
"http://localhost:8080";
|
||||||
|
|
||||||
|
const IAM_LOGOUT_ENDPOINT = `${GATEWAY_URL.replace(/\/$/, "")}/api/v1/iam/logout`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 cookie 头中的指定 cookie。
|
||||||
|
*/
|
||||||
|
function readCookie(cookieHeader: string | null, name: string): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const match = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.find((p) => p.startsWith(`${name}=`));
|
||||||
|
if (!match) return null;
|
||||||
|
return decodeURIComponent(match.slice(name.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
const cookieHeader = req.headers.get("cookie");
|
||||||
|
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||||
|
const secure = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
|
// ── 1. best-effort 调 iam logout(使服务端 refresh token 失效) ──
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await fetch(IAM_LOGOUT_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(req.headers.get("x-forwarded-for")
|
||||||
|
? {
|
||||||
|
"X-Forwarded-For": req.headers.get("x-forwarded-for") as string,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(req.headers.get("user-agent")
|
||||||
|
? { "User-Agent": req.headers.get("user-agent") as string }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// iam 不可达 → 静默,本地登出仍然完成
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
console.warn(
|
||||||
|
`[portal-shell] /api/auth/logout: iam unreachable: ${message} (url=${IAM_LOGOUT_ENDPOINT})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. 清除 edu_session + edu_perms cookie(无论 iam 是否成功) ──
|
||||||
|
const response = NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
response.cookies.set(SESSION_COOKIE, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
secure,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
response.cookies.set(PERMS_COOKIE, "", {
|
||||||
|
httpOnly: false,
|
||||||
|
secure,
|
||||||
|
sameSite: "strict",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/auth/logout → 简单状态端点(不暴露任何敏感信息)。
|
||||||
|
*/
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true, endpoint: "/api/auth/logout", method: "POST" },
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
123
apps/portal-shell/src/app/api/graphql/route.ts
Normal file
123
apps/portal-shell/src/app/api/graphql/route.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* GraphQL 同域代理(P0-3,ARCHITECTURE.md §3.4 V3-A2 / §4 / §5.2)
|
||||||
|
*
|
||||||
|
* 浏览器 Apollo Client 一律走同域 `/api/graphql`:
|
||||||
|
* Browser → /api/graphql (本 Route Handler) → apollo-router :3000
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从 httpOnly cookie `edu_session` 取 JWT,注入 `Authorization: Bearer`
|
||||||
|
* 2. 透传 body(APQ hash 或 query)与 Apollo 相关头
|
||||||
|
* 3. 响应 status / JSON 原样回传,不缓存
|
||||||
|
* 4. 错误归一化:网络错误 → `{ errors: [{ message: "UPSTREAM_UNAVAILABLE" }] }`
|
||||||
|
*
|
||||||
|
* 安全收益(§4.2):
|
||||||
|
* - JWT 全程不出 httpOnly cookie,消除 XSS 窃取凭证面
|
||||||
|
* - 修复"浏览器绕过 api-gateway"问题(代理在服务端调 router)
|
||||||
|
*
|
||||||
|
* 验收命令:
|
||||||
|
* DevTools Network 面板无 `localhost:3000` 直连;所有 GraphQL 请求走 `/api/graphql`
|
||||||
|
*/
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "edu_session";
|
||||||
|
|
||||||
|
const UPSTREAM_URL =
|
||||||
|
process.env.APOLLO_ROUTER_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
|
||||||
|
"http://localhost:3000/graphql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Cookie 头解析指定 cookie 值。
|
||||||
|
*/
|
||||||
|
function readCookie(cookieHeader: string | null, name: string): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const match = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.find((p) => p.startsWith(`${name}=`));
|
||||||
|
if (!match) return null;
|
||||||
|
return decodeURIComponent(match.slice(name.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
|
const cookieHeader = req.headers.get("cookie");
|
||||||
|
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||||
|
|
||||||
|
// 透传 body(APQ hash 请求或完整 query),不解析不修改
|
||||||
|
const body = await req.text();
|
||||||
|
|
||||||
|
const upstreamHeaders: Record<string, string> = {
|
||||||
|
"Content-Type": req.headers.get("content-type") ?? "application/json",
|
||||||
|
Accept: req.headers.get("accept") ?? "application/json",
|
||||||
|
// Apollo Persisted Query 协议头透传
|
||||||
|
"X-APQ": req.headers.get("x-apq") ?? "1",
|
||||||
|
// 服务端追踪:透传客户端 X-Request-Id(若有)
|
||||||
|
...(req.headers.get("x-request-id")
|
||||||
|
? { "X-Request-Id": req.headers.get("x-request-id") as string }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注入 Authorization(若 cookie 中有 JWT)
|
||||||
|
if (token) {
|
||||||
|
upstreamHeaders.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(UPSTREAM_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: upstreamHeaders,
|
||||||
|
body,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await upstream.text();
|
||||||
|
return new NextResponse(responseText, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type":
|
||||||
|
upstream.headers.get("content-type") ?? "application/json",
|
||||||
|
// 不缓存:GraphQL 响应可能因身份/变量而异
|
||||||
|
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message : "Unknown upstream error";
|
||||||
|
console.error(
|
||||||
|
`[portal-shell] /api/graphql upstream error: ${message} (url=${UPSTREAM_URL})`,
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: "UPSTREAM_UNAVAILABLE",
|
||||||
|
extensions: {
|
||||||
|
code: "UPSTREAM_UNAVAILABLE",
|
||||||
|
reason: message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/graphql → 简单健康标识,便于排查路由是否挂载。
|
||||||
|
* Apollo Router 自身的 health 在 :8088/health。
|
||||||
|
*/
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
proxy: "/api/graphql",
|
||||||
|
upstream: UPSTREAM_URL,
|
||||||
|
},
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
220
apps/portal-shell/src/app/login/login-form.tsx
Normal file
220
apps/portal-shell/src/app/login/login-form.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录表单(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §8.3)
|
||||||
|
*
|
||||||
|
* 客户端组件,shadcn 令牌登录表单。
|
||||||
|
* - 提交:POST /api/auth/login { email, password }
|
||||||
|
* - 成功:router.push(next || "/shell")
|
||||||
|
* - 失败:notify.error 显示归一化错误
|
||||||
|
* - DEV_MODE:显示提示横幅 + 一键填充 dev 凭证按钮
|
||||||
|
*
|
||||||
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§8.3、§11.7 红线 #2
|
||||||
|
*/
|
||||||
|
import { useState, useTransition, type FormEvent } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { GraduationCap, Loader2, LogIn } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { notify } from "@/shared/lib/notify";
|
||||||
|
|
||||||
|
const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true";
|
||||||
|
|
||||||
|
interface LoginSuccessResponse {
|
||||||
|
success: true;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
permissions: string[];
|
||||||
|
dataScope: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginErrorResponse {
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse = LoginSuccessResponse | LoginErrorResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把后端返回的错误码映射为中文文案。
|
||||||
|
*/
|
||||||
|
function mapLoginError(errorCode: string, fallback: string): string {
|
||||||
|
switch (errorCode) {
|
||||||
|
case "INVALID_BODY":
|
||||||
|
case "INVALID_INPUT":
|
||||||
|
return "请输入有效的邮箱和密码";
|
||||||
|
case "INVALID_CREDENTIALS":
|
||||||
|
return "邮箱或密码错误";
|
||||||
|
case "RATE_LIMITED":
|
||||||
|
return "登录尝试过于频繁,请稍后再试";
|
||||||
|
case "IAM_UNREACHABLE":
|
||||||
|
case "IAM_BAD_RESPONSE":
|
||||||
|
return "登录服务暂不可用,请稍后再试";
|
||||||
|
case "IAM_ERROR":
|
||||||
|
return fallback || "登录失败,请重试";
|
||||||
|
default:
|
||||||
|
return fallback || "登录失败,请重试";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm(): React.ReactElement {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isPending) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const next = searchParams.get("next") || "/shell";
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
notify.error("网络错误,请检查网络连接后重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: LoginResponse;
|
||||||
|
try {
|
||||||
|
data = (await response.json()) as LoginResponse;
|
||||||
|
} catch {
|
||||||
|
notify.error("登录服务返回数据异常");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !("success" in data)) {
|
||||||
|
const errorResp = data as LoginErrorResponse;
|
||||||
|
notify.error(mapLoginError(errorResp.error, errorResp.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notify.success(`欢迎回来,${data.user.name || data.user.email}`);
|
||||||
|
// 用 router.push 而非 location.href,保留 SPA 体验;cookie 已由 Set-Cookie 写入
|
||||||
|
router.push(next);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillDevCredentials(): void {
|
||||||
|
setEmail("dev@edu.local");
|
||||||
|
setPassword("dev-password");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-screen items-center justify-center px-4 py-12">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-6 flex flex-col items-center gap-2">
|
||||||
|
<div className="bg-primary text-primary-foreground flex size-12 items-center justify-center rounded-xl">
|
||||||
|
<GraduationCap className="size-6" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Edu Portal</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">K12 智慧教务平台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">登录</CardTitle>
|
||||||
|
<CardDescription>使用邮箱与密码登录你的账号</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">
|
||||||
|
邮箱
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@school.edu.cn"
|
||||||
|
aria-label="邮箱"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
aria-label="密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-3">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isPending || !email || !password}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="size-4 animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
登录中…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="size-4" aria-hidden="true" />
|
||||||
|
登录
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{DEV_MODE ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full text-xs"
|
||||||
|
onClick={fillDevCredentials}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
开发模式:填充 dev 凭证
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 text-center text-xs">
|
||||||
|
登录即表示同意 Edu Portal 使用条款与隐私政策
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/portal-shell/src/app/login/page.tsx
Normal file
31
apps/portal-shell/src/app/login/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 登录页(P0-1,ARCHITECTURE.md §3.4 V3-A2 / §4.1 / §4.2 / §7.1)
|
||||||
|
*
|
||||||
|
* 路由:/login
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - RSC 入口:检测已登录(cookie edu_session 存在)→ redirect /shell
|
||||||
|
* - 渲染 <LoginForm /> 客户端组件
|
||||||
|
*
|
||||||
|
* 已登录检测策略:
|
||||||
|
* - middleware 将 /login 列入 PUBLIC_ROUTES,不强制身份校验
|
||||||
|
* - 本页面 RSC 通过 cookies() 读 edu_session cookie(仅看存在性,不验签——验签由 middleware 负责)
|
||||||
|
* - 存在 cookie 视为已登录,直接 redirect /shell,避免登录页"闪现"
|
||||||
|
*
|
||||||
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§4.1、§4.2、§11.7 红线 #2
|
||||||
|
*/
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { LoginForm } from "./login-form";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function LoginPage(): Promise<React.ReactElement> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get("edu_session");
|
||||||
|
if (session?.value) {
|
||||||
|
redirect("/shell");
|
||||||
|
}
|
||||||
|
return <LoginForm />;
|
||||||
|
}
|
||||||
@@ -5,30 +5,36 @@ import type { Role } from "@/lib/types";
|
|||||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell 入口(RSC Server Component,v2.1 M8 验收点 + 流式渲染)
|
* Shell 入口(RSC Server Component,v3.0 P0-2 fail-closed + 流式渲染)
|
||||||
*
|
*
|
||||||
* 数据流(portal-shell spec §5.5、README v2.0 §5.3 流式渲染):
|
* 数据流(ARCHITECTURE.md §3.4 V3-A2/A3、§5.5):
|
||||||
* ① 从请求头获取 userId / role(api-gateway 注入 x-user-id / x-user-role)
|
* ① 从 middleware 注入的请求头获取 userId / role(middleware 已校验 cookie + 权限)
|
||||||
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig(三层合并)
|
||||||
* ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染:
|
* ③ Config Promise 直接传给 ClientShell,由客户端 use() 消费,启用流式渲染:
|
||||||
* - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI
|
* - HTML 流式输出:loading.tsx 先行,Promise resolve 后替换为真实 UI
|
||||||
* - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求)
|
* - 客户端 Suspense:避免客户端瀑布流(不用 useEffect 二次请求)
|
||||||
*
|
*
|
||||||
* 流式渲染分层(README v2.0 §5.3):
|
* fail-closed(P0-2,§11.7 红线 #5):
|
||||||
* - L1 路由级(loading.tsx):整页骨架,fetchPluginConfig 进行中
|
* - middleware 已保证到达此处的请求必带 x-user-id / x-user-role 头
|
||||||
* - L2 区块级(DashboardSection):单一区块骨架,Suspense 包裹
|
* - 头缺失 = middleware 未运行(异常路径)→ 抛错触发 error.tsx,禁止默认 teacher
|
||||||
* - L3 插件级(PluginBoundary):单插件骨架,dynamic import + Suspense
|
|
||||||
*
|
*
|
||||||
* M8 验收:portal-shell 查询走 apollo-router(fetchPluginConfig 经 Apollo Client)。
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2/A3、§5.5、§11.7 红线 #5
|
||||||
*
|
|
||||||
* 关联:portal-shell spec §5.5、§6.2、M8 验收标准、README v2.0 §5.3
|
|
||||||
*/
|
*/
|
||||||
export default async function ShellPage(): Promise<React.ReactElement> {
|
export default async function ShellPage(): Promise<React.ReactElement> {
|
||||||
const headerList = await headers();
|
const headerList = await headers();
|
||||||
const userId =
|
const userId = headerList.get("x-user-id");
|
||||||
headerList.get("x-user-id") ||
|
const roleHeader = headerList.get("x-user-role");
|
||||||
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
|
|
||||||
const role = (headerList.get("x-user-role") || "teacher") as Role;
|
// fail-closed:middleware 必须注入身份头,缺失即异常(不再默认 teacher)
|
||||||
|
if (!userId || !roleHeader) {
|
||||||
|
throw new Error(
|
||||||
|
"[portal-shell] ShellPage missing identity headers " +
|
||||||
|
"(middleware must inject x-user-id / x-user-role). " +
|
||||||
|
"If middleware is configured, this indicates a routing misconfiguration.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = roleHeader as Role;
|
||||||
|
|
||||||
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
// 服务端通过 apollo-router 获取三层合并后的插件配置
|
||||||
// 不 await:直接将 Promise 传给 ClientShell,启用流式渲染
|
// 不 await:直接将 Promise 传给 ClientShell,启用流式渲染
|
||||||
|
|||||||
62
apps/portal-shell/src/app/shell/forbidden/page.tsx
Normal file
62
apps/portal-shell/src/app/shell/forbidden/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ShieldX } from "lucide-react";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 403 Forbidden 页(P0-2,ARCHITECTURE.md §3.4 V3-A3 / §4.2 / §6.2)
|
||||||
|
*
|
||||||
|
* middleware 的 checkRoutePermission 拒绝时 302 重定向到此页。
|
||||||
|
* 页面渲染:
|
||||||
|
* - 友好提示 + 图标 + 返回仪表盘链接
|
||||||
|
* - 不暴露内部权限配置细节(仅显示通用 403 文案)
|
||||||
|
* - 支持查询参数 reason=no_config | missing_role | missing_permission(仅 UX 提示)
|
||||||
|
*
|
||||||
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A3、§6.2、§11.7 红线 #5
|
||||||
|
*/
|
||||||
|
export default async function ForbiddenPage(): Promise<React.ReactElement> {
|
||||||
|
const headerList = await headers();
|
||||||
|
const userId = headerList.get("x-user-id") ?? "unknown";
|
||||||
|
const role = headerList.get("x-user-role") ?? "unknown";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex min-h-screen flex-col items-center justify-center gap-6 bg-background p-6 text-foreground"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||||
|
<ShieldX className="h-8 w-8" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold leading-tight">
|
||||||
|
403 · 无权访问此页面
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
|
你的账号没有访问该页面的权限。如果认为这是错误,请联系管理员调整角色或权限。
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
用户:<span className="font-mono">{userId}</span>
|
||||||
|
{role !== "unknown" && (
|
||||||
|
<>
|
||||||
|
{" · "}角色:<span className="font-mono">{role}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex gap-3" aria-label="操作">
|
||||||
|
<Link
|
||||||
|
href="/shell"
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
返回仪表盘
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
切换账号登录
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/portal-shell/src/instrumentation.ts
Normal file
38
apps/portal-shell/src/instrumentation.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Next.js Instrumentation Hook(启动期守卫)
|
||||||
|
*
|
||||||
|
* 关联:ARCHITECTURE.md §3.4 V3-A2 / §6.1 / §10 P0-5
|
||||||
|
*
|
||||||
|
* 在生产环境(NODE_ENV=production)下,DEV_MODE 必须为 false;
|
||||||
|
* 若 NEXT_PUBLIC_DEV_MODE=true(或非 "false")则启动即抛错退出,
|
||||||
|
* 防止"生产永久绕过认证"(见 §1.3-F4 / §6.1)。
|
||||||
|
*
|
||||||
|
* 验收命令:
|
||||||
|
* NODE_ENV=production NEXT_PUBLIC_DEV_MODE=true pnpm start
|
||||||
|
* → 进程退出,stderr 输出包含 "NEXT_PUBLIC_DEV_MODE must be false in production"
|
||||||
|
*
|
||||||
|
* 开发态(NODE_ENV !== "production")放行,由 middleware 提供 dev-token 合成身份。
|
||||||
|
*/
|
||||||
|
export async function register(): Promise<void> {
|
||||||
|
const nodeEnv = process.env.NODE_ENV ?? "";
|
||||||
|
const devModeRaw = process.env.NEXT_PUBLIC_DEV_MODE ?? "";
|
||||||
|
|
||||||
|
if (nodeEnv !== "production") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅生产环境校验;DEV_MODE 必须显式为 "false"(或等价 falsy)
|
||||||
|
const devModeEnabled =
|
||||||
|
devModeRaw !== "false" && devModeRaw !== "0" && devModeRaw !== "";
|
||||||
|
|
||||||
|
if (devModeEnabled) {
|
||||||
|
const msg =
|
||||||
|
"[portal-shell] FATAL: NEXT_PUBLIC_DEV_MODE must be false in production " +
|
||||||
|
`(got NEXT_PUBLIC_DEV_MODE=${JSON.stringify(devModeRaw)}). ` +
|
||||||
|
"Aborting startup to prevent authentication bypass " +
|
||||||
|
"(ARCHITECTURE.md §10 P0-5).";
|
||||||
|
console.error(msg);
|
||||||
|
// 非零退出码,触发进程管理器/容器重启策略
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Apollo Client(v2.1 M8 验收点)
|
* Apollo Client(v3.0 P0-3,ARCHITECTURE.md §3.4 V3-A2 / §5.1 / §5.2)
|
||||||
*
|
|
||||||
* 所有 portal-shell 查询走 apollo-router(GraphQL 联邦入口):
|
|
||||||
* portal-shell → apollo-router :3000/graphql → 各子图(iam/core-edu/content/msg/data-ana/ai/config-service)
|
|
||||||
*
|
*
|
||||||
* 双端使用:
|
* 双端使用:
|
||||||
* - 服务端(RSC):createApolloClient() 每次请求新建实例,ssrMode=true
|
* - 客户端:HttpLink 指向同域 `/api/graphql`,JWT 由 httpOnly cookie 经代理注入
|
||||||
* - 客户端:getApolloClient() 单例,复用 InMemoryCache
|
* - 服务端(RSC):直连 `APOLLO_ROUTER_URL`(内网,由 middleware 注入身份头)
|
||||||
*
|
*
|
||||||
* APQ(Automatic Persisted Queries,v2.1 M3 安全加固):
|
* APQ(Automatic Persisted Queries,v2.1 M3 安全加固):
|
||||||
* - 生产环境前端只发 query hash(sha256),不发明文 query
|
* - 生产环境前端只发 query hash(sha256),不发明文 query
|
||||||
@@ -14,17 +11,29 @@
|
|||||||
* - 防止攻击者通过 DevTools 构造任意查询探测 schema
|
* - 防止攻击者通过 DevTools 构造任意查询探测 schema
|
||||||
* - 开发模式可设 NEXT_PUBLIC_APOLLO_APQ=false 关闭 APQ 便于调试
|
* - 开发模式可设 NEXT_PUBLIC_APOLLO_APQ=false 关闭 APQ 便于调试
|
||||||
*
|
*
|
||||||
* 关联:portal-shell spec §4.1 APQ、§5.5 RSC 预取、§5.6 统一 Hook、M8 验收标准
|
* 安全(V3-A2):
|
||||||
|
* - 客户端不再注入 Authorization 头(token 全程不出 httpOnly cookie)
|
||||||
|
* - 客户端不再读 localStorage.edu_token(方案已废止)
|
||||||
|
* - 客户端 credentials:"include" 让 cookie 流向同域 /api/graphql
|
||||||
|
*
|
||||||
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§5.1、§5.2
|
||||||
*/
|
*/
|
||||||
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
|
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
|
||||||
import { setContext } from "@apollo/client/link/context";
|
|
||||||
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
|
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries";
|
||||||
import { sha256 } from "crypto-hash";
|
import { sha256 } from "crypto-hash";
|
||||||
|
|
||||||
const APOLLO_ROUTER_URL =
|
/**
|
||||||
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
|
* 服务端 RSC 直连 apollo-router URL(仅服务端可用,浏览器走同域代理)。
|
||||||
process.env.APOLLO_ROUTER_URL ||
|
* 不挂 NEXT_PUBLIC_ 前缀 → 不打包进客户端 bundle。
|
||||||
"http://localhost:3000/graphql";
|
*/
|
||||||
|
const SERVER_APOLLO_ROUTER_URL =
|
||||||
|
process.env.APOLLO_ROUTER_URL || "http://localhost:3000/graphql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端同域代理路径(V3-A2):浏览器只发同域请求,
|
||||||
|
* 由 /api/graphql Route Handler 取 httpOnly cookie 中的 JWT 并转发。
|
||||||
|
*/
|
||||||
|
const CLIENT_PROXY_URL = "/api/graphql";
|
||||||
|
|
||||||
// 开发模式可关闭 APQ 便于调试(NEXT_PUBLIC_APOLLO_APQ=false)
|
// 开发模式可关闭 APQ 便于调试(NEXT_PUBLIC_APOLLO_APQ=false)
|
||||||
// 生产环境默认启用(未设置或设置为 true 均启用)
|
// 生产环境默认启用(未设置或设置为 true 均启用)
|
||||||
@@ -33,42 +42,39 @@ const APQ_ENABLED = process.env.NEXT_PUBLIC_APOLLO_APQ !== "false";
|
|||||||
/**
|
/**
|
||||||
* 创建 Apollo Client 实例。
|
* 创建 Apollo Client 实例。
|
||||||
*
|
*
|
||||||
* @param getAuthToken 可选,返回 JWT 用于注入 Authorization 头(客户端从 cookie/localStorage 读取)
|
* @param options 可选:
|
||||||
|
* - serverSide: true 表示服务端 RSC 模式(直连 router),false/省略表示客户端(走 /api/graphql)
|
||||||
|
*
|
||||||
|
* 客户端不再接受 getAuthToken 参数:JWT 已迁至 httpOnly cookie,
|
||||||
|
* JS 永远拿不到 token(ARCHITECTURE.md §3.4 V3-A2 / §11.7 红线 #2)。
|
||||||
*/
|
*/
|
||||||
export function createApolloClient(
|
export function createApolloClient(
|
||||||
getAuthToken?: () => string | null,
|
options: { serverSide?: boolean } = {},
|
||||||
): ApolloClient<unknown> {
|
): ApolloClient<unknown> {
|
||||||
|
const isServer = options.serverSide ?? typeof window === "undefined";
|
||||||
|
|
||||||
const httpLink = new HttpLink({
|
const httpLink = new HttpLink({
|
||||||
uri: APOLLO_ROUTER_URL,
|
uri: isServer ? SERVER_APOLLO_ROUTER_URL : CLIENT_PROXY_URL,
|
||||||
credentials: "include",
|
// 客户端:同域请求,cookie 自动随行;服务端:直连 router 不需要 cookie
|
||||||
|
credentials: isServer ? "omit" : "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
const authLink = setContext((_, { headers }) => {
|
// Link 链顺序:pqLink → httpLink
|
||||||
const token = getAuthToken?.() ?? null;
|
|
||||||
return {
|
|
||||||
headers: {
|
|
||||||
...headers,
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Link 链顺序:authLink → pqLink → httpLink
|
|
||||||
// - authLink 注入 Authorization 头
|
|
||||||
// - pqLink 将 query 替换为 hash(启用时)
|
// - pqLink 将 query 替换为 hash(启用时)
|
||||||
// - httpLink 发送请求
|
// - httpLink 发送请求(含 cookie)
|
||||||
|
// 客户端不再需要 authLink(token 注入由 /api/graphql 代理负责)
|
||||||
const link = APQ_ENABLED
|
const link = APQ_ENABLED
|
||||||
? from([authLink, createPersistedQueryLink({ sha256 }), httpLink])
|
? from([createPersistedQueryLink({ sha256 }), httpLink])
|
||||||
: from([authLink, httpLink]);
|
: from([httpLink]);
|
||||||
|
|
||||||
return new ApolloClient({
|
return new ApolloClient({
|
||||||
link,
|
link,
|
||||||
cache: new InMemoryCache(),
|
cache: new InMemoryCache(),
|
||||||
ssrMode: typeof window === "undefined",
|
ssrMode: isServer,
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
query: {
|
query: {
|
||||||
errorPolicy: "all",
|
errorPolicy: "all",
|
||||||
fetchPolicy: typeof window === "undefined" ? "no-cache" : "cache-first",
|
fetchPolicy: isServer ? "no-cache" : "cache-first",
|
||||||
},
|
},
|
||||||
watchQuery: {
|
watchQuery: {
|
||||||
errorPolicy: "all",
|
errorPolicy: "all",
|
||||||
@@ -81,15 +87,15 @@ let clientSingleton: ApolloClient<unknown> | null = null;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。
|
* 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。
|
||||||
|
*
|
||||||
|
* 注意:不再接受 getAuthToken 参数(V3-A2 移除 localStorage 方案)。
|
||||||
*/
|
*/
|
||||||
export function getApolloClient(
|
export function getApolloClient(): ApolloClient<unknown> {
|
||||||
getAuthToken?: () => string | null,
|
|
||||||
): ApolloClient<unknown> {
|
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return createApolloClient(getAuthToken);
|
return createApolloClient({ serverSide: true });
|
||||||
}
|
}
|
||||||
if (!clientSingleton) {
|
if (!clientSingleton) {
|
||||||
clientSingleton = createApolloClient(getAuthToken);
|
clientSingleton = createApolloClient({ serverSide: false });
|
||||||
}
|
}
|
||||||
return clientSingleton;
|
return clientSingleton;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,14 @@
|
|||||||
*/
|
*/
|
||||||
import { gql } from "@apollo/client";
|
import { gql } from "@apollo/client";
|
||||||
import { createApolloClient } from "./apollo-client";
|
import { createApolloClient } from "./apollo-client";
|
||||||
import type { PluginConfigResponse, Role } from "./types";
|
import type {
|
||||||
|
LayoutTemplateInfo,
|
||||||
|
PluginConfigResponse,
|
||||||
|
PluginPlacement,
|
||||||
|
PluginRegistryItem,
|
||||||
|
Role,
|
||||||
|
SlotConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
/** 查询用户合并后的插件配置(走 apollo-router → config-service 子图) */
|
/** 查询用户合并后的插件配置(走 apollo-router → config-service 子图) */
|
||||||
export const GET_PLUGIN_CONFIG = gql`
|
export const GET_PLUGIN_CONFIG = gql`
|
||||||
@@ -124,9 +131,11 @@ export async function fetchPluginConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 最终兜底:空默认配置
|
// 3. 最终兜底:内置默认配置(按角色静态定义,P0-4)
|
||||||
console.warn(`[portal-shell] fetchPluginConfig returning empty default`);
|
console.warn(
|
||||||
return getDefaultConfig();
|
`[portal-shell] fetchPluginConfig falling back to built-in default config (role=${role})`,
|
||||||
|
);
|
||||||
|
return getDefaultConfig(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,25 +197,196 @@ async function fetchPluginConfigDirect(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认 classic 布局配置(Router 未就绪 / 查询失败时降级)。
|
* 默认 classic 布局配置(Router 未就绪 / 查询失败时降级)。
|
||||||
* 保证 Shell 始终可渲染,不因下游不可用而白屏。
|
*
|
||||||
|
* P0-4(ARCHITECTURE.md §10):按角色静态定义内置默认仪表盘插件集,
|
||||||
|
* 保证 config-service 不可用时仪表盘仍有内容(fail-safe 而非空壳)。
|
||||||
|
*
|
||||||
|
* 角色映射依据各 widget 的 manifest `requiredRoles` 字段(src/widgets 下各目录):
|
||||||
|
* - topbar 4 件:全角色(notification-bell/global-search/locale-switcher/user-menu)
|
||||||
|
* - sidebar:teacher → class-selector + term-switcher + quick-actions;
|
||||||
|
* student → term-switcher + quick-actions;
|
||||||
|
* parent → child-selector + term-switcher + quick-actions;
|
||||||
|
* admin → 无 sidebar 上下文
|
||||||
|
* - main:universal 7 件按角色过滤 + 角色专属 widget
|
||||||
|
*
|
||||||
|
* @param role 用户角色;未提供时按 teacher 兜底(与现有 ShellPage 默认行为一致)
|
||||||
*/
|
*/
|
||||||
export function getDefaultConfig(): PluginConfigResponse {
|
export function getDefaultConfig(role?: Role): PluginConfigResponse {
|
||||||
|
const effectiveRole: Role = role ?? "teacher";
|
||||||
|
|
||||||
|
const layout: LayoutTemplateInfo = {
|
||||||
|
layoutId: "classic",
|
||||||
|
displayName: "经典三栏",
|
||||||
|
description: "TopBar + SideNav + Main",
|
||||||
|
availableSlots: ["top", "side", "main"],
|
||||||
|
layoutSchemaJson: JSON.stringify({
|
||||||
|
grid: { rows: 1, cols: 1, areas: [["main"]] },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const slots: SlotConfig[] = [
|
||||||
|
{ slotName: "top", navItems: [] },
|
||||||
|
{ slotName: "side", navItems: [] },
|
||||||
|
{ slotName: "main", navItems: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── top slot(全角色共享:通知铃 / 全局搜索 / 语言切换 / 用户菜单) ──
|
||||||
|
const topPlugins: PluginPlacement[] = [
|
||||||
|
placement("notification-bell", "top", 0, { colSpan: 1, rowSpan: 1 }),
|
||||||
|
placement("global-search", "top", 1, { colSpan: 1, rowSpan: 1 }),
|
||||||
|
placement("locale-switcher", "top", 2, { colSpan: 1, rowSpan: 1 }),
|
||||||
|
placement("user-menu", "top", 3, { colSpan: 1, rowSpan: 1 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── side slot(按角色裁剪) ──
|
||||||
|
const sidePlugins: PluginPlacement[] = SIDE_DEFAULTS[effectiveRole].map(
|
||||||
|
(id, idx) => placement(id, "side", idx, { colSpan: 1, rowSpan: 1 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── main slot(universal 按角色 + 角色专属) ──
|
||||||
|
const mainPlugins: PluginPlacement[] = MAIN_DEFAULTS[effectiveRole].map(
|
||||||
|
(id, idx) => placement(id, "main", idx, { colSpan: 2, rowSpan: 1 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── registry(按角色聚合所有可见插件的元信息) ──
|
||||||
|
const registry: PluginRegistryItem[] = [
|
||||||
|
...topPlugins,
|
||||||
|
...sidePlugins,
|
||||||
|
...mainPlugins,
|
||||||
|
].map((p) => registryItem(p.pluginId, effectiveRole));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeLayout: {
|
activeLayout: layout,
|
||||||
layoutId: "classic",
|
slots,
|
||||||
displayName: "经典三栏",
|
plugins: [...topPlugins, ...sidePlugins, ...mainPlugins],
|
||||||
description: "TopBar + SideNav + Main",
|
registry,
|
||||||
availableSlots: ["top", "side", "main"],
|
|
||||||
layoutSchemaJson: JSON.stringify({
|
|
||||||
grid: { rows: 1, cols: 1, areas: [["main"]] },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
slots: [
|
|
||||||
{ slotName: "top", navItems: [] },
|
|
||||||
{ slotName: "side", navItems: [] },
|
|
||||||
{ slotName: "main", navItems: [] },
|
|
||||||
],
|
|
||||||
plugins: [],
|
|
||||||
registry: [],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** side slot 角色默认(按渲染顺序) */
|
||||||
|
const SIDE_DEFAULTS: Record<Role, string[]> = {
|
||||||
|
teacher: ["class-selector", "term-switcher", "quick-actions"],
|
||||||
|
student: ["term-switcher", "quick-actions"],
|
||||||
|
parent: ["child-selector", "term-switcher", "quick-actions"],
|
||||||
|
admin: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** main slot 角色默认(universal + 角色专属,按渲染顺序) */
|
||||||
|
const MAIN_DEFAULTS: Record<Role, string[]> = {
|
||||||
|
teacher: [
|
||||||
|
"schedule-widget",
|
||||||
|
"grades-widget",
|
||||||
|
"homework-widget",
|
||||||
|
"exams-widget",
|
||||||
|
"attendance-widget",
|
||||||
|
"announcements-widget",
|
||||||
|
"notifications-widget",
|
||||||
|
"lesson-plan-editor",
|
||||||
|
"question-bank",
|
||||||
|
"textbook-manager",
|
||||||
|
"scheduling-rules",
|
||||||
|
],
|
||||||
|
student: [
|
||||||
|
"schedule-widget",
|
||||||
|
"grades-widget",
|
||||||
|
"homework-widget",
|
||||||
|
"exams-widget",
|
||||||
|
"attendance-widget",
|
||||||
|
"announcements-widget",
|
||||||
|
"notifications-widget",
|
||||||
|
"error-book",
|
||||||
|
"learning-path",
|
||||||
|
"elective-selector",
|
||||||
|
"ai-tutor",
|
||||||
|
],
|
||||||
|
parent: [
|
||||||
|
"schedule-widget",
|
||||||
|
"grades-widget",
|
||||||
|
"homework-widget",
|
||||||
|
"exams-widget",
|
||||||
|
"attendance-widget",
|
||||||
|
"announcements-widget",
|
||||||
|
"notifications-widget",
|
||||||
|
"child-overview",
|
||||||
|
"leave-approval",
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
"announcements-widget",
|
||||||
|
"notifications-widget",
|
||||||
|
"user-management",
|
||||||
|
"rbac-manager",
|
||||||
|
"plugin-manager",
|
||||||
|
"school-settings",
|
||||||
|
"audit-logs",
|
||||||
|
"invitation-codes",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 简易 PluginPlacement 构造器 */
|
||||||
|
function placement(
|
||||||
|
pluginId: string,
|
||||||
|
slot: string,
|
||||||
|
sortOrder: number,
|
||||||
|
size: { colSpan: number; rowSpan: number },
|
||||||
|
): PluginPlacement {
|
||||||
|
return {
|
||||||
|
pluginId,
|
||||||
|
slot,
|
||||||
|
sortOrder,
|
||||||
|
sizeJson: JSON.stringify(size),
|
||||||
|
propsJson: "{}",
|
||||||
|
isVisible: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 简易 PluginRegistryItem 构造器(基于内置 manifest 元数据) */
|
||||||
|
function registryItem(pluginId: string, role: Role): PluginRegistryItem {
|
||||||
|
// category 由 pluginId 前缀目录决定,与 src/widgets/<category>/ 对齐
|
||||||
|
const category = inferCategory(pluginId);
|
||||||
|
return {
|
||||||
|
pluginId,
|
||||||
|
category,
|
||||||
|
version: "0.1.0",
|
||||||
|
displayName: pluginId,
|
||||||
|
description: `Built-in ${category} plugin (default config fallback)`,
|
||||||
|
requiredRoles: [role],
|
||||||
|
isBuiltin: true,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由 pluginId 推断 category(与目录结构 src/widgets/<category>/ 对齐) */
|
||||||
|
function inferCategory(pluginId: string): string {
|
||||||
|
if (pluginId.endsWith("-widget")) return "universal";
|
||||||
|
if (
|
||||||
|
pluginId === "notification-bell" ||
|
||||||
|
pluginId === "global-search" ||
|
||||||
|
pluginId === "locale-switcher" ||
|
||||||
|
pluginId === "user-menu"
|
||||||
|
)
|
||||||
|
return "topbar";
|
||||||
|
if (
|
||||||
|
pluginId === "class-selector" ||
|
||||||
|
pluginId === "child-selector" ||
|
||||||
|
pluginId === "term-switcher" ||
|
||||||
|
pluginId === "quick-actions"
|
||||||
|
)
|
||||||
|
return "sidebar";
|
||||||
|
if (
|
||||||
|
pluginId === "lesson-plan-editor" ||
|
||||||
|
pluginId === "question-bank" ||
|
||||||
|
pluginId === "textbook-manager" ||
|
||||||
|
pluginId === "scheduling-rules"
|
||||||
|
)
|
||||||
|
return "teacher";
|
||||||
|
if (
|
||||||
|
pluginId === "error-book" ||
|
||||||
|
pluginId === "learning-path" ||
|
||||||
|
pluginId === "elective-selector" ||
|
||||||
|
pluginId === "ai-tutor"
|
||||||
|
)
|
||||||
|
return "student";
|
||||||
|
if (pluginId === "child-overview" || pluginId === "leave-approval")
|
||||||
|
return "parent";
|
||||||
|
return "admin";
|
||||||
|
}
|
||||||
|
|||||||
275
apps/portal-shell/src/middleware.ts
Normal file
275
apps/portal-shell/src/middleware.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* portal-shell 中间件:认证 + 路由门禁(P0-2,ARCHITECTURE.md §3.4 V3-A2/A3 / §4 / §6)
|
||||||
|
*
|
||||||
|
* 职责链(每个 /shell/** 请求):
|
||||||
|
* 1. 公共路径白名单 → 直接放行
|
||||||
|
* 2. 读取 httpOnly cookie `edu_session`(JWT)
|
||||||
|
* - 无 cookie / 无效 → 302 /login?next=<pathname>
|
||||||
|
* 3. 验证 JWT(dev 模式 decode-only;生产模式 jose JWKS RS256)
|
||||||
|
* 4. 注入请求头 x-user-id / x-user-role / x-user-permissions(供 RSC 读取)
|
||||||
|
* 5. 调 checkRoutePermission → 拒绝 → 302 /shell/forbidden
|
||||||
|
*
|
||||||
|
* DEV_MODE 合成身份(§3.4 V3-A2):
|
||||||
|
* - 仅当 NODE_ENV !== "production" && NEXT_PUBLIC_DEV_MODE === "true" 时启用
|
||||||
|
* - 无 cookie 时合成 dev-user / teacher / 全权限位图
|
||||||
|
* - 生产环境若 DEV_MODE=true 由 instrumentation.ts 拒绝启动
|
||||||
|
*
|
||||||
|
* JWKS(§4.3):
|
||||||
|
* - 端点:`IAM_JWKS_URI` 或默认 `http://api-gateway:8080/v1/iam/.well-known/jwks.json`
|
||||||
|
* - 缓存 5min(jose 内置);不可达 → fail-closed 跳登录
|
||||||
|
*
|
||||||
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2/V3-A3、§4、§6.2、§11.7
|
||||||
|
*/
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
||||||
|
import {
|
||||||
|
checkRoutePermission,
|
||||||
|
PUBLIC_ROUTES,
|
||||||
|
} from "@/shared/lib/route-permissions";
|
||||||
|
import {
|
||||||
|
encodePermissionsBitmap,
|
||||||
|
PERMISSION_BITMAP_ORDER,
|
||||||
|
} from "@edu/shared-ts/permission-bitmap";
|
||||||
|
import type { Role } from "@edu/shared-ts/contracts";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// 拦截 /shell/** 与 /api/** 与 /(首页);不拦截 Next.js 静态资源
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|robots.txt|.*\\.png$|.*\\.svg$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "edu_session";
|
||||||
|
const PERMS_COOKIE = "edu_perms";
|
||||||
|
|
||||||
|
const DEV_MODE =
|
||||||
|
process.env.NODE_ENV !== "production" &&
|
||||||
|
process.env.NEXT_PUBLIC_DEV_MODE === "true";
|
||||||
|
|
||||||
|
const IAM_JWKS_URI =
|
||||||
|
process.env.IAM_JWKS_URI ||
|
||||||
|
process.env.NEXT_PUBLIC_IAM_JWKS_URI ||
|
||||||
|
"http://api-gateway:8080/v1/iam/.well-known/jwks.json";
|
||||||
|
|
||||||
|
const JWT_ISSUER = process.env.JWT_ISSUER || "edu.iam";
|
||||||
|
const JWT_AUDIENCE = process.env.JWT_AUDIENCE || "edu-portal";
|
||||||
|
|
||||||
|
// jose JWKS 远程集合(自动缓存 5min)
|
||||||
|
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||||
|
function getJwks(): ReturnType<typeof createRemoteJWKSet> {
|
||||||
|
if (!jwks) {
|
||||||
|
jwks = createRemoteJWKSet(new URL(IAM_JWKS_URI));
|
||||||
|
}
|
||||||
|
return jwks;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
role: Role;
|
||||||
|
perms?: string[];
|
||||||
|
// iam 自定义 claims
|
||||||
|
"https://edu.cn/role"?: Role;
|
||||||
|
"https://edu.cn/permissions"?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 cookie 头中的指定 cookie。
|
||||||
|
*/
|
||||||
|
function readCookie(cookieHeader: string | null, name: string): string | null {
|
||||||
|
if (!cookieHeader) return null;
|
||||||
|
const match = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.find((p) => p.startsWith(`${name}=`));
|
||||||
|
if (!match) return null;
|
||||||
|
return decodeURIComponent(match.slice(name.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 JWT 并返回身份信息。
|
||||||
|
* - dev 模式:仅 decode(不验签)
|
||||||
|
* - 生产模式:jose JWKS RS256 验签 + iss/aud 校验
|
||||||
|
*/
|
||||||
|
async function verifySession(
|
||||||
|
token: string,
|
||||||
|
): Promise<{ userId: string; role: Role; perms: string[] } | null> {
|
||||||
|
// dev 模式:仅 decode(用于本地开发,无密钥环境)
|
||||||
|
if (DEV_MODE) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(token.split(".")[1] ?? "", "base64").toString("utf-8"),
|
||||||
|
) as JwtPayload;
|
||||||
|
return {
|
||||||
|
userId: payload.sub ?? "dev-user",
|
||||||
|
role: payload.role ?? payload["https://edu.cn/role"] ?? "teacher",
|
||||||
|
perms:
|
||||||
|
payload.perms ??
|
||||||
|
payload["https://edu.cn/permissions"] ??
|
||||||
|
// dev 默认放全权限(仅本地)
|
||||||
|
PERMISSION_BITMAP_ORDER.filter((p) => !p.startsWith("_RESERVED_")),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产模式:jose 验签
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, getJwks(), {
|
||||||
|
issuer: JWT_ISSUER,
|
||||||
|
audience: JWT_AUDIENCE,
|
||||||
|
algorithms: ["RS256"],
|
||||||
|
});
|
||||||
|
const p = payload as unknown as JwtPayload;
|
||||||
|
return {
|
||||||
|
userId: p.sub ?? "",
|
||||||
|
role: p.role ?? p["https://edu.cn/role"] ?? "teacher",
|
||||||
|
perms: p.perms ?? p["https://edu.cn/permissions"] ?? [],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof joseErrors.JWKSNoMatchingKey) {
|
||||||
|
// JWKS 可达但无匹配密钥 → 视为无效 token
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// JWKS 不可达(网络错误)→ fail-closed
|
||||||
|
console.warn(
|
||||||
|
`[portal-shell] middleware JWT verify failed: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合成 dev-token 身份(仅 DEV_MODE=true 时使用)。
|
||||||
|
* 提供本地无后端可用的最小可运行身份。
|
||||||
|
*/
|
||||||
|
function devIdentity(): {
|
||||||
|
userId: string;
|
||||||
|
role: Role;
|
||||||
|
perms: string[];
|
||||||
|
bitmap: string;
|
||||||
|
} {
|
||||||
|
const perms = PERMISSION_BITMAP_ORDER.filter(
|
||||||
|
(p) => !p.startsWith("_RESERVED_"),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
userId: "dev-user",
|
||||||
|
role: "teacher",
|
||||||
|
perms,
|
||||||
|
bitmap: encodePermissionsBitmap(perms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||||
|
const { pathname, search } = req.nextUrl;
|
||||||
|
const cookieHeader = req.headers.get("cookie");
|
||||||
|
|
||||||
|
// ── 0. 公共路径白名单:直接放行 ──
|
||||||
|
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. 读取 cookie 中的 JWT ──
|
||||||
|
const token = readCookie(cookieHeader, SESSION_COOKIE);
|
||||||
|
|
||||||
|
// ── 2. 无 cookie 处理 ──
|
||||||
|
if (!token) {
|
||||||
|
if (DEV_MODE) {
|
||||||
|
// dev 模式合成身份,继续走权限检查(让本地体验"未登录也有 teacher 身份")
|
||||||
|
const identity = devIdentity();
|
||||||
|
const response = NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: injectIdentityHeaders(
|
||||||
|
req,
|
||||||
|
identity.userId,
|
||||||
|
identity.role,
|
||||||
|
identity.bitmap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
// 生产模式 → 跳登录
|
||||||
|
const loginUrl = new URL("/login", req.url);
|
||||||
|
loginUrl.searchParams.set("next", `${pathname}${search}`);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. 验证 JWT ──
|
||||||
|
const verified = await verifySession(token);
|
||||||
|
if (!verified) {
|
||||||
|
if (DEV_MODE) {
|
||||||
|
// dev 模式 JWT 失效也合成身份(避免本地开发卡死)
|
||||||
|
const identity = devIdentity();
|
||||||
|
return NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: injectIdentityHeaders(
|
||||||
|
req,
|
||||||
|
identity.userId,
|
||||||
|
identity.role,
|
||||||
|
identity.bitmap,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 生产模式 → 清 cookie + 跳登录
|
||||||
|
const loginUrl = new URL("/login", req.url);
|
||||||
|
loginUrl.searchParams.set("next", `${pathname}${search}`);
|
||||||
|
const response = NextResponse.redirect(loginUrl);
|
||||||
|
response.cookies.delete(SESSION_COOKIE);
|
||||||
|
response.cookies.delete(PERMS_COOKIE);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. 计算权限位图(优先读 edu_perms cookie,回退到 JWT 内 perms 计算) ──
|
||||||
|
const permsBitmap =
|
||||||
|
readCookie(cookieHeader, PERMS_COOKIE) ||
|
||||||
|
encodePermissionsBitmap(verified.perms);
|
||||||
|
|
||||||
|
// ── 5. 注入身份头(供 RSC headers() 读取) ──
|
||||||
|
const requestHeaders = injectIdentityHeaders(
|
||||||
|
req,
|
||||||
|
verified.userId,
|
||||||
|
verified.role,
|
||||||
|
permsBitmap,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 6. 路由门禁(仅对 /shell/** 强制执行;其他路径已放行或交由后端) ──
|
||||||
|
if (pathname.startsWith("/shell/") || pathname === "/shell") {
|
||||||
|
const result = checkRoutePermission(pathname, permsBitmap, verified.role);
|
||||||
|
if (!result.allowed) {
|
||||||
|
const forbiddenUrl = new URL("/shell/forbidden", req.url);
|
||||||
|
// 附带拒绝原因作为查询参数(页面可显示,但不暴露内部细节)
|
||||||
|
if (result.reason) {
|
||||||
|
forbiddenUrl.searchParams.set("reason", result.reason);
|
||||||
|
}
|
||||||
|
// 仍然注入身份头(forbidden 页可能需要显示用户名)
|
||||||
|
return NextResponse.redirect(forbiddenUrl, {
|
||||||
|
headers: requestHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next({
|
||||||
|
request: { headers: requestHeaders },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造身份头注入的 Headers 对象。
|
||||||
|
* RSC 通过 `await headers()` 读取这些值。
|
||||||
|
*/
|
||||||
|
function injectIdentityHeaders(
|
||||||
|
req: NextRequest,
|
||||||
|
userId: string,
|
||||||
|
role: Role,
|
||||||
|
permsBitmap: string,
|
||||||
|
): Headers {
|
||||||
|
const requestHeaders = new Headers(req.headers);
|
||||||
|
requestHeaders.set("x-user-id", userId);
|
||||||
|
requestHeaders.set("x-user-role", role);
|
||||||
|
requestHeaders.set("x-user-permissions", permsBitmap);
|
||||||
|
return requestHeaders;
|
||||||
|
}
|
||||||
@@ -1,37 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ApolloProvider(v2.1 M8)
|
* ApolloProvider(v3.0 P0-3,ARCHITECTURE.md §3.4 V3-A2 / §11.7 红线 #2)
|
||||||
*
|
*
|
||||||
* 注入 Apollo Client 单例,所有 widget 的 useWidgetQuery 经此 Client
|
* 注入 Apollo Client 单例,所有 widget 的 useWidgetQuery 经此 Client
|
||||||
* 查询 apollo-router(M8 验收点:portal-shell 查询走 Router)。
|
* 查询同域 /api/graphql 代理(V3-A2:浏览器不再直连 apollo-router)。
|
||||||
*
|
*
|
||||||
* Token 注入:从 localStorage 读取 JWT(对齐 teacher-portal F12 约定),
|
* 凭证传递(V3-A2):
|
||||||
* cookie 凭证通过 credentials:"include" 一并发送。
|
* - 客户端 HttpLink credentials:"include",同域 cookie 自动随行
|
||||||
|
* - JWT 全程在 httpOnly cookie `edu_session` 中,JS 永不接触
|
||||||
|
* - 已删除 localStorage["edu_token"] 读取(方案废止)
|
||||||
*
|
*
|
||||||
* 关联:portal-shell spec §5.6、M8 验收标准
|
* 关联:portal-shell ARCHITECTURE.md §3.4 V3-A2、§5.1、§11.7
|
||||||
*/
|
*/
|
||||||
import { useMemo, type ReactNode } from "react";
|
import { useMemo, type ReactNode } from "react";
|
||||||
import { ApolloProvider as ApolloGraphQLProvider } from "@apollo/client";
|
import { ApolloProvider as ApolloGraphQLProvider } from "@apollo/client";
|
||||||
import { getApolloClient } from "@/lib/apollo-client";
|
import { getApolloClient } from "@/lib/apollo-client";
|
||||||
|
|
||||||
const TOKEN_KEY = "edu_token";
|
|
||||||
|
|
||||||
function readToken(): string | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem(TOKEN_KEY);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApolloProvider({
|
export function ApolloProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const client = useMemo(() => getApolloClient(readToken), []);
|
// getApolloClient 不再接受 getAuthToken 参数(token 已迁至 httpOnly cookie)
|
||||||
|
const client = useMemo(() => getApolloClient(), []);
|
||||||
return (
|
return (
|
||||||
<ApolloGraphQLProvider client={client}>{children}</ApolloGraphQLProvider>
|
<ApolloGraphQLProvider client={client}>{children}</ApolloGraphQLProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -156,15 +156,24 @@ describe("permission-bitmap", () => {
|
|||||||
expect(isValidPermission("")).toBe(false);
|
expect(isValidPermission("")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("PERMISSION_BITMAP_ORDER 全部合法", () => {
|
it("_RESERVED_* 占位返回 false(P0-6 防误用铁律)", () => {
|
||||||
|
expect(isValidPermission("_RESERVED_31")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PERMISSION_BITMAP_ORDER 全部合法(_RESERVED_* 占位除外)", () => {
|
||||||
for (const perm of PERMISSION_BITMAP_ORDER) {
|
for (const perm of PERMISSION_BITMAP_ORDER) {
|
||||||
expect(isValidPermission(perm)).toBe(true);
|
if (perm.startsWith("_RESERVED_")) {
|
||||||
|
// 占位不应视为合法权限点
|
||||||
|
expect(isValidPermission(perm)).toBe(false);
|
||||||
|
} else {
|
||||||
|
expect(isValidPermission(perm)).toBe(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("全量编解码压力测试", () => {
|
describe("全量编解码压力测试", () => {
|
||||||
it("全部权限点编码后解码应还原(去重比较,已知 GRADE_READ 在 ORDER 中重复)", () => {
|
it("全部权限点编码后解码应还原(含 _RESERVED_31 占位,P0-6 去重后无 GRADE_READ 重复)", () => {
|
||||||
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
|
const allPerms = [...new Set(PERMISSION_BITMAP_ORDER)];
|
||||||
const encoded = encodePermissionsBitmap(allPerms);
|
const encoded = encodePermissionsBitmap(allPerms);
|
||||||
const decoded = decodePermissionsBitmap(encoded);
|
const decoded = decodePermissionsBitmap(encoded);
|
||||||
@@ -177,5 +186,19 @@ describe("permission-bitmap", () => {
|
|||||||
// 67 bit → base36 约 14 字符
|
// 67 bit → base36 约 14 字符
|
||||||
expect(encoded.length).toBeLessThan(20);
|
expect(encoded.length).toBeLessThan(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GRADE_READ 在 PERMISSION_BITMAP_ORDER 中仅出现一次(P0-6 去重铁律)", () => {
|
||||||
|
const occurrences = PERMISSION_BITMAP_ORDER.filter(
|
||||||
|
(p) => p === "GRADE_READ",
|
||||||
|
).length;
|
||||||
|
expect(occurrences).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("_RESERVED_31 占位存在且永不作为有效权限点(P0-6 占位铁律)", () => {
|
||||||
|
// 占位存在于 ORDER(保留 bit 位语义)
|
||||||
|
expect(PERMISSION_BITMAP_ORDER).toContain("_RESERVED_31");
|
||||||
|
// 但 isValidPermission 不应将其视为合法权限点(防误用)
|
||||||
|
expect(isValidPermission("_RESERVED_31")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -267,14 +267,44 @@ export const DASHBOARD_ROUTE_PERMISSIONS: Record<
|
|||||||
*
|
*
|
||||||
* 用于 /api/* 路径的权限校验。
|
* 用于 /api/* 路径的权限校验。
|
||||||
* 注意:API Route 通常需要更严格的权限校验,因为它们直接操作数据。
|
* 注意:API Route 通常需要更严格的权限校验,因为它们直接操作数据。
|
||||||
|
*
|
||||||
|
* P0-2(ARCHITECTURE.md §6.2 §3.4 V3-A3):新增 auth/graphql 公开端点。
|
||||||
*/
|
*/
|
||||||
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
export const API_ROUTE_PERMISSIONS: Record<string, RoutePermissionConfig> = {
|
||||||
// 错误上报端点:所有登录用户可访问
|
// 错误上报端点:所有登录用户可访问(空 config 表示登录即可)
|
||||||
"/api/log": {},
|
"/api/log": {},
|
||||||
// 健康检查:公开
|
// 健康检查:公开
|
||||||
"/api/healthz": {},
|
"/api/healthz": {},
|
||||||
|
"/api/health": {},
|
||||||
|
"/api/ready": {},
|
||||||
|
// 认证端点:公开(未登录也要能调登录接口)
|
||||||
|
"/api/auth/login": {},
|
||||||
|
"/api/auth/logout": {},
|
||||||
|
// GraphQL 同域代理:登录即可(细粒度由后端 resolver 把关)
|
||||||
|
"/api/graphql": {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5. 公共路由白名单(P0-2,ARCHITECTURE.md §6.2 §3.4 V3-A3 / §11.7 红线 #5)
|
||||||
|
*
|
||||||
|
* 这些路由允许匿名访问(登录前/无身份也能访问)。
|
||||||
|
* middleware 对白名单路由跳过身份校验,直接放行。
|
||||||
|
*
|
||||||
|
* 注意:白名单外的路由,未登录访问 → middleware 重定向到 /login。
|
||||||
|
*/
|
||||||
|
export const PUBLIC_ROUTES: readonly string[] = [
|
||||||
|
"/",
|
||||||
|
"/login",
|
||||||
|
"/shell/forbidden",
|
||||||
|
"/api/health",
|
||||||
|
"/api/healthz",
|
||||||
|
"/api/ready",
|
||||||
|
"/api/log",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/logout",
|
||||||
|
"/api/graphql",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验权限配置的合法性(开发时辅助)
|
* 校验权限配置的合法性(开发时辅助)
|
||||||
*
|
*
|
||||||
@@ -343,6 +373,11 @@ export function checkRoutePermission(
|
|||||||
userBitmap: string,
|
userBitmap: string,
|
||||||
userRole: Role,
|
userRole: Role,
|
||||||
): RoutePermissionResult {
|
): RoutePermissionResult {
|
||||||
|
// 0. 公共路由白名单优先(含 /shell/forbidden 自身,避免循环重定向)
|
||||||
|
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||||
|
return { allowed: true, matchedPath: "PUBLIC" };
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 匹配精确路由
|
// 1. 匹配精确路由
|
||||||
const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname];
|
const exactConfig = EXACT_ROUTE_PERMISSIONS[pathname];
|
||||||
if (exactConfig) {
|
if (exactConfig) {
|
||||||
@@ -368,14 +403,23 @@ export function checkRoutePermission(
|
|||||||
if (apiConfig) {
|
if (apiConfig) {
|
||||||
return evaluateConfig(apiConfig, userBitmap, userRole, pathname);
|
return evaluateConfig(apiConfig, userBitmap, userRole, pathname);
|
||||||
}
|
}
|
||||||
// 未配置的 API 路由默认拒绝
|
// 未配置的 API 路由默认拒绝(fail-closed,§11.7 红线 #5)
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
reason: "no_config",
|
reason: "no_config",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 未匹配任何配置:默认放行(如 / /login /shell/forbidden 等公共路由)
|
// 5. /shell/** 下未登记路由 → 默认拒绝(fail-closed,§3.4 V3-A3 / §11.7 红线 #5)
|
||||||
|
// 防止"幽灵路由"绕过门禁;新增路由必须显式登记到 EXACT/PREFIX/DASHBOARD 表
|
||||||
|
if (pathname.startsWith("/shell/") || pathname === "/shell") {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: "no_config",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 其他未匹配路由(如 /favicon.ico / 静态资源)默认放行
|
||||||
return { allowed: true };
|
return { allowed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,10 @@ export const PERMISSION_BITMAP_ORDER = [
|
|||||||
"HOMEWORK_SUBMIT",
|
"HOMEWORK_SUBMIT",
|
||||||
"HOMEWORK_GRADE",
|
"HOMEWORK_GRADE",
|
||||||
|
|
||||||
// ── 成绩(5)──────────────────────────────────────────
|
// ── 成绩(4 + 1 占位)─────────────────────────────────
|
||||||
"GRADE_READ",
|
// GRADE_READ 曾在此重复定义(ARCHITECTURE.md §3.4 V3-A3 P0-6 去重)。
|
||||||
|
// 索引 31 标记为 _RESERVED_31 永不复用,避免破坏已签发 JWT 的 bit 位语义。
|
||||||
|
"_RESERVED_31",
|
||||||
"GRADE_READ_OWN",
|
"GRADE_READ_OWN",
|
||||||
"GRADE_READ_CHILD",
|
"GRADE_READ_CHILD",
|
||||||
"GRADE_RECORD_MANAGE",
|
"GRADE_RECORD_MANAGE",
|
||||||
@@ -264,10 +266,13 @@ function parseBase36BigInt(str: string): bigint | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验权限点是否在位图顺序表中
|
* 校验权限点是否在位图顺序表中(且非 _RESERVED_* 占位)
|
||||||
*
|
*
|
||||||
* 用于开发时校验 manifest 声明的权限点是否合法
|
* 用于开发时校验 manifest 声明的权限点是否合法。
|
||||||
|
* _RESERVED_* 占位保留 bit 位语义但不视为合法权限点(防误用)。
|
||||||
*/
|
*/
|
||||||
export function isValidPermission(permission: string): boolean {
|
export function isValidPermission(permission: string): boolean {
|
||||||
return PERMISSION_BIT_INDEX.has(permission);
|
if (!PERMISSION_BIT_INDEX.has(permission)) return false;
|
||||||
|
if (permission.startsWith("_RESERVED_")) return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
379
pnpm-lock.yaml
generated
379
pnpm-lock.yaml
generated
@@ -186,7 +186,7 @@ importers:
|
|||||||
version: 5.101.2(react@18.3.0)
|
version: 5.101.2(react@18.3.0)
|
||||||
'@urql/next':
|
'@urql/next':
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0(next@14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0)(urql@4.2.0(@urql/core@5.0.0(graphql@16.14.2))(react@18.3.0))
|
version: 1.1.0(next@14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0)(urql@4.2.0(@urql/core@5.0.0(graphql@16.14.2))(react@18.3.0))
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@@ -363,6 +363,9 @@ importers:
|
|||||||
graphql:
|
graphql:
|
||||||
specifier: ^16.8.0
|
specifier: ^16.8.0
|
||||||
version: 16.14.2
|
version: 16.14.2
|
||||||
|
jose:
|
||||||
|
specifier: ^5.9.6
|
||||||
|
version: 5.10.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.7)
|
version: 0.562.0(react@19.2.7)
|
||||||
@@ -390,6 +393,9 @@ importers:
|
|||||||
tailwindcss-animate:
|
tailwindcss-animate:
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7(tailwindcss@4.3.2)
|
version: 1.0.7(tailwindcss@4.3.2)
|
||||||
|
zod:
|
||||||
|
specifier: ^3.23.8
|
||||||
|
version: 3.25.76
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.9
|
specifier: ^5.0.9
|
||||||
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7))
|
||||||
@@ -1850,6 +1856,7 @@ packages:
|
|||||||
'@apollo/server@4.13.0':
|
'@apollo/server@4.13.0':
|
||||||
resolution: {integrity: sha512-t4GzaRiYIcPwYy40db6QjZzgvTr9ztDKBddykUXmBb2SVjswMKXbkaJ5nPeHqmT3awr9PAaZdCZdZhRj55I/8A==}
|
resolution: {integrity: sha512-t4GzaRiYIcPwYy40db6QjZzgvTr9ztDKBddykUXmBb2SVjswMKXbkaJ5nPeHqmT3awr9PAaZdCZdZhRj55I/8A==}
|
||||||
engines: {node: '>=14.16.0'}
|
engines: {node: '>=14.16.0'}
|
||||||
|
deprecated: Apollo Server v4 is end-of-life since January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v5 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
graphql: ^16.6.0
|
graphql: ^16.6.0
|
||||||
|
|
||||||
@@ -2181,11 +2188,11 @@ packages:
|
|||||||
|
|
||||||
'@esbuild-kit/core-utils@3.3.2':
|
'@esbuild-kit/core-utils@3.3.2':
|
||||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||||
deprecated: 'Merged into tsx: https://tsx.is'
|
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||||
|
|
||||||
'@esbuild-kit/esm-loader@2.6.5':
|
'@esbuild-kit/esm-loader@2.6.5':
|
||||||
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
|
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
|
||||||
deprecated: 'Merged into tsx: https://tsx.is'
|
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.1':
|
'@esbuild/aix-ppc64@0.28.1':
|
||||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||||
@@ -2199,12 +2206,6 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-w+Akc0vv5leog550kjJV9Ru+MXMR2VuMrui3C61mnysim0gkFCPOUTAfzTP0qX+HpN9Syu3YA3p1hf3EPqObRw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [android]
|
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.19.7':
|
'@esbuild/android-arm64@0.19.7':
|
||||||
resolution: {integrity: sha512-YEDcw5IT7hW3sFKZBkCAQaOCJQLONVcD4bOyTXMZz5fr66pTHnAet46XAtbXAkJRfIn2YVhdC6R9g4xa27jQ1w==}
|
resolution: {integrity: sha512-YEDcw5IT7hW3sFKZBkCAQaOCJQLONVcD4bOyTXMZz5fr66pTHnAet46XAtbXAkJRfIn2YVhdC6R9g4xa27jQ1w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2223,12 +2224,6 @@ packages:
|
|||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-arm@0.19.3':
|
|
||||||
resolution: {integrity: sha512-Lemgw4io4VZl9GHJmjiBGzQ7ONXRfRPHcUEerndjwiSkbxzrpq0Uggku5MxxrXdwJ+pTj1qyw4jwTu7hkPsgIA==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [arm]
|
|
||||||
os: [android]
|
|
||||||
|
|
||||||
'@esbuild/android-arm@0.19.7':
|
'@esbuild/android-arm@0.19.7':
|
||||||
resolution: {integrity: sha512-YGSPnndkcLo4PmVl2tKatEn+0mlVMr3yEpOOT0BeMria87PhvoJb5dg5f5Ft9fbCVgtAz4pWMzZVgSEGpDAlww==}
|
resolution: {integrity: sha512-YGSPnndkcLo4PmVl2tKatEn+0mlVMr3yEpOOT0BeMria87PhvoJb5dg5f5Ft9fbCVgtAz4pWMzZVgSEGpDAlww==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2247,12 +2242,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-x64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-FKQJKkK5MXcBHoNZMDNUAg1+WcZlV/cuXrWCoGF/TvdRiYS4znA0m5Il5idUwfxrE20bG/vU1Cr5e1AD6IEIjQ==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [android]
|
|
||||||
|
|
||||||
'@esbuild/android-x64@0.19.7':
|
'@esbuild/android-x64@0.19.7':
|
||||||
resolution: {integrity: sha512-jhINx8DEjz68cChFvM72YzrqfwJuFbfvSxZAk4bebpngGfNNRm+zRl4rtT9oAX6N9b6gBcFaJHFew5Blf6CvUw==}
|
resolution: {integrity: sha512-jhINx8DEjz68cChFvM72YzrqfwJuFbfvSxZAk4bebpngGfNNRm+zRl4rtT9oAX6N9b6gBcFaJHFew5Blf6CvUw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2271,12 +2260,6 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-kw7e3FXU+VsJSSSl2nMKvACYlwtvZB8RUIeVShIEY6PVnuZ3c9+L9lWB2nWeeKWNNYDdtL19foCQ0ZyUL7nqGw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.19.7':
|
'@esbuild/darwin-arm64@0.19.7':
|
||||||
resolution: {integrity: sha512-dr81gbmWN//3ZnBIm6YNCl4p3pjnabg1/ZVOgz2fJoUO1a3mq9WQ/1iuEluMs7mCL+Zwv7AY5e3g1hjXqQZ9Iw==}
|
resolution: {integrity: sha512-dr81gbmWN//3ZnBIm6YNCl4p3pjnabg1/ZVOgz2fJoUO1a3mq9WQ/1iuEluMs7mCL+Zwv7AY5e3g1hjXqQZ9Iw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2295,12 +2278,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-tPfZiwF9rO0jW6Jh9ipi58N5ZLoSjdxXeSrAYypy4psA2Yl1dAMhM71KxVfmjZhJmxRjSnb29YlRXXhh3GqzYw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.19.7':
|
'@esbuild/darwin-x64@0.19.7':
|
||||||
resolution: {integrity: sha512-Lc0q5HouGlzQEwLkgEKnWcSazqr9l9OdV2HhVasWJzLKeOt0PLhHaUHuzb8s/UIya38DJDoUm74GToZ6Wc7NGQ==}
|
resolution: {integrity: sha512-Lc0q5HouGlzQEwLkgEKnWcSazqr9l9OdV2HhVasWJzLKeOt0PLhHaUHuzb8s/UIya38DJDoUm74GToZ6Wc7NGQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2319,12 +2296,6 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-ERDyjOgYeKe0Vrlr1iLrqTByB026YLPzTytDTz1DRCYM+JI92Dw2dbpRHYmdqn6VBnQ9Bor6J8ZlNwdZdxjlSg==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [freebsd]
|
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.19.7':
|
'@esbuild/freebsd-arm64@0.19.7':
|
||||||
resolution: {integrity: sha512-+y2YsUr0CxDFF7GWiegWjGtTUF6gac2zFasfFkRJPkMAuMy9O7+2EH550VlqVdpEEchWMynkdhC9ZjtnMiHImQ==}
|
resolution: {integrity: sha512-+y2YsUr0CxDFF7GWiegWjGtTUF6gac2zFasfFkRJPkMAuMy9O7+2EH550VlqVdpEEchWMynkdhC9ZjtnMiHImQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2343,12 +2314,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-nXesBZ2Ad1qL+Rm3crN7NmEVJ5uvfLFPLJev3x1j3feCQXfAhoYrojC681RhpdOph8NsvKBBwpYZHR7W0ifTTA==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [freebsd]
|
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.19.7':
|
'@esbuild/freebsd-x64@0.19.7':
|
||||||
resolution: {integrity: sha512-CdXOxIbIzPJmJhrpmJTLx+o35NoiKBIgOvmvT+jeSadYiWJn0vFKsl+0bSG/5lwjNHoIDEyMYc/GAPR9jxusTA==}
|
resolution: {integrity: sha512-CdXOxIbIzPJmJhrpmJTLx+o35NoiKBIgOvmvT+jeSadYiWJn0vFKsl+0bSG/5lwjNHoIDEyMYc/GAPR9jxusTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2367,12 +2332,6 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-qXvYKmXj8GcJgWq3aGvxL/JG1ZM3UR272SdPU4QSTzD0eymrM7leiZH77pvY3UetCy0k1xuXZ+VPvoJNdtrsWQ==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.19.7':
|
'@esbuild/linux-arm64@0.19.7':
|
||||||
resolution: {integrity: sha512-inHqdOVCkUhHNvuQPT1oCB7cWz9qQ/Cz46xmVe0b7UXcuIJU3166aqSunsqkgSGMtUCWOZw3+KMwI6otINuC9g==}
|
resolution: {integrity: sha512-inHqdOVCkUhHNvuQPT1oCB7cWz9qQ/Cz46xmVe0b7UXcuIJU3166aqSunsqkgSGMtUCWOZw3+KMwI6otINuC9g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2391,12 +2350,6 @@ packages:
|
|||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.19.3':
|
|
||||||
resolution: {integrity: sha512-zr48Cg/8zkzZCzDHNxXO/89bf9e+r4HtzNUPoz4GmgAkF1gFAFmfgOdCbR8zMbzFDGb1FqBBhdXUpcTQRYS1cQ==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [arm]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.19.7':
|
'@esbuild/linux-arm@0.19.7':
|
||||||
resolution: {integrity: sha512-Y+SCmWxsJOdQtjcBxoacn/pGW9HDZpwsoof0ttL+2vGcHokFlfqV666JpfLCSP2xLxFpF1lj7T3Ox3sr95YXww==}
|
resolution: {integrity: sha512-Y+SCmWxsJOdQtjcBxoacn/pGW9HDZpwsoof0ttL+2vGcHokFlfqV666JpfLCSP2xLxFpF1lj7T3Ox3sr95YXww==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2415,12 +2368,6 @@ packages:
|
|||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.19.3':
|
|
||||||
resolution: {integrity: sha512-7XlCKCA0nWcbvYpusARWkFjRQNWNGlt45S+Q18UeS///K6Aw8bB2FKYe9mhVWy/XLShvCweOLZPrnMswIaDXQA==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [ia32]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.19.7':
|
'@esbuild/linux-ia32@0.19.7':
|
||||||
resolution: {integrity: sha512-2BbiL7nLS5ZO96bxTQkdO0euGZIUQEUXMTrqLxKUmk/Y5pmrWU84f+CMJpM8+EHaBPfFSPnomEaQiG/+Gmh61g==}
|
resolution: {integrity: sha512-2BbiL7nLS5ZO96bxTQkdO0euGZIUQEUXMTrqLxKUmk/Y5pmrWU84f+CMJpM8+EHaBPfFSPnomEaQiG/+Gmh61g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2439,12 +2386,6 @@ packages:
|
|||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-qGTgjweER5xqweiWtUIDl9OKz338EQqCwbS9c2Bh5jgEH19xQ1yhgGPNesugmDFq+UUSDtWgZ264st26b3de8A==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [loong64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.19.7':
|
'@esbuild/linux-loong64@0.19.7':
|
||||||
resolution: {integrity: sha512-BVFQla72KXv3yyTFCQXF7MORvpTo4uTA8FVFgmwVrqbB/4DsBFWilUm1i2Oq6zN36DOZKSVUTb16jbjedhfSHw==}
|
resolution: {integrity: sha512-BVFQla72KXv3yyTFCQXF7MORvpTo4uTA8FVFgmwVrqbB/4DsBFWilUm1i2Oq6zN36DOZKSVUTb16jbjedhfSHw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2463,12 +2404,6 @@ packages:
|
|||||||
cpu: [mips64el]
|
cpu: [mips64el]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.19.3':
|
|
||||||
resolution: {integrity: sha512-gy1bFskwEyxVMFRNYSvBauDIWNggD6pyxUksc0MV9UOBD138dKTzr8XnM2R4mBsHwVzeuIH8X5JhmNs2Pzrx+A==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [mips64el]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.19.7':
|
'@esbuild/linux-mips64el@0.19.7':
|
||||||
resolution: {integrity: sha512-DzAYckIaK+pS31Q/rGpvUKu7M+5/t+jI+cdleDgUwbU7KdG2eC3SUbZHlo6Q4P1CfVKZ1lUERRFP8+q0ob9i2w==}
|
resolution: {integrity: sha512-DzAYckIaK+pS31Q/rGpvUKu7M+5/t+jI+cdleDgUwbU7KdG2eC3SUbZHlo6Q4P1CfVKZ1lUERRFP8+q0ob9i2w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2487,12 +2422,6 @@ packages:
|
|||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-UrYLFu62x1MmmIe85rpR3qou92wB9lEXluwMB/STDzPF9k8mi/9UvNsG07Tt9AqwPQXluMQ6bZbTzYt01+Ue5g==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [ppc64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.19.7':
|
'@esbuild/linux-ppc64@0.19.7':
|
||||||
resolution: {integrity: sha512-JQ1p0SmUteNdUaaiRtyS59GkkfTW0Edo+e0O2sihnY4FoZLz5glpWUQEKMSzMhA430ctkylkS7+vn8ziuhUugQ==}
|
resolution: {integrity: sha512-JQ1p0SmUteNdUaaiRtyS59GkkfTW0Edo+e0O2sihnY4FoZLz5glpWUQEKMSzMhA430ctkylkS7+vn8ziuhUugQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2511,12 +2440,6 @@ packages:
|
|||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-9E73TfyMCbE+1AwFOg3glnzZ5fBAFK4aawssvuMgCRqCYzE0ylVxxzjEfut8xjmKkR320BEoMui4o/t9KA96gA==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [riscv64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.19.7':
|
'@esbuild/linux-riscv64@0.19.7':
|
||||||
resolution: {integrity: sha512-xGwVJ7eGhkprY/nB7L7MXysHduqjpzUl40+XoYDGC4UPLbnG+gsyS1wQPJ9lFPcxYAaDXbdRXd1ACs9AE9lxuw==}
|
resolution: {integrity: sha512-xGwVJ7eGhkprY/nB7L7MXysHduqjpzUl40+XoYDGC4UPLbnG+gsyS1wQPJ9lFPcxYAaDXbdRXd1ACs9AE9lxuw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2535,12 +2458,6 @@ packages:
|
|||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.19.3':
|
|
||||||
resolution: {integrity: sha512-LlmsbuBdm1/D66TJ3HW6URY8wO6IlYHf+ChOUz8SUAjVTuaisfuwCOAgcxo3Zsu3BZGxmI7yt//yGOxV+lHcEA==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [s390x]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.19.7':
|
'@esbuild/linux-s390x@0.19.7':
|
||||||
resolution: {integrity: sha512-U8Rhki5PVU0L0nvk+E8FjkV8r4Lh4hVEb9duR6Zl21eIEYEwXz8RScj4LZWA2i3V70V4UHVgiqMpszXvG0Yqhg==}
|
resolution: {integrity: sha512-U8Rhki5PVU0L0nvk+E8FjkV8r4Lh4hVEb9duR6Zl21eIEYEwXz8RScj4LZWA2i3V70V4UHVgiqMpszXvG0Yqhg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2559,12 +2476,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-ogV0+GwEmvwg/8ZbsyfkYGaLACBQWDvO0Kkh8LKBGKj9Ru8VM39zssrnu9Sxn1wbapA2qNS6BiLdwJZGouyCwQ==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.19.7':
|
'@esbuild/linux-x64@0.19.7':
|
||||||
resolution: {integrity: sha512-ZYZopyLhm4mcoZXjFt25itRlocKlcazDVkB4AhioiL9hOWhDldU9n38g62fhOI4Pth6vp+Mrd5rFKxD0/S+7aQ==}
|
resolution: {integrity: sha512-ZYZopyLhm4mcoZXjFt25itRlocKlcazDVkB4AhioiL9hOWhDldU9n38g62fhOI4Pth6vp+Mrd5rFKxD0/S+7aQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2589,12 +2500,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [netbsd]
|
os: [netbsd]
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-o1jLNe4uzQv2DKXMlmEzf66Wd8MoIhLNO2nlQBHLtWyh2MitDG7sMpfCO3NTcoTMuqHjfufgUQDFRI5C+xsXQw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [netbsd]
|
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.19.7':
|
'@esbuild/netbsd-x64@0.19.7':
|
||||||
resolution: {integrity: sha512-/yfjlsYmT1O3cum3J6cmGG16Fd5tqKMcg5D+sBYLaOQExheAJhqr8xOAEIuLo8JYkevmjM5zFD9rVs3VBcsjtQ==}
|
resolution: {integrity: sha512-/yfjlsYmT1O3cum3J6cmGG16Fd5tqKMcg5D+sBYLaOQExheAJhqr8xOAEIuLo8JYkevmjM5zFD9rVs3VBcsjtQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2619,12 +2524,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [openbsd]
|
os: [openbsd]
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-AZJCnr5CZgZOdhouLcfRdnk9Zv6HbaBxjcyhq0StNcvAdVZJSKIdOiPB9az2zc06ywl0ePYJz60CjdKsQacp5Q==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [openbsd]
|
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.19.7':
|
'@esbuild/openbsd-x64@0.19.7':
|
||||||
resolution: {integrity: sha512-MYDFyV0EW1cTP46IgUJ38OnEY5TaXxjoDmwiTXPjezahQgZd+j3T55Ht8/Q9YXBM0+T9HJygrSRGV5QNF/YVDQ==}
|
resolution: {integrity: sha512-MYDFyV0EW1cTP46IgUJ38OnEY5TaXxjoDmwiTXPjezahQgZd+j3T55Ht8/Q9YXBM0+T9HJygrSRGV5QNF/YVDQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2649,12 +2548,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [sunos]
|
os: [sunos]
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-Acsujgeqg9InR4glTRvLKGZ+1HMtDm94ehTIHKhJjFpgVzZG9/pIcWW/HA/DoMfEyXmANLDuDZ2sNrWcjq1lxw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [sunos]
|
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.19.7':
|
'@esbuild/sunos-x64@0.19.7':
|
||||||
resolution: {integrity: sha512-JcPvgzf2NN/y6X3UUSqP6jSS06V0DZAV/8q0PjsZyGSXsIGcG110XsdmuWiHM+pno7/mJF6fjH5/vhUz/vA9fw==}
|
resolution: {integrity: sha512-JcPvgzf2NN/y6X3UUSqP6jSS06V0DZAV/8q0PjsZyGSXsIGcG110XsdmuWiHM+pno7/mJF6fjH5/vhUz/vA9fw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2673,12 +2566,6 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-FSrAfjVVy7TifFgYgliiJOyYynhQmqgPj15pzLyJk8BUsnlWNwP/IAy6GAiB1LqtoivowRgidZsfpoYLZH586A==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.19.7':
|
'@esbuild/win32-arm64@0.19.7':
|
||||||
resolution: {integrity: sha512-ZA0KSYti5w5toax5FpmfcAgu3ZNJxYSRm0AW/Dao5up0YV1hDVof1NvwLomjEN+3/GMtaWDI+CIyJOMTRSTdMw==}
|
resolution: {integrity: sha512-ZA0KSYti5w5toax5FpmfcAgu3ZNJxYSRm0AW/Dao5up0YV1hDVof1NvwLomjEN+3/GMtaWDI+CIyJOMTRSTdMw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2697,12 +2584,6 @@ packages:
|
|||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.19.3':
|
|
||||||
resolution: {integrity: sha512-xTScXYi12xLOWZ/sc5RBmMN99BcXp/eEf7scUC0oeiRoiT5Vvo9AycuqCp+xdpDyAU+LkrCqEpUS9fCSZF8J3Q==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [ia32]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.19.7':
|
'@esbuild/win32-ia32@0.19.7':
|
||||||
resolution: {integrity: sha512-CTOnijBKc5Jpk6/W9hQMMvJnsSYRYgveN6O75DTACCY18RA2nqka8dTZR+x/JqXCRiKk84+5+bRKXUSbbwsS0A==}
|
resolution: {integrity: sha512-CTOnijBKc5Jpk6/W9hQMMvJnsSYRYgveN6O75DTACCY18RA2nqka8dTZR+x/JqXCRiKk84+5+bRKXUSbbwsS0A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2721,12 +2602,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.19.3':
|
|
||||||
resolution: {integrity: sha512-FbUN+0ZRXsypPyWE2IwIkVjDkDnJoMJARWOcFZn4KPPli+QnKqF0z1anvfaYe3ev5HFCpRDLLBDHyOALLppWHw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.19.7':
|
'@esbuild/win32-x64@0.19.7':
|
||||||
resolution: {integrity: sha512-gRaP2sk6hc98N734luX4VpF318l3w+ofrtTu9j5L8EQXF+FzQKV6alCOHMVoJJHvVK/mGbwBXfOL1HETQu9IGQ==}
|
resolution: {integrity: sha512-gRaP2sk6hc98N734luX4VpF318l3w+ofrtTu9j5L8EQXF+FzQKV6alCOHMVoJJHvVK/mGbwBXfOL1HETQu9IGQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4883,12 +4758,14 @@ packages:
|
|||||||
'@opentelemetry/propagation-utils@0.30.11':
|
'@opentelemetry/propagation-utils@0.30.11':
|
||||||
resolution: {integrity: sha512-rY4L/2LWNk5p/22zdunpqVmgz6uN419DsRTw5KFMa6u21tWhXS8devlMy4h8m8nnS20wM7r6yYweCNNKjgLYJw==}
|
resolution: {integrity: sha512-rY4L/2LWNk5p/22zdunpqVmgz6uN419DsRTw5KFMa6u21tWhXS8devlMy4h8m8nnS20wM7r6yYweCNNKjgLYJw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
deprecated: The use of process spans has been removed from Messaging Semantic Conventions. It is now recommended to connect pub/sub spans via Span Links. See https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/ for details.
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.0.0
|
'@opentelemetry/api': ^1.0.0
|
||||||
|
|
||||||
'@opentelemetry/propagation-utils@0.30.15':
|
'@opentelemetry/propagation-utils@0.30.15':
|
||||||
resolution: {integrity: sha512-nQ30K+eXTkd9Kt8yep9FPrqogS712GvdkV6R1T+xZMSZnFrRCyZuWxMtP3+s3hrK2HWw3ti4lsIfBzsHWYiyrA==}
|
resolution: {integrity: sha512-nQ30K+eXTkd9Kt8yep9FPrqogS712GvdkV6R1T+xZMSZnFrRCyZuWxMtP3+s3hrK2HWw3ti4lsIfBzsHWYiyrA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
deprecated: The use of process spans has been removed from Messaging Semantic Conventions. It is now recommended to connect pub/sub spans via Span Links. See https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/ for details.
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.0.0
|
'@opentelemetry/api': ^1.0.0
|
||||||
|
|
||||||
@@ -7100,9 +6977,6 @@ packages:
|
|||||||
bl@2.2.0:
|
bl@2.2.0:
|
||||||
resolution: {integrity: sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==}
|
resolution: {integrity: sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==}
|
||||||
|
|
||||||
bl@4.0.3:
|
|
||||||
resolution: {integrity: sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==}
|
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||||
|
|
||||||
@@ -7372,9 +7246,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
commander@2.20.0:
|
|
||||||
resolution: {integrity: sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==}
|
|
||||||
|
|
||||||
commander@2.20.3:
|
commander@2.20.3:
|
||||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||||
|
|
||||||
@@ -7428,10 +7299,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
content-type@1.0.4:
|
|
||||||
resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
content-type@1.0.5:
|
content-type@1.0.5:
|
||||||
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -7988,11 +7855,6 @@ packages:
|
|||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
esbuild@0.19.3:
|
|
||||||
resolution: {integrity: sha512-UlJ1qUUA2jL2nNib1JTSkifQTcYTroFqRjwCFW4QYEKEsixXD5Tik9xML7zh2gTxkYTBKGHNH9y7txMwVyPbjw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
esbuild@0.19.7:
|
esbuild@0.19.7:
|
||||||
resolution: {integrity: sha512-6brbTZVqxhqgbpqBR5MzErImcpA0SQdoKOkcWK/U30HtQxnokIpG3TX2r0IJqbFUzqLjhU/zC1S5ndgakObVCQ==}
|
resolution: {integrity: sha512-6brbTZVqxhqgbpqBR5MzErImcpA0SQdoKOkcWK/U30HtQxnokIpG3TX2r0IJqbFUzqLjhU/zC1S5ndgakObVCQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -8413,6 +8275,7 @@ packages:
|
|||||||
git-raw-commits@4.0.0:
|
git-raw-commits@4.0.0:
|
||||||
resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
|
resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead.
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
github-from-package@0.0.0:
|
github-from-package@0.0.0:
|
||||||
@@ -8432,6 +8295,7 @@ packages:
|
|||||||
glob@10.4.2:
|
glob@10.4.2:
|
||||||
resolution: {integrity: sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==}
|
resolution: {integrity: sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==}
|
||||||
engines: {node: '>=16 || 14 >=14.18'}
|
engines: {node: '>=16 || 14 >=14.18'}
|
||||||
|
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
glob@10.5.0:
|
glob@10.5.0:
|
||||||
@@ -8675,10 +8539,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
iconv-lite@0.6.2:
|
|
||||||
resolution: {integrity: sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -9063,8 +8923,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jose@5.0.0:
|
jose@5.10.0:
|
||||||
resolution: {integrity: sha512-CzP0RPWMzo5oXfCSo+qA0Zz8enk1cI3Hpqzpz1o8Pck4xfFZqxC6OXRqEsAUzSTEl08fiuvU9xIbkoY1zeOg2w==}
|
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||||
|
|
||||||
joycon@3.1.1:
|
joycon@3.1.1:
|
||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
@@ -9523,34 +9383,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
mime-db@1.40.0:
|
|
||||||
resolution: {integrity: sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
mime-db@1.44.0:
|
|
||||||
resolution: {integrity: sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
mime-db@1.51.0:
|
|
||||||
resolution: {integrity: sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
mime-db@1.52.0:
|
mime-db@1.52.0:
|
||||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
mime-types@2.1.24:
|
|
||||||
resolution: {integrity: sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
mime-types@2.1.27:
|
|
||||||
resolution: {integrity: sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
mime-types@2.1.34:
|
|
||||||
resolution: {integrity: sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==}
|
|
||||||
engines: {node: '>= 0.6'}
|
|
||||||
|
|
||||||
mime-types@2.1.35:
|
mime-types@2.1.35:
|
||||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -10443,6 +10279,7 @@ packages:
|
|||||||
recharts@2.12.0:
|
recharts@2.12.0:
|
||||||
resolution: {integrity: sha512-rVNcdNQ5b7+40Ue7mcEKZJyEv+3SUk2bDEVvOyXPDXXVE7TU3lrvnJUgAvO36hSzhRP2DnAamKXvHLFIFOU0Ww==}
|
resolution: {integrity: sha512-rVNcdNQ5b7+40Ue7mcEKZJyEv+3SUk2bDEVvOyXPDXXVE7TU3lrvnJUgAvO36hSzhRP2DnAamKXvHLFIFOU0Ww==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0
|
react: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0
|
||||||
@@ -11152,6 +10989,7 @@ packages:
|
|||||||
tar@6.1.11:
|
tar@6.1.11:
|
||||||
resolution: {integrity: sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==}
|
resolution: {integrity: sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
|
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||||
|
|
||||||
tdigest@0.1.2:
|
tdigest@0.1.2:
|
||||||
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
|
resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==}
|
||||||
@@ -11654,14 +11492,17 @@ packages:
|
|||||||
|
|
||||||
uuid@10.0.0:
|
uuid@10.0.0:
|
||||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||||
|
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uuid@9.0.0:
|
uuid@9.0.0:
|
||||||
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
|
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
|
||||||
|
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uuid@9.0.1:
|
uuid@9.0.1:
|
||||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||||
|
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
validate-npm-package-license@3.0.4:
|
validate-npm-package-license@3.0.4:
|
||||||
@@ -12015,6 +11856,9 @@ packages:
|
|||||||
zod@3.23.0:
|
zod@3.23.0:
|
||||||
resolution: {integrity: sha512-OFLT+LTocvabn6q76BTwVB0hExEBS0IduTr3cqZyMqEDbOnYmcU+y0tUAYbND4uwclpBGi4I4UUBGzylWpjLGA==}
|
resolution: {integrity: sha512-OFLT+LTocvabn6q76BTwVB0hExEBS0IduTr3cqZyMqEDbOnYmcU+y0tUAYbND4uwclpBGi4I4UUBGzylWpjLGA==}
|
||||||
|
|
||||||
|
zod@3.25.76:
|
||||||
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
zone.js@0.11.0:
|
zone.js@0.11.0:
|
||||||
resolution: {integrity: sha512-Aeh31lIySDLetjIyTxk1X+j6jxWPHH99cLuOreApK0hNrEogxEO+cPhx5wQaeg7d7KcAFWrA6ZlIVTv7MTasyA==}
|
resolution: {integrity: sha512-Aeh31lIySDLetjIyTxk1X+j6jxWPHH99cLuOreApK0hNrEogxEO+cPhx5wQaeg7d7KcAFWrA6ZlIVTv7MTasyA==}
|
||||||
|
|
||||||
@@ -12209,7 +12053,7 @@ snapshots:
|
|||||||
negotiator: 0.6.4
|
negotiator: 0.6.4
|
||||||
node-abort-controller: 3.1.1
|
node-abort-controller: 3.1.1
|
||||||
node-fetch: 2.7.0(encoding@0.1.13)
|
node-fetch: 2.7.0(encoding@0.1.13)
|
||||||
uuid: 9.0.0
|
uuid: 9.0.1
|
||||||
whatwg-mimetype: 3.0.0
|
whatwg-mimetype: 3.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
@@ -12646,9 +12490,6 @@ snapshots:
|
|||||||
'@esbuild/android-arm64@0.18.20':
|
'@esbuild/android-arm64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.19.7':
|
'@esbuild/android-arm64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12658,9 +12499,6 @@ snapshots:
|
|||||||
'@esbuild/android-arm@0.18.20':
|
'@esbuild/android-arm@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/android-arm@0.19.7':
|
'@esbuild/android-arm@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12670,9 +12508,6 @@ snapshots:
|
|||||||
'@esbuild/android-x64@0.18.20':
|
'@esbuild/android-x64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-x64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/android-x64@0.19.7':
|
'@esbuild/android-x64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12682,9 +12517,6 @@ snapshots:
|
|||||||
'@esbuild/darwin-arm64@0.18.20':
|
'@esbuild/darwin-arm64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.19.7':
|
'@esbuild/darwin-arm64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12694,9 +12526,6 @@ snapshots:
|
|||||||
'@esbuild/darwin-x64@0.18.20':
|
'@esbuild/darwin-x64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.19.7':
|
'@esbuild/darwin-x64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12706,9 +12535,6 @@ snapshots:
|
|||||||
'@esbuild/freebsd-arm64@0.18.20':
|
'@esbuild/freebsd-arm64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.19.7':
|
'@esbuild/freebsd-arm64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12718,9 +12544,6 @@ snapshots:
|
|||||||
'@esbuild/freebsd-x64@0.18.20':
|
'@esbuild/freebsd-x64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.19.7':
|
'@esbuild/freebsd-x64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12730,9 +12553,6 @@ snapshots:
|
|||||||
'@esbuild/linux-arm64@0.18.20':
|
'@esbuild/linux-arm64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.19.7':
|
'@esbuild/linux-arm64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12742,9 +12562,6 @@ snapshots:
|
|||||||
'@esbuild/linux-arm@0.18.20':
|
'@esbuild/linux-arm@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.19.7':
|
'@esbuild/linux-arm@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12754,9 +12571,6 @@ snapshots:
|
|||||||
'@esbuild/linux-ia32@0.18.20':
|
'@esbuild/linux-ia32@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.19.7':
|
'@esbuild/linux-ia32@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12766,9 +12580,6 @@ snapshots:
|
|||||||
'@esbuild/linux-loong64@0.18.20':
|
'@esbuild/linux-loong64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.19.7':
|
'@esbuild/linux-loong64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12778,9 +12589,6 @@ snapshots:
|
|||||||
'@esbuild/linux-mips64el@0.18.20':
|
'@esbuild/linux-mips64el@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.19.7':
|
'@esbuild/linux-mips64el@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12790,9 +12598,6 @@ snapshots:
|
|||||||
'@esbuild/linux-ppc64@0.18.20':
|
'@esbuild/linux-ppc64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.19.7':
|
'@esbuild/linux-ppc64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12802,9 +12607,6 @@ snapshots:
|
|||||||
'@esbuild/linux-riscv64@0.18.20':
|
'@esbuild/linux-riscv64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.19.7':
|
'@esbuild/linux-riscv64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12814,9 +12616,6 @@ snapshots:
|
|||||||
'@esbuild/linux-s390x@0.18.20':
|
'@esbuild/linux-s390x@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.19.7':
|
'@esbuild/linux-s390x@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12826,9 +12625,6 @@ snapshots:
|
|||||||
'@esbuild/linux-x64@0.18.20':
|
'@esbuild/linux-x64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.19.7':
|
'@esbuild/linux-x64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12841,9 +12637,6 @@ snapshots:
|
|||||||
'@esbuild/netbsd-x64@0.18.20':
|
'@esbuild/netbsd-x64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.19.7':
|
'@esbuild/netbsd-x64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12856,9 +12649,6 @@ snapshots:
|
|||||||
'@esbuild/openbsd-x64@0.18.20':
|
'@esbuild/openbsd-x64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.19.7':
|
'@esbuild/openbsd-x64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12871,9 +12661,6 @@ snapshots:
|
|||||||
'@esbuild/sunos-x64@0.18.20':
|
'@esbuild/sunos-x64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.19.7':
|
'@esbuild/sunos-x64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12883,9 +12670,6 @@ snapshots:
|
|||||||
'@esbuild/win32-arm64@0.18.20':
|
'@esbuild/win32-arm64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.19.7':
|
'@esbuild/win32-arm64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12895,9 +12679,6 @@ snapshots:
|
|||||||
'@esbuild/win32-ia32@0.18.20':
|
'@esbuild/win32-ia32@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.19.7':
|
'@esbuild/win32-ia32@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -12907,9 +12688,6 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.18.20':
|
'@esbuild/win32-x64@0.18.20':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.19.3':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.19.7':
|
'@esbuild/win32-x64@0.19.7':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -13497,7 +13275,7 @@ snapshots:
|
|||||||
graphql-request: 6.0.0(encoding@0.1.13)(graphql@16.14.2)
|
graphql-request: 6.0.0(encoding@0.1.13)(graphql@16.14.2)
|
||||||
http-proxy-agent: 7.0.0
|
http-proxy-agent: 7.0.0
|
||||||
https-proxy-agent: 7.0.0
|
https-proxy-agent: 7.0.0
|
||||||
jose: 5.0.0
|
jose: 5.10.0
|
||||||
js-yaml: 4.3.0
|
js-yaml: 4.3.0
|
||||||
lodash: 4.18.1
|
lodash: 4.18.1
|
||||||
scuid: 1.1.0
|
scuid: 1.1.0
|
||||||
@@ -13865,7 +13643,7 @@ snapshots:
|
|||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
string-width-cjs: string-width@4.2.0
|
string-width-cjs: string-width@4.2.3
|
||||||
strip-ansi: 7.2.0
|
strip-ansi: 7.2.0
|
||||||
strip-ansi-cjs: strip-ansi@6.0.1
|
strip-ansi-cjs: strip-ansi@6.0.1
|
||||||
wrap-ansi: 8.1.0
|
wrap-ansi: 8.1.0
|
||||||
@@ -13909,7 +13687,7 @@ snapshots:
|
|||||||
|
|
||||||
'@jridgewell/source-map@0.3.11':
|
'@jridgewell/source-map@0.3.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.5
|
'@jridgewell/gen-mapping': 0.3.12
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.4.10': {}
|
'@jridgewell/sourcemap-codec@1.4.10': {}
|
||||||
@@ -14045,7 +13823,7 @@ snapshots:
|
|||||||
next: 14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)
|
next: 14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)
|
||||||
react: 18.3.0
|
react: 18.3.0
|
||||||
react-dom: 18.3.0(react@18.3.0)
|
react-dom: 18.3.0(react@18.3.0)
|
||||||
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@18.3.0)
|
styled-jsx: 5.1.6(react@18.3.0)
|
||||||
webpack: 5.92.1(lightningcss@1.32.0)(postcss@8.5.18)
|
webpack: 5.92.1(lightningcss@1.32.0)(postcss@8.5.18)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@rspack/core'
|
- '@rspack/core'
|
||||||
@@ -15760,7 +15538,7 @@ snapshots:
|
|||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
'@opentelemetry/api-logs': 0.57.1
|
'@opentelemetry/api-logs': 0.57.1
|
||||||
'@types/shimmer': 1.2.0
|
'@types/shimmer': 1.2.0
|
||||||
import-in-the-middle: 1.8.1
|
import-in-the-middle: 1.11.2
|
||||||
require-in-the-middle: 7.5.2
|
require-in-the-middle: 7.5.2
|
||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
shimmer: 1.2.1
|
shimmer: 1.2.1
|
||||||
@@ -17722,7 +17500,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- graphql
|
- graphql
|
||||||
|
|
||||||
'@urql/next@1.1.0(next@14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0)(urql@4.2.0(@urql/core@5.0.0(graphql@16.14.2))(react@18.3.0))':
|
'@urql/next@1.1.0(next@14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0))(react@18.3.0)(urql@4.2.0(@urql/core@5.0.0(graphql@16.14.2))(react@18.3.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
next: 14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)
|
next: 14.2.35(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@18.3.0(react@18.3.0))(react@18.3.0)
|
||||||
react: 18.3.0
|
react: 18.3.0
|
||||||
@@ -17974,7 +17752,7 @@ snapshots:
|
|||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 2.1.34
|
mime-types: 2.1.35
|
||||||
negotiator: 0.6.3
|
negotiator: 0.6.3
|
||||||
|
|
||||||
acorn-import-assertions@1.9.0(acorn@8.17.0):
|
acorn-import-assertions@1.9.0(acorn@8.17.0):
|
||||||
@@ -18336,12 +18114,6 @@ snapshots:
|
|||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
bl@4.0.3:
|
|
||||||
dependencies:
|
|
||||||
buffer: 5.7.1
|
|
||||||
inherits: 2.0.4
|
|
||||||
readable-stream: 3.6.2
|
|
||||||
|
|
||||||
bl@4.1.0:
|
bl@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
buffer: 5.7.1
|
buffer: 5.7.1
|
||||||
@@ -18651,8 +18423,6 @@ snapshots:
|
|||||||
|
|
||||||
commander@11.1.0: {}
|
commander@11.1.0: {}
|
||||||
|
|
||||||
commander@2.20.0: {}
|
|
||||||
|
|
||||||
commander@2.20.3: {}
|
commander@2.20.3: {}
|
||||||
|
|
||||||
commander@4.0.0: {}
|
commander@4.0.0: {}
|
||||||
@@ -18709,8 +18479,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
content-type@1.0.4: {}
|
|
||||||
|
|
||||||
content-type@1.0.5: {}
|
content-type@1.0.5: {}
|
||||||
|
|
||||||
conventional-changelog-angular@7.0.0:
|
conventional-changelog-angular@7.0.0:
|
||||||
@@ -19081,7 +18849,7 @@ snapshots:
|
|||||||
|
|
||||||
encoding@0.1.13:
|
encoding@0.1.13:
|
||||||
dependencies:
|
dependencies:
|
||||||
iconv-lite: 0.6.2
|
iconv-lite: 0.6.3
|
||||||
|
|
||||||
end-of-stream@1.4.5:
|
end-of-stream@1.4.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -19229,31 +18997,6 @@ snapshots:
|
|||||||
'@esbuild/win32-ia32': 0.18.20
|
'@esbuild/win32-ia32': 0.18.20
|
||||||
'@esbuild/win32-x64': 0.18.20
|
'@esbuild/win32-x64': 0.18.20
|
||||||
|
|
||||||
esbuild@0.19.3:
|
|
||||||
optionalDependencies:
|
|
||||||
'@esbuild/android-arm': 0.19.3
|
|
||||||
'@esbuild/android-arm64': 0.19.3
|
|
||||||
'@esbuild/android-x64': 0.19.3
|
|
||||||
'@esbuild/darwin-arm64': 0.19.3
|
|
||||||
'@esbuild/darwin-x64': 0.19.3
|
|
||||||
'@esbuild/freebsd-arm64': 0.19.3
|
|
||||||
'@esbuild/freebsd-x64': 0.19.3
|
|
||||||
'@esbuild/linux-arm': 0.19.3
|
|
||||||
'@esbuild/linux-arm64': 0.19.3
|
|
||||||
'@esbuild/linux-ia32': 0.19.3
|
|
||||||
'@esbuild/linux-loong64': 0.19.3
|
|
||||||
'@esbuild/linux-mips64el': 0.19.3
|
|
||||||
'@esbuild/linux-ppc64': 0.19.3
|
|
||||||
'@esbuild/linux-riscv64': 0.19.3
|
|
||||||
'@esbuild/linux-s390x': 0.19.3
|
|
||||||
'@esbuild/linux-x64': 0.19.3
|
|
||||||
'@esbuild/netbsd-x64': 0.19.3
|
|
||||||
'@esbuild/openbsd-x64': 0.19.3
|
|
||||||
'@esbuild/sunos-x64': 0.19.3
|
|
||||||
'@esbuild/win32-arm64': 0.19.3
|
|
||||||
'@esbuild/win32-ia32': 0.19.3
|
|
||||||
'@esbuild/win32-x64': 0.19.3
|
|
||||||
|
|
||||||
esbuild@0.19.7:
|
esbuild@0.19.7:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/android-arm': 0.19.7
|
'@esbuild/android-arm': 0.19.7
|
||||||
@@ -19526,7 +19269,7 @@ snapshots:
|
|||||||
array-flatten: 1.1.1
|
array-flatten: 1.1.1
|
||||||
body-parser: 1.20.6
|
body-parser: 1.20.6
|
||||||
content-disposition: 0.5.4
|
content-disposition: 0.5.4
|
||||||
content-type: 1.0.4
|
content-type: 1.0.5
|
||||||
cookie: 0.7.1
|
cookie: 0.7.1
|
||||||
cookie-signature: 1.0.7
|
cookie-signature: 1.0.7
|
||||||
debug: 2.6.9
|
debug: 2.6.9
|
||||||
@@ -20175,10 +19918,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
iconv-lite@0.6.2:
|
|
||||||
dependencies:
|
|
||||||
safer-buffer: 2.1.2
|
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -20590,7 +20329,7 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
jose@5.0.0: {}
|
jose@5.10.0: {}
|
||||||
|
|
||||||
joycon@3.1.1: {}
|
joycon@3.1.1: {}
|
||||||
|
|
||||||
@@ -21010,26 +20749,8 @@ snapshots:
|
|||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 2.3.2
|
picomatch: 2.3.2
|
||||||
|
|
||||||
mime-db@1.40.0: {}
|
|
||||||
|
|
||||||
mime-db@1.44.0: {}
|
|
||||||
|
|
||||||
mime-db@1.51.0: {}
|
|
||||||
|
|
||||||
mime-db@1.52.0: {}
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
mime-types@2.1.24:
|
|
||||||
dependencies:
|
|
||||||
mime-db: 1.40.0
|
|
||||||
|
|
||||||
mime-types@2.1.27:
|
|
||||||
dependencies:
|
|
||||||
mime-db: 1.44.0
|
|
||||||
|
|
||||||
mime-types@2.1.34:
|
|
||||||
dependencies:
|
|
||||||
mime-db: 1.51.0
|
|
||||||
|
|
||||||
mime-types@2.1.35:
|
mime-types@2.1.35:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.52.0
|
mime-db: 1.52.0
|
||||||
@@ -22658,14 +22379,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
|
|
||||||
styled-jsx@5.1.6(@babel/core@7.29.7)(react@18.3.0):
|
|
||||||
dependencies:
|
|
||||||
client-only: 0.0.1
|
|
||||||
react: 18.3.0
|
|
||||||
optionalDependencies:
|
|
||||||
'@babel/core': 7.29.7
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7):
|
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
@@ -22673,6 +22386,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
|
|
||||||
|
styled-jsx@5.1.6(react@18.3.0):
|
||||||
|
dependencies:
|
||||||
|
client-only: 0.0.1
|
||||||
|
react: 18.3.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
subscriptions-transport-ws@0.11.0(graphql@16.14.2):
|
subscriptions-transport-ws@0.11.0(graphql@16.14.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
backo2: 1.0.2
|
backo2: 1.0.2
|
||||||
@@ -22812,7 +22531,7 @@ snapshots:
|
|||||||
|
|
||||||
tar-stream@2.1.4:
|
tar-stream@2.1.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
bl: 4.0.3
|
bl: 4.1.0
|
||||||
end-of-stream: 1.4.5
|
end-of-stream: 1.4.5
|
||||||
fs-constants: 1.0.0
|
fs-constants: 1.0.0
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
@@ -22885,7 +22604,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/source-map': 0.3.11
|
'@jridgewell/source-map': 0.3.11
|
||||||
acorn: 8.17.0
|
acorn: 8.17.0
|
||||||
commander: 2.20.0
|
commander: 2.20.3
|
||||||
source-map-support: 0.5.21
|
source-map-support: 0.5.21
|
||||||
|
|
||||||
test-exclude@7.0.2:
|
test-exclude@7.0.2:
|
||||||
@@ -23091,7 +22810,7 @@ snapshots:
|
|||||||
type-is@1.6.18:
|
type-is@1.6.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
media-typer: 0.3.0
|
media-typer: 0.3.0
|
||||||
mime-types: 2.1.24
|
mime-types: 2.1.35
|
||||||
|
|
||||||
typed-array-buffer@1.0.3:
|
typed-array-buffer@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -23346,7 +23065,7 @@ snapshots:
|
|||||||
|
|
||||||
vite@5.0.0(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.49.0):
|
vite@5.0.0(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.49.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.19.3
|
esbuild: 0.19.7
|
||||||
postcss: 8.5.18
|
postcss: 8.5.18
|
||||||
rollup: 4.62.2
|
rollup: 4.62.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -23466,7 +23185,7 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
json-parse-even-better-errors: 2.3.1
|
json-parse-even-better-errors: 2.3.1
|
||||||
loader-runner: 4.3.2
|
loader-runner: 4.3.2
|
||||||
mime-types: 2.1.27
|
mime-types: 2.1.35
|
||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
schema-utils: 3.3.0
|
schema-utils: 3.3.0
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
@@ -23506,7 +23225,7 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
json-parse-even-better-errors: 2.3.1
|
json-parse-even-better-errors: 2.3.1
|
||||||
loader-runner: 4.3.2
|
loader-runner: 4.3.2
|
||||||
mime-types: 2.1.27
|
mime-types: 2.1.35
|
||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
schema-utils: 3.3.0
|
schema-utils: 3.3.0
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
@@ -23546,7 +23265,7 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
json-parse-even-better-errors: 2.3.1
|
json-parse-even-better-errors: 2.3.1
|
||||||
loader-runner: 4.3.2
|
loader-runner: 4.3.2
|
||||||
mime-types: 2.1.27
|
mime-types: 2.1.35
|
||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
schema-utils: 3.3.0
|
schema-utils: 3.3.0
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
@@ -23755,6 +23474,8 @@ snapshots:
|
|||||||
|
|
||||||
zod@3.23.0: {}
|
zod@3.23.0: {}
|
||||||
|
|
||||||
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zone.js@0.11.0:
|
zone.js@0.11.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ packages:
|
|||||||
|
|
||||||
# pnpm 11+ build scripts 白名单(从 package.json 的 pnpm.onlyBuiltDependencies 迁移)
|
# pnpm 11+ build scripts 白名单(从 package.json 的 pnpm.onlyBuiltDependencies 迁移)
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@apollo/protobufjs': set this to true or false
|
'@apollo/protobufjs': false
|
||||||
'@module-federation/nextjs-mf': true
|
'@module-federation/nextjs-mf': true
|
||||||
'@nestjs/core': true
|
'@nestjs/core': true
|
||||||
'@sentry/cli': true
|
'@sentry/cli': true
|
||||||
@@ -17,7 +17,7 @@ allowBuilds:
|
|||||||
esbuild: true
|
esbuild: true
|
||||||
msw: true
|
msw: true
|
||||||
protobufjs: true
|
protobufjs: true
|
||||||
sharp: set this to true or false
|
sharp: false
|
||||||
ssh2: true
|
ssh2: true
|
||||||
tsx: true
|
tsx: true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user