# Opentu 核心功能流程
本文档详细说明项目的核心功能实现流程,包括 AI 生成、Service Worker 架构、工作流机制等。这是 `CLAUDE.md` 的详细补充,当需要理解具体功能实现时参考本文档。
> **注意**:本文档由 CLAUDE.md 拆分而来,包含详细的架构说明和实现细节。
---
## 目录
- [AI 生成流程](#ai-生成流程service-worker-模式)
- [Service Worker 任务队列架构](#service-worker-任务队列架构)
- [Service Worker 预缓存机制](#service-worker-预缓存机制)
- [工作流提交机制](#工作流提交机制)
- [WorkZone 画布元素](#workzone-画布元素)
- [灵感创意板块](#灵感创意板块)
- [历史提示词功能](#历史提示词功能)
- [图片合并与分割](#图片合并与分割)
---
## 核心功能流程
### 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/传统模式)
```
**通信协议**:
```typescript
// 应用层 → 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
```
**使用示例**:
```typescript
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`):
```typescript
// Vite 插件在构建完成后扫描 dist 目录
function precacheManifestPlugin(): Plugin {
return {
name: 'precache-manifest',
closeBundle: {
async handler() {
// 扫描所有静态资源,生成 precache-manifest.json
// 包含 URL 和文件哈希(用于增量更新)
manifest.push({ url: '/assets/xxx.js', revision: 'a1b2c3d4' });
}
}
};
}
```
2. **SW 安装时预缓存所有资源** (`sw/index.ts`):
```typescript
sw.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const files = await loadPrecacheManifest(); // 读取 manifest
await precacheStaticFiles(cache, files); // 并发预缓存
})()
);
});
```
3. **增量更新**:通过 `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.tsx` 中 `window.addEventListener('load', () => { navigator.serviceWorker.register(...) })`
3. **`event.waitUntil()` 只影响 SW 生命周期**:它让 SW 等待预缓存完成后才激活,不影响主线程
```
时间 →
[页面加载] ████████████ load 事件
↓
[用户可用] ──────────────●──────────────── 用户已可使用
[SW 注册] ●────────
[SW 预缓存] ████████████████
[SW 激活] ●────────
```
**结论**:首次访问正常加载,SW 在后台默默预缓存,下次访问秒开。
**Service Worker 自动激活策略**:
项目采用 **自动激活** 策略,确保用户总是使用最新版本:
```typescript
// 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 | **自动刷新** | 调试页面需要最新版本 |
```typescript
// 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 签名**:
```typescript
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` 事件:
```typescript
window.dispatchEvent(new CustomEvent('ai-generation-complete', {
detail: { type: 'image' | 'mind' | 'flowchart', success: boolean, workzoneId: string }
}));
```
- 思维导图/流程图:在 `sw-capabilities/handler.ts` 中触发
- 图片生成:在 `useAutoInsertToCanvas.ts` 的 `updateWorkflowStepForTask` 中触发
- `AIInputBar` 监听此事件来重置 `isSubmitting` 状态
**技术要点**:
- 使用 SVG `foreignObject` 在画布中嵌入 React 组件
- 使用 XHTML 命名空间确保 DOM 元素正确渲染
- 需要在 `pointerdown` 阶段阻止事件冒泡,避免 Plait 拦截点击事件
- WorkZone 元素被选中时不触发 popup-toolbar(在 `popup-toolbar.tsx` 中过滤)
- AIInputBar 发送工作流时不自动展开 ChatDrawer(通过 `autoOpen: false` 参数控制)
**位置策略**(按优先级):
1. **有选中元素** → 放在选中元素下方(左对齐)
2. **无选中元素** → 放在最底部元素下方(左对齐)
3. **画布为空** → 放在视口中心
**选中框缩放**:
- 选中框大小根据 `zoom` 属性自动调整,与缩放后的内容匹配
- 使用 `activeGenerator` 的 `getRectangle` 计算缩放后的矩形
**自动滚动**:
- 使用 `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`:只有当 `isDataReady` 为 `true` 时才开始检查画布是否为空
- 避免在数据加载前误判画布为空,防止灵感板闪烁
### 历史提示词功能
支持记录和管理用户的历史提示词,方便快速复用。
**核心文件**:
- `services/prompt-storage-service.ts` - 存储服务(localStorage)
- `hooks/usePromptHistory.ts` - React Hook
- `components/ai-input-bar/PromptHistoryPopover.tsx` - UI 组件
**功能特点**:
- 自动保存用户发送的提示词(无数量限制,使用 IndexedDB 存储)
- 支持置顶/取消置顶常用提示词
- 鼠标悬浮三点图标显示历史列表
- 点击历史提示词回填到输入框
- 支持删除单条历史记录
**API 示例**:
```typescript
const { history, addHistory, removeHistory, togglePinHistory } = usePromptHistory();
// 添加历史
addHistory('生成一张日落风景图', hasSelection);
// 置顶/取消置顶
togglePinHistory(itemId);
```
### 文本粘贴功能
支持智能文本粘贴到画布,自动控制文本宽度避免过长。
**核心文件**:
- `plugins/with-text-paste.ts` - 文本粘贴插件
- `plugins/with-common.tsx` - 插件注册(文本粘贴 + 图片粘贴)
**功能特点**:
- 自动换行:超过 50 字符自动换行
- 智能断行:优先在空格处断行,保持单词完整
- 保留格式:保留原文本的换行符
- 不影响现有功能:图片粘贴和 Plait 元素复制粘贴正常工作
**配置参数**:
```typescript
const TEXT_CONFIG = {
MAX_CHARS_PER_LINE: 50, // 最大字符数/行
DEFAULT_WIDTH: 400, // 默认文本框宽度
MAX_WIDTH: 600, // 最大文本框宽度
CHAR_WIDTH: 8, // 估算字符宽度
};
```
**使用方法**:
1. 从任何地方复制文本
2. 在画布上按 `Ctrl+V` / `Cmd+V`
3. 文本自动插入并换行
**插件链顺序**:
```typescript
// 在 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` - 样式文件
**功能特点**:
- 仅当 `baseUrl` 为 `api.tu-zi.com` 时才启用
- 每 5 分钟自动刷新健康数据
- 全局缓存避免重复请求
- 彩色方块徽章显示状态,hover 显示状态文字
- API 请求失败时静默处理,不显示健康状态
**API 来源**:
- 端点:`https://apistatus.tu-zi.com/api/history/aggregated`
- 返回字段:`model_name`、`status_label`、`status_color`、`error_rate` 等
**使用示例**:
```typescript
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');
// 使用徽章组件
```
**显示规则**:
- `shouldShowHealth` 为 `true` 时(baseUrl 包含 `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 Fonts(link 标签)
↓
Service Worker 拦截请求
├─ 检查 drawnix-fonts 缓存
├─ 缓存命中 → 直接返回
└─ 缓存未命中 → 下载并缓存
↓
字体加载完成 → board.redraw()
```
**缓存策略**:
- 使用 Service Worker Cache API
- Cache-First 策略(优先使用缓存)
- 缓存 CSS 文件和字体文件(woff2)
- 应用层无感知,完全由 Service Worker 管理
**支持的字体**:
- 系统字体:苹方、微软雅黑、黑体、宋体、楷体等
- Google Fonts:Noto 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` 用于视频生成的首帧/尾帧)
**使用示例**:
```tsx
// AI 图片生成中的使用
// AI 视频生成中的使用(首帧/尾帧模式)
```
**类型定义**:
```typescript
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**:
```typescript
// 导出
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,进入视口才加载图片
- 实时更新:支持 `prependItems`、`updateItem`、`removeItem` 操作
**使用示例**:
```typescript
// 无限滚动分页
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 分页查询**:
```typescript
// Service Worker 中的游标分页实现
async getPaginatedTasks(params: PaginationParams): Promise {
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 install(prewarming → 预缓存资源 → markNewVersionReady → upgradeState='ready')
→ 新 SW 进入 waiting 状态
→ 主线程 statechange='installed' → requestSWVersionState
→ SW 响应 SW_VERSION_STATE(pendingVersion + 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 |
---