✅
备份成功
-
${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));
};