/** * Draft 持久化抽象层 * * 默认实现:IndexedDbDraftStorage(localStorage fallback) * - tiktok.drafts.index → DraftMeta[] 列表渲染快 * - tiktok.draft. → 完整 PipelineDraft(按需 lazy load) * * 后续可继续升级到远端 /api/drafts,业务零改动。 */ import { DraftMeta, PipelineDraft, toDraftMeta } from '../models/pipeline-draft.model'; export interface DraftStorage { list(): Promise; get(id: string): Promise; put(draft: PipelineDraft): Promise; delete(id: string): Promise; /** 清空所有 drafts(仅调试 / 用户主动调用) */ clear(): Promise; } const INDEX_KEY = 'tiktok.drafts.index'; const DRAFT_KEY_PREFIX = 'tiktok.draft.'; const IDB_DB_NAME = 'videoWorkflow.workspaceStorage'; const IDB_DB_VERSION = 1; const IDB_DRAFT_STORE = 'drafts'; const IDB_DRAFT_INDEX_STORE = 'draftIndex'; const IDB_SCOPE_INDEX = 'scope'; const IDB_MIGRATION_KEY_PREFIX = 'videoWorkflow.storageMigration.draftsIndexedDb.v1.'; export class LocalStorageDraftStorage implements DraftStorage { constructor(private readonly scope = '') {} private key(base: string): string { if (!this.scope) return base; const safeScope = this.scope.replace(/[^a-zA-Z0-9_.:-]/g, '_'); return `${base}.${safeScope}`; } private draftKey(id: string): string { if (!this.scope) return DRAFT_KEY_PREFIX + id; const safeScope = this.scope.replace(/[^a-zA-Z0-9_.:-]/g, '_'); return `${DRAFT_KEY_PREFIX}${safeScope}.${id}`; } async list(): Promise { try { const raw = localStorage.getItem(this.key(INDEX_KEY)); if (!raw) return []; const arr = JSON.parse(raw); return Array.isArray(arr) ? arr : []; } catch (e) { console.warn('[DraftStorage] list parse failed', e); return []; } } async get(id: string): Promise { try { const raw = localStorage.getItem(this.draftKey(id)); if (!raw) return null; return JSON.parse(raw) as PipelineDraft; } catch (e) { console.warn('[DraftStorage] get parse failed', id, e); return null; } } async put(draft: PipelineDraft): Promise { try { localStorage.setItem(this.draftKey(draft.id), JSON.stringify(draft)); } catch (e) { // 配额溢出:尝试清最旧的草稿后重试一次 console.warn('[DraftStorage] put failed, trying to evict oldest', e); await this.evictOldest(); try { localStorage.setItem(this.draftKey(draft.id), JSON.stringify(draft)); } catch (e2) { console.error('[DraftStorage] put failed after evict', e2); throw e2; } } // 同步更新索引 const list = await this.list(); const idx = list.findIndex((m) => m.id === draft.id); const meta = toDraftMeta(draft); if (idx >= 0) list[idx] = meta; else list.unshift(meta); list.sort((a, b) => b.updatedAt - a.updatedAt); try { localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(list)); } catch (e) { console.warn('[DraftStorage] index update failed', e); } } async delete(id: string): Promise { try { localStorage.removeItem(this.draftKey(id)); } catch {} const list = await this.list(); const next = list.filter((m) => m.id !== id); try { localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(next)); } catch (e) { console.warn('[DraftStorage] index delete failed', e); } } async clear(): Promise { const list = await this.list(); for (const m of list) { try { localStorage.removeItem(this.draftKey(m.id)); } catch {} } try { localStorage.removeItem(this.key(INDEX_KEY)); } catch {} } /** 配额溢出回收:移除最旧的非 running draft */ private async evictOldest(): Promise { const list = await this.list(); const evictable = list .filter((m) => m.status !== 'running') .sort((a, b) => a.updatedAt - b.updatedAt); if (!evictable.length) return; const victim = evictable[0]; try { localStorage.removeItem(this.draftKey(victim.id)); const next = list.filter((m) => m.id !== victim.id); localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(next)); console.warn('[DraftStorage] evicted oldest draft', victim.id); } catch {} } } interface ScopedDraftRecord { storageKey: string; scope: string; draft: PipelineDraft; } interface ScopedDraftMetaRecord { storageKey: string; scope: string; meta: DraftMeta; } export class IndexedDbDraftStorage implements DraftStorage { private dbPromise: Promise | null = null; private readonly fallback: LocalStorageDraftStorage; private readonly safeScope: string; constructor(private readonly scope = '') { this.safeScope = this.normalizeScope(scope); this.fallback = new LocalStorageDraftStorage(scope); } async list(): Promise { try { await this.ensureMigrated(); const db = await this.openDb(); const records = await this.getAllByScope(db, IDB_DRAFT_INDEX_STORE); return records .map((record) => record.meta) .sort((a, b) => b.updatedAt - a.updatedAt); } catch (error) { console.warn('[IndexedDbDraftStorage] list failed, using localStorage fallback', error); return this.fallback.list(); } } async get(id: string): Promise { try { await this.ensureMigrated(); const db = await this.openDb(); const record = await this.requestToPromise( db.transaction(IDB_DRAFT_STORE, 'readonly').objectStore(IDB_DRAFT_STORE).get(this.storageKey(id)), ); return record?.draft || null; } catch (error) { console.warn('[IndexedDbDraftStorage] get failed, using localStorage fallback', id, error); return this.fallback.get(id); } } async put(draft: PipelineDraft): Promise { try { await this.ensureMigrated(); const db = await this.openDb(); await this.putRecord(db, draft); } catch (error) { console.warn('[IndexedDbDraftStorage] put failed, using localStorage fallback', error); await this.fallback.put(draft); } } async delete(id: string): Promise { try { await this.ensureMigrated(); const db = await this.openDb(); const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite'); tx.objectStore(IDB_DRAFT_STORE).delete(this.storageKey(id)); tx.objectStore(IDB_DRAFT_INDEX_STORE).delete(this.storageKey(id)); await this.transactionDone(tx); } catch (error) { console.warn('[IndexedDbDraftStorage] delete failed, using localStorage fallback', id, error); await this.fallback.delete(id); } } async clear(): Promise { try { await this.ensureMigrated(); const db = await this.openDb(); const drafts = await this.getAllByScope(db, IDB_DRAFT_INDEX_STORE); const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite'); for (const record of drafts) { tx.objectStore(IDB_DRAFT_STORE).delete(record.storageKey); tx.objectStore(IDB_DRAFT_INDEX_STORE).delete(record.storageKey); } await this.transactionDone(tx); } catch (error) { console.warn('[IndexedDbDraftStorage] clear failed, using localStorage fallback', error); await this.fallback.clear(); } } private async ensureMigrated(): Promise { if (this.hasMigrationMarker()) return; const db = await this.openDb(); const existingCount = await this.countByScope(db, IDB_DRAFT_INDEX_STORE); if (existingCount === 0) { const metas = await this.fallback.list(); for (const meta of metas) { const draft = await this.fallback.get(meta.id); if (draft) await this.putRecord(db, draft); } } this.setMigrationMarker(); } private async putRecord(db: IDBDatabase, draft: PipelineDraft): Promise { const storageKey = this.storageKey(draft.id); const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite'); tx.objectStore(IDB_DRAFT_STORE).put({ storageKey, scope: this.safeScope, draft, } satisfies ScopedDraftRecord); tx.objectStore(IDB_DRAFT_INDEX_STORE).put({ storageKey, scope: this.safeScope, meta: toDraftMeta(draft), } satisfies ScopedDraftMetaRecord); await this.transactionDone(tx); } private openDb(): Promise { if (this.dbPromise) return this.dbPromise; if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB 不可用')); this.dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_DRAFT_STORE)) { const store = db.createObjectStore(IDB_DRAFT_STORE, { keyPath: 'storageKey' }); store.createIndex(IDB_SCOPE_INDEX, 'scope', { unique: false }); } if (!db.objectStoreNames.contains(IDB_DRAFT_INDEX_STORE)) { const store = db.createObjectStore(IDB_DRAFT_INDEX_STORE, { keyPath: 'storageKey' }); store.createIndex(IDB_SCOPE_INDEX, 'scope', { unique: false }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败')); req.onblocked = () => reject(new Error('IndexedDB 被其他页面占用,请关闭旧页面后重试')); }); return this.dbPromise; } private async getAllByScope(db: IDBDatabase, storeName: string): Promise { const store = db.transaction(storeName, 'readonly').objectStore(storeName); const index = store.index(IDB_SCOPE_INDEX); return this.requestToPromise(index.getAll(this.safeScope)); } private async countByScope(db: IDBDatabase, storeName: string): Promise { const store = db.transaction(storeName, 'readonly').objectStore(storeName); return this.requestToPromise(store.index(IDB_SCOPE_INDEX).count(this.safeScope)); } private requestToPromise(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } private transactionDone(tx: IDBTransaction): Promise { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error || new Error('IndexedDB 事务已中止')); }); } private storageKey(id: string): string { return `${this.safeScope}::${id}`; } private normalizeScope(scope: string): string { return (scope || 'default').replace(/[^a-zA-Z0-9_.:-]/g, '_'); } private hasMigrationMarker(): boolean { try { return localStorage.getItem(IDB_MIGRATION_KEY_PREFIX + this.safeScope) === 'done'; } catch { return false; } } private setMigrationMarker(): void { try { localStorage.setItem(IDB_MIGRATION_KEY_PREFIX + this.safeScope, 'done'); } catch {} } }