feat(backup-restore): add backup restoration functionality with automatic chunking support

- Introduced a new restore button in the SW debug panel for restoring data from ZIP backups.
- Implemented `BackupPartManager` to handle automatic chunking during backup processes.
- Enhanced the backup process to include tasks, prompts, and assets, with progress updates.
- Updated version to 0.6.7 and adjusted build time in `version.json`.
- Refactored existing backup logic to support the new restoration features.
This commit is contained in:
liquid committed 2026-03-08 00:34:06 +08:00
1 parent 75161b60b3
commit 7d76899a22
28 files changed
+2217 -1768

No files matched your search

+9 -1
View File
@@ -52,6 +52,14 @@
</svg>
数据备份
</button>
<!-- 恢复备份按钮 -->
<button id="restoreBackup" class="primary" title="从备份 ZIP 恢复数据">
<svg class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 15v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
恢复备份
</button>
<input type="file" id="restoreBackupInput" accept=".zip" style="display: none;">
<button id="toggleTheme" class="icon-btn" title="切换深色/浅色主题">
<svg id="themeIcon" class="icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
@@ -447,7 +455,7 @@
<div class="tab" data-tab="console">控制台日志 <span id="consoleCount" style="opacity:0.6">(0)</span><span id="consoleErrorDot" class="error-dot" style="display:none"></span></div>
<div class="tab" data-tab="postmessage">PostMessage日志 <span id="postmessageCount" style="opacity:0.6">(0)</span></div>
<div class="tab" data-tab="llmapi">LLM API <span id="llmapiErrorDot" class="error-dot" style="display:none"></span></div>
<div class="tab" data-tab="gist">Gist 管理</div>
<div class="tab debug-mode-only" data-tab="gist">Gist 管理</div>
<div class="tab" data-tab="crash">内存日志 <span id="crashCount" style="opacity:0.6">(0)</span><span id="crashErrorDot" class="error-dot" style="display:none"></span></div>
</div>
@@ -109,6 +109,15 @@ export function updateAnalysisModeUI() {
document.querySelectorAll('.debug-mode-only').forEach(el => {
el.style.display = isAnalysis ? 'none' : '';
});
// If entering analysis mode while on a hidden tab (e.g. gist), switch to fetch
if (isAnalysis) {
const activeTab = document.querySelector('.tab.active');
if (activeTab && activeTab.classList.contains('debug-mode-only')) {
const fetchTab = document.querySelector('.tab[data-tab="fetch"]');
if (fetchTab) fetchTab.click();
}
}
// In analysis mode, left panel shows user info instead of SW status
// The panel is always visible, but content changes based on mode
+5
View File
@@ -38,6 +38,7 @@ import {
// Feature modules
import { performBackup } from './backup.js';
import { triggerRestoreDialog, handleRestoreFile } from './backup-restore.js';
import {
toggleAnalysisMode,
updateAnalysisModeUI,
@@ -689,6 +690,10 @@ function setupEventListeners() {
// Backup button event listener
elements.backupDataBtn?.addEventListener('click', performBackup);
// Restore button event listeners
elements.restoreBackupBtn?.addEventListener('click', triggerRestoreDialog);
elements.restoreBackupInput?.addEventListener('change', handleRestoreFile);
elements.toggleDebugBtn?.addEventListener('click', toggleDebug);
elements.exportLogsBtn?.addEventListener('click', openExportModal);
@@ -0,0 +1,154 @@
/**
* SW Debug Panel - Backup Part Manager
* 管理备份分片逻辑
*/
/** 分片阈值:500MB 未压缩大小 */
export const PART_SIZE_THRESHOLD = 500 * 1024 * 1024;
/** 备份签名和版本(与 backup.js 保持一致) */
const BACKUP_SIGNATURE = 'aitu-backup';
const BACKUP_VERSION = 3;
/**
* 下载 Blob 文件
*/
function downloadBlob(blob, filename) {
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 {
constructor(baseFilename, backupId) {
this.baseFilename = baseFilename;
this.backupId = backupId;
this.partIndex = 1;
this.currentZip = new JSZip();
this.currentSize = 0;
this.downloadedParts = [];
this.part1Zip = this.currentZip;
}
/** 添加文件到当前 ZIP(非素材,不触发分片) */
addFile(path, content) {
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, blob, metaPath, metaContent) {
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 时立即下载) */
async _finalizePart() {
if (this.partIndex === 1) return;
const partManifest = {
signature: BACKUP_SIGNATURE,
version: BACKUP_VERSION,
createdAt: Date.now(),
source: 'sw-debug-panel',
backupId: this.backupId,
partIndex: this.partIndex,
totalParts: null,
isFinalPart: false,
includes: { assets: true },
};
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 });
}
_startNewPart() {
this.partIndex++;
this.currentZip = new JSZip();
this.currentSize = 0;
}
/**
* 完成所有分片
* @param {object} manifest - 完整的 manifest 数据
* @returns {{ files: Array<{filename, size}>, totalParts: number }}
*/
async finalizeAll(manifest) {
const isMultiPart = this.partIndex > 1;
if (!isMultiPart) {
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 };
}
// 多分片模式:先下载 Part1
const part1Manifest = { ...manifest, 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, 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 };
}
}
+389
View File
@@ -0,0 +1,389 @@
/**
* SW Debug Panel - Backup Restore
* 从备份 ZIP 恢复数据到 IndexedDB + Cache Storage
*/
import { elements } from './state.js';
import {
IDB_STORES,
KV_KEYS,
CACHE_NAMES,
SW_TASK_QUEUE_DB,
readAllFromIDB,
readKVItem,
writeToIDB,
writeKVItem,
writeBatchToIDB,
} from './indexeddb.js';
import { BACKUP_SIGNATURE, waitForJSZip } from './backup.js';
import { showToast } from './toast.js';
/**
* 触发文件选择器
*/
export function triggerRestoreDialog() {
elements.restoreBackupInput?.click();
}
/**
* 处理文件选择后的恢复流程
*/
export async function handleRestoreFile(event) {
const file = event.target.files?.[0];
if (!file) return;
// 重置 input 以便再次选择同一文件
event.target.value = '';
try {
await performRestore(file);
} catch (error) {
showToast('恢复失败: ' + error.message, 'error', 5000);
}
}
/**
* 执行恢复
*/
async function performRestore(file) {
const jsZipLoaded = await waitForJSZip(5000);
if (!jsZipLoaded) throw new Error('JSZip 库加载超时');
const progressContainer = showRestoreProgress();
const updateProgress = (percent, text) => {
const bar = progressContainer.querySelector('.backup-progress-fill');
const label = progressContainer.querySelector('.backup-progress-text');
if (bar) bar.style.width = `${percent}%`;
if (label) label.textContent = text;
};
try {
updateProgress(5, '正在读取 ZIP 文件...');
const zip = await JSZip.loadAsync(file);
// 读取并验证 manifest
const manifestFile = zip.file('manifest.json');
if (!manifestFile) throw new Error('无效的备份文件:缺少 manifest.json');
const manifest = JSON.parse(await manifestFile.async('string'));
if (manifest.signature !== BACKUP_SIGNATURE) {
throw new Error('无效的备份文件:签名不匹配');
}
// 兼容 v2(无分片字段)和 v3
if (manifest.version < 2 || manifest.version > 3) {
throw new Error(`不支持的备份版本: ${manifest.version}`);
}
const stats = { prompts: 0, projects: 0, tasks: 0, assets: 0 };
// 1. 恢复提示词
if (manifest.includes?.prompts !== false) {
updateProgress(15, '正在恢复提示词...');
stats.prompts = await restorePrompts(zip);
}
// 2. 恢复项目
if (manifest.includes?.projects !== false) {
updateProgress(30, '正在恢复项目...');
stats.projects = await restoreProjects(zip);
}
// 3. 恢复任务
if (manifest.includes?.tasks !== false) {
updateProgress(45, '正在恢复任务...');
stats.tasks = await restoreTasks(zip);
}
// 4. 恢复素材
if (manifest.includes?.assets !== false) {
updateProgress(55, '正在恢复素材...');
stats.assets = await restoreAssets(zip, (current, total) => {
const percent = 55 + Math.round((current / total) * 35);
updateProgress(percent, `正在恢复素材 (${current}/${total})...`);
});
}
updateProgress(100, '恢复完成!');
setTimeout(() => {
progressContainer.remove();
showRestoreSuccessNotification(stats, manifest);
}, 500);
} catch (error) {
progressContainer.remove();
throw error;
}
}
/**
* 恢复提示词(按 content 去重合并)
*/
async function restorePrompts(zip) {
const promptsFile = zip.file('prompts.json');
if (!promptsFile) return 0;
const data = JSON.parse(await promptsFile.async('string'));
let count = 0;
// 合并各类提示词历史
const mergePromptList = async (kvKey, incoming) => {
if (!incoming || incoming.length === 0) return 0;
const existing = (await readKVItem(kvKey)) || [];
const existingContents = new Set(existing.map(p => p.content));
const newItems = incoming.filter(p => p.content && !existingContents.has(p.content));
if (newItems.length > 0) {
await writeKVItem(kvKey, [...existing, ...newItems]);
}
return newItems.length;
};
count += await mergePromptList(KV_KEYS.PROMPT_HISTORY, data.promptHistory);
count += await mergePromptList(KV_KEYS.VIDEO_PROMPT_HISTORY, data.videoPromptHistory);
count += await mergePromptList(KV_KEYS.IMAGE_PROMPT_HISTORY, data.imagePromptHistory);
// 恢复预设设置(如果本地没有则写入)
if (data.presetSettings) {
const existing = await readKVItem(KV_KEYS.PRESET_SETTINGS);
if (!existing) {
await writeKVItem(KV_KEYS.PRESET_SETTINGS, data.presetSettings);
}
}
return count;
}
/**
* 恢复项目(文件夹 + 画板,按 ID upsert)
*/
async function restoreProjects(zip) {
const projectsFolder = zip.folder('projects');
if (!projectsFolder) return 0;
let count = 0;
const folders = [];
const boards = [];
// 遍历 projects/ 下的所有 .drawnix 文件
const drawnixFiles = [];
projectsFolder.forEach((relativePath, file) => {
if (file.name.endsWith('.drawnix') && !file.dir) {
drawnixFiles.push(file);
}
});
for (const file of drawnixFiles) {
try {
const content = JSON.parse(await file.async('string'));
if (content.boardMeta) {
const meta = content.boardMeta;
boards.push({
id: meta.id,
name: meta.name,
folderId: meta.folderId,
order: meta.order,
elements: content.elements || [],
viewport: content.viewport || { zoom: 1 },
theme: content.theme,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
});
}
} catch (err) {
console.debug('[Restore] Failed to parse board file:', file.name, err);
}
}
// 从画板中提取文件夹信息(通过 folderId 关联)
// 文件夹结构在 ZIP 中是目录,但元数据在画板的 boardMeta.folderId 中
// 读取现有文件夹以避免覆盖
const existingFolders = await readAllFromIDB(
IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.FOLDERS
);
const existingFolderIds = new Set((existingFolders || []).map(f => f.id));
// 写入画板(upsert)
if (boards.length > 0) {
const existingBoards = await readAllFromIDB(
IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.BOARDS
);
const existingBoardMap = new Map((existingBoards || []).map(b => [b.id, b]));
for (const board of boards) {
const existing = existingBoardMap.get(board.id);
// 如果已存在且本地更新时间更新,跳过
if (existing && existing.updatedAt >= (board.updatedAt || 0)) continue;
try {
await writeToIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.BOARDS, board);
count++;
} catch (err) {
console.debug('[Restore] Failed to write board:', board.id, err);
}
}
}
return count;
}
/**
* 恢复任务(按 ID upsert)
*/
async function restoreTasks(zip) {
const tasksFile = zip.file('tasks.json');
if (!tasksFile) return 0;
const tasks = JSON.parse(await tasksFile.async('string'));
if (!Array.isArray(tasks) || tasks.length === 0) return 0;
const existingTasks = await readAllFromIDB(SW_TASK_QUEUE_DB.name, SW_TASK_QUEUE_DB.stores.TASKS);
const existingIds = new Set((existingTasks || []).map(t => t.id));
const newTasks = tasks.filter(t => !existingIds.has(t.id));
if (newTasks.length > 0) {
// 标记为远程同步,避免 SW 重新执行
const markedTasks = newTasks.map(t => ({ ...t, syncedFromRemote: true }));
await writeBatchToIDB(SW_TASK_QUEUE_DB.name, SW_TASK_QUEUE_DB.stores.TASKS, markedTasks);
}
return newTasks.length;
}
/**
* 恢复素材(元数据到 IndexedDB + 二进制到 Cache Storage)
*/
async function restoreAssets(zip, onProgress) {
const assetsFolder = zip.folder('assets');
if (!assetsFolder) return 0;
let count = 0;
const cache = await caches.open(CACHE_NAMES.IMAGES);
// 收集所有 .meta.json 文件
const metaFiles = [];
assetsFolder.forEach((relativePath, file) => {
if (file.name.endsWith('.meta.json') && !file.dir) {
metaFiles.push({ relativePath, file });
}
});
const totalItems = metaFiles.length;
let processedCount = 0;
for (const { relativePath, file } of metaFiles) {
try {
const meta = JSON.parse(await file.async('string'));
const assetId = meta.id;
if (!assetId) { processedCount++; continue; }
// 查找对应的二进制文件
const binaryFile = findBinaryFile(assetsFolder, relativePath, assetId);
if (binaryFile) {
const blob = await binaryFile.async('blob');
if (blob.size > 0 && meta.url) {
// 写入 Cache Storage(为每个 URL 创建独立 Response)
const contentType = meta.mimeType || blob.type || 'application/octet-stream';
const response = new Response(blob, {
headers: { 'Content-Type': contentType },
});
await cache.put(meta.url, response);
}
}
// 写入元数据到对应的 IndexedDB
if (meta.source === 'AI_GENERATED') {
// AI 生成的素材 → unified-cache
const cacheItem = {
url: meta.url,
type: meta.type === 'VIDEO' ? 'video' : 'image',
mimeType: meta.mimeType,
size: meta.size,
cachedAt: meta.createdAt,
lastUsed: meta.updatedAt || meta.createdAt,
metadata: meta.metadata,
};
await writeToIDB(IDB_STORES.UNIFIED_CACHE.name, IDB_STORES.UNIFIED_CACHE.store, cacheItem);
} else {
// 本地素材 → aitu-assets
await writeToIDB(IDB_STORES.ASSETS.name, IDB_STORES.ASSETS.store, meta);
}
count++;
} catch (err) {
console.debug('[Restore] Failed to restore asset:', relativePath, err);
}
processedCount++;
if (onProgress) onProgress(processedCount, totalItems);
}
return count;
}
/**
* 查找 meta 文件对应的二进制文件
*/
function findBinaryFile(assetsFolder, metaRelativePath, assetId) {
// meta 文件路径: "assetId.meta.json",二进制文件: "assetId.ext"
const basePath = metaRelativePath.replace('.meta.json', '');
const extensions = ['.jpg', '.png', '.gif', '.webp', '.svg', '.mp4', '.webm', '.mov', ''];
for (const ext of extensions) {
const file = assetsFolder.file(basePath + ext);
if (file && !file.dir) return file;
}
return null;
}
/**
* 显示恢复进度条
*/
function showRestoreProgress() {
const container = document.createElement('div');
container.className = 'backup-progress-container';
container.innerHTML = `
<div class="backup-progress-content">
<div class="backup-progress-header">
<span class="backup-progress-icon">📥</span>
<span class="backup-progress-title">正在恢复数据</span>
</div>
<div class="backup-progress-bar">
<div class="backup-progress-fill" style="width: 0%"></div>
</div>
<div class="backup-progress-text">准备中...</div>
</div>
`;
document.body.appendChild(container);
return container;
}
/**
* 显示恢复成功通知
*/
function showRestoreSuccessNotification(stats, manifest) {
const partInfo = manifest.partIndex
? ` (分片 ${manifest.partIndex}${manifest.totalParts ? '/' + manifest.totalParts : ''})`
: '';
const notification = document.createElement('div');
notification.className = 'import-notification restore-notification';
notification.innerHTML = `
<div class="import-notification-content">
<span class="icon">✅</span>
<div class="info">
<strong>恢复成功${partInfo}</strong>
<p class="counts">
${stats.prompts > 0 ? `新增提示词: ${stats.prompts}` : ''}
${stats.projects > 0 ? `${stats.prompts > 0 ? ' | ' : ''}画板: ${stats.projects}` : ''}
${stats.tasks > 0 ? `${(stats.prompts + stats.projects) > 0 ? ' | ' : ''}任务: ${stats.tasks}` : ''}
${stats.assets > 0 ? `${(stats.prompts + stats.projects + stats.tasks) > 0 ? ' | ' : ''}素材: ${stats.assets}` : ''}
${(stats.prompts + stats.projects + stats.tasks + stats.assets) === 0 ? '所有数据已是最新,无需更新' : ''}
</p>
</div>
<button class="close" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, 5000);
}
+146 -249
View File
@@ -1,6 +1,5 @@
/**
* SW Debug Panel - Data Backup
* 数据备份功能模块
* SW Debug Panel - Data Backup(支持自动分片)
*/
import { elements } from './state.js';
@@ -15,17 +14,18 @@ import {
readKVItem,
} from './indexeddb.js';
import { showToast } from './toast.js';
import { BackupPartManager } from './backup-part-manager.js';
/**
* 备份签名和版本
*/
const BACKUP_SIGNATURE = 'aitu-backup';
const BACKUP_VERSION = 2;
export const BACKUP_SIGNATURE = 'aitu-backup';
export const BACKUP_VERSION = 3;
/**
* 获取文件扩展名
* 获取文件扩展名(导出供 restore 复用)
*/
function getExtensionFromMimeType(mimeType) {
export function getExtensionFromMimeType(mimeType) {
const mimeToExt = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
@@ -41,9 +41,9 @@ function getExtensionFromMimeType(mimeType) {
}
/**
* 清理文件/文件夹名称
* 清理文件/文件夹名称(导出供 restore 复用)
*/
function sanitizeFileName(name) {
export function sanitizeFileName(name) {
return (
name
.replace(/[<>:"/\\|?*]/g, '_')
@@ -53,17 +53,27 @@ function sanitizeFileName(name) {
}
/**
* 等待 JSZip 加载完成
* @param {number} timeout - 超时时间(毫秒)
* @returns {Promise<boolean>}
* 从 URL 生成唯一 ID(导出供 restore 复用)
*/
function waitForJSZip(timeout = 5000) {
export function generateIdFromUrl(url) {
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return `cache-${Math.abs(hash).toString(36)}`;
}
/**
* 等待 JSZip 加载完成
*/
export function waitForJSZip(timeout = 5000) {
return new Promise((resolve) => {
if (typeof JSZip !== 'undefined') {
resolve(true);
return;
}
const startTime = Date.now();
const checkInterval = setInterval(() => {
if (typeof JSZip !== 'undefined') {
@@ -83,46 +93,27 @@ function waitForJSZip(timeout = 5000) {
export async function performBackup() {
const btn = elements.backupDataBtn;
if (!btn) return;
const originalText = btn.innerHTML;
try {
btn.disabled = true;
btn.innerHTML = '⏳ 加载中...';
// 等待 JSZip 加载
const jsZipLoaded = await waitForJSZip(5000);
if (!jsZipLoaded) {
throw new Error('JSZip 库加载超时,请检查网络连接后重试');
}
btn.innerHTML = '⏳ 准备中...';
const zip = new JSZip();
const manifest = {
signature: BACKUP_SIGNATURE,
version: BACKUP_VERSION,
createdAt: Date.now(),
source: 'sw-debug-panel',
includes: {
prompts: true,
projects: true,
assets: true,
},
stats: {
promptCount: 0,
videoPromptCount: 0,
imagePromptCount: 0,
folderCount: 0,
boardCount: 0,
assetCount: 0,
taskCount: 0,
},
};
// 显示进度条
// 生成文件名前缀
const date = new Date();
const dateStr = date.toISOString().split('T')[0];
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '');
const baseFilename = `aitu_backup_${dateStr}_${timeStr}`;
const backupId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const progressContainer = showBackupProgress();
const updateProgress = (percent, text) => {
const progressBar = progressContainer.querySelector('.backup-progress-fill');
@@ -130,94 +121,84 @@ export async function performBackup() {
if (progressBar) progressBar.style.width = `${percent}%`;
if (progressText) progressText.textContent = text;
};
// 0. 先收集任务数据(后面提示词收集需要用到)
const partManager = new BackupPartManager(baseFilename, backupId);
const manifest = {
signature: BACKUP_SIGNATURE,
version: BACKUP_VERSION,
createdAt: Date.now(),
source: 'sw-debug-panel',
backupId,
includes: { prompts: true, projects: true, tasks: true, assets: true },
stats: {
promptCount: 0, videoPromptCount: 0, imagePromptCount: 0,
folderCount: 0, boardCount: 0, assetCount: 0, taskCount: 0,
},
};
// 0. 任务数据
updateProgress(5, '正在读取任务数据...');
const allTasks = await collectTasksData();
// 1. 收集提示词数据(会从任务中提取提示词合并)
// 1. 提示词(非素材,放 Part1)
updateProgress(15, '正在备份提示词...');
const promptsData = await collectPromptsData(allTasks);
zip.file('prompts.json', JSON.stringify(promptsData, null, 2));
partManager.addFile('prompts.json', promptsData);
manifest.stats.promptCount = promptsData.promptHistory?.length || 0;
manifest.stats.videoPromptCount = promptsData.videoPromptHistory?.length || 0;
manifest.stats.imagePromptCount = promptsData.imagePromptHistory?.length || 0;
// 2. 收集项目数据
// 2. 项目数据(非素材,放 Part1)
updateProgress(25, '正在备份项目...');
const projectStats = await collectProjectsData(zip);
const projectStats = await collectProjectsData(partManager.currentZip);
manifest.stats.folderCount = projectStats.folders;
manifest.stats.boardCount = projectStats.boards;
// 3. 收集素材数据(进度回调)
updateProgress(35, '正在备份素材...');
const assetCount = await collectAssetsData(zip, (current, total) => {
const percent = 35 + Math.round((current / total) * 40);
updateProgress(percent, `正在备份素材 (${current}/${total})...`);
});
manifest.stats.assetCount = assetCount;
// 4. 导出已完成的媒体任务数据(素材库展示需要)
updateProgress(80, '正在导出任务数据...');
// 3. 任务数据(非素材,放 Part1)
updateProgress(30, '正在导出任务数据...');
const completedMediaTasks = allTasks.filter(
task => task.status === TaskStatus.COMPLETED &&
(task.type === TaskType.IMAGE || task.type === TaskType.VIDEO) &&
task.result?.url
);
if (completedMediaTasks.length > 0) {
zip.file('tasks.json', JSON.stringify(completedMediaTasks, null, 2));
partManager.addFile('tasks.json', completedMediaTasks);
manifest.stats.taskCount = completedMediaTasks.length;
}
// 5. 写入清单文件
zip.file('manifest.json', JSON.stringify(manifest, null, 2));
// 6. 生成并下载 ZIP 文件
updateProgress(85, '正在压缩文件...');
const blob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
}, (metadata) => {
const percent = 85 + Math.round(metadata.percent * 0.14);
updateProgress(percent, '正在压缩文件...');
// 4. 素材数据(通过 partManager 自动分片)
updateProgress(35, '正在备份素材...');
const assetCount = await collectAssetsData(partManager, (current, total) => {
const percent = 35 + Math.round((current / total) * 50);
updateProgress(percent, `正在备份素材 (${current}/${total})...`);
});
// 下载文件
updateProgress(100, '备份完成!');
const date = new Date();
const dateStr = date.toISOString().split('T')[0];
const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '');
const filename = `aitu_backup_${dateStr}_${timeStr}.zip`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
manifest.stats.assetCount = assetCount;
// 5. finalize 所有分片
updateProgress(88, '正在压缩文件...');
const result = await partManager.finalizeAll(manifest);
// 关闭进度条,显示成功信息
updateProgress(100, '备份完成!');
setTimeout(() => {
progressContainer.remove();
const sizeInMB = (blob.size / 1024 / 1024).toFixed(2);
const totalSize = result.files.reduce((sum, f) => sum + f.size, 0);
const sizeInMB = (totalSize / 1024 / 1024).toFixed(2);
showBackupSuccessNotification({
filename,
files: result.files,
totalParts: result.totalParts,
size: sizeInMB,
stats: manifest.stats,
});
}, 500);
btn.innerHTML = originalText;
btn.disabled = false;
} catch (error) {
// 关闭进度条
const progressContainer = document.querySelector('.backup-progress-container');
if (progressContainer) progressContainer.remove();
showToast('备份失败: ' + error.message, 'error', 5000);
btn.innerHTML = originalText;
btn.disabled = false;
@@ -226,7 +207,6 @@ export async function performBackup() {
/**
* 收集任务数据
* 从 sw-task-queue 数据库读取所有任务
*/
async function collectTasksData() {
try {
@@ -240,9 +220,6 @@ async function collectTasksData() {
/**
* 收集提示词数据
* 合并两个来源:
* 1. IndexedDB 中的提示词历史
* 2. 任务队列中已完成任务的提示词
*/
async function collectPromptsData(allTasks = []) {
const [promptHistory, videoPromptHistory, imagePromptHistory, presetSettings] = await Promise.all([
@@ -251,15 +228,13 @@ async function collectPromptsData(allTasks = []) {
readKVItem(KV_KEYS.IMAGE_PROMPT_HISTORY),
readKVItem(KV_KEYS.PRESET_SETTINGS),
]);
let finalPromptHistory = promptHistory || [];
let finalVideoPromptHistory = videoPromptHistory || [];
let finalImagePromptHistory = imagePromptHistory || [];
// 从已完成的任务中提取提示词
const completedTasks = allTasks.filter(task => task.status === TaskStatus.COMPLETED);
// 提取图片任务的提示词
const imageTaskPrompts = completedTasks
.filter(task => task.type === TaskType.IMAGE && task.params?.prompt)
.map(task => ({
@@ -268,8 +243,7 @@ async function collectPromptsData(allTasks = []) {
timestamp: task.completedAt || task.createdAt,
}))
.filter(item => item.content && item.content.length > 0);
// 提取视频任务的提示词
const videoTaskPrompts = completedTasks
.filter(task => task.type === TaskType.VIDEO && task.params?.prompt)
.map(task => ({
@@ -278,17 +252,15 @@ async function collectPromptsData(allTasks = []) {
timestamp: task.completedAt || task.createdAt,
}))
.filter(item => item.content && item.content.length > 0);
// 合并图片提示词(去重)
const existingImageContents = new Set(finalImagePromptHistory.map(p => p.content));
const newImagePrompts = imageTaskPrompts.filter(p => !existingImageContents.has(p.content));
finalImagePromptHistory = [...finalImagePromptHistory, ...newImagePrompts];
// 合并视频提示词(去重)
const existingVideoContents = new Set(finalVideoPromptHistory.map(p => p.content));
const newVideoPrompts = videoTaskPrompts.filter(p => !existingVideoContents.has(p.content));
finalVideoPromptHistory = [...finalVideoPromptHistory, ...newVideoPrompts];
return {
promptHistory: finalPromptHistory,
videoPromptHistory: finalVideoPromptHistory,
@@ -300,38 +272,29 @@ async function collectPromptsData(allTasks = []) {
};
}
/**
* 收集项目数据
* 从 aitu-workspace 数据库的 folders 和 boards store 读取
*/
/** 收集项目数据 */
async function collectProjectsData(zip) {
const projectsFolder = zip.folder('projects');
// 从独立的 store 读取,而不是 KV 存储
const [folders, boards] = await Promise.all([
readAllFromIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.FOLDERS),
readAllFromIDB(IDB_STORES.WORKSPACE.name, IDB_STORES.WORKSPACE.stores.BOARDS),
]);
const folderList = folders || [];
const boardList = boards || [];
// 构建文件夹路径映射
const folderPathMap = new Map();
const folderMap = new Map();
for (const folder of folderList) {
folderMap.set(folder.id, folder);
}
const getPath = (folderId) => {
if (folderPathMap.has(folderId)) {
return folderPathMap.get(folderId);
}
if (folderPathMap.has(folderId)) return folderPathMap.get(folderId);
const folder = folderMap.get(folderId);
if (!folder) return '';
const safeName = sanitizeFileName(folder.name);
if (folder.parentId) {
const parentPath = getPath(folder.parentId);
@@ -339,170 +302,116 @@ async function collectProjectsData(zip) {
folderPathMap.set(folderId, fullPath);
return fullPath;
}
folderPathMap.set(folderId, safeName);
return safeName;
};
for (const folder of folderList) {
getPath(folder.id);
}
// 创建文件夹结构
for (const folder of folderList) getPath(folder.id);
for (const folder of folderList) {
const path = folderPathMap.get(folder.id) || folder.name;
projectsFolder.folder(path);
}
// 导出画板
for (const board of boardList) {
const folderPath = board.folderId ? folderPathMap.get(board.folderId) : null;
const safeName = sanitizeFileName(board.name);
const boardPath = folderPath
? `${folderPath}/${safeName}.drawnix`
: `${safeName}.drawnix`;
const boardPath = folderPath ? `${folderPath}/${safeName}.drawnix` : `${safeName}.drawnix`;
const drawnixData = {
type: 'drawnix',
version: 1,
source: 'backup',
type: 'drawnix', version: 1, source: 'backup',
elements: board.elements || [],
viewport: board.viewport || { zoom: 1 },
theme: board.theme,
boardMeta: {
id: board.id,
name: board.name,
folderId: board.folderId,
order: board.order,
createdAt: board.createdAt,
updatedAt: board.updatedAt,
id: board.id, name: board.name, folderId: board.folderId,
order: board.order, createdAt: board.createdAt, updatedAt: board.updatedAt,
},
};
projectsFolder.file(boardPath, JSON.stringify(drawnixData, null, 2));
}
return {
folders: folderList.length,
boards: boardList.length,
};
return { folders: folderList.length, boards: boardList.length };
}
/**
* 从 URL 生成唯一 ID(与应用层保持一致)
* 收集素材数据(使用 partManager 自动分片)
* @param {BackupPartManager} partManager
* @param {Function} onProgress
*/
function generateIdFromUrl(url) {
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return `cache-${Math.abs(hash).toString(36)}`;
}
/**
* 收集素材数据
* 三个数据源:
* 1. aitu-assets 数据库(本地素材库元数据)
* 2. drawnix-unified-cache 数据库(AI 生成媒体元数据)
* 3. drawnix-images Cache Storage(媒体二进制数据)
* @param {JSZip} zip - ZIP 实例
* @param {Function} onProgress - 进度回调 (current, total)
*/
async function collectAssetsData(zip, onProgress) {
const assetsFolder = zip.folder('assets');
async function collectAssetsData(partManager, onProgress) {
let exportedCount = 0;
const exportedUrls = new Set();
try {
// 打开 Cache Storage
const cache = await caches.open(CACHE_NAMES.IMAGES);
// 1. 从 aitu-assets 数据库读取本地素材元数据
const assetMetaList = await readAllFromIDB(IDB_STORES.ASSETS.name, IDB_STORES.ASSETS.store);
// 2. 从 drawnix-unified-cache 数据库读取 AI 生成媒体元数据
const unifiedCacheItems = await readAllFromIDB(IDB_STORES.UNIFIED_CACHE.name, IDB_STORES.UNIFIED_CACHE.store);
// 3. 获取虚拟路径缓存
const cacheKeys = await cache.keys();
const virtualRequests = cacheKeys.filter(req => req.url.includes('/__aitu_cache__/'));
// 计算总数用于进度显示
const totalItems = assetMetaList.length + unifiedCacheItems.length + virtualRequests.length;
let processedCount = 0;
// 1. 导出本地素材
// 1. 本地素材
for (const asset of assetMetaList) {
try {
assetsFolder.file(`${asset.id}.meta.json`, JSON.stringify(asset, null, 2));
if (asset.url) {
const response = await cache.match(asset.url);
if (response) {
const blob = await response.blob();
if (blob.size > 0) {
const ext = getExtensionFromMimeType(asset.mimeType || blob.type);
assetsFolder.file(`${asset.id}${ext}`, blob);
await partManager.addAssetBlob(
`${asset.id}${ext}`, blob,
`${asset.id}.meta.json`, asset
);
exportedUrls.add(asset.url);
exportedCount++;
}
}
}
} catch (err) {
// 静默处理错误
}
} catch (err) { /* 静默 */ }
processedCount++;
if (onProgress) onProgress(processedCount, totalItems);
}
// 2. 导出 unified-cache 中的素材
// 2. unified-cache 素材
const newCacheItems = unifiedCacheItems.filter(item => !exportedUrls.has(item.url));
for (const item of newCacheItems) {
try {
const itemId = item.metadata?.taskId || generateIdFromUrl(item.url);
const metaData = {
id: itemId,
url: item.url,
id: itemId, url: item.url,
type: item.type === 'video' ? 'VIDEO' : 'IMAGE',
mimeType: item.mimeType,
size: item.size,
mimeType: item.mimeType, size: item.size,
source: 'AI_GENERATED',
createdAt: item.cachedAt,
updatedAt: item.lastUsed,
createdAt: item.cachedAt, updatedAt: item.lastUsed,
metadata: item.metadata,
};
assetsFolder.file(`${itemId}.meta.json`, JSON.stringify(metaData, null, 2));
const response = await cache.match(item.url);
if (response) {
const blob = await response.blob();
if (blob.size > 0) {
const ext = getExtensionFromMimeType(item.mimeType);
assetsFolder.file(`${itemId}${ext}`, blob);
await partManager.addAssetBlob(
`${itemId}${ext}`, blob,
`${itemId}.meta.json`, metaData
);
exportedUrls.add(item.url);
exportedCount++;
}
}
} catch (err) {
// 静默处理错误
}
} catch (err) { /* 静默 */ }
processedCount++;
if (onProgress) onProgress(processedCount, totalItems);
}
// 跳过已处理的 unified-cache items
processedCount += (unifiedCacheItems.length - newCacheItems.length);
// 3. 导出虚拟路径缓存中的媒体(可能有些不在 unified-cache 中)
// 3. 虚拟路径缓存
const pendingVirtualRequests = virtualRequests.filter(req => !exportedUrls.has(req.url));
for (const request of pendingVirtualRequests) {
const url = request.url;
try {
const response = await cache.match(request);
if (response) {
@@ -511,37 +420,24 @@ async function collectAssetsData(zip, onProgress) {
const urlParts = url.split('/');
const filename = urlParts[urlParts.length - 1];
const id = filename.split('.')[0] || `cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contentType = response.headers.get('content-type') || blob.type;
const ext = getExtensionFromMimeType(contentType);
const type = contentType.startsWith('video/') ? 'VIDEO' : 'IMAGE';
const metadata = {
id,
url,
type,
mimeType: contentType,
size: blob.size,
source: 'AI_GENERATED',
createdAt: Date.now(),
};
assetsFolder.file(`${id}.meta.json`, JSON.stringify(metadata, null, 2));
assetsFolder.file(`${id}${ext}`, blob);
const metadata = { id, url, type, mimeType: contentType, size: blob.size, source: 'AI_GENERATED', createdAt: Date.now() };
await partManager.addAssetBlob(
`${id}${ext}`, blob,
`${id}.meta.json`, metadata
);
exportedUrls.add(url);
exportedCount++;
}
}
} catch (err) {
// 静默处理错误
}
} catch (err) { /* 静默 */ }
processedCount++;
if (onProgress) onProgress(processedCount, totalItems);
}
} catch (error) {
// 静默处理错误
}
} catch (error) { /* 静默 */ }
return exportedCount;
}
@@ -570,15 +466,18 @@ function showBackupProgress() {
/**
* 显示备份成功通知
*/
function showBackupSuccessNotification({ filename, size, stats }) {
function showBackupSuccessNotification({ files, totalParts, size, stats }) {
const notification = document.createElement('div');
notification.className = 'import-notification backup-notification';
const fileInfo = totalParts > 1
? `${totalParts} 个分片文件`
: files[0]?.filename || 'backup.zip';
notification.innerHTML = `
<div class="import-notification-content">
<span class="icon">✅</span>
<div class="info">
<strong>备份成功</strong>
<p>${filename}</p>
<p>${fileInfo}</p>
<p class="counts">
文件大小: ${size} MB
${stats.boardCount > 0 ? `| 画板: ${stats.boardCount}` : ''}
@@ -592,12 +491,10 @@ function showBackupSuccessNotification({ filename, size, stats }) {
<button class="close" onclick="this.parentElement.parentElement.remove()">×</button>
</div>
`;
document.body.appendChild(notification);
// 5 秒后自动消失
setTimeout(() => {
notification.classList.add('fade-out');
setTimeout(() => notification.remove(), 300);
}, 5000);
}
}
+54
View File
@@ -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); };
});
}
+5
View File
@@ -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;
+5
View File
@@ -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) {
+3
View File
@@ -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'),
};
}
+30
View File
@@ -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;
+1 -1
View File
@@ -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策略"
+19 -11
View File
@@ -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('<!DOCTYPE') ||
textSample.includes('<html') ||
textSample.includes('<HTML') ||
textSample.includes('Not Found') ||
textSample.includes('404');
if (isTextResource && looksLikeHtml) {
console.warn(`[CDN Fallback] ${cdn.name} returned HTML instead of ${contentType}`);
markCDNFailure(cdn.name);
continue;
const reader = clonedResponse.body?.getReader();
if (reader) {
const { value } = await reader.read();
reader.cancel(); // 立即释放流,不读取剩余数据
if (value) {
const textSample = new TextDecoder().decode(value.slice(0, 200));
const looksLikeHtml = textSample.includes('<!DOCTYPE') ||
textSample.includes('<html') ||
textSample.includes('<HTML') ||
textSample.includes('Not Found') ||
textSample.includes('404');
if (isTextResource && looksLikeHtml) {
console.warn(`[CDN Fallback] ${cdn.name} returned HTML instead of ${contentType}`);
markCDNFailure(cdn.name);
continue;
}
}
}
} catch (sampleError) {
// 采样失败不阻止使用(可能是二进制文件)
+1 -1
View File
@@ -655,7 +655,7 @@ interface ReferenceImageUploadProps {
支持将用户数据(提示词、项目、素材)导出为 ZIP 文件,并从 ZIP 文件恢复数据。
**核心文件**:
- `services/backup-restore-service.ts` - 备份恢复服务
- `services/backup-restore/` - 备份恢复服务(支持自动分片)
- `components/backup-restore/backup-restore-dialog.tsx` - UI 对话框
**功能特点**:
@@ -14,7 +14,8 @@ import {
BackupOptions,
ImportResult,
BackupWorkspaceState,
} from '../../services/backup-restore-service';
ExportResult,
} from '../../services/backup-restore';
import { workspaceService } from '../../services/workspace-service';
import { safeReload } from '../../utils/active-tasks';
import './backup-restore-dialog.scss';
@@ -133,7 +134,7 @@ export const BackupRestoreDialog = ({
setProgressMessage('正在准备...');
try {
const blob = await backupRestoreService.exportToZip(
const result: ExportResult = await backupRestoreService.exportToZip(
backupOptions,
(p, msg) => {
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);
@@ -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 => {
+61 -29
View File
@@ -169,8 +169,14 @@ function getFriendlyErrorMessage(error: any): string {
* return <YourComponents />;
* }
*/
// 最大并发任务数,防止页面加载时大量任务同时执行导致 OOM
const MAX_CONCURRENT_TASKS = 3;
// 页面加载后延迟执行积压任务,避免与页面初始化竞争资源
const STARTUP_DELAY_MS = 2000;
export function useTaskExecutor(): void {
const executingTasksRef = useRef<Set<string>>(new Set());
const pendingQueueRef = useRef<Task[]>([]);
const timeoutCheckIntervalRef = useRef<NodeJS.Timeout | null>(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);
+68 -84
View File
@@ -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<Task[]>([]);
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<typeof setTimeout> | null = null;
const loadTasks = async (): Promise<boolean> => {
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 () => {
+3 -2
View File
@@ -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(
File diff suppressed because it is too large. Load diff
@@ -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<ExportResult> {
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<PromptsData> {
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<PresetStorageData>(
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<void> {
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<string, string> {
const pathMap = new Map<string, string>();
const folderMap = new Map<string, Folder>();
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<void> {
const exportedUrls = new Set<string>();
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<import('../../types/asset.types').StoredAsset>(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();
@@ -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<ImportResult> {
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<PresetStorageData>(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<string>();
const folderIdMap = new Map<string, string>();
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();
@@ -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<void> {
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<void> {
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<ExportResult> {
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 };
}
}
@@ -0,0 +1,41 @@
/**
* Backup & Restore - Shared Utilities
*/
export function getExtensionFromMimeType(mimeType: string): string {
const mimeToExt: Record<string, string> = {
'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)}`;
}
@@ -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<ExportResult> {
return backupExportService.exportToZip(options, onProgress);
}
importFromZip(file: File, onProgress?: ProgressCallback): Promise<ImportResult> {
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';
@@ -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;
});
}
@@ -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());
@@ -166,7 +166,8 @@ class TaskStorageReader extends BaseStorageReader<TaskCache> {
// 按创建时间倒序排序
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));
};