Files
TrueGrowth/docs/FEATURE_FLOWS.md

29 KiB
Raw Blame History

Opentu 核心功能流程

本文档详细说明项目的核心功能实现流程,包括 AI 生成、Service Worker 架构、工作流机制等。这是 CLAUDE.md 的详细补充,当需要理解具体功能实现时参考本文档。

注意:本文档由 CLAUDE.md 拆分而来,包含详细的架构说明和实现细节。


目录


核心功能流程

AI 生成流程Service Worker 模式)

项目使用 Service Worker 作为后台任务执行器,实现页面刷新不影响任务执行。

用户输入
  ↓
AIInputBar (输入组件)
  ↓
swTaskQueueService.createTask() (应用层)
  ↓ postMessage
Service Worker (后台)
  ├── SWTaskQueue.submitTask() (任务管理)
  ├── ImageHandler / VideoHandler (执行器)
  └── taskQueueStorage (IndexedDB 持久化)
  ↓ broadcastToClients
应用层接收状态更新
  ↓
Canvas 插入 / 媒体库缓存

核心特性

  • 页面刷新恢复:任务状态持久化到 IndexedDB刷新后自动恢复
  • 多标签页同步:通过 broadcastToClients 向所有标签页广播状态
  • 视频任务恢复:通过 remoteId 恢复轮询,继续等待视频生成完成

Service Worker 任务队列架构

apps/web/src/sw/
├── index.ts                       # SW 主入口
└── task-queue/
    ├── queue.ts                   # 任务队列核心 (SWTaskQueue)
    ├── storage.ts                 # IndexedDB 存储 (TaskQueueStorage)
    ├── types.ts                   # 类型定义
    ├── handlers/                  # 任务处理器
    │   ├── image.ts               # 图片生成处理器
    │   ├── video.ts               # 视频生成处理器
    │   ├── character.ts           # 角色生成处理器
    │   └── chat.ts                # 聊天处理器
    ├── workflow-executor.ts       # 工作流执行器
    ├── workflow-types.ts          # 工作流类型
    ├── chat-workflow/             # 聊天工作流
    │   ├── executor.ts            # 聊天工作流执行器
    │   └── types.ts               # 聊天工作流类型
    ├── mcp/                       # MCP 工具系统
    │   ├── tools.ts               # 工具注册
    │   └── executor.ts            # 工具执行器
    └── utils/
        └── index.ts               # 工具函数导出

应用层服务

packages/drawnix/src/services/
├── sw-client/
│   ├── client.ts                  # SW 通信客户端 (SWTaskQueueClient)
│   ├── types.ts                   # 消息类型定义
│   └── index.ts                   # 导出
├── sw-task-queue-service.ts       # SW 任务队列服务
├── sw-chat-service.ts             # SW 聊天服务
├── sw-chat-workflow-service.ts    # SW 聊天工作流服务
└── task-queue/
    └── index.ts                   # 任务队列入口(自动选择 SW/传统模式)

通信协议

// 应用层 → Service Worker
type MainToSWMessage =
  | { type: 'TASK_QUEUE_INIT'; geminiConfig; videoConfig }
  | { type: 'TASK_SUBMIT'; taskId; taskType; params }
  | { type: 'TASK_CANCEL'; taskId }
  | { type: 'TASK_RETRY'; taskId }
  | { type: 'TASK_GET_ALL' }
  | { type: 'CHAT_START'; chatId; params }
  | { type: 'WORKFLOW_SUBMIT'; workflow }
  // ...

// Service Worker → 应用层
type SWToMainMessage =
  | { type: 'TASK_CREATED'; task }
  | { type: 'TASK_STATUS'; taskId; status; progress }
  | { type: 'TASK_COMPLETED'; taskId; result }
  | { type: 'TASK_FAILED'; taskId; error; retryCount }
  | { type: 'WORKFLOW_STEP_STATUS'; workflowId; stepId; status }
  // ...

IndexedDB 存储结构

  • tasks - 任务数据(图片/视频/角色生成)
  • config - API 配置apiKey, baseUrl
  • workflows - 工作流数据
  • chat-workflows - 聊天工作流数据
  • pending-tool-requests - 待处理的主线程工具请求

任务生命周期

PENDING → PROCESSING → COMPLETED
                    ↘ FAILED
                    ↘ CANCELLED

使用示例

import { taskQueueService } from '../services/task-queue';

// 创建任务
const task = taskQueueService.createTask(
  { prompt: '生成一张日落图片', size: '1:1' },
  TaskType.IMAGE
);

// 监听任务更新
taskQueueService.observeTaskUpdates().subscribe((event) => {
  if (event.type === 'taskUpdated' && event.task.status === TaskStatus.COMPLETED) {
    console.log('任务完成:', event.task.result?.url);
  }
});

Service Worker 预缓存机制 (Precache Manifest)

项目使用 Precache Manifest 机制确保版本更新时用户能快速加载新版本:

问题:如果 SW 只预缓存少量基础文件(如 index.html版本更新后用户首次访问需要从网络下载所有 JS/CSS导致加载慢。

解决方案

  1. 构建时生成资源清单 (vite.config.ts)
// Vite 插件在构建完成后扫描 dist 目录
function precacheManifestPlugin(): Plugin {
  return {
    name: 'precache-manifest',
    closeBundle: {
      async handler() {
        // 扫描所有静态资源,生成 precache-manifest.json
        // 包含 URL 和文件哈希(用于增量更新)
        manifest.push({ url: '/assets/xxx.js', revision: 'a1b2c3d4' });
      }
    }
  };
}
  1. SW 安装时预缓存所有资源 (sw/index.ts)
sw.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const files = await loadPrecacheManifest(); // 读取 manifest
      await precacheStaticFiles(cache, files);    // 并发预缓存
    })()
  );
});
  1. 增量更新:通过 x-sw-revision 头比较文件哈希,跳过未变化的文件

工作流程

版本更新发布 → 用户访问 → 新 SW 安装
                           ↓
                    读取 precache-manifest.json
                           ↓
                    并发预缓存所有资源6个并发
                           ↓
                    预缓存完成 → 通知用户更新
                           ↓
                    用户确认 → 激活新 SW → 缓存优先秒开

相关文件

  • apps/web/vite.config.ts - precacheManifestPlugin() 生成 manifest
  • apps/web/src/sw/index.ts - loadPrecacheManifest()precacheStaticFiles()
  • dist/apps/web/precache-manifest.json - 构建产物

预缓存不阻塞首次访问

这是一个常见误解 —— 很多开发者担心 event.waitUntil() 会阻塞页面加载。实际上:

  1. 页面加载和 SW 安装是并行的用户访问时页面正常从网络加载SW 在后台安装
  2. SW 在页面 load 后才注册main.tsxwindow.addEventListener('load', () => { navigator.serviceWorker.register(...) })
  3. event.waitUntil() 只影响 SW 生命周期:它让 SW 等待预缓存完成后才激活,不影响主线程
时间 →
[页面加载] ████████████ load 事件
                         ↓
[用户可用] ──────────────●──────────────── 用户已可使用
                         
[SW 注册]                ●────────
[SW 预缓存]                   ████████████████
[SW 激活]                                    ●────────

结论首次访问正常加载SW 在后台默默预缓存,下次访问秒开。

Service Worker 自动激活策略

项目采用 自动激活 策略,确保用户总是使用最新版本:

// sw/index.ts - install 事件
sw.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      await precacheStaticFiles(cache, files);
      // 预缓存完成后,直接激活新 SW
      sw.skipWaiting();
    })()
  );
});

// activate 事件
sw.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      await sw.clients.claim(); // 接管所有页面
      // 通知客户端 SW 已更新
      const clients = await sw.clients.matchAll();
      clients.forEach(client => {
        client.postMessage({ type: 'SW_ACTIVATED', version: APP_VERSION });
      });
    })()
  );
});

页面刷新策略(区分主应用和调试页面):

页面类型 SW 更新后行为 原因
主应用 (React) 显示更新提示,不自动刷新 避免打断用户操作
sw-debug.html 自动刷新 调试页面需要最新版本
// main.tsx - 主应用只在用户确认后才刷新
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (!userConfirmedUpgrade) {
    return; // 用户没确认就不刷新
  }
  window.location.reload();
});

// sw-debug/app.js - 调试页面自动刷新
onControllerChange(() => {
  window.location.reload();
});

编辑器插件系统

Drawnix (主编辑器)
  ├── Plait Board (绘图核心)
  └── Plugins
      ├── withTool (工具系统)
      ├── withFreehand (自由画)
      ├── withMind (思维导图)
      ├── withDraw (基础绘图)
      ├── withHotkey (快捷键)
      ├── withTextLink (文本链接)
      ├── withTextPaste (文本粘贴)
      ├── withImage (图片粘贴)
      ├── withVideo (视频支持)
      ├── withWorkZone (工作流进度)
      └── ...

工作流提交机制

项目使用统一的工作流提交机制,避免重复创建工作流导致的问题。

核心文件

  • hooks/useWorkflowSubmission.ts - 工作流提交 Hook
  • components/ai-input-bar/workflow-converter.ts - 工作流转换器
  • services/workflow-submission-service.ts - 工作流提交服务

工作流程

AIInputBar 创建工作流
  ↓
convertToWorkflow() - 创建 LegacyWorkflowDefinition唯一 ID
  ↓
submitWorkflowToSW(parsedParams, referenceImages, retryContext, existingWorkflow)
  ↓
useWorkflowSubmission.submitWorkflow() - 复用已有工作流,避免重复创建
  ↓
workflowSubmissionService.submit() - 提交到 SW
  ↓
SW WorkflowExecutor 执行

关键设计

  • submitWorkflow 接受可选的 existingWorkflow 参数
  • 如果传入 existingWorkflow,直接使用而不重新创建
  • 避免因重复调用 convertToWorkflow 导致不同 ID 的工作流

API 签名

submitWorkflow: (
  parsedInput: ParsedGenerationParams,
  referenceImages: string[],
  retryContext?: WorkflowRetryContext,
  existingWorkflow?: LegacyWorkflowDefinition
) => Promise<{ workflowId: string; usedSW: boolean }>

WorkZone 画布元素

WorkZone 是一个特殊的画布元素,用于在画布上直接显示 AI 生成任务的工作流进度。

核心文件

  • plugins/with-workzone.ts - Plait 插件,注册 WorkZone 元素类型
  • components/workzone-element/WorkZoneContent.tsx - React 渲染组件
  • types/workzone.types.ts - 类型定义

工作流程

AIInputBar 提交生成任务
  ↓
创建 WorkZone 元素到画布 (WorkZoneTransforms.insertWorkZone)
  ↓
WorkflowContext 更新工作流状态
  ↓
WorkZoneContent 组件响应更新,显示进度
  ↓
任务完成/失败后可删除 WorkZone

关键 API

  • WorkZoneTransforms.insertWorkZone(board, options) - 创建 WorkZone
  • WorkZoneTransforms.updateWorkflow(board, id, workflow) - 更新工作流状态
  • WorkZoneTransforms.removeWorkZone(board, id) - 删除 WorkZone

AI 生成完成事件 当所有工作流步骤完成后,会触发 ai-generation-complete 事件:

window.dispatchEvent(new CustomEvent('ai-generation-complete', {
  detail: { type: 'image' | 'mind' | 'flowchart', success: boolean, workzoneId: string }
}));
  • 思维导图/流程图:在 sw-capabilities/handler.ts 中触发
  • 图片生成:在 useAutoInsertToCanvas.tsupdateWorkflowStepForTask 中触发
  • AIInputBar 监听此事件来重置 isSubmitting 状态

技术要点

  • 使用 SVG foreignObject 在画布中嵌入 React 组件
  • 使用 XHTML 命名空间确保 DOM 元素正确渲染
  • 需要在 pointerdown 阶段阻止事件冒泡,避免 Plait 拦截点击事件
  • WorkZone 元素被选中时不触发 popup-toolbarpopup-toolbar.tsx 中过滤)
  • AIInputBar 发送工作流时不自动展开 ChatDrawer通过 autoOpen: false 参数控制)

位置策略(按优先级):

  1. 有选中元素 → 放在选中元素下方(左对齐)
  2. 无选中元素 → 放在最底部元素下方(左对齐)
  3. 画布为空 → 放在视口中心

选中框缩放

  • 选中框大小根据 zoom 属性自动调整,与缩放后的内容匹配
  • 使用 activeGeneratorgetRectangle 计算缩放后的矩形

自动滚动

  • 使用 scrollToPointIfNeeded 函数智能滚动
  • WorkZone 不在视口内时自动滚动到中心位置
  • WorkZone 已在视口内时不滚动(避免干扰用户)

灵感创意板块 (InspirationBoard)

当画板为空时,在 AI 输入框上方显示灵感创意板块,帮助用户快速开始创作。

核心文件

  • components/inspiration-board/InspirationBoard.tsx - 主组件
  • components/inspiration-board/InspirationCard.tsx - 模版卡片组件
  • components/inspiration-board/constants.ts - 模版数据配置

功能特点

  • 画板为空时自动显示,有内容时隐藏
  • 3x2 网格布局展示创意模版
  • 支持分页浏览更多模版
  • 点击模版自动填充提示词到输入框
  • 提供"提示词"快捷按钮,可打开香蕉提示词工具

数据加载状态管理 (isDataReady)

为了避免在画布数据加载完成前误判画布为空(导致灵感板闪烁),项目使用 isDataReady 状态来标识数据是否已准备好。

数据流

app.tsx (isDataReady state)
  ↓ setValue 完成后 setIsDataReady(true)
  ↓ prop
drawnix.tsx (isDataReady prop)
  ↓ prop
DrawnixContent (isDataReady prop)
  ↓ prop
AIInputBar (isDataReady prop)
  ↓ prop
SelectionWatcher (isDataReady prop)
  ↓
只有 isDataReady=true 时才检查画布是否为空

关键逻辑

  • app.tsx:初始 isDataReady = false,在 setValue 完成后(finally 块中)设置为 true
  • SelectionWatcher:只有当 isDataReadytrue 时才开始检查画布是否为空
  • 避免在数据加载前误判画布为空,防止灵感板闪烁

历史提示词功能

支持记录和管理用户的历史提示词,方便快速复用。

核心文件

  • services/prompt-storage-service.ts - 存储服务localStorage
  • hooks/usePromptHistory.ts - React Hook
  • components/ai-input-bar/PromptHistoryPopover.tsx - UI 组件

功能特点

  • 自动保存用户发送的提示词(无数量限制,使用 IndexedDB 存储)
  • 支持置顶/取消置顶常用提示词
  • 鼠标悬浮三点图标显示历史列表
  • 点击历史提示词回填到输入框
  • 支持删除单条历史记录

API 示例

const { history, addHistory, removeHistory, togglePinHistory } = usePromptHistory();

// 添加历史
addHistory('生成一张日落风景图', hasSelection);

// 置顶/取消置顶
togglePinHistory(itemId);

文本粘贴功能

支持智能文本粘贴到画布,自动控制文本宽度避免过长。

核心文件

  • plugins/with-text-paste.ts - 文本粘贴插件
  • plugins/with-common.tsx - 插件注册(文本粘贴 + 图片粘贴)

功能特点

  • 自动换行:超过 50 字符自动换行
  • 智能断行:优先在空格处断行,保持单词完整
  • 保留格式:保留原文本的换行符
  • 不影响现有功能:图片粘贴和 Plait 元素复制粘贴正常工作

配置参数

const TEXT_CONFIG = {
  MAX_CHARS_PER_LINE: 50,    // 最大字符数/行
  DEFAULT_WIDTH: 400,         // 默认文本框宽度
  MAX_WIDTH: 600,             // 最大文本框宽度
  CHAR_WIDTH: 8,              // 估算字符宽度
};

使用方法

  1. 从任何地方复制文本
  2. 在画布上按 Ctrl+V / Cmd+V
  3. 文本自动插入并换行

插件链顺序

// 在 with-common.tsx 中
return withTextPastePlugin(withImagePlugin(newBoard));

详细文档:/docs/TEXT_PASTE_FEATURE.md

模型健康状态功能

在模型选择下拉菜单中显示实时健康状态,帮助用户了解模型的可用性。

核心文件

  • services/model-health-service.ts - 健康状态 API 服务
  • hooks/useModelHealth.ts - React Hook带缓存和自动刷新
  • components/shared/ModelHealthBadge.tsx - 健康状态徽章组件
  • components/shared/model-health-badge.scss - 样式文件

功能特点

  • 仅当 baseUrlapi.tu-zi.com 时才启用
  • 每 5 分钟自动刷新健康数据
  • 全局缓存避免重复请求
  • 彩色方块徽章显示状态hover 显示状态文字
  • API 请求失败时静默处理,不显示健康状态

API 来源

  • 端点:https://apistatus.tu-zi.com/api/history/aggregated
  • 返回字段:model_namestatus_labelstatus_colorerror_rate

使用示例

import { useModelHealth } from '../../hooks/useModelHealth';
import { ModelHealthBadge } from '../shared/ModelHealthBadge';

// 在组件中使用 Hook
const { shouldShowHealth, getHealthStatus } = useModelHealth();

// 获取特定模型的健康状态
const status = getHealthStatus('gemini-2.0-flash-exp-image-generation');

// 使用徽章组件
<ModelHealthBadge modelId="gemini-2.0-flash-exp-image-generation" />

显示规则

  • shouldShowHealthtruebaseUrl 包含 api.tu-zi.com)才显示徽章
  • 没有匹配模型的健康数据时不显示徽章
  • 徽章颜色由 API 返回的 statusColor 决定(绿/黄/红等)

字体管理与缓存

支持 Google Fonts 的自动加载和缓存,通过 Service Worker 实现应用层无感知的字体缓存。

核心文件

  • services/font-manager-service.ts - 字体管理服务
  • apps/web/src/sw/index.ts - Service Worker 字体缓存逻辑
  • constants/text-effects.ts - 字体配置(系统字体 + Google Fonts

功能特点

  • 自动提取画布中使用的字体
  • 画布初始化时预加载已使用的字体
  • Service Worker 自动缓存字体文件(基于 URL
  • 支持系统字体和 Google Fonts
  • 字体预览图管理

工作流程

画布加载
  ↓
提取使用的字体(从 text.children 中)
  ↓
fontManagerService.preloadBoardFonts()
  ↓
加载 Google Fontslink 标签)
  ↓
Service Worker 拦截请求
  ├─ 检查 drawnix-fonts 缓存
  ├─ 缓存命中 → 直接返回
  └─ 缓存未命中 → 下载并缓存
  ↓
字体加载完成 → board.redraw()

缓存策略

  • 使用 Service Worker Cache API
  • Cache-First 策略(优先使用缓存)
  • 缓存 CSS 文件和字体文件woff2
  • 应用层无感知,完全由 Service Worker 管理

支持的字体

  • 系统字体:苹方、微软雅黑、黑体、宋体、楷体等
  • Google FontsNoto Sans SC、ZCOOL 系列、Ma Shan Zheng 等

参考图上传组件 (ReferenceImageUpload)

统一的参考图上传组件,用于 AI 图片生成和视频生成弹窗。

核心文件

  • components/ttd-dialog/shared/ReferenceImageUpload.tsx - 主组件
  • components/ttd-dialog/shared/ReferenceImageUpload.scss - 样式文件

功能特点

  • 本地文件上传:点击"本地"按钮选择文件
  • 素材库选择:点击"素材库"按钮从媒体库选择图片
  • 拖拽上传:支持将图片拖拽到上传区域
  • 粘贴板获取:支持 Ctrl+V / Cmd+V 粘贴图片
  • 多种模式:
    • 单图模式 (multiple=false)
    • 多图网格模式 (multiple=true)
    • 插槽模式 (slotLabels 用于视频生成的首帧/尾帧)

使用示例

// AI 图片生成中的使用
<ReferenceImageUpload
  images={uploadedImages}
  onImagesChange={setUploadedImages}
  language={language}
  disabled={isGenerating}
  multiple={true}
  label="参考图片 (可选)"
/>

// AI 视频生成中的使用(首帧/尾帧模式)
<ReferenceImageUpload
  images={uploadedImages}
  onImagesChange={handleImagesChange}
  language={language}
  disabled={isGenerating}
  multiple={true}
  maxCount={2}
  slotLabels={['首帧', '尾帧']}
  label="首尾帧图片 (可选)"
/>

类型定义

interface ReferenceImage {
  url: string;    // Base64 或 URL
  name: string;   // 文件名
  file?: File;    // 原始文件对象
}

interface ReferenceImageUploadProps {
  images: ReferenceImage[];
  onImagesChange: (images: ReferenceImage[]) => void;
  language?: 'zh' | 'en';
  disabled?: boolean;
  multiple?: boolean;
  maxCount?: number;
  label?: string;
  slotLabels?: string[];  // 插槽标签(如 ['首帧', '尾帧']
  onError?: (error: string | null) => void;
}

样式特点

  • 虚线边框的上传区域
  • 垂直排列的"本地"和"素材库"按钮
  • 拖拽时的视觉反馈
  • 统一的按钮样式(图标 16px字体 13px字重 400

备份恢复功能 (Backup & Restore)

支持将用户数据(提示词、项目、素材)导出为 ZIP 文件,并从 ZIP 文件恢复数据。

核心文件

  • services/backup-restore/ - 备份恢复服务(支持自动分片)
  • components/backup-restore/backup-restore-dialog.tsx - UI 对话框

功能特点

  • 导出提示词历史(图片/视频提示词)
  • 导出项目数据(文件夹和画板)
  • 导出素材库(本地上传 + AI 生成的缓存媒体)
  • 增量导入(自动去重,不覆盖已有数据)
  • 支持进度显示

ZIP 文件结构

aitu_backup_xxx.zip
├── manifest.json              # 备份元信息
├── prompts.json               # 提示词数据
├── projects/                  # 项目文件
│   ├── 文件夹名/
│   │   └── 画板名.drawnix     # 画板数据
│   └── 画板名.drawnix         # 根目录画板
└── assets/                    # 素材文件
    ├── xxx.meta.json          # 素材元数据
    └── xxx.jpg/.mp4           # 媒体文件

数据来源

导出素材:
├── 本地素材库 ← localforage (asset-storage-service)
└── AI 生成缓存 ← unified-cache-service (drawnix-unified-cache)

导入素材:
├── 本地素材 → localforage + unified-cache
└── AI 生成素材 (source: 'AI_GENERATED') → 仅 unified-cache

关键 API

// 导出
const blob = await backupRestoreService.exportToZip({
  includePrompts: true,
  includeProjects: true,
  includeAssets: true,
}, onProgress);
backupRestoreService.downloadZip(blob);

// 导入
const result = await backupRestoreService.importFromZip(file, onProgress);
// result: { success, prompts, projects, assets, errors }

缓存刷新机制 导入数据后需要刷新内存缓存才能生效:

  • resetPromptStorageCache() - 刷新提示词缓存
  • workspaceService.reload() - 刷新工作区缓存

技术要点

  • 使用 JSZip 处理 ZIP 文件
  • 媒体文件通过 unifiedCacheService.getCachedBlob() 获取
  • 虚拟 URL/asset-library/)从 Cache API 获取
  • 导入时区分本地素材和 AI 生成素材,存储位置不同

分页加载与虚拟滚动

支持任务队列和素材库的分页加载与虚拟滚动,优化大数据量场景下的性能。

核心文件

  • hooks/useInfinitePagination.ts - 无限滚动分页 Hook
  • hooks/useVirtualList.ts - 虚拟列表 Hook封装 @tanstack/react-virtual
  • hooks/useImageLazyLoad.ts - 图片懒加载 Hook
  • components/lazy-image/LazyImage.tsx - 懒加载图片组件
  • components/task-queue/VirtualTaskList.tsx - 虚拟任务列表组件
  • components/media-library/VirtualAssetGrid.tsx - 虚拟素材网格组件
  • apps/web/src/sw/task-queue/storage.ts - IndexedDB 游标分页查询

功能特点

  • IndexedDB 游标分页:支持大数据量的高效分页查询
  • 无限滚动:滚动到底部自动加载更多数据
  • 虚拟滚动:只渲染可见区域的元素,大幅减少 DOM 节点
  • 图片懒加载:基于 IntersectionObserver进入视口才加载图片
  • 实时更新:支持 prependItemsupdateItemremoveItem 操作

使用示例

// 无限滚动分页
const {
  items,
  isLoading,
  isLoadingMore,
  hasMore,
  loadMore,
  reset,
} = useInfinitePagination({
  fetcher: async ({ offset, limit }) => {
    const result = await swTaskQueueClient.requestPaginatedTasks({
      offset,
      limit,
      status: filterStatus,
    });
    return {
      items: result.tasks,
      total: result.total,
      hasMore: result.hasMore,
    };
  },
  pageSize: 50,
  getItemKey: (task) => task.id,
  deps: [filterStatus],
});

// 虚拟列表
const { parentRef, virtualItems, totalSize, getItem } = useVirtualList({
  items: tasks,
  estimateSize: 200,
  overscan: 3,
});

IndexedDB 分页查询

// Service Worker 中的游标分页实现
async getPaginatedTasks(params: PaginationParams): Promise<PaginatedResult> {
  const { offset = 0, limit = 50, status } = params;
  const db = await this.getDB();
  const tx = db.transaction('tasks', 'readonly');
  const store = tx.objectStore('tasks');
  const index = store.index('by-createdAt');

  let cursor = await index.openCursor(null, 'prev');
  let skipped = 0;
  const items: Task[] = [];

  while (cursor && items.length < limit) {
    if (status && cursor.value.status !== status) {
      cursor = await cursor.continue();
      continue;
    }
    if (skipped < offset) {
      skipped++;
      cursor = await cursor.continue();
      continue;
    }
    items.push(cursor.value);
    cursor = await cursor.continue();
  }

  return { items, total, hasMore: offset + items.length < total };
}

性能优化

  • 默认每页 50 条数据
  • 虚拟列表 overscan 设置为 3-5 个元素
  • 图片懒加载 rootMargin 设置为 200px提前加载
  • 使用 getItemKey 进行去重,避免重复数据

Service Worker 版本升级流程

升级时序

主线程 5 分钟定时 registration.update()
  → 浏览器检测到新 sw.js
  → 新 SW installprewarming → 预缓存资源 → markNewVersionReady → upgradeState='ready'
  → 新 SW 进入 waiting 状态
  → 主线程 statechange='installed' → requestSWVersionState
  → SW 响应 SW_VERSION_STATEpendingVersion + upgradeState='ready'
  → 主线程 dispatch 'sw-update-available'
  → VersionUpdatePrompt 显示升级提示
  → 用户点击"立即更新" → COMMIT_UPGRADE → skipWaiting → activate → claim → reload

自然激活(所有旧 tab 关闭)

当所有旧 tab 关闭后waiting SW 自动变为 active。activate 处理器必须无条件将 committedVersion 更新为 APP_VERSION,否则新 tab 会用旧版本号打开旧缓存、从网络获取新 index.html,导致新 hash 资源用旧版本号请求 CDN → 404。

踩坑记录

  1. waiting SW 的 postmessage-duplex 不可靠:新 SW 处于 waiting 状态时,channelManager.sendSWNewVersionReady() 可能无法到达客户端channel 未与 waiting worker 建立)。必须依赖原生 postMessage + statechange 事件作为可靠通知路径。

  2. statechange 通知需要重试requestSWVersionState(newWorker) 只发一次,如果 SW 仍在预缓存(waitUntil 未完成),响应可能丢失。需要 5 秒后重试 + visibilitychange 时重新检查。

  3. CDN URL 版本重写陷阱cleanResourcePath 会剥离 CDN 路径中的版本前缀(npm/aitu-app@x.y.z/buildCDNUrl 再用 committedVersion 重建。如果 committedVersion 与请求 URL 中的版本不一致,会构造出错误的 CDN URL。防御措施extractVersionFromCDNPath 优先使用 URL 中已有的版本号。

  4. activate 必须无条件更新 committedVersion原设计只在首次安装和用户确认时更新自然激活时遗漏。SW 一旦激活就是当前版本,committedVersion 必须与 APP_VERSION 一致。

关键文件

文件 职责
apps/web/src/main.tsx SW 注册、版本状态监听、升级确认
apps/web/src/sw/index.ts install/activate 处理、版本状态管理、静态资源拦截
apps/web/src/sw/cdn-fallback.ts CDN 回退、版本号提取、健康检查
packages/drawnix/src/components/version-update/version-update-prompt.tsx 升级提示 UI