draft-storage.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. /**
  2. * Draft 持久化抽象层
  3. *
  4. * 默认实现:IndexedDbDraftStorage(localStorage fallback)
  5. * - tiktok.drafts.index → DraftMeta[] 列表渲染快
  6. * - tiktok.draft.<id> → 完整 PipelineDraft(按需 lazy load)
  7. *
  8. * 后续可继续升级到远端 /api/drafts,业务零改动。
  9. */
  10. import { DraftMeta, PipelineDraft, toDraftMeta } from '../models/pipeline-draft.model';
  11. export interface DraftStorage {
  12. list(): Promise<DraftMeta[]>;
  13. get(id: string): Promise<PipelineDraft | null>;
  14. put(draft: PipelineDraft): Promise<void>;
  15. delete(id: string): Promise<void>;
  16. /** 清空所有 drafts(仅调试 / 用户主动调用) */
  17. clear(): Promise<void>;
  18. }
  19. const INDEX_KEY = 'tiktok.drafts.index';
  20. const DRAFT_KEY_PREFIX = 'tiktok.draft.';
  21. const IDB_DB_NAME = 'videoWorkflow.workspaceStorage';
  22. const IDB_DB_VERSION = 1;
  23. const IDB_DRAFT_STORE = 'drafts';
  24. const IDB_DRAFT_INDEX_STORE = 'draftIndex';
  25. const IDB_SCOPE_INDEX = 'scope';
  26. const IDB_MIGRATION_KEY_PREFIX = 'videoWorkflow.storageMigration.draftsIndexedDb.v1.';
  27. export class LocalStorageDraftStorage implements DraftStorage {
  28. constructor(private readonly scope = '') {}
  29. private key(base: string): string {
  30. if (!this.scope) return base;
  31. const safeScope = this.scope.replace(/[^a-zA-Z0-9_.:-]/g, '_');
  32. return `${base}.${safeScope}`;
  33. }
  34. private draftKey(id: string): string {
  35. if (!this.scope) return DRAFT_KEY_PREFIX + id;
  36. const safeScope = this.scope.replace(/[^a-zA-Z0-9_.:-]/g, '_');
  37. return `${DRAFT_KEY_PREFIX}${safeScope}.${id}`;
  38. }
  39. async list(): Promise<DraftMeta[]> {
  40. try {
  41. const raw = localStorage.getItem(this.key(INDEX_KEY));
  42. if (!raw) return [];
  43. const arr = JSON.parse(raw);
  44. return Array.isArray(arr) ? arr : [];
  45. } catch (e) {
  46. console.warn('[DraftStorage] list parse failed', e);
  47. return [];
  48. }
  49. }
  50. async get(id: string): Promise<PipelineDraft | null> {
  51. try {
  52. const raw = localStorage.getItem(this.draftKey(id));
  53. if (!raw) return null;
  54. return JSON.parse(raw) as PipelineDraft;
  55. } catch (e) {
  56. console.warn('[DraftStorage] get parse failed', id, e);
  57. return null;
  58. }
  59. }
  60. async put(draft: PipelineDraft): Promise<void> {
  61. try {
  62. localStorage.setItem(this.draftKey(draft.id), JSON.stringify(draft));
  63. } catch (e) {
  64. // 配额溢出:尝试清最旧的草稿后重试一次
  65. console.warn('[DraftStorage] put failed, trying to evict oldest', e);
  66. await this.evictOldest();
  67. try {
  68. localStorage.setItem(this.draftKey(draft.id), JSON.stringify(draft));
  69. } catch (e2) {
  70. console.error('[DraftStorage] put failed after evict', e2);
  71. throw e2;
  72. }
  73. }
  74. // 同步更新索引
  75. const list = await this.list();
  76. const idx = list.findIndex((m) => m.id === draft.id);
  77. const meta = toDraftMeta(draft);
  78. if (idx >= 0) list[idx] = meta;
  79. else list.unshift(meta);
  80. list.sort((a, b) => b.updatedAt - a.updatedAt);
  81. try {
  82. localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(list));
  83. } catch (e) {
  84. console.warn('[DraftStorage] index update failed', e);
  85. }
  86. }
  87. async delete(id: string): Promise<void> {
  88. try {
  89. localStorage.removeItem(this.draftKey(id));
  90. } catch {}
  91. const list = await this.list();
  92. const next = list.filter((m) => m.id !== id);
  93. try {
  94. localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(next));
  95. } catch (e) {
  96. console.warn('[DraftStorage] index delete failed', e);
  97. }
  98. }
  99. async clear(): Promise<void> {
  100. const list = await this.list();
  101. for (const m of list) {
  102. try {
  103. localStorage.removeItem(this.draftKey(m.id));
  104. } catch {}
  105. }
  106. try {
  107. localStorage.removeItem(this.key(INDEX_KEY));
  108. } catch {}
  109. }
  110. /** 配额溢出回收:移除最旧的非 running draft */
  111. private async evictOldest(): Promise<void> {
  112. const list = await this.list();
  113. const evictable = list
  114. .filter((m) => m.status !== 'running')
  115. .sort((a, b) => a.updatedAt - b.updatedAt);
  116. if (!evictable.length) return;
  117. const victim = evictable[0];
  118. try {
  119. localStorage.removeItem(this.draftKey(victim.id));
  120. const next = list.filter((m) => m.id !== victim.id);
  121. localStorage.setItem(this.key(INDEX_KEY), JSON.stringify(next));
  122. console.warn('[DraftStorage] evicted oldest draft', victim.id);
  123. } catch {}
  124. }
  125. }
  126. interface ScopedDraftRecord {
  127. storageKey: string;
  128. scope: string;
  129. draft: PipelineDraft;
  130. }
  131. interface ScopedDraftMetaRecord {
  132. storageKey: string;
  133. scope: string;
  134. meta: DraftMeta;
  135. }
  136. export class IndexedDbDraftStorage implements DraftStorage {
  137. private dbPromise: Promise<IDBDatabase> | null = null;
  138. private readonly fallback: LocalStorageDraftStorage;
  139. private readonly safeScope: string;
  140. constructor(private readonly scope = '') {
  141. this.safeScope = this.normalizeScope(scope);
  142. this.fallback = new LocalStorageDraftStorage(scope);
  143. }
  144. async list(): Promise<DraftMeta[]> {
  145. try {
  146. await this.ensureMigrated();
  147. const db = await this.openDb();
  148. const records = await this.getAllByScope<ScopedDraftMetaRecord>(db, IDB_DRAFT_INDEX_STORE);
  149. return records
  150. .map((record) => record.meta)
  151. .sort((a, b) => b.updatedAt - a.updatedAt);
  152. } catch (error) {
  153. console.warn('[IndexedDbDraftStorage] list failed, using localStorage fallback', error);
  154. return this.fallback.list();
  155. }
  156. }
  157. async get(id: string): Promise<PipelineDraft | null> {
  158. try {
  159. await this.ensureMigrated();
  160. const db = await this.openDb();
  161. const record = await this.requestToPromise<ScopedDraftRecord | undefined>(
  162. db.transaction(IDB_DRAFT_STORE, 'readonly').objectStore(IDB_DRAFT_STORE).get(this.storageKey(id)),
  163. );
  164. return record?.draft || null;
  165. } catch (error) {
  166. console.warn('[IndexedDbDraftStorage] get failed, using localStorage fallback', id, error);
  167. return this.fallback.get(id);
  168. }
  169. }
  170. async put(draft: PipelineDraft): Promise<void> {
  171. try {
  172. await this.ensureMigrated();
  173. const db = await this.openDb();
  174. await this.putRecord(db, draft);
  175. } catch (error) {
  176. console.warn('[IndexedDbDraftStorage] put failed, using localStorage fallback', error);
  177. await this.fallback.put(draft);
  178. }
  179. }
  180. async delete(id: string): Promise<void> {
  181. try {
  182. await this.ensureMigrated();
  183. const db = await this.openDb();
  184. const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite');
  185. tx.objectStore(IDB_DRAFT_STORE).delete(this.storageKey(id));
  186. tx.objectStore(IDB_DRAFT_INDEX_STORE).delete(this.storageKey(id));
  187. await this.transactionDone(tx);
  188. } catch (error) {
  189. console.warn('[IndexedDbDraftStorage] delete failed, using localStorage fallback', id, error);
  190. await this.fallback.delete(id);
  191. }
  192. }
  193. async clear(): Promise<void> {
  194. try {
  195. await this.ensureMigrated();
  196. const db = await this.openDb();
  197. const drafts = await this.getAllByScope<ScopedDraftMetaRecord>(db, IDB_DRAFT_INDEX_STORE);
  198. const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite');
  199. for (const record of drafts) {
  200. tx.objectStore(IDB_DRAFT_STORE).delete(record.storageKey);
  201. tx.objectStore(IDB_DRAFT_INDEX_STORE).delete(record.storageKey);
  202. }
  203. await this.transactionDone(tx);
  204. } catch (error) {
  205. console.warn('[IndexedDbDraftStorage] clear failed, using localStorage fallback', error);
  206. await this.fallback.clear();
  207. }
  208. }
  209. private async ensureMigrated(): Promise<void> {
  210. if (this.hasMigrationMarker()) return;
  211. const db = await this.openDb();
  212. const existingCount = await this.countByScope(db, IDB_DRAFT_INDEX_STORE);
  213. if (existingCount === 0) {
  214. const metas = await this.fallback.list();
  215. for (const meta of metas) {
  216. const draft = await this.fallback.get(meta.id);
  217. if (draft) await this.putRecord(db, draft);
  218. }
  219. }
  220. this.setMigrationMarker();
  221. }
  222. private async putRecord(db: IDBDatabase, draft: PipelineDraft): Promise<void> {
  223. const storageKey = this.storageKey(draft.id);
  224. const tx = db.transaction([IDB_DRAFT_STORE, IDB_DRAFT_INDEX_STORE], 'readwrite');
  225. tx.objectStore(IDB_DRAFT_STORE).put({
  226. storageKey,
  227. scope: this.safeScope,
  228. draft,
  229. } satisfies ScopedDraftRecord);
  230. tx.objectStore(IDB_DRAFT_INDEX_STORE).put({
  231. storageKey,
  232. scope: this.safeScope,
  233. meta: toDraftMeta(draft),
  234. } satisfies ScopedDraftMetaRecord);
  235. await this.transactionDone(tx);
  236. }
  237. private openDb(): Promise<IDBDatabase> {
  238. if (this.dbPromise) return this.dbPromise;
  239. if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB 不可用'));
  240. this.dbPromise = new Promise((resolve, reject) => {
  241. const req = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION);
  242. req.onupgradeneeded = () => {
  243. const db = req.result;
  244. if (!db.objectStoreNames.contains(IDB_DRAFT_STORE)) {
  245. const store = db.createObjectStore(IDB_DRAFT_STORE, { keyPath: 'storageKey' });
  246. store.createIndex(IDB_SCOPE_INDEX, 'scope', { unique: false });
  247. }
  248. if (!db.objectStoreNames.contains(IDB_DRAFT_INDEX_STORE)) {
  249. const store = db.createObjectStore(IDB_DRAFT_INDEX_STORE, { keyPath: 'storageKey' });
  250. store.createIndex(IDB_SCOPE_INDEX, 'scope', { unique: false });
  251. }
  252. };
  253. req.onsuccess = () => resolve(req.result);
  254. req.onerror = () => reject(req.error || new Error('IndexedDB 打开失败'));
  255. req.onblocked = () => reject(new Error('IndexedDB 被其他页面占用,请关闭旧页面后重试'));
  256. });
  257. return this.dbPromise;
  258. }
  259. private async getAllByScope<T>(db: IDBDatabase, storeName: string): Promise<T[]> {
  260. const store = db.transaction(storeName, 'readonly').objectStore(storeName);
  261. const index = store.index(IDB_SCOPE_INDEX);
  262. return this.requestToPromise<T[]>(index.getAll(this.safeScope));
  263. }
  264. private async countByScope(db: IDBDatabase, storeName: string): Promise<number> {
  265. const store = db.transaction(storeName, 'readonly').objectStore(storeName);
  266. return this.requestToPromise<number>(store.index(IDB_SCOPE_INDEX).count(this.safeScope));
  267. }
  268. private requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
  269. return new Promise((resolve, reject) => {
  270. request.onsuccess = () => resolve(request.result);
  271. request.onerror = () => reject(request.error);
  272. });
  273. }
  274. private transactionDone(tx: IDBTransaction): Promise<void> {
  275. return new Promise((resolve, reject) => {
  276. tx.oncomplete = () => resolve();
  277. tx.onerror = () => reject(tx.error);
  278. tx.onabort = () => reject(tx.error || new Error('IndexedDB 事务已中止'));
  279. });
  280. }
  281. private storageKey(id: string): string {
  282. return `${this.safeScope}::${id}`;
  283. }
  284. private normalizeScope(scope: string): string {
  285. return (scope || 'default').replace(/[^a-zA-Z0-9_.:-]/g, '_');
  286. }
  287. private hasMigrationMarker(): boolean {
  288. try {
  289. return localStorage.getItem(IDB_MIGRATION_KEY_PREFIX + this.safeScope) === 'done';
  290. } catch {
  291. return false;
  292. }
  293. }
  294. private setMigrationMarker(): void {
  295. try {
  296. localStorage.setItem(IDB_MIGRATION_KEY_PREFIX + this.safeScope, 'done');
  297. } catch {}
  298. }
  299. }