完成 Muse Studio React SPA 基座和六个用户端功能域,接入 MSW Mock、Tiptap 写作台、知识库资料管理、智能体槽位预检绑定、市场授权安装和个人中心用量权益视图。
78 lines
5.4 KiB
Markdown
78 lines
5.4 KiB
Markdown
# 任务留痕:作品与工作区章节管理实现 (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)
|