完成 Muse Studio React SPA 基座和六个用户端功能域,接入 MSW Mock、Tiptap 写作台、知识库资料管理、智能体槽位预检绑定、市场授权安装和个人中心用量权益视图。
5.4 KiB
任务留痕:作品与工作区章节管理实现 (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 可选字段带来的编译难题。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,读取属性会引发编译失败。
- 解决方案:在大纲载入自动激活首章时,必须进行显式类型收窄保护:
const firstChapter = chapters[0]; if (firstChapter && firstChapter.id) { setActiveChapter(firstChapter.id); }
③ MSW 单元测试状态与并发隔离
由于 MSW 的拦截器在 Node.js 中会以全局单例的形式加载,并发单元测试对 mockDB 内存数据的改动可能会造成竞态污染(Flaky Tests)。
- 解决方案:
- 在
content.ts拦截器中定义深拷贝初始状态,并导出resetMockDb()。 - 在每个单元测试文件的
beforeEach钩子中,强行调用resetMockDb()重置 Mock 库状态,保障隔离性。 - 在 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 次网络请求开销下毫秒级同步。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
- API 交互 Hooks: useWorks.ts
- 动态 Mock DB: content.ts
- 作品卡片列表: WorkListPage.tsx
- 创建作品弹窗: CreateWorkModal.tsx
- 大纲侧栏面板: ChapterPanel.tsx
- 创作台工作区: WorkspacePage.tsx
- 单元测试套件: useWorks.test.tsx