# 项目规则速查手册

> 一页式规则清单，指明正确做法。无需解释原因，遇到问题查表即可。

---

## 一、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` 引入类型。

