项目规则速查手册
一页式规则清单,指明正确做法。无需解释原因,遇到问题查表即可。
一、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() 参数为动态变量时,必须包含完整嵌套路径:
| 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 错误时:
- 用
SHOW COLUMNS FROM 表名 对比数据库实际结构与 schema.ts
- 若字段缺失,执行
npm run db:push 同步,或手动执行对应迁移 SQL
- 验证:用相同 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 表缺失时)
- 在
drizzle/ 创建 00XX_*.sql 文件(即使 DDL 已应用,仍需归档)
- 在
drizzle/meta/_journal.json 添加对应 entry(tag = 文件名不含扩展名)
- 计算文件 sha256 hash,插入
__drizzle_migrations 表:
- 验证:
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 |
— |
错误现象
正确示例
十四、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()}(含函数) |
错误现象
修复模式
十五、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 会误判为错误边界触发。
正确检测方式
十六、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 不一致 |
错误现象
根因
useSession() 在 SSR 期间返回 status:"loading",CSR hydration 后变成 "authenticated",导致 SiteHeader 中 displayName/avatarFallback 渲染结果不同,触发 Radix UI 组件重新生成 id 属性。
修复模式
十七、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")) |
错误现象
修复模式
十八、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 |
— |
错误现象
根因
SectionErrorBoundary 内部调用 t("error.boundaryTitle") 和 t("error.boundaryDescription"),若 namespace JSON 缺少这些 key,next-intl 在 SSR 期间抛 IntlError,导致 Server Component 渲染失败,React 回退到 client 渲染,触发 hydration mismatch。
修复模式
十九、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 }} |
错误现象
修复模式
二十、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 文件不注册 |
错误现象
根因
i18n/request.ts 中配置的 messages key 是 errorBook(驼峰),但 17 个组件错误使用了 useTranslations("error-book")(连字符,与文件名 error-book.json 混淆)。next-intl 找不到 error-book namespace,所有 t() 调用抛出 MISSING_MESSAGE 错误。
修复模式
二十一、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 |
— |
错误现象
根因
useSearchParams() 在 SSR 期间会导致包裹它的 <Suspense> 显示 fallback(Skeleton),而 CSR 期间直接显示真实内容。组件树结构在 SSR/CSR 间不同,导致 React useId() 生成的 id 不匹配,触发全局 hydration mismatch(影响页面所有 Radix UI 和 ChartContainer 组件的 id)。
修复模式
二十二、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 |