docs(architecture): update impact map, data, audit report, troubleshooting
- Update 004_architecture_impact_map.md - Update 005_architecture_data.json - Add lesson-preparation-audit-report-v5.md - Add troubleshooting docs
This commit is contained in:
@@ -894,7 +894,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `actions.ts` | 502 | 14 个 Server Action(写操作,含 Zod 校验 + 资源归属校验 + 知识图谱查询/前置依赖管理 + class-mastery 视图) |
|
||||
| `data-access.ts` | 593 | 教材/章节/知识点 CRUD + 跨模块查询接口 + 资源归属校验 + 数据范围过滤 + 前置依赖 CRUD(v3 P2:硬编码异常改为 NotFoundError) |
|
||||
| `data-access-graph.ts` | 188 | 知识图谱只读查询(✅ Task 5 新增:知识点关联聚合、学生/班级掌握度、前置后置知识点,标记 `server-only`;v2 修复:前置依赖双向 IN 过滤防跨教材污染) |
|
||||
| `types.ts` | 94 | 类型定义(含知识图谱类型:GraphViewMode/MasteryInfo/KpWithRelations/GraphNodeData/GraphEdgeData/KnowledgeGraphData/MasteryLevel) |
|
||||
| `types.ts` | 94 | 类型定义(含知识图谱类型:GraphViewMode/GraphLayoutMode/MasteryInfo/KpWithRelations/GraphNodeData/GraphEdgeData/KnowledgeGraphData/MasteryLevel) |
|
||||
| `schema.ts` | 56 | Zod 校验(含 CreatePrerequisiteSchema/DeletePrerequisiteSchema;v2 修复:refine 消息改为英文;v3 修复:移除死代码 ReorderChaptersSchema) |
|
||||
| `constants.ts` | 91 | 学科/年级常量与颜色映射(✅ 新增;v1 测试修复:新增 Chinese/Grade 1/Grade 2) |
|
||||
| `utils.ts` | 226 | 章节树构建/排序/查找等纯函数 + 循环检测(✅ 新增,含单测) |
|
||||
@@ -908,10 +908,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `hooks/use-kp-update.ts` | 36 | 知识点更新 Hook(v3 P2 新增:从 use-kp-crud 拆分) |
|
||||
| `hooks/use-text-selection.ts` | 49 | 文本选区捕获 Hook |
|
||||
| `hooks/use-graph-data.ts` | 65 | 知识图谱数据加载 Hook(✅ Task 11 新增:派生值模式避免 effect 中 setState;v2 修复:区分 isLoading/isRefreshing 避免 UI 闪烁) |
|
||||
| `components/knowledge-graph.tsx` | 356 | React Flow 知识图谱主组件(✅ Task 10/15 重写:全书视图 + 搜索高亮 + 关联节点高亮 + 章节着色;v2 新增:添加/删除前置依赖 Dialog) |
|
||||
| `components/knowledge-graph.tsx` | 408 | 知识图谱主组件(✅ Task 10/15 重写:全书视图 + 搜索高亮 + 关联节点高亮 + 章节着色;v2 新增:添加/删除前置依赖 Dialog;v4 新增:hierarchical/force 布局模式切换,根据布局模式渲染分层 ReactFlow 或 Obsidian 风格力导向图,状态/详情面板/依赖增删对话框共享) |
|
||||
| `components/graph-kp-node.tsx` | 92 | React Flow 自定义节点(✅ Task 9 新增:知识点名称+题目数徽章+掌握度进度条;v2 修复:节点宽度常量化 NODE_WIDTH;v3 修复:类型守卫替代 as 断言) |
|
||||
| `components/graph-prerequisite-edge.tsx` | 43 | React Flow 自定义边(✅ Task 9 新增:虚线+箭头表示前置依赖;v2 修复:GraphEdgeData 类型安全转换) |
|
||||
| `components/graph-toolbar.tsx` | 84 | 图谱工具栏(✅ Task 9 新增:视图模式切换 + 搜索 + 重置视图;v2 新增:isRefreshing 轻量指示器) |
|
||||
| `components/graph-toolbar.tsx` | 121 | 图谱工具栏(✅ Task 9 新增:视图模式切换 + 搜索 + 重置视图;v2 新增:isRefreshing 轻量指示器;v4 新增:hierarchical/force 布局模式切换 Select) |
|
||||
| `components/force-graph.tsx` | 451 | Obsidian 风格力导向图谱组件(v4 新增:基于 react-force-graph-2d + d3-force;v5 视觉对齐 Obsidian Graph View:小圆点节点+极细半透明边线+标签右侧带背景+hover/选中邻居高亮+紫色外圈焦点环;通过 next/dynamic ssr:false + import type 加载避免 SSR canvas/window 报错) |
|
||||
| `components/graph-node-detail-panel.tsx` | 171 | 节点详情侧边面板(✅ Task 13 新增:描述/掌握度/关联题目/前置后置列表;v2 修复:移除未使用 textbookId prop;v3 修复:aria-label) |
|
||||
| `components/chapter-sidebar-list.tsx` | 325 | 章节侧边栏列表(v2 修复:canEdit 默认 false + orderUpdateFailed i18n key;v3 修复:键盘导航) |
|
||||
| `components/section-error-boundary.tsx` | 53 | 章节内容错误边界(v2 修复:默认值改为空字符串 + 条件渲染;v3 修复:componentDidCatch + a11y) |
|
||||
@@ -919,7 +920,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `components/textbook-content-panel.tsx` | 199 | 章节内容面板(v3 P2:17 props 按 chapter/editing/selection/highlight 四组重构) |
|
||||
| `components/textbook-form-dialog.tsx` | 68 | 教材创建对话框(v3 P2:复用 TextbookFormFields) |
|
||||
| `components/textbook-settings-dialog.tsx` | 136 | 教材设置对话框(v3 P2:复用 TextbookFormFields) |
|
||||
| `components/textbook-reader.tsx` | 412 | 教材阅读器主组件(v3 P2:移除命令式 classList,高亮改为状态驱动) |
|
||||
| `components/textbook-reader.tsx` | 458 | 教材阅读器主组件(v3 P2:移除命令式 classList,高亮改为状态驱动;v5 桌面端改用 ResizablePanel 可拖拽分栏,图谱 tab 时图谱初始占 60%,切换 tab 用 key 重置比例) |
|
||||
| `components/*` | 17 文件 | 教材编辑/知识图谱组件(新增 `textbook-form-fields.tsx`、5 个 graph-* 组件、3 个 use-kp-* 子 Hook;`teacher-textbook-reader.tsx` 已移至 app 层) |
|
||||
|
||||
---
|
||||
@@ -2758,6 +2759,34 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
> - **权限扩展**:新增 STANDARD_READ/MANAGE/LINK + LESSON_PLAN_SUBMIT/REVIEW/ANALYTICS_READ/SUBSTITUTE_MANAGE
|
||||
> - **i18n 扩展**:zh-CN 新增 9 个命名空间;status 扩展为 6 状态
|
||||
> - **数据库表**:新增 6 张表(lessonPlanComments/lessonPlanReviewRecords/lessonPlanFormativeItems/lessonPlanAttachments/lessonPlanAiEvaluations/lessonPlanSubstituteAssignments)
|
||||
>
|
||||
> 架构变更(2026-07-03,V5 场景缺口修复第一阶段):
|
||||
> - **V5-1 自动保存失败 UI 兜底**:version-slice.ts 新增 saveError/isOnline 状态;lesson-plan-editor.tsx 监听 online/offline 事件 + toast 提示 + 重试按钮
|
||||
> - **V5-2 撤销/重做**:新增 hooks/history-slice.ts(past/future 栈,50 步上限);editor-slice.ts 所有 mutation 包装 pushHistory;编辑器顶部 Undo/Redo 按钮 + Ctrl/Cmd+Z/Shift+Z 快捷键
|
||||
> - **V5-3 发布前作业单预览**:publish-homework-dialog.tsx 改为 3 步流程(选班级 → 预览题目+总分+学生数 → 确认发布);exercise-block.tsx 传入 items
|
||||
> - **V5-4 导出/打印**:新增 lib/export.ts(文档扁平化,详细版/简洁版)+ components/print-view.tsx(打印视图);编辑器顶部新增"导出"按钮
|
||||
> - **V5-5 多媒体嵌入 + 素材库**:types.ts RichTextBlockData 新增 attachments 字段;rich-text-block.tsx 集成 @tiptap/extension-image;新增 components/attachment-picker.tsx(复用 use-file-upload + M6 附件表 data-access);LessonPlanDataService 接口新增 getLessonPlanAttachments/createLessonPlanAttachment/deleteLessonPlanAttachment 三个方法;新增依赖 @tiptap/extension-image;FileTargetType 新增 "lesson_plan"
|
||||
> - **i18n 扩展**:zh-CN/en 同步新增 action.undo/redo、status.retrySave/saveFailed/recovered/offline、publish.step1-3/preview*/confirm*、questionBank.type 多题型、export.*、attachment.*、editor.textbookContent
|
||||
> - **审计报告**:新增 docs/architecture/audit/lesson-preparation-audit-report-v5.md(场景缺口维度,对照 v4 架构合规维度)
|
||||
>
|
||||
> 架构变更(2026-07-03,V5 场景缺口修复第二阶段 V5-6~V5-21):
|
||||
> - **V5-6 W1 题库组卷预览**:exercise-block.tsx 增加组卷预览模式(题目列表 + 总分统计)
|
||||
> - **V5-7 W3/W4 课案绑定课时**:新增 data-access-schedules.ts + actions-schedules.ts + components/schedule-dialog.tsx;编辑器新增「安排课时」按钮;数据库新增 lesson_plan_schedules 表
|
||||
> - **V5-8 U2 画布自动布局**:新增 lib/auto-layout.ts(@dagrejs/dagre DAG 自动布局,项目已安装依赖无需新增);editor-slice.ts 新增 autoLayout action
|
||||
> - **V5-9 F5 教材模糊搜索 + 最近使用**:template-picker.tsx 增加客户端模糊搜索(标题/学科/年级/出版社)+ localStorage 最近使用 5 个教材
|
||||
> - **V5-10 P1 画布性能优化**:node-editor.tsx ReactFlow 新增 onlyRenderVisibleElements 虚拟化 + minZoom={0.2}/maxZoom={2.5}/elevateNodesOnSelect={false}
|
||||
> - **V5-12 R4 校内课案库**:新增 teacher/lesson-plans/library 路由 + loading.tsx + error.tsx;actions.ts 新增 duplicateLessonPlanFormAction(FormData wrapper 适配 form action)
|
||||
> - **V5-13 P3 移动端只读视图**:新增 components/lesson-plan-mobile-view.tsx(按教学阶段分组线性卡片视图);lesson-plan-readonly-view.tsx 集成 useMediaQuery("(max-width: 768px)") 小屏切换
|
||||
> - **V5-14 F4 板书可视化工具**:blackboard-block.tsx 增加轻量级可视化预览(不引入新库)——编辑/预览模式切换、structure 层级树、mindmap 中心+分支、text 等宽展示;新增 BlackboardPreview 子组件 + parseContent 纯函数(按缩进识别层级)
|
||||
> - **V5-15 T1 教学阶段分组**:types.ts 新增 TeachingStage 类型 + TEACHING_STAGE_KEYS 常量;Block 接口新增 stage? 字段;node-edit-panel.tsx 添加教学阶段选择器 UI
|
||||
> - **V5-16 T2 反思闭环(版本对比)**:新增 lib/version-diff.ts(纯函数 diffDocuments 对比两个 LessonPlanDocument 节点级差异,added/removed/modified/unchanged + changedFields);新增 components/version-diff-view.tsx;version-history-drawer.tsx 集成版本对比(currentDoc prop + 对比按钮)
|
||||
> - **V5-17 A1/A2 AI 反馈闭环**:新增 lib/ai-feedback.ts(generateLessonPlanFeedback 纯服务端函数,4 维度 strengths/improvements/alignment/differentiation,含解释性 reason 字段);actions-ai.ts 新增 generateLessonPlanFeedbackAction;新增 components/ai-feedback-dialog.tsx;编辑器工具栏新增「AI 反馈」按钮
|
||||
> - **V5-18 W6 差异化教学标记**:types.ts 新增 DifferentiationLevel 类型 + DIFFERENTIATION_LEVEL_KEYS 常量;Block 接口新增 differentiation? 字段;node-edit-panel.tsx 添加差异化标记选择器 UI
|
||||
> - **V5-19 T3 目标-评价一致性校验**:新增 lib/consistency-check.ts(纯函数 checkConsistency 输出 ConsistencyResult,校验规则:缺少 objective/exercise 警告、exercise 无知识点警告、objective 未被评价覆盖警告);新增 components/consistency-check-dialog.tsx;编辑器工具栏新增「一致性校验」按钮
|
||||
> - **V5-20 T4 课标覆盖热力图**:新增 lib/curriculum-coverage.ts(纯函数 computeCurriculumCoverage 按 chapterId 分组统计覆盖率,识别 isBlindSpot 教学盲点;getHeatLevel 0-4 热力等级);新增 components/curriculum-heatmap.tsx;新增 teacher/lesson-plans/heatmap 路由 + loading.tsx + error.tsx
|
||||
> - **V5-21 A3/A4/A5 AI 差异化模块**:新增 lib/ai-differentiation.ts(3 个纯服务端函数:generateDifferentiationSuggestions/checkCurriculumAlignment/generateExplainableAssessment,复用 ai-feedback.ts 的 createAiChatCompletion + Zod + JSON 提取模式);actions-ai.ts 新增 4 个 server actions(generateDifferentiationSuggestionsAction/checkCurriculumAlignmentAction/generateExplainableAssessmentAction/getKnowledgePointsForAlignmentAction);新增 components/ai-differentiation-dialog.tsx(3 Tab 视图:差异化建议/课标核对/可解释评估,A4 按教材 ID 按需加载知识点);编辑器工具栏新增「AI 差异化」按钮
|
||||
> - **i18n 扩展**:zh-CN/en 同步新增 editor.stage*/editor.differentiation*/editor.consistency*/consistency.*/diff.*/library.*/feedback.*/heatmap.*/aiDifferentiation.*/blackboard.editMode/previewMode/editHint/previewEmpty/untitled
|
||||
> - **V5-11 R3 协同编辑**:需引入新依赖(Yjs/Liveblocks)或采用轻量协作方案(评论+@提醒+版本对比),暂标记为待规划,待用户决策
|
||||
|
||||
---
|
||||
|
||||
@@ -3893,7 +3922,7 @@ rateLimit(params: RateLimitParams): Promise<RateLimitResult> // audit-P1-6:
|
||||
resetRateLimit(key: string): Promise<void> // audit-P1-6:改为 Promise 返回
|
||||
rateLimitKey(prefix: string, identifier: string): string // 同步纯函数
|
||||
rateLimitHeaders(result: RateLimitResult): Record<string, string> // 同步纯函数
|
||||
getRateLimiter(): RateLimiter // audit-P1-6 新增:返回当前进程的限流器实例
|
||||
getRateLimiter(): Promise<RateLimiter> // audit-P1-6 新增:返回当前进程的限流器实例;redis 驱动下动态 import redis-limiter 故为异步
|
||||
RATE_LIMIT_RULES // 同步常量(含 LOGIN/API/UPLOAD/AI_CHAT/PASSWORD_CHANGE/ONBOARDING_BIND;audit-P1-8 新增 ONBOARDING_BIND: 5次/小时)
|
||||
// RateLimiter interface (audit-P1-6 新增)
|
||||
// limit(params: RateLimitParams): Promise<RateLimitResult>
|
||||
|
||||
@@ -6443,11 +6443,21 @@
|
||||
"name": "GraphViewMode",
|
||||
"type": "type",
|
||||
"definition": "\"structure\" | \"student-mastery\" | \"class-mastery\"",
|
||||
"purpose": "Task 7 新增:图谱视图模式",
|
||||
"purpose": "Task 7 新增:图谱数据视角(结构 / 学生掌握度 / 班级掌握度)",
|
||||
"usedBy": [
|
||||
"getKnowledgeGraphDataAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "GraphLayoutMode",
|
||||
"type": "type",
|
||||
"definition": "\"hierarchical\" | \"force\"",
|
||||
"purpose": "v4 新增:图谱渲染布局(hierarchical=分层有向图 ReactFlow+dagre;force=Obsidian 风格力导向图 react-force-graph-2d)",
|
||||
"usedBy": [
|
||||
"components/knowledge-graph.tsx",
|
||||
"components/graph-toolbar.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MasteryInfo",
|
||||
"type": "interface",
|
||||
@@ -6648,7 +6658,17 @@
|
||||
},
|
||||
{
|
||||
"name": "KnowledgeGraph",
|
||||
"purpose": "知识图谱可视化"
|
||||
"purpose": "知识图谱可视化(v4 新增:支持 hierarchical/force 两种布局模式切换,hierarchical=ReactFlow+dagre 分层有向图,force=Obsidian 风格力导向图;外层共享状态/详情面板/前置依赖增删对话框)"
|
||||
},
|
||||
{
|
||||
"name": "ForceKnowledgeGraph",
|
||||
"file": "components/force-graph.tsx",
|
||||
"purpose": "v4 新增:Obsidian 风格力导向图谱组件,基于 react-force-graph-2d + d3-force;圆形节点+曲线连接+物理力导向+邻居高亮+拖拽联动;通过 next/dynamic ssr:false 加载避免 SSR canvas/window 报错"
|
||||
},
|
||||
{
|
||||
"name": "GraphToolbar",
|
||||
"file": "components/graph-toolbar.tsx",
|
||||
"purpose": "图谱工具栏(v4 新增 hierarchical/force 布局模式切换 Select;保留视图模式切换/搜索/重置视图)"
|
||||
},
|
||||
{
|
||||
"name": "GraphNodeDetailPanel",
|
||||
@@ -6693,7 +6713,10 @@
|
||||
],
|
||||
"uiDeps": [],
|
||||
"uiDepsNote": "已通过 render prop 解耦,TeacherTextbookReader 已移至 app 层(src/app/(dashboard)/teacher/textbooks/[id]/_components/),textbooks 模块不再直接 import questions 模块组件",
|
||||
"npmDeps": ["@xyflow/react", "@dagrejs/dagre", "react-force-graph-2d"],
|
||||
"npmDepsNote": "v4 新增 react-force-graph-2d(基于 d3-force + Canvas)实现 Obsidian 风格力导向图谱;通过 next/dynamic ssr:false 加载避免 SSR canvas/window 报错;@xyflow/react + @dagrejs/dagre 用于分层有向图布局",
|
||||
"knownIssues": [
|
||||
"v4 新增(2026-07-03):知识点图谱新增 Obsidian 风格力导向布局(force 模式),与原分层有向图(hierarchical 模式)共存,工具栏可切换;新增组件 force-graph.tsx,新增类型 GraphLayoutMode;保留全部现有功能(掌握度视图/搜索高亮/邻居高亮/前置依赖增删)",
|
||||
"i18n 覆盖率约 99%(v3 新增 notFound/noClassMasteryPermission/close/prerequisiteAddFailed/prerequisiteRemoveFailed 等键,data-access 层硬编码英文异常文案已改为 NotFoundError 错误码化)",
|
||||
"v3 审计修复(2026-06-24):学生端两个页面添加 requirePermission(Permissions.TEXTBOOK_READ) 显式权限校验",
|
||||
"v3 审计修复(2026-06-24):getKnowledgeGraphDataAction 添加学生/家长年级 scope 过滤,class-mastery 视图模式限制为 teacher/admin",
|
||||
@@ -6749,8 +6772,9 @@
|
||||
"components/graph-kp-node.tsx": 92,
|
||||
"components/graph-node-detail-panel.tsx": 171,
|
||||
"components/graph-prerequisite-edge.tsx": 43,
|
||||
"components/graph-toolbar.tsx": 84,
|
||||
"components/knowledge-graph.tsx": 356,
|
||||
"components/graph-toolbar.tsx": 121,
|
||||
"components/force-graph.tsx": 454,
|
||||
"components/knowledge-graph.tsx": 408,
|
||||
"components/knowledge-point-dialogs.tsx": 167,
|
||||
"components/knowledge-point-list.tsx": 117,
|
||||
"components/section-error-boundary.tsx": 53,
|
||||
@@ -20513,6 +20537,76 @@
|
||||
"name": "suggestKnowledgePoints",
|
||||
"file": "ai-suggest.ts",
|
||||
"purpose": "AI 建议知识点(基于课案内容)"
|
||||
},
|
||||
{
|
||||
"name": "autoLayout",
|
||||
"file": "lib/auto-layout.ts",
|
||||
"purpose": "V5-8 U2 新增:基于 @dagrejs/dagre 的 DAG 自动布局算法,按 edges 拓扑排序计算 nodes 位置"
|
||||
},
|
||||
{
|
||||
"name": "diffDocuments",
|
||||
"file": "lib/version-diff.ts",
|
||||
"purpose": "V5-16 T2 新增:纯函数对比两个 LessonPlanDocument 节点级差异,返回 added/removed/modified/unchanged + changedFields"
|
||||
},
|
||||
{
|
||||
"name": "hasChanges",
|
||||
"file": "lib/version-diff.ts",
|
||||
"purpose": "V5-16 T2 新增:判断 VersionDiffResult 是否存在变更"
|
||||
},
|
||||
{
|
||||
"name": "checkConsistency",
|
||||
"file": "lib/consistency-check.ts",
|
||||
"purpose": "V5-19 T3 新增:纯函数目标-评价一致性校验,输出 ConsistencyResult(缺少 objective/exercise 警告、exercise 无知识点警告、objective 未被评价覆盖警告)"
|
||||
},
|
||||
{
|
||||
"name": "hasConsistencyWarnings",
|
||||
"file": "lib/consistency-check.ts",
|
||||
"purpose": "V5-19 T3 新增:判断 ConsistencyResult 是否存在警告"
|
||||
},
|
||||
{
|
||||
"name": "computeCurriculumCoverage",
|
||||
"file": "lib/curriculum-coverage.ts",
|
||||
"purpose": "V5-20 T4 新增:纯函数按 chapterId 分组统计教师所有课案对教材知识点的覆盖率,识别 isBlindSpot 教学盲点"
|
||||
},
|
||||
{
|
||||
"name": "getHeatLevel",
|
||||
"file": "lib/curriculum-coverage.ts",
|
||||
"purpose": "V5-20 T4 新增:根据覆盖率返回热力图颜色等级(0-4)"
|
||||
},
|
||||
{
|
||||
"name": "generateLessonPlanFeedback",
|
||||
"file": "lib/ai-feedback.ts",
|
||||
"purpose": "V5-17 A1/A2 新增:AI 反馈闭环,纯服务端函数,4 维度 strengths/improvements/alignment/differentiation,含解释性 reason 字段;AI 不可用时返回空结果"
|
||||
},
|
||||
{
|
||||
"name": "generateDifferentiationSuggestions",
|
||||
"file": "lib/ai-differentiation.ts",
|
||||
"purpose": "V5-21 A3 新增:AI 差异化教学建议生成,按 basic/intermediate/advanced 三层生成建议"
|
||||
},
|
||||
{
|
||||
"name": "checkCurriculumAlignment",
|
||||
"file": "lib/ai-differentiation.ts",
|
||||
"purpose": "V5-21 A4 新增:AI 课标实时核对,检查课案是否覆盖教材知识点"
|
||||
},
|
||||
{
|
||||
"name": "generateExplainableAssessment",
|
||||
"file": "lib/ai-differentiation.ts",
|
||||
"purpose": "V5-21 A5 新增:AI 可解释评估,对 exercise 节点给出 conclusion/rationale/suggestion"
|
||||
},
|
||||
{
|
||||
"name": "getLessonPlanSchedules",
|
||||
"file": "data-access-schedules.ts",
|
||||
"purpose": "V5-7 W3/W4 新增:查询课案绑定的课时列表"
|
||||
},
|
||||
{
|
||||
"name": "createLessonPlanSchedule",
|
||||
"file": "data-access-schedules.ts",
|
||||
"purpose": "V5-7 W3/W4 新增:课案绑定课时"
|
||||
},
|
||||
{
|
||||
"name": "deleteLessonPlanSchedule",
|
||||
"file": "data-access-schedules.ts",
|
||||
"purpose": "V5-7 W3/W4 新增:删除课时绑定"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -20629,6 +20723,60 @@
|
||||
"permission": "LESSON_PLAN_READ",
|
||||
"file": "actions.ts",
|
||||
"purpose": "按 textbookId 获取章节树(供 template-picker 选择章节)"
|
||||
},
|
||||
{
|
||||
"name": "duplicateLessonPlanFormAction",
|
||||
"permission": "LESSON_PLAN_CREATE",
|
||||
"file": "actions.ts",
|
||||
"purpose": "V5-12 R4 新增:FormData wrapper 适配 <form action={...}>,内部调用 duplicateLessonPlanAction 后 redirect 到新课案编辑页"
|
||||
},
|
||||
{
|
||||
"name": "getLessonPlanSchedulesAction",
|
||||
"permission": "LESSON_PLAN_READ",
|
||||
"file": "actions-schedules.ts",
|
||||
"purpose": "V5-7 W3/W4 新增:查询课案绑定的课时列表"
|
||||
},
|
||||
{
|
||||
"name": "createLessonPlanScheduleAction",
|
||||
"permission": "LESSON_PLAN_UPDATE",
|
||||
"file": "actions-schedules.ts",
|
||||
"purpose": "V5-7 W3/W4 新增:课案绑定课时"
|
||||
},
|
||||
{
|
||||
"name": "deleteLessonPlanScheduleAction",
|
||||
"permission": "LESSON_PLAN_UPDATE",
|
||||
"file": "actions-schedules.ts",
|
||||
"purpose": "V5-7 W3/W4 新增:删除课时绑定"
|
||||
},
|
||||
{
|
||||
"name": "generateLessonPlanFeedbackAction",
|
||||
"permission": "LESSON_PLAN_READ + AI_CHAT",
|
||||
"file": "actions-ai.ts",
|
||||
"purpose": "V5-17 A1/A2 新增:AI 反馈闭环,调用 generateLessonPlanFeedback 生成结构化反馈"
|
||||
},
|
||||
{
|
||||
"name": "generateDifferentiationSuggestionsAction",
|
||||
"permission": "LESSON_PLAN_READ + AI_CHAT",
|
||||
"file": "actions-ai.ts",
|
||||
"purpose": "V5-21 A3 新增:AI 差异化教学建议生成"
|
||||
},
|
||||
{
|
||||
"name": "checkCurriculumAlignmentAction",
|
||||
"permission": "LESSON_PLAN_READ + AI_CHAT",
|
||||
"file": "actions-ai.ts",
|
||||
"purpose": "V5-21 A4 新增:AI 课标实时核对"
|
||||
},
|
||||
{
|
||||
"name": "generateExplainableAssessmentAction",
|
||||
"permission": "LESSON_PLAN_READ + AI_CHAT",
|
||||
"file": "actions-ai.ts",
|
||||
"purpose": "V5-21 A5 新增:AI 可解释评估"
|
||||
},
|
||||
{
|
||||
"name": "getKnowledgePointsForAlignmentAction",
|
||||
"permission": "LESSON_PLAN_READ",
|
||||
"file": "actions-ai.ts",
|
||||
"purpose": "V5-21 A4 辅助新增:按 textbookId 获取知识点列表(仅 id+name),供课标核对 Tab 按需加载"
|
||||
}
|
||||
],
|
||||
"schemas": [
|
||||
@@ -20867,7 +21015,12 @@
|
||||
"V4-11": "P2-4 废弃文件删除:components/block-renderer.tsx(@deprecated,无源码引用)删除",
|
||||
"V4-12": "P2-5 anchor-injector regex 一致性:markdownToPlainText 的 /^>\\s+/gm 改为 /^\\s*>\\s+/gm;skipPatterns 5 个 regex 统一添加 g 标志,与 markdownToPlainText 保持一致",
|
||||
"V4-13": "P2-6 Zustand store 拆 slice:hooks/use-lesson-plan-editor.ts 从 303 行单体 store 拆分为 3 个独立 slice——hooks/editor-slice.ts(文档结构+文档操作)/hooks/selection-slice.ts(选中状态)/hooks/version-slice.ts(版本与保存状态);主文件重写为 25 行薄层,组合 3 个 slice 并导出 EditorState 类型",
|
||||
"V4-14": "type-guards 导出补全:lib/type-guards.ts 的 VALID_QUESTION_TYPES 常量从模块私有改为 export(供 inline-question-editor.tsx 复用,消除重复定义)"
|
||||
"V4-14": "type-guards 导出补全:lib/type-guards.ts 的 VALID_QUESTION_TYPES 常量从模块私有改为 export(供 inline-question-editor.tsx 复用,消除重复定义)",
|
||||
"V5-1": "自动保存失败 UI 兜底:version-slice.ts 新增 saveError/isOnline 状态 + setSaveError/setOnline 方法;lesson-plan-editor.tsx 自动保存失败时 toast 提示 + 设置 saveError,断网时不触发保存请求,监听 window online/offline 事件 toast 提示,saveError 时显示重试按钮,恢复时提示已恢复",
|
||||
"V5-2": "撤销/重做:新增 hooks/history-slice.ts(past/future 栈,50 步上限,canUndo/canRedo/pushHistory/undo/redo/clearHistory 方法);editor-slice.ts 所有 mutation 方法(setTitle/addNode/updateNode/removeNode/updateTextbookContent/addAnchor/removeAnchor/updateAnchor/connect/disconnect/setEdges)包装 pushHistory,updateNodePosition 除外(拖拽过程中由调用方在 onDragStart 时推入);version-slice hydrate/replaceDoc 调用 clearHistory;lesson-plan-editor.tsx 顶部新增 Undo/Redo 按钮 + Ctrl/Cmd+Z 撤销 + Ctrl/Cmd+Shift+Z 或 Ctrl/Cmd+Y 重做快捷键",
|
||||
"V5-3": "发布前作业单预览:publish-homework-dialog.tsx 改为 3 步流程(step=select 选班级+时间 → step=preview 预览题目+总分+班级数 → step=confirm 确认发布),第二步显示题目列表(题型/来源/题干预览/分值)+汇总信息(班级数/题目数/总分),第三步显示最终确认信息+警告文案;exercise-block.tsx 调用 PublishHomeworkDialog 时传入 items={data.items}",
|
||||
"V5-4": "导出/打印:新增 lib/export.ts(flattenLessonPlanForPrint 函数,将画布式 LessonPlanDocument 扁平化为 PrintableLessonPlan 线性结构,支持 detailed/concise 两种 variant,concise 仅含 objective/new_teaching/exercise/homework)+ components/print-view.tsx(打印视图组件,详细版/简洁版切换 + 调用 window.print() 通过浏览器原生能力保存为 PDF + print: CSS 媒体查询隐藏工具栏 + 页眉含教材/教师/班级/教学时长/最后保存时间 + break-inside-avoid 防止段落跨页);lesson-plan-editor.tsx 新增 printablePlan useMemo 构造 + 导出按钮 + PrintView 渲染",
|
||||
"V5-5": "多媒体嵌入 + 我的素材库:types.ts RichTextBlockData 新增 attachments?: RichTextAttachment[] 字段 + RichTextAttachment 类型(attachmentId/fileId/displayName/kind: image|audio|video|file/url/mimeType?);rich-text-block.tsx 集成 @tiptap/extension-image(Image.configure inline:false allowBase64:false)+ 新增附件入口按钮 + 已嵌入附件列表渲染(非图片附件列表 + 图片附件缩略图,含删除按钮)+ 图片附件通过 editor.commands.setImage 直接插入富文本;新增 components/attachment-picker.tsx(素材库 picker,复用 use-file-upload hook 调用 /api/upload + 调用 service.createLessonPlanAttachment 落库到 M6 lessonPlanAttachments 表 + 列出 service.getLessonPlanAttachments + 删除 service.deleteLessonPlanAttachment + inferAttachmentKind 工具函数);LessonPlanDataService 接口新增 getLessonPlanAttachments/createLessonPlanAttachment/deleteLessonPlanAttachment 三个方法 + LessonPlanAttachmentOption 导出类型;default-data-service.ts 实现三个方法(DB 层 Date 转 ISO string 以匹配接口类型);BlockRenderProps 新增 planId? 字段;NodeEditPanel 新增 planId prop 透传;lesson-plan-editor.tsx NodeEditPanel 调用传入 planId;新增依赖 @tiptap/extension-image;files/types.ts FileTargetType 新增 'lesson_plan' 类型"
|
||||
}
|
||||
},
|
||||
"error-book": {
|
||||
@@ -22029,6 +22182,13 @@
|
||||
"columns": 6,
|
||||
"indexes": 2,
|
||||
"foreignKeys": 2
|
||||
},
|
||||
"lessonPlanSchedules": {
|
||||
"owner": "lesson-preparation",
|
||||
"description": "V5-7 W3/W4 课案绑定课时表(planId+classId+lessonDate 唯一约束,2026-07-03 新增)",
|
||||
"columns": 6,
|
||||
"indexes": 3,
|
||||
"foreignKeys": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -25438,11 +25598,45 @@
|
||||
"duplicateLessonPlanAction",
|
||||
"saveAsTemplateAction",
|
||||
"suggestKnowledgePointsAction",
|
||||
"publishLessonPlanHomeworkAction"
|
||||
"publishLessonPlanHomeworkAction",
|
||||
"getLessonPlanSchedulesAction",
|
||||
"createLessonPlanScheduleAction",
|
||||
"deleteLessonPlanScheduleAction",
|
||||
"generateLessonPlanFeedbackAction",
|
||||
"generateDifferentiationSuggestionsAction",
|
||||
"checkCurriculumAlignmentAction",
|
||||
"generateExplainableAssessmentAction",
|
||||
"getKnowledgePointsForAlignmentAction"
|
||||
],
|
||||
"permission": "lesson_plan:update",
|
||||
"permissionRead": "lesson_plan:read",
|
||||
"description": "编辑课案页面(权限:requirePermission(LESSON_PLAN_UPDATE);只读访问需 LESSON_PLAN_READ)"
|
||||
"description": "编辑课案页面(权限:requirePermission(LESSON_PLAN_UPDATE);只读访问需 LESSON_PLAN_READ;V5-7 新增课时绑定 actions;V5-17/V5-21 新增 AI 反馈/差异化 actions)"
|
||||
},
|
||||
"/teacher/lesson-plans/library": {
|
||||
"component": "LibraryPage",
|
||||
"type": "server",
|
||||
"module": "lesson-preparation",
|
||||
"method": "GET",
|
||||
"dataAccess": [
|
||||
"lesson-preparation/data-access.getLessonPlans"
|
||||
],
|
||||
"actions": [
|
||||
"duplicateLessonPlanFormAction"
|
||||
],
|
||||
"permission": "lesson_plan:read",
|
||||
"description": "V5-12 R4 新增:校内课案库页面,展示其他教师发布的课案,支持一键复制(权限:requirePermission(LESSON_PLAN_READ))"
|
||||
},
|
||||
"/teacher/lesson-plans/heatmap": {
|
||||
"component": "HeatmapPage",
|
||||
"type": "server",
|
||||
"module": "lesson-preparation",
|
||||
"method": "GET",
|
||||
"dataAccess": [
|
||||
"lesson-preparation/data-access.getLessonPlans",
|
||||
"textbooks/data-access.getKnowledgePointsByTextbookId"
|
||||
],
|
||||
"permission": "lesson_plan:read",
|
||||
"description": "V5-20 T4 新增:课标覆盖热力图页面,统计教师所有课案对教材知识点的覆盖情况(权限:requirePermission(LESSON_PLAN_READ))"
|
||||
},
|
||||
"/teacher/error-book": {
|
||||
"component": "TeacherErrorBookPage + SubjectTabs + ClassFilter + AnalyticsStatsCards + ClassErrorBarChart + KnowledgePointWeaknessChart + ChapterWeaknessChart + GroupedStudentErrorTable + TopWrongQuestions",
|
||||
|
||||
289
docs/architecture/audit/lesson-preparation-audit-report-v5.md
Normal file
289
docs/architecture/audit/lesson-preparation-audit-report-v5.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# 备课模块审计报告 v5
|
||||
|
||||
> 审计日期:2026-07-03
|
||||
> 审计范围:`src/modules/lesson-preparation/` 场景覆盖度维度(区别于 v4 的架构合规维度)
|
||||
> 审计依据:用户实际备课工作流反馈 + v4 审计报告 + K12 行业产品对标(Planboard / Nearpod / Common Curriculum)
|
||||
> 前序报告:`lesson-preparation-audit-report-v4.md`(2026-06-25,架构合规审计)
|
||||
> 审计方法:场景驱动缺口分析 + 实际代码核对 + 高频备课工作流走查
|
||||
|
||||
---
|
||||
|
||||
## 一、审计背景
|
||||
|
||||
v4 审计聚焦**架构合规性**(权限/类型/解耦/a11y),30 项 P0/P1/P2 已全部修复。M1-M12 中长期计划已**部分实施**(M1 课标 / M2 评论 / M3 审核 / M4 课程地图 / M5 形成性 / M6 附件表 / M8 AI 评估 / M9 日历 / M10 分析 / M11 diff / M12 代课)。
|
||||
|
||||
但用户反馈显示:**M1-M12 虽建了数据层,UI 集成度不够**(M6 附件表无 UI、M9 日历不绑课表、M2 评论无协同编辑),且**完全遗漏了 4 项核心能力**(导出/打印、撤销/重做、多媒体嵌入、发布前预览)。
|
||||
|
||||
v5 审计聚焦"场景覆盖度"维度,对照教师日常备课工作流识别缺口。
|
||||
|
||||
---
|
||||
|
||||
## 二、教师备课工作流与模块覆盖度
|
||||
|
||||
### 2.1 标准备课工作流
|
||||
|
||||
| 阶段 | 教师动作 | 模块覆盖 | 缺口 |
|
||||
|------|---------|---------|------|
|
||||
| 课前 | 选教材/课文 → 选模板 → 创建课案 | ✅ 完整 | - |
|
||||
| 课前 | 设计教学目标 / 重难点 / 导入 / 新授 / 总结 | ✅ 11 种 Block 完整 | - |
|
||||
| 课前 | 关联知识点 / 课标 | ✅ 知识点 / M1 课标 | - |
|
||||
| 课前 | 插入朗读音频 / 实验视频 / 图片素材 | ❌ rich_text 仅富文本 | F2 多媒体嵌入缺失 |
|
||||
| 课前 | 重用历史素材 | ❌ 无素材库 | F3 我的素材库缺失 |
|
||||
| 课前 | 设计板书(图文混排、分区) | ⚠️ blackboard 仅文本 | F4 板书可视化弱 |
|
||||
| 课前 | 撤销/重做画布操作 | ❌ 无 history 栈 | U1 撤销/重做缺失 |
|
||||
| 课前 | 自动保存失败提示 | ❌ 仅 console.error | S1 UI 兜底缺失 |
|
||||
| 课前 | 题库选题 → 一键发布作业 | ⚠️ 无预览步骤 | W2 发布前预览缺失 |
|
||||
| 课前 | 导出 PDF / 打印教案 | ❌ 完全缺失 | F1 导出/打印缺失 |
|
||||
| 课中 | 学生查看发布课案 | ✅ 只读视图 | - |
|
||||
| 课后 | 教学反思 + 二次备课 | ⚠️ reflection 节点存在,无锁定/diff | T2 反思闭环不完整 |
|
||||
|
||||
### 2.2 工作流卡点分析
|
||||
|
||||
教师日常备课高频路径中存在 **5 个"卡点"**——遇到即停止使用,回退到 Word/PPT:
|
||||
|
||||
| 卡点 | 频次 | 影响 |
|
||||
|------|------|------|
|
||||
| 画布误删节点无法撤销 | 极高 | 教师不敢操作画布 |
|
||||
| 自动保存失败无提示,关闭页面数据丢失 | 高 | 信任崩塌 |
|
||||
| 备课成果无法导出/打印交付教研组 | 高 | 系统外循环 |
|
||||
| 无法插入多媒体素材 | 高 | 课"备不活" |
|
||||
| 一键发布作业无预览 | 中 | 误发布风险 |
|
||||
|
||||
---
|
||||
|
||||
## 三、场景缺口详细分析
|
||||
|
||||
### 3.1 S1 - 自动保存失败 UI 兜底(🔴 极紧急)
|
||||
|
||||
- **位置**:[lesson-plan-editor.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx) L97-116
|
||||
- **现状**:自动保存失败仅 `console.error("[LessonPlanEditor] auto-save failed", e)`,UI 无任何提示
|
||||
- **后果**:
|
||||
- 教师关闭页面时数据丢失(beforeunload 仅检查 isDirty,不区分"保存失败"与"未触发保存")
|
||||
- 断网场景下教师继续编辑,所有改动静默丢失
|
||||
- **违反需求**:用户 §五"怕丢数据:必须要有强自动保存,以及断网恢复提示"
|
||||
|
||||
### 3.2 U1 - 撤销/重做(🔴 极紧急)
|
||||
|
||||
- **位置**:[editor-slice.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/hooks/editor-slice.ts) 全文
|
||||
- **现状**:Zustand store 无 history 栈,所有 mutation 直接 set
|
||||
- **后果**:
|
||||
- 画布上误删一个节点,只能去版本抽屉回滚到 30 分钟前的状态
|
||||
- 教师不敢操作画布,回退到列表式备课
|
||||
- **违反需求**:用户 §三.5"撤销/重做:画布操作没有撤销,简直没法用。必须支持至少 50 步撤销"
|
||||
|
||||
### 3.3 W2 - 发布前作业单预览(🔴 极紧急)
|
||||
|
||||
- **位置**:[publish-homework-dialog.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/publish-homework-dialog.tsx) 全文
|
||||
- **现状**:对话框仅选班级 + 时间,点击"发布"直接调用 service.publishLessonPlanHomework
|
||||
- **缺失**:
|
||||
- 无题目列表预览(题干/选项/答案/分值)
|
||||
- 无总分预览
|
||||
- 无班级学生数提示
|
||||
- 无"返回修改"步骤
|
||||
- **后果**:一键发布到学生/家长端,误发布无法收回
|
||||
- **违反需求**:用户 §五"怕误发布:发布前必须有预览"
|
||||
|
||||
### 3.4 F1 - 导出/打印(🔴 极紧急)
|
||||
|
||||
- **位置**:全模块无导出/打印相关代码
|
||||
- **现状**:画布做得再好,备课成果无法走出系统
|
||||
- **缺失**:
|
||||
- 无 PDF 导出
|
||||
- 无打印视图
|
||||
- 无"详细版/简洁版"切换
|
||||
- **后果**:教研组检查无纸质交付,教师回退到 Word
|
||||
- **违反需求**:用户 §二.1"没有导出,备课成果无法走出系统"
|
||||
|
||||
### 3.5 F2/F3 - 多媒体嵌入 + 我的素材库(🔴 极紧急)
|
||||
|
||||
- **位置**:
|
||||
- [rich-text-block.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/rich-text-block.tsx) L29-45 仅用 Tiptap StarterKit + Placeholder
|
||||
- [data-access-attachments.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-attachments.ts) M6 数据层已建,**无 UI 集成**
|
||||
- [actions-attachments.ts](file:///e:/Desktop/CICD/src/modules/lesson-preparation/actions-attachments.ts) Server Actions 已建,**无组件调用**
|
||||
- **现状**:rich_text 节点不支持图片/音视频;M6 附件表与 UI 完全断链
|
||||
- **后果**:
|
||||
- 教师无法插入朗读音频 / 实验视频 / PPT 截图
|
||||
- 每次都得从本地重新上传,无沉淀
|
||||
- **违反需求**:用户 §二.2"这决定了我能不能把课'备活'"
|
||||
|
||||
---
|
||||
|
||||
## 四、改进方案与实施
|
||||
|
||||
### 4.1 第一阶段实施(v5 P0,本次完成)
|
||||
|
||||
| ID | 缺口 | 实施方案 | 验收标准 |
|
||||
|----|------|---------|---------|
|
||||
| V5-1 | S1 自动保存失败 UI 兜底 | editor 添加 saveError 状态 + toast 提示 + 在线状态监听 + 重试按钮 | 断网时 toast 显示"保存失败",恢复时 toast 显示"已恢复在线" |
|
||||
| V5-2 | U1 撤销/重做 | 新增 `hooks/history-slice.ts`(past/future 栈,50 步上限)+ 编辑器顶部 Undo/Redo 按钮 + Cmd/Ctrl+Z / Cmd+Shift+Z 快捷键 | 误删节点后 Cmd+Z 可恢复 |
|
||||
| V5-3 | W2 发布前作业单预览 | publish-homework-dialog 改为 3 步流程(选班级 → 预览题目+总分+学生数 → 确认发布) | 发布前可见所有题干、选项、答案、分值、班级学生数 |
|
||||
| V5-4 | F1 导出/打印 | 新增 `lib/export.ts`(文档扁平化)+ `components/print-view.tsx`(打印视图)+ 编辑器"导出/打印"按钮 + 详细版/简洁版切换 | 浏览器打印对话框可保存为 PDF,含教材/教师/班级信息 |
|
||||
| V5-5 | F2/F3 多媒体嵌入 + 素材库 | rich-text-block 集成 Tiptap Image 扩展 + 新增 `components/attachment-picker.tsx` 附件库 picker + 集成 M6 附件表 | 可在 rich_text 中插入图片,从素材库选择已有附件 |
|
||||
|
||||
### 4.2 第二阶段计划(v5 P1,本次仅记录)
|
||||
|
||||
| ID | 缺口 | 优先级 |
|
||||
|----|------|--------|
|
||||
| V5-6 | W1 题库组卷预览(题干展开) | 🟠 |
|
||||
| V5-7 | W3/W4 课案绑定课时 | 🟠 |
|
||||
| V5-8 | U2 画布自动布局 | 🟠 |
|
||||
| V5-9 | F5 教材模糊搜索 + 最近使用 | 🟠 |
|
||||
| V5-10 | P1 大课案画布性能压测 | 🟠 |
|
||||
|
||||
### 4.3 第三阶段计划(v5 P2,本次仅记录)
|
||||
|
||||
| ID | 缺口 | 优先级 |
|
||||
|----|------|--------|
|
||||
| V5-11 | R3 协同编辑(Yjs/Liveblocks 评估) | 🟡 |
|
||||
| V5-12 | R4 校内课案库 | 🟡 |
|
||||
| V5-13 | P3 移动端只读视图 | 🟡 |
|
||||
| V5-14 | F4 板书可视化工具 | 🟡 |
|
||||
| V5-15 | T1 教学阶段分组 | 🟡 |
|
||||
| V5-16 | T2 反思闭环 | 🟡 |
|
||||
| V5-17 | A1/A2 AI 反馈闭环 + 解释性 | 🟡 |
|
||||
|
||||
### 4.4 第四阶段计划(v5 P3,本次仅记录)
|
||||
|
||||
| ID | 缺口 | 优先级 |
|
||||
|----|------|--------|
|
||||
| V5-18 | W6 差异化教学标记 | 🟢 |
|
||||
| V5-19 | T3 目标-评价一致性 | 🟢 |
|
||||
| V5-20 | T4 教师端课标热力图 | 🟢 |
|
||||
| V5-21 | A3/A4/A5 AI 差异化生成 + 课标实时核对 + 评估可解释 | 🟢 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构影响
|
||||
|
||||
### 5.1 新增文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `hooks/history-slice.ts` | Zustand history slice(past/future 栈,50 步上限) |
|
||||
| `lib/export.ts` | 文档扁平化导出工具(详细版/简洁版) |
|
||||
| `components/print-view.tsx` | 打印视图组件(含教材/教师/班级信息) |
|
||||
| `components/attachment-picker.tsx` | 附件库 picker(集成 M6 附件表) |
|
||||
|
||||
### 5.2 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `hooks/use-lesson-plan-editor.ts` | 组合 history slice |
|
||||
| `hooks/editor-slice.ts` | mutation 方法包装 history 推送 |
|
||||
| `components/lesson-plan-editor.tsx` | 自动保存失败 UI 兜底 + Undo/Redo 按钮 + 快捷键 + 导出按钮 |
|
||||
| `components/publish-homework-dialog.tsx` | 3 步发布流程(选班级 → 预览 → 确认) |
|
||||
| `components/blocks/rich-text-block.tsx` | 集成 Tiptap Image 扩展 + 附件库入口 |
|
||||
| `shared/i18n/messages/zh-CN/lesson-preparation.json` | 新增 undo/redo/export/print/saveError 等键 |
|
||||
| `shared/i18n/messages/en/lesson-preparation.json` | 同步英文翻译 |
|
||||
|
||||
### 5.3 架构图同步
|
||||
|
||||
修改 `docs/architecture/004_architecture_impact_map.md` §2.27 章节末尾追加"V5 场景缺口修复"小节。
|
||||
修改 `docs/architecture/005_architecture_data.json` modules.lesson_preparation.auditFixes 新增 V5-1 至 V5-5 条目。
|
||||
|
||||
---
|
||||
|
||||
## 六、验收标准
|
||||
|
||||
- ✅ `npm run lint` 零错误
|
||||
- ✅ `npx tsc --noEmit` 零错误
|
||||
- ✅ 单文件行数:组件 ≤ 500,hooks ≤ 80,工具函数 ≤ 40
|
||||
- ✅ 无 `any`、无 `as` 断言(除 unknown 收窄)
|
||||
- ✅ 全量 i18n(zh-CN + en)
|
||||
- ✅ 架构文档 004/005 已同步
|
||||
- ✅ `docs/troubleshooting/known-issues.md` 已记录
|
||||
|
||||
---
|
||||
|
||||
**v5 审计完成。本次实施第一阶段 V5-1 至 V5-5 共 5 项场景缺口修复。**
|
||||
|
||||
---
|
||||
|
||||
## 七、V5 第二阶段实施结果(2026-07-03 续)
|
||||
|
||||
### 7.1 已完成清单
|
||||
|
||||
| ID | 缺口 | 状态 | 实施摘要 |
|
||||
|----|------|------|---------|
|
||||
| V5-6 | W1 题库组卷预览 | ✅ | exercise-block.tsx 增加组卷预览模式 |
|
||||
| V5-7 | W3/W4 课案绑定课时 | ✅ | 新增 data-access-schedules.ts + actions-schedules.ts + schedule-dialog.tsx + lesson_plan_schedules 表 |
|
||||
| V5-8 | U2 画布自动布局 | ✅ | 新增 lib/auto-layout.ts(@dagrejs/dagre,复用已安装依赖) |
|
||||
| V5-9 | F5 教材模糊搜索 + 最近使用 | ✅ | template-picker.tsx 增加客户端模糊搜索 + localStorage 最近使用 |
|
||||
| V5-10 | P1 大画布性能优化 | ✅ | node-editor.tsx ReactFlow onlyRenderVisibleElements + zoom 限制 |
|
||||
| V5-12 | R4 校内课案库 | ✅ | 新增 teacher/lesson-plans/library 路由 + duplicateLessonPlanFormAction |
|
||||
| V5-13 | P3 移动端只读视图 | ✅ | 新增 lesson-plan-mobile-view.tsx + useMediaQuery 切换 |
|
||||
| V5-14 | F4 板书可视化工具 | ✅ | blackboard-block.tsx 增加轻量级可视化预览(不引入新库,纯 CSS + 文本解析) |
|
||||
| V5-15 | T1 教学阶段分组 | ✅ | types.ts TeachingStage 类型 + node-edit-panel 选择器 |
|
||||
| V5-16 | T2 反思闭环 | ✅ | 新增 lib/version-diff.ts 纯函数 + version-diff-view 组件 + 抽屉集成 |
|
||||
| V5-17 | A1/A2 AI 反馈闭环 | ✅ | 新增 lib/ai-feedback.ts 纯服务端函数 + ai-feedback-dialog + 工具栏集成 |
|
||||
| V5-18 | W6 差异化教学标记 | ✅ | types.ts DifferentiationLevel 类型 + node-edit-panel 选择器 |
|
||||
| V5-19 | T3 一致性校验 | ✅ | 新增 lib/consistency-check.ts 纯函数 + consistency-check-dialog + 工具栏集成 |
|
||||
| V5-20 | T4 课标热力图 | ✅ | 新增 lib/curriculum-coverage.ts 纯函数 + curriculum-heatmap + teacher/lesson-plans/heatmap 路由 |
|
||||
| V5-21 | A3/A4/A5 AI 差异化 | ✅ | 新增 lib/ai-differentiation.ts 3 个纯服务端函数 + ai-differentiation-dialog(3 Tab) + 4 个 server actions + 工具栏集成 |
|
||||
|
||||
### 7.2 待规划
|
||||
|
||||
| ID | 缺口 | 状态 | 备注 |
|
||||
|----|------|------|------|
|
||||
| V5-11 | R3 协同编辑 | 🟡 长远计划 | 详见 §7.5 Yjs 方案评估(2026-07-03 评估,因架构改造复杂度高暂不实施) |
|
||||
|
||||
### 7.3 i18n 修复
|
||||
|
||||
- 修复 zh-CN/en lesson-preparation.json 重复键问题(JSON 后者覆盖前者)
|
||||
- 新增命名空间:`editor.stage*`/`editor.differentiation*`/`editor.consistency*`/`consistency.*`/`diff.*`/`library.*`/`feedback.*`/`heatmap.*`/`aiDifferentiation.*`/`blackboard.editMode/previewMode/editHint/previewEmpty/untitled`
|
||||
|
||||
### 7.4 验证结果
|
||||
|
||||
- ✅ `npx tsc --noEmit` 零错误
|
||||
- ✅ 本次修改的 5 个文件(ai-differentiation-dialog/blackboard-block/actions-ai/lesson-plan-editor/ai-differentiation lib)`npx eslint` 零错误
|
||||
- ✅ 架构文档 004/005 已同步 V5-6~V5-21
|
||||
- ✅ `docs/troubleshooting/known-issues.md` 新增"V5 第二阶段 V5-6~V5-21 通用规则"小节
|
||||
|
||||
**V5 第二阶段完成 15 项(V5-6~V5-21 除 V5-11),V5-11 作为长远计划记录于 §7.5。**
|
||||
|
||||
### 7.5 V5-11 R3 协同编辑长远规划(Yjs + y-websocket 方案)
|
||||
|
||||
**决策**:2026-07-03 评估,因架构改造复杂度高,暂不实施,作为长远计划。
|
||||
|
||||
#### 7.5.1 Yjs 工作原理(澄清)
|
||||
|
||||
Yjs 是 **CRDT(无冲突复制数据类型)** 库,**不是邮箱/消息系统**,而是**实时状态同步**机制。当 A 老师在编辑器输入"导入",B 老师的屏幕上**立刻**出现"导入"——这是实时同步,不是发消息。
|
||||
|
||||
- **Yjs**:CRDT 数据结构库,保证多端最终一致性
|
||||
- **y-websocket**:传输层,需要独立的 WebSocket 服务器作为中转
|
||||
- **y-prosemirror**:Yjs 与 ProseMirror(Tiptap 底层)的绑定,让富文本编辑器支持多人光标
|
||||
|
||||
#### 7.5.2 与当前项目的关键冲突
|
||||
|
||||
| 冲突点 | 当前项目 | Yjs 要求 |
|
||||
|--------|---------|---------|
|
||||
| 服务器类型 | Next.js HTTP | 需独立 WebSocket 服务器进程 |
|
||||
| 持久化 | Drizzle ORM + MySQL | Yjs document 状态需独立持久化(LevelDB 或自定义 adapter) |
|
||||
| 权限校验 | Server Actions + requirePermission | WS 连接时需校验 JWT/session |
|
||||
| 编辑器状态 | Zustand store(editor-slice) | Yjs document 作为真相源,Zustand 降级为视图层 |
|
||||
| 数据结构 | LessonPlanDocument JSON | Yjs.XmlFragment(Tiptap 文档)或 Yjs.Map |
|
||||
|
||||
#### 7.5.3 推荐架构(未来实施时)
|
||||
|
||||
- **WS 服务器部署**:项目内独立进程(`server/ws-server.ts`,`npm run ws` 启动,生产环境用 PM2/Docker 管理)
|
||||
- **协同范围**:仅 RichTextBlock(Tiptap)节点实现多人实时协同,其他节点保持单人编辑
|
||||
- **持久化策略**:双写——Yjs document 作为实时层,每隔 N 秒(或用户离开时)将状态序列化为 JSON 写入 `lessonPlans.content`,兼容现有数据结构
|
||||
- **权限校验**:WS 连接握手时校验 JWT,按 planId 校验 `LESSON_PLAN_UPDATE` 权限
|
||||
|
||||
#### 7.5.4 需要安装的依赖
|
||||
|
||||
| 依赖 | 用途 | 备注 |
|
||||
|------|------|------|
|
||||
| `yjs` | CRDT 核心库 | 必装 |
|
||||
| `y-websocket` | WebSocket 传输层 | 必装 |
|
||||
| `y-prosemirror` | Yjs ↔ ProseMirror 绑定 | 必装(Tiptap 集成) |
|
||||
| `@hocuspocus/server` | WS 服务器框架(可选) | 提供权限/持久化/扩展插件,开箱即用 |
|
||||
| `y-leveldb` | Yjs 持久化(可选) | LevelDB 存储,或自定义 MySQL adapter |
|
||||
|
||||
#### 7.5.5 替代方案
|
||||
|
||||
若未来评估认为 Yjs 复杂度过高,可考虑**轻量协作方案**作为替代:
|
||||
- 不引入实时协同库
|
||||
- 复用现有 messaging/notifications 模块 + V5-16 版本对比
|
||||
- 实现「Block 级评论 + @提醒 + 版本对比」
|
||||
- 无需新依赖,开发快,与项目现有架构契合
|
||||
652
docs/troubleshooting/known-issues.md
Normal file
652
docs/troubleshooting/known-issues.md
Normal file
@@ -0,0 +1,652 @@
|
||||
# 项目规则速查手册
|
||||
|
||||
> 一页式规则清单,指明正确做法。无需解释原因,遇到问题查表即可。
|
||||
|
||||
---
|
||||
|
||||
## 一、i18n 翻译文件规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| key 禁止包含 `.` | `"title": { "parent": "..." }` | `"title.parent": "..."` |
|
||||
| 同字段多角色变体用嵌套对象 | `"title": { "default": "...", "parent": "...", "teacher": "..." }` | `"title": "..."`, `"title.parent": "..."` |
|
||||
| 不同字段用驼峰命名 | `"createDesc": "..."` | `"create.desc": "..."` |
|
||||
| 调用方 `t("title.parent")` 自动解析嵌套 | 无需改调用方代码 | — |
|
||||
| 动态拼 key 需包含完整路径 | `t(\`type.${type}\`)`(type 在 type 对象下) | `t(type)`(漏掉 `type.` 前缀) |
|
||||
| 嵌套 key 调用需带父级路径 | `t(\`difficulty.${level}\`)` | `t(level)` |
|
||||
|
||||
### i18n 动态 key 调用规则
|
||||
|
||||
当 `t()` 参数为动态变量时,**必须包含完整嵌套路径**:
|
||||
|
||||
```typescript
|
||||
// JSON 结构: { "type": { "single_choice": "单选题", "multiple_choice": "多选题" } }
|
||||
|
||||
// 正确:带父级路径
|
||||
const label = t(`type.${question.type}`) // t("type.single_choice")
|
||||
|
||||
// 错误:漏掉父级路径,触发 MISSING_MESSAGE
|
||||
const label = t(question.type) // t("single_choice") → 找不到
|
||||
```
|
||||
| ICU 参数用单花括号 | `"level": "难度 {level}"` | `"level": "难度 {{level}}"` |
|
||||
| ICU 多参数直接并列 | `"summary": "{count} 题 · {subject}"` | `"summary": "{{count}} 题 · {{subject}}"` |
|
||||
|
||||
---
|
||||
|
||||
## 二、"use server" 文件规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 只能导出 `async function` | `export async function foo() {}` | `export const foo = ...` |
|
||||
| 常量/对象放 `types.ts` 或 `constants.ts` | `types.ts: export const CONFIG = {...}` | `actions.ts: export const CONFIG = {...}` |
|
||||
| 同步函数放 `lib/` 目录 | `lib/track-event.ts: export function track() {}` | `actions.ts: export function track() {}` |
|
||||
| 禁止值 re-export | 调用方直接从源文件 import | `export { foo } from "./bar"` |
|
||||
| 类型 re-export 允许 | `export type { Foo } from "./bar"` | — |
|
||||
|
||||
---
|
||||
|
||||
## 三、Client Component 依赖隔离规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 常量定义放 `types.ts` 或 `constants.ts` | `types.ts: export const MAX = 30` | `retention.ts: export const MAX = 30`(含 `import { db }`) |
|
||||
| Client Component 不得 import server-only 文件 | `import { MAX } from "../types"` | `import { MAX } from "../retention"`(拉入 mysql2) |
|
||||
| `import { db }` 或 `import "server-only"` 的文件不得被客户端 import | — | — |
|
||||
| 纯类型文件不得 import db/mysql2 | `types.ts` 无 server-only import | `types.ts: import { db } from "@/shared/db"` |
|
||||
|
||||
---
|
||||
|
||||
## 四、可选依赖处理规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 可选依赖动态 import 加 `webpackIgnore` | `await import(/* webpackIgnore: true */ "@upstash/redis")` | `await import("@upstash/redis")` |
|
||||
| 环境变量控制加载的模块用动态 import | `if (env.DRIVER === "redis") { const { Redis } = await import(...) }` | 顶层静态 import |
|
||||
| 仅 redis 驱动加载的类用动态 import | `const { RedisRateLimiter } = await import("./redis-limiter")` | `import { RedisRateLimiter } from "./redis-limiter"` |
|
||||
|
||||
---
|
||||
|
||||
## 五、Next.js 配置规则
|
||||
|
||||
| 规则 | 配置 |
|
||||
|------|------|
|
||||
| Node.js 服务端驱动标记为外部包 | `serverExternalPackages: ["mysql2"]` |
|
||||
| 输出模式 | `output: "standalone"` |
|
||||
|
||||
---
|
||||
|
||||
## 六、架构分层规则
|
||||
|
||||
| 层级 | 允许依赖 | 禁止依赖 |
|
||||
|------|---------|---------|
|
||||
| `app/` | `modules/` 的 actions + data-access | 直接访问 DB |
|
||||
| `modules/` | 其他 `modules/` 的 data-access + `shared/` | 直接查询对方 DB 表 |
|
||||
| `shared/` | 无 | `@/auth`、`@/proxy`、`modules/*` |
|
||||
|
||||
---
|
||||
|
||||
## 七、模块文件职责规则
|
||||
|
||||
| 文件 | 职责 | 禁止 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | Server Actions(async function + 权限校验) | 导出常量、同步函数、值 re-export |
|
||||
| `data-access.ts` | 数据访问(可拆分 `data-access-*.ts`) | 业务逻辑、权限校验 |
|
||||
| `types.ts` | 类型定义 + 常量 | `import { db }`、`import "server-only"` |
|
||||
| `schema.ts` | Zod 验证 | — |
|
||||
| `lib/` | 同步工具函数、业务逻辑 | "use server" 标记 |
|
||||
|
||||
---
|
||||
|
||||
## 八、验证命令速查
|
||||
|
||||
| 场景 | 命令 |
|
||||
|------|------|
|
||||
| 类型检查 | `npx tsc --noEmit` |
|
||||
| Lint | `npm run lint` |
|
||||
| 开发服务器 | `npm run dev` |
|
||||
| 生产构建 | `npm run build` |
|
||||
| 检查 i18n key 是否含 `.` | Grep pattern: `^\s*"[a-zA-Z_]+\.[a-zA-Z_\.]+"\s*:`,glob: `**/messages/**/*.json` |
|
||||
| 同步 schema 到数据库 | `npm run db:push` |
|
||||
| 执行迁移文件 | `npm run db:migrate` |
|
||||
| 检查表结构 | `SHOW COLUMNS FROM table_name` |
|
||||
|
||||
---
|
||||
|
||||
## 十、数据库 Schema 同步规则
|
||||
|
||||
| 规则 | 正确做法 | 错误做法 |
|
||||
|------|---------|---------|
|
||||
| 修改 schema.ts 后必须同步数据库 | `npm run db:push` 或 `npm run db:migrate` | 仅改 schema.ts 不同步数据库 |
|
||||
| 报错 `Unknown column` 时先查表结构 | `SHOW COLUMNS FROM xxx` | 仅看代码 schema 假设数据库已同步 |
|
||||
| 迁移文件按编号顺序执行 | `drizzle/00XX_*.sql` 按序号递增 | 跳过编号或乱序执行 |
|
||||
| `Failed query` 错误先排查数据库结构 | 用 mysql 客户端执行相同 SQL 确认 | 仅看代码假设查询逻辑错误 |
|
||||
| 新增字段必须生成迁移文件 | `npm run db:generate` 后 commit `.sql` + `_journal.json` | 仅 ALTER 数据库不留迁移文件 |
|
||||
| 修改 schema 后验证一致性 | `node scripts/verify-modules-schema.mjs` 或 `npm run db:push` | 仅看 dev server 不报错即认为成功 |
|
||||
|
||||
### 数据库同步问题排查
|
||||
|
||||
当出现 `Unknown column 'xxx' in 'field list'` 或 `Failed query` 错误时:
|
||||
|
||||
1. 用 `SHOW COLUMNS FROM 表名` 对比数据库实际结构与 `schema.ts`
|
||||
2. 若字段缺失,执行 `npm run db:push` 同步,或手动执行对应迁移 SQL
|
||||
3. 验证:用相同 SQL 在数据库客户端执行,确认不再报错
|
||||
|
||||
---
|
||||
|
||||
## 十一、Drizzle 迁移记录管理规则
|
||||
|
||||
| 规则 | 正确做法 | 错误做法 |
|
||||
|------|---------|---------|
|
||||
| 迁移文件必须录入 `_journal.json` | `npm run db:generate` 自动生成 entry | 手动创建 `.sql` 不更新 journal |
|
||||
| `__drizzle_migrations` 表必须记录已应用迁移 | `npm run db:migrate` 自动写入 hash | 手动 ALTER 后不补 hash 记录 |
|
||||
| 迁移文件编号唯一不可复用 | `0016_invitation_codes.sql` 与 `0016_message_reports.sql` 应改 `0022+` | 同前缀冲突 |
|
||||
| 手动补 DDL 后必须同步 journal + migrations 表 | 见下方"手动补迁移"流程 | 仅 ALTER 数据库,留 journal 缺失 |
|
||||
| 检查迁移状态 | `SELECT * FROM __drizzle_migrations` | 仅看 `_journal.json` 文件 |
|
||||
|
||||
### 手动补迁移流程(数据库已 ALTER 但 journal/migrations 表缺失时)
|
||||
|
||||
1. 在 `drizzle/` 创建 `00XX_*.sql` 文件(即使 DDL 已应用,仍需归档)
|
||||
2. 在 `drizzle/meta/_journal.json` 添加对应 entry(`tag` = 文件名不含扩展名)
|
||||
3. 计算文件 sha256 hash,插入 `__drizzle_migrations` 表:
|
||||
```sql
|
||||
INSERT INTO __drizzle_migrations (hash, created_at) VALUES ('<sha256>', <unix_ms>);
|
||||
```
|
||||
4. 验证:`npm run db:migrate` 应输出 "No new migrations to apply"
|
||||
|
||||
### Drizzle 字段映射规则
|
||||
|
||||
| 场景 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| TS 属性名与 DB 列名不同 | `status: mysqlEnum("report_status", [...])` | 假设 DB 列名是 `status` |
|
||||
| 验证 SQL 用 DB 列名 | `SELECT report_status FROM learning_diagnostic_reports` | `SELECT status FROM learning_diagnostic_reports` |
|
||||
| ORM 查询用 TS 属性名 | `db.select({ s: table.status })` | `db.select({ s: table.report_status })` |
|
||||
|
||||
---
|
||||
|
||||
## 十二、V5 备课模块场景缺口修复规则
|
||||
|
||||
### Zustand store 拆 slice + history slice 模式
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| mutation 方法包装 history | `setTitle: (title) => { get().pushHistory(); set({ title, isDirty: true }); }` | `setTitle: (title) => set({ title, isDirty: true })` |
|
||||
| 拖拽位置更新除外 | `updateNodePosition` 不推 history(调用方 onDragStart 时推) | 拖拽过程每次 move 都 pushHistory(栈瞬间爆满) |
|
||||
| hydrate/replaceDoc 清空历史 | `hydrate: (...) => set({ ..., past: [], future: [] })` | 切换课案后旧 history 残留 |
|
||||
| history 栈上限 | `past: [...s.past, snapshot].slice(-MAX_HISTORY)` | 无上限(内存爆炸) |
|
||||
|
||||
### 撤销/重做快捷键
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| Cmd/Ctrl 区分 | `const isMod = e.metaKey \|\| e.ctrlKey` | 仅 `e.ctrlKey`(Mac 不工作) |
|
||||
| Shift+Z 或 Y 重做 | `(key === "z" && e.shiftKey) \|\| key === "y"` | 仅 `key === "y"` |
|
||||
| preventDefault | `e.preventDefault()` | 不阻止默认(浏览器原生撤销/重做冲突) |
|
||||
|
||||
### 自动保存失败 UI 兜底
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 保存失败显示 toast | `toast.error(t("status.saveFailed"), { description: ... })` | `console.error(e)`(用户无感知) |
|
||||
| 断网时不触发保存 | `if (!editor.isOnline) return;` 在 debounce 内 | 网络请求堆积 |
|
||||
| 监听 online/offline | `window.addEventListener("online", handleOnline)` | 不监听(永远显示离线) |
|
||||
| saveError 显示重试按钮 | `{editor.saveError && <Button onClick={handleRetrySave}>...}` | 失败后无重试入口 |
|
||||
|
||||
### 发布前预览 3 步流程
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 步骤状态机 | `type Step = "select" \| "preview" \| "confirm"` | 单步直接发布 |
|
||||
| 预览显示题目列表 | `<ol>{items.map(...)}` 含题干/选项/分值 | 仅显示题目数量 |
|
||||
| 总分 useMemo | `const totalScore = useMemo(() => items.reduce(...), [items])` | 每次渲染 reduce |
|
||||
| 确认页警告文案 | `{t("publish.confirmWarning")}` | 无确认步骤直接发布 |
|
||||
|
||||
### Tiptap Image 扩展集成
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 安装依赖 | `npm install @tiptap/extension-image` | 自建图片上传组件 |
|
||||
| 配置 inline:false | `Image.configure({ inline: false, allowBase64: false })` | `allowBase64: true`(XSS 风险) |
|
||||
| 插入图片命令 | `editor.commands.setImage({ src, alt })` | 手动拼接 HTML `<img>` |
|
||||
| HTMLAttributes 类名 | `class: "rich-text-image max-w-full h-auto rounded"` | 内联 style |
|
||||
|
||||
### 附件表 Date → ISO string 转换
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| DB 层 Date 转接口 string | `createdAt: a.createdAt instanceof Date ? a.createdAt.toISOString() : a.createdAt` | 直接 `createdAt: a.createdAt`(类型不匹配) |
|
||||
| data-access 与 service 接口分离 | data-access 返回 `LessonPlanAttachment`(Date),service 转换为 `LessonPlanAttachmentOption`(string) | data-access 直接返回 string(破坏类型一致性) |
|
||||
|
||||
### 打印视图 print: 媒体查询
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 隐藏工具栏 | `<div className="... print:hidden">` | 工具栏也打印出来 |
|
||||
| 内容区全屏 | `print:static print:w-full print:max-h-none print:rounded-none` | 保持 modal 样式 |
|
||||
| 防止跨页 | `break-inside-avoid` | 段落被切断 |
|
||||
| 页脚固定 | `print:fixed print:bottom-2` | 页脚丢失 |
|
||||
|
||||
### 素材库 picker 复用现有基础设施
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 复用 use-file-upload hook | `const { inputRef, handleFiles, tasks } = useFileUpload(...)` | 自建 FormData + XMLHttpRequest |
|
||||
| 通过 service 调用 | `service.createLessonPlanAttachment(...)` | 组件直接 import createLessonPlanAttachmentAction |
|
||||
| LessonPlanDataService 接口扩展 | 在接口声明 + default-data-service 实现 | 组件直接 import actions(破坏解耦) |
|
||||
| FileTargetType 扩展 | `files/types.ts` 新增 `'lesson_plan'` 类型 | 字符串硬编码 `"lesson_plan"` |
|
||||
|
||||
### V5 第二阶段 V5-6~V5-21 通用规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| Server Action form action 适配 | `duplicateLessonPlanFormAction(formData: FormData)` 内部调用 `duplicateLessonPlanAction(planId)` + `redirect()` | 直接把 `duplicateLessonPlanAction(planId: string)` 传给 `<form action>`(签名不匹配) |
|
||||
| 按需加载知识点避免首屏负担 | 对话框 Tab 内 `useEffect` 调用 `getKnowledgePointsForAlignmentAction(textbookId)` | 编辑页直接 `Promise.all` 拉取整教材知识点(即使不打开 Tab) |
|
||||
| 复用已安装依赖 | V5-8 直接用 `@dagrejs/dagre`(已在 package.json) | 重新装 dagre 或自实现 DAG 布局 |
|
||||
| 轻量级可视化不引入新库 | V5-14 板书预览纯 CSS + 文本解析(缩进识别层级) | 引入 mermaid/markmap 库做板书预览 |
|
||||
| AI 模块纯服务端函数 | `import "server-only"` + `createAiChatCompletion` + Zod 验证 + JSON 提取 | AI 函数混在 actions 里,没有 server-only 标记 |
|
||||
| AI 不可用降级返回空结果 | `if (!text.trim()) return [];` `try { ... } catch { return []; }` | AI 失败抛错导致整个对话框崩溃 |
|
||||
| 版本对比纯函数 | `diffDocuments(oldDoc, newDoc)` 输出 added/removed/modified/unchanged | 在组件内直接对比(无法复用 + 难测试) |
|
||||
| 一致性校验纯函数 | `checkConsistency(doc)` 输出 ConsistencyResult | 在对话框 useEffect 内嵌套 if/else 校验逻辑 |
|
||||
| 课标覆盖热力图纯函数 | `computeCurriculumCoverage(allKps, planLinks)` | 在组件内嵌套计算逻辑 |
|
||||
| useMediaQuery 移动端切换 | `const isMobile = useMediaQuery("(max-width: 768px)")` 在 readonly-view | 服务端 `useState` 默认值与客户端不一致(hydration mismatch) |
|
||||
| 编辑器工具栏按钮分组 | 一致性校验/AI 反馈/AI 差异化 三个并列 Button + 各自 state + ErrorBoundary 包裹 | 三个对话框共用一个 state(无法独立打开) |
|
||||
| FocusTrap + ESC 关闭对话框 | `useEffect` 监听 `Escape` 键 + FocusTrap 包裹 | 仅点击 X 关闭(键盘用户无法关闭) |
|
||||
| 类型扩展不破坏旧数据 | `Block` 接口新增 `stage?` / `differentiation?` 可选字段 | 必填字段(旧数据迁移失败) |
|
||||
| KnowledgePoint 类型导入路径 | `import type { KnowledgePoint } from "@/modules/textbooks/types"` | `from "@/modules/textbooks/data-access"`(仅 types 文件导出) |
|
||||
| chapterId null 处理 | `chapterId: kp.chapterId ?? null` | 直接 `kp.chapterId`(`string \| undefined` 不能赋给 `string \| null`) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 九、问题记录规则
|
||||
|
||||
| 规则 | 要求 |
|
||||
|------|------|
|
||||
| 构建报错修复后 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||
| 运行时异常修复后 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||
| 框架/库版本兼容问题 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||
| 依赖配置问题 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||
| 架构约束违规 | 追加到 `docs/troubleshooting/known-issues.md` |
|
||||
| 记录格式 | 标题 → 错误现象 → 根因 → 解决方案 → 验证方法 → 受影响文件 |
|
||||
| 去重 | 同类问题在原条目补充,不重复创建 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、Drizzle 子查询字段 alias 规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 子查询中 raw SQL 字段必须声明 alias | `count().as("errorCount")` | `count()` |
|
||||
| `sql\`...\`` 字段也需 alias | `sql<number>\`sum(...)\`.as("masteredCount")` | `sql<number>\`sum(...)\`` |
|
||||
| 普通列引用可不加 alias | `questionId: errorBookItems.questionId` | — |
|
||||
| 外层引用子查询字段用 `.field` | `aggregatedSubquery.errorCount` | — |
|
||||
|
||||
### 错误现象
|
||||
|
||||
```
|
||||
You tried to reference "errorCount" field from a subquery, which is a raw SQL field,
|
||||
but it doesn't have an alias declared. Please add an alias to the field using ".as('alias')" method.
|
||||
```
|
||||
|
||||
### 正确示例
|
||||
|
||||
```typescript
|
||||
const aggregatedSubquery = db
|
||||
.select({
|
||||
questionId: errorBookItems.questionId,
|
||||
errorCount: count().as("errorCount"),
|
||||
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`.as("masteredCount"),
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(whereClause)
|
||||
.groupBy(errorBookItems.questionId)
|
||||
.as("aggregated")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、Server/Client Component 函数传递规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| Server Component 不传含函数的对象给 Client Component | `<Provider>`(不传 monitor prop) | `<Provider monitor={noopMonitor}>`(含 track 函数) |
|
||||
| Client Component prop 含函数时改为可选 | `monitor?: DiagnosticMonitor` | `monitor: DiagnosticMonitor`(必填) |
|
||||
| 函数对象在 Client 端组装 | `useMemo(() => createService(monitor), [monitor])` | Server 端 `createService()` 后传给 Client |
|
||||
| Server Component 仅传纯数据 | `reports={reports.reports}`(纯数据数组) | `service={createMonitoredService()}`(含函数) |
|
||||
|
||||
### 错误现象
|
||||
|
||||
```
|
||||
Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
|
||||
Attempted to call createMonitoredDiagnosticService() from the server but createMonitoredDiagnosticService is on the client.
|
||||
```
|
||||
|
||||
### 修复模式
|
||||
|
||||
```typescript
|
||||
// Provider (client component) - prop 改为可选,未传时用默认值
|
||||
interface ProviderProps {
|
||||
monitor?: DiagnosticMonitor // 可选,Server Component 不传
|
||||
children: ReactNode
|
||||
}
|
||||
export function Provider({ monitor, children }: ProviderProps) {
|
||||
const value = monitor ?? noopDiagnosticMonitor
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
// Server Component - 不传含函数的 prop
|
||||
<DiagnosticMonitorProvider>
|
||||
<DiagnosticServiceProvider>
|
||||
<ReportList reports={reports.reports} />
|
||||
</DiagnosticServiceProvider>
|
||||
</DiagnosticMonitorProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十五、Playwright 测试检测规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 不检查 content 中的 i18n JSON 文本 | `"发生未知错误" in content`(会匹配翻译 JSON) | — |
|
||||
| 检测错误用 HTTP 状态码 | `response.status != 200` | 仅检查页面文本 |
|
||||
| 检测可见错误用 locator + is_visible | `page.locator('text="错误").is_visible()` | `"错误" in content`(匹配隐藏 JSON) |
|
||||
| pageerror 回调不能累积注册 | 循环外注册一次,循环内 `errors.clear()` | 每次循环 `page.on("pageerror", ...)` |
|
||||
|
||||
### 错误现象
|
||||
|
||||
i18n 翻译 JSON 内联在页面 HTML 中(如 `"errors":{"unexpected":"发生未知错误"}`),`"发生未知错误" in content` 会误判为错误边界触发。
|
||||
|
||||
### 正确检测方式
|
||||
|
||||
```python
|
||||
# 1. HTTP 状态码
|
||||
response = page.goto(url, ...)
|
||||
http_status = response.status # 属性,不是方法
|
||||
|
||||
# 2. pageerror(循环外注册一次)
|
||||
all_errors = []
|
||||
page.on("pageerror", lambda err: all_errors.append(str(err)))
|
||||
for module in modules:
|
||||
all_errors.clear()
|
||||
# ...
|
||||
|
||||
# 3. 可见错误元素(不检查 HTML content)
|
||||
loc = page.locator('text="发生未知错误"').first
|
||||
if loc.count() > 0 and loc.is_visible():
|
||||
# 真正的错误边界
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十六、next-auth useSession SSR Hydration Mismatch 规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| Root Layout SSR 期间预取 session | `const session = await auth()` 后传给 `<AuthSessionProvider session={session}>` | 依赖 client 端 `useSession()` 异步获取 |
|
||||
| SessionProvider 接收 SSR session prop | `<SessionProvider session={session} refetchOnWindowFocus={false} refetchInterval={0}>` | 不传 session,client 端首次渲染为 `status:"loading"` |
|
||||
| Hydration mismatch 修复不用 `mounted` workaround | root layout 提供 SSR session 后直接用 `useSession()` | `const [mounted] = useState(false); useEffect(()=>setMounted(true),[]); if(!mounted) return null` |
|
||||
| 关闭 SessionProvider 自动 refetch | `refetchOnWindowFocus={false} refetchInterval={0}` | 默认 refetch 触发 SSR/CSR 不一致 |
|
||||
|
||||
### 错误现象
|
||||
|
||||
```
|
||||
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||||
+ id="radix-_R_19ebn6lb_"
|
||||
- id="radix-_R_55qbn6lb_"
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`useSession()` 在 SSR 期间返回 `status:"loading"`,CSR hydration 后变成 `"authenticated"`,导致 `SiteHeader` 中 `displayName`/`avatarFallback` 渲染结果不同,触发 Radix UI 组件重新生成 `id` 属性。
|
||||
|
||||
### 修复模式
|
||||
|
||||
```typescript
|
||||
// src/app/layout.tsx (Server Component)
|
||||
import { auth } from "@/auth"
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth() // SSR 预取
|
||||
return (
|
||||
<AuthSessionProvider session={session}>
|
||||
{children}
|
||||
</AuthSessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// src/shared/components/auth-session-provider.tsx ("use client")
|
||||
export function AuthSessionProvider({ children, session }: { children: React.ReactNode; session?: Session | null }) {
|
||||
return (
|
||||
<SessionProvider session={session} refetchOnWindowFocus={false} refetchInterval={0}>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十七、useState 初始值 SSR/CSR 一致性规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 初始值禁止 `typeof window` 分支 | `useState("unsupported")` 固定值 | `useState(typeof window !== "undefined" ? Notification.permission : "unsupported")` |
|
||||
| 浏览器 API 检测放 `useEffect` | `useEffect(() => { if ("Notification" in window) setPermission(Notification.permission) }, [])` | `useState` 初始值中读取 `window.Notification` |
|
||||
| 初始值禁止 `Math.random`/`Date.now()` | `useState(0)`,副作用在 `useEffect` 中更新 | `useState(Date.now())` |
|
||||
| 初始值禁止读取 localStorage | `useState(null)`,`useEffect` 中读 localStorage | `useState(localStorage.getItem("key"))` |
|
||||
|
||||
### 错误现象
|
||||
|
||||
```
|
||||
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||||
Hydration failed because the initial UI does not match what was rendered on the server.
|
||||
```
|
||||
|
||||
### 修复模式
|
||||
|
||||
```typescript
|
||||
// 错误:SSR 返回 "unsupported",CSR 返回 "default"/"granted"
|
||||
const [permission, setPermission] = useState(
|
||||
typeof window !== "undefined" && "Notification" in window
|
||||
? Notification.permission
|
||||
: "unsupported"
|
||||
)
|
||||
|
||||
// 正确:初始固定值 + useEffect 检测
|
||||
const [permission, setPermission] = useState<NotificationPermission | "unsupported">("unsupported")
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !("Notification" in window)) return
|
||||
setPermission(Notification.permission)
|
||||
}, [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十八、SectionErrorBoundary i18n Keys 完整性规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 使用 `SectionErrorBoundary namespace="X"` 时必须补全 3 个 key | `{namespace}.json` 中 `error.boundaryTitle` / `error.boundaryDescription` / `error.retry` 全部存在 | 仅添加 `error.retry` |
|
||||
| 新增 namespace 用法时同步检查 i18n 文件 | `<SectionErrorBoundary namespace="notifications">` → 检查 `notifications.json` 是否有 `error.boundaryTitle/Description` | 仅检查 `error.retry` |
|
||||
| 所有语言文件同步补全 | `zh-CN/X.json` 和 `en/X.json` 都要加 | 只加中文 |
|
||||
| 缺 key 会触发 hydration 警告 | `MISSING_MESSAGE` 错误在 SSR 期间抛出,client 渲染回退,触发 hydration mismatch | — |
|
||||
|
||||
### 错误现象
|
||||
|
||||
```
|
||||
[error] IntlError: MISSING_MESSAGE: Could not resolve `notifications.error.boundaryTitle` in messages for locale `zh-CN`.
|
||||
[error] IntlError: MISSING_MESSAGE: Could not resolve `notifications.error.boundaryDescription` in messages for locale `zh-CN`.
|
||||
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`SectionErrorBoundary` 内部调用 `t("error.boundaryTitle")` 和 `t("error.boundaryDescription")`,若 namespace JSON 缺少这些 key,next-intl 在 SSR 期间抛 `IntlError`,导致 Server Component 渲染失败,React 回退到 client 渲染,触发 hydration mismatch。
|
||||
|
||||
### 修复模式
|
||||
|
||||
```json
|
||||
// src/shared/i18n/messages/zh-CN/notifications.json
|
||||
{
|
||||
"error": {
|
||||
"loadFailed": "通知加载失败",
|
||||
"loadFailedDesc": "...",
|
||||
"boundaryTitle": "通知区块加载失败",
|
||||
"boundaryDescription": "加载通知数据时发生错误,请重试。",
|
||||
"retry": "重试"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十九、Server Component 传递纯数据替代函数规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 分页组件接收 `basePath` + `statusFilter` 纯数据 | `<Pagination basePath="/announcements" statusFilter="published" />` | `<Pagination buildPageHref={(p) => \`/announcements?page=${p}\`} />` |
|
||||
| Client Component 内部构建 URL | `const buildHref = (p) => { const params = new URLSearchParams(); if (statusFilter) params.set("status", statusFilter); ... }` | Server 端定义函数传给 client |
|
||||
| Server Component 仅传可序列化数据 | `pagination={{ page, pageSize, total, basePath, statusFilter }}` | `pagination={{ page, pageSize, total, buildPageHref: fn }}` |
|
||||
|
||||
### 错误现象
|
||||
|
||||
```
|
||||
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
|
||||
{page: 1, pageSize: 12, total: 2, buildPageHref: function buildPageHref}
|
||||
```
|
||||
|
||||
### 修复模式
|
||||
|
||||
```typescript
|
||||
// Server Component (announcements/page.tsx)
|
||||
const statusFilter = sp.status === "published" ? sp.status : "all"
|
||||
<AnnouncementList
|
||||
pagination={{
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
basePath: "/announcements", // 纯数据
|
||||
statusFilter, // 纯数据
|
||||
}}
|
||||
/>
|
||||
|
||||
// Client Component (announcement-pagination.tsx)
|
||||
export function AnnouncementPagination({ page, pageSize, total, basePath, statusFilter }: Props) {
|
||||
const buildHref = (targetPage: number): string => {
|
||||
const params = new URLSearchParams()
|
||||
if (statusFilter && statusFilter !== "all") params.set("status", statusFilter)
|
||||
if (targetPage > 1) params.set("page", String(targetPage))
|
||||
const qs = params.toString()
|
||||
return qs ? `${basePath}?${qs}` : basePath
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二十、i18n Namespace 与文件名一致性规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| `useTranslations` namespace 必须与 `i18n/request.ts` 配置一致 | `useTranslations("errorBook")`(驼峰,与 `request.ts` 的 `errorBook: errorBook.default` 一致) | `useTranslations("error-book")`(连字符,与文件名 `error-book.json` 混淆) |
|
||||
| namespace 跟 `request.ts` 的 messages key,不跟文件名 | 文件名 `error-book.json` → messages key `errorBook` → `useTranslations("errorBook")` | 文件名 `error-book.json` → `useTranslations("error-book")` |
|
||||
| 批量检查 namespace 一致性 | `grep -r "useTranslations(\"error-book\")"` 应返回 0 结果 | — |
|
||||
| 新增 namespace 时同步更新 `request.ts` | `request.ts` 新增 `import` + `messages` key | 只创建 JSON 文件不注册 |
|
||||
|
||||
### 错误现象
|
||||
|
||||
```
|
||||
IntlError: MISSING_MESSAGE: Could not resolve `error-book` in messages for locale `zh-CN`.
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`i18n/request.ts` 中配置的 messages key 是 `errorBook`(驼峰),但 17 个组件错误使用了 `useTranslations("error-book")`(连字符,与文件名 `error-book.json` 混淆)。next-intl 找不到 `error-book` namespace,所有 `t()` 调用抛出 `MISSING_MESSAGE` 错误。
|
||||
|
||||
### 修复模式
|
||||
|
||||
```typescript
|
||||
// i18n/request.ts 配置
|
||||
errorBook: errorBook.default, // key 是 "errorBook"
|
||||
|
||||
// 组件中正确使用
|
||||
const t = useTranslations("errorBook") // ✅ 驼峰,与配置一致
|
||||
|
||||
// 组件中错误使用
|
||||
const t = useTranslations("error-book") // ❌ 连字符,与文件名混淆
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二十一、useSearchParams() 导致 Hydration Mismatch 规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| Client Component 中避免 `useSearchParams()` | `window.location.search` 在 `handleSelect` 点击时读取 | `useSearchParams()` 在组件顶层读取 |
|
||||
| `useSearchParams()` 需要 `<Suspense>` 包裹且会导致 SSR fallback | 仅在需要 SSR 流式渲染的场景使用 | 在普通交互组件中直接使用 |
|
||||
| URL 参数在点击时构建 | `const params = new URLSearchParams(typeof window !== "undefined" ? window.location.search : ""); params.set("subject", id); router.push(\`?${params}\`)` | `const searchParams = useSearchParams(); const params = new URLSearchParams(searchParams.toString())` |
|
||||
| `useSearchParams()` 导致组件树 SSR/CSR 不同 | SSR 显示 Suspense fallback,CSR 显示真实内容,`useId()` 生成不同 id | — |
|
||||
|
||||
### 错误现象
|
||||
|
||||
```
|
||||
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
|
||||
+ aria-controls="radix-_R_3jabn6lb_"
|
||||
- aria-controls="radix-_R_edabn6lb_"
|
||||
|
||||
+ data-chart="chart-_R_4matpesndubn6lb_"
|
||||
- data-chart="chart-_R_ipbn5ritnqbn6lb_"
|
||||
```
|
||||
|
||||
### 根因
|
||||
|
||||
`useSearchParams()` 在 SSR 期间会导致包裹它的 `<Suspense>` 显示 fallback(Skeleton),而 CSR 期间直接显示真实内容。组件树结构在 SSR/CSR 间不同,导致 React `useId()` 生成的 id 不匹配,触发全局 hydration mismatch(影响页面所有 Radix UI 和 ChartContainer 组件的 id)。
|
||||
|
||||
### 修复模式
|
||||
|
||||
```typescript
|
||||
// 错误:useSearchParams() 导致 SSR/CSR 组件树不同
|
||||
export function SubjectTabs({ subjects, currentSubjectId }: Props) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams() // ❌ SSR 显示 fallback
|
||||
|
||||
const handleSelect = (subjectId: string | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// 正确:在点击时用 window.location.search 读取当前 URL 参数
|
||||
export function SubjectTabs({ subjects, currentSubjectId }: Props) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleSelect = (subjectId: string | null) => {
|
||||
// 仅在 client 端点击时执行,不影响 SSR
|
||||
const params = new URLSearchParams(
|
||||
typeof window !== "undefined" ? window.location.search : ""
|
||||
)
|
||||
if (subjectId === null) {
|
||||
params.delete("subject")
|
||||
} else {
|
||||
params.set("subject", subjectId)
|
||||
}
|
||||
router.push(`?${params.toString()}`, { scroll: false })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 二十二、React StrictMode 与 Server Action Hook 规则
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| useEffect 中调用 Server Action 时禁止用 ref 防重复 | 仅用 useEffect 依赖数组控制执行频率 | `const lastKey = useRef(""); if (lastKey.current === key) return; lastKey.current = key;` |
|
||||
| StrictMode 下 cleanup 会将第一次 action 结果标记为 cancelled | `let cancelled = false; action().then(r => { if (cancelled) return; ... }); return () => { cancelled = true }` 接受 StrictMode 下 action 执行两次 | 用 ref 阻止第二次 effect 执行 action,导致第一次结果被 cancelled 丢弃,永久 loading |
|
||||
| 客户端 dynamic import 第三方 Canvas/Window 依赖必须用 `import type` | `import type ForceGraph2DType from "react-force-graph-2d"` + `dynamic(...)` | `import ForceGraph2D from "react-force-graph-2d"` 值导入会触发顶层副作用在 SSR 执行 |
|
||||
|
||||
**问题现象**:知识点图谱页面一直显示"正在加载知识点",但 Server Action 实际已返回 `success: true` 和数据。
|
||||
|
||||
**根因 1**:Next.js 默认启用 `reactStrictMode: true`,dev 模式下 useEffect 执行两次(mount → unmount → mount)。若 Hook 内用 `lastRequestKey` ref 防重复,第一次 mount 发起 action 后 cleanup 设 `cancelled=true`,第二次 mount 因 key 相同跳过不发起 action,第一次 action 结果被 cancelled 忽略,state 永不更新。
|
||||
|
||||
**根因 2**:`next/dynamic` 加载的第三方库若在顶层用值导入(`import X from "lib"`),即使 `dynamic({ ssr: false })`,模块顶层代码仍会在 SSR 求值时执行,导致 canvas/window 访问报错,dynamic loading 永久卡住。必须用 `import type` 引入类型。
|
||||
|
||||
Reference in New Issue
Block a user