oh-my-muse/docs/memorys/2026-05-24-作品与工作区章节管理.md
zizi 317dfd4d13 feat(muse-studio): 搭建 P2 用户端工作台
完成 Muse Studio React SPA 基座和六个用户端功能域,接入 MSW Mock、Tiptap 写作台、知识库资料管理、智能体槽位预检绑定、市场授权安装和个人中心用量权益视图。
2026-05-25 10:15:28 +08:00

78 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 任务留痕:作品与工作区章节管理实现 (2026-05-24)
## 1. 任务背景与目标
开发 `muse-studio` 前端创作台子项目中的 **Feature 2: 我的作品 + 工作区章节管理** 功能。要求在对齐 TypeScript 6.0 极严类型约束的基线规范下,实现作品创建、卡片列表、侧边栏大纲管理、行内新建章节、级联物理删除、自动保存字数局部静默更新以及防止切章时编辑器残留的创作环境。
---
## 2. 关键设计与最佳实践
### ① TS 6.0 可选属性编译规避 (`exactOptionalPropertyTypes`)
在开启 `exactOptionalPropertyTypes: true` 下,如果将含有显式 `undefined` 值的字段作为可选参数传给 DTO/API 接口TS 编译器会报错(不允许传递 `{ genre: undefined }`,只允许直接省略该键)。
* **解决方案**:编写全局通用过滤函数 `stripUndefined`,在表单提交前采用严格的 `value !== undefined` 判断剔除空属性,成功解决 Zod 表单 transform 可选字段带来的编译难题。
```typescript
export function stripUndefined<T extends Record<string, any>>(obj: T): {
[K in keyof T]: Exclude<T[K], undefined>;
} {
const result = {} as any;
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
```
### ② TS 6.0 数组严格索引防空 (`noUncheckedIndexedAccess`)
在 `noUncheckedIndexedAccess: true` 模式下,直接读取数组 `list[0]` 推导的类型是 `T | undefined`,读取属性会引发编译失败。
* **解决方案**:在大纲载入自动激活首章时,必须进行显式类型收窄保护:
```typescript
const firstChapter = chapters[0];
if (firstChapter && firstChapter.id) {
setActiveChapter(firstChapter.id);
}
```
### ③ MSW 单元测试状态与并发隔离
由于 MSW 的拦截器在 Node.js 中会以全局单例的形式加载,并发单元测试对 mockDB 内存数据的改动可能会造成竞态污染Flaky Tests
* **解决方案**
1. 在 `content.ts` 拦截器中定义深拷贝初始状态,并导出 `resetMockDb()`。
2. 在每个单元测试文件的 `beforeEach` 钩子中,强行调用 `resetMockDb()` 重置 Mock 库状态,保障隔离性。
3. 在 Vitest 运行配置中,禁止对涉及 mockDB 修改的 API hooks 开启 `concurrent` 并发测试,保持串行稳健运行。
### ④ 编辑器生命周期重载与切章残留防护
在单页应用中Tiptap/ProseMirror 编辑器拥有独立的 Document 状态。如果在左侧大纲切换章节时只替换 `content` 变量,可能会导致旧章节的正文内容残留在新的章节内,引发严重的保存冲突 BUG。
* **解决方案**:在工作区装配编辑器时,为其强制绑定 `key={activeChapterId}`。利用 React 默认的 Key 渲染更替机制,在每次激活的章节 ID 变更时彻底销毁旧的编辑器实例并重装,彻底擦除上一章的 State 缓存。
### ⑤ 零网络重绘大纲字数静默回写
高频打字触发的自动保存(每 2 秒一次)如果直接 `invalidateQueries` 大纲缓存 `['chapterList']`,会导致左侧侧栏每 2 秒就发起 HTTP 请求并全量重新闪烁重绘,产生严重卡顿。但若不刷新,侧栏字数统计又会产生长达 30 秒的滞后。
* **解决方案**:在编辑器自动保存 API 同步成功的回调中,使用 `queryClient.setQueryData` 局部静默更新当前章节的 `wordCount`,做到侧边栏字数在 0 次网络请求开销下毫秒级同步。
```typescript
queryClient.setQueryData(
['chapterList', workId],
(oldData: any[] | undefined) => {
if (!oldData) return [];
return oldData.map((ch) =>
ch.id === id ? { ...ch, wordCount: newWordCount } : ch
);
}
);
```
### ⑥ React.memo 与 useCallback 缓存失效规避
将章节单项提取为 `ChapterItem` 并使用 `React.memo` 优化。但在父组件 `ChapterPanel` 传递给它的 `onSelect`、`onDelete` 回调中若使用匿名内联函数,每次父组件重绘时都会生成新的函数引用,导致 `React.memo` 完全失效。
* **解决方案**:在父面板中,将所有回传的回调函数严格使用 `useCallback` 强包装,维护引用的一致性,阻断高频打字重渲染。
---
## 3. 任务交付件索引
- 全局过滤器: [object.ts](file:///Users/qingse/Sync/local-git/oh-my-muse/muse-studio/src/lib/object.ts)
- API 交互 Hooks: [useWorks.ts](file:///Users/qingse/Sync/local-git/oh-my-muse/muse-studio/src/features/editor/hooks/useWorks.ts)
- 动态 Mock DB: [content.ts](file:///Users/qingse/Sync/local-git/oh-my-muse/muse-studio/src/api/mocks/handlers/content.ts)
- 作品卡片列表: [WorkListPage.tsx](file:///Users/qingse/Sync/local-git/oh-my-muse/muse-studio/src/features/editor/components/WorkListPage.tsx)
- 创建作品弹窗: [CreateWorkModal.tsx](file:///Users/qingse/Sync/local-git/oh-my-muse/muse-studio/src/features/editor/components/CreateWorkModal.tsx)
- 大纲侧栏面板: [ChapterPanel.tsx](file:///Users/qingse/Sync/local-git/oh-my-muse/muse-studio/src/features/editor/components/ChapterPanel.tsx)
- 创作台工作区: [WorkspacePage.tsx](file:///Users/qingse/Sync/local-git/oh-my-muse/muse-studio/src/pages/WorkspacePage.tsx)
- 单元测试套件: [useWorks.test.tsx](file:///Users/qingse/Sync/local-git/oh-my-muse/muse-studio/src/features/editor/hooks/useWorks.test.tsx)