feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View File

@@ -0,0 +1,342 @@
# 备课模块lesson-preparation审查报告 v2
> 审查日期2026-06-20
> 审查范围:`src/modules/lesson-preparation/` 全部文件 + 路由页面
> 审查方式:代码审查 + Playwright 运行时测试
> 前置状态v1 已进行一次修正Tiptap setContent 参数、lint 错误等)
---
## 一、审查结论
| 维度 | 状态 |
|------|------|
| 编辑页可用性 | ✅ 已修复v1 遗留的 Tiptap SSR 崩溃) |
| 功能完整性 | ⚠️ 存在 7 个 P1 功能缺陷 |
| 代码质量 | ⚠️ 存在 8 个 P2 规范违规 |
| 用户体验 | ⚠️ 存在 5 个 P3 改进项 |
| 架构合规 | ✅ 三层架构正确,权限校验完整 |
---
## 二、本次已修复问题
### [P0-已修复] Tiptap SSR immediatelyRender 未设置导致编辑页崩溃
**文件**[rich-text-block.tsx](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/rich-text-block.tsx)
**现象**:编辑页显示 "Something went wrong!",控制台报错:
```
Error: Tiptap Error: SSR has been detected, please set `immediatelyRender` explicitly to `false` to avoid hydration mismatches.
```
**原因**Tiptap v3 的 `useEditor` 在 SSR 环境下默认会尝试立即渲染,导致 hydration mismatch。Next.js App Router 的客户端组件会经历 SSR 阶段,必须显式设置 `immediatelyRender: false`
**修复**:在 `useEditor` 配置中添加 `immediatelyRender: false`
**验证**Playwright 测试编辑页正常渲染,无控制台错误。
---
## 三、P1 功能缺陷(建议修复)
### [P1-1] 版本回退后编辑器内容不刷新
**文件**[lesson-plan-editor.tsx:173-175](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L173-L175)
**现象**:用户点击"回退到此版本"后,服务端 content 已更新,但编辑器界面仍显示旧内容。
**原因**`onReverted` 回调是空函数:
```tsx
<VersionHistoryDrawer
onReverted={() => { /* 触发页面刷新由父组件处理 */ }}
/>
```
**修复建议**:回退成功后调用 `useLessonPlanEditor.getState()` 重新拉取课案内容并 `replaceDoc`,或用 `router.refresh()` 刷新服务端数据。
---
### [P1-2] 版本抽屉 loading 状态失效
**文件**[version-history-drawer.tsx:27-39](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/version-history-drawer.tsx#L27-L39)
**现象**:打开版本抽屉时"加载中..."永不显示。
**原因**`loading` 初始为 `false`effect 中从未调用 `setLoading(true)`
```tsx
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const res = await getLessonPlanVersionsAction(planId); // 缺少 setLoading(true)
if (cancelled) return;
if (res.success && res.data) setVersions(res.data.versions);
setLoading(false);
})();
// ...
}, [open, planId]);
```
**修复建议**:在 async IIFE 开头添加 `setLoading(true)`
---
### [P1-3] 初始化 useEffect 依赖对象引用导致 store 被重置
**文件**[lesson-plan-editor.tsx:55-63](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L55-L63)
**现象**:父组件 re-render 时,`initialDoc` 对象引用变化,触发 useEffect 重新执行 `useLessonPlanEditor.setState()`,覆盖用户正在编辑的内容。
**原因**
```tsx
useEffect(() => {
useLessonPlanEditor.setState({
planId, title: initialTitle, doc: initialDoc, // ← 整个 doc 被重置
isDirty: false, lastSavedAt: Date.now(),
});
}, [planId, initialTitle, initialDoc]); // ← initialDoc 是对象,引用每次都变
```
**修复建议**:只依赖 `planId`,在 planId 变化时才初始化;或用 `useRef` 缓存 initialDoc 的原始引用。
---
### [P1-4] 自动保存闭包问题导致保存旧内容
**文件**[lesson-plan-editor.tsx:66-83](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L66-L83)
**现象**用户快速连续编辑时3 秒后保存的可能不是最新内容。
**原因**debounce 的 setTimeout 闭包了触发时的 `editor.title``editor.doc` 快照。虽然 effect 依赖包含 `editor.doc`,但用户在 3 秒内继续编辑会创建新的 setTimeout旧的被 clearTimeout所以实际上保存的是最后一次 effect 触发时的快照。但 `editor.title``editor.doc` 是 zustand 的订阅值,在 setTimeout 执行时可能已过期。
**修复建议**:在 setTimeout 回调中用 `useLessonPlanEditor.getState()` 获取最新值,而非闭包值。
---
### [P1-5] 题库搜索无 debounce
**文件**[question-bank-picker.tsx:32-46](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/question-bank-picker.tsx#L32-L46)
**现象**:搜索输入每次按键都触发 server action 请求。
**原因**`useEffect` 依赖 `filters`,而 `filters` 在每次 `onChange` 时更新。
**修复建议**:对搜索输入添加 300ms debounce。
---
### [P1-6] 课案列表搜索无 debounce
**文件**[lesson-plan-filters.tsx:17](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-filters.tsx#L17)
**现象**:搜索框每次按键触发 server action。
**修复建议**:添加 debounce 或使用 `useTransition`
---
### [P1-7] inline-question-editor 知识点标注缺失
**文件**[inline-question-editor.tsx:22](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/inline-question-editor.tsx#L22)
**现象**:课案内新建题目无法关联知识点。
**原因**`kpIds` 被硬编码为常量空数组:
```tsx
const kpIds: string[] = [];
```
**修复建议**:添加知识点选择器 UI或复用 `KnowledgePointPicker`
---
## 四、P2 代码质量/架构问题
### [P2-1] publish-service 用 JSON.parse(JSON.stringify()) 深拷贝
**文件**[publish-service.ts:78-80](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L78-L80)
**问题**:性能差,且不支持 Date 等特殊类型。
**建议**:用 `structuredClone()` 或手动构造新对象。
---
### [P2-2] publish-service 用非空断言 `!`
**文件**[publish-service.ts:82-83](file:///e:/Desktop/CICD/src/modules/lesson-preparation/publish-service.ts#L82-L83)
**问题**:违反项目规范"可选链后禁止跟非空断言"。
```tsx
const newBlock = newContent.blocks.find((b) => b.id === input.blockId)!;
```
**建议**:添加 null 检查并抛出明确错误。
---
### [P2-3] 多个组件用 alert()/confirm()
**文件**version-history-drawer.tsx:42, lesson-plan-card.tsx:48, inline-question-editor.tsx:26
**问题**:不符合现代 Web UI 规范,阻塞主线程。
**建议**:使用项目的 `AlertDialog` 组件(`@/shared/components/ui/alert-dialog`)或 `sonner` toast。
---
### [P2-4] block-renderer 用 `as never` 类型断言
**文件**[block-renderer.tsx:103,111,117,122](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/block-renderer.tsx)
**问题**`block.data as never` 绕过类型检查,违反"禁止 as 断言"规范。
**建议**:用类型守卫函数根据 `block.type` 收窄 `block.data` 类型。
---
### [P2-5] data-access-knowledge 用 LIKE 查 JSON 字段
**文件**[data-access-knowledge.ts:14-17](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-knowledge.ts#L14-L17)
**问题**`like(lessonPlans.content, '%${id}%')` 可能误匹配(如 ID 是另一个 ID 的子串),且无法用索引。
**建议**MySQL 8.0+ 可用 `JSON_CONTAINS`;或维护关联表。
---
### [P2-6] buildScopeCondition switch 无 default 分支
**文件**[data-access.ts:49-67](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access.ts#L49-L67)
**问题**switch 未覆盖所有 DataScope 类型时无 fallback虽然 TypeScript 会报错但逻辑上不完整。
**建议**:添加 `default` 分支返回空条件或抛错。
---
### [P2-7] lesson-plan-card 用 window.location.reload()
**文件**[lesson-plan-card.tsx:39,50](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-card.tsx#L39)
**问题**:不符合 SPA 模式,导致整个页面重新加载。
**建议**:用 `useRouter().refresh()``revalidatePath` 后自动刷新。
---
### [P2-8] data-access-templates 用 `as never` 类型断言
**文件**[data-access-templates.ts:66](file:///e:/Desktop/CICD/src/modules/lesson-preparation/data-access-templates.ts#L66)
```tsx
type: b.type as never,
```
**建议**:用 `b.type as BlockType` 并添加运行时校验。
---
## 五、P3 用户体验改进
### [P3-1] 编辑器无离开未保存提示
**问题**:用户有未保存内容时关闭/离开页面不会提示。
**建议**:监听 `beforeunload` 事件,`isDirty` 时弹出确认。
---
### [P3-2] 添加环节菜单点击外部不关闭
**文件**[lesson-plan-editor.tsx:150-166](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/lesson-plan-editor.tsx#L150-L166)
**问题**:点击菜单外部不会关闭菜单。
**建议**:添加 `useRef` + `mousedown` 事件监听,或用 Radix `DropdownMenu`
---
### [P3-3] 版本抽屉无预览功能
**问题**:版本列表只显示版本号和标签,无法预览版本内容差异。
**建议**:点击版本时展开内容预览,或显示 block 数量/摘要。
---
### [P3-4] exercise-block 用 index 作为 key
**文件**[exercise-block.tsx:67](file:///e:/Desktop/CICD/src/modules/lesson-preparation/components/blocks/exercise-block.tsx#L67)
```tsx
{data.items.map((item, idx) => (
<div key={idx} ...>
```
**问题**:删除/排序时可能导致 React 状态错乱。
**建议**:用 `item.questionId` 作为 key。
---
### [P3-5] 编辑器无 loading 骨架屏
**问题**:编辑器初始化时无加载状态,网络慢时白屏。
**建议**:添加 Suspense fallback 或骨架屏。
---
## 六、架构合规性检查
| 检查项 | 状态 | 说明 |
|--------|------|------|
| 三层架构app→modules→shared | ✅ | 路由层只调用 actions 和 data-access |
| 模块间通过 data-access 通信 | ✅ | publish-service 通过 questions/exams/homework 的 data-access |
| Server Action 权限校验 | ✅ | 所有 action 调用 requirePermission |
| Zod 校验 | ✅ | actions 使用 schema 校验输入 |
| ActionState 返回类型 | ✅ | 统一使用 ActionState<T> |
| "server-only" 标注 | ✅ | 所有 data-access 文件有 "server-only" |
| "use client" 标注 | ✅ | 所有客户端组件有 "use client" |
| revalidatePath 精确刷新 | ✅ | 创建/删除/回退后调用 revalidatePath |
| 架构图同步 | ✅ | 004/005 已同步 |
---
## 七、修复优先级建议
| 优先级 | 问题编号 | 描述 | 影响 |
|--------|----------|------|------|
| **P0** | 已修复 | Tiptap SSR 崩溃 | 编辑页完全不可用 |
| **P1** | P1-3 | 初始化 useEffect 重置 store | 用户编辑内容丢失 |
| **P1** | P1-4 | 自动保存闭包问题 | 保存旧内容 |
| **P1** | P1-1 | 版本回退不刷新 | 回退后看到旧内容 |
| **P1** | P1-2 | 版本抽屉 loading 失效 | UX 体验差 |
| **P1** | P1-7 | inline 题目无知识点 | 功能缺失 |
| **P1** | P1-5,6 | 搜索无 debounce | 性能问题 |
| **P2** | P2-1~8 | 代码规范 | 可维护性 |
| **P3** | P3-1~5 | UX 改进 | 体验优化 |
---
## 八、验证记录
| 验证项 | 命令 | 结果 |
|--------|------|------|
| TypeScript | `npx tsc --noEmit` | ✅ exit 0 |
| ESLint | `npm run lint` | ✅ exit 0 |
| 数据库迁移 | `npm run db:migrate` | ✅ 成功 |
| 编辑页渲染 | Playwright 测试 | ✅ 正常渲染,无错误 |
| 控制台错误 | Playwright 捕获 | ✅ 无 error/warning |
---
## 九、附录:测试截图
- `bugs/v2_list.png` - 课案列表页
- `bugs/v2_new.png` - 新建课案页
- `bugs/v2_edit.png` - 编辑页(修复后正常)