# 项目规则速查手册 > 一页式规则清单,指明正确做法。无需解释原因,遇到问题查表即可。 --- ## 一、i18n 翻译文件规则 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | key 禁止包含 `.` | `"title": { "parent": "..." }` | `"title.parent": "..."` | | 同字段多角色变体用嵌套对象 | `"title": { "default": "...", "parent": "...", "teacher": "..." }` | `"title": "..."`, `"title.parent": "..."` | | 不同字段用驼峰命名 | `"createDesc": "..."` | `"create.desc": "..."` | | 调用方 `t("title.parent")` 自动解析嵌套 | 无需改调用方代码 | — | | 动态拼 key 需包含完整路径 | `t(\`type.${type}\`)`(type 在 type 对象下) | `t(type)`(漏掉 `type.` 前缀) | | 嵌套 key 调用需带父级路径 | `t(\`difficulty.${level}\`)` | `t(level)` | ### i18n 动态 key 调用规则 当 `t()` 参数为动态变量时,**必须包含完整嵌套路径**: ```typescript // JSON 结构: { "type": { "single_choice": "单选题", "multiple_choice": "多选题" } } // 正确:带父级路径 const label = t(`type.${question.type}`) // t("type.single_choice") // 错误:漏掉父级路径,触发 MISSING_MESSAGE const label = t(question.type) // t("single_choice") → 找不到 ``` | ICU 参数用单花括号 | `"level": "难度 {level}"` | `"level": "难度 {{level}}"` | | ICU 多参数直接并列 | `"summary": "{count} 题 · {subject}"` | `"summary": "{{count}} 题 · {{subject}}"` | --- ## 二、"use server" 文件规则 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | 只能导出 `async function` | `export async function foo() {}` | `export const foo = ...` | | 常量/对象放 `types.ts` 或 `constants.ts` | `types.ts: export const CONFIG = {...}` | `actions.ts: export const CONFIG = {...}` | | 同步函数放 `lib/` 目录 | `lib/track-event.ts: export function track() {}` | `actions.ts: export function track() {}` | | 禁止值 re-export | 调用方直接从源文件 import | `export { foo } from "./bar"` | | 类型 re-export 允许 | `export type { Foo } from "./bar"` | — | --- ## 三、Client Component 依赖隔离规则 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | 常量定义放 `types.ts` 或 `constants.ts` | `types.ts: export const MAX = 30` | `retention.ts: export const MAX = 30`(含 `import { db }`) | | Client Component 不得 import server-only 文件 | `import { MAX } from "../types"` | `import { MAX } from "../retention"`(拉入 mysql2) | | `import { db }` 或 `import "server-only"` 的文件不得被客户端 import | — | — | | 纯类型文件不得 import db/mysql2 | `types.ts` 无 server-only import | `types.ts: import { db } from "@/shared/db"` | --- ## 四、可选依赖处理规则 | 规则 | 正确写法 | 错误写法 | |------|---------|---------| | 可选依赖动态 import 加 `webpackIgnore` | `await import(/* webpackIgnore: true */ "@upstash/redis")` | `await import("@upstash/redis")` | | 环境变量控制加载的模块用动态 import | `if (env.DRIVER === "redis") { const { Redis } = await import(...) }` | 顶层静态 import | | 仅 redis 驱动加载的类用动态 import | `const { RedisRateLimiter } = await import("./redis-limiter")` | `import { RedisRateLimiter } from "./redis-limiter"` | --- ## 五、Next.js 配置规则 | 规则 | 配置 | |------|------| | Node.js 服务端驱动标记为外部包 | `serverExternalPackages: ["mysql2"]` | | 输出模式 | `output: "standalone"` | --- ## 六、架构分层规则 | 层级 | 允许依赖 | 禁止依赖 | |------|---------|---------| | `app/` | `modules/` 的 actions + data-access | 直接访问 DB | | `modules/` | 其他 `modules/` 的 data-access + `shared/` | 直接查询对方 DB 表 | | `shared/` | 无 | `@/auth`、`@/proxy`、`modules/*` | --- ## 七、模块文件职责规则 | 文件 | 职责 | 禁止 | |------|------|------| | `actions.ts` | Server Actions(async function + 权限校验) | 导出常量、同步函数、值 re-export | | `data-access.ts` | 数据访问(可拆分 `data-access-*.ts`) | 业务逻辑、权限校验 | | `types.ts` | 类型定义 + 常量 | `import { db }`、`import "server-only"` | | `schema.ts` | Zod 验证 | — | | `lib/` | 同步工具函数、业务逻辑 | "use server" 标记 | --- ## 八、验证命令速查 | 场景 | 命令 | |------|------| | 类型检查 | `npx tsc --noEmit` | | Lint | `npm run lint` | | 开发服务器 | `npm run dev` | | 生产构建 | `npm run build` | | 检查 i18n key 是否含 `.` | Grep pattern: `^\s*"[a-zA-Z_]+\.[a-zA-Z_\.]+"\s*:`,glob: `**/messages/**/*.json` | | 同步 schema 到数据库 | `npm run db:push` | | 执行迁移文件 | `npm run db:migrate` | | 检查表结构 | `SHOW COLUMNS FROM table_name` | --- ## 十、数据库 Schema 同步规则 | 规则 | 正确做法 | 错误做法 | |------|---------|---------| | 修改 schema.ts 后必须同步数据库 | `npm run db:push` 或 `npm run db:migrate` | 仅改 schema.ts 不同步数据库 | | 报错 `Unknown column` 时先查表结构 | `SHOW COLUMNS FROM xxx` | 仅看代码 schema 假设数据库已同步 | | 迁移文件按编号顺序执行 | `drizzle/00XX_*.sql` 按序号递增 | 跳过编号或乱序执行 | | `Failed query` 错误先排查数据库结构 | 用 mysql 客户端执行相同 SQL 确认 | 仅看代码假设查询逻辑错误 | | 新增字段必须生成迁移文件 | `npm run db:generate` 后 commit `.sql` + `_journal.json` | 仅 ALTER 数据库不留迁移文件 | | 修改 schema 后验证一致性 | `node scripts/verify-modules-schema.mjs` 或 `npm run db:push` | 仅看 dev server 不报错即认为成功 | ### 数据库同步问题排查 当出现 `Unknown column 'xxx' in 'field list'` 或 `Failed query` 错误时: 1. 用 `SHOW COLUMNS FROM 表名` 对比数据库实际结构与 `schema.ts` 2. 若字段缺失,执行 `npm run db:push` 同步,或手动执行对应迁移 SQL 3. 验证:用相同 SQL 在数据库客户端执行,确认不再报错 --- ## 十一、Drizzle 迁移记录管理规则 | 规则 | 正确做法 | 错误做法 | |------|---------|---------| | 迁移文件必须录入 `_journal.json` | `npm run db:generate` 自动生成 entry | 手动创建 `.sql` 不更新 journal | | `__drizzle_migrations` 表必须记录已应用迁移 | `npm run db:migrate` 自动写入 hash | 手动 ALTER 后不补 hash 记录 | | 迁移文件编号唯一不可复用 | `0016_invitation_codes.sql` 与 `0016_message_reports.sql` 应改 `0022+` | 同前缀冲突 | | 手动补 DDL 后必须同步 journal + migrations 表 | 见下方"手动补迁移"流程 | 仅 ALTER 数据库,留 journal 缺失 | | 检查迁移状态 | `SELECT * FROM __drizzle_migrations` | 仅看 `_journal.json` 文件 | ### 手动补迁移流程(数据库已 ALTER 但 journal/migrations 表缺失时) 1. 在 `drizzle/` 创建 `00XX_*.sql` 文件(即使 DDL 已应用,仍需归档) 2. 在 `drizzle/meta/_journal.json` 添加对应 entry(`tag` = 文件名不含扩展名) 3. 计算文件 sha256 hash,插入 `__drizzle_migrations` 表: ```sql INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('', ); ``` 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 &&