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)
343 lines
12 KiB
Markdown
343 lines
12 KiB
Markdown
# 备课模块(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` - 编辑页(修复后正常)
|