1683 lines
119 KiB
Markdown
1683 lines
119 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"` |
|
||
| Tailwind v4 排除非源码目录防止误扫描 | `@source not "../../docs"; @source not "../../scripts"; @source not "../../tests";` 置于 `globals.css` | 不配置(docs 中的 `[length:var(--space-N)]` 字符串被扫描成类,生成 `length: var(--*)` 致 CSS 解析失败 `Unexpected token Delim('*')`) |
|
||
|
||
---
|
||
|
||
## 六、架构分层规则
|
||
|
||
| 层级 | 允许依赖 | 禁止依赖 |
|
||
|------|---------|---------|
|
||
| `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
|
||
}
|
||
// ...
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 备课模块审核问题(lesson-preparation audit)
|
||
|
||
### Server Action 权限常量引用规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 权限校验必须引用 `Permissions` 常量 | `await requirePermission(Permissions.LESSON_PLAN_READ)` | `await requirePermission("lesson_plan:read")` |
|
||
| Server Action 返回值统一用 `ActionState<T>` | `Promise<ActionState<null>>` / `Promise<ActionState<{ planId: string }>>` | `Promise<ActionState>` |
|
||
|
||
涉及文件:`actions-analytics.ts:38,61,76,91`、`actions.ts:142,248,263,282`
|
||
|
||
### TypeScript `as` 断言规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 字面量收窄用类型守卫 | `isLessonPlanStatus(v) ? v : "draft"` | `"published" as LessonPlanStatus` |
|
||
| DB JSON 字段转换用类型守卫 | `isLessonPlanDocument(content) ? content : null` | `content as unknown as LessonPlanDocument` |
|
||
| select onChange 值用类型守卫 | `isTeachingStage(v) && updateNode(id, { stage: v })` | `updateNode(id, { stage: v as TeachingStage })` |
|
||
| 判别联合字段提取用类型守卫 | `isInteractionBlockData(node.data) && ...` | `node.data as InteractionBlockData` |
|
||
| AI patch 合并到节点 data 用注册表守卫 | `mergeBlockDataPatch(node, patch)`(内部用 `BLOCK_DATA_GUARDS: Record<BlockType, BlockDataGuard>` 校验,失败回退原 data) | `{ ...node.data, ...patch } as BlockData` |
|
||
| DB enum 字段安全窄化用 `toXxx` 辅助函数 | `toLessonPlanStatus(r.status)` / `toMessageReportReason(r.reason)`(守卫失败返回 fallback 默认值) | `r.status as LessonPlanStatus` / `r.reason as MessageReportReason` |
|
||
| 类型守卫参数类型放宽到 `unknown` | `function isRichTextBlockData(data: unknown): data is RichTextBlockData`(兼容 `BlockDataGuard = (data: unknown) => data is BlockData` 注册表签名) | `function isRichTextBlockData(data: BlockData): data is RichTextBlockData`(无法赋值给 `BlockDataGuard`,触发逆变错误) |
|
||
| AI 输出 patch 类型用 `Record<string, unknown>` | `interface NodeContentUpdate { data: Record<string, unknown> }`(诚实反映 Zod `z.record(z.string(), z.unknown())` 校验后的 untrusted 输出,强制调用方走 `mergeBlockDataPatch`) | `interface NodeContentUpdate { data: Partial<BlockData> }`(伪装成可信类型,调用方直接 `as BlockData` 断言) |
|
||
| switch 分发用类型守卫而非 `as` | `case "objective": return isObjectiveBlockData(data) ? flattenObjective(data, t) : []` | `case "objective": return flattenObjective(data as ObjectiveBlockData, t)` |
|
||
| TextbookContentNode 分支用 `unknown` 中间变量 | `const merged: unknown = { ...n, ...patch }; return merged as TextbookContentNode`(结构类型不兼容 Block.data 联合,从 unknown 收窄需 `as`) | `return { ...n, ...patch } as unknown as TextbookContentNode`(双重断言) |
|
||
| React Flow `node.data` 收窄用类型守卫 | `const nodeData = isGraphLayoutNodeData(node.data) ? node.data : undefined` | `node.data as GraphLayoutNodeData` |
|
||
| React Flow `EdgeProps.data` 双重断言用类型守卫 | `const edgeData = isGraphEdgeData(data) ? data : undefined` | `data as unknown as GraphEdgeData | undefined` |
|
||
| react-force-graph-2d `NodeObject` 收窄用类型守卫 | `if (!isKpGraphNode(node)) return; const kpNode = node` | `const kpNode = node as KpGraphNode` |
|
||
| react-force-graph-2d `LinkObject` 收窄用类型守卫 | `if (!isKpGraphLink(link)) return; const kpLink = link` | `const kpLink = link as KpGraphLink` |
|
||
| 力导向边 source/target 字符串排除后守卫 | `if (typeof source === "string") return; if (!isKpGraphNode(source)) return` | `kpLink.source as KpGraphNode` |
|
||
| next/dynamic 第三方库类型限制保留 `as` | `as unknown as ComponentType<React.ComponentProps<typeof ForceGraph2DType>>`(next/dynamic 返回 `ComponentType` 与库默认导出类型不兼容,需 `as unknown as` 保留完整 props 类型) | 无(库类型限制无法用守卫替代) |
|
||
| DB JSON `unknown` 字段收窄用 `isRecord` | `if (!isRecord(value)) return value; const record = value` | `const record = value as Record<string, unknown>` |
|
||
| Tiptap `JSONContent` 收窄用守卫 | `isJSONContent(v) ? v : fallback` / `isJSONContentArray(arr) ? arr : []` | `slice.content.toJSON() as JSONContent[]` / `node as JSONContent` |
|
||
| dnd-kit `UniqueIdentifier` 转 string 用 `toSortableId` | `toSortableId(event.active.id)` / `toSortableId(over.id)` | `event.active.id as string` / `over.id as string` |
|
||
| Tiptap NodeView `node.attrs` 收窄用守卫 | `const attrs = isQuestionBlockAttrs(node.attrs) ? node.attrs : { questionId: "", type: "single_choice", score: 0 }` | `node.attrs as QuestionBlockAttrs` |
|
||
| AI 接口返回 `doc` 字段守卫失败显式报错 | `if (!isJSONContent(result.data.doc)) { toast.error(t("...")); return }` | `result.data.doc as EditorJSONContent` |
|
||
| next-intl `t()` params 类型放宽 | `type TranslationFn = (key: string, params?: Record<string, string \| number \| Date>) => string` | `t(key, params as Record<string, string \| number \| Date> \| undefined)` |
|
||
| react-hook-form + zodResolver 协变差异保留 `as unknown as` | `zodResolver(formSchema) as unknown as Resolver<ExamFormValues>`(库协变差异需 `as unknown as`,注释说明原因) | `zodResolver(formSchema) as Resolver<ExamFormValues>`(直接 `as` 违规) |
|
||
| 复杂 RichQuestionContent 结构守卫失败用 isRecord 兜底 | `if (!isRecord(parsed)) return fallback; const content = parsed as EditorQuestion["content"]`(结构校验由下游 Zod schema 保证,注释说明) | `parsed as EditorQuestion["content"]`(直接断言无守卫) |
|
||
|
||
### TypeScript 箭头函数语法规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 箭头函数带返回类型必须有 `=>` | `export const fn = async (x: string): Promise<void> => { ... }` | `export const fn = async (x: string): Promise<void> { ... }`(TS1005: '=>' expected) |
|
||
| `const fn = async ()` 与 `async function fn()` 区别 | `const fn = async (): Promise<T> => {` 用箭头;`async function fn(): Promise<T> {` 用声明 | 混用:`const fn = async (): Promise<T> {`(缺 `=>`) |
|
||
|
||
> 2026-07-07 修复:`textbooks/data-access.ts` 的 `getKnowledgePointsByTextbookIdRaw` 缺失 `=>`(pre-existing bug,tsc 报 TS1005),补全后通过。
|
||
|
||
严重违规双重断言:
|
||
- `structure-tree.tsx:72` `{ ...textbookNode, type: "textbook_content" } as unknown as Block`
|
||
- `version-diff-viewer.tsx:38` `selectedVersion.content as unknown as LessonPlanDocument`
|
||
|
||
**类型守卫专项重构(2026-07-04)涉及文件**:
|
||
- `lesson-preparation/lib/type-guards.ts` — 11 个 `isXxxBlockData` 守卫参数放宽到 `unknown`,新增 `mergeBlockDataPatch` + `BLOCK_DATA_GUARDS` 注册表
|
||
- `lesson-preparation/lib/export.ts` — 14 处 `as` 替换为 `isXxxBlockData` 守卫
|
||
- `lesson-preparation/data-access-calendar.ts` — 新增 `toLessonPlanStatus`,移除 6 处 `as LessonPlanStatus`
|
||
- `lesson-preparation/data-access-review.ts` — 移除 5 处 `as`(含 `as LessonPlanStatus` / `as ReviewDecision`)
|
||
- `lesson-preparation/hooks/editor-slice.ts` — `updateNode` 显式标注 `AnyLessonPlanNode` 返回类型 + `unknown` 中间变量
|
||
- `lesson-preparation/hooks/use-node-ai-assist.ts` — 4 处 `as BlockData` 替换为 `mergeBlockDataPatch`
|
||
- `lesson-preparation/lib/ai-node-assist.ts` — `NodeContentUpdate.data` 改为 `Record<string, unknown>`,2 处 `as Partial<BlockData>` 移除
|
||
- `messaging/lib/type-guards.ts`(新建)— `isRecipientRole` / `isMessageReportReason` / `isMessageReportStatus` + `toMessageReportReason` / `toMessageReportStatus`
|
||
- `messaging/data-access.ts` — 5 处字面量 `as RecipientRole` 移除 + 2 处 `mapMessageReport` 改用 `toXxx` 辅助
|
||
- `messaging/components/message-report-block.tsx` — `setReason(v as MessageReportReason)` 替换为 `if (isMessageReportReason(v)) setReason(v)`
|
||
|
||
**教材模块类型守卫修复(2026-07-07)涉及文件**:
|
||
- `textbooks/lib/type-guards.ts`(新建)— `isKpWithRelations` / `isGraphNodeData` / `isGraphLayoutNodeData` / `isGraphEdgeData` / `isKpGraphNode` / `isKpGraphLink` 6 个守卫
|
||
- `textbooks/types.ts` — `KpGraphNode` / `KpGraphLink` 接口从 force-graph.tsx 迁移至此(供守卫模块引用,避免组件文件被守卫模块反向依赖)
|
||
- `textbooks/components/graph-kp-node.tsx` — 2 处 `as`(`data.kp as KpWithRelations`、`data.graphData as GraphNodeData | undefined`)改用守卫;`extractNodeData` 返回 `| null`,校验失败组件渲染 null
|
||
- `textbooks/components/graph-prerequisite-edge.tsx` — `data as unknown as GraphEdgeData | undefined` 双重断言改用 `isGraphEdgeData` 守卫
|
||
- `textbooks/components/knowledge-graph-node.tsx` — `node.data as GraphLayoutNodeData` 改用 `isGraphLayoutNodeData` 守卫
|
||
- `textbooks/components/force-graph.tsx` — 6 处 `as`(`node as KpGraphNode` ×3、`link as KpGraphLink`、`kpLink.source as KpGraphNode`、`kpLink.target as KpGraphNode`)改用 `isKpGraphNode`/`isKpGraphLink` 守卫;保留 Line 80 `as unknown as ComponentType`(next/dynamic 第三方库类型限制)
|
||
|
||
**exams 模块类型守卫修复(2026-07-07)涉及文件**:
|
||
- `exams/lib/type-guards.ts`(新建)— `isRecord` / `isSimpleTextContent` / `isQuestionContentObj` / `isJSONContent` / `isJSONContentArray` / `toSortableId` / `isQuestionBlockAttrs` 共 7 个守卫/转换函数;`isJSONContent` 返回 `v is JSONContent`(依赖 `@tiptap/react` 类型)
|
||
- `exams/ai-pipeline/request.ts` — 2 处 `as Record<string, unknown>` 改用 `isRecord` 守卫;`as const` 字面量断言保留
|
||
- `exams/components/exam-preview-utils.ts` — `isPreviewBackgroundTask` 内 `as` 改用 `isRecord`
|
||
- `exams/components/exam-actions.tsx` — `isRawStructureNode` 守卫用 `isRecord` 替代 `typeof === "object"`
|
||
- `exams/components/assembly/structure-editor.tsx` — 9 处 `as` 修复(`as string` ×4 改 `toSortableId`;`as QuestionContent` ×2 改 `isQuestionContentObj`;`as Record` 改 `isRecord`)
|
||
- `exams/components/assembly/question-bank-list.tsx` — `extractQuestionText` 用 `isRecord`;`parsedContent` 用 `isSimpleTextContent`
|
||
- `exams/components/assembly/exam-paper-preview.tsx` — `as QuestionContent` ×2 改用 `isQuestionContentObj` + 显式映射 ChoiceOption
|
||
- `exams/components/exam-columns.tsx` — `TranslationFn` 类型签名放宽 params 类型匹配 next-intl
|
||
- `exams/components/exam-data-table.tsx` — 移除 `t(key, params as ...)` 断言,类型由 TranslationFn 保证
|
||
- `exams/components/exam-form.tsx` — `zodResolver(formSchema) as Resolver` → `as unknown as Resolver`(合规:库协变差异,加注释)
|
||
- `exams/editor/selection-toolbar.tsx` — `as JSONContent[]` / `as JSONContent` 改用 `isJSONContentArray` + `.filter(isJSONContent)`
|
||
- `exams/editor/exam-nodes-to-editor-doc.ts` — `typeof raw === "object"` 改 `isRecord`;保留 `as EditorQuestion["content"]` 加注释(结构由下游 Zod 保证)
|
||
- `exams/editor/exam-rich-editor-inner.tsx` — 移除 `editor.getJSON() as EditorJSONContent` ×2;`node.attrs.type as QuestionBlockType` 改用 `isRichQuestionType` 守卫
|
||
- `exams/editor/extensions/question-block.tsx` — `node.attrs as QuestionBlockAttrs` 改用 `isQuestionBlockAttrs` 守卫,失败回退默认 attrs
|
||
- `exams/components/exam-rich-form.tsx` — `result.data.doc as EditorJSONContent` 改用 `isJSONContent` 守卫,失败 toast 报错并 return
|
||
|
||
### 非空断言 `!.` 规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 数组首元素先做存在性判断 | `const row = rows[0]; if (!row) return; row.resolved` | `rows[0]!.resolved` |
|
||
| Map.get 后做空值处理 | `const arr = chapterMap.get(chId); if (arr) arr.push(kp)` | `chapterMap.get(chId)!.push(kp)` |
|
||
|
||
涉及文件:`data-access-comments.ts:113`、`data-access-review.ts:51,83,217`、`data-access-substitutes.ts:131`、`lib/curriculum-coverage.ts:90`
|
||
|
||
### ESLint 零警告规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| effect 中读 localStorage 用 `useEffectEvent` 或加依赖 | `useEffect(() => { setRecentIds(readRecentTextbookIds()) }, [])` 改用 `useSyncExternalStore` 或初始化函数 | `useEffect(() => { setRecentIds(readRecentTextbookIds()) }, [])` 触发 set-state-in-effect |
|
||
| 挂载时从 localStorage 恢复 + 含表单副作用可豁免 | `// eslint-disable-next-line react-hooks/set-state-in-effect -- 挂载时恢复 localStorage` + `setXxx(data)` | 裸 `setXxx(data)` 触发 set-state-in-effect |
|
||
| 禁止 `eslint-disable-next-line`(仅限 exhaustive-deps) | 修正依赖数组或用 `useCallback` 包裹 | `// eslint-disable-next-line react-hooks/exhaustive-deps` |
|
||
| ref 禁止渲染期访问,改用 `useState` 惰性初始化 | `const [q] = useState(() => new PQueue({...}))` | `const r = useRef(null); if (!r.current) r.current = new PQueue(...)` |
|
||
| useEffect 依赖数组须含闭包引用变量 | `useEffect(() => { f(t) }, [t])` | `useEffect(() => { f(t) }, [])` 漏 `t` |
|
||
| 闭包捕获的 state 恒为初始值,条件判断无意义需移除 | `if (active && usingSSE) { fetch() }`(移除 `count === 0`) | `if (active && usingSSE && count === 0)` 在 `[]` effect 内恒真 |
|
||
|
||
涉及文件:`template-picker.tsx:72`(error)、`schedule-dialog.tsx:53`(warning)、`lesson-plan-editor.tsx:81`(disable)、`use-exam-preview-tasks.ts:28,36`(refs+setState)、`unread-message-badge.tsx:92`(闭包陷阱)、`practice-result-view.tsx:26`(unused)、`seed-grade5-chinese.ts`(unused imports)
|
||
|
||
### Tailwind 任意值规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 优先映射 Tailwind 默认阶梯 | `min-h-[40px]` → `min-h-10`、`min-w-[80px]` → `min-w-20` | 保留 `min-h-[40px]` 裸任意值 |
|
||
| 无法令牌化的固定尺寸加 `arbitrary-value:` 豁免注释 | `{/* arbitrary-value: dialog fixed width */}` 置于 JSX 元素上方 | 裸用 `w-[680px]` `min-h-[120px]` `text-[10px]` 无注释 |
|
||
| JSX 子元素上下文用 `{/* */}` 注释 | `<div>{/* arbitrary-value: ... */}\n<span .../>` | `//` 会渲染为文本 |
|
||
| `return (` / `&& (` 等 JS 表达式上下文用 `//` 注释 | `return (\n // arbitrary-value: ...\n <div/>)` | `{/* */}` 在 `()` 内触发语法错误 |
|
||
| Tiptap editorProps.attributes 等 JS 对象用 `//` 注释 | `attributes: {\n // arbitrary-value: tiptap editor fixed size\n class: "..."}` | `{/* */}` 在 JS 对象内非法 |
|
||
|
||
涉及 lesson-preparation 模块 23 文件(Task 11 已清理):Tier 1 替换 6 处(min-h-10/min-h-20/min-w-20),Tier 3 豁免 26 处(dialog 宽度/textarea min-h/badge text-[10px]/tiptap 等)
|
||
|
||
### i18n 翻译文件对称性规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| zh-CN 与 en 必须 key 完全对称 | 同步增删 key,CI 校验 key 差集 | 单边新增 key,导致 fallback |
|
||
| 缺失 key 必须补齐 | `analytics.publishedPlans` 等同步到 zh-CN | en 有 12 个 analytics.* key 但 zh-CN 缺失 |
|
||
|
||
涉及文件:`zh-CN/lesson-preparation.json`(缺 12 个 analytics.* key)、`en/lesson-preparation.json`(缺 `analytics.templateUsage`)
|
||
|
||
### i18n 硬编码中文规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 用户可见文本必须走 i18n | `t("v4.contextMenu.copySuffix")` | `${src.title}(副本)` 硬编码 |
|
||
| 导出/打印文本通过 i18n 注入 | `flattenObjective(data, t)` 接受翻译函数 | `dimensionLabel: { knowledge: "知识与技能" }` 硬编码 |
|
||
| 师生角色标签用 i18n | `t("v4.interaction.roleTeacher")` | `turn.role === "teacher" ? "师" : "生"` 硬编码 |
|
||
|
||
严重违规:
|
||
- [editor-slice.ts:181](file:///e:/Desktop/CICD/src/modules/lesson-preparation/hooks/editor-slice.ts#L181) `title: \`${src.title}(副本)\`` 硬编码"副本"
|
||
- [lib/export.ts:165-271](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L165) 5 个 label 映射表全硬编码中文(dimensionLabel/importMethodLabels/homeworkTypeLabels/blackboardLayoutLabels/reflectionAspectLabels)
|
||
- [lib/export.ts:246](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L246) `item.source === "inline" ? "课案内新建" : "题库"` 硬编码
|
||
- [lib/export.ts:271](file:///e:/Desktop/CICD/src/modules/lesson-preparation/lib/export.ts#L271) `turn.role === "teacher" ? "师" : "生"` 硬编码
|
||
|
||
### AI Prompt 中文常量规则(允许)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| AI prompt 提取为模块常量 | `const AI_SUGGEST_PROMPT_TEMPLATE = \`...\`` | 散落在函数体内 |
|
||
| AI prompt 不强制 i18n | 开发期调优的固定 prompt,可保持中文 | — |
|
||
|
||
合规文件:`ai-suggest.ts:31`、`lib/ai-differentiation.ts:80,92,103` 已提取为常量
|
||
|
||
---
|
||
|
||
## 二十、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` 引入类型。
|
||
|
||
---
|
||
|
||
## V4 备课编辑器(纸感重构)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 正文节点必须是 Tiptap 编辑器 | `TextbookTiptapEditor`(含 AnchorMark 扩展) | 用 `whitespace-pre-wrap` 显示 Markdown 字符串 |
|
||
| 锚点用 Tiptap Mark 内嵌 | `editor.chain().toggleMark("anchor", { anchorId, nodeId }).run()` | 用字符串偏移 `start/end` + `injectPlaceholders` |
|
||
| 节点展开位置由 order 决定 | `nodes.sort((a,b) => a.order - b.order)` | 按 anchor 位置插入 |
|
||
| 节点类型色点用 `--lp-dot-*` | `var(--lp-dot-objective)` | `var(--lesson-node-objective)`(已删除) |
|
||
| 文档版本必须是 4 | `doc.version === 4` + `expandedNodeIds` 字段 | `doc.version === 3` |
|
||
| 师生交互节点用 QaEditor | `<QaEditor nodeId data onUpdate />` | 自定义对话编辑器 |
|
||
| 右键菜单触发对象区分 | inline-node 右键 = 节点菜单;正文右键 = 锚定菜单 | 任何右键都触发同一菜单 |
|
||
| v3 锚点迁移失效提示 | `<AnchorMigrationBanner />`(按 planId localStorage) | toast 一闪即逝 |
|
||
|
||
|
||
### i18n 键缺失与中英文不同步
|
||
|
||
模块新增 t() 调用时,必须同步更新 zh-CN 和 en 两个语言文件,且键名必须完全一致(不能用 `week`/`weekView` 这种同义不同名的两套键)。
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 嵌套路径不存在时需创建子对象 | JSON 中 `"version": { "diff": { "title": "..." } }` 对应 `t("version.diff.title")` | JSON 中 `"version"` 和 `"diff"` 是平级节,却调用 `t("version.diff.title")` → MISSING_MESSAGE |
|
||
| Zod schema 错误键必须存在于 JSON `error` 节 | `z.string().min(1, "error.labelTooLong")` + JSON `error.labelTooLong` 存在 | schema 引用 `error.labelTooLong` 但 JSON 中无此键 → 校验失败时显示键路径 |
|
||
| zh-CN 与 en 键名必须完全一致 | zh-CN `calendar.weekView` + en `calendar.weekView` | zh-CN `calendar.weekView` + en `calendar.week` → en locale 下 MISSING_MESSAGE |
|
||
| 禁止 JSX 中硬编码中文 | `label={t("v4.detail.stageLabel")}` | `label="教学阶段"` → en 用户看到中文 |
|
||
| 未启用 useTranslations 的组件如需展示文本,必须先引入 | `import { useTranslations } from "next-intl"; const t = useTranslations("lessonPreparation");` | 组件内直接写中文字面量 |
|
||
|
||
### i18n dead 节清理规则
|
||
|
||
语言文件中未被任何 `t()` 调用引用的节(dead keys)应定期清理,避免 zh-CN 与 en 各持不同 schema 导致维护混乱。清理前必须全代码库搜索(含 `src/app/`),确认无引用。
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 删除 dead 节前必须验证全代码库无引用 | `grep -r "t(\"review." src/` 无结果后删除 | 直接删除节,导致隐藏引用方 MISSING_MESSAGE |
|
||
| zh-CN 和 en 键集必须完全一致 | 两文件均保留 `analytics` 节且键名相同 | zh-CN 有 `analytics.totalPublished`,en 只有 `analytics.publishedPlans` |
|
||
|
||
### i18n 动态键翻译守卫
|
||
|
||
Zod schema 中存储的 i18n 键(如 `"error.titleRequired"`)在服务端翻译时,必须先用 `t.has()` 守卫检查键是否存在,避免 schema 误写不存在的键时抛出 MISSING_MESSAGE。
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 动态键翻译前必须用 t.has() 守卫 | `if (t.has(msg)) { return t(msg); } return msg;` | `return t(msg);` — 键不存在时抛 MISSING_MESSAGE |
|
||
|
||
---
|
||
|
||
## 设计令牌替换规则(chart/graph 组件 #hex 清理)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| chart 颜色配置用 `--chart-1~5` 令牌 | `color: "hsl(var(--chart-1))"` | `color: "#e11d48"` |
|
||
| graph 节点色用 `--graph-node-1~6` 令牌 | `"hsl(var(--graph-node-1))"` | `"#3b82f6"` |
|
||
| 节点默认/未评估色用 `--muted-foreground` | `"hsl(var(--muted-foreground))"` | `"#6b7280"` / `"#94a3b8"` |
|
||
| 超过 6 色的调色板按顺序循环复用 graph-node-1~6 | `["hsl(var(--graph-node-1))", ..., "hsl(var(--graph-node-6))", "hsl(var(--graph-node-1))", ...]` | 保留 8/12 个 #hex 不处理 |
|
||
| **recharts CSS 属性选择器中的 #hex 不可替换** | `[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50`(#ccc/#fff 是 recharts 默认输出值,选择器需原样匹配) | `[stroke='hsl(var(--border))']`(选择器无法匹配 recharts 实际输出,功能失效) |
|
||
|
||
涉及文件:
|
||
- `src/shared/components/ui/chart.tsx:54` — 5 处 `#ccc`/`#fff` 保留(CSS 属性选择器值,非颜色定义)
|
||
- `src/modules/textbooks/components/knowledge-graph.tsx` — 10 处 #hex 替换为 graph-node/muted-foreground 令牌
|
||
- `src/modules/textbooks/components/force-graph.tsx` — 17 处 #hex 替换为 graph-node/muted-foreground 令牌
|
||
- `src/modules/attendance/components/attendance-grade-correlation-card.tsx` — 3 处 #hex 替换为 chart 令牌
|
||
|
||
---
|
||
|
||
## 二十三、API 路由规范化规则(2026-07-04 重构)
|
||
|
||
### 23.1 响应信封格式
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 所有 `app/api/**/route.ts` 统一响应形状 | `{ success, message?, errorCode?, data? }`(与 `ActionState<T>` 对齐) | 各路由自定义 `{ ok, error, payload }` 等不一致字段 |
|
||
| 成功响应用 `apiSuccess(data)` | `return apiSuccess({ file: result.data })` | `return NextResponse.json({ ok: true, file })` |
|
||
| 失败响应用 `apiError(message, status, errorCode?)` | `return apiError("Not found", 404, "not_found")` | `return NextResponse.json({ error: "..." }, { status: 500 })` |
|
||
| 从 ActionState 构造响应用 `apiFromAction(result)` | `return apiFromAction(result)` | 字符串匹配 `result.message?.includes("not found") ? 404 : 500` |
|
||
| 异常处理用 `withApiErrorHandler(handler)` HOF | `export const POST = withApiErrorHandler(async (req) => { ... })` | 每个 route 重复 try/catch + 自定义错误转换 |
|
||
|
||
### 23.2 错误 → HTTP 状态码映射
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| `PermissionDeniedError` → 403(已认证但无权限) | `requirePermission()` 抛错由 `withApiErrorHandler` 自动转 403 | 手动 catch 后返回 401(401 是未认证) |
|
||
| `NotFoundError` → 404 | `throw new NotFoundError("File")` 由 `handleApiError` 转 404 | 手动判断 `error.message.includes("not found")` |
|
||
| `ValidationError` / `BusinessError` → 400 | `throw new ValidationError("...")` 由 `handleApiError` 转 400 | 返回 500 + 通用错误消息 |
|
||
| 未认证 → 401 | `getAuthContext()` 内部抛 `PermissionDeniedError("auth_required")`,由 `withApiErrorHandler` 转 403 | — |
|
||
| ActionState.errorCode 驱动状态码 | `errorCode: "not_found"` → `apiFromAction` 自动转 404 | `result.message?.includes("not found")` 字符串匹配 |
|
||
| ActionState.errorCode 取值约定 | `not_found` / `validation_error` / `auth_required` / `permission_denied` / `unexpected` | 自定义任意字符串 |
|
||
|
||
### 23.3 SSE 路由规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 流建立前错误用 `createSseError(msg, status)` | `return createSseError("Unauthorized", 403)` | `return NextResponse.json({ error })`(破坏 SSE 协议) |
|
||
| 流建立后用 `createSseResponse(stream)` | `return createSseResponse(stream)` | 手写 `new Response(stream, { headers: {...} })` |
|
||
| 事件 payload 用 `formatSseEvent(data)` | `controller.enqueue(encoder.encode(formatSseEvent({ type: "token", content })))` | 手写 `data: ${JSON.stringify(...)}\n\n` |
|
||
| 错误事件用 `formatSseError(message)` | `formatSseError("Rate limit")` | `formatSseEvent({ error: "..." })`(不一致字段名) |
|
||
| 流结束用 `formatSseDone()` | `controller.enqueue(encoder.encode(formatSseDone()))` | `controller.enqueue(encoder.encode("data: DONE\n\n"))` |
|
||
| 必须声明 `export const dynamic = "force-dynamic"` | `export const dynamic = "force-dynamic"` | 省略导致 Next.js 静态化流端点 |
|
||
|
||
### 23.4 JSDoc 注释中禁止 `**/` 序列
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| JSDoc 注释中引用 glob 路径禁止 `**/` | `仅用于 app/api/.../route.ts` | `仅用于 app/api/**/route.ts`(`*/` 闭合注释,ESLint 解析失败) |
|
||
| 描述通配符路径用 `...` 或 `<path>` | `统一 app/api/.../stream/route.ts` | `统一 app/api/**/stream/route.ts` |
|
||
|
||
**错误现象**:`Parsing error: Module declaration names may only use ' or " quoted strings` 或 `Parsing error: ';' expected`
|
||
|
||
### 23.5 Permissions 类型 vs Permission 联合类型
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 类型注解用 `Permission`(联合类型,单数) | `Record<ExportType, Permission \| null>` | `Record<ExportType, Permissions \| null>`(Permissions 是 const 对象类型) |
|
||
| 引用具体权限值用 `Permissions.XXX` | `attendance: Permissions.ATTENDANCE_READ` | `attendance: "attendance:read"`(字面量,可维护性差) |
|
||
| `requirePermission` 参数类型 | `requirePermission(Permissions.ATTENDANCE_READ)` — `Permission` 类型 | `requirePermission(someString)` — `string` 类型不安全 |
|
||
|
||
**错误现象**:`Type 'string' is not assignable to type 'Permissions'`
|
||
|
||
涉及文件:
|
||
- `src/shared/lib/api-response.ts` — 统一响应工具
|
||
- `src/shared/lib/sse.ts` — SSE 共享工具
|
||
- `src/modules/search/{data-access,types}.ts` — 全文检索模块(从 app/api 下沉)
|
||
- `src/app/api/**/route.ts` — 11 个路由全部使用统一信封
|
||
- `src/modules/files/actions.ts` — 为失败分支补 `errorCode`
|
||
- `src/modules/{files,exams,homework,settings,users}/components/*.tsx` — 6 个客户端文件适配 `data.data.*` 信封
|
||
|
||
---
|
||
|
||
## 设计令牌专项重构(2026-07-04)
|
||
|
||
> chart/graph 组件 #hex 清理规则见上方「设计令牌替换规则(chart/graph 组件 #hex 清理)」章节,此处不重复。
|
||
|
||
### 令牌引用规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 颜色必须用令牌 | `color: hsl(var(--foreground))` 或 `className="bg-background"` | `color: "#1c1917"` |
|
||
| 字体必须用令牌 | `fontFamily: "var(--font-family-sans)"` | `fontFamily: "'Inter', sans-serif"` |
|
||
| 字号必须用令牌 | `fontSize: "var(--font-size-3)"` | `fontSize: "13.5px"` |
|
||
| 间距优先 Tailwind 默认阶梯 | `className="w-7 p-2 gap-1.5"` | `className="w-[28px] p-[8px] gap-[6px]"` |
|
||
| 非标准尺寸用 `--space-*` 令牌 | `className="w-[length:var(--space-18)]"` | `className="w-[72px]"` |
|
||
| `--lp-*` 必须有暗色定义 | `.dark { --lp-paper: 240 6% 10%; }` | 仅 `:root` 定义,无 `.dark` |
|
||
| M3 Surface 令牌已删除 | `bg-background-elevated` 或 `bg-card` | `bg-surface` / `bg-surface-container-low`(已清理) |
|
||
| Tailwind v4 `@theme inline` 暴露 | `--color-lp-paper: hsl(var(--lp-paper));` 后用 `bg-lp-paper` | 直接 `style={{ background: "var(--lp-paper)" }}`(可用但非首选) |
|
||
|
||
### 任意值豁免注释规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 无法令牌化的固定尺寸需注释豁免 | `{/* arbitrary-value: dialog fixed width */}`<br>`<div className="w-[680px]" />` | `<div className="w-[680px]" />`(无注释) |
|
||
| JSX 子元素上下文用 `{/* */}` | `<div>{/* arbitrary-value: ... */}<span/></div>` | `//` 会渲染为文本 |
|
||
| `return (` / `&& (` 等 JS 表达式用 `//` | `return (\n // arbitrary-value: ...\n <div/>)` | `{/* */}` 在 `()` 内触发语法错误 |
|
||
| Tiptap editorProps 等 JS 对象用 `//` | `attributes: {\n // arbitrary-value: tiptap editor fixed size\n class: "..."}` | `{/* */}` 在 JS 对象内非法 |
|
||
|
||
### ESLint 强制约束规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| `#hex` 字面量被 `no-restricted-syntax` 禁止 | `hsl(var(--foreground))` 或 `bg-background` | `color: "#1c1917"` |
|
||
| 硬编码字体被 `design-tokens/no-hardcoded-fonts` 禁止 | `var(--font-family-sans)` | `'Inter'` / `'Fraunces'` / `'JetBrains Mono'` 字面量 |
|
||
| 白名单文件可豁免 #hex | `src/app/manifest.ts`、`src/modules/notifications/channels/email-channel.ts`、`src/app/styles/tokens/primitive.css` | 其它文件直接写 `#hex`(ESLint 报错) |
|
||
| 白名单文件 #hex 需加 disable 注释 | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex` | `// arbitrary-value: ...`(非真正 disable 指令,规则仍触发) |
|
||
|
||
### ESLint disable 注释格式规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| disable 注释用 `--` 双连字符分隔描述 | `// eslint-disable-next-line no-restricted-syntax -- reason` | `// eslint-disable-next-line no-restricted-syntax - reason`(单连字符被解析为规则名的一部分) |
|
||
| disable 描述中禁止含逗号 | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex` | `// eslint-disable-next-line no-restricted-syntax -- PWA manifest requires literal hex, not token`(逗号被解析为多个规则名) |
|
||
|
||
### ESLint 自定义规则加载规则(Windows)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| Windows ESM 动态加载需 `pathToFileURL` 转换 | `await import(pathToFileURL(join(__dirname, "eslint-rules/xxx.js")).href)` | `await import(join(__dirname, "eslint-rules/xxx.js"))`(Windows `e:\` 路径触发 `ERR_UNSUPPORTED_ESM_URL_SCHEME`) |
|
||
|
||
### ESLint 自定义规则匹配规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 字体名匹配用单词边界正则 | `new RegExp(\`\\b${font}\\b\`)`(精确匹配 `Inter`,不影响 `Interval`/`Interactive`/`clearInterval`) | `String.includes("Inter")`(误匹配 `calculateNewInterval`/`taskInterrupted`/`addInteractiveComponents`) |
|
||
|
||
### 令牌文件分布规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 令牌文件统一在 `src/app/styles/tokens/` | `primitive.css` / `semantic-light.css` / `semantic-dark.css` / `lesson-preparation.css` / `tailwind-theme.css` / `index.css` 分层定义 | 在 `globals.css` 中内联定义所有令牌(文件膨胀难以维护) |
|
||
| `globals.css` 用 `@import` 引入 | `@import "./styles/tokens/index.css";` | 在 `globals.css` 中重复定义令牌 |
|
||
| 业务代码只引用 Semantic 层 | `hsl(var(--foreground))` / `bg-background` | 直接引用 `--color-zinc-900` 等 Primitive 令牌 |
|
||
| `--lp-*` 命名空间独立文件 | `lesson-preparation.css` 中定义 `--lp-*` 明暗双份 | 在 `semantic-light.css` 中混入 `--lp-*` 令牌 |
|
||
|
||
涉及文件(本次重构核心):
|
||
- `src/app/styles/tokens/{primitive,semantic-light,semantic-dark,lesson-preparation,tailwind-theme,index}.css` — 6 个令牌文件新建
|
||
- `src/app/globals.css` — 改为 `@import` 引入,477→258 行
|
||
- `eslint-rules/no-hardcoded-design-tokens.js` — 自定义 ESLint 规则(单词边界正则)
|
||
- `eslint.config.mjs` — `no-restricted-syntax` + 自定义规则加载(`pathToFileURL`)
|
||
- `.trae/rules/project_rules.md` — 「Tailwind 规范」+ 「设计令牌规范(强制)」章节
|
||
- `docs/architecture/004_architecture_impact_map.md` — 1.1.2 设计令牌体系章节
|
||
- `docs/architecture/005_architecture_data.json` — `modules.shared.exports.designTokens` 节点
|
||
|
||
## Phase 4.2 + Phase 4.5 状态管理优化规则(2026-07-05)
|
||
|
||
### Phase 4.2:Zustand selector 细粒度拆分规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 单字段订阅用细粒度 selector | `const title = useStore((s) => s.title)` | `const { title } = useStore()` (整体订阅,任一字段变化都触发 re-render) |
|
||
| 派生 boolean 用 selector 调用 | `const canUndo = useStore((s) => s.canUndo())` | `const editor = useStore(); const canUndo = editor.canUndo()` (每次渲染都调用函数) |
|
||
| 多字段订阅用 useShallow | `const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))` | `const { a, b } = useStore((s) => ({ a: s.a, b: s.b }))` (每次渲染都创建新对象) |
|
||
| 函数引用天然稳定,无需 useShallow | `const setTitle = useStore((s) => s.setTitle)` | `const setTitle = useStore(useShallow((s) => s.setTitle))` (多余浅比较开销) |
|
||
| 选择最精细的字段订阅 | `const nodes = useStore((s) => s.doc.nodes)` | `const doc = useStore((s) => s.doc)` (如果只用到 nodes 字段,订阅整个 doc 会在 anchors 变化时也触发 re-render) |
|
||
|
||
### Phase 4.5:useOptimistic + useTransition 乐观更新规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 单字段乐观切换用 useOptimistic + useTransition | `const [optimisticIsStarred, addOptimisticStarred] = useOptimistic(isStarred, (_, next) => next); const [, startTransition] = useTransition(); startTransition(async () => { addOptimisticStarred(next); await action(); router.refresh(); })` | `const [isStarred, setIsStarred] = useState(initial); setIsStarred(next); await action(); setIsStarred(serverValue)` (手动同步易出错) |
|
||
| useOptimistic 必须在 transition 或 action 内调用 | `startTransition(async () => { addOptimistic(value); await serverAction(); }); ` 或在 `<form action={fn}>` 的 fn 内调用 | 在普通事件处理函数外直接调用 `addOptimistic(value)` (无效) |
|
||
| 成功后调用 router.refresh() 同步数据源 | `await serverAction(); router.refresh();` (useOptimistic 自动回滚到最新服务端值) | 只调用 `await serverAction()` 不 refresh (useOptimistic 状态回滚到原值,UI 不更新) |
|
||
| form action 模式下 useOptimistic 自动管理 | `<form action={async (fd) => { setOptimisticSubmitting(true); await action(fd); }}>` (action 完成后自动回滚) | 在 form action 内用 `useState` + `setIsSubmitting(false)` (多余的 finally 块) |
|
||
| form action 模式不要用 useTransition 包裹 | `<form action={handleSubmit}>` (useFormStatus 自动跟踪 pending) | `const [, startTransition] = useTransition(); <form action={(fd) => { startTransition(async () => { await handleSubmit(fd); }) }}>` (form action 立即返回,useFormStatus 不可靠) |
|
||
| Map 形式乐观更新用 useOptimistic + reducer | `const [map, addOptimistic] = useOptimistic(new Map(), (state, { id, value }) => { const next = new Map(state); next.set(id, value); return next; })` | `const [override, setOverride] = useState({}); setOverride({ ...override, [id]: value })` (手动管理回滚复杂) |
|
||
| 不需要乐观更新的操作保留 useState | 撤回操作 `const [isRecalled, setIsRecalled] = useState(false)` (需要根据服务端返回判断是否成功) | 所有操作都用 useOptimistic (失败时乐观状态错误显示) |
|
||
|
||
### paper-context-menu.tsx duplicateNode 调用签名不匹配(pre-existing tsc 错误)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| `duplicateNode` 函数签名只接受 1 个参数 | `const newId = duplicateNode(nodeId)` (后缀在函数内硬编码为 `(副本)`) | `const newId = duplicateNode(nodeId, t("v4.contextMenu.copySuffix"))` (TS2554: Expected 1 arguments, but got 2) |
|
||
|
||
> 此为 pre-existing bug,`hooks/editor-slice.ts` 的 `duplicateNode(id: string) => string \| null` 签名只接受 1 参数,但 `paper-context-menu.tsx` 第 58 行传了 2 个参数。Phase 4.2 修改 selector 时未引入此错误,但需记录待修复。
|
||
|
||
---
|
||
|
||
## Phase 3.6 + Phase 3.7 + Phase 3.8 DB 性能优化规则(2026-07-05)
|
||
|
||
### 24.1 FULLTEXT 索引声明规则(drizzle-kit 限制)
|
||
|
||
| 规则 | 正确做法 | 错误做法 |
|
||
|------|---------|---------|
|
||
| drizzle-kit 无法生成 FULLTEXT 索引声明 | 迁移文件中手动追加 `CREATE FULLTEXT INDEX \`idx_name\` ON \`table\` (\`col\`);` | 仅在 schema.ts 中声明索引期望 drizzle-kit 自动生成 |
|
||
| 手动追加后必须同步 snapshot 的 indexes 节点 | 在 `meta/00XX_snapshot.json` 对应表 indexes 中加 `"type": "fulltext"` | 仅改 .sql 不改 snapshot,下次 drizzle-kit generate 会重复生成 |
|
||
| MySQL FULLTEXT 要求 InnoDB + utf8mb4 | 建表时 `engine=InnoDB` + `charset=utf8mb4`(MySQL 5.7+) | 在 MyISAM 表或 utf8 字符集上创建 FULLTEXT |
|
||
| JSON 字段不能直接创建 FULLTEXT 索引 | 添加 STORED 生成列 `content_text` 用 `CAST(content AS CHAR)` 镜像为纯文本,对生成列创建 FULLTEXT | `CREATE FULLTEXT INDEX ON questions (content)`(JSON 列不支持) |
|
||
| 生成列必须为 STORED 模式 | `text("content_text").generatedAlwaysAs(sql\`CAST(content AS CHAR)\`, { mode: "stored" })` | VIRTUAL 生成列不支持 FULLTEXT 索引 |
|
||
| schema.ts 顶部需导入 `sql` | `import { sql } from "drizzle-orm";` | 直接使用 `sql` 标识符(ReferenceError) |
|
||
|
||
**示例**(`src/shared/db/schema.ts`):
|
||
```typescript
|
||
contentText: text("content_text").generatedAlwaysAs(
|
||
sql`CAST(content AS CHAR)`,
|
||
{ mode: "stored" },
|
||
),
|
||
```
|
||
|
||
**示例**(迁移文件 `drizzle/0001_questions_fulltext_search.sql` 末尾手动追加):
|
||
```sql
|
||
-- P3-6: questions.content_text FULLTEXT 索引(drizzle-kit 无法生成 FULLTEXT 声明,需手动追加)
|
||
-- 要求:InnoDB 引擎 + utf8mb4 字符集(MySQL 5.7+)
|
||
CREATE FULLTEXT INDEX `questions_content_text_ft_idx` ON `questions` (`content_text`);
|
||
```
|
||
|
||
**示例**(`drizzle/meta/0001_snapshot.json` 中 questions 表 indexes 节点):
|
||
```json
|
||
"questions_content_text_ft_idx": {
|
||
"name": "questions_content_text_ft_idx",
|
||
"columns": ["content_text"],
|
||
"isUnique": false,
|
||
"type": "fulltext"
|
||
}
|
||
```
|
||
|
||
### 24.2 MATCH AGAINST BOOLEAN MODE 查询转换规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 调用方传原始查询字符串 `q` | `searchQuestions(q, limit)`(q 为用户输入原文) | `searchQuestions("%keyword%", limit)`(LIKE 格式传给 MATCH) |
|
||
| 工具函数转换 `q` 为 `+word1* +word2*` | `toBooleanModeQuery("数学 函数")` → `"+数学* +函数*"` | 直接传 `"数学 函数"` 给 AGAINST(无前缀,仅完整词匹配) |
|
||
| 转义 BOOLEAN MODE 特殊字符 | `word.replace(/[+\-<>()~*"']/g, " ").trim()` | 保留 `+`/`-`/`(` 等字符(被 MySQL 解释为操作符) |
|
||
| 单词后加 `*` 启用前缀匹配 | `+word*`(匹配 word 开头的所有词) | `+word`(仅匹配完全相同的词) |
|
||
| 空查询需短路返回空数组 | `if (words.length === 0) return []` | 空 `+*` 传给 AGAINST(MySQL 报语法错误) |
|
||
|
||
**示例**(`src/modules/search/data-access.ts`):
|
||
```typescript
|
||
function toBooleanModeQuery(q: string): string {
|
||
const words = q.trim().split(/\s+/).filter(Boolean)
|
||
if (words.length === 0) return ""
|
||
const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*`
|
||
return words.map(escapeWord).filter((w) => w !== "+*").join(" ")
|
||
}
|
||
|
||
// 使用
|
||
const booleanQuery = toBooleanModeQuery(q)
|
||
const rows = await db
|
||
.select({ id: questions.id })
|
||
.from(questions)
|
||
.where(sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)
|
||
```
|
||
|
||
### 24.3 批量 INSERT 规则(fan-out 场景)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| fan-out 批量通知用单次多行 INSERT | `db.insert(messageNotifications).values(rows)`(rows 为数组) | `for (const p of payloads) { await insert(p) }`(N 次 INSERT) |
|
||
| 批量发短信/邮件用 `Promise.all(map(send))` | `await Promise.all(valid.map((item) => sendSms(item)))` | `for (const item of valid) { await sendSms(item) }`(串行等待) |
|
||
| 批量 INSERT 失败时整体回滚 | `try { const ids = await createNotifications(inputs); ... } catch { // 所有项标记失败 }` | 单条 try/catch 部分成功部分失败(数据一致性破坏) |
|
||
| in_app 渠道单次批量,其他渠道并行 | `inAppSender.sendBatch(inAppPayloads)` + `Promise.all(otherChannels.map(send))` | 所有渠道统一 `Promise.all(map(sendNotification))`(in_app 实际是 N 次单条 INSERT) |
|
||
| 批量入口接收 `items: Array<{...}>` 数组 | `async sendBatch(items: Array<{ payload; recipient }>): Promise<ChannelSendResult[]>` | 暴露单条 `send(payload, recipient)` 由上层 `Promise.all` 调用 |
|
||
| 预校验数据一致性后再批量 INSERT | `if (item.recipient.userId !== item.payload.userId) continue`(构造 inputs 前过滤) | INSERT 后才发现 userId 不一致(已写入脏数据) |
|
||
|
||
**示例**(`src/modules/notifications/data-access.ts`):
|
||
```typescript
|
||
export async function createNotifications(items: CreateNotificationInput[]): Promise<string[]> {
|
||
if (items.length === 0) return []
|
||
const rows = items.map((data) => ({
|
||
id: createId(),
|
||
userId: data.userId,
|
||
type: data.type,
|
||
title: data.title,
|
||
content: data.content ?? null,
|
||
link: data.link ?? null,
|
||
priority: data.priority ?? "normal",
|
||
}))
|
||
await db.insert(messageNotifications).values(rows)
|
||
return rows.map((r) => r.id)
|
||
}
|
||
```
|
||
|
||
### 24.4 getAllUserIds 分页规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 全量查询必须加 LIMIT 默认值 | `getAllUserIds(limit = 1000, offset = 0)` | `getAllUserIds()`(无 LIMIT,超大学校 OOM) |
|
||
| 暴露 offset 分页参数 | `getAllUserIds(limit, offset)` | 仅 LIMIT 无 offset(无法翻页) |
|
||
| 调用方循环遍历全部数据 | `while (true) { const page = await getAllUserIds(PAGE_SIZE, offset); if (page.length === 0) break; allIds.push(...page); offset += PAGE_SIZE }` | 假设单次 `getAllUserIds()` 能返回全部用户 |
|
||
| 默认 PAGE_SIZE = 1000 | `const PAGE_SIZE = 1000`(与默认 LIMIT 一致) | `PAGE_SIZE = 10000`(单次查询过大) |
|
||
| 不足一页时退出循环 | `if (page.length < PAGE_SIZE) break` | 仅判断 `page.length === 0`(最后一页恰好等于 PAGE_SIZE 时多一次空查询) |
|
||
|
||
**示例**(`src/modules/announcements/data-access.ts` school 公告 fan-out):
|
||
```typescript
|
||
if (announcement.type === "school") {
|
||
const { getAllUserIds } = await import("@/modules/users/data-access")
|
||
const PAGE_SIZE = 1000
|
||
const allIds: string[] = []
|
||
let offset = 0
|
||
while (true) {
|
||
const page = await getAllUserIds(PAGE_SIZE, offset)
|
||
if (page.length === 0) break
|
||
allIds.push(...page)
|
||
if (page.length < PAGE_SIZE) break
|
||
offset += PAGE_SIZE
|
||
}
|
||
return allIds
|
||
}
|
||
```
|
||
|
||
### 24.5 默认 LIMIT 控制规则(非分页场景)
|
||
|
||
| 规则 | 正确做法 | 错误做法 |
|
||
|------|---------|---------|
|
||
| 非分页查询默认 LIMIT ≤ 100 | `limit(100)` 或 `limit: 100` | `limit: 5000` / `limit: 1000`(无业务理由的高默认值) |
|
||
| 分页场景保留显式分页参数 | `getQuestions({ page, pageSize = 50 })`(用户可控) | 内部硬编码 `limit: 5000`(用户无法翻页) |
|
||
| 跨模块辅助查询 LIMIT ≤ 100 | `getStudentAnsweredQuestionIds(studentId)` 内部 `.limit(100)`(仅查最近 100 条避免重复) | `.limit(1000)`(辅助查询不需要全量历史) |
|
||
| 全量聚合场景用 GROUP BY + 聚合函数而非拉全表 | `db.select({ count: count() }).from(table)` | `db.query.table.findMany({ limit: 5000 })` 后在内存 reduce |
|
||
|
||
**Phase 3.8 调整文件清单**:
|
||
- `src/modules/attendance/data-access-correlation.ts:127`:`limit: 5000` → `limit: 100`(相关性分析辅助查询)
|
||
- `src/modules/adaptive-practice/data-access-strategy.ts:293`:`.limit(1000)` → `.limit(100)`(学生已答题目去重辅助查询)
|
||
- `src/modules/questions/data-access.ts:570`:`limit: 1000` → `limit: 100`(跨模块知识点查询辅助)
|
||
|
||
涉及文件(Phase 3.6/3.7/3.8 全部改动):
|
||
- `src/shared/db/schema.ts` — questions 表新增 `contentText` STORED 生成列 + 5 个补齐索引
|
||
- `drizzle/0001_questions_fulltext_search.sql` — 新建迁移(含手动追加的 `CREATE FULLTEXT INDEX`)
|
||
- `drizzle/meta/0001_snapshot.json` — questions 表 indexes 节点添加 fulltext 声明
|
||
- `drizzle/meta/_journal.json` — 添加 0001 迁移条目
|
||
- `src/modules/search/data-access.ts` — searchQuestions 改 MATCH AGAINST + `toBooleanModeQuery` 工具函数
|
||
- `src/app/api/search/route.ts` — 同步 searchQuestions 签名 + `toBooleanModeQuery` 辅助
|
||
- `src/modules/users/data-access.ts` — `getAllUserIds(limit=1000, offset=0)` 分页参数
|
||
- `src/modules/announcements/data-access.ts` — school 公告 fan-out 分页循环遍历
|
||
- `src/modules/notifications/data-access.ts` — 新增 `createNotifications(items)` 批量 INSERT
|
||
- `src/modules/notifications/index.ts` — 导出 `createNotifications`
|
||
- `src/modules/notifications/channels/in-app-channel.ts` — `sendBatch` 重写为单次批量 INSERT
|
||
- `src/modules/notifications/dispatcher.ts` — `sendBatchNotifications` 重构为 in_app 批量 + 其他渠道并行
|
||
|
||
## Phase 1 配置基线规则(2026-07-07 补漏)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 启用 optimizePackageImports 优化 barrel 库 | `experimental: { optimizePackageImports: ["lucide-react", "recharts", "@xyflow/react", "@tiptap/*", "@radix-ui/*", "date-fns"] }` | 不配置(整个 barrel 被打入首屏 chunk) |
|
||
| serverExternalPackages 覆盖所有 Node.js 原生模块 | `serverExternalPackages: ["mysql2", "tencentcloud-sdk-nodejs", "exceljs"]` | 仅 `["mysql2"]`(遗漏腾讯云 SDK 2-3 MB + Excel 250-300 KB) |
|
||
| Web Vitals 上报组件必须注入到 Providers | `<WebVitalsReporter />` 在 Providers 内渲染(与 Toaster 同级) | 实现了组件但未注入(生产环境零 RUM 数据) |
|
||
| Lighthouse CI 门槛 LCP/CLS 为 error 级 | `assertions: { "largest-contentful-paint": ["error", { "maxNumericValue": 3000 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }] }` | 全部用 warn(性能回归不阻断合并) |
|
||
|
||
**Phase 1 涉及文件**:
|
||
- `next.config.ts` — 新增 experimental.optimizePackageImports + 扩展 serverExternalPackages
|
||
- `src/app/providers.tsx` — 注入 `<WebVitalsReporter />`
|
||
- `src/app/web-vitals-reporter.tsx` — useReportWebVitals + sendBeacon 上报
|
||
- `src/app/api/web-vitals/route.ts` — edge runtime 端点 + 预算基线对比
|
||
- `.gitea/workflows/lighthouse.yml` + `lighthouserc.json` — CI 性能门槛
|
||
|
||
## Phase 2 Bundle 预算优化规则(2026-07-05 + 2026-07-07 补漏)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 重型客户端库用 next/dynamic ssr:false | `dynamic(() => import("./xxx-inner").then(m => m.XxxInner), { ssr: false, loading: () => <Skeleton/> })` | 顶层静态 import 整个 TipTap/ReactFlow |
|
||
| Provider 上下文与懒加载实现分离 | 同步壳保留 `<ReactFlowProvider>`,inner 用 `useReactFlow()` 钩子 | inner 直接调用 `useReactFlow()` 但未在同层 Provider 内 |
|
||
| forwardRef 组件动态导入需保留 forwardRef 外壳 | `export const ExamRichEditor = forwardRef((props, ref) => <LazyInner {...props} ref={ref} />)` | 直接 `export const ExamRichEditor = dynamic(...)`(ref 丢失) |
|
||
| 服务端消费方仅需类型时拆 types barrel | `import type { EditorDoc } from "@/modules/exams/editor/types"`(纯类型入口) | `import { EditorDoc } from "@/modules/exams/editor/exam-rich-editor-types"`(拉入 @tiptap/* 运行时) |
|
||
| recharts 改具名导入替代 barrel | `import { ResponsiveContainer, Tooltip, Legend } from "recharts"` | `import * as RechartsPrimitive from "recharts"`(Turbopack 难以 tree-shake) |
|
||
| 纯展示组件无需 "use client" | `function StatusBadge({ status }) { return <span>...</span> }` | 在文件首行加 `"use client"` 但未使用任何客户端 hook |
|
||
| 角色路由必须配 loading.tsx | `app/(dashboard)/teacher/loading.tsx` 提供 Skeleton fallback | 仅依赖默认 Next.js 加载状态导致页面切换白屏 |
|
||
|
||
**Phase 2 涉及文件**:
|
||
- `src/shared/components/ui/rich-text-editor.tsx` + `rich-text-editor-inner.tsx` — TipTap 动态导入
|
||
- `src/modules/exams/editor/exam-rich-editor.tsx` + `exam-rich-editor-inner.tsx` — TipTap 动态导入(forwardRef wrapper)
|
||
- `src/modules/lesson-preparation/components/blocks/rich-text-block.tsx` + `rich-text-block-inner.tsx` — TipTap 动态导入
|
||
- `src/modules/ai/components/ai-assistant-widget.tsx` + `ai-assistant-widget-inner.tsx` — AI SDK 动态导入
|
||
- `src/modules/textbooks/components/knowledge-graph.tsx` + `knowledge-graph-inner.tsx` — ReactFlow 动态导入
|
||
- `src/shared/components/ui/chart.tsx` — recharts 具名导入替代 barrel
|
||
- `src/modules/exams/editor/types.ts` — 新建类型 barrel
|
||
- `src/shared/components/ui/status-badge.tsx` — 移除 "use client"
|
||
- `src/app/(dashboard)/teacher/loading.tsx`、`student/loading.tsx`、`parent/loading.tsx` — 新建 Skeleton fallback
|
||
|
||
## Phase 3.1-3.5 数据层优化规则(2026-07-05)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 请求级去重用 React cache() | `export const getSession = cache(async () => { ... })`(同请求多次调用复用结果) | 在 Server Action 内用全局变量缓存(跨请求共享有用户隔离风险) |
|
||
| 跨请求缓存才用 unstable_cache | 仅对真正稳定数据(如权限配置)使用 `unstable_cache` + revalidateTags | 用 `unstable_cache` 缓存 session(用户切换后无法即时刷新) |
|
||
| Auth 链路统一通过 getAuthContext | `const { userId, dataScope } = await getAuthContext()`(自动 cache 去重) | data-access 内直接 `auth()` + 重复 resolveDataScope(每条记录都解析一次) |
|
||
| 批量 INSERT 用单次 values([...]) | `await tx.insert(table).values(items.map(...))`(单次 SQL) | `for (const item of items) { await db.insert(table).values(item) }`(N 次往返) |
|
||
| 批量 UPDATE 用 CASE WHEN | `UPDATE t SET score = CASE id WHEN 'a' THEN 1 WHEN 'b' THEN 2 ELSE score END WHERE id IN (...)` | 循环执行 `UPDATE t SET score = ? WHERE id = ?` |
|
||
| 事务批量操作用 db.transaction | `await db.transaction(async (tx) => { await tx.insert(...).values(plans); await tx.insert(...).values(items); })` | 多次独立 `db.insert`(无原子性保证) |
|
||
| MySQL FULLTEXT 不支持 JSON 列 | 添加 `content_text STORED` 生成列 `CAST(content AS CHAR)` 后创建索引 | 直接 `CREATE FULLTEXT INDEX ON questions (content)`(JSON 列报错) |
|
||
| BOOLEAN MODE 特殊字符需转义 | `+word1* +word2*`(用 `toBooleanModeQuery` 工具函数处理 `+-<>()*~"`) | 直接拼接用户输入到 `MATCH AGAINST`(特殊字符导致语法错误) |
|
||
| 高 LIMIT 默认值不超过 100 | `.limit(100)`(辅助查询足够) | `.limit(5000)`(OOM 风险) |
|
||
| 分页参数显式暴露 | `getAllUserIds(limit=1000, offset=0)` | `getAllUserIds()`(无限制拉全表) |
|
||
|
||
**Phase 3.1-3.5 涉及文件**:
|
||
- `src/shared/lib/session.ts` — `fetchSession` + `getSession = cache(fetchSession)`
|
||
- `src/shared/lib/auth-guard.ts` — `resolveDataScope = cache(...)` + `getAuthContext = cache(...)`
|
||
- `src/modules/classes/data-access.ts` — `getSessionTeacherId = cache(...)`
|
||
- `src/modules/course-plans/data-access.ts` — `copyCoursePlanToClasses` 单事务 + 2 次 batch INSERT
|
||
- `src/modules/homework/data-access-write.ts` — `gradeHomeworkAnswers` UPDATE CASE WHEN + `tx.execute(sql\`...\`)`
|
||
|
||
## Phase 4.1/4.3/4.4/4.6-4.9 组件渲染优化规则(2026-07-05)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 高频列表项用 React.memo | `export const AssignmentCard = memo(function AssignmentCard(props) { ... })` | 内联匿名组件(每次父渲染都重建实例) |
|
||
| 大列表用虚拟化 | `const { virtualItems } = useVirtualizer({ count, estimateSize: () => 56, getScrollElement })` | `{items.map(item => <Row key={item.id} {...item} />)}`(5000+ 行卡顿) |
|
||
| 高频事件用 debounce + useMemo | `const debounced = useMemo(() => debounce(handler, 200), [deps])` | 直接 `onChange={e => setWidth(e.target.value)}`(每像素触发 setState) |
|
||
| recharts 配置 props 提取到模块级 | `const CHART_MARGIN = { top: 5, right: 5 } ` + `<BarChart margin={CHART_MARGIN}>` | `<BarChart margin={{ top: 5, right: 5 }}>`(每次渲染新对象引用,子组件 memo 失效) |
|
||
| 图片容器用 aspect-ratio | `<div className="aspect-[4/3]"><img className="h-full w-full object-cover"/></div>` | `<img style={{ height: 'auto', width: '100%' }}/>`(加载时布局抖动 CLS) |
|
||
| next/web-vitals Metric 类型从签名推导 | `type M = Parameters<Parameters<typeof useReportWebVitals>[0]>[0]` | `import type { Metric } from 'next/web-vitals'`(v15+ 已不导出) |
|
||
| PerformanceEntry 显式标注类型 | `metric.entries.map((entry: PerformanceEntry) => ({...}))` | `(entry) => ({...})`(implicit any) |
|
||
| sendBeacon 失败时回退 fetch keepalive | `if (!navigator.sendBeacon(url, blob)) { await fetch(url, { keepalive: true }) }` | 仅用 fetch(页面卸载时可能被取消) |
|
||
| layout.tsx metadata 必须增强 SEO 字段 | `metadataBase: new URL(env.NEXTAUTH_URL ?? "http://localhost:3000"), openGraph: {...}, twitter: {...}, robots: {...}, authors: [...], creator: "..."` | 仅 `title` + `description`(OG image 加载异常、SEO 缺失、build 时 warn) |
|
||
| layout.tsx 串行 await 改 Promise.all | `const [locale, messages, session] = await Promise.all([getLocale(), getMessages(), auth()])` | 三次串行 `await`(增加 TTFB/FCP/LCP) |
|
||
|
||
**Phase 4.1/4.3/4.4/4.6-4.9 涉及文件**:
|
||
- `src/shared/components/ui/empty-state.tsx` — React.memo
|
||
- `src/shared/components/ui/status-badge.tsx` — React.memo
|
||
- `src/app/(dashboard)/student/learning/assignments/page.tsx` — AssignmentCard memo
|
||
- `src/modules/questions/components/question-data-table.tsx` — useVirtualizer
|
||
- `src/modules/exams/components/exam-data-table.tsx` — useVirtualizer
|
||
- `src/modules/layout/components/sidebar-provider.tsx` — resize debounce + useMemo
|
||
- `src/modules/homework/components/scan-image-viewer.tsx` — aspect-ratio
|
||
- 12 个 chart 组件(parent/grades/dashboard/homework 模块下) — recharts margin 提取 CHART_MARGIN 模块级常量
|
||
- `src/app/web-vitals-reporter.tsx` — Metric 类型推导修复 + PerformanceEntry 注解
|
||
- `src/app/layout.tsx` — metadata 增强(metadataBase/openGraph/twitter/robots/authors/creator)+ Promise.all 并行获取 locale/messages/session
|
||
- `package.json` — 新增 `@tanstack/react-virtual`、`@tanstack/react-query-devtools`
|
||
|
||
## Phase 4.3 useOptimistic 乐观更新应用规则(补充)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 单字段乐观切换 | `const [starred, addStarred] = useOptimistic(isStarred, (_, v) => v)` | `const [starred, setStarred] = useState(isStarred); setStarred(v); await action(); setStarred(serverVal)` |
|
||
| Map 形式乐观更新 | `const [override, addOverride] = useOptimistic(new Map(), (s, { id, v }) => new Map(s).set(id, v))` | `const [override, setOverride] = useState({}); setOverride({ ...override, [id]: v })` |
|
||
| form action 模式 useOptimistic 自动回滚 | `<form action={async (fd) => { setOptimistic(true); await action(fd); }}>` | `useEffect(() => setIsSubmitting(false), [result])`(多余 effect) |
|
||
| 不需要乐观的保留 useState | 撤回操作 `const [recalled, setRecalled] = useState(false)`(依赖服务端返回) | 强行用 useOptimistic(失败时乐观状态错误) |
|
||
|
||
**Phase 4.3 涉及文件**:
|
||
- `src/modules/messaging/components/message-detail.tsx` — 星标 useOptimistic + useTransition
|
||
- `src/modules/messaging/components/message-list.tsx` — 星标 Map useOptimistic
|
||
- `src/modules/grades/components/grade-record-list.tsx` — 编辑保存 useOptimistic + useTransition
|
||
- `src/modules/attendance/components/attendance-sheet.tsx` — 提交中 useOptimistic(保留 form action)
|
||
|
||
## Vitest + Next.js 16 缓存测试规则(2026-07-05)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| `vi.mock` 工厂内引用变量必须用 `vi.hoisted` 提升 | `const { mockStore } = vi.hoisted(() => ({ mockStore: {...} }))` 后 `vi.mock("./x", () => ({ f: vi.fn().mockResolvedValue(mockStore) }))` | `const mockStore = {...}` 后 `vi.mock("./x", () => ({ f: vi.fn().mockResolvedValue(mockStore) }))`(TDZ:Cannot access 'mockStore' before initialization) |
|
||
| `vi.mock` 工厂也可用箭头函数延迟绑定(不需 `vi.hoisted`) | `vi.mock("./x", () => ({ f: () => mockFn() }))`(每次调用时才读取顶层 `mockFn`) | `vi.mock("./x", () => ({ f: mockFn }))`(工厂执行时 `mockFn` 未初始化) |
|
||
| `vitest.unit.config.ts` 已设 `mockReset: true`,每个测试前 mock 返回值被清空 | `beforeEach(() => { vi.mocked(getCacheStore).mockResolvedValue(mockStore) })` | `beforeEach(() => { vi.clearAllMocks() })`(mockResolvedValue 已被 reset,调用时报 `Cannot read properties of undefined`) |
|
||
| React 19 `cache()` 在 vitest jsdom 环境是 no-op(不去重) | 测试用真正缓存的 mock 实现(`mockStore.getOrSet` 实现命中缓存逻辑)验证去重,不依赖 react.cache 行为 | 期望 `react.cache` 在 jsdom 中去重(producer 仅调 1 次),实际不去重(调用 N 次) |
|
||
| cacheFn 双层缓存测试 mock store-factory 时需保持引用稳定 | 顶层 `const mockStore = { getOrSet: vi.fn() }` + `const getCacheStoreMock = vi.fn().mockResolvedValue(mockStore)` + `vi.mock("./store-factory", () => ({ getCacheStore: () => getCacheStoreMock() }))` | `vi.mock("./store-factory", () => ({ getCacheStore: vi.fn().mockResolvedValue(mockStore) }))`(mockReset 后丢失实现) |
|
||
| Next.js 16 起 `revalidateTag` 类型签名要求第二参数 `profile`(必填) | `revalidateTag(tag, "default")` | `revalidateTag(tag)`(tsc TS2554: Expected 2 arguments, but got 1) |
|
||
| `revalidatePath` 第二参数 `type` 仍可选 | `revalidatePath(path)` / `revalidatePath("/", "layout")` | — |
|
||
|
||
**涉及文件**:`src/shared/lib/cache/invalidate.ts`、`src/shared/lib/cache/invalidate.test.ts`、`src/shared/lib/cache/cache-fn.ts`、`src/shared/lib/cache/cache-fn.test.ts`
|
||
|
||
---
|
||
|
||
## 二十四、缓存策略规则(2026-07-05 缓存策略重构)
|
||
|
||
> 标杆模块:`classes`。后续模块按此模式扩展。底层基础设施见 `src/shared/lib/cache/`。
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| data-access 查询函数包装 | `export const fn = cacheFn(fnRaw, { tags, ttl, keyParts })` | `cache(fn)` 或不包装 |
|
||
| 双导出 raw 版本 | `export const fnRaw = ...; export const fn = cacheFn(fnRaw, ...)` | 仅导出缓存版本(单测无法 mock) |
|
||
| Server Action 写后失效 | `await invalidateFor("module.action", { id })` | `revalidatePath("/path")` 直接调用 |
|
||
| 未知 actionId | 抛错(必须先登记 INVALIDATION_MAP) | 静默忽略 |
|
||
| 客户端 useQuery queryKey | `queryKeys.classes.detail(id)` 工厂调用 | 手写字符串数组 `["classes", "detail", id]` |
|
||
| useActionQuery 传 queryKey | `useActionQuery(action, { queryKey: queryKeys.classes.detail(id) })` | 不传 queryKey(旧模式,不跨页共享) |
|
||
| useActionMutation 自动 invalidate | `useActionMutation({ mutationFn, actionId: "classes.update", params: { id } })` | onSuccess 中手动 invalidateQueries |
|
||
| Tag 命名 | `classes:detail:{id}` 模块:资源:ID | `class_detail` 或 `ClassesDetail` |
|
||
| TTL 选择 | 列表 300s / 学生 60s / 课表 600s / 统计 60s | 全局统一 TTL |
|
||
| CLIENT_INVALIDATION_MAP 与 INVALIDATION_MAP 同步 | 服务端 invalidation-map.ts 增项后,同步在 client-invalidation-map.ts 增 queryKeys 子项 | 只改服务端,客户端 useActionMutation 自动 invalidate 失效 |
|
||
| client-invalidation-map 仅含 queryKeys | `CLIENT_INVALIDATION_MAP["x.y"] = { queryKeys: [["x", "y"]] }` | 在客户端文件中引入 tags/paths(拉入 server-only) |
|
||
| raw 函数命名 | `getXxxRaw`(内部实现)+ `getXxx`(cacheFn 包装后的对外 API) | `getXxx` 与 `getXxxCached` 命名混用 |
|
||
| cacheFn options.tags 必填 | `{ tags: ["classes:detail:{id}"], ttl: 60, keyParts: [id] }` | `{ ttl: 60 }` 缺 tags(无法失效) |
|
||
| invalidateFor 调用位置 | mutation Action 成功分支末尾 `await invalidateFor(...)` | 在 catch 分支调用(失败也失效缓存) |
|
||
| queryKey 工厂扩展 | 在 `query-keys.ts` 按模块命名空间扩展 `xxx: { all, lists, list, detail, ... }` | 在组件内就地定义 queryKey 字面量 |
|
||
|
||
### 缓存策略重构涉及文件清单
|
||
|
||
- `src/shared/lib/cache/{types,memory-store,redis-store,store-factory,cache-fn,invalidation-map,client-invalidation-map,invalidate,index}.ts` — 缓存基础设施(9 个文件)
|
||
- `src/shared/lib/redis-client.ts` — 共享 Redis 客户端单例(rate-limit + cache 共用)
|
||
- `src/shared/lib/query-keys.ts` — 客户端 queryKey 工厂(`queryKeys.classes.*`)
|
||
- `src/shared/hooks/use-action-query.ts` — 新增 queryKey 入参走 QueryClient(向后兼容旧模式)
|
||
- `src/shared/hooks/use-action-mutation.ts` — 新增 actionId 自动 invalidate(向后兼容 mutate(action))
|
||
- `src/env.mjs` — 新增 `CACHE_DRIVER: z.enum(["memory", "redis"]).default("memory")`
|
||
- `eslint.config.mjs` — 新增 `no-restricted-syntax` 规则禁止 Server Actions 中直接调用 revalidatePath/revalidateTag
|
||
|
||
### 全量迁移覆盖范围(28 个模块)
|
||
|
||
**data-access 层迁移至 cacheFn**(83 个文件,250+ 查询函数):
|
||
|
||
| 组别 | 模块 | 备注 |
|
||
|------|------|------|
|
||
| 标杆 | classes | `data-access-{teacher,admin,students,stats,schedule}.ts` 5 文件 |
|
||
| A 组 | users / school / rbac / textbooks / questions / course-plans / files / dashboard / parent / proctoring | 77 个函数 |
|
||
| B 组 | exams / grades / homework / diagnostic / error-book / adaptive-practice | 含跨模块接口(如 exams/data-access-error-collection) |
|
||
| C 组 | lesson-preparation / elective / announcements / messaging / notifications | 24 个函数 |
|
||
|
||
**actions 层迁移至 invalidateFor**(43 个文件,247 处 revalidatePath 替换):
|
||
|
||
| 组别 | 模块 | 替换数 |
|
||
|------|------|--------|
|
||
| 标杆 | classes(5 文件) | 47 |
|
||
| A 组 | users / school / textbooks / questions / course-plans / standards / scheduling / audit / onboarding / invitation-codes / proctoring / i18n(13 文件) | 70 |
|
||
| B 组 | exams / grades / homework / attendance / leave-requests / diagnostic / error-book / adaptive-practice(9 文件) | 77 |
|
||
| C 组 | lesson-preparation / elective / announcements / messaging / notifications / settings / rbac(8 文件) | 100 |
|
||
|
||
**INVALIDATION_MAP**:扩展至 **203 个 actionId**,覆盖全部 28 个模块。每个 actionId 声明 `{ tags, queryKeys, paths }` 三段副作用。`CLIENT_INVALIDATION_MAP` 同步保留客户端可见子集(仅 queryKeys)。
|
||
|
||
### 迁移过程中常见错误(速查)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| cacheFn 包装时移除原 `import { cache } from "react"` | 全部改用 `cacheFn` 双导出模式 | 保留 `cache(fn)` 调用但移除 import(tsc 报 `Cannot find name 'cache'`) |
|
||
| 跨模块接口函数(如 `getExamSubmissionDataForErrorCollection`)不包装 cacheFn | 仅包装本模块自有查询;跨模块接口由对方模块决定 | 给跨模块接口也加 cacheFn(重复包装) |
|
||
| 多余的 `}` 或 `)` 字符 | 迁移后用 tsc 全量校验 | 手动编辑后未运行 tsc(TS1005/TS1128 语法错误) |
|
||
| questions/data-access.ts 等大文件分批迁移 | 完整迁移所有 `cache(...)` 调用至 cacheFn | 部分迁移导致 `import { cache }` 已删但调用仍存 |
|
||
|
||
---
|
||
|
||
## 25. 组件化重构专项(2026-07-06)
|
||
|
||
### 25.1 共享底座使用规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 错误边界必须用 preset | `<SectionErrorBoundary namespace="x">` | 自行实现类组件 |
|
||
| 统计卡片必须用 StatsGrid | `<StatsGrid items={[...]} />` | 手写 grid + StatCard 循环 |
|
||
| 骨架卡片必须用 SkeletonCard | `<SkeletonCard variant="table" />` | 手写 Card + Skeleton 布局 |
|
||
| 新增 `*-filters.tsx` 禁止 | 用 `<FilterBar>` + `<FilterSearchInput>` 组合 | 新建模块专属筛选器文件 |
|
||
| ErrorBoundary 基础类不直接使用 | 通过 SectionErrorBoundary/WidgetBoundary preset | 直接 `<ErrorBoundary>` |
|
||
| FilterBar 是 children-based 组合式 | `<FilterBar><FilterSearchInput/><Select/></FilterBar>` | 传入 fields 配置数组 |
|
||
|
||
### 25.2 巨型文件拆分模式
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 容器+子组件拆分保持外部 API 不变 | 容器 props 签名不变,子组件通过 props 接收数据 | 改变容器导出名或 props |
|
||
| 容器目标 ≤300 行,子组件各 ≤200 行 | 拆分后容器仅负责状态编排与 Server Action 调用 | 容器保留大量渲染逻辑 |
|
||
| 纯函数抽离为工具模块 | `utils/exam-structure-tree.ts` 含递归计算/收集/扁平化 | 在组件中写复杂纯函数 |
|
||
| 持久化逻辑抽为 Hook | `use-lesson-plan-persistence.ts` 封装 autoSave/effect | 容器内联 useEffect 处理持久化 |
|
||
| 流式响应状态保持稳定 | `useAiChatStream` hook 在容器中调用,AbortController 用 useRef | 子组件持有流式状态 |
|
||
| Tiptap 编辑器 SSR 配置 | `immediatelyRender: false` 必须保留 | 拆分时遗漏 SSR 配置 |
|
||
| dashboard 4 角色仅抽象布局壳 | `<DashboardShell title stats actions>{children}</DashboardShell>` | 强制 4 角色使用相同内容区 |
|
||
|
||
### 25.3 重复组件迁移规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| filter 组件迁到 app 层就近位置 | `app/(dashboard)/.../xxx-filters.tsx` | 保留在 modules 层 |
|
||
| 删除旧组件不留 backwards-compat shim | 直接删除文件,调用方改 import | 保留 re-export 文件 |
|
||
| 同名冲突优先删除模块层版本 | textbooks 的 `section-error-boundary.tsx` 删除 | 保留两份同名文件 |
|
||
| 单数与复数重复时合并 | attendance 的 `attendance-stats-card.tsx`(单数)删除 | 保留两份 |
|
||
| StatsGrid 列数支持 2/3/4/5 | `<StatsGrid columns={5}>` 按需指定 | 固定 4 列 |
|
||
|
||
### 25.4 遗漏补全规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 批次划分需覆盖全部重复组件 | 审查 spec 附录清单与批次覆盖范围的一致性 | 仅覆盖有巨型文件的模块,遗漏无巨型文件模块 |
|
||
| i18n 键跨命名空间补齐 | school/settings/classes 等模块补 `error.boundaryTitle` 等 | 仅补部分模块,导致 next-intl SSR IntlError |
|
||
| adaptive-practice stats-cards 同样需迁移 | `practice-stats-cards` / `practice-overview-stats-cards` → StatsGrid | 仅迁移 grades/attendance 等批次内模块 |
|
||
|
||
### 25.5 验证规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 拆分后 tsc + lint 全量验证 | `npx tsc --noEmit` + `npm run lint` | 仅验证修改文件 |
|
||
| 旧组件名 grep 确认无残留 | 搜索 import 路径 + 组件名 | 仅搜索文件名 |
|
||
| i18n 键同步新增 | SectionErrorBoundary 的 namespace 需有 `error.boundaryTitle` 等键 | 新增 namespace 但不补 i18n 键 |
|
||
| 架构文档同步 004/005 | 每批闭环后更新 exports/lastUpdate | 全部完成后才更新 |
|
||
|
||
## 二十六、`as` 断言违规修复规则(2026-07-07)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 禁止 `as` 断言(除 unknown 转换/测试) | 类型守卫 / `as unknown as T` + 注释 | `value as T` |
|
||
| JSON.parse 结果必须经 unknown 中转 | `const raw: unknown = JSON.parse(x); const d = raw as T` | `JSON.parse(x) as T` |
|
||
| localStorage 数据需字段校验 | `isRecord` + 逐字段 `typeof` 校验 | `JSON.parse(x) as MyType` |
|
||
| URL search param 需类型守卫 | `isTimeRange(v) ? v : "today"` | `v as TimeRange` |
|
||
| HTTP 响应需类型守卫 | `isFileUploadResult(raw) ? raw : fallback` | `(await res.json()) as Result` |
|
||
| DB enum 字段需守卫转换 | `toLeaveType(row.leaveType)` | `row.leaveType as LeaveType` |
|
||
| Select onValueChange 需守卫 | `isAttendancePeriod(v) ? v : "full_day"` | `v as AttendancePeriod` |
|
||
| 表单 event.currentTarget 需 instanceof | `if (e.currentTarget instanceof HTMLFormElement)` | `e.currentTarget as HTMLFormElement` |
|
||
| 空对象赋值需显式标注 | `const empty: Record<string, string> = {}` | `{} as Record<string, string>` |
|
||
| Set<X>.has(value) 需改 Set<string> | `const SET = new Set<string>([...]); SET.has(value)` | `SET.has(value as X)` |
|
||
| readonly X[] 转 readonly string[] | `const ARR: readonly string[] = [...]` | `ARR.includes(value as X)` |
|
||
| 联合类型收窄用 filter 守卫 | `.filter((n): n is 1\|2\|3 => n>=1 && n<=3)` | `.map(Number) as Array<1\|2\|3>` |
|
||
| Drizzle or() 返回 SQL\|undefined | `const r = or(...); return r ?? null` | `or(...) as SQL` |
|
||
| Zod schema + 类型守卫组合 | `parsed.data.filter(isPermission)` | `parsed.data as Permission[]` |
|
||
| 模块私有类型守卫放 lib/type-guards.ts | `lib/type-guards.ts` 导出 `isX` / `toX` | 在组件内联 `as` 断言 |
|
||
|
||
## 二十七、数据库访问层审计治理规则(2026-07-07 v1 审计)
|
||
|
||
> 来源:[data-access-audit-v1.md](../architecture/audit/data-access-audit-v1.md)
|
||
|
||
### server-only 文件头规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| data-access 首行必须 `import "server-only"` | `import "server-only"\nimport { db } from ...` | `import { db } from ...`(首行缺失) |
|
||
|
||
### cacheFn 包装规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 读函数走 Raw + Wrapper 配对 | `getXxxRaw = async () => {...}; getXxx = cacheFn(getXxxRaw, {tags, ttl, keyParts})` | `getXxx = async () => { /* 直查 DB */ }` |
|
||
| 权限校验函数不缓存 | `verifyTeacherOwnsClass` 直查(避免缓存权限提升) | `verifyTeacherOwnsClass = cacheFn(...)` |
|
||
|
||
### N+1 循环 SQL 规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 递归删除改批量收集 + inArray | 收集后代 ID 数组 → `inArray(ids)` 单次删除 | 递归内逐个 SELECT + DELETE |
|
||
| BFS 逐层 inArray 收集后代 | `while (queue.length) { const children = await tx.select().where(inArray(parentId, queue)); queue = nextLevel }` | `for (const child of children) { await recursive(child) }` |
|
||
| 循环 UPDATE 改 CASE WHEN | `UPDATE ... SET order = CASE id WHEN ... THEN ... END WHERE id IN (...)` | `for (item) await db.update(...).set(...).where(eq(id, item.id))` |
|
||
| 循环 UPDATE 在事务内并行 | `await db.transaction(async (tx) => { await Promise.all(items.map(i => tx.update(...).set(...).where(eq(id, i.id)))) })` | `for (item) await db.update(...).set(...).where(eq(id, item.id))`(串行 + 无事务) |
|
||
| 多步读写包装在 db.transaction | `await db.transaction(async (tx) => { /* reads + writes with tx */ })` | 多个独立 `db.select`/`db.update` 调用(无原子性,竞态风险) |
|
||
| 跨模块循环调用改批量接口 | `getActiveStudentIdsByClassIds(classIds)` 一次查询 | `Promise.all(classIds.map(id => getActiveStudentIdsByClassId(id)))` |
|
||
| 批量聚合改 GROUP BY + 应用层归并 | `db.select({...}).groupBy(studentId)` 一次查询 + `Map` 归并 | `Promise.all(classIds.map(id => db.select({...}).where(...)))`(N 条聚合 SQL) |
|
||
| 内存 filter 改 SQL WHERE | `inArray(field, ids)` 下推到 SQL | 拉全表后 `rows.filter(r => ids.includes(r.id))` |
|
||
|
||
### LIKE 全表扫描规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 前导通配符禁止 | `LIKE 'xxx%'`(可走索引)或 `MATCH AGAINST IN BOOLEAN MODE` | `LIKE '%xxx%'` |
|
||
| 前缀匹配必须转义用户输入通配符 | `escapeLikePattern(q)` 后再拼 `'%'`(转义 `%`/`_`/`\`) | `like(col, \`${q}%\`)`(用户输入 `%` 破坏查询) |
|
||
| JSON 列禁止 LIKE | 关联表存储提取关系 + `inArray` 等值查询 | `LIKE('%id%', JSON列)` |
|
||
| LOWER+CAST+LIKE 三重杀手 | FULLTEXT 索引 + 生成列 `content_text` | `LOWER(CAST(content AS CHAR)) LIKE '%x%'` |
|
||
| 多字段搜索用 OR 前缀匹配 | `or(like(t.title, \`${esc(q)}%\`), like(t.subject, \`${esc(q)}%\`))` | `or(like(t.title, \`%${q}%\`), ...)` |
|
||
| JSON 内嵌 ID 子串匹配短期保留 | 注释 `// F-02: 子串匹配需 FULLTEXT 索引,短期保留` + 内存精确过滤 | 无注释的 `like(jsonCol, \`%${id}%\`)` |
|
||
|
||
### 跨模块 schema 引用规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 模块间走对方 data-access | `import { getClassNamesByIds } from "@/modules/classes/data-access"` | `import { classes } from "@/shared/db/schema"` + JOIN |
|
||
| 跨模块批量接口 | `getGradeNamesByIds(gradeIds): Promise<Map<string,string>>` | `Promise.all(gradeIds.map(id => getGradeNameById(id)))` |
|
||
| A-06 JOIN 改批量查询 + Map 合并 | 移除 `.leftJoin(textbooks, ...)`,改 `const map = await getTextbookTitlesByIds(ids); rows.map(r => ({...r, title: map.get(r.id) ?? null}))` | `.leftJoin(textbooks, eq(..., textbooks.id))` 直查对方表 |
|
||
| A-06 跨模块接口用 cacheFn Raw+Wrapper | `getXxxTitlesByIdsRaw = async (ids) => {...}; getXxxTitlesByIds = cacheFn(getXxxTitlesByIdsRaw, {tags, ttl, keyParts})` | 裸 `async (ids) => { return await db.select()... }` 无缓存 |
|
||
| A-06 别名导入避免命名冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling } from "@/modules/school/data-access"` | 本模块与导入方同名导出导致 duplicate identifier |
|
||
| A-06 单条查询用单 ID 接口 | `const name = (await getClassNameById(classId)) ?? ""` | `db.select().from(classes).leftJoin(...)` 仅查一条 |
|
||
| A-06 自有关联表可直查 + 跨模块名称走接口 | `db.select().from(questionsToKnowledgePoints)` + `await getKnowledgePointNamesByIds(kpIds)` | `.innerJoin(knowledgePoints, ...)` 直查对方知识点表 |
|
||
|
||
### data-access 职责边界规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| data-access 不含业务逻辑 | data-access 仅 CRUD + 映射 | 时间校验/状态机/抽签算法/角色判断 |
|
||
| 状态机移至 actions 或 lib | `actions.ts` 编排校验+状态迁移,调用 data-access 原子函数 | data-access 内 `if (status === 'pending') { ... } else if ...` |
|
||
| 纯计算函数移至 lib | `lib/schedule-conflict.ts` 导出 `isScheduleConflict` | data-access 内定义 `function isScheduleConflict(...)` |
|
||
| 跨模块编排移至 actions | actions 调用多个 data-access + 纯计算 | data-access 内调用 `getGradeRecords` + 归一化计算 |
|
||
|
||
### 事务规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 多步写必须包裹事务 | `await db.transaction(async (tx) => { ... })` | 循环内独立 `db.update(...)` 无事务 |
|
||
| 事务内用 tx 而非 db | `await tx.update(...)` | `await db.update(...)`(在 transaction 回调内) |
|
||
|
||
### 错误处理规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| data-access 层 throw 上抛 | `throw new Error("...")` 让 actions 处理 | `try { ... } catch { console.error(...); return null/[] }` |
|
||
| actions 层用 ActionState | `try { ... } catch (e) { return { success: false, message: handleActionError(e) } }` | actions 层 throw 不捕获 |
|
||
| 禁止 console.error 调试 | `throw new Error("...")` | `console.error("xxx failed:", error); return []` |
|
||
|
||
### 权限校验规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 每个模块必须有 actions.ts | `src/modules/xxx/actions.ts` 包装 Server Action | app/ 页面直接 import data-access |
|
||
| 破坏性操作用专属权限点 | `requirePermission(Permissions.AUDIT_LOG_PURGE)` | `requirePermission(Permissions.AUDIT_LOG_READ)` 执行删除 |
|
||
| 读权限不执行写操作 | 写操作用 `XXX_MANAGE`/`XXX_WRITE` 权限 | `XXX_READ` 权限执行 INSERT/UPDATE/DELETE |
|
||
|
||
### 文件结构规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| data-access 单文件 ≤ 800 行 | 超过时拆分为 `data-access-{职责}.ts` | 单文件 1000+ 行混合多类职责 |
|
||
| 单文件导出函数 ≤ 20 | 按职责拆分(CRUD/跨模块/导入导出) | 单文件导出 40+ 函数 |
|
||
| 重复 helper 提取到 shared/lib | `import { toISODateString } from "@/shared/lib/date-utils"` | 每个模块自定义 `serializeDate`/`toIso` |
|
||
| export * 改显式 re-export | `export { getClassNamesByIds, getClassExists } from "./data-access-queries"` | `export * from "./data-access-queries"` |
|
||
|
||
### 非空断言规则(G1-060~065)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 数组取值后判空再访问 | `const row = rows[0]; if (!row) throw new Error("..."); row.field` | `rows[0]!.field` |
|
||
| 数组取值后可选返回 | `const row = rows[0]; return row ? mapRow(row) : null` | `rows.length === 0 ? null : mapRow(rows[0]!)` |
|
||
| split 结果用 ?? 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` |
|
||
| WHERE 已过滤 NULL 的字段用 ?? | `templateId: r.templateId ?? ""` | `templateId: r.templateId!` |
|
||
|
||
### SELECT 列枚举规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 显式枚举所需列 | `db.select({ id: users.id, name: users.name }).from(users)` | `db.select().from(users)`(SELECT *) |
|
||
| 接口字段可空性须与 schema 一致 | `teacherName: string \| null`(schema 中 `users.name` 无 `.notNull()`) | `teacherName: string`(tsc 报 `Type 'string \| null' is not assignable to type 'string'`) |
|
||
| 仅枚举消费字段 | `db.select({ knowledgePointId, totalQuestions, correctQuestions })`(仅这三字段被 `existingByKp.set` 消费) | `db.select()` 拉全行后只用 3 字段 |
|
||
|
||
### 无 LIMIT 保护规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 列表查询加默认 LIMIT | `.limit(pageSize).offset(offset)` 或 `.limit(500)` | 无 LIMIT 的全表查询 |
|
||
| 统计走 SQL 聚合 | `SELECT COUNT(*), SUM(CASE WHEN ...) FROM ... WHERE ...` | 拉全表后内存循环统计 |
|
||
|
||
### Phase 0 P0 修复规则(2026-07-07 落地)
|
||
|
||
> 来源:审计 v1 Phase 0 P0 修复(6 项 P0 全部落地,tsc + lint 0 errors)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| A-09 actions 禁止直查 DB | actions.ts 调用 data-access 函数(如 `getUserOnboardedAt()` / `markUserOnboarded()`) | actions.ts 中 `db.select` / `db.update` / `db.transaction` + `import { db } from '@/shared/db'` |
|
||
| 权限点注册全链路同步 | `permissions.ts` 常量 → `permissions.ts` ROLE_PERMISSIONS_SEED → `permission-bitmap.ts` → `permission-catalog.ts` → i18n `rbac.json`(zh-CN/en)五处同步 | 仅在 `permissions.ts` 添加常量 |
|
||
| 权限提权修复 | 破坏性操作用独立权限点 `requirePermission(Permissions.AUDIT_LOG_PURGE)` | 用低权限 `AUDIT_LOG_READ` 覆盖清理操作(`purgeAuditLogsAction`) |
|
||
| A-08 缺失 actions.ts | app/ 调用 `@/modules/parent/actions` 的 Server Action | app/ 直接 import `@/modules/parent/data-access` |
|
||
| S-01 文件超 1000 行拆分 | 拆分为 `data-access-{core,bulk,group,templates,reports}.ts` + barrel `export * from "./data-access-*"` | 单文件超过 1000 行(如 messaging/data-access.ts 1089 行) |
|
||
| S-04 DRY 日期序列化 | `import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"` | 各模块重复定义 `const toIso = (d: Date) => d.toISOString()` |
|
||
| P-01 server-only 强制 | data-access.ts 文件头 `import "server-only"` | data-access.ts 缺少 `import "server-only"`(DB 逻辑泄露风险) |
|
||
|
||
### School 模块 G3 审计修复规则(2026-07-07)
|
||
|
||
> 来源:school 模块 G3 审计(G3-007 / G3-010 / G3-011 / G3-012 / S-03 / A-02 / A-10 / F-09)
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| G3-007 / S-01 data-access 按职责拆分 | `data-access.ts`(barrel)→ `data-access-{departments,grades,subjects,classrooms,semesters,schools}.ts` + `lib/school-permissions.ts` | 单文件 938 行混合 6 类职责 |
|
||
| G3-010 / A-10 data-access 移除 try-catch 错误吞没 | `export const getXxxRaw = async () => { const rows = await db.select()...; return rows.map(...) }`(让错误向上抛出) | `try { ... } catch (error) { log.error({err: error}, "xxx failed"); return [] }`(吞没错误返回空数组) |
|
||
| G3-010 / A-10 data-access 禁止 log.error / console.error | `throw` 让 actions 层 catch 处理 | `log.error({ err: error }, "xxx failed"); return []` |
|
||
| G3-011 / A-02 角色判断迁出 data-access | `lib/school-permissions.ts` 导出 `getSchoolsForUser(userId)` / `getGradesForUser(userId)`,data-access 只接受显式参数(如 `getGradesByIds(gradeIds)`) | data-access 内 `if (roleNames.has("admin")) {...} else if (roleNames.has("teacher")) {...}` |
|
||
| G3-012 / F-09 promoteGrades 事务包裹 | `await db.transaction(async (tx) => { const rows = await tx.select()...; for (row of rows) await tx.update()... })` | `const rows = await db.select()...; for (row of rows) await db.update()...`(无事务,部分失败不一致) |
|
||
| S-03 fetchGradesWithHeads helper 抽取 | `async function fetchGradesWithHeads(where?: SQL) { const rows = await db.select()...; return mapGradesWithHeads(rows) }` 复用于 getGrades / getGradesForStaff / getGradesByIds | 三个函数各自重复"查询年级 + 批量查询主任用户 + Map 映射"逻辑 |
|
||
| barrel re-export 保持向后兼容 | 拆分后 barrel 文件 re-export 子文件导出,消费者无需改 import 路径 | 拆分后修改所有消费者的 import 路径 |
|
||
| G3-048 barrel export * 改显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` + `export type { TypeA } from "./sub"` | `export * from "./sub"`(子文件导出 < 15 时仍用 export *) |
|
||
| G3-048 barrel export * 保留 | 子文件导出 >= 15 时保留 `export * from "./sub"` + 注释说明原因 | 子文件导出 >= 15 时强行显式列举(易遗漏符号) |
|
||
| 子文件首行 `import "server-only"` | 每个 `data-access-*.ts` 文件首行 `import "server-only"` | 仅 barrel 有 `import "server-only"`,子文件缺失 |
|
||
|
||
---
|
||
|
||
## 二十八、日志系统规则(2026-07-07 重构)
|
||
|
||
### pino logger 使用
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 服务端日志统一用 pino | `import { createModuleLogger } from "@/shared/lib/logger"; const log = createModuleLogger("module-name"); log.info({...}, "msg")` | `console.log/error/warn/info(...)` |
|
||
| 模块级 logger 命名 | `createModuleLogger("audit")` / `createModuleLogger("exams")` / `createModuleLogger("client-error")` | `createModuleLogger("Audit")` / `createModuleLogger("a")` |
|
||
| 日志参数顺序 | `log.error({ err: error, userId }, "操作描述")` | `log.error("操作描述", error)` |
|
||
| 错误对象字段名 | `log.error({ err: error }, "msg")` | `log.error({ error }, "msg")` |
|
||
|
||
### Edge Runtime 限制
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| proxy.ts 不能导入 Node.js 模块 | `const requestId = crypto.randomUUID()` (Web Crypto API) | `import { randomUUID } from "node:crypto"` |
|
||
| Edge Runtime 路由不能导入 pino | `export const runtime = "nodejs"` + `import { createModuleLogger }` | `export const runtime = "edge"` + `import { createModuleLogger }` |
|
||
| 客户端组件不能导入服务端 logger | 客户端 track-event 改为纯 no-op | 客户端 .tsx 中 `import { createModuleLogger }` |
|
||
|
||
### Request ID 贯穿
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| Server Action 包装 | `export const myAction = withRequestContext(async (args) => {...})` | 直接在 Server Action 中 `requestContextStorage.run(...)` |
|
||
| Request ID 注入 | proxy.ts 通过 `NextResponse.next({ request: { headers } })` 注入 | proxy.ts 中 `requestContextStorage.run(...)` (Edge Runtime 不支持) |
|
||
| 读取 Request ID | `getRequestContext().requestId` (从 AsyncLocalStorage) | `headers().get("x-request-id")` (重复读取) |
|
||
|
||
### ESLint no-console 规则
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| 服务端 .ts 文件 | `import { createModuleLogger } from "@/shared/lib/logger"` | `console.error(...)` (ESLint error) |
|
||
| 客户端 .tsx 文件 | 暂时豁免(留待客户端错误上报机制处理) | - |
|
||
| scripts/ 和 tests/ | 允许 console(脚本/测试场景) | - |
|
||
|
||
### 客户端错误上报
|
||
|
||
| 规则 | 正确写法 | 错误写法 |
|
||
|------|---------|---------|
|
||
| error.tsx 错误上报 | `useErrorReport(error)` (Hook 自动 sendBeacon) | `useEffect(() => { console.error(error) }, [error])` |
|
||
| 上报端点 | POST /api/client-error (Route Handler 用 pino 记录) | 自定义 console 输出 |
|
||
| 节流策略 | sessionStorage 记录 digest,1 分钟内不重复上报 | 每次渲染都上报 |
|
||
|
||
---
|
||
|
||
## 二十九、数据库访问层审计 Phase 1-4 修复规则速查(2026-07-07)
|
||
|
||
> Phase 0-4 全部落地(230 条问题,17 P0/48 P1/105 P2/60 P3),tsc + lint 0 errors。
|
||
> 详细规则见第二十七节「数据库访问层审计治理规则」,本节为按规则 ID 的速查索引。
|
||
|
||
| 规则 ID | 正确写法 | 错误写法 |
|
||
|---------|---------|---------|
|
||
| F-01 N+1 递归删除 | BFS 逐层 `inArray` 收集后代 + 单次批量删除 | 递归内单独查询+删除 |
|
||
| F-01 N+1 批量统计 | 单条 `GROUP BY` 聚合 + 应用层 `Map` 归并 | 循环内每项单独查询 |
|
||
| F-01 N+1 多步写 | `db.transaction(async (tx) => { await Promise.all(items.map(...)) })` | 串行 `for (item) await db.update(...)` |
|
||
| F-02 LIKE 前缀匹配 | `like(field, escapeLikePattern(q) + "%")` | `like(field, "%" + q + "%")` |
|
||
| F-02 多字段搜索 | `or(like(t.title, esc(q)+"%"), like(t.subject, esc(q)+"%"))` | `or(like(t.title, "%"+q+"%"), ...)` |
|
||
| F-02 JSON 列搜索 | FULLTEXT 索引 + 生成列 `content_text` | `LIKE('%id%', JSON列)` |
|
||
| F-05 默认 LIMIT | `.limit(DEFAULT_LIMIT)` 无分页查询(DEFAULT_LIMIT=1000) | 无 LIMIT 的全表查询 |
|
||
| F-09 事务包裹 | `db.transaction(async (tx) => { ... })` 多步写操作 | 多个独立 `db.update` 无事务 |
|
||
| F-09 事务内用 tx | `await tx.update(...)` | `await db.update(...)`(在 transaction 回调内) |
|
||
| A-02 业务逻辑下沉 | 算法/校验放 `lib/`,CRUD 留 `data-access` | `data-access` 含状态机/抽签算法/时间冲突检测 |
|
||
| A-06 跨模块调用 | 调用对方 `data-access` 函数(`getTextbookTitlesByIds` 等) | `import { classes } from "@/shared/db/schema"` + JOIN |
|
||
| A-06 跨模块批量接口 | `getXxxTitlesByIds(ids): Map<id, title>` 一次查询 | `Promise.all(ids.map(id => getXxxTitleById(id)))` |
|
||
| A-06 别名导入避免冲突 | `import { getClassroomsForScheduling as getSchoolClassroomsForScheduling }` | 同名导出导致 duplicate identifier |
|
||
| A-10 错误处理 | `data-access` 层 `throw`,`actions` 层 `try-catch` 返回 `ActionState` | `data-access` 层 `console.error` + 吞错误返回 `[]` |
|
||
| P-03 cacheFn 配对 | `getXxxRaw` + `getXxx = cacheFn(getXxxRaw, {tags, ttl, keyParts})` | 读函数无缓存(直查 DB) |
|
||
| P-03 权限校验不缓存 | `verifyTeacherOwnsClass` 直查(避免缓存权限提升) | `verifyTeacherOwnsClass = cacheFn(...)` |
|
||
| S-01 文件拆分 | 按职责拆为 `data-access-xxx.ts` + barrel | 单文件 > 1000 行(硬上限) |
|
||
| S-01 barrel re-export | barrel `export * from "./data-access-*"` 保持向后兼容 | 拆分后修改所有消费者 import 路径 |
|
||
| S-03 helper 去重 | 提取到 `lib/` 或 `shared/lib/`(如 `lib/date-utils.ts`) | 多模块各自重复实现 `toIso`/`isClassSubject` |
|
||
| S-05 export * → 显式 | 子文件导出 < 15 时 `export { fnA, fnB } from "./sub"` | `export * from "./sub"`(导出 < 15 时) |
|
||
| S-05 export * 保留 | 子文件导出 >= 15 时保留 `export *` + 注释 | 导出 >= 15 强行显式列举(易遗漏) |
|
||
| S-06 JSDoc | 公开导出函数补 `@param` + `@returns` + `@throws` | 无 JSDoc |
|
||
| P-09 as 断言 | 类型守卫 / `satisfies` / 类型标注常量 | `value as Type` |
|
||
| P-09 非空断言 | `const row = rows[0]; if (!row) throw ...; row.field` | `rows[0]!.field` |
|
||
| P-09 split 兜底 | `str.split("T")[0] ?? ""` | `str.split("T")[0]!` |
|
||
|
||
|