- Update 004_architecture_impact_map.md - Update 005_architecture_data.json - Add lesson-preparation-audit-report-v5.md - Add troubleshooting docs
653 lines
32 KiB
Markdown
653 lines
32 KiB
Markdown
# 项目规则速查手册
|
||
|
||
> 一页式规则清单,指明正确做法。无需解释原因,遇到问题查表即可。
|
||
|
||
---
|
||
|
||
## 一、i18n 翻译文件规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| key 禁止包含 `.` | `"title": { "parent": "..." }` | `"title.parent": "..."` |
|
||
| 同字段多角色变体用嵌套对象 | `"title": { "default": "...", "parent": "...", "teacher": "..." }` | `"title": "..."`, `"title.parent": "..."` |
|
||
| 不同字段用驼峰命名 | `"createDesc": "..."` | `"create.desc": "..."` |
|
||
| 调用方 `t("title.parent")` 自动解析嵌套 | 无需改调用方代码 | — |
|
||
| 动态拼 key 需包含完整路径 | `t(\`type.${type}\`)`(type 在 type 对象下) | `t(type)`(漏掉 `type.` 前缀) |
|
||
| 嵌套 key 调用需带父级路径 | `t(\`difficulty.${level}\`)` | `t(level)` |
|
||
|
||
### i18n 动态 key 调用规则
|
||
|
||
当 `t()` 参数为动态变量时,**必须包含完整嵌套路径**:
|
||
|
||
```typescript
|
||
// JSON 结构: { "type": { "single_choice": "单选题", "multiple_choice": "多选题" } }
|
||
|
||
// 正确:带父级路径
|
||
const label = t(`type.${question.type}`) // t("type.single_choice")
|
||
|
||
// 错误:漏掉父级路径,触发 MISSING_MESSAGE
|
||
const label = t(question.type) // t("single_choice") → 找不到
|
||
```
|
||
| ICU 参数用单花括号 | `"level": "难度 {level}"` | `"level": "难度 {{level}}"` |
|
||
| ICU 多参数直接并列 | `"summary": "{count} 题 · {subject}"` | `"summary": "{{count}} 题 · {{subject}}"` |
|
||
|
||
---
|
||
|
||
## 二、"use server" 文件规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 只能导出 `async function` | `export async function foo() {}` | `export const foo = ...` |
|
||
| 常量/对象放 `types.ts` 或 `constants.ts` | `types.ts: export const CONFIG = {...}` | `actions.ts: export const CONFIG = {...}` |
|
||
| 同步函数放 `lib/` 目录 | `lib/track-event.ts: export function track() {}` | `actions.ts: export function track() {}` |
|
||
| 禁止值 re-export | 调用方直接从源文件 import | `export { foo } from "./bar"` |
|
||
| 类型 re-export 允许 | `export type { Foo } from "./bar"` | — |
|
||
|
||
---
|
||
|
||
## 三、Client Component 依赖隔离规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 常量定义放 `types.ts` 或 `constants.ts` | `types.ts: export const MAX = 30` | `retention.ts: export const MAX = 30`(含 `import { db }`) |
|
||
| Client Component 不得 import server-only 文件 | `import { MAX } from "../types"` | `import { MAX } from "../retention"`(拉入 mysql2) |
|
||
| `import { db }` 或 `import "server-only"` 的文件不得被客户端 import | — | — |
|
||
| 纯类型文件不得 import db/mysql2 | `types.ts` 无 server-only import | `types.ts: import { db } from "@/shared/db"` |
|
||
|
||
---
|
||
|
||
## 四、可选依赖处理规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 可选依赖动态 import 加 `webpackIgnore` | `await import(/* webpackIgnore: true */ "@upstash/redis")` | `await import("@upstash/redis")` |
|
||
| 环境变量控制加载的模块用动态 import | `if (env.DRIVER === "redis") { const { Redis } = await import(...) }` | 顶层静态 import |
|
||
| 仅 redis 驱动加载的类用动态 import | `const { RedisRateLimiter } = await import("./redis-limiter")` | `import { RedisRateLimiter } from "./redis-limiter"` |
|
||
|
||
---
|
||
|
||
## 五、Next.js 配置规则
|
||
|
||
| 规则 | 配置 |
|
||
|------|------|
|
||
| Node.js 服务端驱动标记为外部包 | `serverExternalPackages: ["mysql2"]` |
|
||
| 输出模式 | `output: "standalone"` |
|
||
|
||
---
|
||
|
||
## 六、架构分层规则
|
||
|
||
| 层级 | 允许依赖 | 禁止依赖 |
|
||
|------|---------|---------|
|
||
| `app/` | `modules/` 的 actions + data-access | 直接访问 DB |
|
||
| `modules/` | 其他 `modules/` 的 data-access + `shared/` | 直接查询对方 DB 表 |
|
||
| `shared/` | 无 | `@/auth`、`@/proxy`、`modules/*` |
|
||
|
||
---
|
||
|
||
## 七、模块文件职责规则
|
||
|
||
| 文件 | 职责 | 禁止 |
|
||
|------|------|------|
|
||
| `actions.ts` | Server Actions(async function + 权限校验) | 导出常量、同步函数、值 re-export |
|
||
| `data-access.ts` | 数据访问(可拆分 `data-access-*.ts`) | 业务逻辑、权限校验 |
|
||
| `types.ts` | 类型定义 + 常量 | `import { db }`、`import "server-only"` |
|
||
| `schema.ts` | Zod 验证 | — |
|
||
| `lib/` | 同步工具函数、业务逻辑 | "use server" 标记 |
|
||
|
||
---
|
||
|
||
## 八、验证命令速查
|
||
|
||
| 场景 | 命令 |
|
||
|------|------|
|
||
| 类型检查 | `npx tsc --noEmit` |
|
||
| Lint | `npm run lint` |
|
||
| 开发服务器 | `npm run dev` |
|
||
| 生产构建 | `npm run build` |
|
||
| 检查 i18n key 是否含 `.` | Grep pattern: `^\s*"[a-zA-Z_]+\.[a-zA-Z_\.]+"\s*:`,glob: `**/messages/**/*.json` |
|
||
| 同步 schema 到数据库 | `npm run db:push` |
|
||
| 执行迁移文件 | `npm run db:migrate` |
|
||
| 检查表结构 | `SHOW COLUMNS FROM table_name` |
|
||
|
||
---
|
||
|
||
## 十、数据库 Schema 同步规则
|
||
|
||
| 规则 | 正确做法 | 错误做法 |
|
||
|------|---------|---------|
|
||
| 修改 schema.ts 后必须同步数据库 | `npm run db:push` 或 `npm run db:migrate` | 仅改 schema.ts 不同步数据库 |
|
||
| 报错 `Unknown column` 时先查表结构 | `SHOW COLUMNS FROM xxx` | 仅看代码 schema 假设数据库已同步 |
|
||
| 迁移文件按编号顺序执行 | `drizzle/00XX_*.sql` 按序号递增 | 跳过编号或乱序执行 |
|
||
| `Failed query` 错误先排查数据库结构 | 用 mysql 客户端执行相同 SQL 确认 | 仅看代码假设查询逻辑错误 |
|
||
| 新增字段必须生成迁移文件 | `npm run db:generate` 后 commit `.sql` + `_journal.json` | 仅 ALTER 数据库不留迁移文件 |
|
||
| 修改 schema 后验证一致性 | `node scripts/verify-modules-schema.mjs` 或 `npm run db:push` | 仅看 dev server 不报错即认为成功 |
|
||
|
||
### 数据库同步问题排查
|
||
|
||
当出现 `Unknown column 'xxx' in 'field list'` 或 `Failed query` 错误时:
|
||
|
||
1. 用 `SHOW COLUMNS FROM 表名` 对比数据库实际结构与 `schema.ts`
|
||
2. 若字段缺失,执行 `npm run db:push` 同步,或手动执行对应迁移 SQL
|
||
3. 验证:用相同 SQL 在数据库客户端执行,确认不再报错
|
||
|
||
---
|
||
|
||
## 十一、Drizzle 迁移记录管理规则
|
||
|
||
| 规则 | 正确做法 | 错误做法 |
|
||
|------|---------|---------|
|
||
| 迁移文件必须录入 `_journal.json` | `npm run db:generate` 自动生成 entry | 手动创建 `.sql` 不更新 journal |
|
||
| `__drizzle_migrations` 表必须记录已应用迁移 | `npm run db:migrate` 自动写入 hash | 手动 ALTER 后不补 hash 记录 |
|
||
| 迁移文件编号唯一不可复用 | `0016_invitation_codes.sql` 与 `0016_message_reports.sql` 应改 `0022+` | 同前缀冲突 |
|
||
| 手动补 DDL 后必须同步 journal + migrations 表 | 见下方"手动补迁移"流程 | 仅 ALTER 数据库,留 journal 缺失 |
|
||
| 检查迁移状态 | `SELECT * FROM __drizzle_migrations` | 仅看 `_journal.json` 文件 |
|
||
|
||
### 手动补迁移流程(数据库已 ALTER 但 journal/migrations 表缺失时)
|
||
|
||
1. 在 `drizzle/` 创建 `00XX_*.sql` 文件(即使 DDL 已应用,仍需归档)
|
||
2. 在 `drizzle/meta/_journal.json` 添加对应 entry(`tag` = 文件名不含扩展名)
|
||
3. 计算文件 sha256 hash,插入 `__drizzle_migrations` 表:
|
||
```sql
|
||
INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('<sha256>', <unix_ms>);
|
||
```
|
||
4. 验证:`npm run db:migrate` 应输出 "No new migrations to apply"
|
||
|
||
### Drizzle 字段映射规则
|
||
|
||
| 场景 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| TS 属性名与 DB 列名不同 | `status: mysqlEnum("report_status", [...])` | 假设 DB 列名是 `status` |
|
||
| 验证 SQL 用 DB 列名 | `SELECT report_status FROM learning_diagnostic_reports` | `SELECT status FROM learning_diagnostic_reports` |
|
||
| ORM 查询用 TS 属性名 | `db.select({ s: table.status })` | `db.select({ s: table.report_status })` |
|
||
|
||
---
|
||
|
||
## 十二、V5 备课模块场景缺口修复规则
|
||
|
||
### Zustand store 拆 slice + history slice 模式
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| mutation 方法包装 history | `setTitle: (title) => { get().pushHistory(); set({ title, isDirty: true }); }` | `setTitle: (title) => set({ title, isDirty: true })` |
|
||
| 拖拽位置更新除外 | `updateNodePosition` 不推 history(调用方 onDragStart 时推) | 拖拽过程每次 move 都 pushHistory(栈瞬间爆满) |
|
||
| hydrate/replaceDoc 清空历史 | `hydrate: (...) => set({ ..., past: [], future: [] })` | 切换课案后旧 history 残留 |
|
||
| history 栈上限 | `past: [...s.past, snapshot].slice(-MAX_HISTORY)` | 无上限(内存爆炸) |
|
||
|
||
### 撤销/重做快捷键
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| Cmd/Ctrl 区分 | `const isMod = e.metaKey \|\| e.ctrlKey` | 仅 `e.ctrlKey`(Mac 不工作) |
|
||
| Shift+Z 或 Y 重做 | `(key === "z" && e.shiftKey) \|\| key === "y"` | 仅 `key === "y"` |
|
||
| preventDefault | `e.preventDefault()` | 不阻止默认(浏览器原生撤销/重做冲突) |
|
||
|
||
### 自动保存失败 UI 兜底
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 保存失败显示 toast | `toast.error(t("status.saveFailed"), { description: ... })` | `console.error(e)`(用户无感知) |
|
||
| 断网时不触发保存 | `if (!editor.isOnline) return;` 在 debounce 内 | 网络请求堆积 |
|
||
| 监听 online/offline | `window.addEventListener("online", handleOnline)` | 不监听(永远显示离线) |
|
||
| saveError 显示重试按钮 | `{editor.saveError && <Button onClick={handleRetrySave}>...}` | 失败后无重试入口 |
|
||
|
||
### 发布前预览 3 步流程
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 步骤状态机 | `type Step = "select" \| "preview" \| "confirm"` | 单步直接发布 |
|
||
| 预览显示题目列表 | `<ol>{items.map(...)}` 含题干/选项/分值 | 仅显示题目数量 |
|
||
| 总分 useMemo | `const totalScore = useMemo(() => items.reduce(...), [items])` | 每次渲染 reduce |
|
||
| 确认页警告文案 | `{t("publish.confirmWarning")}` | 无确认步骤直接发布 |
|
||
|
||
### Tiptap Image 扩展集成
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 安装依赖 | `npm install @tiptap/extension-image` | 自建图片上传组件 |
|
||
| 配置 inline:false | `Image.configure({ inline: false, allowBase64: false })` | `allowBase64: true`(XSS 风险) |
|
||
| 插入图片命令 | `editor.commands.setImage({ src, alt })` | 手动拼接 HTML `<img>` |
|
||
| HTMLAttributes 类名 | `class: "rich-text-image max-w-full h-auto rounded"` | 内联 style |
|
||
|
||
### 附件表 Date → ISO string 转换
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| DB 层 Date 转接口 string | `createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : a.createdAt` | 直接 `createdAt: a.createdAt`(类型不匹配) |
|
||
| data-access 与 service 接口分离 | data-access 返回 `LessonPlanAttachment`(Date),service 转换为 `LessonPlanAttachmentOption`(string) | data-access 直接返回 string(破坏类型一致性) |
|
||
|
||
### 打印视图 print: 媒体查询
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 隐藏工具栏 | `<div className="... print:hidden">` | 工具栏也打印出来 |
|
||
| 内容区全屏 | `print:static print:w-full print:max-h-none print:rounded-none` | 保持 modal 样式 |
|
||
| 防止跨页 | `break-inside-avoid` | 段落被切断 |
|
||
| 页脚固定 | `print:fixed print:bottom-2` | 页脚丢失 |
|
||
|
||
### 素材库 picker 复用现有基础设施
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 复用 use-file-upload hook | `const { inputRef, handleFiles, tasks } = useFileUpload(...)` | 自建 FormData + XMLHttpRequest |
|
||
| 通过 service 调用 | `service.createLessonPlanAttachment(...)` | 组件直接 import createLessonPlanAttachmentAction |
|
||
| LessonPlanDataService 接口扩展 | 在接口声明 + default-data-service 实现 | 组件直接 import actions(破坏解耦) |
|
||
| FileTargetType 扩展 | `files/types.ts` 新增 `'lesson_plan'` 类型 | 字符串硬编码 `"lesson_plan"` |
|
||
|
||
### V5 第二阶段 V5-6~V5-21 通用规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| Server Action form action 适配 | `duplicateLessonPlanFormAction(formData: FormData)` 内部调用 `duplicateLessonPlanAction(planId)` + `redirect()` | 直接把 `duplicateLessonPlanAction(planId: string)` 传给 `<form action>`(签名不匹配) |
|
||
| 按需加载知识点避免首屏负担 | 对话框 Tab 内 `useEffect` 调用 `getKnowledgePointsForAlignmentAction(textbookId)` | 编辑页直接 `Promise.all` 拉取整教材知识点(即使不打开 Tab) |
|
||
| 复用已安装依赖 | V5-8 直接用 `@dagrejs/dagre`(已在 package.json) | 重新装 dagre 或自实现 DAG 布局 |
|
||
| 轻量级可视化不引入新库 | V5-14 板书预览纯 CSS + 文本解析(缩进识别层级) | 引入 mermaid/markmap 库做板书预览 |
|
||
| AI 模块纯服务端函数 | `import "server-only"` + `createAiChatCompletion` + Zod 验证 + JSON 提取 | AI 函数混在 actions 里,没有 server-only 标记 |
|
||
| AI 不可用降级返回空结果 | `if (!text.trim()) return [];` `try { ... } catch { return []; }` | AI 失败抛错导致整个对话框崩溃 |
|
||
| 版本对比纯函数 | `diffDocuments(oldDoc, newDoc)` 输出 added/removed/modified/unchanged | 在组件内直接对比(无法复用 + 难测试) |
|
||
| 一致性校验纯函数 | `checkConsistency(doc)` 输出 ConsistencyResult | 在对话框 useEffect 内嵌套 if/else 校验逻辑 |
|
||
| 课标覆盖热力图纯函数 | `computeCurriculumCoverage(allKps, planLinks)` | 在组件内嵌套计算逻辑 |
|
||
| useMediaQuery 移动端切换 | `const isMobile = useMediaQuery("(max-width: 768px)")` 在 readonly-view | 服务端 `useState` 默认值与客户端不一致(hydration mismatch) |
|
||
| 编辑器工具栏按钮分组 | 一致性校验/AI 反馈/AI 差异化 三个并列 Button + 各自 state + ErrorBoundary 包裹 | 三个对话框共用一个 state(无法独立打开) |
|
||
| FocusTrap + ESC 关闭对话框 | `useEffect` 监听 `Escape` 键 + FocusTrap 包裹 | 仅点击 X 关闭(键盘用户无法关闭) |
|
||
| 类型扩展不破坏旧数据 | `Block` 接口新增 `stage?` / `differentiation?` 可选字段 | 必填字段(旧数据迁移失败) |
|
||
| KnowledgePoint 类型导入路径 | `import type { KnowledgePoint } from "@/modules/textbooks/types"` | `from "@/modules/textbooks/data-access"`(仅 types 文件导出) |
|
||
| chapterId null 处理 | `chapterId: kp.chapterId ?? null` | 直接 `kp.chapterId`(`string \| undefined` 不能赋给 `string \| null`) |
|
||
|
||
|
||
---
|
||
|
||
## 九、问题记录规则
|
||
|
||
| 规则 | 要求 |
|
||
|------|------|
|
||
| 构建报错修复后 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||
| 运行时异常修复后 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||
| 框架/库版本兼容问题 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||
| 依赖配置问题 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||
| 架构约束违规 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||
| 记录格式 | 标题 → 错误现象 → 根因 → 解决方案 → 验证方法 → 受影响文件 |
|
||
| 去重 | 同类问题在原条目补充,不重复创建 |
|
||
|
||
---
|
||
|
||
## 十三、Drizzle 子查询字段 alias 规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 子查询中 raw SQL 字段必须声明 alias | `count().as("errorCount")` | `count()` |
|
||
| `sql\`...\`` 字段也需 alias | `sql<number>\`sum(...)\`.as("masteredCount")` | `sql<number>\`sum(...)\`` |
|
||
| 普通列引用可不加 alias | `questionId: errorBookItems.questionId` | — |
|
||
| 外层引用子查询字段用 `.field` | `aggregatedSubquery.errorCount` | — |
|
||
|
||
### 错误现象
|
||
|
||
```
|
||
You tried to reference "errorCount" field from a subquery, which is a raw SQL field,
|
||
but it doesn't have an alias declared. Please add an alias to the field using ".as('alias')" method.
|
||
```
|
||
|
||
### 正确示例
|
||
|
||
```typescript
|
||
const aggregatedSubquery = db
|
||
.select({
|
||
questionId: errorBookItems.questionId,
|
||
errorCount: count().as("errorCount"),
|
||
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`.as("masteredCount"),
|
||
})
|
||
.from(errorBookItems)
|
||
.where(whereClause)
|
||
.groupBy(errorBookItems.questionId)
|
||
.as("aggregated")
|
||
```
|
||
|
||
---
|
||
|
||
## 十四、Server/Client Component 函数传递规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| Server Component 不传含函数的对象给 Client Component | `<Provider>`(不传 monitor prop) | `<Provider monitor={noopMonitor}>`(含 track 函数) |
|
||
| Client Component prop 含函数时改为可选 | `monitor?: DiagnosticMonitor` | `monitor: DiagnosticMonitor`(必填) |
|
||
| 函数对象在 Client 端组装 | `useMemo(() => createService(monitor), [monitor])` | Server 端 `createService()` 后传给 Client |
|
||
| Server Component 仅传纯数据 | `reports={reports.reports}`(纯数据数组) | `service={createMonitoredService()}`(含函数) |
|
||
|
||
### 错误现象
|
||
|
||
```
|
||
Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
|
||
Attempted to call createMonitoredDiagnosticService() from the server but createMonitoredDiagnosticService is on the client.
|
||
```
|
||
|
||
### 修复模式
|
||
|
||
```typescript
|
||
// Provider (client component) - prop 改为可选,未传时用默认值
|
||
interface ProviderProps {
|
||
monitor?: DiagnosticMonitor // 可选,Server Component 不传
|
||
children: ReactNode
|
||
}
|
||
export function Provider({ monitor, children }: ProviderProps) {
|
||
const value = monitor ?? noopDiagnosticMonitor
|
||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||
}
|
||
|
||
// Server Component - 不传含函数的 prop
|
||
<DiagnosticMonitorProvider>
|
||
<DiagnosticServiceProvider>
|
||
<ReportList reports={reports.reports} />
|
||
</DiagnosticServiceProvider>
|
||
</DiagnosticMonitorProvider>
|
||
```
|
||
|
||
---
|
||
|
||
## 十五、Playwright 测试检测规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 不检查 content 中的 i18n JSON 文本 | `"发生未知错误" in content`(会匹配翻译 JSON) | — |
|
||
| 检测错误用 HTTP 状态码 | `response.status != 200` | 仅检查页面文本 |
|
||
| 检测可见错误用 locator + is_visible | `page.locator('text="错误").is_visible()` | `"错误" in content`(匹配隐藏 JSON) |
|
||
| pageerror 回调不能累积注册 | 循环外注册一次,循环内 `errors.clear()` | 每次循环 `page.on("pageerror", ...)` |
|
||
|
||
### 错误现象
|
||
|
||
i18n 翻译 JSON 内联在页面 HTML 中(如 `"errors":{"unexpected":"发生未知错误"}`),`"发生未知错误" in content` 会误判为错误边界触发。
|
||
|
||
### 正确检测方式
|
||
|
||
```python
|
||
# 1. HTTP 状态码
|
||
response = page.goto(url, ...)
|
||
http_status = response.status # 属性,不是方法
|
||
|
||
# 2. pageerror(循环外注册一次)
|
||
all_errors = []
|
||
page.on("pageerror", lambda err: all_errors.append(str(err)))
|
||
for module in modules:
|
||
all_errors.clear()
|
||
# ...
|
||
|
||
# 3. 可见错误元素(不检查 HTML content)
|
||
loc = page.locator('text="发生未知错误"').first
|
||
if loc.count() > 0 and loc.is_visible():
|
||
# 真正的错误边界
|
||
```
|
||
|
||
---
|
||
|
||
## 十六、next-auth useSession SSR Hydration Mismatch 规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| Root Layout SSR 期间预取 session | `const session = await auth()` 后传给 `<AuthSessionProvider session={session}>` | 依赖 client 端 `useSession()` 异步获取 |
|
||
| SessionProvider 接收 SSR session prop | `<SessionProvider session={session} refetchOnWindowFocus={false} refetchInterval={0}>` | 不传 session,client 端首次渲染为 `status:"loading"` |
|
||
| Hydration mismatch 修复不用 `mounted` workaround | root layout 提供 SSR session 后直接用 `useSession()` | `const [mounted] = useState(false); useEffect(()=>setMounted(true),[]); if(!mounted) return null` |
|
||
| 关闭 SessionProvider 自动 refetch | `refetchOnWindowFocus={false} refetchInterval={0}` | 默认 refetch 触发 SSR/CSR 不一致 |
|
||
|
||
### 错误现象
|
||
|
||
```
|
||
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||
+ id="radix-_R_19ebn6lb_"
|
||
- id="radix-_R_55qbn6lb_"
|
||
```
|
||
|
||
### 根因
|
||
|
||
`useSession()` 在 SSR 期间返回 `status:"loading"`,CSR hydration 后变成 `"authenticated"`,导致 `SiteHeader` 中 `displayName`/`avatarFallback` 渲染结果不同,触发 Radix UI 组件重新生成 `id` 属性。
|
||
|
||
### 修复模式
|
||
|
||
```typescript
|
||
// src/app/layout.tsx (Server Component)
|
||
import { auth } from "@/auth"
|
||
|
||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||
const session = await auth() // SSR 预取
|
||
return (
|
||
<AuthSessionProvider session={session}>
|
||
{children}
|
||
</AuthSessionProvider>
|
||
)
|
||
}
|
||
|
||
// src/shared/components/auth-session-provider.tsx ("use client")
|
||
export function AuthSessionProvider({ children, session }: { children: React.ReactNode; session?: Session | null }) {
|
||
return (
|
||
<SessionProvider session={session} refetchOnWindowFocus={false} refetchInterval={0}>
|
||
{children}
|
||
</SessionProvider>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 十七、useState 初始值 SSR/CSR 一致性规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 初始值禁止 `typeof window` 分支 | `useState("unsupported")` 固定值 | `useState(typeof window !== "undefined" ? Notification.permission : "unsupported")` |
|
||
| 浏览器 API 检测放 `useEffect` | `useEffect(() => { if ("Notification" in window) setPermission(Notification.permission) }, [])` | `useState` 初始值中读取 `window.Notification` |
|
||
| 初始值禁止 `Math.random`/`Date.now()` | `useState(0)`,副作用在 `useEffect` 中更新 | `useState(Date.now())` |
|
||
| 初始值禁止读取 localStorage | `useState(null)`,`useEffect` 中读 localStorage | `useState(localStorage.getItem("key"))` |
|
||
|
||
### 错误现象
|
||
|
||
```
|
||
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||
Hydration failed because the initial UI does not match what was rendered on the server.
|
||
```
|
||
|
||
### 修复模式
|
||
|
||
```typescript
|
||
// 错误:SSR 返回 "unsupported",CSR 返回 "default"/"granted"
|
||
const [permission, setPermission] = useState(
|
||
typeof window !== "undefined" && "Notification" in window
|
||
? Notification.permission
|
||
: "unsupported"
|
||
)
|
||
|
||
// 正确:初始固定值 + useEffect 检测
|
||
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("unsupported")
|
||
useEffect(() => {
|
||
if (typeof window === "undefined" || !("Notification" in window)) return
|
||
setPermission(Notification.permission)
|
||
}, [])
|
||
```
|
||
|
||
---
|
||
|
||
## 十八、SectionErrorBoundary i18n Keys 完整性规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 使用 `SectionErrorBoundary namespace="X"` 时必须补全 3 个 key | `{namespace}.json` 中 `error.boundaryTitle` / `error.boundaryDescription` / `error.retry` 全部存在 | 仅添加 `error.retry` |
|
||
| 新增 namespace 用法时同步检查 i18n 文件 | `<SectionErrorBoundary namespace="notifications">` → 检查 `notifications.json` 是否有 `error.boundaryTitle/Description` | 仅检查 `error.retry` |
|
||
| 所有语言文件同步补全 | `zh-CN/X.json` 和 `en/X.json` 都要加 | 只加中文 |
|
||
| 缺 key 会触发 hydration 警告 | `MISSING_MESSAGE` 错误在 SSR 期间抛出,client 渲染回退,触发 hydration mismatch | — |
|
||
|
||
### 错误现象
|
||
|
||
```
|
||
[error] IntlError: MISSING_MESSAGE: Could not resolve `notifications.error.boundaryTitle` in messages for locale `zh-CN`.
|
||
[error] IntlError: MISSING_MESSAGE: Could not resolve `notifications.error.boundaryDescription` in messages for locale `zh-CN`.
|
||
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||
```
|
||
|
||
### 根因
|
||
|
||
`SectionErrorBoundary` 内部调用 `t("error.boundaryTitle")` 和 `t("error.boundaryDescription")`,若 namespace JSON 缺少这些 key,next-intl 在 SSR 期间抛 `IntlError`,导致 Server Component 渲染失败,React 回退到 client 渲染,触发 hydration mismatch。
|
||
|
||
### 修复模式
|
||
|
||
```json
|
||
// src/shared/i18n/messages/zh-CN/notifications.json
|
||
{
|
||
"error": {
|
||
"loadFailed": "通知加载失败",
|
||
"loadFailedDesc": "...",
|
||
"boundaryTitle": "通知区块加载失败",
|
||
"boundaryDescription": "加载通知数据时发生错误,请重试。",
|
||
"retry": "重试"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 十九、Server Component 传递纯数据替代函数规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 分页组件接收 `basePath` + `statusFilter` 纯数据 | `<Pagination basePath="/announcements" statusFilter="published" />` | `<Pagination buildPageHref={(p) => \`/announcements?page=${p}\`} />` |
|
||
| Client Component 内部构建 URL | `const buildHref = (p) => { const params = new URLSearchParams(); if (statusFilter) params.set("status", statusFilter); ... }` | Server 端定义函数传给 client |
|
||
| Server Component 仅传可序列化数据 | `pagination={{ page, pageSize, total, basePath, statusFilter }}` | `pagination={{ page, pageSize, total, buildPageHref: fn }}` |
|
||
|
||
### 错误现象
|
||
|
||
```
|
||
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
|
||
{page: 1, pageSize: 12, total: 2, buildPageHref: function buildPageHref}
|
||
```
|
||
|
||
### 修复模式
|
||
|
||
```typescript
|
||
// Server Component (announcements/page.tsx)
|
||
const statusFilter = sp.status === "published" ? sp.status : "all"
|
||
<AnnouncementList
|
||
pagination={{
|
||
page: currentPage,
|
||
pageSize,
|
||
total,
|
||
basePath: "/announcements", // 纯数据
|
||
statusFilter, // 纯数据
|
||
}}
|
||
/>
|
||
|
||
// Client Component (announcement-pagination.tsx)
|
||
export function AnnouncementPagination({ page, pageSize, total, basePath, statusFilter }: Props) {
|
||
const buildHref = (targetPage: number): string => {
|
||
const params = new URLSearchParams()
|
||
if (statusFilter && statusFilter !== "all") params.set("status", statusFilter)
|
||
if (targetPage > 1) params.set("page", String(targetPage))
|
||
const qs = params.toString()
|
||
return qs ? `${basePath}?${qs}` : basePath
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 二十、i18n Namespace 与文件名一致性规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| `useTranslations` namespace 必须与 `i18n/request.ts` 配置一致 | `useTranslations("errorBook")`(驼峰,与 `request.ts` 的 `errorBook: errorBook.default` 一致) | `useTranslations("error-book")`(连字符,与文件名 `error-book.json` 混淆) |
|
||
| namespace 跟 `request.ts` 的 messages key,不跟文件名 | 文件名 `error-book.json` → messages key `errorBook` → `useTranslations("errorBook")` | 文件名 `error-book.json` → `useTranslations("error-book")` |
|
||
| 批量检查 namespace 一致性 | `grep -r "useTranslations(\"error-book\")"` 应返回 0 结果 | — |
|
||
| 新增 namespace 时同步更新 `request.ts` | `request.ts` 新增 `import` + `messages` key | 只创建 JSON 文件不注册 |
|
||
|
||
### 错误现象
|
||
|
||
```
|
||
IntlError: MISSING_MESSAGE: Could not resolve `error-book` in messages for locale `zh-CN`.
|
||
```
|
||
|
||
### 根因
|
||
|
||
`i18n/request.ts` 中配置的 messages key 是 `errorBook`(驼峰),但 17 个组件错误使用了 `useTranslations("error-book")`(连字符,与文件名 `error-book.json` 混淆)。next-intl 找不到 `error-book` namespace,所有 `t()` 调用抛出 `MISSING_MESSAGE` 错误。
|
||
|
||
### 修复模式
|
||
|
||
```typescript
|
||
// i18n/request.ts 配置
|
||
errorBook: errorBook.default, // key 是 "errorBook"
|
||
|
||
// 组件中正确使用
|
||
const t = useTranslations("errorBook") // ✅ 驼峰,与配置一致
|
||
|
||
// 组件中错误使用
|
||
const t = useTranslations("error-book") // ❌ 连字符,与文件名混淆
|
||
```
|
||
|
||
---
|
||
|
||
## 二十一、useSearchParams() 导致 Hydration Mismatch 规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| Client Component 中避免 `useSearchParams()` | `window.location.search` 在 `handleSelect` 点击时读取 | `useSearchParams()` 在组件顶层读取 |
|
||
| `useSearchParams()` 需要 `<Suspense>` 包裹且会导致 SSR fallback | 仅在需要 SSR 流式渲染的场景使用 | 在普通交互组件中直接使用 |
|
||
| URL 参数在点击时构建 | `const params = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); params.set("subject", id); router.push(\`?${params}\`)` | `const searchParams = useSearchParams(); const params = new URLSearchParams(searchParams.toString())` |
|
||
| `useSearchParams()` 导致组件树 SSR/CSR 不同 | SSR 显示 Suspense fallback,CSR 显示真实内容,`useId()` 生成不同 id | — |
|
||
|
||
### 错误现象
|
||
|
||
```
|
||
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||
+ aria-controls="radix-_R_3jabn6lb_"
|
||
- aria-controls="radix-_R_edabn6lb_"
|
||
|
||
+ data-chart="chart-_R_4matpesndubn6lb_"
|
||
- data-chart="chart-_R_ipbn5ritnqbn6lb_"
|
||
```
|
||
|
||
### 根因
|
||
|
||
`useSearchParams()` 在 SSR 期间会导致包裹它的 `<Suspense>` 显示 fallback(Skeleton),而 CSR 期间直接显示真实内容。组件树结构在 SSR/CSR 间不同,导致 React `useId()` 生成的 id 不匹配,触发全局 hydration mismatch(影响页面所有 Radix UI 和 ChartContainer 组件的 id)。
|
||
|
||
### 修复模式
|
||
|
||
```typescript
|
||
// 错误:useSearchParams() 导致 SSR/CSR 组件树不同
|
||
export function SubjectTabs({ subjects, currentSubjectId }: Props) {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams() // ❌ SSR 显示 fallback
|
||
|
||
const handleSelect = (subjectId: string | null) => {
|
||
const params = new URLSearchParams(searchParams.toString())
|
||
// ...
|
||
}
|
||
}
|
||
|
||
// 正确:在点击时用 window.location.search 读取当前 URL 参数
|
||
export function SubjectTabs({ subjects, currentSubjectId }: Props) {
|
||
const router = useRouter()
|
||
|
||
const handleSelect = (subjectId: string | null) => {
|
||
// 仅在 client 端点击时执行,不影响 SSR
|
||
const params = new URLSearchParams(
|
||
typeof window !== "undefined" ? window.location.search : ""
|
||
)
|
||
if (subjectId === null) {
|
||
params.delete("subject")
|
||
} else {
|
||
params.set("subject", subjectId)
|
||
}
|
||
router.push(`?${params.toString()}`, { scroll: false })
|
||
}
|
||
}
|
||
```
|
||
|
||
## 二十二、React StrictMode 与 Server Action Hook 规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| useEffect 中调用 Server Action 时禁止用 ref 防重复 | 仅用 useEffect 依赖数组控制执行频率 | `const lastKey = useRef(""); if (lastKey.current === key) return; lastKey.current = key;` |
|
||
| StrictMode 下 cleanup 会将第一次 action 结果标记为 cancelled | `let cancelled = false; action().then(r => { if (cancelled) return; ... }); return () => { cancelled = true }` 接受 StrictMode 下 action 执行两次 | 用 ref 阻止第二次 effect 执行 action,导致第一次结果被 cancelled 丢弃,永久 loading |
|
||
| 客户端 dynamic import 第三方 Canvas/Window 依赖必须用 `import type` | `import type ForceGraph2DType from "react-force-graph-2d"` + `dynamic(...)` | `import ForceGraph2D from "react-force-graph-2d"` 值导入会触发顶层副作用在 SSR 执行 |
|
||
|
||
**问题现象**:知识点图谱页面一直显示"正在加载知识点",但 Server Action 实际已返回 `success: true` 和数据。
|
||
|
||
**根因 1**:Next.js 默认启用 `reactStrictMode: true`,dev 模式下 useEffect 执行两次(mount → unmount → mount)。若 Hook 内用 `lastRequestKey` ref 防重复,第一次 mount 发起 action 后 cleanup 设 `cancelled=true`,第二次 mount 因 key 相同跳过不发起 action,第一次 action 结果被 cancelled 忽略,state 永不更新。
|
||
|
||
**根因 2**:`next/dynamic` 加载的第三方库若在顶层用值导入(`import X from "lib"`),即使 `dynamic({ ssr: false })`,模块顶层代码仍会在 SSR 求值时执行,导致 canvas/window 访问报错,dynamic loading 永久卡住。必须用 `import type` 引入类型。
|
||
|