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

5.4 KiB
Raw Blame History

任务留痕:作品与工作区章节管理实现 (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

  • 解决方案
    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 次网络请求开销下毫秒级同步。
    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 传递给它的 onSelectonDelete 回调中若使用匿名内联函数,每次父组件重绘时都会生成新的函数引用,导致 React.memo 完全失效。

  • 解决方案:在父面板中,将所有回传的回调函数严格使用 useCallback 强包装,维护引用的一致性,阻断高频打字重渲染。

3. 任务交付件索引