Files
NextEdu/docs/troubleshooting/known-issues.md
SpecialX 214ebec976 refactor(design-tokens): 全量体系化重建设计令牌
Primitive + Semantic 双层令牌架构,HEX->HSL,明暗双份,@theme inline 暴露为 Tailwind 类。

- 新建 src/app/styles/tokens/ 6 个令牌文件(primitive/semantic-light/semantic-dark/lesson-preparation/tailwind-theme/index)
- globals.css 改为 @import 引入,477->258 行
- 清理 91 处 #hex 硬编码颜色 -> hsl(var(--*))
- 清理 10 处硬编码字体 -> var(--font-family-*)
- 清理 100 文件 Tailwind 任意值(Tier 1 映射/Tier 3 注释豁免)
- 清理 M3 Surface 死代码,升级 --lp-* 令牌(HEX->HSL + 暗色补全)
- 新建 ESLint 自定义规则 no-hardcoded-design-tokens(单词边界正则)
- eslint.config.mjs 新增 no-restricted-syntax 禁止 #hex + 自定义规则加载(pathToFileURL)
- 项目规则新增设计令牌规范强制章节
- 架构图 004/005 同步设计令牌体系节点
- known-issues.md 追加设计令牌问题分类(7 个规则表)

验证: tsc --noEmit 0 errors, npm run lint 0 errors/12 warnings(均为既有问题)
2026-07-05 01:03:19 +08:00

55 KiB
Raw Blame History

项目规则速查手册

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


一、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() 参数为动态变量时,必须包含完整嵌套路径

// 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.tsconstants.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.tsconstants.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@/proxymodules/*

七、模块文件职责规则

文件 职责 禁止
actions.ts Server Actionsasync 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:pushnpm 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.mjsnpm 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.sql0016_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 添加对应 entrytag = 文件名不含扩展名)
  3. 计算文件 sha256 hash插入 __drizzle_migrations 表:
    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.ctrlKeyMac 不工作)
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: trueXSS 风险)
插入图片命令 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 返回 LessonPlanAttachmentDateservice 转换为 LessonPlanAttachmentOptionstring 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.chapterIdstring | 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.

正确示例

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.

修复模式

// 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 会误判为错误边界触发。

正确检测方式

# 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}> 不传 sessionclient 端首次渲染为 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",导致 SiteHeaderdisplayName/avatarFallback 渲染结果不同,触发 Radix UI 组件重新生成 id 属性。

修复模式

// 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.

修复模式

// 错误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}.jsonerror.boundaryTitle / error.boundaryDescription / error.retry 全部存在 仅添加 error.retry
新增 namespace 用法时同步检查 i18n 文件 <SectionErrorBoundary namespace="notifications"> → 检查 notifications.json 是否有 error.boundaryTitle/Description 仅检查 error.retry
所有语言文件同步补全 zh-CN/X.jsonen/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 缺少这些 keynext-intl 在 SSR 期间抛 IntlError,导致 Server Component 渲染失败React 回退到 client 渲染,触发 hydration mismatch。

修复模式

// 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}

修复模式

// 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,91actions.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(双重断言)

严重违规双重断言:

  • 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.tsupdateNode 显式标注 AnyLessonPlanNode 返回类型 + unknown 中间变量
  • lesson-preparation/hooks/use-node-ai-assist.ts — 4 处 as BlockData 替换为 mergeBlockDataPatch
  • lesson-preparation/lib/ai-node-assist.tsNodeContentUpdate.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.tsxsetReason(v as MessageReportReason) 替换为 if (isMessageReportReason(v)) setReason(v)

非空断言 !. 规则

规则 正确写法 错误写法
数组首元素先做存在性判断 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:113data-access-review.ts:51,83,217data-access-substitutes.ts:131lib/curriculum-coverage.ts:90

ESLint 零警告规则

规则 正确写法 错误写法
effect 中读 localStorage 用 useEffectEvent 或加依赖 useEffect(() => { setRecentIds(readRecentTextbookIds()) }, []) 改用 useSyncExternalStore 或初始化函数 useEffect(() => { setRecentIds(readRecentTextbookIds()) }, []) 触发 set-state-in-effect
禁止 eslint-disable-next-line 修正依赖数组或用 useCallback 包裹 // eslint-disable-next-line react-hooks/exhaustive-deps

涉及文件:template-picker.tsx:72errorschedule-dialog.tsx:53warninglesson-plan-editor.tsx:81disable

Tailwind 任意值规则

规则 正确写法 错误写法
优先映射 Tailwind 默认阶梯 min-h-[40px]min-h-10min-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-20Tier 3 豁免 26 处dialog 宽度/textarea min-h/badge text-[10px]/tiptap 等)

i18n 翻译文件对称性规则

规则 正确写法 错误写法
zh-CN 与 en 必须 key 完全对称 同步增删 keyCI 校验 key 差集 单边新增 key导致 fallback
缺失 key 必须补齐 analytics.publishedPlans 等同步到 zh-CN en 有 12 个 analytics.* key 但 zh-CN 缺失

涉及文件:zh-CN/lesson-preparation.json(缺 12 个 analytics.* keyen/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 title: \${src.title}(副本)`` 硬编码"副本"
  • lib/export.ts:165-271 5 个 label 映射表全硬编码中文dimensionLabel/importMethodLabels/homeworkTypeLabels/blackboardLayoutLabels/reflectionAspectLabels
  • lib/export.ts:246 item.source === "inline" ? "课案内新建" : "题库" 硬编码
  • lib/export.ts:271 turn.role === "teacher" ? "师" : "生" 硬编码

AI Prompt 中文常量规则(允许)

规则 正确写法 错误写法
AI prompt 提取为模块常量 const AI_SUGGEST_PROMPT_TEMPLATE = \...`` 散落在函数体内
AI prompt 不强制 i18n 开发期调优的固定 prompt可保持中文

合规文件:ai-suggest.ts:31lib/ai-differentiation.ts:80,92,103 已提取为常量


二十、i18n Namespace 与文件名一致性规则

规则 正确写法 错误写法
useTranslations namespace 必须与 i18n/request.ts 配置一致 useTranslations("errorBook")(驼峰,与 request.tserrorBook: errorBook.default 一致) useTranslations("error-book")(连字符,与文件名 error-book.json 混淆)
namespace 跟 request.ts 的 messages key不跟文件名 文件名 error-book.json → messages key errorBookuseTranslations("errorBook") 文件名 error-book.jsonuseTranslations("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 错误。

修复模式

// 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.searchhandleSelect 点击时读取 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 fallbackCSR 显示真实内容,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> 显示 fallbackSkeleton而 CSR 期间直接显示真实内容。组件树结构在 SSR/CSR 间不同,导致 React useId() 生成的 id 不匹配,触发全局 hydration mismatch影响页面所有 Radix UI 和 ChartContainer 组件的 id

修复模式

// 错误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 和数据。

根因 1Next.js 默认启用 reactStrictMode: truedev 模式下 useEffect 执行两次mount → unmount → mount。若 Hook 内用 lastRequestKey ref 防重复,第一次 mount 发起 action 后 cleanup 设 cancelled=true,第二次 mount 因 key 相同跳过不发起 action第一次 action 结果被 cancelled 忽略state 永不更新。

根因 2next/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.totalPublisheden 只有 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 后返回 401401 是未认证)
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 stringsParsing 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-elevatedbg-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 */}
<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.tssrc/modules/notifications/channels/email-channel.tssrc/app/styles/tokens/primitive.css 其它文件直接写 #hexESLint 报错)
白名单文件 #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.mjsno-restricted-syntax + 自定义规则加载(pathToFileURL
  • .trae/rules/project_rules.md — 「Tailwind 规范」+ 「设计令牌规范(强制)」章节
  • docs/architecture/004_architecture_impact_map.md — 1.1.2 设计令牌体系章节
  • docs/architecture/005_architecture_data.jsonmodules.shared.exports.designTokens 节点