# 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 | ---