From 7d76899a2214b552fa900764d44c19bcee257ca0 Mon Sep 17 00:00:00 2001 From: liquid Date: Sun, 8 Mar 2026 00:34:06 +0800 Subject: [PATCH] feat(backup-restore): add backup restoration functionality with automatic chunking support - Introduced a new restore button in the SW debug panel for restoring data from ZIP backups. - Implemented `BackupPartManager` to handle automatic chunking during backup processes. - Enhanced the backup process to include tasks, prompts, and assets, with progress updates. - Updated version to 0.6.7 and adjusted build time in `version.json`. - Refactored existing backup logic to support the new restoration features. --- apps/web/public/sw-debug.html | 10 +- apps/web/public/sw-debug/analysis-mode.js | 9 + apps/web/public/sw-debug/app.js | 5 + .../public/sw-debug/backup-part-manager.js | 154 ++ apps/web/public/sw-debug/backup-restore.js | 389 +++++ apps/web/public/sw-debug/backup.js | 395 ++--- apps/web/public/sw-debug/indexeddb.js | 54 + apps/web/public/sw-debug/llmapi-logs.js | 5 + apps/web/public/sw-debug/memory-logs.js | 5 + apps/web/public/sw-debug/state.js | 3 + apps/web/public/sw-debug/styles.css | 30 + apps/web/public/version.json | 2 +- apps/web/src/sw/cdn-fallback.ts | 30 +- docs/FEATURE_FLOWS.md | 2 +- .../backup-restore/backup-restore-dialog.tsx | 8 +- .../drawnix/src/hooks/useFilteredTaskQueue.ts | 1 + packages/drawnix/src/hooks/useTaskExecutor.ts | 90 +- packages/drawnix/src/hooks/useTaskQueue.ts | 152 +- packages/drawnix/src/hooks/useTaskStorage.ts | 5 +- .../src/services/backup-restore-service.ts | 1385 ----------------- .../backup-restore/backup-export-service.ts | 409 +++++ .../backup-restore/backup-import-service.ts | 468 ++++++ .../backup-restore/backup-part-manager.ts | 154 ++ .../services/backup-restore/backup-utils.ts | 41 + .../src/services/backup-restore/index.ts | 34 + .../src/services/backup-restore/types.ts | 141 ++ .../src/services/task-queue-service.ts | 1 + .../src/services/task-storage-reader.ts | 3 +- 28 files changed, 2217 insertions(+), 1768 deletions(-) create mode 100644 apps/web/public/sw-debug/backup-part-manager.js create mode 100644 apps/web/public/sw-debug/backup-restore.js delete mode 100644 packages/drawnix/src/services/backup-restore-service.ts create mode 100644 packages/drawnix/src/services/backup-restore/backup-export-service.ts create mode 100644 packages/drawnix/src/services/backup-restore/backup-import-service.ts create mode 100644 packages/drawnix/src/services/backup-restore/backup-part-manager.ts create mode 100644 packages/drawnix/src/services/backup-restore/backup-utils.ts create mode 100644 packages/drawnix/src/services/backup-restore/index.ts create mode 100644 packages/drawnix/src/services/backup-restore/types.ts diff --git a/apps/web/public/sw-debug.html b/apps/web/public/sw-debug.html index a4fe31ed..a75f6eaf 100644 --- a/apps/web/public/sw-debug.html +++ b/apps/web/public/sw-debug.html @@ -52,6 +52,14 @@ 数据备份 + + + + + `; + + document.body.appendChild(notification); + setTimeout(() => { + notification.classList.add('fade-out'); + setTimeout(() => notification.remove(), 300); + }, 5000); +} diff --git a/apps/web/public/sw-debug/backup.js b/apps/web/public/sw-debug/backup.js index 77f94fe0..a19605b8 100644 --- a/apps/web/public/sw-debug/backup.js +++ b/apps/web/public/sw-debug/backup.js @@ -1,6 +1,5 @@ /** - * SW Debug Panel - Data Backup - * 数据备份功能模块 + * SW Debug Panel - Data Backup(支持自动分片) */ import { elements } from './state.js'; @@ -15,17 +14,18 @@ import { readKVItem, } from './indexeddb.js'; import { showToast } from './toast.js'; +import { BackupPartManager } from './backup-part-manager.js'; /** * 备份签名和版本 */ -const BACKUP_SIGNATURE = 'aitu-backup'; -const BACKUP_VERSION = 2; +export const BACKUP_SIGNATURE = 'aitu-backup'; +export const BACKUP_VERSION = 3; /** - * 获取文件扩展名 + * 获取文件扩展名(导出供 restore 复用) */ -function getExtensionFromMimeType(mimeType) { +export function getExtensionFromMimeType(mimeType) { const mimeToExt = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', @@ -41,9 +41,9 @@ function getExtensionFromMimeType(mimeType) { } /** - * 清理文件/文件夹名称 + * 清理文件/文件夹名称(导出供 restore 复用) */ -function sanitizeFileName(name) { +export function sanitizeFileName(name) { return ( name .replace(/[<>:"/\\|?*]/g, '_') @@ -53,17 +53,27 @@ function sanitizeFileName(name) { } /** - * 等待 JSZip 加载完成 - * @param {number} timeout - 超时时间(毫秒) - * @returns {Promise} + * 从 URL 生成唯一 ID(导出供 restore 复用) */ -function waitForJSZip(timeout = 5000) { +export function generateIdFromUrl(url) { + let hash = 0; + for (let i = 0; i < url.length; i++) { + const char = url.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return `cache-${Math.abs(hash).toString(36)}`; +} + +/** + * 等待 JSZip 加载完成 + */ +export function waitForJSZip(timeout = 5000) { return new Promise((resolve) => { if (typeof JSZip !== 'undefined') { resolve(true); return; } - const startTime = Date.now(); const checkInterval = setInterval(() => { if (typeof JSZip !== 'undefined') { @@ -83,46 +93,27 @@ function waitForJSZip(timeout = 5000) { export async function performBackup() { const btn = elements.backupDataBtn; if (!btn) return; - + const originalText = btn.innerHTML; - + try { btn.disabled = true; btn.innerHTML = '⏳ 加载中...'; - - // 等待 JSZip 加载 + const jsZipLoaded = await waitForJSZip(5000); - if (!jsZipLoaded) { throw new Error('JSZip 库加载超时,请检查网络连接后重试'); } - + btn.innerHTML = '⏳ 准备中...'; - - const zip = new JSZip(); - - const manifest = { - signature: BACKUP_SIGNATURE, - version: BACKUP_VERSION, - createdAt: Date.now(), - source: 'sw-debug-panel', - includes: { - prompts: true, - projects: true, - assets: true, - }, - stats: { - promptCount: 0, - videoPromptCount: 0, - imagePromptCount: 0, - folderCount: 0, - boardCount: 0, - assetCount: 0, - taskCount: 0, - }, - }; - - // 显示进度条 + + // 生成文件名前缀 + const date = new Date(); + const dateStr = date.toISOString().split('T')[0]; + const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, ''); + const baseFilename = `aitu_backup_${dateStr}_${timeStr}`; + const backupId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const progressContainer = showBackupProgress(); const updateProgress = (percent, text) => { const progressBar = progressContainer.querySelector('.backup-progress-fill'); @@ -130,94 +121,84 @@ export async function performBackup() { if (progressBar) progressBar.style.width = `${percent}%`; if (progressText) progressText.textContent = text; }; - - // 0. 先收集任务数据(后面提示词收集需要用到) + + const partManager = new BackupPartManager(baseFilename, backupId); + + const manifest = { + signature: BACKUP_SIGNATURE, + version: BACKUP_VERSION, + createdAt: Date.now(), + source: 'sw-debug-panel', + backupId, + includes: { prompts: true, projects: true, tasks: true, assets: true }, + stats: { + promptCount: 0, videoPromptCount: 0, imagePromptCount: 0, + folderCount: 0, boardCount: 0, assetCount: 0, taskCount: 0, + }, + }; + + // 0. 任务数据 updateProgress(5, '正在读取任务数据...'); const allTasks = await collectTasksData(); - - // 1. 收集提示词数据(会从任务中提取提示词合并) + + // 1. 提示词(非素材,放 Part1) updateProgress(15, '正在备份提示词...'); const promptsData = await collectPromptsData(allTasks); - zip.file('prompts.json', JSON.stringify(promptsData, null, 2)); + partManager.addFile('prompts.json', promptsData); manifest.stats.promptCount = promptsData.promptHistory?.length || 0; manifest.stats.videoPromptCount = promptsData.videoPromptHistory?.length || 0; manifest.stats.imagePromptCount = promptsData.imagePromptHistory?.length || 0; - - // 2. 收集项目数据 + + // 2. 项目数据(非素材,放 Part1) updateProgress(25, '正在备份项目...'); - const projectStats = await collectProjectsData(zip); + const projectStats = await collectProjectsData(partManager.currentZip); manifest.stats.folderCount = projectStats.folders; manifest.stats.boardCount = projectStats.boards; - - // 3. 收集素材数据(进度回调) - updateProgress(35, '正在备份素材...'); - const assetCount = await collectAssetsData(zip, (current, total) => { - const percent = 35 + Math.round((current / total) * 40); - updateProgress(percent, `正在备份素材 (${current}/${total})...`); - }); - manifest.stats.assetCount = assetCount; - - // 4. 导出已完成的媒体任务数据(素材库展示需要) - updateProgress(80, '正在导出任务数据...'); + + // 3. 任务数据(非素材,放 Part1) + updateProgress(30, '正在导出任务数据...'); const completedMediaTasks = allTasks.filter( task => task.status === TaskStatus.COMPLETED && (task.type === TaskType.IMAGE || task.type === TaskType.VIDEO) && task.result?.url ); if (completedMediaTasks.length > 0) { - zip.file('tasks.json', JSON.stringify(completedMediaTasks, null, 2)); + partManager.addFile('tasks.json', completedMediaTasks); manifest.stats.taskCount = completedMediaTasks.length; } - - // 5. 写入清单文件 - zip.file('manifest.json', JSON.stringify(manifest, null, 2)); - - // 6. 生成并下载 ZIP 文件 - updateProgress(85, '正在压缩文件...'); - const blob = await zip.generateAsync({ - type: 'blob', - compression: 'DEFLATE', - compressionOptions: { level: 6 }, - }, (metadata) => { - const percent = 85 + Math.round(metadata.percent * 0.14); - updateProgress(percent, '正在压缩文件...'); + + // 4. 素材数据(通过 partManager 自动分片) + updateProgress(35, '正在备份素材...'); + const assetCount = await collectAssetsData(partManager, (current, total) => { + const percent = 35 + Math.round((current / total) * 50); + updateProgress(percent, `正在备份素材 (${current}/${total})...`); }); - - // 下载文件 - updateProgress(100, '备份完成!'); - const date = new Date(); - const dateStr = date.toISOString().split('T')[0]; - const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, ''); - const filename = `aitu_backup_${dateStr}_${timeStr}.zip`; - - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - + manifest.stats.assetCount = assetCount; + + // 5. finalize 所有分片 + updateProgress(88, '正在压缩文件...'); + const result = await partManager.finalizeAll(manifest); + // 关闭进度条,显示成功信息 + updateProgress(100, '备份完成!'); setTimeout(() => { progressContainer.remove(); - const sizeInMB = (blob.size / 1024 / 1024).toFixed(2); + const totalSize = result.files.reduce((sum, f) => sum + f.size, 0); + const sizeInMB = (totalSize / 1024 / 1024).toFixed(2); showBackupSuccessNotification({ - filename, + files: result.files, + totalParts: result.totalParts, size: sizeInMB, stats: manifest.stats, }); }, 500); - + btn.innerHTML = originalText; btn.disabled = false; - + } catch (error) { - // 关闭进度条 const progressContainer = document.querySelector('.backup-progress-container'); if (progressContainer) progressContainer.remove(); - showToast('备份失败: ' + error.message, 'error', 5000); btn.innerHTML = originalText; btn.disabled = false; @@ -226,7 +207,6 @@ export async function performBackup() { /** * 收集任务数据 - * 从 sw-task-queue 数据库读取所有任务 */ async function collectTasksData() { try { @@ -240,9 +220,6 @@ async function collectTasksData() { /** * 收集提示词数据 - * 合并两个来源: - * 1. IndexedDB 中的提示词历史 - * 2. 任务队列中已完成任务的提示词 */ async function collectPromptsData(allTasks = []) { const [promptHistory, videoPromptHistory, imagePromptHistory, presetSettings] = await Promise.all([ @@ -251,15 +228,13 @@ async function collectPromptsData(allTasks = []) { readKVItem(KV_KEYS.IMAGE_PROMPT_HISTORY), readKVItem(KV_KEYS.PRESET_SETTINGS), ]); - + let finalPromptHistory = promptHistory || []; let finalVideoPromptHistory = videoPromptHistory || []; let finalImagePromptHistory = imagePromptHistory || []; - - // 从已完成的任务中提取提示词 + const completedTasks = allTasks.filter(task => task.status === TaskStatus.COMPLETED); - - // 提取图片任务的提示词 + const imageTaskPrompts = completedTasks .filter(task => task.type === TaskType.IMAGE && task.params?.prompt) .map(task => ({ @@ -268,8 +243,7 @@ async function collectPromptsData(allTasks = []) { timestamp: task.completedAt || task.createdAt, })) .filter(item => item.content && item.content.length > 0); - - // 提取视频任务的提示词 + const videoTaskPrompts = completedTasks .filter(task => task.type === TaskType.VIDEO && task.params?.prompt) .map(task => ({ @@ -278,17 +252,15 @@ async function collectPromptsData(allTasks = []) { timestamp: task.completedAt || task.createdAt, })) .filter(item => item.content && item.content.length > 0); - - // 合并图片提示词(去重) + const existingImageContents = new Set(finalImagePromptHistory.map(p => p.content)); const newImagePrompts = imageTaskPrompts.filter(p => !existingImageContents.has(p.content)); finalImagePromptHistory = [...finalImagePromptHistory, ...newImagePrompts]; - - // 合并视频提示词(去重) + const existingVideoContents = new Set(finalVideoPromptHistory.map(p => p.content)); const newVideoPrompts = videoTaskPrompts.filter(p => !existingVideoContents.has(p.content)); finalVideoPromptHistory = [...finalVideoPromptHistory, ...newVideoPrompts]; - + return { promptHistory: finalPromptHistory, videoPromptHistory: finalVideoPromptHistory, @@ -300,38 +272,29 @@ async function collectPromptsData(allTasks = []) { }; } -/** - * 收集项目数据 - * 从 aitu-workspace 数据库的 folders 和 boards store 读取 - */ +/** 收集项目数据 */ async function collectProjectsData(zip) { const projectsFolder = zip.folder('projects'); - - // 从独立的 store 读取,而不是 KV 存储 + const [folders, boards] = await Promise.all([ readAllFromIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.FOLDERS), readAllFromIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.BOARDS), ]); - + const folderList = folders || []; const boardList = boards || []; - - // 构建文件夹路径映射 + const folderPathMap = new Map(); const folderMap = new Map(); - + for (const folder of folderList) { folderMap.set(folder.id, folder); } - + const getPath = (folderId) => { - if (folderPathMap.has(folderId)) { - return folderPathMap.get(folderId); - } - + if (folderPathMap.has(folderId)) return folderPathMap.get(folderId); const folder = folderMap.get(folderId); if (!folder) return ''; - const safeName = sanitizeFileName(folder.name); if (folder.parentId) { const parentPath = getPath(folder.parentId); @@ -339,170 +302,116 @@ async function collectProjectsData(zip) { folderPathMap.set(folderId, fullPath); return fullPath; } - folderPathMap.set(folderId, safeName); return safeName; }; - - for (const folder of folderList) { - getPath(folder.id); - } - - // 创建文件夹结构 + + for (const folder of folderList) getPath(folder.id); + for (const folder of folderList) { const path = folderPathMap.get(folder.id) || folder.name; projectsFolder.folder(path); } - - // 导出画板 + for (const board of boardList) { const folderPath = board.folderId ? folderPathMap.get(board.folderId) : null; const safeName = sanitizeFileName(board.name); - const boardPath = folderPath - ? `${folderPath}/${safeName}.drawnix` - : `${safeName}.drawnix`; - + const boardPath = folderPath ? `${folderPath}/${safeName}.drawnix` : `${safeName}.drawnix`; + const drawnixData = { - type: 'drawnix', - version: 1, - source: 'backup', + type: 'drawnix', version: 1, source: 'backup', elements: board.elements || [], viewport: board.viewport || { zoom: 1 }, theme: board.theme, boardMeta: { - id: board.id, - name: board.name, - folderId: board.folderId, - order: board.order, - createdAt: board.createdAt, - updatedAt: board.updatedAt, + id: board.id, name: board.name, folderId: board.folderId, + order: board.order, createdAt: board.createdAt, updatedAt: board.updatedAt, }, }; - projectsFolder.file(boardPath, JSON.stringify(drawnixData, null, 2)); } - - return { - folders: folderList.length, - boards: boardList.length, - }; + + return { folders: folderList.length, boards: boardList.length }; } /** - * 从 URL 生成唯一 ID(与应用层保持一致) + * 收集素材数据(使用 partManager 自动分片) + * @param {BackupPartManager} partManager + * @param {Function} onProgress */ -function generateIdFromUrl(url) { - let hash = 0; - for (let i = 0; i < url.length; i++) { - const char = url.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return `cache-${Math.abs(hash).toString(36)}`; -} - -/** - * 收集素材数据 - * 三个数据源: - * 1. aitu-assets 数据库(本地素材库元数据) - * 2. drawnix-unified-cache 数据库(AI 生成媒体元数据) - * 3. drawnix-images Cache Storage(媒体二进制数据) - * @param {JSZip} zip - ZIP 实例 - * @param {Function} onProgress - 进度回调 (current, total) - */ -async function collectAssetsData(zip, onProgress) { - const assetsFolder = zip.folder('assets'); +async function collectAssetsData(partManager, onProgress) { let exportedCount = 0; const exportedUrls = new Set(); - + try { - // 打开 Cache Storage const cache = await caches.open(CACHE_NAMES.IMAGES); - - // 1. 从 aitu-assets 数据库读取本地素材元数据 const assetMetaList = await readAllFromIDB(IDB_STORES.ASSETS.name, IDB_STORES.ASSETS.store); - - // 2. 从 drawnix-unified-cache 数据库读取 AI 生成媒体元数据 const unifiedCacheItems = await readAllFromIDB(IDB_STORES.UNIFIED_CACHE.name, IDB_STORES.UNIFIED_CACHE.store); - - // 3. 获取虚拟路径缓存 const cacheKeys = await cache.keys(); const virtualRequests = cacheKeys.filter(req => req.url.includes('/__aitu_cache__/')); - - // 计算总数用于进度显示 + const totalItems = assetMetaList.length + unifiedCacheItems.length + virtualRequests.length; let processedCount = 0; - - // 1. 导出本地素材 + + // 1. 本地素材 for (const asset of assetMetaList) { try { - assetsFolder.file(`${asset.id}.meta.json`, JSON.stringify(asset, null, 2)); - if (asset.url) { const response = await cache.match(asset.url); if (response) { const blob = await response.blob(); if (blob.size > 0) { const ext = getExtensionFromMimeType(asset.mimeType || blob.type); - assetsFolder.file(`${asset.id}${ext}`, blob); + await partManager.addAssetBlob( + `${asset.id}${ext}`, blob, + `${asset.id}.meta.json`, asset + ); exportedUrls.add(asset.url); exportedCount++; } } } - } catch (err) { - // 静默处理错误 - } + } catch (err) { /* 静默 */ } processedCount++; if (onProgress) onProgress(processedCount, totalItems); } - - // 2. 导出 unified-cache 中的素材 + + // 2. unified-cache 素材 const newCacheItems = unifiedCacheItems.filter(item => !exportedUrls.has(item.url)); - for (const item of newCacheItems) { try { const itemId = item.metadata?.taskId || generateIdFromUrl(item.url); - const metaData = { - id: itemId, - url: item.url, + id: itemId, url: item.url, type: item.type === 'video' ? 'VIDEO' : 'IMAGE', - mimeType: item.mimeType, - size: item.size, + mimeType: item.mimeType, size: item.size, source: 'AI_GENERATED', - createdAt: item.cachedAt, - updatedAt: item.lastUsed, + createdAt: item.cachedAt, updatedAt: item.lastUsed, metadata: item.metadata, }; - assetsFolder.file(`${itemId}.meta.json`, JSON.stringify(metaData, null, 2)); - const response = await cache.match(item.url); if (response) { const blob = await response.blob(); if (blob.size > 0) { const ext = getExtensionFromMimeType(item.mimeType); - assetsFolder.file(`${itemId}${ext}`, blob); + await partManager.addAssetBlob( + `${itemId}${ext}`, blob, + `${itemId}.meta.json`, metaData + ); exportedUrls.add(item.url); exportedCount++; } } - } catch (err) { - // 静默处理错误 - } + } catch (err) { /* 静默 */ } processedCount++; if (onProgress) onProgress(processedCount, totalItems); } - - // 跳过已处理的 unified-cache items processedCount += (unifiedCacheItems.length - newCacheItems.length); - - // 3. 导出虚拟路径缓存中的媒体(可能有些不在 unified-cache 中) + + // 3. 虚拟路径缓存 const pendingVirtualRequests = virtualRequests.filter(req => !exportedUrls.has(req.url)); - for (const request of pendingVirtualRequests) { const url = request.url; - try { const response = await cache.match(request); if (response) { @@ -511,37 +420,24 @@ async function collectAssetsData(zip, onProgress) { const urlParts = url.split('/'); const filename = urlParts[urlParts.length - 1]; const id = filename.split('.')[0] || `cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const contentType = response.headers.get('content-type') || blob.type; const ext = getExtensionFromMimeType(contentType); const type = contentType.startsWith('video/') ? 'VIDEO' : 'IMAGE'; - - const metadata = { - id, - url, - type, - mimeType: contentType, - size: blob.size, - source: 'AI_GENERATED', - createdAt: Date.now(), - }; - assetsFolder.file(`${id}.meta.json`, JSON.stringify(metadata, null, 2)); - assetsFolder.file(`${id}${ext}`, blob); + const metadata = { id, url, type, mimeType: contentType, size: blob.size, source: 'AI_GENERATED', createdAt: Date.now() }; + await partManager.addAssetBlob( + `${id}${ext}`, blob, + `${id}.meta.json`, metadata + ); exportedUrls.add(url); exportedCount++; } } - } catch (err) { - // 静默处理错误 - } + } catch (err) { /* 静默 */ } processedCount++; if (onProgress) onProgress(processedCount, totalItems); } - - } catch (error) { - // 静默处理错误 - } - + } catch (error) { /* 静默 */ } + return exportedCount; } @@ -570,15 +466,18 @@ function showBackupProgress() { /** * 显示备份成功通知 */ -function showBackupSuccessNotification({ filename, size, stats }) { +function showBackupSuccessNotification({ files, totalParts, size, stats }) { const notification = document.createElement('div'); notification.className = 'import-notification backup-notification'; + const fileInfo = totalParts > 1 + ? `${totalParts} 个分片文件` + : files[0]?.filename || 'backup.zip'; notification.innerHTML = `
✅
备份成功 -

${filename}

+

${fileInfo}

文件大小: ${size} MB ${stats.boardCount > 0 ? `| 画板: ${stats.boardCount}` : ''} @@ -592,12 +491,10 @@ function showBackupSuccessNotification({ filename, size, stats }) {

`; - + document.body.appendChild(notification); - - // 5 秒后自动消失 setTimeout(() => { notification.classList.add('fade-out'); setTimeout(() => notification.remove(), 300); }, 5000); -} +} \ No newline at end of file diff --git a/apps/web/public/sw-debug/indexeddb.js b/apps/web/public/sw-debug/indexeddb.js index ca78273b..fa1a5e64 100644 --- a/apps/web/public/sw-debug/indexeddb.js +++ b/apps/web/public/sw-debug/indexeddb.js @@ -141,3 +141,57 @@ export async function readKVItem(key) { }; }); } + +/** + * 向 IndexedDB 写入单条数据(put 语义,按 key 覆盖) + */ +export async function writeToIDB(dbName, storeName, item) { + const db = await openIDB(dbName, storeName); + if (!db) { + throw new Error(`Database ${dbName} or store ${storeName} not found`); + } + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + const request = store.put(item); + request.onerror = () => { db.close(); reject(request.error); }; + request.onsuccess = () => { db.close(); resolve(); }; + }); +} + +/** + * 向 KV 存储写入数据 + */ +export async function writeKVItem(key, value) { + const db = await openIDB(IDB_STORES.KV.name, IDB_STORES.KV.store); + if (!db) { + throw new Error('KV store not found'); + } + return new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORES.KV.store, 'readwrite'); + const store = tx.objectStore(IDB_STORES.KV.store); + const request = store.put({ key, value }); + request.onerror = () => { db.close(); reject(request.error); }; + request.onsuccess = () => { db.close(); resolve(); }; + }); +} + +/** + * 批量写入 IndexedDB(单事务内完成) + */ +export async function writeBatchToIDB(dbName, storeName, items) { + if (!items || items.length === 0) return; + const db = await openIDB(dbName, storeName); + if (!db) { + throw new Error(`Database ${dbName} or store ${storeName} not found`); + } + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, 'readwrite'); + const store = tx.objectStore(storeName); + for (const item of items) { + store.put(item); + } + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => { db.close(); reject(tx.error); }; + }); +} diff --git a/apps/web/public/sw-debug/llmapi-logs.js b/apps/web/public/sw-debug/llmapi-logs.js index 27b11fe5..f5bd5d62 100644 --- a/apps/web/public/sw-debug/llmapi-logs.js +++ b/apps/web/public/sw-debug/llmapi-logs.js @@ -29,6 +29,11 @@ function getCurrentFilter() { * @param {number} page - 页码,默认使用当前页 */ export async function loadLLMApiLogs(page) { + // 分析模式下不从 SW 重新加载,直接渲染已导入的数据 + if (state.isAnalysisMode) { + renderLLMApiLogs(); + return; + } try { const targetPage = typeof page === 'number' ? page : (state.llmapiPagination.page || 1); const pageSize = state.llmapiPagination.pageSize || 20; diff --git a/apps/web/public/sw-debug/memory-logs.js b/apps/web/public/sw-debug/memory-logs.js index 409f95a5..ff03bfab 100644 --- a/apps/web/public/sw-debug/memory-logs.js +++ b/apps/web/public/sw-debug/memory-logs.js @@ -15,6 +15,11 @@ let memoryMonitorInterval = null; * Load crash logs from SW (uses duplex RPC) */ export async function loadCrashLogs() { + // 分析模式下不从 SW 重新加载,直接渲染已导入的数据 + if (state.isAnalysisMode) { + renderCrashLogs(); + return; + } try { const result = await loadCrashSnapshots(); if (result && result.snapshots) { diff --git a/apps/web/public/sw-debug/state.js b/apps/web/public/sw-debug/state.js index ac095e55..c37e2bef 100644 --- a/apps/web/public/sw-debug/state.js +++ b/apps/web/public/sw-debug/state.js @@ -195,6 +195,9 @@ export function cacheElements() { panelTitle: document.getElementById('panelTitle'), // Backup button backupDataBtn: document.getElementById('backupData'), + // Restore button + restoreBackupBtn: document.getElementById('restoreBackup'), + restoreBackupInput: document.getElementById('restoreBackupInput'), }; } diff --git a/apps/web/public/sw-debug/styles.css b/apps/web/public/sw-debug/styles.css index 75d06c46..e50fc03c 100644 --- a/apps/web/public/sw-debug/styles.css +++ b/apps/web/public/sw-debug/styles.css @@ -2971,6 +2971,36 @@ body.analysis-mode .log-entry { cursor: not-allowed; } +/* Restore button */ +#restoreBackup { + background: var(--primary-color); + border-color: var(--primary-color); + color: #fff; +} + +#restoreBackup:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} + +#restoreBackup:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +/* Restore notification */ +.restore-notification .import-notification-content { + border-color: var(--primary-color); +} + +.restore-notification .import-notification-content > .icon { + color: var(--primary-color); +} + +.restore-notification .info strong { + color: var(--primary-color); +} + /* Backup progress container */ .backup-progress-container { position: fixed; diff --git a/apps/web/public/version.json b/apps/web/public/version.json index 8e8339ed..b3e7672c 100644 --- a/apps/web/public/version.json +++ b/apps/web/public/version.json @@ -1,6 +1,6 @@ { "version": "0.6.7", - "buildTime": "2026-03-04T16:05:02.327Z", + "buildTime": "2026-03-07T16:29:29.739Z", "gitCommit": "unknown", "changelog": [ "优化Service Worker激活和CDN策略" diff --git a/apps/web/src/sw/cdn-fallback.ts b/apps/web/src/sw/cdn-fallback.ts index d78b2a94..bbc82a00 100644 --- a/apps/web/src/sw/cdn-fallback.ts +++ b/apps/web/src/sw/cdn-fallback.ts @@ -265,19 +265,27 @@ export async function fetchFromCDNWithFallback( } // 3. 内容采样验证(检测 HTML 错误页面) + // 只读取前几百字节,避免将整个响应体加载到内存 const clonedResponse = response.clone(); try { - const textSample = await clonedResponse.text().then(t => t.slice(0, 200)); - const looksLikeHtml = textSample.includes(' { setProgress(p); @@ -141,8 +142,7 @@ export const BackupRestoreDialog = ({ } ); - backupRestoreService.downloadZip(blob); - MessagePlugin.success('备份成功!'); + MessagePlugin.success(`备份成功!共 ${result.totalParts} 个文件`); handleClose(); } catch (error) { console.error('[BackupRestore] Export failed:', error); diff --git a/packages/drawnix/src/hooks/useFilteredTaskQueue.ts b/packages/drawnix/src/hooks/useFilteredTaskQueue.ts index ad919c2c..dc1faa29 100644 --- a/packages/drawnix/src/hooks/useFilteredTaskQueue.ts +++ b/packages/drawnix/src/hooks/useFilteredTaskQueue.ts @@ -77,6 +77,7 @@ export function useFilteredTaskQueue( const isAvailable = await taskStorageReader.isAvailable(); if (isAvailable) { const result = await taskStorageReader.getTasksByType(taskType, offset, pageSize); + console.warn(`[useFilteredTaskQueue] Loaded ${result.tasks.length}/${result.total} tasks (type=${taskType}, offset=${offset})`); if (append) { setTasks(prev => { diff --git a/packages/drawnix/src/hooks/useTaskExecutor.ts b/packages/drawnix/src/hooks/useTaskExecutor.ts index 2f97b9c7..52f28b0a 100644 --- a/packages/drawnix/src/hooks/useTaskExecutor.ts +++ b/packages/drawnix/src/hooks/useTaskExecutor.ts @@ -169,8 +169,14 @@ function getFriendlyErrorMessage(error: any): string { * return ; * } */ +// 最大并发任务数,防止页面加载时大量任务同时执行导致 OOM +const MAX_CONCURRENT_TASKS = 3; +// 页面加载后延迟执行积压任务,避免与页面初始化竞争资源 +const STARTUP_DELAY_MS = 2000; + export function useTaskExecutor(): void { const executingTasksRef = useRef>(new Set()); + const pendingQueueRef = useRef([]); const timeoutCheckIntervalRef = useRef(null); useEffect(() => { @@ -178,6 +184,28 @@ export function useTaskExecutor(): void { // All tasks execute in main thread + // 尝试从等待队列中执行下一个任务(并发控制) + // 注意:引用了 executeTask,但 executeTask 也是 const, + // 由于 tryExecuteNext 只在异步回调中被调用,此时 executeTask 已定义 + const tryExecuteNext = () => { + if (!isActive) return; + while ( + pendingQueueRef.current.length > 0 && + executingTasksRef.current.size < MAX_CONCURRENT_TASKS + ) { + const next = pendingQueueRef.current.shift(); + if (next && !executingTasksRef.current.has(next.id)) { + executeTask(next); + } + } + }; + + // 任务完成后释放并发槽位,触发队列中下一个任务 + const onTaskFinished = (taskId: string) => { + executingTasksRef.current.delete(taskId); + tryExecuteNext(); + }; + // Function to resume a video task that has a remoteId const resumeVideoTask = async (task: Task) => { const taskId = task.id; @@ -185,12 +213,10 @@ export function useTaskExecutor(): void { // Prevent duplicate execution if (executingTasksRef.current.has(taskId)) { - // console.log(`[TaskExecutor] Task ${taskId} is already executing`); return; } executingTasksRef.current.add(taskId); - // console.log(`[TaskExecutor] Resuming video task ${taskId} with remoteId ${remoteId}`); try { // Resume video polling @@ -206,7 +232,6 @@ export function useTaskExecutor(): void { result, }); - // console.log(`[TaskExecutor] Resumed task ${taskId} completed successfully`); // Register image/video metadata in unified cache if (result.url) { @@ -217,7 +242,6 @@ export function useTaskExecutor(): void { prompt: task.params.prompt, params: task.params, }); - // console.log(`[TaskExecutor] Registered metadata for resumed task ${taskId}`); } catch (error) { console.error( `[TaskExecutor] Failed to register metadata for resumed task ${taskId}:`, @@ -258,9 +282,8 @@ export function useTaskExecutor(): void { }, }); - // console.log(`[TaskExecutor] Resumed task ${taskId} failed`); } finally { - executingTasksRef.current.delete(taskId); + onTaskFinished(taskId); } }; @@ -326,7 +349,7 @@ export function useTaskExecutor(): void { }, }); } finally { - executingTasksRef.current.delete(taskId); + onTaskFinished(taskId); } }; @@ -336,12 +359,10 @@ export function useTaskExecutor(): void { // Prevent duplicate execution if (executingTasksRef.current.has(taskId)) { - // console.log(`[TaskExecutor] Character task ${taskId} is already executing`); return; } executingTasksRef.current.add(taskId); - // console.log(`[TaskExecutor] Starting character task ${taskId}`); try { // Update status to processing @@ -365,7 +386,6 @@ export function useTaskExecutor(): void { }, { onStatusChange: (status: CharacterStatus) => { - // console.log(`[TaskExecutor] Character ${taskId} status: ${status}`); }, } ); @@ -400,7 +420,6 @@ export function useTaskExecutor(): void { remoteId: result.characterId, }); - // console.log(`[TaskExecutor] Character task ${taskId} completed: @${result.username}`); } catch (error: any) { if (!isActive) return; @@ -432,9 +451,8 @@ export function useTaskExecutor(): void { }, }); - // console.log(`[TaskExecutor] Character task ${taskId} failed`); } finally { - executingTasksRef.current.delete(taskId); + onTaskFinished(taskId); } }; @@ -468,12 +486,10 @@ export function useTaskExecutor(): void { // Prevent duplicate execution if (executingTasksRef.current.has(taskId)) { - // console.log(`[TaskExecutor] Task ${taskId} is already executing`); return; } executingTasksRef.current.add(taskId); - // console.log(`[TaskExecutor] Starting execution of task ${taskId}`); try { // Update status to processing @@ -493,7 +509,6 @@ export function useTaskExecutor(): void { result, }); - // console.log(`[TaskExecutor] Task ${taskId} completed successfully`); // Register image/video metadata in unified cache if (result.url) { @@ -504,7 +519,6 @@ export function useTaskExecutor(): void { prompt: task.params.prompt, params: task.params, }); - // console.log(`[TaskExecutor] Registered metadata for task ${taskId}`); } catch (error) { console.error( `[TaskExecutor] Failed to register metadata for task ${taskId}:`, @@ -545,9 +559,21 @@ export function useTaskExecutor(): void { }, }); - // console.log(`[TaskExecutor] Task ${taskId} failed`); } finally { - executingTasksRef.current.delete(taskId); + onTaskFinished(taskId); + } + }; + + // 将任务加入执行队列(带并发控制) + const enqueueTask = (task: Task) => { + if (executingTasksRef.current.has(task.id)) return; + // 避免重复入队 + if (pendingQueueRef.current.some(t => t.id === task.id)) return; + + if (executingTasksRef.current.size < MAX_CONCURRENT_TASKS) { + executeTask(task); + } else { + pendingQueueRef.current.push(task); } }; @@ -561,9 +587,6 @@ export function useTaskExecutor(): void { const pendingTasks = tasks.filter( (task) => task.status === TaskStatus.PENDING ); - pendingTasks.forEach((task) => { - executeTask(task); - }); // Process resumable tasks (processing with remoteId) const resumableTasks = tasks.filter( @@ -576,8 +599,16 @@ export function useTaskExecutor(): void { task.status === TaskStatus.PROCESSING && isAsyncImageModel(task.params.model)) ); + + console.warn( + `[TaskExecutor] processPendingTasks: ${tasks.length} total, ${pendingTasks.length} pending, ${resumableTasks.length} resumable, ${executingTasksRef.current.size} executing` + ); + + pendingTasks.forEach((task) => { + enqueueTask(task); + }); resumableTasks.forEach((task) => { - executeTask(task); + enqueueTask(task); }); }; @@ -625,7 +656,7 @@ export function useTaskExecutor(): void { // Execute pending tasks if (task.status === TaskStatus.PENDING) { - executeTask(task); + enqueueTask(task); } // Resume tasks that have remoteId and are in processing state (video or async image) else if ( @@ -637,15 +668,14 @@ export function useTaskExecutor(): void { task.status === TaskStatus.PROCESSING && isAsyncImageModel(task.params.model))) ) { - executeTask(task); + enqueueTask(task); } } }); - // Process existing pending tasks on mount - // Note: This runs synchronously, but tasks may be restored asynchronously by useTaskStorage - // The subscription above will catch tasks restored after this point - processPendingTasks(); + // Process existing pending tasks on mount (delayed to avoid competing with page initialization) + // The subscription above will catch tasks created/restored after this point + const startupTimer = setTimeout(processPendingTasks, STARTUP_DELAY_MS); // Set up timeout checker (every 10 seconds) timeoutCheckIntervalRef.current = setInterval(checkTimeouts, 10000); @@ -654,6 +684,8 @@ export function useTaskExecutor(): void { return () => { isActive = false; subscription.unsubscribe(); + clearTimeout(startupTimer); + pendingQueueRef.current = []; if (timeoutCheckIntervalRef.current) { clearInterval(timeoutCheckIntervalRef.current); diff --git a/packages/drawnix/src/hooks/useTaskQueue.ts b/packages/drawnix/src/hooks/useTaskQueue.ts index 58e9d429..4578cc07 100644 --- a/packages/drawnix/src/hooks/useTaskQueue.ts +++ b/packages/drawnix/src/hooks/useTaskQueue.ts @@ -5,7 +5,7 @@ * Subscribes to task updates and provides memoized selectors. */ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { taskQueueService } from '../services/task-queue'; import { taskStorageReader } from '../services/task-storage-reader'; import { Task, TaskStatus, TaskType, GenerationParams } from '../types/task.types'; @@ -83,107 +83,91 @@ export function useTaskQueue(): UseTaskQueueReturn { const [tasks, setTasks] = useState([]); const [updateCounter, setUpdateCounter] = useState(0); const [isLoading, setIsLoading] = useState(true); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(true); + const [isLoadingMore] = useState(false); + const [hasMore] = useState(false); const [totalCount, setTotalCount] = useState(0); const [loadedCount, setLoadedCount] = useState(0); - const loadSucceeded = useRef(false); - const loadMoreLock = useRef(false); - // 重试计数器 - const [retryCount, setRetryCount] = useState(0); - const maxRetries = 3; - const retryDelay = 500; - // 分页状态(不再使用 SW 分页,数据直接从 IndexedDB 加载) - const updatePaginationState = useCallback(() => { - // No-op: all data loaded from IndexedDB directly - }, []); - - // Subscribe to task updates + // Subscribe to task updates (useTaskStorage handles IndexedDB loading and restoreTasks) useEffect(() => { - // Initialize with current tasks - setTasks(taskQueueService.getAllTasks()); + let cancelled = false; - // Subscribe to updates + // Initialize with current in-memory tasks + const currentTasks = taskQueueService.getAllTasks(); + setTasks(currentTasks); + setTotalCount(currentTasks.length); + setLoadedCount(currentTasks.length); + if (currentTasks.length > 0) { + console.warn(`[useTaskQueue] Init: ${currentTasks.length} tasks from memory`); + setIsLoading(false); + } else { + console.warn('[useTaskQueue] Init: memory empty, waiting for restore or DB fallback'); + } + + // Subscribe to updates — catches tasks restored by useTaskStorage const subscription = taskQueueService.observeTaskUpdates().subscribe(() => { - setTasks(taskQueueService.getAllTasks()); + const allTasks = taskQueueService.getAllTasks(); + setTasks(allTasks); setUpdateCounter(prev => prev + 1); - // 更新分页状态 - updatePaginationState(); + setTotalCount(allTasks.length); + setLoadedCount(allTasks.length); + setIsLoading(false); }); - return () => { - subscription.unsubscribe(); - }; - }, [updatePaginationState]); + // 如果内存中没有数据(useTaskStorage 可能还没完成), + // 延迟从 IndexedDB 补充加载,避免面板打开时显示空数据 + if (currentTasks.length === 0) { + const loadFromDB = async () => { + // 先等一小段时间,给 useTaskStorage 机会先完成 + await new Promise(r => setTimeout(r, 500)); + if (cancelled) return; - // 渲染时从 IndexedDB 加载任务数据(带重试逻辑) - useEffect(() => { - // 如果已成功加载,跳过 - if (loadSucceeded.current) return; - - let cancelled = false; - let retryTimer: ReturnType | null = null; - - const loadTasks = async (): Promise => { - try { - // 检查 IndexedDB 是否可用 - const isAvailable = await taskStorageReader.isAvailable(); - if (!isAvailable) { - return false; + // 再次检查内存,useTaskStorage 可能已经完成了 + const memTasks = taskQueueService.getAllTasks(); + if (memTasks.length > 0) { + console.warn(`[useTaskQueue] Fallback: ${memTasks.length} tasks appeared in memory`); + setTasks(memTasks); + setTotalCount(memTasks.length); + setLoadedCount(memTasks.length); + setIsLoading(false); + return; } - // 直接从 IndexedDB 读取任务(SW 和降级模式统一逻辑) - const storedTasks = await taskStorageReader.getAllTasks(); - - // 先恢复到 taskQueueService 内存中(merge 模式,不会覆盖正在执行的任务) - // 然后从内存读取合并后的最新状态,确保 UI 显示与内存一致 - if (storedTasks.length > 0) { - taskQueueService.restoreTasks(storedTasks); - } - - // 从 taskQueueService 内存获取合并后的状态(比 storedTasks 更准确) - setTasks(taskQueueService.getAllTasks()); - - // 设置分页状态 - const allTasks = taskQueueService.getAllTasks(); - setTotalCount(allTasks.length); - setLoadedCount(allTasks.length); - setHasMore(false); // 直接从 IndexedDB 加载的是全部数据 - return true; - } catch { - return false; - } - }; - const init = async () => { - setIsLoading(true); - const success = await loadTasks(); - - if (cancelled) return; - - setIsLoading(false); - - if (success) { - loadSucceeded.current = true; - } else if (retryCount < maxRetries) { - // 加载失败,安排重试(指数退避) - retryTimer = setTimeout(() => { - if (!cancelled) { - setRetryCount(prev => prev + 1); + // 内存仍为空,直接从 IndexedDB 读取 + console.warn('[useTaskQueue] Fallback: loading from IndexedDB'); + try { + const isAvailable = await taskStorageReader.isAvailable(); + if (!isAvailable || cancelled) { + console.warn('[useTaskQueue] Fallback: IndexedDB not available'); + setIsLoading(false); + return; } - }, retryDelay * (retryCount + 1)); - } - }; + const storedTasks = await taskStorageReader.getAllTasks(); + if (cancelled) return; - init(); + console.warn(`[useTaskQueue] Fallback: loaded ${storedTasks.length} tasks from IndexedDB`); + if (storedTasks.length > 0) { + taskQueueService.restoreTasks(storedTasks); + } + const allTasks = taskQueueService.getAllTasks(); + setTasks(allTasks); + setTotalCount(allTasks.length); + setLoadedCount(allTasks.length); + } catch (error) { + console.warn('[useTaskQueue] Fallback: IndexedDB load failed', error); + } + if (!cancelled) { + setIsLoading(false); + } + }; + loadFromDB(); + } return () => { cancelled = true; - if (retryTimer) { - clearTimeout(retryTimer); - } + subscription.unsubscribe(); }; - }, [updatePaginationState, retryCount]); + }, []); // 加载更多任务(不再需要 SW 分页,直接返回) const loadMore = useCallback(async () => { diff --git a/packages/drawnix/src/hooks/useTaskStorage.ts b/packages/drawnix/src/hooks/useTaskStorage.ts index 3499751a..9614d847 100644 --- a/packages/drawnix/src/hooks/useTaskStorage.ts +++ b/packages/drawnix/src/hooks/useTaskStorage.ts @@ -62,6 +62,7 @@ export function useTaskStorage(): void { // Wait for browser idle time to avoid blocking page load await waitForIdle(50); + console.warn('[useTaskStorage] Idle callback fired, starting init'); try { // 数据迁移:从旧 sw-task-queue 数据库迁移到 aitu-app(一次性) @@ -72,11 +73,11 @@ export function useTaskStorage(): void { // Load tasks from IndexedDB (aitu-app) const storedTasks = await taskStorageReader.getAllTasks(); - // console.log(`[useTaskStorage] Loaded ${storedTasks.length} tasks from IndexedDB`); + console.warn(`[useTaskStorage] Loaded ${storedTasks.length} tasks from IndexedDB`); if (storedTasks.length > 0 && subscriptionActive) { taskQueueService.restoreTasks(storedTasks); - // console.log(`[useTaskStorage] Restored ${storedTasks.length} tasks from storage`); + console.warn(`[useTaskStorage] Restored ${storedTasks.length} tasks to memory`); // Handle interrupted processing tasks based on task type and remoteId const processingTasks = storedTasks.filter( diff --git a/packages/drawnix/src/services/backup-restore-service.ts b/packages/drawnix/src/services/backup-restore-service.ts deleted file mode 100644 index 4b5cce55..00000000 --- a/packages/drawnix/src/services/backup-restore-service.ts +++ /dev/null @@ -1,1385 +0,0 @@ -/** - * Backup & Restore Service - * - * 提供数据备份和恢复功能 - * 支持导出提示词、项目、素材库到 ZIP 文件 - * 支持从 ZIP 文件增量导入数据(去重) - * - * ZIP 结构(与项目导入导出保持一致): - * ├── manifest.json # 备份元信息 - * ├── prompts.json # 提示词数据 - * ├── projects/ # 项目文件(与导出结构一致) - * │ ├── 文件夹名/ - * │ │ └── 画板名.drawnix # 画板数据 - * │ └── 画板名.drawnix # 根目录画板 - * └── assets/ # 素材文件 - * ├── xxx.meta.json # 素材元数据 - * ├── xxx.jpg # 素材文件(带扩展名) - * └── ... - */ - -import JSZip from 'jszip'; -import { workspaceStorageService } from './workspace-storage-service'; -import { workspaceService } from './workspace-service'; -import { kvStorageService } from './kv-storage-service'; -import { - PromptHistoryItem, - VideoPromptHistoryItem, - ImagePromptHistoryItem, - initPromptStorageCache, - resetPromptStorageCache, - getPromptHistory, - getVideoPromptHistory, - getImagePromptHistory, -} from './prompt-storage-service'; -import { taskStorageReader } from './task-storage-reader'; -import { taskQueueService } from './task-queue'; -import { TaskType, TaskStatus, Task } from '../types/task.types'; -import type { Folder, Board } from '../types/workspace.types'; -import type { StoredAsset } from '../types/asset.types'; -import type { PlaitTheme, PlaitElement as CorePlaitElement } from '@plait/core'; -import { LS_KEYS_TO_MIGRATE } from '../constants/storage-keys'; -import { DrawnixExportedType } from '../data/types'; -import { VERSIONS } from '../constants'; -import localforage from 'localforage'; -import { ASSET_CONSTANTS } from '../constants/ASSET_CONSTANTS'; -import { unifiedCacheService } from './unified-cache-service'; -import { analytics } from '../utils/posthog-analytics'; -import { knowledgeBaseService } from './knowledge-base-service'; - -// 备份文件版本 -const BACKUP_VERSION = 2; - -// 备份文件标识 -const BACKUP_SIGNATURE = 'aitu-backup'; - -/** - * 预设提示词设置 - */ -interface PresetPromptSettings { - pinnedPrompts: string[]; - deletedPrompts: string[]; -} - -interface PresetStorageData { - image: PresetPromptSettings; - video: PresetPromptSettings; -} - -/** - * 备份选项 - */ -export interface BackupOptions { - includePrompts: boolean; - includeProjects: boolean; - includeAssets: boolean; - includeKnowledgeBase: boolean; -} - -/** - * 备份时的工作区状态 - */ -export interface BackupWorkspaceState { - /** 当前画板ID */ - currentBoardId: string | null; - /** 当前画板名称(用于显示) */ - currentBoardName?: string; - /** 当前视图状态(使用 origination 格式,与 @plait/core 保持一致) */ - viewport?: { - zoom: number; - origination?: [number, number]; - }; -} - -/** - * 备份清单(manifest.json) - */ -interface BackupManifest { - signature: string; - version: number; - createdAt: number; - includes: { - prompts: boolean; - projects: boolean; - assets: boolean; - knowledgeBase: boolean; - }; - stats: { - promptCount: number; - videoPromptCount: number; - imagePromptCount: number; - folderCount: number; - boardCount: number; - assetCount: number; - taskCount: number; - kbNoteCount: number; - }; - /** 备份时的工作区状态 */ - workspaceState?: BackupWorkspaceState; -} - -/** - * 提示词数据(prompts.json) - */ -interface PromptsData { - promptHistory: PromptHistoryItem[]; - videoPromptHistory: VideoPromptHistoryItem[]; - imagePromptHistory: ImagePromptHistoryItem[]; - presetSettings: PresetStorageData; -} - -/** - * Drawnix 文件格式(与项目导出一致) - */ -interface DrawnixFileData { - type: string; - version: number; - source: string; - elements: PlaitElement[]; - viewport: Viewport; - theme?: PlaitTheme; - boardMeta?: { - id: string; - name: string; - folderId: string | null; - order: number; - createdAt: number; - updatedAt: number; - }; -} - -interface Viewport { - zoom: number; - origination?: [number, number]; -} - -interface PlaitElement { - id?: string; - type?: string; - assetId?: string; - imageAssetId?: string; - videoAssetId?: string; - children?: PlaitElement[]; - [key: string]: unknown; -} - -/** - * 确保所有元素都有 id(为没有 id 的元素生成一个) - * 返回类型兼容 @plait/core 的 PlaitElement[] - */ -function ensureElementIds(elements: PlaitElement[]): CorePlaitElement[] { - return elements.map(el => { - if (!el.id) { - return { ...el, id: `imported-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } as CorePlaitElement; - } - return el as CorePlaitElement; - }); -} - -/** - * 导入结果 - */ -export interface ImportResult { - success: boolean; - prompts: { - imported: number; - skipped: number; - }; - projects: { - folders: number; - boards: number; - /** 合并的画板数量(相同ID的画板) */ - merged: number; - skipped: number; - }; - assets: { - imported: number; - skipped: number; - }; - tasks: { - imported: number; - skipped: number; - }; - knowledgeBase: { - directories: number; - notes: number; - tags: number; - skipped: number; - }; - errors: string[]; - /** 备份时的工作区状态(用于恢复画布位置) */ - workspaceState?: BackupWorkspaceState; -} - -/** - * 进度回调 - */ -export type ProgressCallback = (progress: number, message: string) => void; - -/** - * 获取文件扩展名 - */ -function getExtensionFromMimeType(mimeType: string): string { - const mimeToExt: Record = { - 'image/jpeg': '.jpg', - 'image/jpg': '.jpg', - 'image/png': '.png', - 'image/gif': '.gif', - 'image/webp': '.webp', - 'image/svg+xml': '.svg', - 'video/mp4': '.mp4', - 'video/webm': '.webm', - 'video/quicktime': '.mov', - }; - return mimeToExt[mimeType] || ''; -} - -/** - * 清理文件/文件夹名称 - */ -function sanitizeFileName(name: string): string { - return ( - name - .replace(/[<>:"/\\|?*]/g, '_') - .replace(/\s+/g, ' ') - .trim() || 'unnamed' - ); -} - -/** - * Backup Restore Service - */ -class BackupRestoreService { - /** - * 导出数据到 ZIP 文件 - */ - async exportToZip( - options: BackupOptions, - onProgress?: ProgressCallback - ): Promise { - const startTime = Date.now(); - - // 埋点:备份导出开始 - analytics.track('backup_export_start', { - includePrompts: options.includePrompts, - includeProjects: options.includeProjects, - includeAssets: options.includeAssets, - }); - - const zip = new JSZip(); - - onProgress?.(5, '正在准备数据...'); - - // 获取当前工作区状态 - const currentBoard = workspaceService.getCurrentBoard(); - const workspaceState: BackupWorkspaceState = { - currentBoardId: currentBoard?.id || null, - currentBoardName: currentBoard?.name, - viewport: currentBoard?.viewport, - }; - - const manifest: BackupManifest = { - signature: BACKUP_SIGNATURE, - version: BACKUP_VERSION, - createdAt: Date.now(), - includes: { - prompts: options.includePrompts, - projects: options.includeProjects, - assets: options.includeAssets, - knowledgeBase: options.includeKnowledgeBase, - }, - stats: { - promptCount: 0, - videoPromptCount: 0, - imagePromptCount: 0, - folderCount: 0, - boardCount: 0, - assetCount: 0, - taskCount: 0, - kbNoteCount: 0, - }, - workspaceState, - }; - - // 导出提示词 - if (options.includePrompts) { - onProgress?.(10, '正在导出提示词...'); - const promptsData = await this.collectPromptData(); - zip.file('prompts.json', JSON.stringify(promptsData, null, 2)); - manifest.stats.promptCount = promptsData.promptHistory.length; - manifest.stats.videoPromptCount = promptsData.videoPromptHistory.length; - manifest.stats.imagePromptCount = promptsData.imagePromptHistory.length; - } - - // 导出知识库 - if (options.includeKnowledgeBase) { - onProgress?.(30, '正在导出知识库...'); - try { - const kbData = await knowledgeBaseService.exportAllData(); - zip.file('knowledge-base.json', JSON.stringify(kbData, null, 2)); - manifest.stats.kbNoteCount = kbData.notes.length; - } catch (error) { - console.error('Failed to export knowledge base:', error); - } - } - - // 导出项目 - if (options.includeProjects) { - onProgress?.(40, '正在导出项目...'); - await this.exportProjects(zip, manifest, onProgress); - } - - // 导出素材 - if (options.includeAssets) { - onProgress?.(50, '正在导出素材...'); - await this.exportAssets(zip, manifest, onProgress); - } - - onProgress?.(80, '正在写入清单...'); - - // 写入清单文件 - zip.file('manifest.json', JSON.stringify(manifest, null, 2)); - - onProgress?.(85, '正在压缩文件...'); - - // 生成 ZIP - const blob = await zip.generateAsync( - { - type: 'blob', - compression: 'DEFLATE', - compressionOptions: { level: 6 }, - }, - (metadata: { percent: number; currentFile: string | null }) => { - const zipProgress = 85 + Math.round(metadata.percent * 0.14); - onProgress?.(zipProgress, '正在压缩...'); - } - ); - - onProgress?.(100, '导出完成'); - - // 埋点:备份导出成功 - analytics.track('backup_export_success', { - duration: Date.now() - startTime, - fileSize: blob.size, - promptCount: manifest.stats.promptCount + manifest.stats.videoPromptCount + manifest.stats.imagePromptCount, - projectCount: manifest.stats.boardCount, - assetCount: manifest.stats.assetCount, - taskCount: manifest.stats.taskCount, - }); - - return blob; - } - - /** - * 导入知识库数据 - */ - private async importKnowledgeBase(zip: JSZip): Promise<{ - directories: number; - notes: number; - tags: number; - skipped: number; - errors: string[]; - }> { - const kbFile = zip.file('knowledge-base.json'); - if (!kbFile) { - return { directories: 0, notes: 0, tags: 0, skipped: 0, errors: [] }; - } - - try { - const content = await kbFile.async('string'); - const data = JSON.parse(content); - const result = await knowledgeBaseService.importAllData(data); - - return { - directories: result.dirCount, - notes: result.noteCount, - tags: result.tagCount, - skipped: 0, - errors: [], - }; - } catch (error) { - console.error('Failed to import knowledge base:', error); - return { - directories: 0, - notes: 0, - tags: 0, - skipped: 0, - errors: [`Knowledge base import failed: ${error instanceof Error ? error.message : String(error)}`], - }; - } - } - - /** - * 导出项目数据 - */ - private async exportProjects( - zip: JSZip, - manifest: BackupManifest, - onProgress?: ProgressCallback - ): Promise { - const projectsFolder = zip.folder('projects'); - if (!projectsFolder) return; - - const [folders, boards] = await Promise.all([ - workspaceStorageService.loadAllFolders(), - workspaceStorageService.loadAllBoards(), - ]); - - // 构建文件夹路径映射 - const folderPathMap = this.buildFolderPathMap(folders); - - // 创建文件夹结构 - for (const folder of folders) { - const path = folderPathMap.get(folder.id) || folder.name; - projectsFolder.folder(path); - } - manifest.stats.folderCount = folders.length; - - // 每个画板导出为 .drawnix 文件 - for (let i = 0; i < boards.length; i++) { - const board = boards[i]; - const folderPath = board.folderId - ? folderPathMap.get(board.folderId) - : null; - const safeName = sanitizeFileName(board.name); - const boardPath = folderPath - ? `${folderPath}/${safeName}.drawnix` - : `${safeName}.drawnix`; - - const drawnixData: DrawnixFileData = { - type: DrawnixExportedType.drawnix, - version: VERSIONS.drawnix, - source: 'backup', - elements: board.elements || [], - viewport: board.viewport || { zoom: 1 }, - theme: board.theme, - boardMeta: { - id: board.id, - name: board.name, - folderId: board.folderId, - order: board.order, - createdAt: board.createdAt, - updatedAt: board.updatedAt, - }, - }; - - projectsFolder.file(boardPath, JSON.stringify(drawnixData, null, 2)); - - if (onProgress && boards.length > 0) { - const progress = 25 + Math.round(((i + 1) / boards.length) * 20); - onProgress(progress, `正在导出画板 (${i + 1}/${boards.length})...`); - } - } - manifest.stats.boardCount = boards.length; - } - - /** - * 构建文件夹路径映射 - */ - private buildFolderPathMap(folders: Folder[]): Map { - const pathMap = new Map(); - const folderMap = new Map(); - - for (const folder of folders) { - folderMap.set(folder.id, folder); - } - - const getPath = (folderId: string): string => { - if (pathMap.has(folderId)) { - return pathMap.get(folderId)!; - } - - const folder = folderMap.get(folderId); - if (!folder) { - return ''; - } - - const safeName = sanitizeFileName(folder.name); - if (folder.parentId) { - const parentPath = getPath(folder.parentId); - const fullPath = parentPath ? `${parentPath}/${safeName}` : safeName; - pathMap.set(folderId, fullPath); - return fullPath; - } - - pathMap.set(folderId, safeName); - return safeName; - }; - - for (const folder of folders) { - getPath(folder.id); - } - - return pathMap; - } - - /** - * 导出素材数据 - * 包括: - * 1. 本地素材库中的素材(从 localforage asset store) - * 2. URL 缓存的媒体(从 unified-cache-service) - */ - private async exportAssets( - zip: JSZip, - manifest: BackupManifest, - onProgress?: ProgressCallback - ): Promise { - const assetsFolder = zip.folder('assets'); - if (!assetsFolder) return; - - // 用于跟踪已导出的 URL,避免重复 - const exportedUrls = new Set(); - let exportedCount = 0; - - // 1. 导出本地素材库中的素材 - const store = localforage.createInstance({ - name: ASSET_CONSTANTS.STORAGE_NAME, - storeName: ASSET_CONSTANTS.STORE_NAME, - }); - - const assetKeys = await store.keys(); - const totalItems = assetKeys.length; - - for (let i = 0; i < assetKeys.length; i++) { - try { - const stored = await store.getItem(assetKeys[i]); - if (stored) { - // 写入元数据文件 - assetsFolder.file( - `${stored.id}.meta.json`, - JSON.stringify(stored, null, 2) - ); - - // 从统一缓存服务获取媒体 Blob 数据 - const blobData = await unifiedCacheService.getCachedBlob(stored.url); - if (blobData) { - const ext = getExtensionFromMimeType(stored.mimeType); - assetsFolder.file(`${stored.id}${ext}`, blobData); - exportedUrls.add(stored.url); - exportedCount++; - } - } - } catch (error) { - console.warn( - `[BackupRestore] Failed to load asset ${assetKeys[i]}:`, - error - ); - } - - if (onProgress && totalItems > 0) { - const progress = 50 + Math.round(((i + 1) / totalItems) * 15); - onProgress(progress, `正在导出本地素材 (${i + 1}/${totalItems})...`); - } - } - - // 2. 导出 unified-cache 中的缓存媒体(AI 生成的图片/视频) - const cachedMedia = await unifiedCacheService.getAllCacheMetadata(); - const cacheItems = cachedMedia.filter(item => !exportedUrls.has(item.url)); - const cacheTotal = cacheItems.length; - - for (let i = 0; i < cacheItems.length; i++) { - const item = cacheItems[i]; - try { - // 生成唯一 ID(使用 taskId 或 URL hash) - const itemId = item.metadata?.taskId || this.generateIdFromUrl(item.url); - - // 写入元数据文件(转换为 StoredAsset 兼容格式) - const metaData = { - id: itemId, - url: item.url, - type: item.type === 'video' ? 'VIDEO' : 'IMAGE', - mimeType: item.mimeType, - size: item.size, - source: 'AI_GENERATED', - createdAt: item.cachedAt, - updatedAt: item.lastUsed, - metadata: item.metadata, - }; - assetsFolder.file(`${itemId}.meta.json`, JSON.stringify(metaData, null, 2)); - - // 获取媒体 Blob 数据 - const blobData = await unifiedCacheService.getCachedBlob(item.url); - if (blobData) { - const ext = getExtensionFromMimeType(item.mimeType); - assetsFolder.file(`${itemId}${ext}`, blobData); - exportedUrls.add(item.url); - exportedCount++; - } - } catch (error) { - console.warn( - `[BackupRestore] Failed to export cached media ${item.url}:`, - error - ); - } - - if (onProgress && cacheTotal > 0) { - const progress = 65 + Math.round(((i + 1) / cacheTotal) * 10); - onProgress(progress, `正在导出缓存媒体 (${i + 1}/${cacheTotal})...`); - } - } - - manifest.stats.assetCount = exportedCount; - - // 3. 导出任务数据(用于素材库展示) - onProgress?.(75, '正在导出任务数据...'); - // 从 IndexedDB 获取所有任务 - const allTasks = await taskStorageReader.getAllTasks(); - // 只导出已完成的图片/视频任务(素材库需要的) - const completedMediaTasks = allTasks.filter( - task => - task.status === TaskStatus.COMPLETED && - (task.type === TaskType.IMAGE || task.type === TaskType.VIDEO) && - task.result?.url - ); - - if (completedMediaTasks.length > 0) { - zip.file('tasks.json', JSON.stringify(completedMediaTasks, null, 2)); - manifest.stats.taskCount = completedMediaTasks.length; - } - } - - /** - * 从 URL 生成唯一 ID - */ - private generateIdFromUrl(url: string): string { - // 使用简单的 hash 算法生成 ID - let hash = 0; - for (let i = 0; i < url.length; i++) { - const char = url.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - return `cache-${Math.abs(hash).toString(36)}`; - } - - /** - * 从 ZIP 文件导入数据(增量去重) - */ - async importFromZip( - file: File, - onProgress?: ProgressCallback - ): Promise { - const startTime = Date.now(); - - // 埋点:备份导入开始 - analytics.track('backup_import_start', { - fileSize: file.size, - fileName: file.name, - }); - - const result: ImportResult = { - success: false, - prompts: { imported: 0, skipped: 0 }, - projects: { folders: 0, boards: 0, merged: 0, skipped: 0 }, - assets: { imported: 0, skipped: 0 }, - tasks: { imported: 0, skipped: 0 }, - knowledgeBase: { directories: 0, notes: 0, tags: 0, skipped: 0 }, - errors: [], - }; - - try { - onProgress?.(5, '正在读取文件...'); - const zip = await JSZip.loadAsync(file); - - onProgress?.(10, '正在验证文件格式...'); - - // 读取清单 - const manifestFile = zip.file('manifest.json'); - if (!manifestFile) { - throw new Error('无效的备份文件:未找到 manifest.json'); - } - - const manifestContent = await manifestFile.async('string'); - const manifest: BackupManifest = JSON.parse(manifestContent); - - if (manifest.signature !== BACKUP_SIGNATURE) { - throw new Error('无效的备份文件:签名不匹配'); - } - - // 导入提示词 - if (manifest.includes.prompts) { - onProgress?.(20, '正在导入提示词...'); - const promptsFile = zip.file('prompts.json'); - if (promptsFile) { - const promptsContent = await promptsFile.async('string'); - const promptsData: PromptsData = JSON.parse(promptsContent); - result.prompts = await this.importPromptData(promptsData); - } - } - - // 导入知识库 - if (manifest.includes.knowledgeBase) { - onProgress?.(30, '正在导入知识库...'); - const kbResult = await this.importKnowledgeBase(zip); - result.knowledgeBase = kbResult; - if (kbResult.errors && kbResult.errors.length > 0) { - result.errors.push(...kbResult.errors); - } - } - - // 导入项目 - if (manifest.includes.projects) { - onProgress?.(40, '正在导入项目...'); - result.projects = await this.importProjects(zip, onProgress); - } - - // 导入素材 - if (manifest.includes.assets) { - onProgress?.(60, '正在导入素材...'); - result.assets = await this.importAssets(zip, onProgress); - - // 导入任务数据(素材库展示需要) - onProgress?.(85, '正在导入任务数据...'); - result.tasks = await this.importTasks(zip); - } - - // 如果导入了项目,刷新工作区缓存 - if (result.projects.folders > 0 || result.projects.boards > 0) { - await workspaceService.reload(); - } - - // 返回备份时的工作区状态(用于恢复画布位置) - if (manifest.workspaceState) { - result.workspaceState = manifest.workspaceState; - } - - result.success = result.errors.length === 0; - onProgress?.(100, '导入完成'); - - // 埋点:备份导入成功 - analytics.track('backup_import_success', { - duration: Date.now() - startTime, - promptCount: result.prompts.imported, - projectCount: result.projects.boards, - assetCount: result.assets.imported, - taskCount: result.tasks.imported, - kbNoteCount: result.knowledgeBase.notes, - skippedCount: - result.prompts.skipped + - result.projects.skipped + - result.assets.skipped + - result.tasks.skipped + - result.knowledgeBase.skipped, - }); - } catch (error: unknown) { - const errorMessage = - error instanceof Error ? error.message : String(error); - result.errors.push(errorMessage); - - // 埋点:备份导入失败 - analytics.track('backup_import_failed', { - duration: Date.now() - startTime, - error: errorMessage, - }); - } - - return result; - } - - /** - * 下载 ZIP 文件 - */ - downloadZip(blob: Blob, filename?: string): void { - const date = new Date(); - const dateStr = date.toISOString().split('T')[0]; - const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, ''); - const defaultFilename = `aitu_backup_${dateStr}_${timeStr}.zip`; - - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename || defaultFilename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - - // ========== 私有方法:数据收集 ========== - - /** - * 收集提示词数据 - * 合并两个来源: - * 1. IndexedDB 中的提示词历史(aitu_image_prompt_history / aitu_video_prompt_history) - * 2. 任务队列中已完成任务的提示词 - */ - private async collectPromptData(): Promise { - await initPromptStorageCache(); - - const promptHistory = getPromptHistory(); - let videoPromptHistory = getVideoPromptHistory(); - let imagePromptHistory = getImagePromptHistory(); - - // 获取所有已完成任务的提示词 - const completedTasks = await taskStorageReader.getAllTasks({ status: TaskStatus.COMPLETED }); - - // 提取图片任务的提示词 - const imageTaskPrompts = completedTasks - .filter(task => task.type === TaskType.IMAGE && task.params?.prompt) - .map(task => ({ - id: `task_${task.id}`, - content: task.params.prompt.trim(), - timestamp: task.completedAt || task.createdAt, - })) - .filter(item => item.content.length > 0); - - // 提取视频任务的提示词 - const videoTaskPrompts = completedTasks - .filter(task => task.type === TaskType.VIDEO && task.params?.prompt) - .map(task => ({ - id: `task_${task.id}`, - content: task.params.prompt.trim(), - timestamp: task.completedAt || task.createdAt, - })) - .filter(item => item.content.length > 0); - - // 合并图片提示词(去重) - const existingImageContents = new Set(imagePromptHistory.map(p => p.content)); - const newImagePrompts = imageTaskPrompts.filter(p => !existingImageContents.has(p.content)); - imagePromptHistory = [...imagePromptHistory, ...newImagePrompts]; - - // 合并视频提示词(去重) - const existingVideoContents = new Set(videoPromptHistory.map(p => p.content)); - const newVideoPrompts = videoTaskPrompts.filter(p => !existingVideoContents.has(p.content)); - videoPromptHistory = [...videoPromptHistory, ...newVideoPrompts]; - - const presetSettings = await kvStorageService.get( - LS_KEYS_TO_MIGRATE.PRESET_SETTINGS - ); - - return { - promptHistory, - videoPromptHistory, - imagePromptHistory, - presetSettings: presetSettings || { - image: { pinnedPrompts: [], deletedPrompts: [] }, - video: { pinnedPrompts: [], deletedPrompts: [] }, - }, - }; - } - - // ========== 私有方法:导入 ========== - - /** - * 导入提示词数据(增量去重) - */ - private async importPromptData( - data: PromptsData - ): Promise<{ imported: number; skipped: number }> { - let imported = 0; - let skipped = 0; - - // 防御性检查:确保数据存在 - const inputPromptHistory = data.promptHistory || []; - const inputVideoPromptHistory = data.videoPromptHistory || []; - const inputImagePromptHistory = data.imagePromptHistory || []; - - // 获取现有数据 - await initPromptStorageCache(); - const existingPrompts = getPromptHistory(); - const existingVideoPrompts = getVideoPromptHistory(); - const existingImagePrompts = getImagePromptHistory(); - - // 去重合并提示词历史(AI 输入框) - const existingPromptIds = new Set(existingPrompts.map((p) => p.id)); - const existingPromptContents = new Set( - existingPrompts.map((p) => p.content) - ); - const newPrompts = inputPromptHistory.filter((p) => { - if ( - existingPromptIds.has(p.id) || - existingPromptContents.has(p.content) - ) { - skipped++; - return false; - } - imported++; - return true; - }); - - // 去重合并视频提示词历史 - const existingVideoIds = new Set(existingVideoPrompts.map((p) => p.id)); - const existingVideoContents = new Set( - existingVideoPrompts.map((p) => p.content) - ); - const newVideoPrompts = inputVideoPromptHistory.filter((p) => { - if (existingVideoIds.has(p.id) || existingVideoContents.has(p.content)) { - skipped++; - return false; - } - imported++; - return true; - }); - - // 去重合并图片提示词历史 - const existingImageIds = new Set(existingImagePrompts.map((p) => p.id)); - const existingImageContents = new Set( - existingImagePrompts.map((p) => p.content) - ); - const newImagePrompts = inputImagePromptHistory.filter((p) => { - if (existingImageIds.has(p.id) || existingImageContents.has(p.content)) { - skipped++; - return false; - } - imported++; - return true; - }); - - // 合并并保存 - const mergedPrompts = [...existingPrompts, ...newPrompts]; - const mergedVideoPrompts = [...existingVideoPrompts, ...newVideoPrompts]; - const mergedImagePrompts = [...existingImagePrompts, ...newImagePrompts]; - - await kvStorageService.set( - LS_KEYS_TO_MIGRATE.PROMPT_HISTORY, - mergedPrompts - ); - await kvStorageService.set( - LS_KEYS_TO_MIGRATE.VIDEO_PROMPT_HISTORY, - mergedVideoPrompts - ); - await kvStorageService.set( - LS_KEYS_TO_MIGRATE.IMAGE_PROMPT_HISTORY, - mergedImagePrompts - ); - - // 合并预设设置 - const existingPreset = await kvStorageService.get( - LS_KEYS_TO_MIGRATE.PRESET_SETTINGS - ); - if (existingPreset && data.presetSettings) { - const mergedPreset: PresetStorageData = { - image: { - pinnedPrompts: [ - ...new Set([ - ...existingPreset.image.pinnedPrompts, - ...data.presetSettings.image.pinnedPrompts, - ]), - ], - deletedPrompts: [ - ...new Set([ - ...existingPreset.image.deletedPrompts, - ...data.presetSettings.image.deletedPrompts, - ]), - ], - }, - video: { - pinnedPrompts: [ - ...new Set([ - ...existingPreset.video.pinnedPrompts, - ...data.presetSettings.video.pinnedPrompts, - ]), - ], - deletedPrompts: [ - ...new Set([ - ...existingPreset.video.deletedPrompts, - ...data.presetSettings.video.deletedPrompts, - ]), - ], - }, - }; - await kvStorageService.set( - LS_KEYS_TO_MIGRATE.PRESET_SETTINGS, - mergedPreset - ); - } else if (data.presetSettings) { - await kvStorageService.set( - LS_KEYS_TO_MIGRATE.PRESET_SETTINGS, - data.presetSettings - ); - } - - // 重置缓存,强制从 IndexedDB 重新加载数据 - await resetPromptStorageCache(); - - return { imported, skipped }; - } - - /** - * 导入项目数据 - */ - private async importProjects( - zip: JSZip, - onProgress?: ProgressCallback - ): Promise<{ folders: number; boards: number; merged: number; skipped: number }> { - let foldersImported = 0; - let boardsImported = 0; - let boardsMerged = 0; - let skipped = 0; - - // 获取现有数据 - const existingFolders = await workspaceStorageService.loadAllFolders(); - const existingBoards = await workspaceStorageService.loadAllBoards(); - const existingBoardIds = new Set(existingBoards.map((b) => b.id)); - - // 查找所有 .drawnix 文件 - const drawnixFiles = Object.keys(zip.files).filter( - (name) => - name.startsWith('projects/') && - name.endsWith('.drawnix') && - !name.includes('/_') - ); - - if (drawnixFiles.length === 0) { - return { folders: 0, boards: 0, merged: 0, skipped: 0 }; - } - - // 从目录结构推断文件夹 - const folderPaths = new Set(); - const folderIdMap = new Map(); - - for (const filePath of drawnixFiles) { - const relativePath = filePath.replace(/^projects\//, ''); - const parts = relativePath.split('/'); - if (parts.length > 1) { - let currentPath = ''; - for (let i = 0; i < parts.length - 1; i++) { - currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; - folderPaths.add(currentPath); - } - } - } - - // 按深度排序文件夹路径 - const sortedPaths = Array.from(folderPaths).sort((a, b) => { - const depthA = a.split('/').length; - const depthB = b.split('/').length; - return depthA - depthB || a.localeCompare(b); - }); - - // 导入文件夹 - for (let i = 0; i < sortedPaths.length; i++) { - const folderPath = sortedPaths[i]; - const parts = folderPath.split('/'); - const folderName = parts[parts.length - 1]; - const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : null; - const parentId = parentPath ? folderIdMap.get(parentPath) || null : null; - - // 生成新的文件夹 ID - const folderId = this.generateId(); - folderIdMap.set(folderPath, folderId); - - // 检查是否已存在同名文件夹(简单去重) - const existingFolder = existingFolders.find( - (f) => f.name === folderName && f.parentId === parentId - ); - if (existingFolder) { - folderIdMap.set(folderPath, existingFolder.id); - skipped++; - continue; - } - - const folder: Folder = { - id: folderId, - name: folderName, - parentId, - order: i, - isExpanded: true, - createdAt: Date.now(), - updatedAt: Date.now(), - }; - await workspaceStorageService.saveFolder(folder); - foldersImported++; - } - - // 导入画板 - for (let i = 0; i < drawnixFiles.length; i++) { - const filePath = drawnixFiles[i]; - try { - const drawnixFile = zip.file(filePath); - if (drawnixFile) { - const drawnixContent = await drawnixFile.async('string'); - const drawnixData: DrawnixFileData = JSON.parse(drawnixContent); - - const boardMeta = drawnixData.boardMeta; - - // 推断文件夹 ID - const relativePath = filePath.replace(/^projects\//, ''); - const parts = relativePath.split('/'); - const folderPath = - parts.length > 1 ? parts.slice(0, -1).join('/') : null; - const folderId = folderPath - ? folderIdMap.get(folderPath) || boardMeta?.folderId || null - : null; - - const fileName = parts[parts.length - 1] || 'unnamed.drawnix'; - const boardName = boardMeta?.name || fileName.replace('.drawnix', ''); - - // 检查是否已存在相同 ID 的画板 - if (boardMeta?.id && existingBoardIds.has(boardMeta.id)) { - // 找到现有画板进行合并 - const existingBoard = existingBoards.find(b => b.id === boardMeta.id); - if (existingBoard) { - // 合并元素:保留现有元素,添加备份中新增的元素(基于ID去重) - const existingElementIds = new Set( - (existingBoard.elements || []) - .map(el => el.id) - .filter((id): id is string => !!id) - ); - - const backupElements = drawnixData.elements || []; - // 过滤出备份中的新元素: - // - 有 ID 的元素:只保留现有画板中不存在的 - // - 无 ID 的元素:全部保留(不会与现有元素冲突) - const newElements = backupElements.filter(el => - el.id ? !existingElementIds.has(el.id) : true - ); - - // 合并后的元素:现有元素 + 备份中新增的元素 - // 使用 ensureElementIds 确保新元素都有 id - const mergedElements = [ - ...(existingBoard.elements || []), - ...ensureElementIds(newElements), - ]; - - // 合并画板数据 - const mergedBoard: Board = { - ...existingBoard, - // 合并后的元素(图形可以多,但不能少) - elements: mergedElements, - // 使用备份的 viewport(恢复视图位置) - viewport: drawnixData.viewport || existingBoard.viewport, - // 使用备份的 theme - theme: drawnixData.theme || existingBoard.theme, - // 更新时间 - updatedAt: Date.now(), - }; - - await workspaceStorageService.saveBoard(mergedBoard); - boardsMerged++; - continue; - } - } - - const board: Board = { - id: boardMeta?.id || this.generateId(), - name: boardName, - folderId, - order: boardMeta?.order ?? i, - elements: ensureElementIds(drawnixData.elements || []), - viewport: drawnixData.viewport, - theme: drawnixData.theme, - createdAt: boardMeta?.createdAt || Date.now(), - updatedAt: boardMeta?.updatedAt || Date.now(), - }; - - await workspaceStorageService.saveBoard(board); - boardsImported++; - } - } catch (error) { - console.warn( - `[BackupRestore] Failed to import board ${filePath}:`, - error - ); - } - - if (onProgress && drawnixFiles.length > 0) { - const progress = 40 + Math.round(((i + 1) / drawnixFiles.length) * 15); - onProgress( - progress, - `正在导入画板 (${i + 1}/${drawnixFiles.length})...` - ); - } - } - - return { folders: foldersImported, boards: boardsImported, merged: boardsMerged, skipped }; - } - - /** - * 导入素材数据 - * 包括: - * 1. 本地素材库的素材(保存到 localforage + unified-cache) - * 2. AI 生成的缓存媒体(只保存到 unified-cache) - */ - private async importAssets( - zip: JSZip, - onProgress?: ProgressCallback - ): Promise<{ imported: number; skipped: number }> { - let imported = 0; - let skipped = 0; - - // 查找所有素材元数据文件 - const metaFiles = Object.keys(zip.files).filter( - (name) => name.startsWith('assets/') && name.endsWith('.meta.json') - ); - - if (metaFiles.length === 0) { - return { imported: 0, skipped: 0 }; - } - - const store = localforage.createInstance({ - name: ASSET_CONSTANTS.STORAGE_NAME, - storeName: ASSET_CONSTANTS.STORE_NAME, - }); - - // 获取现有素材 ID 和已缓存的 URL - const existingKeys = await store.keys(); - const existingIds = new Set(existingKeys); - const existingCacheUrls = new Set(await unifiedCacheService.getAllCachedUrls()); - - for (let i = 0; i < metaFiles.length; i++) { - const metaPath = metaFiles[i]; - const assetId = metaPath.replace('assets/', '').replace('.meta.json', ''); - - try { - const metaFile = zip.file(metaPath); - if (!metaFile) { - skipped++; - continue; - } - - const metaContent = await metaFile.async('string'); - const metadata = JSON.parse(metaContent); - - // 判断是否为 AI 生成的缓存媒体 - const isAIGenerated = metadata.source === 'AI_GENERATED'; - - // 检查是否已存在 - if (isAIGenerated) { - // AI 生成的媒体:检查 URL 是否已在缓存中 - if (existingCacheUrls.has(metadata.url)) { - skipped++; - continue; - } - } else { - // 本地素材:检查 ID 是否已存在 - if (existingIds.has(assetId)) { - skipped++; - continue; - } - } - - // 查找对应的媒体文件(尝试多种扩展名) - const possibleExtensions = [ - '.jpg', - '.png', - '.gif', - '.webp', - '.mp4', - '.webm', - '.mov', - '.svg', - ]; - let blobData: Blob | null = null; - - for (const ext of possibleExtensions) { - const blobFile = zip.file(`assets/${assetId}${ext}`); - if (blobFile) { - blobData = await blobFile.async('blob'); - break; - } - } - - // 如果没找到带扩展名的,尝试根据 mimeType 查找 - if (!blobData && metadata.mimeType) { - const ext = getExtensionFromMimeType(metadata.mimeType); - if (ext) { - const blobFile = zip.file(`assets/${assetId}${ext}`); - if (blobFile) { - blobData = await blobFile.async('blob'); - } - } - } - - if (blobData) { - // 将媒体文件存入统一缓存服务 - const cacheType = metadata.type === 'VIDEO' ? 'video' : 'image'; - await unifiedCacheService.cacheMediaFromBlob( - metadata.url, - blobData, - cacheType as 'image' | 'video', - { - taskId: metadata.metadata?.taskId || assetId, - prompt: metadata.metadata?.prompt, - model: metadata.metadata?.model, - } - ); - - // 只有本地素材才保存到 localforage asset store - if (!isAIGenerated) { - await store.setItem(assetId, metadata); - } - - imported++; - } else { - // 没有媒体文件,跳过(只有元数据没有意义) - console.warn( - `[BackupRestore] No media file found for asset ${assetId}, skipping` - ); - skipped++; - } - } catch (error) { - console.warn( - `[BackupRestore] Failed to import asset ${assetId}:`, - error - ); - skipped++; - } - - if (onProgress && metaFiles.length > 0) { - const progress = 60 + Math.round(((i + 1) / metaFiles.length) * 35); - onProgress(progress, `正在导入素材 (${i + 1}/${metaFiles.length})...`); - } - } - - return { imported, skipped }; - } - - /** - * 导入任务数据 - * 用于恢复素材库中 AI 生成素材的展示 - */ - private async importTasks( - zip: JSZip - ): Promise<{ imported: number; skipped: number }> { - let imported = 0; - let skipped = 0; - - const tasksFile = zip.file('tasks.json'); - if (!tasksFile) { - // 旧版备份文件可能没有 tasks.json,这是正常的 - return { imported: 0, skipped: 0 }; - } - - try { - const tasksContent = await tasksFile.async('string'); - const tasks: Task[] = JSON.parse(tasksContent); - - if (!Array.isArray(tasks) || tasks.length === 0) { - return { imported: 0, skipped: 0 }; - } - - // 获取所有现有任务 ID - const existingTasks = await taskStorageReader.getAllTasks(); - const existingTaskIds = new Set(existingTasks.map(t => t.id)); - - // 过滤出需要导入的任务(去重) - const tasksToImport = tasks.filter(task => { - if (existingTaskIds.has(task.id)) { - skipped++; - return false; - } - return true; - }); - - if (tasksToImport.length > 0) { - // 使用 restoreTasks 方法恢复任务 - await taskQueueService.restoreTasks(tasksToImport); - imported = tasksToImport.length; - } - } catch (error) { - console.warn('[BackupRestore] Failed to import tasks:', error); - } - - return { imported, skipped }; - } - - /** - * 生成唯一 ID - */ - private generateId(): string { - return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 11)}`; - } -} - -export const backupRestoreService = new BackupRestoreService(); diff --git a/packages/drawnix/src/services/backup-restore/backup-export-service.ts b/packages/drawnix/src/services/backup-restore/backup-export-service.ts new file mode 100644 index 00000000..0d1331e1 --- /dev/null +++ b/packages/drawnix/src/services/backup-restore/backup-export-service.ts @@ -0,0 +1,409 @@ +/** + * Backup Export Service + * 导出数据到 ZIP 文件,支持自动分片 + */ + +import { workspaceStorageService } from '../workspace-storage-service'; +import { workspaceService } from '../workspace-service'; +import { kvStorageService } from '../kv-storage-service'; +import { + initPromptStorageCache, + getPromptHistory, + getVideoPromptHistory, + getImagePromptHistory, +} from '../prompt-storage-service'; +import { taskStorageReader } from '../task-storage-reader'; +import { TaskType, TaskStatus } from '../../types/task.types'; +import type { Folder } from '../../types/workspace.types'; +import { LS_KEYS_TO_MIGRATE } from '../../constants/storage-keys'; +import { DrawnixExportedType } from '../../data/types'; +import { VERSIONS } from '../../constants'; +import localforage from 'localforage'; +import { ASSET_CONSTANTS } from '../../constants/ASSET_CONSTANTS'; +import { unifiedCacheService } from '../unified-cache-service'; +import { analytics } from '../../utils/posthog-analytics'; +import { knowledgeBaseService } from '../knowledge-base-service'; +import { BackupPartManager } from './backup-part-manager'; +import { + BACKUP_VERSION, + BACKUP_SIGNATURE, + BackupManifest, + BackupWorkspaceState, + BackupOptions, + PromptsData, + PresetStorageData, + DrawnixFileData, + ExportResult, + ProgressCallback, +} from './types'; +import { + sanitizeFileName, + getExtensionFromMimeType, + generateIdFromUrl, +} from './backup-utils'; + +class BackupExportService { + /** + * 导出数据到 ZIP 文件(内部自动下载分片) + */ + async exportToZip( + options: BackupOptions, + onProgress?: ProgressCallback + ): Promise { + const startTime = Date.now(); + + analytics.track('backup_export_start', { + includePrompts: options.includePrompts, + includeProjects: options.includeProjects, + includeAssets: options.includeAssets, + }); + + onProgress?.(5, '正在准备数据...'); + + // 生成备份文件名和 backupId + const date = new Date(); + const dateStr = date.toISOString().split('T')[0]; + const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, ''); + const baseFilename = `aitu_backup_${dateStr}_${timeStr}`; + const backupId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + + const partManager = new BackupPartManager(baseFilename, backupId); + + // 获取当前工作区状态 + const currentBoard = workspaceService.getCurrentBoard(); + const workspaceState: BackupWorkspaceState = { + currentBoardId: currentBoard?.id || null, + currentBoardName: currentBoard?.name, + viewport: currentBoard?.viewport, + }; + + const manifest: BackupManifest = { + signature: BACKUP_SIGNATURE, + version: BACKUP_VERSION, + createdAt: Date.now(), + includes: { + prompts: options.includePrompts, + projects: options.includeProjects, + assets: options.includeAssets, + knowledgeBase: options.includeKnowledgeBase, + }, + stats: { + promptCount: 0, videoPromptCount: 0, imagePromptCount: 0, + folderCount: 0, boardCount: 0, assetCount: 0, taskCount: 0, kbNoteCount: 0, + }, + workspaceState, + }; + + // 导出提示词(非素材,放入 Part1) + if (options.includePrompts) { + onProgress?.(10, '正在导出提示词...'); + const promptsData = await this.collectPromptData(); + partManager.addFile('prompts.json', promptsData); + manifest.stats.promptCount = promptsData.promptHistory.length; + manifest.stats.videoPromptCount = promptsData.videoPromptHistory.length; + manifest.stats.imagePromptCount = promptsData.imagePromptHistory.length; + } + // 导出知识库(非素材,放入 Part1) + if (options.includeKnowledgeBase) { + onProgress?.(30, '正在导出知识库...'); + try { + const kbData = await knowledgeBaseService.exportAllData(); + partManager.addFile('knowledge-base.json', kbData); + manifest.stats.kbNoteCount = kbData.notes.length; + } catch (error) { + console.error('Failed to export knowledge base:', error); + } + } + + // 导出项目(非素材,放入 Part1) + if (options.includeProjects) { + onProgress?.(40, '正在导出项目...'); + await this.exportProjects(partManager, manifest, onProgress); + } + + // 导出素材(通过 partManager.addAssetBlob 自动分片) + if (options.includeAssets) { + onProgress?.(50, '正在导出素材...'); + await this.exportAssets(partManager, manifest, onProgress); + } + + onProgress?.(85, '正在压缩文件...'); + + // 完成所有分片 + const result = await partManager.finalizeAll(manifest); + + onProgress?.(100, '导出完成'); + + analytics.track('backup_export_success', { + duration: Date.now() - startTime, + totalParts: result.totalParts, + promptCount: manifest.stats.promptCount + manifest.stats.videoPromptCount + manifest.stats.imagePromptCount, + projectCount: manifest.stats.boardCount, + assetCount: manifest.stats.assetCount, + taskCount: manifest.stats.taskCount, + }); + + return result; + } + + /** + * 下载 ZIP 文件(供外部单独使用) + */ + downloadZip(blob: Blob, filename?: string): void { + const date = new Date(); + const dateStr = date.toISOString().split('T')[0]; + const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, ''); + const defaultFilename = `aitu_backup_${dateStr}_${timeStr}.zip`; + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename || defaultFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + /** + * 收集提示词数据 + */ + private async collectPromptData(): Promise { + await initPromptStorageCache(); + + const promptHistory = getPromptHistory(); + let videoPromptHistory = getVideoPromptHistory(); + let imagePromptHistory = getImagePromptHistory(); + + const completedTasks = await taskStorageReader.getAllTasks({ status: TaskStatus.COMPLETED }); + + const imageTaskPrompts = completedTasks + .filter(task => task.type === TaskType.IMAGE && task.params?.prompt) + .map(task => ({ + id: `task_${task.id}`, + content: task.params.prompt.trim(), + timestamp: task.completedAt || task.createdAt, + })) + .filter(item => item.content.length > 0); + + const videoTaskPrompts = completedTasks + .filter(task => task.type === TaskType.VIDEO && task.params?.prompt) + .map(task => ({ + id: `task_${task.id}`, + content: task.params.prompt.trim(), + timestamp: task.completedAt || task.createdAt, + })) + .filter(item => item.content.length > 0); + + const existingImageContents = new Set(imagePromptHistory.map(p => p.content)); + const newImagePrompts = imageTaskPrompts.filter(p => !existingImageContents.has(p.content)); + imagePromptHistory = [...imagePromptHistory, ...newImagePrompts]; + + const existingVideoContents = new Set(videoPromptHistory.map(p => p.content)); + const newVideoPrompts = videoTaskPrompts.filter(p => !existingVideoContents.has(p.content)); + videoPromptHistory = [...videoPromptHistory, ...newVideoPrompts]; + + const presetSettings = await kvStorageService.get( + LS_KEYS_TO_MIGRATE.PRESET_SETTINGS + ); + + return { + promptHistory, + videoPromptHistory, + imagePromptHistory, + presetSettings: presetSettings || { + image: { pinnedPrompts: [], deletedPrompts: [] }, + video: { pinnedPrompts: [], deletedPrompts: [] }, + }, + }; + } + /** + * 导出项目数据(写入 partManager) + */ + private async exportProjects( + partManager: BackupPartManager, + manifest: BackupManifest, + onProgress?: ProgressCallback + ): Promise { + const [folders, boards] = await Promise.all([ + workspaceStorageService.loadAllFolders(), + workspaceStorageService.loadAllBoards(), + ]); + + const folderPathMap = this.buildFolderPathMap(folders); + + // 创建文件夹结构(空文件夹通过 manifest 记录即可) + manifest.stats.folderCount = folders.length; + + for (let i = 0; i < boards.length; i++) { + const board = boards[i]; + const folderPath = board.folderId ? folderPathMap.get(board.folderId) : null; + const safeName = sanitizeFileName(board.name); + const boardPath = folderPath + ? `projects/${folderPath}/${safeName}.drawnix` + : `projects/${safeName}.drawnix`; + + const drawnixData: DrawnixFileData = { + type: DrawnixExportedType.drawnix, + version: VERSIONS.drawnix, + source: 'backup', + elements: board.elements || [], + viewport: board.viewport || { zoom: 1 }, + theme: board.theme, + boardMeta: { + id: board.id, + name: board.name, + folderId: board.folderId, + order: board.order, + createdAt: board.createdAt, + updatedAt: board.updatedAt, + }, + }; + + partManager.addFile(boardPath, drawnixData); + + if (onProgress && boards.length > 0) { + const progress = 25 + Math.round(((i + 1) / boards.length) * 20); + onProgress(progress, `正在导出画板 (${i + 1}/${boards.length})...`); + } + } + manifest.stats.boardCount = boards.length; + } + + private buildFolderPathMap(folders: Folder[]): Map { + const pathMap = new Map(); + const folderMap = new Map(); + + for (const folder of folders) { + folderMap.set(folder.id, folder); + } + + const getPath = (folderId: string): string => { + if (pathMap.has(folderId)) return pathMap.get(folderId)!; + const folder = folderMap.get(folderId); + if (!folder) return ''; + const safeName = sanitizeFileName(folder.name); + if (folder.parentId) { + const parentPath = getPath(folder.parentId); + const fullPath = parentPath ? `${parentPath}/${safeName}` : safeName; + pathMap.set(folderId, fullPath); + return fullPath; + } + pathMap.set(folderId, safeName); + return safeName; + }; + + for (const folder of folders) { + getPath(folder.id); + } + return pathMap; + } + /** + * 导出素材数据(通过 partManager.addAssetBlob 自动分片) + */ + private async exportAssets( + partManager: BackupPartManager, + manifest: BackupManifest, + onProgress?: ProgressCallback + ): Promise { + const exportedUrls = new Set(); + let exportedCount = 0; + + // 1. 导出本地素材库 + const store = localforage.createInstance({ + name: ASSET_CONSTANTS.STORAGE_NAME, + storeName: ASSET_CONSTANTS.STORE_NAME, + }); + + const assetKeys = await store.keys(); + const totalItems = assetKeys.length; + + for (let i = 0; i < assetKeys.length; i++) { + try { + const stored = await store.getItem(assetKeys[i]); + if (stored) { + const blobData = await unifiedCacheService.getCachedBlob(stored.url); + if (blobData) { + const ext = getExtensionFromMimeType(stored.mimeType); + await partManager.addAssetBlob( + `${stored.id}${ext}`, + blobData, + `${stored.id}.meta.json`, + stored + ); + exportedUrls.add(stored.url); + exportedCount++; + } + } + } catch (error) { + console.warn(`[BackupRestore] Failed to load asset ${assetKeys[i]}:`, error); + } + + if (onProgress && totalItems > 0) { + const progress = 50 + Math.round(((i + 1) / totalItems) * 15); + onProgress(progress, `正在导出本地素材 (${i + 1}/${totalItems})...`); + } + } + + // 2. 导出 unified-cache 中的缓存媒体 + const cachedMedia = await unifiedCacheService.getAllCacheMetadata(); + const cacheItems = cachedMedia.filter(item => !exportedUrls.has(item.url)); + const cacheTotal = cacheItems.length; + + for (let i = 0; i < cacheItems.length; i++) { + const item = cacheItems[i]; + try { + const itemId = item.metadata?.taskId || generateIdFromUrl(item.url); + const metaData = { + id: itemId, + url: item.url, + type: item.type === 'video' ? 'VIDEO' : 'IMAGE', + mimeType: item.mimeType, + size: item.size, + source: 'AI_GENERATED', + createdAt: item.cachedAt, + updatedAt: item.lastUsed, + metadata: item.metadata, + }; + + const blobData = await unifiedCacheService.getCachedBlob(item.url); + if (blobData) { + const ext = getExtensionFromMimeType(item.mimeType); + await partManager.addAssetBlob( + `${itemId}${ext}`, + blobData, + `${itemId}.meta.json`, + metaData + ); + exportedUrls.add(item.url); + exportedCount++; + } + } catch (error) { + console.warn(`[BackupRestore] Failed to export cached media ${item.url}:`, error); + } + + if (onProgress && cacheTotal > 0) { + const progress = 65 + Math.round(((i + 1) / cacheTotal) * 10); + onProgress(progress, `正在导出缓存媒体 (${i + 1}/${cacheTotal})...`); + } + } + + manifest.stats.assetCount = exportedCount; + + // 3. 导出任务数据 + onProgress?.(75, '正在导出任务数据...'); + const allTasks = await taskStorageReader.getAllTasks(); + const completedMediaTasks = allTasks.filter( + task => + task.status === TaskStatus.COMPLETED && + (task.type === TaskType.IMAGE || task.type === TaskType.VIDEO) && + task.result?.url + ); + + if (completedMediaTasks.length > 0) { + partManager.addFile('tasks.json', completedMediaTasks); + manifest.stats.taskCount = completedMediaTasks.length; + } + } +} + +export const backupExportService = new BackupExportService(); diff --git a/packages/drawnix/src/services/backup-restore/backup-import-service.ts b/packages/drawnix/src/services/backup-restore/backup-import-service.ts new file mode 100644 index 00000000..163bd507 --- /dev/null +++ b/packages/drawnix/src/services/backup-restore/backup-import-service.ts @@ -0,0 +1,468 @@ +/** + * Backup Import Service + * 从 ZIP 文件导入数据(增量去重),兼容 v2/v3 manifest + */ + +import JSZip from 'jszip'; +import { workspaceStorageService } from '../workspace-storage-service'; +import { workspaceService } from '../workspace-service'; +import { kvStorageService } from '../kv-storage-service'; +import { + initPromptStorageCache, + resetPromptStorageCache, + getPromptHistory, + getVideoPromptHistory, + getImagePromptHistory, +} from '../prompt-storage-service'; +import { taskStorageReader } from '../task-storage-reader'; +import { taskQueueService } from '../task-queue'; +import { Task } from '../../types/task.types'; +import type { Folder, Board } from '../../types/workspace.types'; +import { LS_KEYS_TO_MIGRATE } from '../../constants/storage-keys'; +import localforage from 'localforage'; +import { ASSET_CONSTANTS } from '../../constants/ASSET_CONSTANTS'; +import { unifiedCacheService } from '../unified-cache-service'; +import { analytics } from '../../utils/posthog-analytics'; +import { knowledgeBaseService } from '../knowledge-base-service'; +import { + BACKUP_SIGNATURE, + BackupManifest, + PromptsData, + PresetStorageData, + DrawnixFileData, + ImportResult, + ProgressCallback, + ensureElementIds, +} from './types'; +import { getExtensionFromMimeType, generateId } from './backup-utils'; + +class BackupImportService { + async importFromZip( + file: File, + onProgress?: ProgressCallback + ): Promise { + const startTime = Date.now(); + + analytics.track('backup_import_start', { + fileSize: file.size, + fileName: file.name, + }); + const result: ImportResult = { + success: false, + prompts: { imported: 0, skipped: 0 }, + projects: { folders: 0, boards: 0, merged: 0, skipped: 0 }, + assets: { imported: 0, skipped: 0 }, + tasks: { imported: 0, skipped: 0 }, + knowledgeBase: { directories: 0, notes: 0, tags: 0, skipped: 0 }, + errors: [], + }; + + try { + onProgress?.(5, '正在读取文件...'); + const zip = await JSZip.loadAsync(file); + + onProgress?.(10, '正在验证文件格式...'); + const manifestFile = zip.file('manifest.json'); + if (!manifestFile) { + throw new Error('无效的备份文件:未找到 manifest.json'); + } + + const manifestContent = await manifestFile.async('string'); + const manifest: BackupManifest = JSON.parse(manifestContent); + + if (manifest.signature !== BACKUP_SIGNATURE) { + throw new Error('无效的备份文件:签名不匹配'); + } + // v2 和 v3 manifest 都支持,v3 有分片字段但导入逻辑不变(每个分片独立导入) + + if (manifest.includes.prompts) { + onProgress?.(20, '正在导入提示词...'); + const promptsFile = zip.file('prompts.json'); + if (promptsFile) { + const promptsContent = await promptsFile.async('string'); + const promptsData: PromptsData = JSON.parse(promptsContent); + result.prompts = await this.importPromptData(promptsData); + } + } + + if (manifest.includes.knowledgeBase) { + onProgress?.(30, '正在导入知识库...'); + const kbResult = await this.importKnowledgeBase(zip); + result.knowledgeBase = kbResult; + if (kbResult.errors && kbResult.errors.length > 0) { + result.errors.push(...kbResult.errors); + } + } + + if (manifest.includes.projects) { + onProgress?.(40, '正在导入项目...'); + result.projects = await this.importProjects(zip, onProgress); + } + + if (manifest.includes.assets) { + onProgress?.(60, '正在导入素材...'); + result.assets = await this.importAssets(zip, onProgress); + onProgress?.(85, '正在导入任务数据...'); + result.tasks = await this.importTasks(zip); + } + + if (result.projects.folders > 0 || result.projects.boards > 0) { + await workspaceService.reload(); + } + + if (manifest.workspaceState) { + result.workspaceState = manifest.workspaceState; + } + + result.success = result.errors.length === 0; + onProgress?.(100, '导入完成'); + + analytics.track('backup_import_success', { + duration: Date.now() - startTime, + promptCount: result.prompts.imported, + projectCount: result.projects.boards, + assetCount: result.assets.imported, + taskCount: result.tasks.imported, + kbNoteCount: result.knowledgeBase.notes, + skippedCount: + result.prompts.skipped + result.projects.skipped + + result.assets.skipped + result.tasks.skipped + result.knowledgeBase.skipped, + }); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + result.errors.push(errorMessage); + analytics.track('backup_import_failed', { + duration: Date.now() - startTime, + error: errorMessage, + }); + } + + return result; + } + private async importKnowledgeBase(zip: JSZip): Promise<{ + directories: number; notes: number; tags: number; skipped: number; errors: string[]; + }> { + const kbFile = zip.file('knowledge-base.json'); + if (!kbFile) { + return { directories: 0, notes: 0, tags: 0, skipped: 0, errors: [] }; + } + try { + const content = await kbFile.async('string'); + const data = JSON.parse(content); + const r = await knowledgeBaseService.importAllData(data); + return { directories: r.dirCount, notes: r.noteCount, tags: r.tagCount, skipped: 0, errors: [] }; + } catch (error) { + console.error('Failed to import knowledge base:', error); + return { + directories: 0, notes: 0, tags: 0, skipped: 0, + errors: [`Knowledge base import failed: ${error instanceof Error ? error.message : String(error)}`], + }; + } + } + + private async importPromptData(data: PromptsData): Promise<{ imported: number; skipped: number }> { + let imported = 0; + let skipped = 0; + + const inputPromptHistory = data.promptHistory || []; + const inputVideoPromptHistory = data.videoPromptHistory || []; + const inputImagePromptHistory = data.imagePromptHistory || []; + + await initPromptStorageCache(); + const existingPrompts = getPromptHistory(); + const existingVideoPrompts = getVideoPromptHistory(); + const existingImagePrompts = getImagePromptHistory(); + + const existingPromptIds = new Set(existingPrompts.map(p => p.id)); + const existingPromptContents = new Set(existingPrompts.map(p => p.content)); + const newPrompts = inputPromptHistory.filter(p => { + if (existingPromptIds.has(p.id) || existingPromptContents.has(p.content)) { skipped++; return false; } + imported++; + return true; + }); + + const existingVideoIds = new Set(existingVideoPrompts.map(p => p.id)); + const existingVideoContents = new Set(existingVideoPrompts.map(p => p.content)); + const newVideoPrompts = inputVideoPromptHistory.filter(p => { + if (existingVideoIds.has(p.id) || existingVideoContents.has(p.content)) { skipped++; return false; } + imported++; + return true; + }); + + const existingImageIds = new Set(existingImagePrompts.map(p => p.id)); + const existingImageContents = new Set(existingImagePrompts.map(p => p.content)); + const newImagePrompts = inputImagePromptHistory.filter(p => { + if (existingImageIds.has(p.id) || existingImageContents.has(p.content)) { skipped++; return false; } + imported++; + return true; + }); + await kvStorageService.set(LS_KEYS_TO_MIGRATE.PROMPT_HISTORY, [...existingPrompts, ...newPrompts]); + await kvStorageService.set(LS_KEYS_TO_MIGRATE.VIDEO_PROMPT_HISTORY, [...existingVideoPrompts, ...newVideoPrompts]); + await kvStorageService.set(LS_KEYS_TO_MIGRATE.IMAGE_PROMPT_HISTORY, [...existingImagePrompts, ...newImagePrompts]); + + const existingPreset = await kvStorageService.get(LS_KEYS_TO_MIGRATE.PRESET_SETTINGS); + if (existingPreset && data.presetSettings) { + const mergedPreset: PresetStorageData = { + image: { + pinnedPrompts: [...new Set([...existingPreset.image.pinnedPrompts, ...data.presetSettings.image.pinnedPrompts])], + deletedPrompts: [...new Set([...existingPreset.image.deletedPrompts, ...data.presetSettings.image.deletedPrompts])], + }, + video: { + pinnedPrompts: [...new Set([...existingPreset.video.pinnedPrompts, ...data.presetSettings.video.pinnedPrompts])], + deletedPrompts: [...new Set([...existingPreset.video.deletedPrompts, ...data.presetSettings.video.deletedPrompts])], + }, + }; + await kvStorageService.set(LS_KEYS_TO_MIGRATE.PRESET_SETTINGS, mergedPreset); + } else if (data.presetSettings) { + await kvStorageService.set(LS_KEYS_TO_MIGRATE.PRESET_SETTINGS, data.presetSettings); + } + + await resetPromptStorageCache(); + return { imported, skipped }; + } + private async importProjects( + zip: JSZip, + onProgress?: ProgressCallback + ): Promise<{ folders: number; boards: number; merged: number; skipped: number }> { + let foldersImported = 0; + let boardsImported = 0; + let boardsMerged = 0; + let skipped = 0; + + const existingFolders = await workspaceStorageService.loadAllFolders(); + const existingBoards = await workspaceStorageService.loadAllBoards(); + const existingBoardIds = new Set(existingBoards.map(b => b.id)); + + const drawnixFiles = Object.keys(zip.files).filter( + name => name.startsWith('projects/') && name.endsWith('.drawnix') && !name.includes('/_') + ); + + if (drawnixFiles.length === 0) { + return { folders: 0, boards: 0, merged: 0, skipped: 0 }; + } + + // 从目录结构推断文件夹 + const folderPaths = new Set(); + const folderIdMap = new Map(); + + for (const filePath of drawnixFiles) { + const relativePath = filePath.replace(/^projects\//, ''); + const parts = relativePath.split('/'); + if (parts.length > 1) { + let currentPath = ''; + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + folderPaths.add(currentPath); + } + } + } + + const sortedPaths = Array.from(folderPaths).sort((a, b) => { + const depthA = a.split('/').length; + const depthB = b.split('/').length; + return depthA - depthB || a.localeCompare(b); + }); + + for (let i = 0; i < sortedPaths.length; i++) { + const folderPath = sortedPaths[i]; + const parts = folderPath.split('/'); + const folderName = parts[parts.length - 1]; + const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : null; + const parentId = parentPath ? folderIdMap.get(parentPath) || null : null; + + const folderId = generateId(); + folderIdMap.set(folderPath, folderId); + + const existingFolder = existingFolders.find(f => f.name === folderName && f.parentId === parentId); + if (existingFolder) { + folderIdMap.set(folderPath, existingFolder.id); + skipped++; + continue; + } + + const folder: Folder = { + id: folderId, name: folderName, parentId, order: i, + isExpanded: true, createdAt: Date.now(), updatedAt: Date.now(), + }; + await workspaceStorageService.saveFolder(folder); + foldersImported++; + } + // 导入画板 + for (let i = 0; i < drawnixFiles.length; i++) { + const filePath = drawnixFiles[i]; + try { + const drawnixFile = zip.file(filePath); + if (drawnixFile) { + const drawnixContent = await drawnixFile.async('string'); + const drawnixData: DrawnixFileData = JSON.parse(drawnixContent); + const boardMeta = drawnixData.boardMeta; + + const relativePath = filePath.replace(/^projects\//, ''); + const parts = relativePath.split('/'); + const folderPath = parts.length > 1 ? parts.slice(0, -1).join('/') : null; + const folderId = folderPath + ? folderIdMap.get(folderPath) || boardMeta?.folderId || null + : null; + + const fileName = parts[parts.length - 1] || 'unnamed.drawnix'; + const boardName = boardMeta?.name || fileName.replace('.drawnix', ''); + + if (boardMeta?.id && existingBoardIds.has(boardMeta.id)) { + const existingBoard = existingBoards.find(b => b.id === boardMeta.id); + if (existingBoard) { + const existingElementIds = new Set( + (existingBoard.elements || []).map(el => el.id).filter((id): id is string => !!id) + ); + const newElements = (drawnixData.elements || []).filter(el => + el.id ? !existingElementIds.has(el.id) : true + ); + const mergedBoard: Board = { + ...existingBoard, + elements: [...(existingBoard.elements || []), ...ensureElementIds(newElements)], + viewport: drawnixData.viewport || existingBoard.viewport, + theme: drawnixData.theme || existingBoard.theme, + updatedAt: Date.now(), + }; + await workspaceStorageService.saveBoard(mergedBoard); + boardsMerged++; + continue; + } + } + + const board: Board = { + id: boardMeta?.id || generateId(), + name: boardName, folderId, order: boardMeta?.order ?? i, + elements: ensureElementIds(drawnixData.elements || []), + viewport: drawnixData.viewport, theme: drawnixData.theme, + createdAt: boardMeta?.createdAt || Date.now(), + updatedAt: boardMeta?.updatedAt || Date.now(), + }; + await workspaceStorageService.saveBoard(board); + boardsImported++; + } + } catch (error) { + console.warn(`[BackupRestore] Failed to import board ${filePath}:`, error); + } + + if (onProgress && drawnixFiles.length > 0) { + const progress = 40 + Math.round(((i + 1) / drawnixFiles.length) * 15); + onProgress(progress, `正在导入画板 (${i + 1}/${drawnixFiles.length})...`); + } + } + + return { folders: foldersImported, boards: boardsImported, merged: boardsMerged, skipped }; + } + private async importAssets( + zip: JSZip, + onProgress?: ProgressCallback + ): Promise<{ imported: number; skipped: number }> { + let imported = 0; + let skipped = 0; + + const metaFiles = Object.keys(zip.files).filter( + name => name.startsWith('assets/') && name.endsWith('.meta.json') + ); + if (metaFiles.length === 0) return { imported: 0, skipped: 0 }; + + const store = localforage.createInstance({ + name: ASSET_CONSTANTS.STORAGE_NAME, + storeName: ASSET_CONSTANTS.STORE_NAME, + }); + + const existingKeys = await store.keys(); + const existingIds = new Set(existingKeys); + const existingCacheUrls = new Set(await unifiedCacheService.getAllCachedUrls()); + + for (let i = 0; i < metaFiles.length; i++) { + const metaPath = metaFiles[i]; + const assetId = metaPath.replace('assets/', '').replace('.meta.json', ''); + + try { + const metaFile = zip.file(metaPath); + if (!metaFile) { skipped++; continue; } + + const metaContent = await metaFile.async('string'); + const metadata = JSON.parse(metaContent); + const isAIGenerated = metadata.source === 'AI_GENERATED'; + + if (isAIGenerated) { + if (existingCacheUrls.has(metadata.url)) { skipped++; continue; } + } else { + if (existingIds.has(assetId)) { skipped++; continue; } + } + + const possibleExtensions = ['.jpg', '.png', '.gif', '.webp', '.mp4', '.webm', '.mov', '.svg']; + let blobData: Blob | null = null; + + for (const ext of possibleExtensions) { + const blobFile = zip.file(`assets/${assetId}${ext}`); + if (blobFile) { blobData = await blobFile.async('blob'); break; } + } + + if (!blobData && metadata.mimeType) { + const ext = getExtensionFromMimeType(metadata.mimeType); + if (ext) { + const blobFile = zip.file(`assets/${assetId}${ext}`); + if (blobFile) blobData = await blobFile.async('blob'); + } + } + + if (blobData) { + const cacheType = metadata.type === 'VIDEO' ? 'video' : 'image'; + await unifiedCacheService.cacheMediaFromBlob( + metadata.url, blobData, cacheType as 'image' | 'video', + { taskId: metadata.metadata?.taskId || assetId, prompt: metadata.metadata?.prompt, model: metadata.metadata?.model } + ); + if (!isAIGenerated) await store.setItem(assetId, metadata); + imported++; + } else { + console.warn(`[BackupRestore] No media file found for asset ${assetId}, skipping`); + skipped++; + } + } catch (error) { + console.warn(`[BackupRestore] Failed to import asset ${assetId}:`, error); + skipped++; + } + + if (onProgress && metaFiles.length > 0) { + const progress = 60 + Math.round(((i + 1) / metaFiles.length) * 35); + onProgress(progress, `正在导入素材 (${i + 1}/${metaFiles.length})...`); + } + } + + return { imported, skipped }; + } + + private async importTasks(zip: JSZip): Promise<{ imported: number; skipped: number }> { + let imported = 0; + let skipped = 0; + + const tasksFile = zip.file('tasks.json'); + if (!tasksFile) return { imported: 0, skipped: 0 }; + + try { + const tasksContent = await tasksFile.async('string'); + const tasks: Task[] = JSON.parse(tasksContent); + if (!Array.isArray(tasks) || tasks.length === 0) return { imported: 0, skipped: 0 }; + + const existingTasks = await taskStorageReader.getAllTasks(); + const existingTaskIds = new Set(existingTasks.map(t => t.id)); + + const tasksToImport = tasks.filter(task => { + if (existingTaskIds.has(task.id)) { skipped++; return false; } + return true; + }); + + if (tasksToImport.length > 0) { + await taskQueueService.restoreTasks(tasksToImport); + imported = tasksToImport.length; + } + } catch (error) { + console.warn('[BackupRestore] Failed to import tasks:', error); + } + + return { imported, skipped }; + } +} + +export const backupImportService = new BackupImportService(); diff --git a/packages/drawnix/src/services/backup-restore/backup-part-manager.ts b/packages/drawnix/src/services/backup-restore/backup-part-manager.ts new file mode 100644 index 00000000..700c5df2 --- /dev/null +++ b/packages/drawnix/src/services/backup-restore/backup-part-manager.ts @@ -0,0 +1,154 @@ +/** + * Backup Part Manager + * 管理备份分片逻辑,从 sw-debug 的 JS 版移植为 TypeScript + */ + +import JSZip from 'jszip'; +import { BACKUP_SIGNATURE, BACKUP_VERSION, BackupManifest, ExportResult } from './types'; + +/** 分片阈值:500MB 未压缩大小 */ +export const PART_SIZE_THRESHOLD = 500 * 1024 * 1024; + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * BackupPartManager - 管理备份分片 + * Part1 延迟下载:保留在内存中直到确定是否需要拆分 + */ +export class BackupPartManager { + private baseFilename: string; + private backupId: string; + private partIndex = 1; + private currentZip: JSZip; + private currentSize = 0; + private downloadedParts: Array<{ filename: string; size: number }> = []; + private part1Zip: JSZip; + + constructor(baseFilename: string, backupId: string) { + this.baseFilename = baseFilename; + this.backupId = backupId; + this.currentZip = new JSZip(); + this.part1Zip = this.currentZip; + } + + /** 添加文件到当前 ZIP(非素材,不触发分片) */ + addFile(path: string, content: string | object): void { + const data = typeof content === 'string' ? content : JSON.stringify(content, null, 2); + this.currentZip.file(path, data); + this.currentSize += new Blob([data]).size; + } + + /** 添加素材 blob,超阈值时自动 finalize 当前分片 */ + async addAssetBlob(path: string, blob: Blob, metaPath: string, metaContent: string | object): Promise { + const metaStr = typeof metaContent === 'string' ? metaContent : JSON.stringify(metaContent, null, 2); + const newSize = blob.size + new Blob([metaStr]).size; + + if (this.currentSize + newSize > PART_SIZE_THRESHOLD && this.currentSize > 0) { + await this.finalizePart(); + this.startNewPart(); + } + + const assetsFolder = this.currentZip.folder('assets')!; + assetsFolder.file(metaPath, metaStr); + assetsFolder.file(path, blob); + this.currentSize += newSize; + } + + /** finalize 当前分片并下载(非 Part1 时立即下载) */ + private async finalizePart(): Promise { + if (this.partIndex === 1) return; + + const partManifest = { + signature: BACKUP_SIGNATURE, + version: BACKUP_VERSION, + createdAt: Date.now(), + source: 'app', + backupId: this.backupId, + partIndex: this.partIndex, + totalParts: null, + isFinalPart: false, + includes: { prompts: false, projects: false, assets: true, knowledgeBase: false }, + }; + this.currentZip.file('manifest.json', JSON.stringify(partManifest, null, 2)); + + const blob = await this.currentZip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + const filename = `${this.baseFilename}_part${this.partIndex}.zip`; + if (this.downloadedParts.length > 0) { + await new Promise(r => setTimeout(r, 500)); + } + downloadBlob(blob, filename); + this.downloadedParts.push({ filename, size: blob.size }); + } + + private startNewPart(): void { + this.partIndex++; + this.currentZip = new JSZip(); + this.currentSize = 0; + } + + /** + * 完成所有分片 + */ + async finalizeAll(manifest: BackupManifest): Promise { + const isMultiPart = this.partIndex > 1; + + if (!isMultiPart) { + manifest.backupId = this.backupId; + manifest.partIndex = 1; + manifest.totalParts = 1; + manifest.isFinalPart = true; + this.part1Zip.file('manifest.json', JSON.stringify(manifest, null, 2)); + + const blob = await this.part1Zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + const filename = `${this.baseFilename}.zip`; + downloadBlob(blob, filename); + return { files: [{ filename, size: blob.size }], totalParts: 1, stats: manifest.stats }; + } + + // 多分片:先下载 Part1 + const part1Manifest = { ...manifest, backupId: this.backupId, partIndex: 1, totalParts: null, isFinalPart: false }; + this.part1Zip.file('manifest.json', JSON.stringify(part1Manifest, null, 2)); + const part1Blob = await this.part1Zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + const part1Filename = `${this.baseFilename}_part1.zip`; + downloadBlob(part1Blob, part1Filename); + this.downloadedParts.unshift({ filename: part1Filename, size: part1Blob.size }); + + // 下载最后一个分片 + if (this.currentSize > 0) { + const finalManifest = { ...manifest, backupId: this.backupId, partIndex: this.partIndex, totalParts: this.partIndex, isFinalPart: true }; + this.currentZip.file('manifest.json', JSON.stringify(finalManifest, null, 2)); + const blob = await this.currentZip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + const filename = `${this.baseFilename}_part${this.partIndex}.zip`; + await new Promise(r => setTimeout(r, 500)); + downloadBlob(blob, filename); + this.downloadedParts.push({ filename, size: blob.size }); + } + + return { files: this.downloadedParts, totalParts: this.partIndex, stats: manifest.stats }; + } +} diff --git a/packages/drawnix/src/services/backup-restore/backup-utils.ts b/packages/drawnix/src/services/backup-restore/backup-utils.ts new file mode 100644 index 00000000..53c1910c --- /dev/null +++ b/packages/drawnix/src/services/backup-restore/backup-utils.ts @@ -0,0 +1,41 @@ +/** + * Backup & Restore - Shared Utilities + */ + +export function getExtensionFromMimeType(mimeType: string): string { + const mimeToExt: Record = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/svg+xml': '.svg', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'video/quicktime': '.mov', + }; + return mimeToExt[mimeType] || ''; +} + +export function sanitizeFileName(name: string): string { + return ( + name + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .trim() || 'unnamed' + ); +} + +export function generateIdFromUrl(url: string): string { + let hash = 0; + for (let i = 0; i < url.length; i++) { + const char = url.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return `cache-${Math.abs(hash).toString(36)}`; +} + +export function generateId(): string { + return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 11)}`; +} diff --git a/packages/drawnix/src/services/backup-restore/index.ts b/packages/drawnix/src/services/backup-restore/index.ts new file mode 100644 index 00000000..b1eda3b5 --- /dev/null +++ b/packages/drawnix/src/services/backup-restore/index.ts @@ -0,0 +1,34 @@ +/** + * Backup & Restore Service - Facade + * 保持向后兼容的入口 + */ + +import { backupExportService } from './backup-export-service'; +import { backupImportService } from './backup-import-service'; +import type { BackupOptions, ExportResult, ImportResult, ProgressCallback } from './types'; + +class BackupRestoreService { + exportToZip(options: BackupOptions, onProgress?: ProgressCallback): Promise { + return backupExportService.exportToZip(options, onProgress); + } + + importFromZip(file: File, onProgress?: ProgressCallback): Promise { + return backupImportService.importFromZip(file, onProgress); + } + + downloadZip(blob: Blob, filename?: string): void { + backupExportService.downloadZip(blob, filename); + } +} + +export const backupRestoreService = new BackupRestoreService(); + +// Re-export all types +export type { + BackupOptions, + BackupWorkspaceState, + ImportResult, + ExportResult, + ProgressCallback, + BackupManifest, +} from './types'; diff --git a/packages/drawnix/src/services/backup-restore/types.ts b/packages/drawnix/src/services/backup-restore/types.ts new file mode 100644 index 00000000..32af50d3 --- /dev/null +++ b/packages/drawnix/src/services/backup-restore/types.ts @@ -0,0 +1,141 @@ +/** + * Backup & Restore - Type Definitions + */ + +import type { + PromptHistoryItem, + VideoPromptHistoryItem, + ImagePromptHistoryItem, +} from '../prompt-storage-service'; +import type { PlaitTheme, PlaitElement as CorePlaitElement } from '@plait/core'; + +// 备份文件版本(v3 支持分片) +export const BACKUP_VERSION = 3; + +// 备份文件标识 +export const BACKUP_SIGNATURE = 'aitu-backup'; + +export interface PresetPromptSettings { + pinnedPrompts: string[]; + deletedPrompts: string[]; +} + +export interface PresetStorageData { + image: PresetPromptSettings; + video: PresetPromptSettings; +} + +export interface BackupOptions { + includePrompts: boolean; + includeProjects: boolean; + includeAssets: boolean; + includeKnowledgeBase: boolean; +} + +export interface BackupWorkspaceState { + currentBoardId: string | null; + currentBoardName?: string; + viewport?: { + zoom: number; + origination?: [number, number]; + }; +} + +export interface BackupManifest { + signature: string; + version: number; + createdAt: number; + source?: string; + includes: { + prompts: boolean; + projects: boolean; + assets: boolean; + knowledgeBase: boolean; + }; + stats: { + promptCount: number; + videoPromptCount: number; + imagePromptCount: number; + folderCount: number; + boardCount: number; + assetCount: number; + taskCount: number; + kbNoteCount: number; + }; + workspaceState?: BackupWorkspaceState; + // v3 分片字段 + backupId?: string; + partIndex?: number; + totalParts?: number | null; + isFinalPart?: boolean; +} + +export interface PromptsData { + promptHistory: PromptHistoryItem[]; + videoPromptHistory: VideoPromptHistoryItem[]; + imagePromptHistory: ImagePromptHistoryItem[]; + presetSettings: PresetStorageData; +} + +export interface DrawnixFileData { + type: string; + version: number; + source: string; + elements: PlaitElement[]; + viewport: Viewport; + theme?: PlaitTheme; + boardMeta?: { + id: string; + name: string; + folderId: string | null; + order: number; + createdAt: number; + updatedAt: number; + }; +} + +export interface Viewport { + zoom: number; + origination?: [number, number]; +} + +export interface PlaitElement { + id?: string; + type?: string; + assetId?: string; + imageAssetId?: string; + videoAssetId?: string; + children?: PlaitElement[]; + [key: string]: unknown; +} + +export interface ImportResult { + success: boolean; + prompts: { imported: number; skipped: number }; + projects: { folders: number; boards: number; merged: number; skipped: number }; + assets: { imported: number; skipped: number }; + tasks: { imported: number; skipped: number }; + knowledgeBase: { directories: number; notes: number; tags: number; skipped: number }; + errors: string[]; + workspaceState?: BackupWorkspaceState; +} + +export interface ExportResult { + files: Array<{ filename: string; size: number }>; + totalParts: number; + stats: BackupManifest['stats']; +} + +export type ProgressCallback = (progress: number, message: string) => void; + +/** + * 确保所有元素都有 id + */ +export function ensureElementIds(elements: PlaitElement[]): CorePlaitElement[] { + return elements.map(el => { + if (!el.id) { + return { ...el, id: `imported-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` } as CorePlaitElement; + } + return el as CorePlaitElement; + }); +} diff --git a/packages/drawnix/src/services/task-queue-service.ts b/packages/drawnix/src/services/task-queue-service.ts index 63da636c..7ff3bcac 100644 --- a/packages/drawnix/src/services/task-queue-service.ts +++ b/packages/drawnix/src/services/task-queue-service.ts @@ -531,6 +531,7 @@ class TaskQueueService { }); // Emit a single batch update event instead of per-task events + console.warn(`[TaskQueueService] restoreTasks: ${restoredCount}/${tasks.length} restored, total in memory: ${this.tasks.size}`); if (restoredCount > 0) { // Use the first task to emit a generic update that triggers UI refresh const allTasks = Array.from(this.tasks.values()); diff --git a/packages/drawnix/src/services/task-storage-reader.ts b/packages/drawnix/src/services/task-storage-reader.ts index 9d3aed6b..e3a7570e 100644 --- a/packages/drawnix/src/services/task-storage-reader.ts +++ b/packages/drawnix/src/services/task-storage-reader.ts @@ -166,7 +166,8 @@ class TaskStorageReader extends BaseStorageReader { // 按创建时间倒序排序 rawTasks.sort((a, b) => b.createdAt - a.createdAt); - + + console.warn(`[TaskStorageReader] getAllTasks: ${rawTasks.length} tasks from IndexedDB (filter: type=${options?.type}, status=${options?.status})`); resolve(rawTasks.map(convertSWTaskToTask)); };