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:
1 parent
75161b60b3
commit
7d76899a22
28 files changed
+2217
-1768
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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); };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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策略"
|
||||
|
||||
@@ -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) {
|
||||
// 采样失败不阻止使用(可能是二进制文件)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user