Files
TrueGrowth/docs/FEATURE_FLOWS.md

856 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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');
// 使用徽章组件
<ModelHealthBadge modelId="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 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` 用于视频生成的首帧/尾帧)
**使用示例**
```tsx
// 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="首尾帧图片 (可选)"
/>
```
**类型定义**
```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<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 |
---