| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- /**
- * Draft 持久化抽象层
- *
- * 默认实现:IndexedDbDraftStorage(localStorage fallback)
- * - tiktok.drafts.index → DraftMeta[] 列表渲染快
- * - tiktok.draft.<id> → 完整 PipelineDraft(按需 lazy load)
- *
- * 后续可继续升级到远端 /api/drafts,业务零改动。
- */
- import { DraftMeta, PipelineDraft, toDraftMeta } from '../models/pipeline-draft.model';
- export interface DraftStorage {
- list(): Promise<DraftMeta[]>;
- get(id: string): Promise<PipelineDraft | null>;
- put(draft: PipelineDraft): Promise<void>;
- delete(id: string): Promise<void>;
- /** 清空所有 drafts(仅调试 / 用户主动调用) */
- clear(): Promise<void>;
- }
- 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<DraftMeta[]> {
- 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<PipelineDraft | null> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<void> {
- 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<IDBDatabase> | 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<DraftMeta[]> {
- try {
- await this.ensureMigrated();
- const db = await this.openDb();
- const records = await this.getAllByScope<ScopedDraftMetaRecord>(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<PipelineDraft | null> {
- try {
- await this.ensureMigrated();
- const db = await this.openDb();
- const record = await this.requestToPromise<ScopedDraftRecord | undefined>(
- 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<void> {
- 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<void> {
- 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<void> {
- try {
- await this.ensureMigrated();
- const db = await this.openDb();
- const drafts = await this.getAllByScope<ScopedDraftMetaRecord>(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<void> {
- 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<void> {
- 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<IDBDatabase> {
- 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<T>(db: IDBDatabase, storeName: string): Promise<T[]> {
- const store = db.transaction(storeName, 'readonly').objectStore(storeName);
- const index = store.index(IDB_SCOPE_INDEX);
- return this.requestToPromise<T[]>(index.getAll(this.safeScope));
- }
- private async countByScope(db: IDBDatabase, storeName: string): Promise<number> {
- const store = db.transaction(storeName, 'readonly').objectStore(storeName);
- return this.requestToPromise<number>(store.index(IDB_SCOPE_INDEX).count(this.safeScope));
- }
- private requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
- return new Promise((resolve, reject) => {
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error);
- });
- }
- private transactionDone(tx: IDBTransaction): Promise<void> {
- 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 {}
- }
- }
|