From 0f9d8825e7817d59f98fb0a55f6f8fbe449f5bfd Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:07:18 +0800 Subject: [PATCH] =?UTF-8?q?docs(cache):=20=E5=90=8C=E6=AD=A5=20cacheFn=20?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=9B=BE=E4=B8=8E=E5=B7=B2=E7=9F=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E9=80=9F=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../004_architecture_impact_map.md | 214 ++++++++++++- docs/architecture/005_architecture_data.json | 73 ++++- docs/troubleshooting/known-issues.md | 281 ++++++++++++++++++ 3 files changed, 551 insertions(+), 17 deletions(-) diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index ef8dacf..84ffe8b 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -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.2:lesson-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.5:useOptimistic + useTransition 乐观更新 + +将 4 个组件从 `useState` + 等待 Server Action 模式迁移到 React 19 `useOptimistic` + `useTransition`,自动管理乐观状态与回滚。 + +| 文件 | 修改要点 | +|------|---------| +| `modules/messaging/components/message-detail.tsx` | 星标 `useState` → `useOptimistic` + `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>`(key=messageId, value=isStarred)。`getIsStarred` 改为 `optimisticStarredMap.get(m.id)`。撤回 `recalledOverride` 仍保留 useState(不需要乐观更新) | +| `modules/grades/components/grade-record-list.tsx` | 编辑保存 `isSaving` useState → `useOptimistic` + `useTransition`。`handleEditSave` 构造 `optimisticRecord` 在 `startSaveTransition` 内 `addOptimisticRecord` + `await updateGradeRecordAction` + `router.refresh()`。两处 `records.map` 改为 `optimisticRecords.map`(桌面表格 + 移动端卡片视图)即时显示乐观记录 | +| `modules/attendance/components/attendance-sheet.tsx` | `isSubmitting` useState → `useOptimistic`。保留 `
` 模式 + `useFormStatus` SubmitButton(progressive 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.6:searchQuestions 改 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.7:getAllUserIds 加 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` `` 加 `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` 跟踪 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+ | 列定义 Hook(useQuestionColumns) | | `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-05,Phase 4.5 — grade-record-list.tsx 编辑保存改用 useOptimistic + useTransition): +> - **grade-record-list.tsx**:编辑保存 `isSaving` useState → `useOptimistic` + `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-05,Phase 4.5 — attendance-sheet.tsx 提交状态改用 useOptimistic): +> - **attendance-sheet.tsx**:`isSubmitting` useState → `useOptimistic`。保留 `` 模式 + `useFormStatus` SubmitButton(progressive 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-05,Phase 4.5 — 消息星标改用 useOptimistic + useTransition): +> - **message-detail.tsx**:星标 `useState` → `useOptimistic` + `useTransition`。`handleToggleStar` 在 `startStarTransition` 内 `addOptimisticStarred(nextStarred)` + `await toggleMessageStarAction` + `router.refresh()`。JSX 中所有 `isStarred` 引用改为 `optimisticIsStarred`(Button variant/aria-pressed/Star className/星标徽章) +> - **message-list.tsx**:星标 `starredOverride` useState → `useOptimistic>`(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-13b:NotificationList 支持优先级 Badge 显示和归档操作;✅ V2-P2-13c:NotificationList 支持按类型筛选) @@ -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-8:console 日志添加 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-8:console 日志添加 TODO 统一日志服务标记;✅ P3-7:新增 `createNotifications` 批量 INSERT 函数) | | `preferences.ts` | 166 | 通知偏好 CRUD(P0-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-6:onDuplicateKeyUpdate 实现幂等 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-7:school 分支改为分页循环遍历 `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-6:onDuplicateKeyUpdate 实现幂等 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-1:Actions 通过 `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` 接入真实 action;i18n 新增 `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/aiFailed)(zh-CN + en 同步);P1 修复 en locale 下大面积报错:en `calendar` 节键名对齐 zh-CN(weekView/monthView/loadFailed + prev/next/eventMeta/weekDays 子节),en `analytics` 节补齐 totalPublished/totalSubmitted/totalStandardsLinked/loadFailed;P2 修复 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/gradeHead,analytics 因被 `app/(dashboard)/admin/curriculum-map` 引用而保留);`lib/i18n-errors.ts` 新增 `t.has(msg)` 运行时守卫,避免 `as` 断言绕过类型检查(键不存在时返回原消息而非抛出 MISSING_MESSAGE);zh-CN 与 en 顶层键集一致性验证通过(43 = 43);005_architecture_data.json 同步追加 `V4-I18N-1` auditFixes 条目;known-issues.md 追加 3 个 i18n 规则表(键缺失与中英文不同步 / dead 节清理规则 / 动态键翻译守卫) +> 架构变更(2026-07-05,Phase 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` | 拖拽位置 Hook(pointer 事件 + 拖拽状态)— 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 | 懒加载 wrapper(next/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() 单例工厂:默认 MemoryCacheStore,CACHE_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 +cacheFn(fn: (...args: TArgs) => Promise, options: CacheFnOptions): (...args: TArgs) => Promise +fillTemplate(template: string, params: Record): string +INVALIDATION_MAP // 集中式失效映射表常量 +invalidateFor(actionId: string, params?: Record): Promise + // 三步编排:1) CacheStore.invalidateTags 2) revalidateTag × N 3) revalidatePath × N + // 未知 actionId 抛错 "[cache] Unknown actionId: . Update INVALIDATION_MAP." ``` ### 业务模块核心 Actions diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 46659c8..7f58482 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -5,8 +5,8 @@ "generatedAt": "2026-06-17", "formatVersion": "1.1", "rule": "每次文件修改后须同步更新本文件", - "lastUpdate": "M7 移动端 PWA 支持(2026-07-01):(1) 新增 src/app/manifest.ts(Next.js App Router 元数据路由,生成 /manifest.webmanifest,PWA 清单:name/short_name=NextEdu/start_url=/display=standalone/theme_color=#1976d2/icons=/icon.svg)。(2) src/app/layout.tsx 新增 viewport 导出(width=device-width, initialScale=1, maximumScale=5, userScalable=true, themeColor 按明暗主题切换)+ metadata 新增 manifest/appleWebApp 配置。(3) lesson-preparation 模块 node-editor.tsx 与 lesson-plan-readonly-view.tsx 的 ReactFlow 组件新增触控友好属性(panOnDrag/zoomOnPinch/zoomOnDoubleClick={false}/selectionOnDrag={false}),防止移动端双击误缩放。--- 之前更新:新增 008_module_role_mapping.md 模块-角色-功能映射总览文档。RBAC 模块 P1/P2 审计修复完成(2026-06-24,permissions-audit-report 续):P1 阶段:(1) P1-1/P1-2/P1-3 i18n 化:zh-CN/en rbac.json 补全 67 个权限点标签+描述(134 键×2 语言);6 个 RBAC 组件改用 useTranslations 替换硬编码英文;admin-users-view.tsx 改用 useTranslations 替换硬编码中文,users.json 新增对应键。(2) P1-5/P1-6 resolveDataScope 重构:新增 classes/data-access.ts 三个轻量级函数(getTeacherScopeData/getStudentScopeData/getGradeIdsForStudentIds);新增 rbac/lib/data-scope-config.ts(DataScopeRule 接口)+ rbac/lib/data-scope-resolver.ts(DATA_SCOPE_RULES 配置数组 + resolveDataScopeFromConfig);shared/lib/auth-guard.ts 改用动态 import 调用 resolver,移除直接 DB 查询 classes/classEnrollments/classSubjectTeachers/grades/parentStudentRelations 表,移除硬编码 roleNames.includes('xxx') 链。(3) P1-8/P1-9 安全性增强:assignRolesToUser 新增 isEnabled 字段查询,过滤禁用角色(admin 例外)。P2 阶段:(4) P2-1 权限矩阵搜索+折叠。(5) P2-3 权限变更影响提示(userCount)。(6) P2-5 a11y 增强(caption/aria-label/fieldset/legend)。(7) P2-7 埋点接口(rbac/lib/track.ts)。(8) P2-8 role-utils.ts 保留 grade_head/teaching_head 角色差异。--- 之前更新:actions.ts 从 1525 行降至 633 行(低于 800 建议上限),修复 updateExamFromRichEditorAction 直接操作数据库的 P0-4 违规。(1) 新增 actions-helpers.ts(96 行):提取跨文件共享的辅助函数 getStringValue/getBoolValue/parseExamModeConfig/failState/successState/invalidFormState/prepareExamCreateContext。(2) 新增 ai-pipeline/auto-mark.ts(356 行):从 actions.ts 迁出 autoMarkExamAction Server Action 及其纯转换辅助 buildTiptapDocFromAiResponse/splitByDottedTexts/extractTitleFromAiResponse/AutoMarkSchema/AutoMarkResult。(3) 新增 actions-rich-editor.ts(250 行):迁移 createExamFromRichEditorAction/updateExamFromRichEditorAction 及共享辅助 convertStructureNode/RichExamCreateSchema/RichExamUpdateSchema。(4) data-access.ts 新增 updateExamWithRichEditorData(DB 事务下沉,修复 P0-4:原 updateExamFromRichEditorAction 直接 db.transaction 写 exams/examQuestions 表)。(5) ai-pipeline/index.ts 新增 loadAiDraftQuestionsAndStructure(从 actions.ts 下沉)。(6) actions.ts 保留 re-export 保持向后兼容(autoMarkExamAction/createExamFromRichEditorAction/updateExamFromRichEditorAction/AutoMarkResult/AiPreviewData/AiRewriteQuestionData)。(7) exam-rich-form.tsx 更新 import 路径直连新文件。--- 之前更新:用户权限(RBAC)模块审计修复(2026-06-24,permissions-audit-report):(1) 新增 shared/lib/type-guards.ts(isPermission/toPermission 类型守卫),替代 rbac/data-access.ts、rbac/data-access-permissions.ts、shared/lib/permissions.ts、shared/lib/auth-guard.ts 中多处 as Permission/as Role[] 断言。(2) 新增 rbac/data-access-permissions.ts(getPermissionRoleCounts),将权限统计查询从 admin/permissions/page.tsx 下沉到 data-access 层,移除 app 层直接 DB 查询。(3) 新增 rbac/components/error-boundary.tsx(RBAC 专用错误边界)。(4) 新增 8 个 loading.tsx/error.tsx(admin/roles、admin/roles/[id]、admin/permissions、admin/users)。(5) users/data-access.ts 新增 deleteUserById(含最后管理员保护逻辑);getCurrentStudentUser 改用 shared/lib/session.getAuthContext() 统一认证入口(替代直接 auth())。(6) users/actions.ts:deleteUserAction 签名从 (prevState, formData) 改为 (userId),改调 data-access.deleteUserById;删除空实现 updateUserRoleAction;移除直接 DB 导入。(7) users/components/admin-users-view.tsx:移除 fetch('/api/admin/users/...') 客户端 fetch,改用 useActionMutation + deleteUserAction Server Action。(8) 005 JSON 新增 rbac 模块完整记录(8 actions + 10 data-access + 3 data-access-assignments + 1 data-access-permissions + 4 schema + 7 types + 1 lib + 8 components + 依赖关系 + dbTables)。--- 之前更新:error-book 学生端组件 i18n + 类型安全 + 解耦改造(2026-06-24):(1) 7 个组件全部接入 useTranslations('error-book'),替换所有硬编码中文为翻译键。(2) error-book-stats-cards/error-book-list 从服务端组件改为 'use client'。(3) review-buttons 的 REVIEW_OPTIONS 从模块级常量改为组件内构建(依赖 t 函数)。(4) error-book-item-card/add-error-book-dialog/error-book-detail-dialog 移除 as 断言,改用类型守卫。(5) error-book-detail-dialog 移除直接 import AiErrorBookAnalysis(modules/ai) 和 createPracticeSessionAction(modules/adaptive-practice),新增 aiAnalysisSlot/onStartVariantPractice 可选 props 注入。(6) add-error-book-dialog 移除直接 import getQuestionsAction(modules/questions),新增 questionOptions/onLoadQuestions 可选 props 注入。(7) 同步更新 004/005 架构文档。注意:page.tsx 需后续更新以传入新 props 恢复 AI 分析/变式练习/题库选择功能。--- 之前更新:grades 模块批量录入重新设计(2026-06-24):按试卷录入每题得分,替换原只录总分的批量录入。(1) 新增 grade_record_answers 表(gradeRecordId/questionId/score/fullScore/feedback,外键 cascade 删除,迁移 0010_grade_record_answers.sql)。(2) grades data-access 新增 batchCreateGradeRecordsByExam:单事务写入 grade_records + grade_record_answers + 投影到 exam_submissions(status=graded) + submission_answers(answerContent=null),使错题集/成绩分析等下游模块无需改造即可读取教师录入的成绩。(3) grades actions 新增 batchCreateGradeRecordsByExamAction(requirePermission + getExamForGradeEntry scope 校验 + assertClassInScope + safeJsonParse + BatchGradeEntryByExamSchema 校验 + updateMasteryFromExamScore + notifyGradeEntered)。(4) exams data-access 新增 getExamsForGradeEntry(按 scope 过滤试卷列表,只返回有题目的试卷)+ getExamForGradeEntry(获取单个试卷详情含题目列表,innerJoin questions 获取 type,含 scope 校验)。(5) 新增类型:grades/types.ts(GradeRecordAnswer/BatchGradeEntryByExamQuestion/BatchGradeEntryByExamItem)、exams/types.ts(ExamQuestionItem/ExamForGradeEntry/ExamOptionForEntry)。(6) 新增 Zod schema:BatchGradeEntryByExamSchema(examId/classId/subjectId 必填,title 1-255,fullScore 1-1000,records 1-500)。(7) UI:BatchGradeEntryByExam 组件完全重写 BatchGradeEntry,Excel 式表格(行=学生,列=题目,末列=总分自动计算),支持多行多列粘贴、Enter 跳下一行、Tab 跳下一格、分数校验、撤销机制(sessionStorage 5 分钟有效)。(8) page.tsx 重写:服务端预加载试卷列表+班级+classGradeMap+试卷详情+学生列表,URL 参数 ?examId=&classId=。(9) i18n 新增 batchByExam 章节(zh-CN/en ~35 键)。(10) grades 模块新增对 exams 模块的依赖(getExamsForGradeEntry/getExamForGradeEntry)。--- 之前更新:lesson-preparation 模块 V4 交互修复+版本聚合(2026-06-23):(V4-1) TextbookContentNode 修复正文文本选择和缩放手柄被画布拖拽拦截的问题:在正文内容区和缩放手柄 div 上添加 React Flow 的 nodrag CSS class(React Flow 识别此 class 跳过 pointerdown 拖拽逻辑),移除容器层无效的 onMouseDown stopPropagation;节点仍可通过头部拖动。(V4-2) 版本聚合显示:LessonPlanListItem 新增 versionCount + versions 字段(LessonPlanVersionSummary 类型:id/title/status/updatedAt/lastSavedAt);getLessonPlans 查询后按 textbookId+chapterId+creatorId 分组(无教材/章节的课案各自独立),每组取 updatedAt 最新为代表,附加版本摘要数组;data-access-knowledge.ts 的两个查询函数补齐 versionCount=1/versions=[] 默认值;LessonPlanCard 多版本时在标题右侧显示版本数徽章 + 下方版本选择下拉框(选择后 router.push 到对应版本编辑/查看页);i18n 新增 list.versionCount/versionSelectorLabel/versionCurrent 键(zh-CN/en)。--- 之前更新:lesson-preparation 模块 V3 多角色课案查看(2026-06-23):(V3-1) 新增 publishLessonPlan/unpublishLessonPlan data-access 函数 + publishLessonPlanAction/unpublishLessonPlanAction Server Actions(LESSON_PLAN_PUBLISH 权限),支持课案 status=published 供学生/家长/教研组长只读查看。(V3-2) 新增 LessonPlanReadonlyView 只读画布组件(复用 React Flow,nodesDraggable=false, nodesConnectable=false)。(V3-3) LessonPlanCard/LessonPlanList/LessonPlanEditor 新增 viewMode prop(teacher/student/parent/admin/gradeHead)+ 发布/撤回按钮(AlertDialog 确认);LessonPlanEditor 新增 initialStatus prop。(V3-4) TextbookContentNode 新增 anchorableNodes/onCreateNewNode props,AnchorNodeSelector 重写为节点列表+创建新节点选项;NodeEditPanel 选中 textbook_content 时显示操作提示+锚点列表(含删除)。(V3-5) TemplatePicker 加载并显示个人模板,分区显示系统/个人模板。(V3-6) rf-mappers 增强:toRfNodes ctx 新增 anchorableNodes/onCreateNewNode;toRfEdges 锚点边颜色用 getNodeColor(anchor.nodeId) 替代硬编码,anchorId 存入 edge.data;fromRfEdges 从 e.data.anchorId 读取(回退 className 判断)。(V3-7) student/parent/grade_head/teaching_head 角色新增 LESSON_PLAN_READ 权限。(V3-8) DataScope class_members/children 新增可选 gradeIds? 字段,auth-guard resolveDataScope 通过 classEnrollments.innerJoin(classes) 预解析年级 ID。(V3-9) 新增 8 个路由:/student/lesson-plans、/student/lesson-plans/[planId]/view、/parent/lesson-plans、/parent/lesson-plans/[planId]/view、/admin/lesson-plans、/admin/lesson-plans/[planId]/view、/grade-head/lesson-plans、/grade-head/lesson-plans/[planId]/view。admin/student/parent/grade_head/teaching_head 导航新增课案相关入口。--- 之前更新:年级管理模块改进 v4-P2-6(2026-06-23):(1) school/data-access.ts 新增 getGradeOverviewStats() 函数 + GradeOverviewStats 接口(每个年级的 classCount/studentCount/teacherCount,动态导入 classes/data-access 避免循环依赖);GradesClient 新增 gradeStats prop,渲染年级概览卡片视图(班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口);admin/school/grades/page.tsx 新增 getGradeOverviewStats() 查询。(2) 新增 GradeInsightsFilters 组件(ChipNav 替代原生 form get,无整页刷新);admin/school/grades/insights/page.tsx 与 management/grade/insights/page.tsx 重写,添加 i18n + 表格 overflow-x-auto。(3) 修复 grades/actions.ts 中 assertClassInScope 同步函数位于 \"use server\" 文件的 bug,迁移至独立 lib/scope-check.ts 文件,actions.ts 与 actions-analytics.ts 均从 ./lib/scope-check 导入。school.json(en + zh-CN)新增 grades.gradeInsights 和 grades.gradeOverview 两个章节 i18n 键。--- 之前更新:grades 模块 v3/v4 P3 长期问题修复(2026-06-23):(v3-P3-1) batch-grade-entry.tsx 新增下载模板按钮,客户端生成 CSV 模板(含学生姓名/分数/备注列头 + BOM 支持 Excel UTF-8)。(v3-P3-2) grade-record-list.tsx 新增多选复选框(全选/单选)+ 批量删除工具栏 + 批量删除确认对话框;data-access.ts 新增 bulkDeleteGradeRecords(使用 inArray 一次性删除避免 N+1);actions.ts 新增 bulkDeleteGradeRecordsAction(GRADE_RECORD_MANAGE 权限 + 限制单次最多 500 条)。(v4-P3-2) batch-grade-entry.tsx 顶部新增可折叠新手引导提示框(4 步使用说明),使用 localStorage 记住用户关闭状态。i18n zh-CN/en grades.json 同步新增 batch.downloadTemplate/templateAriaLabel/templateStudentName/templateScore/templateRemark/templateFilename + batch.guide.* + list.bulkDelete/bulkDeleteConfirmation/bulkDeleteSelected/bulkDeleteSuccess/bulkDeleteFailed/selectAll/selectRow/clearSelection 键。--- 之前更新:V4 AI 模块深度增强(ai-module-v2 对标 Khanmigo/Duolingo Max/Squirrel AI/Century Tech):(V4-1) SSE 流式响应:shared/lib/ai/client.ts 新增 createAiChatCompletionStream(AsyncGenerator 逐 token 产出),新增 API 路由 /api/ai/chat/stream(POST,requirePermission(AI_CHAT)+checkDailyLimit+filterUserInput+学生苏格拉底系统提示+ReadableStream 流式输出+filterAiOutput+incrementDailyUsage+trackEvent),新增 hook useAiChatStream(fetch+ReadableStream reader+SSE 解析+AbortController 停止生成+localStorage 持久化最近 20 条)。(V4-2) Markdown 渲染:新增组件 AiMarkdownRenderer(react-markdown+remark-gfm,代码块/表格/列表+hover 复制按钮+memo 优化),AiChatPanel 全面重写(流式渲染+停止按钮+清空确认+建议提示词空状态+aria-live+错误展示)。(V4-3) 全局 AI 助手:新增组件 AiAssistantWidget(fixed 浮动按钮+Sheet 侧抽屉+usePathname 路由推断上下文+inferContextFromPath 映射 7 类场景系统提示:教师批改/备课/考试/学生错题本/学生作业/家长/管理员+useAiClientOptional 无 Provider 时隐藏+pulsing 绿色指示器),dashboard layout 全局注入 AiClientProvider+AiAssistantWidget。(V4-4) 内容安全:新增 services/content-safety.ts(filterUserInput 阻断暴力/自残/色情/毒品/黑客/PII 索取;filterAiOutput 输出二次过滤+学生场景阻断直接答案;checkDailyLimit 学生 50/教师 200/家长 30/管理员 500;incrementDailyUsage;getDailyLimit),COPPA/FERPA K12 合规。(V4-5) 多角色 AI 覆盖:types.ts 新增 ChildSummaryInput/Result、StudyPathInput/Result、AiUsageStats 类型 + AiService/AiClientService 接口扩展;schema.ts 新增 4 个 Zod schema;prompt-templates.ts 新增 CHILD_SUMMARY_SYSTEM_PROMPT(家庭教育顾问,家长友好语言)+ STUDY_PATH_SYSTEM_PROMPT(自适应学习路径设计师,3-7 步骤);ai-service.ts 新增 generateChildSummary(聚合成绩/出勤/错题本数据)+ recommendStudyPath(基于掌握度数据);actions.ts 新增 generateChildSummaryAction/recommendStudyPathAction(AI_CHAT 权限)+ getAiUsageStatsAction(AI_CONFIGURE 权限);新增组件 AiChildSummary(家长端:整体评估 Markdown+优势绿勾+改进橙警+家庭辅导建议+下一步徽章)、AiUsageDashboard(管理员端:4 统计卡片+按能力进度条+按角色徽章+Top 用户列表+最近活动日志)、AiStudyPath(学生端:当前等级横幅+学习路径步骤+连接线+状态图标+预估时间+激励消息)。(V4-6) i18n 修复与扩展:修复 AiGradingAssist/AiLessonContentGenerator/AiQuestionVariantGenerator 共 8 处错误 i18n 键引用(CardDescription 重复 title、label/placeholder/button 复用 generateContent、3 个变体类型标签全部显示「生成」);zh-CN/en ai.json 全面重写新增 chat.streaming/stopGeneration/copy/clearConfirm/suggestedPrompts、grading.description/batch*、lessonPrep.description/additionalContext/insertContent、exam.variantType.*/targetDifficulty/addVariant、parent.*、admin.*、studyPath.*、widget.*、safety.* 等键。架构文档 004/005 同步更新。", - "lastUpdated": "2026-07-04" + "lastUpdate": "性能预算重构专项 Phase 2-4 完成(2026-07-05):Phase 2 Bundle 预算优化:(2.1) TipTap 三实例(exam-rich-editor/question-rich-editor/paper-editor)+ AiAssistantWidget 改 next/dynamic ssr:false + xxx-inner.tsx 拆分模式;(2.2) KnowledgeGraph 改 ReactFlowProvider 同步壳 + lazy 内部实现;(2.3) exams/editor 拆 types.ts barrel 减少服务端消费方拉入 @tiptap/* 运行时;(2.4) status-badge 移除多余 'use client';(2.5) teacher/student/parent 角色路由补齐 loading.tsx。Phase 3 数据层优化:(3.1) getSession/getAuthContext/resolveDataScope/getSessionTeacherId 全部 React cache() 包裹实现请求级去重;(3.2) copyCoursePlanToClasses 改单事务 + 2 次 batch INSERT;(3.3) gradeHomeworkAnswers 改 UPDATE CASE WHEN 单次批量更新;(3.4) searchQuestions 改 FULLTEXT INDEX + MATCH AGAINST BOOLEAN MODE + toBooleanModeQuery 工具;(3.5) getAllUserIds 加默认 LIMIT 1000 + 分页参数;(3.6) announcements/notifications fan-out 改 createNotifications 批量 INSERT + sendBatchNotifications 并行收集;(3.7) 高 LIMIT 默认值调低(attendance/adaptive-practice/questions data-access 1000-5000→100)。Phase 4 组件渲染优化:(4.1) AssignmentCard/EmptyState/StatusBadge React.memo;(4.2) lesson-plan-editor/text-study-block/exercise-block/paper-context-menu 拆分 Zustand 细粒度 selector(避免整体订阅);(4.3) message-detail/message-list/grade-record-list/attendance-sheet 改 useOptimistic + useTransition 乐观更新;(4.4) question-data-table/exam-data-table 接入 @tanstack/react-virtual 列表虚拟化;(4.5) sidebar-provider resize 改 debounce + useMemo;(4.6) 14 个 recharts 组件 props 提取到模块级常量避免 inline 重建;(4.7) scan-image-viewer aspect-ratio;(4.8) AiAssistantWidget inner 拆分 + 动态 import;(4.9) web-vitals-reporter.tsx 修复 Metric 类型推导(从 useReportWebVitals 签名推导)+ PerformanceEntry 类型注解 + sendBeacon/fetch keepalive fallback。架构决策:(1) 动态 import 标准模式 xxx-inner.tsx + lazy wrapper;(2) 请求级去重 > 跨请求 cache(React cache() not unstable_cache);(3) 批量 SQL > 循环 SQL(INSERT batch + UPDATE CASE WHEN);(4) React 19 useOptimistic 替代手动 isPending;(5) 细粒度 Zustand selector > useShallow 多字段 selector。验证:npx tsc --noEmit 零新增错误;npm run lint 零新增错误(pre-existing 3 errors + 13 warnings 均位于未修改文件)。", + "lastUpdated": "2026-07-05" }, "architectureOverview": { "layers": [ @@ -1419,6 +1419,70 @@ "api/ai/chat/stream", "api/notifications/stream" ] + }, + { + "name": "getCacheStore", + "file": "lib/cache/store-factory.ts", + "signature": "getCacheStore(): Promise", + "purpose": "获取当前进程 CacheStore 单例(默认 MemoryCacheStore,CACHE_DRIVER=redis 时懒加载 RedisCacheStore)", + "deps": [ + "env.mjs", + "./memory-store", + "./redis-store" + ], + "usedBy": [ + "shared/lib/cache/cache-fn", + "shared/lib/cache/invalidate" + ] + }, + { + "name": "cacheFn", + "file": "lib/cache/cache-fn.ts", + "signature": "cacheFn(fn: (...args: TArgs) => Promise, options: CacheFnOptions): (...args: TArgs) => Promise", + "purpose": "服务端数据缓存双层包装器:外层 react.cache 提供请求级 memoization(同 RSC 请求内去重),内层 cacheStore.getOrSet 跨请求/跨实例缓存。命中且未过期返回缓存值,否则调用 producer 并按 tag 写入 CacheStore", + "deps": [ + "./store-factory", + "react" + ], + "usedBy": [ + "modules/* data-access" + ] + }, + { + "name": "fillTemplate", + "file": "lib/cache/invalidation-map.ts", + "signature": "fillTemplate(template: string, params: Record): string", + "purpose": "模板占位符填充({key} → params[key]),缺失参数时保留原占位符", + "deps": [], + "usedBy": [ + "shared/lib/cache/invalidate" + ] + }, + { + "name": "INVALIDATION_MAP", + "file": "lib/cache/invalidation-map.ts", + "signature": "INVALIDATION_MAP: Record", + "purpose": "集中式失效映射表:每个 mutation actionId 声明其副作用 tags/queryKeys/paths", + "deps": [ + "./types" + ], + "usedBy": [ + "shared/lib/cache/invalidate" + ] + }, + { + "name": "invalidateFor", + "file": "lib/cache/invalidate.ts", + "signature": "invalidateFor(actionId: string, params?: Record): Promise", + "purpose": "Server Action 写操作成功后集中编排缓存失效三步:1) CacheStore.invalidateTags 2) Next.js revalidateTag × N 3) revalidatePath × N", + "deps": [ + "./invalidation-map", + "./store-factory", + "next/cache" + ], + "usedBy": [ + "modules/* Server Actions" + ] } ], "hooks": [ @@ -4250,7 +4314,7 @@ { "name": "ExamDataTable", "file": "exam-data-table.tsx", - "purpose": "考试数据表格" + "purpose": "考试数据表格(✅ Phase 4.3:@tanstack/react-virtual 虚拟滚动,替换分页避免大数据渲染卡顿)" }, { "name": "ExamFilters", @@ -6289,7 +6353,7 @@ { "name": "QuestionDataTable", "file": "question-data-table.tsx", - "purpose": "题目数据表格(i18n + 集成 BatchOperations)" + "purpose": "题目数据表格(i18n + 集成 BatchOperations + ✅ Phase 4.3:@tanstack/react-virtual 虚拟滚动替换分页)" }, { "name": "QuestionFilters", @@ -24269,6 +24333,7 @@ "AiSuggestionSkeleton", "AiProviderSelector", "AiAssistantWidget", + "AiAssistantWidgetInner", "AiMarkdownRenderer", "AiChartRenderer", "AiChildSummary", diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index affa229..f511c49 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -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.2:Zustand selector 细粒度拆分规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 单字段订阅用细粒度 selector | `const title = useStore((s) => s.title)` | `const { title } = useStore()` (整体订阅,任一字段变化都触发 re-render) | +| 派生 boolean 用 selector 调用 | `const canUndo = useStore((s) => s.canUndo())` | `const editor = useStore(); const canUndo = editor.canUndo()` (每次渲染都调用函数) | +| 多字段订阅用 useShallow | `const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))` | `const { a, b } = useStore((s) => ({ a: s.a, b: s.b }))` (每次渲染都创建新对象) | +| 函数引用天然稳定,无需 useShallow | `const setTitle = useStore((s) => s.setTitle)` | `const setTitle = useStore(useShallow((s) => s.setTitle))` (多余浅比较开销) | +| 选择最精细的字段订阅 | `const nodes = useStore((s) => s.doc.nodes)` | `const doc = useStore((s) => s.doc)` (如果只用到 nodes 字段,订阅整个 doc 会在 anchors 变化时也触发 re-render) | + +### Phase 4.5:useOptimistic + useTransition 乐观更新规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 单字段乐观切换用 useOptimistic + useTransition | `const [optimisticIsStarred, addOptimisticStarred] = useOptimistic(isStarred, (_, next) => next); const [, startTransition] = useTransition(); startTransition(async () => { addOptimisticStarred(next); await action(); router.refresh(); })` | `const [isStarred, setIsStarred] = useState(initial); setIsStarred(next); await action(); setIsStarred(serverValue)` (手动同步易出错) | +| useOptimistic 必须在 transition 或 action 内调用 | `startTransition(async () => { addOptimistic(value); await serverAction(); }); ` 或在 `` 的 fn 内调用 | 在普通事件处理函数外直接调用 `addOptimistic(value)` (无效) | +| 成功后调用 router.refresh() 同步数据源 | `await serverAction(); router.refresh();` (useOptimistic 自动回滚到最新服务端值) | 只调用 `await serverAction()` 不 refresh (useOptimistic 状态回滚到原值,UI 不更新) | +| form action 模式下 useOptimistic 自动管理 | ` { setOptimisticSubmitting(true); await action(fd); }}>` (action 完成后自动回滚) | 在 form action 内用 `useState` + `setIsSubmitting(false)` (多余的 finally 块) | +| form action 模式不要用 useTransition 包裹 | `` (useFormStatus 自动跟踪 pending) | `const [, startTransition] = useTransition(); { startTransition(async () => { await handleSubmit(fd); }) }}>` (form action 立即返回,useFormStatus 不可靠) | +| Map 形式乐观更新用 useOptimistic + reducer | `const [map, addOptimistic] = useOptimistic(new Map(), (state, { id, value }) => { const next = new Map(state); next.set(id, value); return next; })` | `const [override, setOverride] = useState({}); setOverride({ ...override, [id]: value })` (手动管理回滚复杂) | +| 不需要乐观更新的操作保留 useState | 撤回操作 `const [isRecalled, setIsRecalled] = useState(false)` (需要根据服务端返回判断是否成功) | 所有操作都用 useOptimistic (失败时乐观状态错误显示) | + +### paper-context-menu.tsx duplicateNode 调用签名不匹配(pre-existing tsc 错误) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| `duplicateNode` 函数签名只接受 1 个参数 | `const newId = duplicateNode(nodeId)` (后缀在函数内硬编码为 `(副本)`) | `const newId = duplicateNode(nodeId, t("v4.contextMenu.copySuffix"))` (TS2554: Expected 1 arguments, but got 2) | + +> 此为 pre-existing bug,`hooks/editor-slice.ts` 的 `duplicateNode(id: string) => string \| null` 签名只接受 1 参数,但 `paper-context-menu.tsx` 第 58 行传了 2 个参数。Phase 4.2 修改 selector 时未引入此错误,但需记录待修复。 + +--- + +## Phase 3.6 + Phase 3.7 + Phase 3.8 DB 性能优化规则(2026-07-05) + +### 24.1 FULLTEXT 索引声明规则(drizzle-kit 限制) + +| 规则 | 正确做法 | 错误做法 | +|------|---------|---------| +| drizzle-kit 无法生成 FULLTEXT 索引声明 | 迁移文件中手动追加 `CREATE FULLTEXT INDEX \`idx_name\` ON \`table\` (\`col\`);` | 仅在 schema.ts 中声明索引期望 drizzle-kit 自动生成 | +| 手动追加后必须同步 snapshot 的 indexes 节点 | 在 `meta/00XX_snapshot.json` 对应表 indexes 中加 `"type": "fulltext"` | 仅改 .sql 不改 snapshot,下次 drizzle-kit generate 会重复生成 | +| MySQL FULLTEXT 要求 InnoDB + utf8mb4 | 建表时 `engine=InnoDB` + `charset=utf8mb4`(MySQL 5.7+) | 在 MyISAM 表或 utf8 字符集上创建 FULLTEXT | +| JSON 字段不能直接创建 FULLTEXT 索引 | 添加 STORED 生成列 `content_text` 用 `CAST(content AS CHAR)` 镜像为纯文本,对生成列创建 FULLTEXT | `CREATE FULLTEXT INDEX ON questions (content)`(JSON 列不支持) | +| 生成列必须为 STORED 模式 | `text("content_text").generatedAlwaysAs(sql\`CAST(content AS CHAR)\`, { mode: "stored" })` | VIRTUAL 生成列不支持 FULLTEXT 索引 | +| schema.ts 顶部需导入 `sql` | `import { sql } from "drizzle-orm";` | 直接使用 `sql` 标识符(ReferenceError) | + +**示例**(`src/shared/db/schema.ts`): +```typescript +contentText: text("content_text").generatedAlwaysAs( + sql`CAST(content AS CHAR)`, + { mode: "stored" }, +), +``` + +**示例**(迁移文件 `drizzle/0001_questions_fulltext_search.sql` 末尾手动追加): +```sql +-- P3-6: questions.content_text FULLTEXT 索引(drizzle-kit 无法生成 FULLTEXT 声明,需手动追加) +-- 要求:InnoDB 引擎 + utf8mb4 字符集(MySQL 5.7+) +CREATE FULLTEXT INDEX `questions_content_text_ft_idx` ON `questions` (`content_text`); +``` + +**示例**(`drizzle/meta/0001_snapshot.json` 中 questions 表 indexes 节点): +```json +"questions_content_text_ft_idx": { + "name": "questions_content_text_ft_idx", + "columns": ["content_text"], + "isUnique": false, + "type": "fulltext" +} +``` + +### 24.2 MATCH AGAINST BOOLEAN MODE 查询转换规则 + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| 调用方传原始查询字符串 `q` | `searchQuestions(q, limit)`(q 为用户输入原文) | `searchQuestions("%keyword%", limit)`(LIKE 格式传给 MATCH) | +| 工具函数转换 `q` 为 `+word1* +word2*` | `toBooleanModeQuery("数学 函数")` → `"+数学* +函数*"` | 直接传 `"数学 函数"` 给 AGAINST(无前缀,仅完整词匹配) | +| 转义 BOOLEAN MODE 特殊字符 | `word.replace(/[+\-<>()~*"']/g, " ").trim()` | 保留 `+`/`-`/`(` 等字符(被 MySQL 解释为操作符) | +| 单词后加 `*` 启用前缀匹配 | `+word*`(匹配 word 开头的所有词) | `+word`(仅匹配完全相同的词) | +| 空查询需短路返回空数组 | `if (words.length === 0) return []` | 空 `+*` 传给 AGAINST(MySQL 报语法错误) | + +**示例**(`src/modules/search/data-access.ts`): +```typescript +function toBooleanModeQuery(q: string): string { + const words = q.trim().split(/\s+/).filter(Boolean) + if (words.length === 0) return "" + const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*` + return words.map(escapeWord).filter((w) => w !== "+*").join(" ") +} + +// 使用 +const booleanQuery = toBooleanModeQuery(q) +const rows = await db + .select({ id: questions.id }) + .from(questions) + .where(sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`) +``` + +### 24.3 批量 INSERT 规则(fan-out 场景) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| fan-out 批量通知用单次多行 INSERT | `db.insert(messageNotifications).values(rows)`(rows 为数组) | `for (const p of payloads) { await insert(p) }`(N 次 INSERT) | +| 批量发短信/邮件用 `Promise.all(map(send))` | `await Promise.all(valid.map((item) => sendSms(item)))` | `for (const item of valid) { await sendSms(item) }`(串行等待) | +| 批量 INSERT 失败时整体回滚 | `try { const ids = await createNotifications(inputs); ... } catch { // 所有项标记失败 }` | 单条 try/catch 部分成功部分失败(数据一致性破坏) | +| in_app 渠道单次批量,其他渠道并行 | `inAppSender.sendBatch(inAppPayloads)` + `Promise.all(otherChannels.map(send))` | 所有渠道统一 `Promise.all(map(sendNotification))`(in_app 实际是 N 次单条 INSERT) | +| 批量入口接收 `items: Array<{...}>` 数组 | `async sendBatch(items: Array<{ payload; recipient }>): Promise` | 暴露单条 `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 { + 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: () => })` | 顶层静态 import 整个 TipTap/ReactFlow | +| Provider 上下文与懒加载实现分离 | 同步壳保留 ``,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 ... }` | 在文件首行加 `"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 => )}`(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 } ` + `` | ``(每次渲染新对象引用,子组件 memo 失效) | +| 图片容器用 aspect-ratio | `
` | ``(加载时布局抖动 CLS) | +| next/web-vitals Metric 类型从签名推导 | `type M = Parameters[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 自动回滚 | ` { setOptimistic(true); await action(fd); }}>` | `useEffect(() => setIsSubmitting(false), [result])`(多余 effect) | +| 不需要乐观的保留 useState | 撤回操作 `const [recalled, setRecalled] = useState(false)`(依赖服务端返回) | 强行用 useOptimistic(失败时乐观状态错误) | + +**Phase 4.3 涉及文件**: +- `src/modules/messaging/components/message-detail.tsx` — 星标 useOptimistic + useTransition +- `src/modules/messaging/components/message-list.tsx` — 星标 Map useOptimistic +- `src/modules/grades/components/grade-record-list.tsx` — 编辑保存 useOptimistic + useTransition +- `src/modules/attendance/components/attendance-sheet.tsx` — 提交中 useOptimistic(保留 form action) + +## Vitest + Next.js 16 缓存测试规则(2026-07-05) + +| 规则 | 正确写法 | 错误写法 | +|------|---------|---------| +| `vi.mock` 工厂内引用变量必须用 `vi.hoisted` 提升 | `const { mockStore } = vi.hoisted(() => ({ mockStore: {...} }))` 后 `vi.mock("./x", () => ({ f: vi.fn().mockResolvedValue(mockStore) }))` | `const mockStore = {...}` 后 `vi.mock("./x", () => ({ f: vi.fn().mockResolvedValue(mockStore) }))`(TDZ:Cannot access 'mockStore' before initialization) | +| `vi.mock` 工厂也可用箭头函数延迟绑定(不需 `vi.hoisted`) | `vi.mock("./x", () => ({ f: () => mockFn() }))`(每次调用时才读取顶层 `mockFn`) | `vi.mock("./x", () => ({ f: mockFn }))`(工厂执行时 `mockFn` 未初始化) | +| `vitest.unit.config.ts` 已设 `mockReset: true`,每个测试前 mock 返回值被清空 | `beforeEach(() => { vi.mocked(getCacheStore).mockResolvedValue(mockStore) })` | `beforeEach(() => { vi.clearAllMocks() })`(mockResolvedValue 已被 reset,调用时报 `Cannot read properties of undefined`) | +| React 19 `cache()` 在 vitest jsdom 环境是 no-op(不去重) | 测试用真正缓存的 mock 实现(`mockStore.getOrSet` 实现命中缓存逻辑)验证去重,不依赖 react.cache 行为 | 期望 `react.cache` 在 jsdom 中去重(producer 仅调 1 次),实际不去重(调用 N 次) | +| cacheFn 双层缓存测试 mock store-factory 时需保持引用稳定 | 顶层 `const mockStore = { getOrSet: vi.fn() }` + `const getCacheStoreMock = vi.fn().mockResolvedValue(mockStore)` + `vi.mock("./store-factory", () => ({ getCacheStore: () => getCacheStoreMock() }))` | `vi.mock("./store-factory", () => ({ getCacheStore: vi.fn().mockResolvedValue(mockStore) }))`(mockReset 后丢失实现) | +| Next.js 16 起 `revalidateTag` 类型签名要求第二参数 `profile`(必填) | `revalidateTag(tag, "default")` | `revalidateTag(tag)`(tsc TS2554: Expected 2 arguments, but got 1) | +| `revalidatePath` 第二参数 `type` 仍可选 | `revalidatePath(path)` / `revalidatePath("/", "layout")` | — | + +**涉及文件**:`src/shared/lib/cache/invalidate.ts`、`src/shared/lib/cache/invalidate.test.ts`、`src/shared/lib/cache/cache-fn.ts`、`src/shared/lib/cache/cache-fn.test.ts` +