docs(cache): 同步 cacheFn 架构图与已知问题速查

This commit is contained in:
SpecialX
2026-07-05 18:07:18 +08:00
parent d6227d6e6c
commit 0f9d8825e7
3 changed files with 551 additions and 17 deletions

View File

@@ -135,6 +135,157 @@ src/app/
---
## 1.1.3 Phase 4.2 + Phase 4.5 状态管理优化2026-07-05 新增)
V5 状态管理统一的后续阶段,针对两类高频性能问题进行细粒度优化:**Zustand 整体订阅导致的级联 re-render** 与 **useState + 等待 Server Action 模式导致的 UI 不即时反馈**
### Phase 4.2lesson-plan-editor Zustand selector 拆分
`useLessonPlanEditor` 是组合了 5 个 slice 的 zustand store。原消费方使用 `const editor = useLessonPlanEditor()` 整体订阅,导致任一字段变化都触发整体 re-render。
| 文件 | 修改要点 |
|------|---------|
| `modules/lesson-preparation/components/lesson-plan-editor.tsx` | 拆分为 9 个细粒度 selector`isDirty`/`isOnline`/`doc`/`title`/`isSaving`/`saveError`/`lastSavedAt`/`setTitle` + `canUndo`/`canRedo` 派生 boolean移除 `editor.canUndo()` 重复调用,更新 `useEffect` 依赖与 `printablePlan` useMemo 使用拆分后的变量 |
| `modules/lesson-preparation/components/blocks/text-study-block.tsx` | `const { updateNode } = useLessonPlanEditor()``useLessonPlanEditor((s) => s.updateNode)` |
| `modules/lesson-preparation/components/blocks/exercise-block.tsx` | 拆为 `useLessonPlanEditor((s) => s.updateNode)` + `useLessonPlanEditor((s) => s.planId)` |
| `modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx` | 拆为 7 个细粒度 selector`toggleExpand`/`expandedNodeIds`/`updateNode`/`removeNode`/`addAnchor`/`duplicateNode`/`nodes`),特别地将 `doc` 改为 `s.doc.nodes` 更精细订阅(`addAnchor` 等仅修改 anchors 不会触发本组件 re-render |
**关键决策**
- `canUndo`/`canRedo` 改为 `useLessonPlanEditor((s) => s.canUndo())` 订阅派生 boolean避免每次渲染时调用函数
- 不使用 `useShallow` 包装多字段 selector因为单字段 selector 已经够细,且避免了 `useShallow` 自身的浅比较开销
- 函数引用(`setTitle`/`updateNode` 等)天然稳定,无需 `useShallow`
### Phase 4.5useOptimistic + useTransition 乐观更新
将 4 个组件从 `useState` + 等待 Server Action 模式迁移到 React 19 `useOptimistic` + `useTransition`,自动管理乐观状态与回滚。
| 文件 | 修改要点 |
|------|---------|
| `modules/messaging/components/message-detail.tsx` | 星标 `useState``useOptimistic<boolean>` + `useTransition``handleToggleStar``startStarTransition``addOptimisticStarred(nextStarred)` + `await toggleMessageStarAction` + `router.refresh()`。JSX 中所有 `isStarred` 引用改为 `optimisticIsStarred`Button variant/aria-pressed/Star className/星标徽章) |
| `modules/messaging/components/message-list.tsx` | 星标 `starredOverride` useState → `useOptimistic<Map<string, boolean>>`key=messageId, value=isStarred`getIsStarred` 改为 `optimisticStarredMap.get(m.id)`。撤回 `recalledOverride` 仍保留 useState不需要乐观更新 |
| `modules/grades/components/grade-record-list.tsx` | 编辑保存 `isSaving` useState → `useOptimistic<GradeRecordListItem[], GradeRecordListItem>` + `useTransition``handleEditSave` 构造 `optimisticRecord``startSaveTransition``addOptimisticRecord` + `await updateGradeRecordAction` + `router.refresh()`。两处 `records.map` 改为 `optimisticRecords.map`(桌面表格 + 移动端卡片视图)即时显示乐观记录 |
| `modules/attendance/components/attendance-sheet.tsx` | `isSubmitting` useState → `useOptimistic<boolean>`。保留 `<form action={handleSubmit}>` 模式 + `useFormStatus` SubmitButtonprogressive enhancement`setOptimisticSubmitting(true)` 在 form action 内调用action 完成后 `optimisticSubmitting` 自动恢复 false移除 `finally` 块 |
**关键决策**
- attendance-sheet 保留 form action 模式而非改用 `useTransition` 包裹,因为 `useFormStatus().pending` 依赖 form action 的 promise 状态,若用 `startTransition` 包裹会导致 form action 立即返回、`useFormStatus` 不可靠
- `useOptimistic``addOptimistic` 必须在 transition 或 action 内调用form action 也算 action
- 成功后调用 `router.refresh()` 同步数据源,让 `useOptimistic` 回滚到最新服务端值
- 不为撤回recall操作使用 useOptimistic因为撤回需要根据服务端返回判断是否成功2 分钟窗口校验),失败时不应乐观显示已撤回
### 验证结果
- `npx tsc --noEmit`零新增错误pre-existing 错误:`providers.tsx` 缺少 `@tanstack/react-query-devtools``web-vitals-reporter.tsx` Metric 导出、`homework/data-access-write.ts` MySqlTransaction.run、`paper-context-menu.tsx` 第 58 行 `duplicateNode` 调用签名不匹配——此为 pre-existing bug`duplicateNode(id: string)` 只接受 1 参数但调用方传 2 个、`print-view.tsx` Object.entries 类型推断)
- `npm run lint`零新增错误8 个 modified 文件均未出现在 lint 输出中pre-existing 3 errors + 13 warnings 均位于未修改文件:`scripts/check-db-state.mjs``scripts/seed-grade5-chinese.ts``practice-result-view.tsx``exam-rich-form.tsx``use-exam-preview-state.ts``use-exam-preview-tasks.ts``homework/data-access-write.ts``unread-message-badge.tsx`
---
## 1.1.4 Phase 3.6 + Phase 3.7 + Phase 3.8 DB 性能优化2026-07-05 新增)
针对数据库查询性能的三阶段优化:**FULLTEXT 全文检索**、**公告 fan-out 批量化**、**高 LIMIT 默认值调低**。
### Phase 3.6searchQuestions 改 FULLTEXT 索引
将 questions 表的模糊查询从 `LIKE '%xxx%'` 升级为 MySQL FULLTEXT 索引 + `MATCH AGAINST ... IN BOOLEAN MODE`,提升大表检索性能。
| 文件 | 修改要点 |
|------|---------|
| `src/shared/db/schema.ts` | 添加 `import { sql } from "drizzle-orm"`;在 `questions` 表添加 STORED 生成列 `contentText``text("content_text").generatedAlwaysAs(sql\`CAST(content AS CHAR)\`, { mode: "stored" })`);补齐 5 个先前未同步的索引typeDifficultyIdx/statusCreatedIdx/creatorIdx/examStatusIdx/submittedAtIdx |
| `drizzle/0001_questions_fulltext_search.sql` | drizzle-kit 生成的迁移CREATE TABLE lesson_plan_schedules + content_text 生成列 + 8 个索引;末尾手动追加 `CREATE FULLTEXT INDEX questions_content_text_ft_idx ON questions (content_text)` |
| `drizzle/meta/0001_snapshot.json` | questions 表 indexes 节点添加 `questions_content_text_ft_idx` 声明type: "fulltext" |
| `src/modules/search/data-access.ts` | `searchQuestions(kw, limit?)` → `searchQuestions(q, limit?)`;新增 `toBooleanModeQuery` 工具函数将原始查询转为 `+word1* +word2*` 格式;查询改用 `MATCH(questions.contentText) AGAINST(? IN BOOLEAN MODE)` |
| `src/app/api/search/route.ts` | 本地 `searchQuestions` 函数同步改用 MATCH AGAINST + `toBooleanModeQuery`;调用点从 `searchQuestions(kw, pageSize)` 改为 `searchQuestions(q, pageSize)` |
**关键决策**
- 使用 STORED 生成列 `content_text` 而非 VIRTUAL因为 FULLTEXT 索引在 MySQL 5.7+ 仅支持 STORED 生成列
- `CAST(content AS CHAR)` 将 JSON 字段序列化为纯文本,避免 MySQL FULLTEXT 无法直接索引 JSON 列的限制
- drizzle-kit 无法生成 FULLTEXT 索引声明,迁移文件中需手动追加 `CREATE FULLTEXT INDEX` 语句,并同步更新 snapshot 的 indexes 节点
- `toBooleanModeQuery` 转义 BOOLEAN MODE 特殊字符(+ - < > ( ) ~ * " ')避免用户输入破坏查询语法
- 保留 textbooks/exams/announcements 的 LIKE 模糊匹配FULLTEXT 索引需 InnoDB + utf8mb4且这些表查询量较小
### Phase 3.7getAllUserIds 加 LIMIT + 公告 fan-out 批量化
| 文件 | 修改要点 |
|------|---------|
| `src/modules/users/data-access.ts` | `getAllUserIds()` → `getAllUserIds(limit=1000, offset=0)`,添加默认 LIMIT 防止 OOM + 分页参数支持 |
| `src/modules/announcements/data-access.ts` | `resolveAnnouncementTargetUserIds` school 分支改为分页循环遍历 `getAllUserIds(PAGE_SIZE, offset)`PAGE_SIZE=1000聚合所有用户 ID |
| `src/modules/notifications/data-access.ts` | 新增 `createNotifications(items)` 批量 INSERT 函数(单次 `db.insert().values([...])`),返回生成的 ID 数组 |
| `src/modules/notifications/channels/in-app-channel.ts` | `InAppChannelSender.sendBatch` 从 `Promise.all(items.map(send))` 改为调用 `createNotifications(inputs)` 单次批量 INSERT预校验 recipient/userId 一致性;失败时整体回滚并返回失败结果 |
| `src/modules/notifications/dispatcher.ts` | `sendBatchNotifications` 重构:并行获取所有用户偏好+联系方式 → 收集 in_app 渠道 payload 单次 `sendBatch` → 其他渠道保持 per-payload 并行 → 按 payload 聚合日志 |
| `src/modules/notifications/index.ts` | 导出 `createNotifications` |
**关键决策**
- `getAllUserIds` 默认 LIMIT 1000 是平衡单次查询压力与 fan-out 完整性的折中值;调用方通过分页遍历获取全部用户
- `createNotifications` 使用 drizzle 的 `db.insert().values(array)` 一次 INSERT 多行,相比 N 次单行 INSERT 显著降低网络往返
- in_app 渠道批量化其他渠道sms/email/wechat保持原并行模式因为这些渠道的发送成本主要在网络 IO 而非 DB 写入
- 批量 INSERT 失败时整体回滚drizzle 默认事务行为),每条记录返回失败结果
### Phase 3.8:调低高 LIMIT 默认值
在 `src/modules/*/data-access*.ts` 中搜索 LIMIT > 100 的默认值并调低至 ≤100保留分页场景
| 文件 | 原 LIMIT | 新 LIMIT | 函数 |
|------|---------|---------|------|
| `src/modules/attendance/data-access-correlation.ts` | 5000 | 100 | `getAttendanceGradeCorrelation` 调用 `getGradeRecords` |
| `src/modules/adaptive-practice/data-access-strategy.ts` | 1000 | 100 | `getStudentAnsweredQuestionIds` |
| `src/modules/questions/data-access.ts` | 1000 | 100 | `exportQuestions` |
**未修改(不在 data-access*.ts 范围内)**
- `src/modules/homework/stats-service.ts:276`limit: 200和 `:345`limit: 5000不在 `data-access*.ts` glob 范围内,本次未修改
### 验证结果
- `npx tsc --noEmit`零新增错误pre-existing 错误:`providers.tsx`、`web-vitals-reporter.tsx`、`homework/data-access-write.ts`、`paper-context-menu.tsx`、`print-view.tsx` 均为未修改文件)
- `npm run lint`零新增错误16 个 pre-existing problems 均位于未修改文件:`scripts/check-db-state.mjs`、`scripts/seed-grade5-chinese.ts`、`practice-result-view.tsx`、`exam-rich-form.tsx`、`use-exam-preview-state.ts`、`use-exam-preview-tasks.ts`、`homework/data-access-write.ts`、`unread-message-badge.tsx`
---
## 1.1.5 性能预算重构专项2026-07-05 新增)
> 完整审计报告见 `docs/architecture/audit/performance-budget-audit-report.md`。
> 本章节记录本次重构对架构产生的关键变更,以便后续维护时定位。
### 影响范围概览
| Phase | 改造内容 | 关键文件 |
|-------|---------|---------|
| 2.1 | tiptap 动态导入(3 处) | `shared/components/ui/rich-text-editor.tsx` + `modules/exams/editor/exam-rich-editor.tsx` + `modules/lesson-preparation/components/blocks/rich-text-block.tsx` |
| 2.2 | ReactFlow 动态导入 | `modules/textbooks/components/knowledge-graph.tsx` 拆为 `knowledge-graph-inner.tsx` + lazy wrapper,ReactFlowProvider 留外层 |
| 2.3 | exams/editor barrel 拆分 | 新增 `modules/exams/editor/types.ts` 作为纯类型入口,供 server-only 消费方引用 |
| 2.4 | chart.tsx barrel + status-badge 移除 use client | chart.tsx 已是具名导出;`status-badge.tsx` 移除 `"use client"`(纯展示组件) |
| 2.5 | 4 个角色根路由 loading.tsx | 新增 `teacher/loading.tsx` + `student/loading.tsx` + `parent/loading.tsx`(admin 已有) |
| 3.1 | unstable_cache 评估 | 评估后决定不引入,与项目现有 React `cache()` 模式一致 |
| 3.2 | auth 函数 cache 包装 | `shared/lib/session.ts:getSession` + `shared/lib/auth-guard.ts:resolveDataScope` + `getAuthContext` + `modules/classes/data-access.ts:getSessionTeacherId` 全部用 React `cache()` 包装,`getSessionTeacherId` 改走 `getAuthContext()` |
| 3.3 | copyCoursePlanToClasses 批量化 | `modules/course-plans/data-access.ts:347` 改为单事务 + 2 次批量 INSERT,O(N*M) → O(1) |
| 3.4 | gradeHomeworkAnswers 批量 UPDATE | `modules/homework/data-access-write.ts:356` 改为单次 `UPDATE ... CASE WHEN` + `sql.join`,替代 N 次串行 UPDATE |
| 3.5 | exams 表索引补齐 | `shared/db/schema.ts` 新增 5 个索引,迁移 `drizzle/0001_questions_fulltext_search.sql` |
| 3.6 | searchQuestions FULLTEXT | `shared/db/schema.ts` 新增 `contentText` STORED 生成列 + `questions_content_text_ft_idx` FULLTEXT 索引;`search/data-access.ts` 改用 `MATCH(...) AGAINST(... IN BOOLEAN MODE)` |
| 3.7 | 公告 fan-out 批量化 | `getAllUserIds` 加分页参数;新增 `notifications/data-access.ts:createNotifications` 批量 INSERT;`dispatcher.sendBatchNotifications` 重构为 in_app 单次批量 + 其他渠道并行 |
| 3.8 | 高 LIMIT 调低 | attendance 5000→100、adaptive-practice 1000→100、questions 1000→100 |
| 4.1 | SidebarProvider memoize | `modules/layout/components/sidebar-provider.tsx` resize debounce 200ms + useMemo context + useCallback toggleSidebar |
| 4.2 | lesson-plan-editor selector 拆分 | `lesson-plan-editor.tsx` + 3 个子组件改为单字段 selector |
| 4.3 | 题库表 + 考试表虚拟化 | 新增 `@tanstack/react-virtual` 依赖;`question-data-table.tsx` + `exam-data-table.tsx` 引入 `useVirtualizer` |
| 4.4 | recharts props 模块级常量 | 14 个 chart 文件提取 `CHART_MARGIN`/`AXIS_TICK`/`TOOLTIP_CURSOR` 等模块级常量 |
| 4.5 | useOptimistic 改造 | `message-detail.tsx` + `message-list.tsx` + `grade-record-list.tsx` + `attendance-sheet.tsx` 改用 `useOptimistic` + `useTransition` |
| 4.6 | 高频子组件 React.memo | `AssignmentCard` + `StatusBadge` + `EmptyState` 添加 React.memo(ClassCard/GraphKpNode 已有) |
| 4.7 | AiAssistantWidget 懒加载 | 新增 `modules/ai/components/ai-assistant-widget-inner.tsx`,外层改为 lazy wrapper |
| 4.8 | scan-image-viewer aspect-ratio | `modules/homework/components/scan-image-viewer.tsx` `<img>` 加 `aspectRatio: "4 / 3"` + width/height 属性 |
| 4.9 | metadata 增强 | `app/layout.tsx` 补 `metadataBase`/`openGraph`/`robots`/`applicationName`/`authors`/`creator` |
### 关键架构决策
1. **动态导入模式标准化**:重 UI 库(tiptap / @xyflow/react / AI SDK)统一采用 `xxx-inner.tsx` + `xxx.tsx` lazy wrapper 模式。ReactFlowProvider 等 context provider 留在外层同步渲染,保证 lazy 子组件的 `useReactFlow()` 等 hook 可用。
2. **请求级去重优先于跨请求缓存**:auth 相关 4 个核心函数(getSession/resolveDataScope/getAuthContext/getSessionTeacherId)全部用 React `cache()` 包装。不引入 `unstable_cache`,因权限数据易变,跨请求缓存风险高于收益。
3. **批量 SQL 优于循环 SQL**:
- INSERT 场景:用 `db.insert().values([...])` 批量
- UPDATE 场景:用 `sql.join` + `CASE WHEN` 单次执行
- 复杂事务:用 `db.transaction` 包裹多个批量操作
4. **React 19 useOptimistic 替代手动 isPending**:消息星标/出勤/成绩录入统一改为 `useOptimistic` + `useTransition`,移除手动 `useState<boolean>` 跟踪 pending。
5. **细粒度 Zustand selector**:单字段 selector 优先于 `useShallow` 多字段 selector,因为函数引用天然稳定。
---
## 1.2 模块依赖关系图
下图展示模块间的实际依赖关系,**标注依赖类型与合规性**。
@@ -842,7 +993,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- Data-access`getQuestions`(✅ 审计升级:支持 `textbookId`/`chapterId` 级联筛选参数,通过 textbooks data-access 获取知识点 ID 集合)/ `createQuestionWithRelations` / `updateQuestionById` / `deleteQuestionByIdRecursive` / `getKnowledgePointOptions` / `getTextbookOptions`(✅ 审计新增)/ `getChapterOptions(textbookId)`(✅ 审计新增)/ `getKnowledgePointOptionsByChapter(chapterId)`(✅ 审计新增)/ `deleteQuestionsBatch`(✅ 审计新增:批量删除,事务原子性)/ `exportQuestions`(✅ 审计新增:导出,权限范围过滤)/ `importQuestions`(✅ 审计新增:批量导入,事务原子性)
- Types`Question` / `QuestionWithRelations` / `GetQuestionsParams`(✅ 审计升级:新增 `textbookId`/`chapterId` 字段)/ `CreateQuestionInput` / `KnowledgePointOption` / `TextbookOption`(✅ 审计新增)/ `ChapterOption`(✅ 审计新增)/ `QuestionContent`(✅ 审计新增:类型守卫接口)/ `QuestionOption`(✅ 审计新增)/ `QUESTION_TYPE_I18N_KEY`(✅ 审计新增:题型 i18n 键映射)/ `DIFFICULTY_I18N_KEY`(✅ 审计新增:难度 i18n 键映射)
- Utils`parseQuestionContent(raw)`(✅ 审计新增:类型守卫,替代 `as` 断言)/ `getQuestionPreview` / `getQuestionOptions` / `getQuestionText` / `trackQuestionEvent`(✅ 审计新增:埋点接口 no-op/ `trackQuestionCreated` / `trackQuestionUpdated` / `trackQuestionDeleted` / `trackQuestionSearched`
- Components`QuestionFilters`(✅ 审计重写:组合 QuestionCascadeFilter/ `QuestionCascadeFilter`(✅ 审计新增三级级联筛选nuqs 管理 URL 状态)/ `QuestionDataTable`(✅ 审计重写i18n + 集成 BatchOperations/ `useQuestionColumns`(✅ 审计改为 Hook因 useTranslations 是 Hook/ `CreateQuestionButton`(✅ 审计重写:权限感知)/ `QuestionActions`(✅ 审计重写:权限感知 + 类型守卫 + QuestionContentRenderer 结构化预览)/ `CreateQuestionDialog`(✅ 审计重写:拆分为 281 行)/ `KnowledgePointSelector`(✅ 审计新增:从 dialog 拆分)/ `OptionsEditor`(✅ 审计新增:从 dialog 拆分)/ `QuestionBankResultsClient`(✅ 审计新增RSC 与客户端 Hook 桥接)/ `QuestionContentRenderer`(✅ 审计新增:题目结构化渲染)/ `BatchOperations`(✅ 审计新增:批量删除工具栏)/ `ImportExportButtons`(✅ 审计新增JSON 导入导出)
- Components`QuestionFilters`(✅ 审计重写:组合 QuestionCascadeFilter/ `QuestionCascadeFilter`(✅ 审计新增三级级联筛选nuqs 管理 URL 状态)/ `QuestionDataTable`(✅ 审计重写i18n + 集成 BatchOperations;✅ Phase 4.3:引入 @tanstack/react-virtual 虚拟滚动替换分页/ `useQuestionColumns`(✅ 审计改为 Hook因 useTranslations 是 Hook/ `CreateQuestionButton`(✅ 审计重写:权限感知)/ `QuestionActions`(✅ 审计重写:权限感知 + 类型守卫 + QuestionContentRenderer 结构化预览)/ `CreateQuestionDialog`(✅ 审计重写:拆分为 281 行)/ `KnowledgePointSelector`(✅ 审计新增:从 dialog 拆分)/ `OptionsEditor`(✅ 审计新增:从 dialog 拆分)/ `QuestionBankResultsClient`(✅ 审计新增RSC 与客户端 Hook 桥接)/ `QuestionContentRenderer`(✅ 审计新增:题目结构化渲染)/ `BatchOperations`(✅ 审计新增:批量删除工具栏)/ `ImportExportButtons`(✅ 审计新增JSON 导入导出)
**依赖关系**
- 依赖:`shared/*`、`@/auth`、`textbooks`(✅ 审计升级:通过 textbooks data-access 的 `getTextbooks` / `getChaptersByTextbookId` / `getKnowledgePointsByChapterId` / `getKnowledgePointsByTextbookId` 实现级联筛选)
@@ -877,7 +1028,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `utils/track-event.ts` | 30+ | 埋点接口no-op 实现) |
| `components/question-cascade-filter.tsx` | 130+ | 三级级联筛选nuqs URL 状态) |
| `components/question-filters.tsx` | 40+ | 筛选栏(组合 QuestionCascadeFilter |
| `components/question-data-table.tsx` | 155+ | 数据表格i18n + 集成 BatchOperations |
| `components/question-data-table.tsx` | 180+ | 数据表格i18n + 集成 BatchOperations + ✅ Phase 4.3@tanstack/react-virtual 虚拟滚动,替换分页避免大数据渲染卡顿 |
| `components/question-columns.tsx` | 100+ | 列定义 HookuseQuestionColumns |
| `components/create-question-button.tsx` | 30+ | 创建按钮(权限感知) |
| `components/question-actions.tsx` | 200+ | 行操作(权限感知 + 类型守卫 + QuestionContentRenderer |
@@ -1142,6 +1293,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `components/knowledge-point-mastery-chart.tsx` | 232 | **P3-3 新增2026-06-26**:知识点掌握度柱状图组件,集成 diagnostic 模块的 getClassMasterySummary 数据。使用 SimpleBarChart + masteryBarColor 函数着色 + isMasteryTooltipPayload 类型守卫 |
| `hooks/use-draft-lock.ts` | 184 | **P3-6 新增2026-06-26**:协同录入锁管理 Hook。职责进入页面自动获取锁、3 分钟心跳续期TTL 5 分钟)、组件卸载/提交成功释放锁、锁冲突状态暴露。使用 ref 保存最新 token 避免闭包捕获旧值,防重复 acquire/release |
> 架构变更2026-07-05Phase 4.5 — grade-record-list.tsx 编辑保存改用 useOptimistic + useTransition
> - **grade-record-list.tsx**:编辑保存 `isSaving` useState → `useOptimistic<GradeRecordListItem[], GradeRecordListItem>` + `useTransition`。`handleEditSave` 构造 `optimisticRecord` 在 `startSaveTransition` 内 `addOptimisticRecord` + `await updateGradeRecordAction` + `router.refresh()`;两处 `records.map` 改为 `optimisticRecords.map`(桌面表格 + 移动端卡片视图)即时显示乐观记录
> - **关键决策**:成功后调用 `router.refresh()` 同步数据源,让 `useOptimistic` 回滚到最新服务端值;不为删除操作使用 useOptimistic因为删除需要服务端确认外键约束、关联记录检查等
---
## 2.7 classes班级模块— 耦合最严重
@@ -1441,6 +1596,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `data-access-correlation.ts` | ~130 | L-9 跨模块关联分析: 委托 classes/users/grades data-access |
| `components/attendance-grade-correlation-card.tsx` | ~220 | L-9 散点图 + 风险表格 (recharts ScatterChart) |
> 架构变更2026-07-05Phase 4.5 — attendance-sheet.tsx 提交状态改用 useOptimistic
> - **attendance-sheet.tsx**`isSubmitting` useState → `useOptimistic<boolean>`。保留 `<form action={handleSubmit}>` 模式 + `useFormStatus` SubmitButtonprogressive enhancement`setOptimisticSubmitting(true)` 在 form action 内调用action 完成后 `optimisticSubmitting` 自动恢复 false移除 `finally` 块
> - **关键决策**:保留 form action 模式而非改用 `useTransition` 包裹,因为 `useFormStatus().pending` 依赖 form action 的 promise 状态,若用 `startTransition` 包裹会导致 form action 立即返回、`useFormStatus` 不可靠;`useOptimistic` 的 `addOptimistic` 必须在 transition 或 action 内调用form action 也算 action
---
## 2.11 users用户模块
@@ -1449,7 +1608,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数**
- Actions`getUserProfileAction` / `updateUserProfileAction` / `importUsersAction` / `exportUsersAction` / `downloadUserTemplateAction` / `updateUserRoleAction` / `deleteUserAction`
- Data-access`getUserProfile` / `getCurrentStudentUser`(✅ P2-20 已修复:从 homework 模块迁移而来6 个 student 页面通过此函数获取学生身份,不再依赖 homework 模块)/ `getAdminUsers`(管理员用户列表分页查询,支持搜索+角色聚合)/ `getAdminUserRoles`(角色名列表,用于筛选下拉框)
- Data-access`getUserProfile` / `getCurrentStudentUser`(✅ P2-20 已修复:从 homework 模块迁移而来6 个 student 页面通过此函数获取学生身份,不再依赖 homework 模块)/ `getAdminUsers`(管理员用户列表分页查询,支持搜索+角色聚合)/ `getAdminUserRoles`(角色名列表,用于筛选下拉框)/ `getAllUserIds(limit=1000, offset=0)`(✅ P3-7全校用户 ID 查询,默认 LIMIT 1000 防止 OOM + 分页参数支持)/ `getUserIdsByGradeId` / `getUserNamesByIds`
- Import-export`generateUserImportTemplate` / `parseUserImportData` / `exportUsersToExcel`+ re-export `batchImportUsers` / `UserImportResult` 保持向后兼容)
- User-service`batchImportUsers`(用户创建 + 密码哈希 + 角色分配)
- Class-registration`registerStudentByInvitationCode`(委托 classes/data-access 完成班级注册)
@@ -1663,6 +1822,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- `message-detail.tsx`:✅ 审计 V1-P1-3 客户端调用 `getMessageThreadAction(messageId)` 加载回复链
- `message-compose.tsx`:✅ 审计 V1-P0-5 `submittedRef` 标志位阻止发送后自动保存 effect 触发新草稿
> 架构变更2026-07-05Phase 4.5 — 消息星标改用 useOptimistic + useTransition
> - **message-detail.tsx**:星标 `useState` → `useOptimistic<boolean>` + `useTransition`。`handleToggleStar` 在 `startStarTransition` 内 `addOptimisticStarred(nextStarred)` + `await toggleMessageStarAction` + `router.refresh()`。JSX 中所有 `isStarred` 引用改为 `optimisticIsStarred`Button variant/aria-pressed/Star className/星标徽章)
> - **message-list.tsx**:星标 `starredOverride` useState → `useOptimistic<Map<string, boolean>>`key=messageId, value=isStarred。`getIsStarred` 改为 `optimisticStarredMap.get(m.id)``handleToggleStar` 重写为 `startStarTransition` + `addOptimisticStar` + `await router.refresh()`。撤回 `recalledOverride` 仍保留 useState不需要乐观更新
> - **关键决策**:成功后调用 `router.refresh()` 同步数据源,让 `useOptimistic` 回滚到最新服务端值不为撤回recall操作使用 useOptimistic因为撤回需要根据服务端返回判断是否成功2 分钟窗口校验),失败时不应乐观显示已撤回
---
## 2.14 notifications通知分发模块
@@ -1671,8 +1835,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数**
- Actions`sendNotificationAction` / `sendClassNotificationAction` / `getNotificationsAction` / `getUnreadNotificationCountAction` / `markNotificationAsReadAction` / `markAllNotificationsAsReadAction` / `archiveNotificationAction`(✅ P1-4 新增:后 4 个通知 CRUD Action 从 messaging 模块迁移;✅ V2-P2-13b 新增archiveNotificationAction 归档 Action✅ V3-P1-3 新增:`getNotificationPreferencesAction` / `updateNotificationPreferencesAction` 从 messaging 模块迁移)
- Dispatcher`sendNotification(payload)` / `sendBatchNotifications(payloads)`
- Data-access`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `archiveNotification` / `unarchiveNotification` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移;✅ V2-P2-13b 新增archiveNotification / unarchiveNotification 归档函数)
- Dispatcher`sendNotification(payload)` / `sendBatchNotifications(payloads)`(✅ P3-7`sendBatchNotifications` 重构为 in_app 单次批量 INSERT其他渠道保持 per-payload 并行)
- Data-access`createNotification` / `createNotifications(items)`(✅ P3-7 新增:批量 INSERT 多行)/ `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `archiveNotification` / `unarchiveNotification` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移;✅ V2-P2-13b 新增archiveNotification / unarchiveNotification 归档函数)
- Preferences`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
- Channels`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender`
- Components`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13bNotificationList 支持优先级 Badge 显示和归档操作;✅ V2-P2-13cNotificationList 支持按类型筛选)
@@ -1705,8 +1869,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**文件清单**
| 文件 | 行数 | 职责 |
|------|------|------|
| `dispatcher.ts` | ~145 | 渠道选择 + 并行分发(✅ V3-P1-4移除 `sendBatchNotifications` 重复日志) |
| `data-access.ts` | ~250 | 站内通知 CRUD + 用户联系方式 + 发送日志持久化notification_logs 表P0-4 / P1-5 修复后新增通知 CRUD✅ V2-P2-13b新增归档函数 + 优先级/归档筛选;✅ V3-P2-8console 日志添加 TODO 统一日志服务标记) |
| `dispatcher.ts` | ~225 | 渠道选择 + 并行分发(✅ V3-P1-4移除 `sendBatchNotifications` 重复日志;✅ P3-7`sendBatchNotifications` 重构为 in_app 批量 INSERT + 其他渠道并行 |
| `data-access.ts` | ~290 | 站内通知 CRUD + 用户联系方式 + 发送日志持久化notification_logs 表P0-4 / P1-5 修复后新增通知 CRUD✅ V2-P2-13b新增归档函数 + 优先级/归档筛选;✅ V3-P2-8console 日志添加 TODO 统一日志服务标记;✅ P3-7新增 `createNotifications` 批量 INSERT 函数 |
| `preferences.ts` | 166 | 通知偏好 CRUDP0-4 / P1-5 修复后从 messaging 迁移) |
| `actions.ts` | ~380 | 9 个 Server Action✅ P1-4新增 4 个通知 CRUD Action✅ V2-P2-13b新增 archiveNotificationAction✅ V3-P1-3新增 getNotificationPreferencesAction / updateNotificationPreferencesAction 从 messaging 迁移) |
| `schema.ts` | 22 | ✅ V3-P1-3 新增:通知偏好校验 Schema从 messaging/schema.ts 迁移) |
@@ -1831,7 +1995,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数**
- Actions`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1 已修复actions 层不再直接访问 DB全部下沉到 data-access✅ 发布公告时触发通知模块 `sendBatchNotifications`/ `toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` / `getAnnouncementReadStatusAction`(✅ V2-P2-13d 新增:置顶切换、已读标记、批量已读状态查询;✅ Audit-P0-3`toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` 新增资源所有权/可见性二次校验;✅ Audit-P1-1所有 Action 返回 i18n 化 message✅ Audit-P1-7`handleActionError` 集成 console.error + trackEvent("announcement.action_error") + 区分 PermissionDeniedError / FORBIDDEN_ANNOUNCEMENT
- Data-access`getAnnouncements`(✅ Audit-P0-2`audience` 字段从单值升级为数组 `{ gradeIds, classIds }`,过滤逻辑改用 `inArray`/ `getAnnouncementById` / `getAnnouncementByIdForUser`(✅ Audit-P0-1 新增:用户端详情页专用,结合 status=published + 受众过滤 + 已读状态 + readCount 一次返回,防止越权)/ `isAnnouncementVisibleToAudience`(✅ Audit-P0-1 新增:纯函数,判断公告对受众是否可见,可独立单测)/ `resolveUserAudience`(✅ Audit-P0-2从原 `resolveAudience` 重命名,遍历所有 childrenIds/classIds/gradeIds 而非仅首个)/ `resolveAnnouncementTargetUserIds`(✅ Audit-P1-5从 actions.ts 下沉到 data-access/ `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `toggleAnnouncementPin`(✅ Audit-P1-6原子化 UPDATE ... SET isPinned = NOT isPinned/ `markAnnouncementAsRead`(✅ Audit-P1-6onDuplicateKeyUpdate 实现幂等 upsert/ `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`(✅ V2-P2-13d 新增:置顶切换 + 已读回执 CRUD/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数)/ `getUserAnnouncementsPageData`(✅ V3-P0-2 新增:用户端列表页编排函数)/ `getAdminAnnouncementDetailPageData`(✅ Audit-P1-2 新增:管理端详情页编排函数,并行 getAnnouncementById + getAnnouncementReadCount + classes.getClassOptions
- Data-access`getAnnouncements`(✅ Audit-P0-2`audience` 字段从单值升级为数组 `{ gradeIds, classIds }`,过滤逻辑改用 `inArray`/ `getAnnouncementById` / `getAnnouncementByIdForUser`(✅ Audit-P0-1 新增:用户端详情页专用,结合 status=published + 受众过滤 + 已读状态 + readCount 一次返回,防止越权)/ `isAnnouncementVisibleToAudience`(✅ Audit-P0-1 新增:纯函数,判断公告对受众是否可见,可独立单测)/ `resolveUserAudience`(✅ Audit-P0-2从原 `resolveAudience` 重命名,遍历所有 childrenIds/classIds/gradeIds 而非仅首个)/ `resolveAnnouncementTargetUserIds`(✅ Audit-P1-5从 actions.ts 下沉到 data-access;✅ P3-7school 分支改为分页循环遍历 `getAllUserIds(PAGE_SIZE, offset)`PAGE_SIZE=1000避免单次查询超大学校全量用户导致 OOM/ `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `toggleAnnouncementPin`(✅ Audit-P1-6原子化 UPDATE ... SET isPinned = NOT isPinned/ `markAnnouncementAsRead`(✅ Audit-P1-6onDuplicateKeyUpdate 实现幂等 upsert/ `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`(✅ V2-P2-13d 新增:置顶切换 + 已读回执 CRUD/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数)/ `getUserAnnouncementsPageData`(✅ V3-P0-2 新增:用户端列表页编排函数)/ `getAdminAnnouncementDetailPageData`(✅ Audit-P1-2 新增:管理端详情页编排函数,并行 getAnnouncementById + getAnnouncementReadCount + classes.getClassOptions
**依赖关系**
- 依赖:`shared/*`、`@/auth`、`school`(获取年级列表)、`classes`(获取班级列表 + 解析受众)、`users`(获取目标用户 ID 列表)、`notifications`(发布公告时发送通知)、`shared/i18n`(✅ Audit-P1-1Actions 通过 `getTranslations('announcements')` 返回 i18n 化 message
@@ -2894,6 +3058,14 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
> - **V2-2 节点级 AI 协助 4 项**`lib/ai-node-assist.ts` 新增 4 个服务函数(`generateNodeContent`/`optimizeNodeExpression`/`suggestNodeDifferentiation`/`generateLayeredQuestions`全部纯服务端、Zod 校验、失败返回 null`actions-ai.ts` 新增 4 个 Server Action每个 action 校验 `LESSON_PLAN_READ + LESSON_PLAN_CREATE + AI_CHAT` 三个权限点;`hooks/use-node-ai-assist.ts` 新增客户端 hook`components/paper-editor/paper-editor.tsx` 注入 `onAiAction` 回调;`components/detail-panel/detail-panel.tsx` 的 4 个 `AiButton` 接入真实 actioni18n 新增 `v4.contextMenu.aiRunning`/`aiSuccess`/`aiFailed`/`aiComingSoon` 键zh-CN + en
> - **V4-I18N-1 i18n 完整审查与修复**:对 lesson-preparation 模块 i18n 配置进行全量审查并修复——P0 修复运行时 MISSING_MESSAGE`version.diff.*` 3 键title/summary/noChanges+ `schema.ts` 引用的 8 个 `error.*` 键labelTooLong/versionNoInvalid/nameRequired/nameTooLong/queryTooLong/blockIdRequired/commentTooLong/reviewerRequired+ `v4.contextMenu` 6 键copied/copyFailed/aiComingSoon/aiRunning/aiSuccess/aiFailedzh-CN + en 同步P1 修复 en locale 下大面积报错en `calendar` 节键名对齐 zh-CNweekView/monthView/loadFailed + prev/next/eventMeta/weekDays 子节en `analytics` 节补齐 totalPublished/totalSubmitted/totalStandardsLinked/loadFailedP2 修复 7 处硬编码中文detail-props.tsx 的 stageLabel/differentiationLabel、inline-node.tsx 的 exerciseCount、inline-qa-dialog.tsx 的 expectedAnswer、qa-editor.tsx 的 turnContentPlaceholder、curriculum-map-view.tsx 的 legendTitle/noData并新增对应 6 个 i18n 键(`v4.detail.stageLabel`/`differentiationLabel`/`exerciseCount`/`turnContentPlaceholder`、`v4.heatmap.legendTitle`/`noData`);清理 7 个 dead 节review/comment/formative/substitute/evaluation/standards/gradeHeadanalytics 因被 `app/(dashboard)/admin/curriculum-map` 引用而保留);`lib/i18n-errors.ts` 新增 `t.has(msg)` 运行时守卫,避免 `as` 断言绕过类型检查(键不存在时返回原消息而非抛出 MISSING_MESSAGEzh-CN 与 en 顶层键集一致性验证通过43 = 43005_architecture_data.json 同步追加 `V4-I18N-1` auditFixes 条目known-issues.md 追加 3 个 i18n 规则表(键缺失与中英文不同步 / dead 节清理规则 / 动态键翻译守卫)
> 架构变更2026-07-05Phase 4.2 — Zustand selector 细粒度拆分,避免整体订阅级联 re-render
> - **lesson-plan-editor.tsx**`const editor = useLessonPlanEditor()` 整体订阅拆分为 9 个细粒度 selector`isDirty`/`isOnline`/`doc`/`title`/`isSaving`/`saveError`/`lastSavedAt`/`setTitle` + `canUndo`/`canRedo` 派生 boolean`canUndo`/`canRedo` 改为 `useLessonPlanEditor((s) => s.canUndo())` 订阅派生 boolean移除每次渲染时调用 `editor.canUndo()` 函数;`useEffect` 依赖与 `printablePlan` useMemo 同步更新使用拆分后的变量
> - **text-study-block.tsx**`const { updateNode } = useLessonPlanEditor()` → `useLessonPlanEditor((s) => s.updateNode)`
> - **exercise-block.tsx**:拆为 `useLessonPlanEditor((s) => s.updateNode)` + `useLessonPlanEditor((s) => s.planId)`
> - **paper-context-menu.tsx**:拆为 7 个细粒度 selector`toggleExpand`/`expandedNodeIds`/`updateNode`/`removeNode`/`addAnchor`/`duplicateNode`/`nodes`);特别地将 `doc` 改为 `s.doc.nodes` 更精细订阅(`addAnchor` 等仅修改 anchors 不会触发本组件 re-render
> - **关键决策**:不使用 `useShallow` 包装多字段 selector因为单字段 selector 已经够细,且避免了 `useShallow` 自身的浅比较开销;函数引用(`setTitle`/`updateNode` 等)天然稳定,无需 `useShallow`
> - **验证**tsc 零新增错误lint 零新增错误4 个 modified 文件均未出现在 lint 输出中)
---
## 2.27b standards课标模块2026-07-01 新增)
@@ -3065,7 +3237,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| **Hook** | `useDragPosition` | `modules/ai/hooks/use-drag-position.ts` | 拖拽位置 Hookpointer 事件 + 拖拽状态)— V3 新增 |
| **Context** | `createFullAiClientService` | `modules/ai/context/create-ai-client-service.ts` | 创建完整 AI 客户端服务工厂(含全部 9 个 Action— V3 新增 |
| **Context** | `createCoreAiClientService` | `modules/ai/context/create-ai-client-service.ts` | 创建核心 AI 客户端服务工厂(仅 6 个常用 Action— V3 新增 |
| **Component** | `AiAssistantWidget` | `modules/ai/components/ai-assistant-widget.tsx` | 全局 AI 助手悬浮按钮(上下文感知)— V2 新增 |
| **Component** | `AiAssistantWidget` | `modules/ai/components/ai-assistant-widget.tsx` | 全局 AI 助手悬浮按钮(上下文感知)— V2 新增 / Phase 4.7 改造为懒加载 wrapper`next/dynamic` ssr:false仅渲染 Skeleton 占位 + LazyInner |
| **Component** | `AiAssistantWidgetInner` | `modules/ai/components/ai-assistant-widget-inner.tsx` | 全局 AI 助手悬浮球内部实现(悬浮球 + 抽屉 + AiChatPanel + 上下文推断)— Phase 4.7 从 ai-assistant-widget.tsx 拆出,通过 dynamic 懒加载,使 AI SDK / Markdown 渲染等重型依赖打入独立 chunk |
| **Component** | `AiChatPanel` | `modules/ai/components/ai-chat-panel.tsx` | AI 对话面板(流式 + Markdown + 复制 + 停止 + 建议)— V2 增强 |
| **Component** | `AiMarkdownRenderer` | `modules/ai/components/ai-markdown-renderer.tsx` | Markdown 渲染器GFM + 复制按钮)— V2 新增 |
| **Component** | `AiGradingAssist` | `modules/ai/components/ai-grading-assist.tsx` | AI 批改辅助(主观题预评分 + 反馈建议) |
@@ -3143,7 +3316,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `modules/ai/services/usage-tracker.ts` | ~90 | AI 使用量埋点V3 新增 child_summary/study_path 能力) |
| `modules/ai/services/content-safety.ts` | ~290 | 内容安全过滤(输入/输出/每日限制/原子配额/苏格拉底校验)— V2 新增 / V3 加固 |
| `modules/ai/context/ai-client-provider.tsx` | ~62 | React Context Provider + Hooks |
| `modules/ai/components/ai-assistant-widget.tsx` | ~170 | 全局 AI 助手悬浮按钮 — V2 新增 / V4 i18n 修复 |
| `modules/ai/components/ai-assistant-widget.tsx` | ~25 | 懒加载 wrappernext/dynamic ssr:false + Skeleton 占位)— Phase 4.7 改造 / V2 新增 / V4 i18n 修复 |
| `modules/ai/components/ai-assistant-widget-inner.tsx` | ~330 | 全局 AI 助手悬浮球内部实现(悬浮球 + 抽屉 + AiChatPanel + 上下文推断)— Phase 4.7 新增(从 ai-assistant-widget.tsx 拆出) |
| `modules/ai/components/ai-chat-panel.tsx` | ~305 | AI 对话面板(流式 + Markdown— V2 增强 |
| `modules/ai/components/ai-markdown-renderer.tsx` | ~100 | Markdown 渲染器 — V2 新增 |
| `modules/ai/components/ai-child-summary.tsx` | ~170 | 家长学情摘要 — V2 新增 |
@@ -3544,7 +3718,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**:封装跨 4 张表questions / textbooks / exams / announcements的全文检索数据访问层供 `app/api/search/route.ts` 调用。本次 API 规范化重构从 `app/api/search/route.ts` 下沉而来,消除 `app/` 层直接查询 DB 的架构违规违反「app → modules → shared」三层架构单向依赖
**导出函数**`data-access.ts`server-only
- `searchQuestions(kw, limit?)` — 题库检索:`CAST(content AS CHAR) LIKE` 模糊匹配 JSON 字段,返回 `SearchResultItem[]`href 指向 `/admin/questions?id=`
- `searchQuestions(q, limit?)` — 题库检索:P3-6 改用 `MATCH(questions.contentText) AGAINST(? IN BOOLEAN MODE)` 全文检索(基于 `content_text` STORED 生成列的 FULLTEXT 索引);`toBooleanModeQuery` 工具函数将原始查询转为 `+word1* +word2*` 格式;返回 `SearchResultItem[]`href 指向 `/admin/questions?id=`
- `searchTextbooks(kw, limit?)` — 教材检索title/subject/publisher 三字段 OR 模糊匹配
- `searchExams(kw, limit?)` — 试卷检索title/description 模糊匹配
- `searchAnnouncements(kw, limit?)` — 公告检索:仅返回 `status=published` 的公告title/content 模糊匹配HTML 内容通过 `stripHtml` 提取纯文本摘要
@@ -3567,7 +3741,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- 被依赖:`app/api/search/route.ts`
**数据库表**(只读):
- `questions`content JSON 字段、type、createdAt
- `questions`content JSON 字段、P3-6 新增 `content_text` STORED 生成列 `CAST(content AS CHAR)` 用于 FULLTEXT 索引、type、createdAt
- `textbooks`title、subject、grade、publisher、createdAt
- `exams`title、description、status、createdAt
- `announcements`title、content、type、status、createdAt
@@ -3575,7 +3749,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**文件清单**
| 文件 | 行数 | 职责 |
|------|------|------|
| `data-access.ts` | 212 | 4 个 search 函数 + 3 个内部纯函数extractTextFromJson/stripHtml/truncate+ DEFAULT_SEARCH_PAGE_SIZE 常量 |
| `data-access.ts` | ~230 | 4 个 search 函数P3-6: searchQuestions 改 MATCH AGAINST+ 4 个内部纯函数extractTextFromJson/stripHtml/truncate/toBooleanModeQuery+ DEFAULT_SEARCH_PAGE_SIZE 常量 |
| `types.ts` | 31 | SearchType/SearchResultItem/SearchResponse 类型 + isSearchType 类型守卫 |
---
@@ -4191,6 +4365,20 @@ formatNumber(v: number | null | undefined, digits?: number): string
// shared/lib/search-params.ts (re-export from utils.ts)
getParam(params: SearchParams, key: string): string | undefined // = getSearchParam
// shared/lib/cache/ (服务端缓存基础设施P1 重构新增)
// store-factory.ts —— getCacheStore() 单例工厂:默认 MemoryCacheStoreCACHE_DRIVER=redis 时懒加载 RedisCacheStore
// memory-store.ts / redis-store.ts —— CacheStore 两种实现
// cache-fn.ts —— cacheFn 双层包装器:外层 react.cache 请求级 memoization内层 cacheStore.getOrSet 跨请求缓存(按 tag 失效TTL 可选)
// invalidation-map.ts —— INVALIDATION_MAP 集中式失效映射表 + fillTemplate 模板填充
// invalidate.ts —— invalidateFor 三步编排函数Server Action 写后调用)
getCacheStore(): Promise<CacheStore>
cacheFn<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => Promise<TResult>, options: CacheFnOptions): (...args: TArgs) => Promise<TResult>
fillTemplate(template: string, params: Record<string, string>): string
INVALIDATION_MAP // 集中式失效映射表常量
invalidateFor(actionId: string, params?: Record<string, string>): Promise<void>
// 三步编排1) CacheStore.invalidateTags 2) revalidateTag × N 3) revalidatePath × N
// 未知 actionId 抛错 "[cache] Unknown actionId: <id>. Update INVALIDATION_MAP."
```
### 业务模块核心 Actions

File diff suppressed because one or more lines are too long

View File

@@ -955,3 +955,284 @@ Zod schema 中存储的 i18n 键(如 `"error.titleRequired"`)在服务端翻
- `.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.2Zustand 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.5useOptimistic + 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 []` | 空 `+*` 传给 AGAINSTMySQL 报语法错误) |
**示例**`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 2 Bundle 预算优化规则2026-07-05
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 重型客户端库用 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 内 |
| 服务端消费方仅需类型时拆 types barrel | `import type { EditorDoc } from "@/modules/exams/editor/types"`(纯类型入口) | `import { EditorDoc } from "@/modules/exams/editor/exam-rich-editor-types"`(拉入 @tiptap/* 运行时) |
| 纯展示组件无需 "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/modules/exams/editor/exam-rich-editor.tsx` — 动态 import 化
- `src/modules/questions/components/question-rich-editor.tsx` — 动态 import 化
- `src/modules/lesson-preparation/components/paper-editor/paper-rich-editor.tsx` — 动态 import 化
- `src/modules/ai/components/ai-assistant-widget.tsx` — lazy wrapper + ai-assistant-widget-inner.tsx 拆分
- `src/modules/textbooks/components/knowledge-graph.tsx` + `knowledge-graph-inner.tsx` — ReactFlowProvider 同步壳 + lazy inner
- `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页面卸载时可能被取消 |
**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
- 14 个 chart 组件(`src/modules/*/components/*-chart.tsx` — recharts props 提取模块级常量
- `src/app/web-vitals-reporter.tsx` — Metric 类型推导修复 + PerformanceEntry 注解
- `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) }))`TDZCannot 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`