From ab1d64e0c90561011a05bb063124f1e3fa729a3f Mon Sep 17 00:00:00 2001 From: cc <98377878+hicccc77@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:57:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E7=89=87=E8=A7=A3=E5=AF=86=E5=86=8D?= =?UTF-8?q?=E6=AC=A1=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 98 ++++- electron/preload.ts | 18 +- electron/services/exportService.ts | 20 +- electron/services/httpService.ts | 8 +- electron/services/imageDecryptService.ts | 260 +++++++----- electron/services/imagePreloadService.ts | 7 +- electron/services/keyServiceMac.ts | 2 - src/App.tsx | 6 - src/pages/ChatPage.tsx | 362 ++++++++++++---- src/pages/ExportPage.scss | 18 +- src/pages/ExportPage.tsx | 505 +++++++++++++++++++---- src/pages/ResourcesPage.tsx | 189 ++++++--- src/pages/WelcomePage.tsx | 49 +-- src/services/backgroundTaskMonitor.ts | 69 +++- src/services/config.ts | 8 +- src/stores/batchImageDecryptStore.ts | 63 ++- src/stores/batchTranscribeStore.ts | 223 ++++++++-- src/types/backgroundTask.ts | 7 + src/types/electron.d.ts | 13 +- src/types/exportAutomation.ts | 1 + 20 files changed, 1504 insertions(+), 422 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index c69969b..19fad71 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2644,6 +2644,9 @@ function registerIpcHandlers() { force?: boolean preferFilePath?: boolean hardlinkOnly?: boolean + disableUpdateCheck?: boolean + allowCacheIndex?: boolean + suppressEvents?: boolean }) => { return imageDecryptService.decryptImage(payload) }) @@ -2656,6 +2659,7 @@ function registerIpcHandlers() { hardlinkOnly?: boolean disableUpdateCheck?: boolean allowCacheIndex?: boolean + suppressEvents?: boolean }) => { return imageDecryptService.resolveCachedImage(payload) }) @@ -2663,19 +2667,84 @@ function registerIpcHandlers() { 'image:resolveCacheBatch', async ( _, - payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>, - options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean } + payloads: Array<{ + sessionId?: string + imageMd5?: string + imageDatName?: string + createTime?: number + preferFilePath?: boolean + hardlinkOnly?: boolean + suppressEvents?: boolean + }>, + options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean } ) => { const list = Array.isArray(payloads) ? payloads : [] - const rows = await Promise.all(list.map(async (payload) => { - return imageDecryptService.resolveCachedImage({ - ...payload, - preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true, - hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true, - disableUpdateCheck: options?.disableUpdateCheck === true, - allowCacheIndex: options?.allowCacheIndex !== false - }) - })) + if (list.length === 0) return { success: true, rows: [] } + + const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 10) + const maxConcurrent = Number.isFinite(maxConcurrentRaw) + ? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 48)) + : 10 + const workerCount = Math.min(maxConcurrent, list.length) + + const rows: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> = new Array(list.length) + let cursor = 0 + const dedupe = new Map>() + + const makeDedupeKey = (payload: typeof list[number]): string => { + const sessionId = String(payload.sessionId || '').trim().toLowerCase() + const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase() + const imageDatName = String(payload.imageDatName || '').trim().toLowerCase() + const createTime = Number(payload.createTime || 0) || 0 + const preferFilePath = payload.preferFilePath ?? options?.preferFilePath === true + const hardlinkOnly = payload.hardlinkOnly ?? options?.hardlinkOnly === true + const allowCacheIndex = options?.allowCacheIndex !== false + const disableUpdateCheck = options?.disableUpdateCheck === true + const suppressEvents = payload.suppressEvents ?? options?.suppressEvents === true + return [ + sessionId, + imageMd5, + imageDatName, + String(createTime), + preferFilePath ? 'pf1' : 'pf0', + hardlinkOnly ? 'hl1' : 'hl0', + allowCacheIndex ? 'ci1' : 'ci0', + disableUpdateCheck ? 'du1' : 'du0', + suppressEvents ? 'se1' : 'se0' + ].join('|') + } + + const resolveOne = (payload: typeof list[number]) => imageDecryptService.resolveCachedImage({ + ...payload, + preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true, + hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true, + disableUpdateCheck: options?.disableUpdateCheck === true, + allowCacheIndex: options?.allowCacheIndex !== false, + suppressEvents: payload.suppressEvents ?? options?.suppressEvents === true + }) + + const worker = async () => { + while (true) { + const index = cursor + cursor += 1 + if (index >= list.length) return + const payload = list[index] + const key = makeDedupeKey(payload) + const existing = dedupe.get(key) + if (existing) { + rows[index] = await existing + continue + } + const task = resolveOne(payload).catch((error) => ({ + success: false, + error: String(error) + })) + dedupe.set(key, task) + rows[index] = await task + } + } + + await Promise.all(Array.from({ length: workerCount }, () => worker())) return { success: true, rows } } ) @@ -2689,6 +2758,13 @@ function registerIpcHandlers() { imagePreloadService.enqueue(payloads || [], options) return true }) + ipcMain.handle( + 'image:preloadHardlinkMd5s', + async (_, md5List?: string[]) => { + await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : []) + return true + } + ) // Windows Hello ipcMain.handle('auth:hello', async (event, message?: string) => { diff --git a/electron/preload.ts b/electron/preload.ts index e89080e..6bd505c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -286,7 +286,18 @@ contextBridge.exposeInMainWorld('electronAPI', { // 图片解密 image: { - decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; force?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean }) => + decrypt: (payload: { + sessionId?: string + imageMd5?: string + imageDatName?: string + createTime?: number + force?: boolean + preferFilePath?: boolean + hardlinkOnly?: boolean + disableUpdateCheck?: boolean + allowCacheIndex?: boolean + suppressEvents?: boolean + }) => ipcRenderer.invoke('image:decrypt', payload), resolveCache: (payload: { sessionId?: string @@ -297,16 +308,19 @@ contextBridge.exposeInMainWorld('electronAPI', { hardlinkOnly?: boolean disableUpdateCheck?: boolean allowCacheIndex?: boolean + suppressEvents?: boolean }) => ipcRenderer.invoke('image:resolveCache', payload), resolveCacheBatch: ( payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>, - options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean } + options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean } ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), preload: ( payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>, options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } ) => ipcRenderer.invoke('image:preload', payloads, options), + preloadHardlinkMd5s: (md5List: string[]) => + ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List), onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) ipcRenderer.on('image:updateAvailable', listener) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 628c464..fe44d51 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -442,8 +442,8 @@ class ExportService { let lastSessionId = '' let lastCollected = 0 let lastExported = 0 - const MIN_PROGRESS_EMIT_INTERVAL_MS = 250 - const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500 + const MIN_PROGRESS_EMIT_INTERVAL_MS = 400 + const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200 const commit = (progress: ExportProgress) => { onProgress(progress) @@ -3682,18 +3682,28 @@ class ExportService { createTime: msg.createTime, force: true, // 导出优先高清,失败再回退缩略图 preferFilePath: true, - hardlinkOnly: true + hardlinkOnly: true, + disableUpdateCheck: true, + allowCacheIndex: !imageMd5, + suppressEvents: true }) if (!result.success || !result.localPath) { - console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) + if (result.failureKind === 'decrypt_failed') { + console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) + } else { + console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) + } // 尝试获取缩略图 const thumbResult = await imageDecryptService.resolveCachedImage({ sessionId, imageMd5, imageDatName, createTime: msg.createTime, - preferFilePath: true + preferFilePath: true, + disableUpdateCheck: true, + allowCacheIndex: !imageMd5, + suppressEvents: true }) if (thumbResult.success && thumbResult.localPath) { console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index b5b010f..f353434 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -1257,7 +1257,9 @@ class HttpService { createTime: msg.createTime, force: true, preferFilePath: true, - hardlinkOnly: true + hardlinkOnly: true, + disableUpdateCheck: true, + suppressEvents: true }) let imagePath = result.success ? result.localPath : undefined @@ -1269,7 +1271,9 @@ class HttpService { imageDatName: msg.imageDatName, createTime: msg.createTime, preferFilePath: true, - hardlinkOnly: true + hardlinkOnly: true, + disableUpdateCheck: true, + suppressEvents: true }) if (cached.success && cached.localPath) { imagePath = cached.localPath diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 81c6fa1..9f300c5 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -53,6 +53,7 @@ type DecryptResult = { success: boolean localPath?: string error?: string + failureKind?: 'not_found' | 'decrypt_failed' isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) } @@ -67,6 +68,7 @@ type CachedImagePayload = { hardlinkOnly?: boolean disableUpdateCheck?: boolean allowCacheIndex?: boolean + suppressEvents?: boolean } type DecryptImagePayload = CachedImagePayload & { @@ -81,6 +83,21 @@ export class ImageDecryptService { private nativeLogged = false private datNameScanMissAt = new Map() private readonly datNameScanMissTtlMs = 1200 + private readonly accountDirCache = new Map() + private cacheRootPath: string | null = null + private readonly ensuredDirs = new Set() + + private shouldEmitImageEvents(payload?: { suppressEvents?: boolean }): boolean { + if (payload?.suppressEvents === true) return false + // 导出 worker 场景不需要向渲染层广播逐条图片事件,避免事件风暴拖慢主界面。 + if (process.env.WEFLOW_WORKER === '1') return false + return true + } + + private shouldCheckImageUpdate(payload?: { disableUpdateCheck?: boolean; suppressEvents?: boolean }): boolean { + if (payload?.disableUpdateCheck === true) return false + return this.shouldEmitImageEvents(payload) + } private logInfo(message: string, meta?: Record): void { if (!this.configService.get('logEnabled')) return @@ -122,7 +139,7 @@ export class ImageDecryptService { const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { - return { success: false, error: '缺少图片标识' } + return { success: false, error: '缺少图片标识', failureKind: 'not_found' } } for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) @@ -135,7 +152,7 @@ export class ImageDecryptService { const isThumb = this.isThumbnailPath(finalPath) const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false if (isThumb) { - if (!payload.disableUpdateCheck) { + if (this.shouldCheckImageUpdate(payload)) { this.triggerUpdateCheck(payload, key, finalPath) } } else { @@ -160,7 +177,8 @@ export class ImageDecryptService { { allowThumbnail: true, skipResolvedCache: false, - hardlinkOnly: true + hardlinkOnly: true, + allowDatNameScanFallback: payload.allowCacheIndex !== false } ) if (datPath) { @@ -175,7 +193,7 @@ export class ImageDecryptService { const isThumb = this.isThumbnailPath(finalPath) const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false if (isThumb) { - if (!payload.disableUpdateCheck) { + if (this.shouldCheckImageUpdate(payload)) { this.triggerUpdateCheck(payload, cacheKey, finalPath) } } else { @@ -187,14 +205,14 @@ export class ImageDecryptService { } } this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) - return { success: false, error: '未找到缓存图片' } + return { success: false, error: '未找到缓存图片', failureKind: 'not_found' } } async decryptImage(payload: DecryptImagePayload): Promise { const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { - return { success: false, error: '缺少图片标识' } + return { success: false, error: '缺少图片标识', failureKind: 'not_found' } } this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running') @@ -296,14 +314,14 @@ export class ImageDecryptService { if (!wxid || !dbPath) { this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath }) this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失') - return { success: false, error: '未配置账号或数据库路径' } + return { success: false, error: '未配置账号或数据库路径', failureKind: 'not_found' } } const accountDir = this.resolveAccountDir(dbPath, wxid) if (!accountDir) { this.logError('未找到账号目录', undefined, { dbPath, wxid }) this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失') - return { success: false, error: '未找到账号目录' } + return { success: false, error: '未找到账号目录', failureKind: 'not_found' } } let datPath: string | null = null @@ -321,8 +339,9 @@ export class ImageDecryptService { payload.createTime, { allowThumbnail: false, - skipResolvedCache: true, - hardlinkOnly: payload.hardlinkOnly === true + skipResolvedCache: false, + hardlinkOnly: payload.hardlinkOnly === true, + allowDatNameScanFallback: payload.allowCacheIndex !== false } ) if (!datPath) { @@ -334,8 +353,9 @@ export class ImageDecryptService { payload.createTime, { allowThumbnail: true, - skipResolvedCache: true, - hardlinkOnly: payload.hardlinkOnly === true + skipResolvedCache: false, + hardlinkOnly: payload.hardlinkOnly === true, + allowDatNameScanFallback: payload.allowCacheIndex !== false } ) fallbackToThumbnail = Boolean(datPath) @@ -356,7 +376,8 @@ export class ImageDecryptService { { allowThumbnail: true, skipResolvedCache: false, - hardlinkOnly: payload.hardlinkOnly === true + hardlinkOnly: payload.hardlinkOnly === true, + allowDatNameScanFallback: payload.allowCacheIndex !== false } ) } @@ -365,9 +386,9 @@ export class ImageDecryptService { this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName }) this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件') if (usedHdAttempt) { - return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' } + return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试', failureKind: 'not_found' } } - return { success: false, error: '未找到图片文件' } + return { success: false, error: '未找到图片文件', failureKind: 'not_found' } } this.logInfo('找到DAT文件', { datPath }) @@ -414,7 +435,7 @@ export class ImageDecryptService { } if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) { this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥') - return { success: false, error: '未配置图片解密密钥' } + return { success: false, error: '未配置图片解密密钥', failureKind: 'not_found' } } const aesKeyRaw = imageKeys.aesKey @@ -426,7 +447,7 @@ export class ImageDecryptService { const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative) if (!nativeResult) { this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用') - return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置' } + return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置', failureKind: 'not_found' } } let decrypted: Buffer = nativeResult.data this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running') @@ -435,35 +456,34 @@ export class ImageDecryptService { const wxgfResult = await this.unwrapWxgf(decrypted) decrypted = wxgfResult.data - let ext = this.detectImageExtension(decrypted) + const detectedExt = this.detectImageExtension(decrypted) - // 如果是 wxgf 格式且没检测到扩展名 - if (wxgfResult.isWxgf && !ext) { - ext = '.hevc' + // 如果解密产物无法识别为图片,归类为“解密失败”。 + if (!detectedExt) { + this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '解密后不是有效图片') + return { + success: false, + error: '解密后不是有效图片', + failureKind: 'decrypt_failed', + isThumb: this.isThumbnailPath(datPath) + } } - const finalExt = ext || '.jpg' + const finalExt = detectedExt const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId) this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running') await writeFile(outputPath, decrypted) this.logInfo('解密成功', { outputPath, size: decrypted.length }) - if (finalExt === '.hevc') { - this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败') - return { - success: false, - error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志', - isThumb: this.isThumbnailPath(datPath) - } - } - const isThumb = this.isThumbnailPath(datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) if (!isThumb) { this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) } else { - this.triggerUpdateCheck(payload, cacheKey, outputPath) + if (this.shouldCheckImageUpdate(payload)) { + this.triggerUpdateCheck(payload, cacheKey, outputPath) + } } const localPath = payload.preferFilePath ? outputPath @@ -475,18 +495,30 @@ export class ImageDecryptService { } catch (e) { this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e)) - return { success: false, error: String(e) } + return { success: false, error: String(e), failureKind: 'not_found' } } } private resolveAccountDir(dbPath: string, wxid: string): string | null { const cleanedWxid = this.cleanAccountDirName(wxid) const normalized = dbPath.replace(/[\\/]+$/, '') + const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}` + const cached = this.accountDirCache.get(cacheKey) + if (cached && existsSync(cached)) return cached + if (cached && !existsSync(cached)) { + this.accountDirCache.delete(cacheKey) + } const direct = join(normalized, cleanedWxid) - if (existsSync(direct)) return direct + if (existsSync(direct)) { + this.accountDirCache.set(cacheKey, direct) + return direct + } - if (this.isAccountDir(normalized)) return normalized + if (this.isAccountDir(normalized)) { + this.accountDirCache.set(cacheKey, normalized) + return normalized + } try { const entries = readdirSync(normalized) @@ -496,7 +528,10 @@ export class ImageDecryptService { if (!this.isDirectory(entryPath)) continue const lowerEntry = entry.toLowerCase() if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) { - if (this.isAccountDir(entryPath)) return entryPath + if (this.isAccountDir(entryPath)) { + this.accountDirCache.set(cacheKey, entryPath) + return entryPath + } } } } catch { } @@ -574,23 +609,35 @@ export class ImageDecryptService { imageDatName?: string, sessionId?: string, createTime?: number, - options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean } + options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean } ): Promise { const allowThumbnail = options?.allowThumbnail ?? true const skipResolvedCache = options?.skipResolvedCache ?? false const hardlinkOnly = options?.hardlinkOnly ?? false + const allowDatNameScanFallback = options?.allowDatNameScanFallback ?? true this.logInfo('[ImageDecrypt] resolveDatPath', { imageMd5, imageDatName, createTime, allowThumbnail, skipResolvedCache, - hardlinkOnly + hardlinkOnly, + allowDatNameScanFallback }) const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName) + const fallbackDatName = String(imageDatName || imageMd5 || '').trim().toLowerCase() || undefined if (lookupMd5s.length === 0) { - const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, imageDatName, sessionId, createTime, allowThumbnail) + if (!allowDatNameScanFallback) { + this.logInfo('[ImageDecrypt] resolveDatPath skip datName scan (no hardlink md5)', { + imageMd5, + imageDatName, + sessionId, + createTime + }) + return null + } + const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail) if (packedDatFallback) { if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback) @@ -637,7 +684,18 @@ export class ImageDecryptService { return hardlinkPath } - const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, imageDatName, sessionId, createTime, allowThumbnail) + if (!allowDatNameScanFallback) { + this.logInfo('[ImageDecrypt] resolveDatPath skip datName fallback after hardlink miss', { + imageMd5, + imageDatName, + sessionId, + createTime, + lookupMd5s + }) + return null + } + + const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail) if (packedDatFallback) { if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback) @@ -680,7 +738,7 @@ export class ImageDecryptService { payload.imageDatName, payload.sessionId, payload.createTime, - { allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true } + { allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false } ) return Boolean(hdPath) } @@ -703,7 +761,7 @@ export class ImageDecryptService { payload.imageDatName, payload.sessionId, payload.createTime, - { allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true } + { allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false } ) if (!hdDatPath) return null @@ -761,10 +819,11 @@ export class ImageDecryptService { } private triggerUpdateCheck( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }, + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; disableUpdateCheck?: boolean; suppressEvents?: boolean }, cacheKey: string, cachedPath: string ): void { + if (!this.shouldCheckImageUpdate(payload)) return if (this.updateFlags.get(cacheKey)) return void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => { if (!hasUpdate) return @@ -1082,6 +1141,16 @@ export class ImageDecryptService { const priorityB = this.getHardlinkCandidatePriority(nameB, baseMd5) if (priorityA !== priorityB) return priorityA - priorityB + let sizeA = 0 + let sizeB = 0 + try { + sizeA = statSync(a).size + } catch { } + try { + sizeB = statSync(b).size + } catch { } + if (sizeA !== sizeB) return sizeB - sizeA + let mtimeA = 0 let mtimeB = 0 try { @@ -1096,13 +1165,6 @@ export class ImageDecryptService { return list } - private isPlainMd5DatName(fileName: string): boolean { - const lower = String(fileName || '').trim().toLowerCase() - if (!lower.endsWith('.dat')) return false - const base = lower.slice(0, -4) - return this.looksLikeMd5(base) - } - private isHardlinkCandidateName(fileName: string, baseMd5: string): boolean { const lower = String(fileName || '').trim().toLowerCase() if (!lower.endsWith('.dat')) return false @@ -1113,57 +1175,33 @@ export class ImageDecryptService { return this.normalizeDatBase(base) === baseMd5 } - private getHardlinkCandidatePriority(fileName: string, baseMd5: string): number { + private getHardlinkCandidatePriority(fileName: string, _baseMd5: string): number { const lower = String(fileName || '').trim().toLowerCase() if (!lower.endsWith('.dat')) return 999 + const base = lower.slice(0, -4) - - // 无后缀 DAT 最后兜底;优先尝试变体 DAT。 - if (base === baseMd5) return 20 - // _t / .t / _thumb 等缩略图 DAT 仅作次级回退。 - if (this.isThumbnailDat(lower)) return 10 - // 其他非缩略图变体优先。 - return 0 - } - - private resolveHardlinkDatVariants(fullPath: string, baseMd5: string): string[] { - const dirPath = dirname(fullPath) - try { - const entries = readdirSync(dirPath, { withFileTypes: true }) - const candidates = entries - .filter((entry) => entry.isFile()) - .map((entry) => entry.name) - .filter((name) => this.isHardlinkCandidateName(name, baseMd5)) - .map((name) => join(dirPath, name)) - .filter((candidatePath) => existsSync(candidatePath)) - return this.sortDatCandidatePaths(candidates, baseMd5) - } catch { - return [] + if ( + base.endsWith('_h') || + base.endsWith('.h') || + base.endsWith('_hd') || + base.endsWith('.hd') + ) { + return 0 } + if (base.endsWith('_b') || base.endsWith('.b')) return 1 + if (this.isThumbnailDat(lower)) return 3 + return 2 } private normalizeHardlinkDatPathByFileName(fullPath: string, fileName: string): string { const normalizedPath = String(fullPath || '').trim() const normalizedFileName = String(fileName || '').trim().toLowerCase() - if (!normalizedPath || !normalizedFileName.endsWith('.dat')) { - return normalizedPath - } - - // hardlink 记录到具体后缀时(如 _b/.b/_t),直接按记录路径解密。 - if (!this.isPlainMd5DatName(normalizedFileName)) { - return normalizedPath - } - - const base = normalizedFileName.slice(0, -4) - if (!this.looksLikeMd5(base)) { - return normalizedPath - } - - const candidates = this.resolveHardlinkDatVariants(normalizedPath, base) - if (candidates.length > 0) { - return candidates[0] - } + if (!normalizedPath || !normalizedFileName) return normalizedPath + if (!normalizedFileName.endsWith('.dat')) return normalizedPath + const normalizedBase = this.normalizeDatBase(normalizedFileName.slice(0, -4)) + if (!this.looksLikeMd5(normalizedBase)) return '' + // 最新策略:只要 hardlink 有记录,始终直接使用其记录路径(包括无后缀 DAT)。 return normalizedPath } @@ -1197,6 +1235,7 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink path hit', { md5: normalizedMd5, fileName, fullPath, selectedPath }) return selectedPath } + this.logInfo('[ImageDecrypt] hardlink path miss', { md5: normalizedMd5, fileName, fullPath, selectedPath }) return null } catch { @@ -1272,9 +1311,7 @@ export class ImageDecryptService { const contactDir = this.sanitizeDirName(sessionId || 'unknown') const timeDir = this.resolveTimeDir(datPath) const outputDir = join(this.getCacheRoot(), contactDir, timeDir) - if (!existsSync(outputDir)) { - mkdirSync(outputDir, { recursive: true }) - } + this.ensureDir(outputDir) return join(outputDir, `${normalizedBase}${suffix}${ext}`) } @@ -1384,7 +1421,8 @@ export class ImageDecryptService { } } - private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void { + private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string): void { + if (!this.shouldEmitImageEvents(payload)) return const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName } for (const win of this.getActiveWindowsSafely()) { if (!win.isDestroyed()) { @@ -1393,7 +1431,8 @@ export class ImageDecryptService { } } - private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void { + private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string, localPath: string): void { + if (!this.shouldEmitImageEvents(payload)) return const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath } for (const win of this.getActiveWindowsSafely()) { if (!win.isDestroyed()) { @@ -1403,13 +1442,14 @@ export class ImageDecryptService { } private emitDecryptProgress( - payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, + payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string, stage: DecryptProgressStage, progress: number, status: 'running' | 'done' | 'error', message?: string ): void { + if (!this.shouldEmitImageEvents(payload)) return const safeProgress = Math.max(0, Math.min(100, Math.floor(progress))) const event = { cacheKey, @@ -1428,16 +1468,27 @@ export class ImageDecryptService { } private getCacheRoot(): string { - const configured = this.configService.get('cachePath') - const root = configured - ? join(configured, 'Images') - : join(this.getDocumentsPath(), 'WeFlow', 'Images') - if (!existsSync(root)) { - mkdirSync(root, { recursive: true }) + let root = this.cacheRootPath + if (!root) { + const configured = this.configService.get('cachePath') + root = configured + ? join(configured, 'Images') + : join(this.getDocumentsPath(), 'WeFlow', 'Images') + this.cacheRootPath = root } + this.ensureDir(root) return root } + private ensureDir(dirPath: string): void { + if (!dirPath) return + if (this.ensuredDirs.has(dirPath) && existsSync(dirPath)) return + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }) + } + this.ensuredDirs.add(dirPath) + } + private tryDecryptDatWithNative( datPath: string, xorKey: number, @@ -1788,6 +1839,9 @@ export class ImageDecryptService { this.resolvedCache.clear() this.pending.clear() this.updateFlags.clear() + this.accountDirCache.clear() + this.ensuredDirs.clear() + this.cacheRootPath = null const configured = this.configService.get('cachePath') const root = configured diff --git a/electron/services/imagePreloadService.ts b/electron/services/imagePreloadService.ts index 4c65bd6..dacee88 100644 --- a/electron/services/imagePreloadService.ts +++ b/electron/services/imagePreloadService.ts @@ -79,7 +79,8 @@ export class ImagePreloadService { preferFilePath: true, hardlinkOnly: true, disableUpdateCheck: !task.allowDecrypt, - allowCacheIndex: task.allowCacheIndex + allowCacheIndex: task.allowCacheIndex, + suppressEvents: true }) if (cached.success) return if (!task.allowDecrypt) return @@ -89,7 +90,9 @@ export class ImagePreloadService { imageDatName: task.imageDatName, createTime: task.createTime, preferFilePath: true, - hardlinkOnly: true + hardlinkOnly: true, + disableUpdateCheck: true, + suppressEvents: true }) } catch { // ignore preload failures diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 9900ec3..53fefa4 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -478,8 +478,6 @@ export class KeyServiceMac { 'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)', 'end try' ] - onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0) - let stdout = '' try { const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { diff --git a/src/App.tsx b/src/App.tsx index a0f11d4..6265a8b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,8 +39,6 @@ import UpdateDialog from './components/UpdateDialog' import UpdateProgressCapsule from './components/UpdateProgressCapsule' import LockScreen from './components/LockScreen' import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' -import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' -import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal' import WindowCloseDialog from './components/WindowCloseDialog' function RouteStateRedirect({ to }: { to: string }) { @@ -554,10 +552,6 @@ function App() { {/* 全局会话监听与通知 */} - {/* 全局批量转写进度浮窗 */} - - - {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && (
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index af0753d..c71b227 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -29,6 +29,7 @@ import { onSingleExportDialogStatus, requestExportSessionStatus } from '../services/exportBridge' +import '../styles/batchTranscribe.scss' import './ChatPage.scss' // 系统消息类型常量 @@ -1370,34 +1371,30 @@ function ChatPage(props: ChatPageProps) { const { isBatchTranscribing, runningBatchVoiceTaskType, - batchTranscribeProgress, startTranscribe, updateProgress, - finishTranscribe, - setShowBatchProgress + updateTranscribeTaskStatus, + finishTranscribe } = useBatchTranscribeStore(useShallow((state) => ({ isBatchTranscribing: state.isBatchTranscribing, runningBatchVoiceTaskType: state.taskType, - batchTranscribeProgress: state.progress, startTranscribe: state.startTranscribe, updateProgress: state.updateProgress, - finishTranscribe: state.finishTranscribe, - setShowBatchProgress: state.setShowToast + updateTranscribeTaskStatus: state.setTaskStatus, + finishTranscribe: state.finishTranscribe }))) const { isBatchDecrypting, - batchDecryptProgress, startDecrypt, updateDecryptProgress, - finishDecrypt, - setShowBatchDecryptToast + updateDecryptTaskStatus, + finishDecrypt } = useBatchImageDecryptStore(useShallow((state) => ({ isBatchDecrypting: state.isBatchDecrypting, - batchDecryptProgress: state.progress, startDecrypt: state.startDecrypt, updateDecryptProgress: state.updateProgress, - finishDecrypt: state.finishDecrypt, - setShowBatchDecryptToast: state.setShowToast + updateDecryptTaskStatus: state.setTaskStatus, + finishDecrypt: state.finishDecrypt }))) const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [batchVoiceCount, setBatchVoiceCount] = useState(0) @@ -5730,22 +5727,74 @@ function ChatPage(props: ChatPageProps) { if (!session) return const taskType = batchVoiceTaskType - startTranscribe(voiceMessages.length, session.displayName || session.username, taskType) - - if (taskType === 'transcribe') { - // 检查模型状态 - const modelStatus = await window.electronAPI.whisper.getModelStatus() - if (!modelStatus?.exists) { - alert('SenseVoice 模型未下载,请先在设置中下载模型') - finishTranscribe(0, 0) - return - } - } - + const totalVoices = voiceMessages.length + const taskVerb = taskType === 'decrypt' ? '语音解密' : '语音转写' let successCount = 0 let failCount = 0 let completedCount = 0 const concurrency = taskType === 'decrypt' ? 12 : 10 + const controlState = { + cancelRequested: false, + pauseRequested: false, + pauseAnnounced: false, + resumeWaiters: [] as Array<() => void> + } + const resolveResumeWaiters = () => { + const waiters = [...controlState.resumeWaiters] + controlState.resumeWaiters.length = 0 + waiters.forEach(resolve => resolve()) + } + const waitIfPaused = async () => { + while (controlState.pauseRequested && !controlState.cancelRequested) { + if (!controlState.pauseAnnounced) { + controlState.pauseAnnounced = true + updateTranscribeTaskStatus( + `${taskVerb}任务已中断,等待继续...`, + `${completedCount} / ${totalVoices}`, + 'paused' + ) + } + await new Promise(resolve => { + controlState.resumeWaiters.push(resolve) + }) + } + if (controlState.pauseAnnounced && !controlState.cancelRequested) { + controlState.pauseAnnounced = false + updateTranscribeTaskStatus( + `继续${taskVerb}(${completedCount}/${totalVoices})`, + `${completedCount} / ${totalVoices}`, + 'running' + ) + } + } + + startTranscribe(totalVoices, session.displayName || session.username, taskType, 'chat', { + cancelable: true, + resumable: true, + onPause: () => { + controlState.pauseRequested = true + updateTranscribeTaskStatus( + `${taskVerb}中断请求已发出,当前处理完成后暂停...`, + `${completedCount} / ${totalVoices}`, + 'pause_requested' + ) + }, + onResume: () => { + controlState.pauseRequested = false + resolveResumeWaiters() + }, + onCancel: () => { + controlState.cancelRequested = true + controlState.pauseRequested = false + resolveResumeWaiters() + updateTranscribeTaskStatus( + `${taskVerb}停止请求已发出,当前处理完成后结束...`, + `${completedCount} / ${totalVoices}`, + 'cancel_requested' + ) + } + }) + updateTranscribeTaskStatus(`正在准备${taskVerb}任务...`, `0 / ${totalVoices}`, 'running') const runOne = async (msg: Message) => { try { @@ -5769,20 +5818,74 @@ function ChatPage(props: ChatPageProps) { } } - for (let i = 0; i < voiceMessages.length; i += concurrency) { - const batch = voiceMessages.slice(i, i + concurrency) - const results = await Promise.all(batch.map(msg => runOne(msg))) + try { + if (taskType === 'transcribe') { + updateTranscribeTaskStatus('正在检查转写模型...', `0 / ${totalVoices}`) + const modelStatus = await window.electronAPI.whisper.getModelStatus() + if (!modelStatus?.exists) { + alert('SenseVoice 模型未下载,请先在设置中下载模型') + updateTranscribeTaskStatus('转写模型缺失,任务已停止', `0 / ${totalVoices}`) + finishTranscribe(0, totalVoices) + return + } + } - results.forEach(result => { + updateTranscribeTaskStatus(`正在${taskVerb}(0/${totalVoices})`, `0 / ${totalVoices}`) + const pool = new Set>() + + const runOneTracked = async (msg: Message) => { + if (controlState.cancelRequested) return + const result = await runOne(msg) if (result.success) successCount++ else failCount++ completedCount++ - updateProgress(completedCount, voiceMessages.length) - }) - } + updateProgress(completedCount, totalVoices) + } - finishTranscribe(successCount, failCount) - }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe]) + for (const msg of voiceMessages) { + if (controlState.cancelRequested) break + await waitIfPaused() + if (controlState.cancelRequested) break + if (pool.size >= concurrency) { + await Promise.race(pool) + if (controlState.cancelRequested) break + await waitIfPaused() + if (controlState.cancelRequested) break + } + let p: Promise = Promise.resolve() + p = runOneTracked(msg).finally(() => { + pool.delete(p) + }) + pool.add(p) + } + + while (pool.size > 0) { + await Promise.race(pool) + } + + if (controlState.cancelRequested) { + const remaining = Math.max(0, totalVoices - completedCount) + finishTranscribe(successCount, failCount, { + status: 'canceled', + detail: `${taskVerb}任务已中断:已完成 ${completedCount}/${totalVoices}(成功 ${successCount},失败 ${failCount},未处理 ${remaining})`, + progressText: `${completedCount} / ${totalVoices}` + }) + return + } + + finishTranscribe(successCount, failCount, { + status: failCount > 0 ? 'failed' : 'completed' + }) + } catch (error) { + const remaining = Math.max(0, totalVoices - completedCount) + failCount += remaining + updateTranscribeTaskStatus(`${taskVerb}过程中发生异常,正在结束任务...`, `${completedCount} / ${totalVoices}`) + finishTranscribe(successCount, failCount, { + status: 'failed' + }) + alert(`批量${taskVerb}失败:${String(error)}`) + } + }, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateTranscribeTaskStatus, updateProgress, finishTranscribe]) // 批量转写:按日期的消息数量 const batchCountByDate = useMemo(() => { @@ -5845,14 +5948,114 @@ function ChatPage(props: ChatPageProps) { setBatchImageDates([]) setBatchImageSelectedDates(new Set()) - startDecrypt(images.length, session.displayName || session.username) - + const totalImages = images.length let successCount = 0 let failCount = 0 + let notFoundCount = 0 + let decryptFailedCount = 0 let completed = 0 + const controlState = { + cancelRequested: false, + pauseRequested: false, + pauseAnnounced: false, + resumeWaiters: [] as Array<() => void> + } + const resolveResumeWaiters = () => { + const waiters = [...controlState.resumeWaiters] + controlState.resumeWaiters.length = 0 + waiters.forEach(resolve => resolve()) + } + const waitIfPaused = async () => { + while (controlState.pauseRequested && !controlState.cancelRequested) { + if (!controlState.pauseAnnounced) { + controlState.pauseAnnounced = true + updateDecryptTaskStatus( + '图片批量解密任务已中断,等待继续...', + `${completed} / ${totalImages}`, + 'paused' + ) + } + await new Promise(resolve => { + controlState.resumeWaiters.push(resolve) + }) + } + if (controlState.pauseAnnounced && !controlState.cancelRequested) { + controlState.pauseAnnounced = false + updateDecryptTaskStatus( + `继续批量解密图片(${completed}/${totalImages})`, + `${completed} / ${totalImages}`, + 'running' + ) + } + } + + startDecrypt(totalImages, session.displayName || session.username, 'chat', { + cancelable: true, + resumable: true, + onPause: () => { + controlState.pauseRequested = true + updateDecryptTaskStatus( + '图片解密中断请求已发出,当前处理完成后暂停...', + `${completed} / ${totalImages}`, + 'pause_requested' + ) + }, + onResume: () => { + controlState.pauseRequested = false + resolveResumeWaiters() + }, + onCancel: () => { + controlState.cancelRequested = true + controlState.pauseRequested = false + resolveResumeWaiters() + updateDecryptTaskStatus( + '图片解密停止请求已发出,当前处理完成后结束...', + `${completed} / ${totalImages}`, + 'cancel_requested' + ) + } + }) + updateDecryptTaskStatus('正在准备批量图片解密任务...', `0 / ${totalImages}`, 'running') + + const hardlinkMd5Set = new Set() + for (const img of images) { + const imageMd5 = String(img.imageMd5 || '').trim().toLowerCase() + if (imageMd5) { + hardlinkMd5Set.add(imageMd5) + continue + } + const imageDatName = String(img.imageDatName || '').trim().toLowerCase() + if (/^[a-f0-9]{32}$/i.test(imageDatName)) { + hardlinkMd5Set.add(imageDatName) + } + } + if (hardlinkMd5Set.size > 0) { + await waitIfPaused() + if (controlState.cancelRequested) { + const remaining = Math.max(0, totalImages - completed) + finishDecrypt(successCount, failCount, { + status: 'canceled', + detail: `图片批量解密已中断:已处理 ${completed}/${totalImages}(成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount},未处理 ${remaining})`, + progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}` + }) + return + } + updateDecryptTaskStatus( + `正在预热图片索引(${hardlinkMd5Set.size} 个标识)...`, + `0 / ${totalImages}` + ) + try { + await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set)) + } catch { + // ignore preload failures and continue decrypt + } + } + updateDecryptTaskStatus(`开始批量解密图片(0/${totalImages})`, `0 / ${totalImages}`) + const concurrency = batchDecryptConcurrency const decryptOne = async (img: typeof images[0]) => { + if (controlState.cancelRequested) return try { const r = await window.electronAPI.image.decrypt({ sessionId: session.username, @@ -5861,32 +6064,59 @@ function ChatPage(props: ChatPageProps) { createTime: img.createTime, force: true, preferFilePath: true, - hardlinkOnly: true + hardlinkOnly: true, + disableUpdateCheck: true, + suppressEvents: true }) if (r?.success) successCount++ - else failCount++ + else { + failCount++ + if (r?.failureKind === 'decrypt_failed') decryptFailedCount++ + else notFoundCount++ + } } catch { failCount++ + notFoundCount++ } completed++ - updateDecryptProgress(completed, images.length) + updateDecryptProgress(completed, totalImages) } - // 并发池:同时跑 concurrency 个任务 const pool = new Set>() for (const img of images) { - const p = decryptOne(img).then(() => { pool.delete(p) }) - pool.add(p) + if (controlState.cancelRequested) break + await waitIfPaused() + if (controlState.cancelRequested) break if (pool.size >= concurrency) { await Promise.race(pool) + if (controlState.cancelRequested) break + await waitIfPaused() + if (controlState.cancelRequested) break } + let p: Promise = Promise.resolve() + p = decryptOne(img).then(() => { pool.delete(p) }) + pool.add(p) } - if (pool.size > 0) { - await Promise.all(pool) + while (pool.size > 0) { + await Promise.race(pool) } - finishDecrypt(successCount, failCount) - }, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) + if (controlState.cancelRequested) { + const remaining = Math.max(0, totalImages - completed) + finishDecrypt(successCount, failCount, { + status: 'canceled', + detail: `图片批量解密已中断:已处理 ${completed}/${totalImages}(成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount},未处理 ${remaining})`, + progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}` + }) + return + } + + finishDecrypt(successCount, failCount, { + status: decryptFailedCount > 0 ? 'failed' : 'completed', + detail: `图片批量解密完成:成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount}`, + progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}` + }) + }, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptTaskStatus, updateDecryptProgress]) const batchImageCountByDate = useMemo(() => { const map = new Map() @@ -6621,16 +6851,10 @@ function ChatPage(props: ChatPageProps) { {!standaloneSessionWindow && (
@@ -7398,17 +7616,17 @@ function ChatPage(props: ChatPageProps) { className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`} onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)} > - {batchDecryptConcurrency === 1 ? '1(最慢,最稳)' : batchDecryptConcurrency === 6 ? '6(推荐)' : batchDecryptConcurrency === 20 ? '20(最快,可能卡顿)' : String(batchDecryptConcurrency)} + {batchDecryptConcurrency === 1 ? '1' : batchDecryptConcurrency === 6 ? '6' : batchDecryptConcurrency === 20 ? '20' : String(batchDecryptConcurrency)} {showConcurrencyDropdown && (
{[ - { value: 1, label: '1(最慢,最稳)' }, + { value: 1, label: '1' }, { value: 3, label: '3' }, - { value: 6, label: '6(推荐)' }, + { value: 6, label: '6' }, { value: 10, label: '10' }, - { value: 20, label: '20(最快,可能卡顿)' }, + { value: 20, label: '20' }, ].map(opt => (
@@ -7789,7 +8007,13 @@ const emojiDataUrlCache = new Map() const imageDataUrlCache = new Map() const voiceDataUrlCache = new Map() const voiceTranscriptCache = new Map() -type SharedImageDecryptResult = { success: boolean; localPath?: string; liveVideoPath?: string; error?: string } +type SharedImageDecryptResult = { + success: boolean + localPath?: string + liveVideoPath?: string + error?: string + failureKind?: 'not_found' | 'decrypt_failed' +} const imageDecryptInFlight = new Map>() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index fd4c63f..28e4f31 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -410,6 +410,17 @@ background: rgba(245, 158, 11, 0.14); } + &.status-pause_requested, + &.status-paused { + color: #b45309; + background: rgba(245, 158, 11, 0.14); + } + + &.status-canceled { + color: #64748b; + background: rgba(148, 163, 184, 0.2); + } + &.status-completed { color: #166534; background: rgba(34, 197, 94, 0.14); @@ -5817,8 +5828,9 @@ } } -/* 终止时间选择器 */ -.automation-stopat-picker { +/* 首次触发/终止时间选择器 */ +.automation-stopat-picker, +.automation-first-trigger-picker { display: flex; align-items: center; gap: 8px; @@ -5884,4 +5896,4 @@ font-size: 11px; border-radius: 6px; } -} \ No newline at end of file +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 279bb17..1bdf337 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -22,6 +22,8 @@ import { MessageSquare, MessageSquareText, Mic, + Pause, + Play, RefreshCw, Search, Square, @@ -48,6 +50,8 @@ import { import { requestCancelBackgroundTask, requestCancelBackgroundTasks, + requestPauseBackgroundTask, + requestResumeBackgroundTask, subscribeBackgroundTasks } from '../services/backgroundTaskMonitor' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' @@ -208,6 +212,8 @@ interface AutomationTaskDraft { dateRangeConfig: ExportAutomationDateRangeConfig | string | null intervalDays: number intervalHours: number + firstTriggerAtEnabled: boolean + firstTriggerAtValue: string stopAtEnabled: boolean stopAtValue: string maxRunsEnabled: boolean @@ -217,6 +223,7 @@ interface AutomationTaskDraft { const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900 +const EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS = 320 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 @@ -248,6 +255,8 @@ const backgroundTaskSourceLabels: Record = { const backgroundTaskStatusLabels: Record = { running: '运行中', + pause_requested: '中断中', + paused: '已中断', cancel_requested: '停止中', completed: '已完成', failed: '失败', @@ -321,6 +330,69 @@ const createEmptyProgress = (): TaskProgress => ({ mediaBytesWritten: 0 }) +const areStringArraysEqual = (left: string[], right: string[]): boolean => { + if (left === right) return true + if (left.length !== right.length) return false + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false + } + return true +} + +const areTaskProgressEqual = (left: TaskProgress, right: TaskProgress): boolean => ( + left.current === right.current && + left.total === right.total && + left.currentName === right.currentName && + left.phase === right.phase && + left.phaseLabel === right.phaseLabel && + left.phaseProgress === right.phaseProgress && + left.phaseTotal === right.phaseTotal && + left.exportedMessages === right.exportedMessages && + left.estimatedTotalMessages === right.estimatedTotalMessages && + left.collectedMessages === right.collectedMessages && + left.writtenFiles === right.writtenFiles && + left.mediaDoneFiles === right.mediaDoneFiles && + left.mediaCacheHitFiles === right.mediaCacheHitFiles && + left.mediaCacheMissFiles === right.mediaCacheMissFiles && + left.mediaCacheFillFiles === right.mediaCacheFillFiles && + left.mediaDedupReuseFiles === right.mediaDedupReuseFiles && + left.mediaBytesWritten === right.mediaBytesWritten +) + +const normalizeProgressFloat = (value: unknown, digits = 3): number => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 0 + const factor = 10 ** digits + return Math.round(parsed * factor) / factor +} + +const normalizeProgressInt = (value: unknown): number => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return 0 + return Math.max(0, Math.floor(parsed)) +} + +const buildProgressPayloadSignature = (payload: ExportProgress): string => ([ + String(payload.phase || ''), + String(payload.currentSessionId || ''), + String(payload.currentSession || ''), + String(payload.phaseLabel || ''), + normalizeProgressFloat(payload.current, 4), + normalizeProgressFloat(payload.total, 4), + normalizeProgressFloat(payload.phaseProgress, 2), + normalizeProgressFloat(payload.phaseTotal, 2), + normalizeProgressInt(payload.collectedMessages), + normalizeProgressInt(payload.exportedMessages), + normalizeProgressInt(payload.estimatedTotalMessages), + normalizeProgressInt(payload.writtenFiles), + normalizeProgressInt(payload.mediaDoneFiles), + normalizeProgressInt(payload.mediaCacheHitFiles), + normalizeProgressInt(payload.mediaCacheMissFiles), + normalizeProgressInt(payload.mediaCacheFillFiles), + normalizeProgressInt(payload.mediaDedupReuseFiles), + normalizeProgressInt(payload.mediaBytesWritten) +].join('|')) + const createEmptyTaskPerformance = (): TaskPerformance => ({ stages: { collect: 0, @@ -508,6 +580,35 @@ const getTaskStatusLabel = (task: ExportTask): string => { return '失败' } +const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => { + if (status === 'running') return 'running' + if (status === 'pause_requested' || status === 'paused') return 'paused' + if (status === 'cancel_requested' || status === 'canceled') return 'stopped' + if (status === 'completed') return 'success' + return 'error' +} + +const parseBackgroundTaskProgress = (progressText?: string): { current: number; total: number; ratio: number | null } => { + const normalized = String(progressText || '').trim() + if (!normalized) { + return { current: 0, total: 0, ratio: null } + } + const match = normalized.match(/(\d+)\s*\/\s*(\d+)/) + if (!match) { + return { current: 0, total: 0, ratio: null } + } + const current = Math.max(0, Math.floor(Number(match[1]) || 0)) + const total = Math.max(0, Math.floor(Number(match[2]) || 0)) + if (total <= 0) { + return { current, total, ratio: null } + } + return { + current, + total, + ratio: Math.max(0, Math.min(1, current / total)) + } +} + const formatAbsoluteDate = (timestamp: number): string => { const d = new Date(timestamp) const y = d.getFullYear() @@ -643,6 +744,11 @@ type ContactsDataSource = 'cache' | 'network' | null const normalizeAutomationIntervalDays = (value: unknown): number => Math.max(0, Math.floor(Number(value) || 0)) const normalizeAutomationIntervalHours = (value: unknown): number => Math.max(0, Math.min(23, Math.floor(Number(value) || 0))) +const normalizeAutomationFirstTriggerAt = (value: unknown): number => { + const numeric = Math.floor(Number(value) || 0) + if (!Number.isFinite(numeric) || numeric <= 0) return 0 + return numeric +} const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => { const days = normalizeAutomationIntervalDays(schedule.intervalDays) @@ -652,6 +758,16 @@ const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number return totalHours * 60 * 60 * 1000 } +const resolveAutomationInitialTriggerAt = (task: ExportAutomationTask): number | null => { + const intervalMs = resolveAutomationIntervalMs(task.schedule) + if (intervalMs <= 0) return null + const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt) + if (firstTriggerAt > 0) return firstTriggerAt + const createdAt = Math.max(0, Math.floor(Number(task.createdAt || 0))) + if (!createdAt) return null + return createdAt + intervalMs +} + const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => { const days = normalizeAutomationIntervalDays(schedule.intervalDays) const hours = normalizeAutomationIntervalHours(schedule.intervalHours) @@ -665,12 +781,60 @@ const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date): const intervalMs = resolveAutomationIntervalMs(task.schedule) if (intervalMs <= 0) return null const nowMs = now.getTime() - const anchorAt = Math.max( - 0, - Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0) - ) - if (nowMs < anchorAt + intervalMs) return null - return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / intervalMs)}` + const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0))) + if (lastTriggeredAt > 0) { + if (nowMs < lastTriggeredAt + intervalMs) return null + return `interval:${lastTriggeredAt}:${Math.floor((nowMs - lastTriggeredAt) / intervalMs)}` + } + const initialTriggerAt = resolveAutomationInitialTriggerAt(task) + if (!initialTriggerAt) return null + if (nowMs < initialTriggerAt) return null + return `first:${initialTriggerAt}` +} + +const resolveAutomationFirstTriggerSummary = (task: ExportAutomationTask): string => { + const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt) + if (firstTriggerAt <= 0) return '未指定(默认按创建时间+间隔)' + return new Date(firstTriggerAt).toLocaleString('zh-CN') +} + +const buildAutomationSchedule = ( + intervalDays: number, + intervalHours: number, + firstTriggerAt: number +): ExportAutomationSchedule => ({ + type: 'interval', + intervalDays, + intervalHours, + firstTriggerAt: firstTriggerAt > 0 ? firstTriggerAt : undefined +}) + +const buildAutomationDatePart = (timestamp: number): string => { + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) return '' + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +const buildAutomationTodayDatePart = (): string => buildAutomationDatePart(Date.now()) + +const normalizeAutomationDatePart = (value: string): string => { + const text = String(value || '').trim() + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '' +} + +const normalizeAutomationTimePart = (value: string): string => { + const text = String(value || '').trim() + if (!/^\d{2}:\d{2}$/.test(text)) return '00:00' + const [hoursText, minutesText] = text.split(':') + const hours = Math.floor(Number(hoursText)) + const minutes = Math.floor(Number(minutesText)) + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return '00:00' + const safeHours = Math.min(23, Math.max(0, hours)) + const safeMinutes = Math.min(59, Math.max(0, minutes)) + return `${`${safeHours}`.padStart(2, '0')}:${`${safeMinutes}`.padStart(2, '0')}` } const toDateTimeLocalValue = (timestamp: number): string => { @@ -811,9 +975,9 @@ const formatAutomationStopCondition = (task: ExportAutomationTask): string => { const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => { const intervalMs = resolveAutomationIntervalMs(task.schedule) if (intervalMs <= 0) return null - const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)) - if (!anchorAt) return null - return anchorAt + intervalMs + const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0))) + if (lastTriggeredAt > 0) return lastTriggeredAt + intervalMs + return resolveAutomationInitialTriggerAt(task) } const formatAutomationCurrentState = ( @@ -1597,25 +1761,40 @@ const SectionInfoTooltip = memo(function SectionInfoTooltip({ interface TaskCenterModalProps { isOpen: boolean tasks: ExportTask[] + chatBackgroundTasks: BackgroundTaskRecord[] taskRunningCount: number taskQueuedCount: number expandedPerfTaskId: string | null nowTick: number onClose: () => void onTogglePerfTask: (taskId: string) => void + onPauseBackgroundTask: (taskId: string) => void + onResumeBackgroundTask: (taskId: string) => void + onCancelBackgroundTask: (taskId: string) => void } const TaskCenterModal = memo(function TaskCenterModal({ isOpen, tasks, + chatBackgroundTasks, taskRunningCount, taskQueuedCount, expandedPerfTaskId, nowTick, onClose, - onTogglePerfTask + onTogglePerfTask, + onPauseBackgroundTask, + onResumeBackgroundTask, + onCancelBackgroundTask }: TaskCenterModalProps) { if (!isOpen) return null + const chatActiveTaskCount = chatBackgroundTasks.filter(task => ( + task.status === 'running' || + task.status === 'pause_requested' || + task.status === 'paused' || + task.status === 'cancel_requested' + )).length + const totalTaskCount = tasks.length + chatBackgroundTasks.length return createPortal(

任务中心

- 进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length} + 导出进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 聊天后台 {chatActiveTaskCount} · 总计 {totalTaskCount}
- {tasks.length === 0 ? ( -
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
+ {totalTaskCount === 0 ? ( +
暂无任务。导出任务和聊天页批量语音/图片任务都会显示在这里。
) : (
{tasks.map(task => { @@ -1833,6 +2012,70 @@ const TaskCenterModal = memo(function TaskCenterModal({
) })} + {chatBackgroundTasks.map(task => { + const taskCardClass = resolveBackgroundTaskCardClass(task.status) + const progress = parseBackgroundTaskProgress(task.progressText) + const canPause = task.resumable && task.status === 'running' + const canResume = task.resumable && (task.status === 'paused' || task.status === 'pause_requested') + const canCancel = task.cancelable && ( + task.status === 'running' || + task.status === 'pause_requested' || + task.status === 'paused' || + task.status === 'cancel_requested' + ) + return ( +
+
+
{task.title}
+
+ {backgroundTaskStatusLabels[task.status]} + {backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other} + {new Date(task.startedAt).toLocaleString('zh-CN')} +
+ {progress.ratio !== null && ( +
+
+
+ )} +
+ {task.detail || '任务进行中'} + {task.progressText ? ` · ${task.progressText}` : ''} +
+
+
+ {canPause && ( + + )} + {canResume && ( + + )} + +
+
+ ) + })}
)}
@@ -4857,6 +5100,7 @@ function ExportPage() { const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => { const schedule = task.schedule + const firstTriggerAt = normalizeAutomationFirstTriggerAt(schedule.firstTriggerAt) const stopAt = Number(task.stopCondition?.endAt || 0) const maxRuns = Number(task.stopCondition?.maxRuns || 0) const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date()) @@ -4877,6 +5121,8 @@ function ExportPage() { dateRangeConfig: task.template.dateRangeConfig, intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays), intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours), + firstTriggerAtEnabled: firstTriggerAt > 0, + firstTriggerAtValue: firstTriggerAt > 0 ? toDateTimeLocalValue(firstTriggerAt) : '', stopAtEnabled: stopAt > 0, stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '', maxRunsEnabled: maxRuns > 0, @@ -4982,7 +5228,18 @@ function ExportPage() { window.alert('执行间隔不能为 0,请至少设置天数或小时') return } - const schedule: ExportAutomationSchedule = { type: 'interval', intervalDays, intervalHours } + const firstTriggerAtTimestamp = automationTaskDraft.firstTriggerAtEnabled + ? parseDateTimeLocalValue(automationTaskDraft.firstTriggerAtValue) + : null + if (automationTaskDraft.firstTriggerAtEnabled && !firstTriggerAtTimestamp) { + window.alert('请填写有效的首次触发时间') + return + } + const schedule = buildAutomationSchedule( + intervalDays, + intervalHours, + firstTriggerAtTimestamp && firstTriggerAtTimestamp > 0 ? firstTriggerAtTimestamp : 0 + ) const stopAtTimestamp = automationTaskDraft.stopAtEnabled ? parseDateTimeLocalValue(automationTaskDraft.stopAtValue) : null @@ -5169,14 +5426,10 @@ function ExportPage() { const settledSessionIdsFromProgress = new Set() const sessionMessageProgress = new Map() let queuedProgressPayload: ExportProgress | null = null - let queuedProgressRaf: number | null = null + let queuedProgressSignature = '' let queuedProgressTimer: number | null = null const clearQueuedProgress = () => { - if (queuedProgressRaf !== null) { - window.cancelAnimationFrame(queuedProgressRaf) - queuedProgressRaf = null - } if (queuedProgressTimer !== null) { window.clearTimeout(queuedProgressTimer) queuedProgressTimer = null @@ -5228,6 +5481,7 @@ function ExportPage() { if (!queuedProgressPayload) return const payload = queuedProgressPayload queuedProgressPayload = null + queuedProgressSignature = '' const now = Date.now() const currentSessionId = String(payload.currentSessionId || '').trim() updateTask(next.id, task => { @@ -5284,77 +5538,71 @@ function ExportPage() { const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten) ? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0)))) : prevMediaBytesWritten + const nextProgress: TaskProgress = { + current: payload.current, + total: payload.total, + currentName: payload.currentSession || '', + phase: payload.phase, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0, + exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported), + estimatedTotalMessages: aggregatedMessageProgress.estimated > 0 + ? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated) + : (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0), + collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages), + writtenFiles, + mediaDoneFiles, + mediaCacheHitFiles, + mediaCacheMissFiles, + mediaCacheFillFiles, + mediaDedupReuseFiles, + mediaBytesWritten + } + const hasSettledListChanged = !areStringArraysEqual(settledSessionIds, nextSettledSessionIds) + const hasProgressChanged = !areTaskProgressEqual(task.progress, nextProgress) + const hasPerformanceChanged = performance !== task.performance + if (!hasSettledListChanged && !hasProgressChanged && !hasPerformanceChanged) { + return task + } return { ...task, - progress: { - current: payload.current, - total: payload.total, - currentName: payload.currentSession, - phase: payload.phase, - phaseLabel: payload.phaseLabel || '', - phaseProgress: payload.phaseProgress || 0, - phaseTotal: payload.phaseTotal || 0, - exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported), - estimatedTotalMessages: aggregatedMessageProgress.estimated > 0 - ? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated) - : (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0), - collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages), - writtenFiles, - mediaDoneFiles, - mediaCacheHitFiles, - mediaCacheMissFiles, - mediaCacheFillFiles, - mediaDedupReuseFiles, - mediaBytesWritten - }, - settledSessionIds: nextSettledSessionIds, - performance + progress: hasProgressChanged ? nextProgress : task.progress, + settledSessionIds: hasSettledListChanged ? nextSettledSessionIds : settledSessionIds, + performance: hasPerformanceChanged ? performance : task.performance } }) } const queueProgressUpdate = (payload: ExportProgress) => { + const signature = buildProgressPayloadSignature(payload) + if (queuedProgressPayload && signature === queuedProgressSignature) { + return + } queuedProgressPayload = payload + queuedProgressSignature = signature if (payload.phase === 'complete') { clearQueuedProgress() flushQueuedProgress() return } - if (queuedProgressRaf !== null || queuedProgressTimer !== null) return - queuedProgressRaf = window.requestAnimationFrame(() => { - queuedProgressRaf = null - queuedProgressTimer = window.setTimeout(() => { - queuedProgressTimer = null - flushQueuedProgress() - }, 180) - }) + if (queuedProgressTimer !== null) return + queuedProgressTimer = window.setTimeout(() => { + queuedProgressTimer = null + flushQueuedProgress() + }, EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS) } if (next.payload.scope === 'sns') { progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => { - updateTask(next.id, task => { - if (task.status !== 'running') return task - return { - ...task, - progress: { - current: payload.current || 0, - total: payload.total || 0, - currentName: '', - phase: 'exporting', - phaseLabel: payload.status || '', - phaseProgress: payload.total > 0 ? payload.current : 0, - phaseTotal: payload.total || 0, - exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages, - estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages, - collectedMessages: task.progress.collectedMessages, - writtenFiles: task.progress.writtenFiles, - mediaDoneFiles: task.progress.mediaDoneFiles, - mediaCacheHitFiles: task.progress.mediaCacheHitFiles, - mediaCacheMissFiles: task.progress.mediaCacheMissFiles, - mediaCacheFillFiles: task.progress.mediaCacheFillFiles, - mediaDedupReuseFiles: task.progress.mediaDedupReuseFiles, - mediaBytesWritten: task.progress.mediaBytesWritten - } - } + queueProgressUpdate({ + current: Number(payload.current || 0), + total: Number(payload.total || 0), + currentSession: '', + currentSessionId: '', + phase: 'exporting', + phaseLabel: String(payload.status || ''), + phaseProgress: payload.total > 0 ? Number(payload.current || 0) : 0, + phaseTotal: Number(payload.total || 0) }) }) } else { @@ -5679,6 +5927,8 @@ function ExportPage() { dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection), intervalDays: 1, intervalHours: 0, + firstTriggerAtEnabled: false, + firstTriggerAtValue: '', stopAtEnabled: false, stopAtValue: '', maxRunsEnabled: false, @@ -7357,11 +7607,23 @@ function ExportPage() { const handleCancelBackgroundTask = useCallback((taskId: string) => { requestCancelBackgroundTask(taskId) }, []) + const handlePauseBackgroundTask = useCallback((taskId: string) => { + requestPauseBackgroundTask(taskId) + }, []) + const handleResumeBackgroundTask = useCallback((taskId: string) => { + requestResumeBackgroundTask(taskId) + }, []) const handleCancelAllNonExportTasks = useCallback(() => { requestCancelBackgroundTasks(task => ( task.sourcePage !== 'export' && + task.sourcePage !== 'chat' && task.cancelable && - (task.status === 'running' || task.status === 'cancel_requested') + ( + task.status === 'running' || + task.status === 'pause_requested' || + task.status === 'paused' || + task.status === 'cancel_requested' + ) )) }, []) @@ -7509,7 +7771,18 @@ function ExportPage() { const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length - const taskCenterAlertCount = taskRunningCount + taskQueuedCount + const chatBackgroundTasks = useMemo(() => ( + backgroundTasks.filter(task => task.sourcePage === 'chat') + ), [backgroundTasks]) + const chatBackgroundActiveTaskCount = useMemo(() => ( + chatBackgroundTasks.filter(task => ( + task.status === 'running' || + task.status === 'pause_requested' || + task.status === 'paused' || + task.status === 'cancel_requested' + )).length + ), [chatBackgroundTasks]) + const taskCenterAlertCount = taskRunningCount + taskQueuedCount + chatBackgroundActiveTaskCount const hasFilteredContacts = filteredContacts.length > 0 const optionalMetricColumnCount = (shouldShowSnsColumn ? 1 : 0) + (shouldShowMutualFriendsColumn ? 1 : 0) const contactsMetricColumnCount = 4 + optionalMetricColumnCount @@ -7524,15 +7797,25 @@ function ExportPage() { width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px` }), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth]) const nonExportBackgroundTasks = useMemo(() => ( - backgroundTasks.filter(task => task.sourcePage !== 'export') + backgroundTasks.filter(task => task.sourcePage !== 'export' && task.sourcePage !== 'chat') ), [backgroundTasks]) const runningNonExportTaskCount = useMemo(() => ( - nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length + nonExportBackgroundTasks.filter(task => ( + task.status === 'running' || + task.status === 'pause_requested' || + task.status === 'paused' || + task.status === 'cancel_requested' + )).length ), [nonExportBackgroundTasks]) const cancelableNonExportTaskCount = useMemo(() => ( nonExportBackgroundTasks.filter(task => ( task.cancelable && - (task.status === 'running' || task.status === 'cancel_requested') + ( + task.status === 'running' || + task.status === 'pause_requested' || + task.status === 'paused' || + task.status === 'cancel_requested' + ) )).length ), [nonExportBackgroundTasks]) const nonExportBackgroundTasksUpdatedAt = useMemo(() => ( @@ -8152,12 +8435,16 @@ function ExportPage() { {isAutomationModalOpen && createPortal( @@ -8233,6 +8520,7 @@ function ExportPage() { {queueState === 'queued' && 排队中}

{formatAutomationScheduleLabel(task.schedule)}

+

首次触发:{resolveAutomationFirstTriggerSummary(task)}

时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}

会话范围:{task.sessionIds.length} 个

导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}

@@ -8346,6 +8634,52 @@ function ExportPage() { +
+ 首次触发时间(可选) + + {automationTaskDraft.firstTriggerAtEnabled && ( +
+ { + const datePart = normalizeAutomationDatePart(event.target.value) + const timePart = normalizeAutomationTimePart(automationTaskDraft.firstTriggerAtValue?.slice(11) || '00:00') + setAutomationTaskDraft((prev) => prev ? { + ...prev, + firstTriggerAtValue: datePart ? `${datePart}T${timePart}` : '' + } : prev) + }} + /> + { + const timePart = normalizeAutomationTimePart(event.target.value) + const datePart = normalizeAutomationDatePart(automationTaskDraft.firstTriggerAtValue?.slice(0, 10)) + || buildAutomationTodayDatePart() + setAutomationTaskDraft((prev) => prev ? { + ...prev, + firstTriggerAtValue: `${datePart}T${timePart}` + } : prev) + }} + /> +
+ )} +
+
导出时间范围(按触发时间动态计算)
@@ -8486,7 +8820,11 @@ function ExportPage() {
- 会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出 + 会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 首次:{ + automationTaskDraft.firstTriggerAtEnabled + ? (automationTaskDraft.firstTriggerAtValue ? automationTaskDraft.firstTriggerAtValue.replace('T', ' ') : '未设置') + : '默认按创建时间+间隔' + } · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
@@ -8959,7 +9297,12 @@ function ExportPage() { type="button" className="session-load-detail-task-stop-btn" onClick={() => handleCancelBackgroundTask(task.id)} - disabled={!task.cancelable || (task.status !== 'running' && task.status !== 'cancel_requested')} + disabled={!task.cancelable || ( + task.status !== 'running' && + task.status !== 'pause_requested' && + task.status !== 'paused' && + task.status !== 'cancel_requested' + )} > 停止 diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx index 7518647..9d1d0c0 100644 --- a/src/pages/ResourcesPage.tsx +++ b/src/pages/ResourcesPage.tsx @@ -44,6 +44,7 @@ const INITIAL_IMAGE_PRELOAD_END = 48 const INITIAL_IMAGE_RESOLVE_END = 12 const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250 const TASK_PROGRESS_UPDATE_MAX_STEPS = 100 +const BATCH_IMAGE_DECRYPT_CONCURRENCY = 8 const GridList = forwardRef>(function GridList(props, ref) { const { className = '', ...rest } = props @@ -71,6 +72,20 @@ function getRangeTimestampEnd(date: string): number | undefined { return Number.isFinite(n) ? n : undefined } +function normalizeMediaToken(value?: string): string { + return String(value || '').trim().toLowerCase() +} + +function getSafeImageDatName(item: Pick): string { + const datName = normalizeMediaToken(item.imageDatName) + if (!datName) return '' + return datName +} + +function hasImageLocator(item: Pick): boolean { + return Boolean(normalizeMediaToken(item.imageMd5) || getSafeImageDatName(item)) +} + function getItemKey(item: MediaStreamItem): string { const sessionId = String(item.sessionId || '').trim().toLowerCase() const localId = Number(item.localId || 0) @@ -84,7 +99,7 @@ function getItemKey(item: MediaStreamItem): string { const mediaId = String( item.mediaType === 'video' ? (item.videoMd5 || '') - : (item.imageMd5 || item.imageDatName || '') + : (item.imageMd5 || getSafeImageDatName(item) || '') ).trim().toLowerCase() return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}` } @@ -658,19 +673,20 @@ function ResourcesPage() { const to = Math.min(displayItems.length - 1, end) if (to < from) return const now = Date.now() - const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] + const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = [] const itemKeys: string[] = [] for (let i = from; i <= to; i += 1) { const item = displayItems[i] if (!item || item.mediaType !== 'image') continue const itemKey = getItemKey(item) if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue - if (!item.imageMd5 && !item.imageDatName) continue + if (!hasImageLocator(item)) continue if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue payloads.push({ sessionId: item.sessionId, - imageMd5: item.imageMd5 || undefined, - imageDatName: item.imageDatName || undefined + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined }) itemKeys.push(itemKey) if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break @@ -686,7 +702,10 @@ function ResourcesPage() { try { const result = await window.electronAPI.image.resolveCacheBatch(payloads, { disableUpdateCheck: true, - allowCacheIndex: false + allowCacheIndex: true, + preferFilePath: true, + hardlinkOnly: true, + suppressEvents: true }) const rows = Array.isArray(result?.rows) ? result.rows : [] const pathPatch: Record = {} @@ -733,30 +752,31 @@ function ResourcesPage() { if (to < from) return const now = Date.now() - const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = [] + const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = [] const dedup = new Set() for (let i = from; i <= to; i += 1) { const item = displayItems[i] if (!item || item.mediaType !== 'image') continue const itemKey = getItemKey(item) if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue - if (!item.imageMd5 && !item.imageDatName) continue + if (!hasImageLocator(item)) continue if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue - const dedupKey = `${item.sessionId || ''}|${item.imageMd5 || ''}|${item.imageDatName || ''}` + const dedupKey = `${item.sessionId || ''}|${normalizeMediaToken(item.imageMd5)}|${getSafeImageDatName(item)}` if (dedup.has(dedupKey)) continue dedup.add(dedupKey) imagePreloadUntilRef.current[itemKey] = now + 12000 payloads.push({ sessionId: item.sessionId, - imageMd5: item.imageMd5 || undefined, - imageDatName: item.imageDatName || undefined + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined }) if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break } if (payloads.length === 0) return void window.electronAPI.image.preload(payloads, { allowDecrypt: false, - allowCacheIndex: false + allowCacheIndex: true }) }, [displayItems]) @@ -954,11 +974,14 @@ function ResourcesPage() { }, '批量删除确认') }, [batchBusy, selectedItems, showAlert, showConfirm]) - const decryptImage = useCallback(async (item: MediaStreamItem): Promise => { + const decryptImage = useCallback(async ( + item: MediaStreamItem, + options?: { allowCacheIndex?: boolean } + ): Promise => { if (item.mediaType !== 'image') return const key = getItemKey(item) - if (!item.imageMd5 && !item.imageDatName) { + if (!hasImageLocator(item)) { showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密') return } @@ -972,12 +995,21 @@ function ResourcesPage() { try { const result = await window.electronAPI.image.decrypt({ sessionId: item.sessionId, - imageMd5: item.imageMd5 || undefined, - imageDatName: item.imageDatName || undefined, - force: true + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined, + force: true, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: options?.allowCacheIndex ?? true, + suppressEvents: true }) if (!result?.success) { - showAlert(`解密失败:${result?.error || '未知错误'}`, '解密失败') + if (result?.failureKind === 'decrypt_failed') { + showAlert(`解密失败:${result?.error || '解密后不是有效图片'}`, '解密失败') + } else { + showAlert(`本地无数据:${result?.error || '未找到原始 DAT 文件'}`, '未找到本地数据') + } return undefined } @@ -991,8 +1023,13 @@ function ResourcesPage() { try { const resolved = await window.electronAPI.image.resolveCache({ sessionId: item.sessionId, - imageMd5: item.imageMd5 || undefined, - imageDatName: item.imageDatName || undefined + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: true, + suppressEvents: true }) if (resolved?.success && resolved.localPath) { const localPath = resolved.localPath @@ -1007,7 +1044,7 @@ function ResourcesPage() { setActionMessage('图片解密完成') return undefined } catch (e) { - showAlert(`解密失败:${String(e)}`, '解密失败') + showAlert(`本地无数据:${String(e)}`, '未找到本地数据') return undefined } finally { setDecryptingKeys((prev) => { @@ -1027,8 +1064,13 @@ function ResourcesPage() { try { const resolved = await window.electronAPI.image.resolveCache({ sessionId: item.sessionId, - imageMd5: item.imageMd5 || undefined, - imageDatName: item.imageDatName || undefined + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: true, + suppressEvents: true }) if (resolved?.success && resolved.localPath) { localPath = resolved.localPath @@ -1046,8 +1088,13 @@ function ResourcesPage() { try { const resolved = await window.electronAPI.image.resolveCache({ sessionId: item.sessionId, - imageMd5: item.imageMd5 || undefined, - imageDatName: item.imageDatName || undefined + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: true, + suppressEvents: true }) if (resolved?.success && resolved.localPath) { localPath = resolved.localPath @@ -1077,7 +1124,8 @@ function ResourcesPage() { setBatchBusy(true) let success = 0 - let failed = 0 + let notFound = 0 + let decryptFailed = 0 const previewPatch: Record = {} const updatePatch: Record = {} const taskId = registerBackgroundTask({ @@ -1105,32 +1153,71 @@ function ResourcesPage() { lastProgressBucket = bucket lastProgressUpdateAt = now } + const hardlinkMd5Set = new Set() for (const item of imageItems) { - if (!item.imageMd5 && !item.imageDatName) { - failed += 1 - completed += 1 - updateTaskProgress() + if (!hasImageLocator(item)) continue + const imageMd5 = normalizeMediaToken(item.imageMd5) + if (imageMd5) { + hardlinkMd5Set.add(imageMd5) continue } - const result = await window.electronAPI.image.decrypt({ - sessionId: item.sessionId, - imageMd5: item.imageMd5 || undefined, - imageDatName: item.imageDatName || undefined, - force: true - }) - if (!result?.success) { - failed += 1 - } else { - success += 1 - if (result.localPath) { - const key = getItemKey(item) - previewPatch[key] = result.localPath - updatePatch[key] = isLikelyThumbnailPreview(result.localPath) + const imageDatName = getSafeImageDatName(item) + if (/^[a-f0-9]{32}$/i.test(imageDatName)) { + hardlinkMd5Set.add(imageDatName) + } + } + if (hardlinkMd5Set.size > 0) { + try { + await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set)) + } catch { + // ignore preload failures and continue decrypt + } + } + + const concurrency = Math.max(1, Math.min(BATCH_IMAGE_DECRYPT_CONCURRENCY, imageItems.length)) + let cursor = 0 + const worker = async () => { + while (true) { + const index = cursor + cursor += 1 + if (index >= imageItems.length) return + const item = imageItems[index] + try { + if (!hasImageLocator(item)) { + notFound += 1 + continue + } + const result = await window.electronAPI.image.decrypt({ + sessionId: item.sessionId, + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined, + force: true, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: true, + suppressEvents: true + }) + if (!result?.success) { + if (result?.failureKind === 'decrypt_failed') decryptFailed += 1 + else notFound += 1 + } else { + success += 1 + if (result.localPath) { + const key = getItemKey(item) + previewPatch[key] = result.localPath + updatePatch[key] = isLikelyThumbnailPreview(result.localPath) + } + } + } catch { + notFound += 1 + } finally { + completed += 1 + updateTaskProgress() } } - completed += 1 - updateTaskProgress() } + await Promise.all(Array.from({ length: concurrency }, () => worker())) updateTaskProgress(true) if (Object.keys(previewPatch).length > 0) { @@ -1139,11 +1226,11 @@ function ResourcesPage() { if (Object.keys(updatePatch).length > 0) { setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch })) } - setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`) - showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成') - finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', { - detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`, - progressText: `成功 ${success} / 失败 ${failed}` + setActionMessage(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`) + showAlert(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`, '批量解密完成') + finishBackgroundTask(taskId, decryptFailed > 0 ? 'failed' : 'completed', { + detail: `资源页图片批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`, + progressText: `成功 ${success} / 未找到 ${notFound} / 解密失败 ${decryptFailed}` }) } catch (e) { finishBackgroundTask(taskId, 'failed', { diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 1dda111..7234964 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -56,43 +56,28 @@ const normalizeDbKeyStatusMessage = (message: string): string => { return message } -const isDbKeyReadyMessage = (message: string): boolean => ( - message.includes('现在可以登录') - || message.includes('Hook安装成功') - || message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信') -) +const isDbKeyReadyMessage = (message: string): boolean => { + if (isWindows) { + return message.includes('现在可以登录') + || message.includes('Hook安装成功') + || message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信') + } + return message.includes('现在可以登录') +} -const pickWxidByAnchorTime = ( - wxids: Array<{ wxid: string; modifiedTime: number }>, - anchorTime?: number +const pickLatestWxid = ( + wxids: Array<{ wxid: string; modifiedTime: number }> ): string => { if (!Array.isArray(wxids) || wxids.length === 0) return '' const fallbackWxid = wxids[0]?.wxid || '' - if (!anchorTime || !Number.isFinite(anchorTime)) return fallbackWxid - const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0) if (valid.length === 0) return fallbackWxid - const anchor = Number(anchorTime) - const nearWindowMs = 10 * 60 * 1000 - - const near = valid - .filter(item => Math.abs(item.modifiedTime - anchor) <= nearWindowMs) - .sort((a, b) => { - const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor) - if (diffGap !== 0) return diffGap - if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime - return a.wxid.localeCompare(b.wxid) - }) - if (near.length > 0) return near[0].wxid - - const closest = valid.sort((a, b) => { - const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor) - if (diffGap !== 0) return diffGap + const latest = [...valid].sort((a, b) => { if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime return a.wxid.localeCompare(b.wxid) }) - return closest[0]?.wxid || fallbackWxid + return latest[0]?.wxid || fallbackWxid } function WelcomePage({ standalone = false }: WelcomePageProps) { @@ -434,7 +419,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { } } - const handleScanWxid = async (silent = false, anchorTime?: number) => { + const handleScanWxid = async (silent = false) => { if (!dbPath) { if (!silent) setError('请先选择数据库目录') return @@ -446,9 +431,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) setWxidOptions(wxids) if (wxids.length > 0) { - // 密钥成功后使用成功时刻作为锚点,自动选择最接近该时刻的活跃账号; - // 其余场景保持“时间最新”优先。 - const selectedWxid = pickWxidByAnchorTime(wxids, anchorTime) + // 自动获取密钥后,始终优先选择最近活跃(modifiedTime 最新)的账号。 + const selectedWxid = pickLatestWxid(wxids) setWxid(selectedWxid || wxids[0].wxid) if (!silent) setError('') } else { @@ -501,8 +485,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setHasReacquiredDbKey(true) setDbKeyStatus('密钥获取成功') setError('') - const keySuccessAt = Date.now() - await handleScanWxid(true, keySuccessAt) + await handleScanWxid(true) } else { if (isAddAccountMode) { setHasReacquiredDbKey(false) diff --git a/src/services/backgroundTaskMonitor.ts b/src/services/backgroundTaskMonitor.ts index 2b41f6d..c379267 100644 --- a/src/services/backgroundTaskMonitor.ts +++ b/src/services/backgroundTaskMonitor.ts @@ -9,10 +9,12 @@ type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void const tasks = new Map() const cancelHandlers = new Map void | Promise>() +const pauseHandlers = new Map void | Promise>() +const resumeHandlers = new Map void | Promise>() const listeners = new Set() let taskSequence = 0 -const ACTIVE_STATUSES = new Set(['running', 'cancel_requested']) +const ACTIVE_STATUSES = new Set(['running', 'pause_requested', 'paused', 'cancel_requested']) const MAX_SETTLED_TASKS = 24 const buildTaskId = (): string => { @@ -34,6 +36,9 @@ const pruneSettledTasks = () => { for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) { tasks.delete(staleTask.id) + cancelHandlers.delete(staleTask.id) + pauseHandlers.delete(staleTask.id) + resumeHandlers.delete(staleTask.id) } } @@ -64,7 +69,9 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => { detail: input.detail, progressText: input.progressText, cancelable: input.cancelable !== false, + resumable: input.resumable === true, cancelRequested: false, + pauseRequested: false, status: 'running', startedAt: now, updatedAt: now @@ -72,6 +79,12 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => { if (input.onCancel) { cancelHandlers.set(taskId, input.onCancel) } + if (input.onPause) { + pauseHandlers.set(taskId, input.onPause) + } + if (input.onResume) { + resumeHandlers.set(taskId, input.onResume) + } pruneSettledTasks() notifyListeners() return taskId @@ -87,6 +100,9 @@ export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate ...patch, status: nextStatus, updatedAt: nextUpdatedAt, + pauseRequested: nextStatus === 'paused' || nextStatus === 'pause_requested' + ? true + : (nextStatus === 'running' ? false : existing.pauseRequested), finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt) }) pruneSettledTasks() @@ -107,9 +123,12 @@ export const finishBackgroundTask = ( status, updatedAt: now, finishedAt: now, - cancelRequested: status === 'canceled' ? true : existing.cancelRequested + cancelRequested: status === 'canceled' ? true : existing.cancelRequested, + pauseRequested: false }) cancelHandlers.delete(taskId) + pauseHandlers.delete(taskId) + resumeHandlers.delete(taskId) pruneSettledTasks() notifyListeners() } @@ -121,6 +140,7 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => { ...existing, status: 'cancel_requested', cancelRequested: true, + pauseRequested: false, detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载', updatedAt: Date.now() }) @@ -132,6 +152,46 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => { return true } +export const requestPauseBackgroundTask = (taskId: string): boolean => { + const existing = tasks.get(taskId) + if (!existing || !existing.resumable) return false + if (existing.status !== 'running' && existing.status !== 'pause_requested') return false + tasks.set(taskId, { + ...existing, + status: 'pause_requested', + pauseRequested: true, + detail: existing.detail || '中断请求已发出,当前处理完成后会暂停', + updatedAt: Date.now() + }) + const pauseHandler = pauseHandlers.get(taskId) + if (pauseHandler) { + void Promise.resolve(pauseHandler()).catch(() => {}) + } + notifyListeners() + return true +} + +export const requestResumeBackgroundTask = (taskId: string): boolean => { + const existing = tasks.get(taskId) + if (!existing || !existing.resumable) return false + if (existing.status !== 'paused' && existing.status !== 'pause_requested') return false + tasks.set(taskId, { + ...existing, + status: 'running', + cancelRequested: false, + pauseRequested: false, + detail: existing.detail || '任务已继续', + updatedAt: Date.now(), + finishedAt: undefined + }) + const resumeHandler = resumeHandlers.get(taskId) + if (resumeHandler) { + void Promise.resolve(resumeHandler()).catch(() => {}) + } + notifyListeners() + return true +} + export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => { let canceledCount = 0 for (const task of tasks.values()) { @@ -147,3 +207,8 @@ export const isBackgroundTaskCancelRequested = (taskId: string): boolean => { const task = tasks.get(taskId) return Boolean(task?.cancelRequested) } + +export const isBackgroundTaskPauseRequested = (taskId: string): boolean => { + const task = tasks.get(taskId) + return Boolean(task?.pauseRequested) +} diff --git a/src/services/config.ts b/src/services/config.ts index a0fe361..60f0bc8 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -689,11 +689,17 @@ const normalizeAutomationTask = (raw: unknown): ExportAutomationTask | null => { if (scheduleType === 'interval') { const rawDays = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalDays, 0)) const rawHours = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalHours, 0)) + const rawFirstTriggerAt = Math.max(0, normalizeAutomationNumeric(scheduleObj.firstTriggerAt, 0)) const totalHours = (rawDays * 24) + rawHours if (totalHours <= 0) return null const intervalDays = Math.floor(totalHours / 24) const intervalHours = totalHours % 24 - schedule = { type: 'interval', intervalDays, intervalHours } + schedule = { + type: 'interval', + intervalDays, + intervalHours, + firstTriggerAt: rawFirstTriggerAt > 0 ? rawFirstTriggerAt : undefined + } } if (!schedule) return null diff --git a/src/stores/batchImageDecryptStore.ts b/src/stores/batchImageDecryptStore.ts index 8e162fb..3601017 100644 --- a/src/stores/batchImageDecryptStore.ts +++ b/src/stores/batchImageDecryptStore.ts @@ -4,7 +4,21 @@ import { registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' -import type { BackgroundTaskSourcePage } from '../types/backgroundTask' +import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask' + +interface BatchDecryptTaskControls { + cancelable?: boolean + resumable?: boolean + onCancel?: () => void | Promise + onPause?: () => void | Promise + onResume?: () => void | Promise +} + +interface BatchDecryptFinishOptions { + status?: Extract + detail?: string + progressText?: string +} export interface BatchImageDecryptState { isBatchDecrypting: boolean @@ -16,9 +30,15 @@ export interface BatchImageDecryptState { sessionName: string taskId: string | null - startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void + startDecrypt: ( + total: number, + sessionName: string, + sourcePage?: BackgroundTaskSourcePage, + controls?: BatchDecryptTaskControls + ) => void updateProgress: (current: number, total: number) => void - finishDecrypt: (success: number, fail: number) => void + setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void + finishDecrypt: (success: number, fail: number, options?: BatchDecryptFinishOptions) => void setShowToast: (show: boolean) => void setShowResultToast: (show: boolean) => void reset: () => void @@ -53,7 +73,7 @@ export const useBatchImageDecryptStore = create((set, ge sessionName: '', taskId: null, - startDecrypt: (total, sessionName, sourcePage = 'chat') => { + startDecrypt: (total, sessionName, sourcePage = 'chat', controls) => { const previousTaskId = get().taskId if (previousTaskId) { taskProgressUpdateMeta.delete(previousTaskId) @@ -73,7 +93,11 @@ export const useBatchImageDecryptStore = create((set, ge title, detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`, progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`, - cancelable: false + cancelable: controls?.cancelable !== false, + resumable: controls?.resumable === true, + onCancel: controls?.onCancel, + onPause: controls?.onPause, + onResume: controls?.onResume }) taskProgressUpdateMeta.set(taskId, { lastAt: Date.now(), @@ -97,6 +121,7 @@ export const useBatchImageDecryptStore = create((set, ge const previousProgress = get().progress const normalizedProgress = clampProgress(current, total) const taskId = get().taskId + let shouldCommitUi = true if (taskId) { const now = Date.now() const meta = taskProgressUpdateMeta.get(taskId) @@ -105,7 +130,9 @@ export const useBatchImageDecryptStore = create((set, ge const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS) const crossedBucket = !meta || bucket !== meta.lastBucket const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total - if (crossedBucket || intervalReached || isFinal) { + const shouldPublish = crossedBucket || intervalReached || isFinal + shouldCommitUi = shouldPublish + if (shouldPublish) { updateBackgroundTask(taskId, { detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`, progressText: `${normalizedProgress.current} / ${normalizedProgress.total}` @@ -117,26 +144,38 @@ export const useBatchImageDecryptStore = create((set, ge }) } } - if ( + if (shouldCommitUi && ( previousProgress.current !== normalizedProgress.current || previousProgress.total !== normalizedProgress.total - ) { + )) { set({ progress: normalizedProgress }) } }, - finishDecrypt: (success, fail) => { + setTaskStatus: (detail, progressText, status) => { + const taskId = get().taskId + if (!taskId) return + const normalizedDetail = String(detail || '').trim() + if (!normalizedDetail) return + updateBackgroundTask(taskId, { + detail: normalizedDetail, + progressText, + status + }) + }, + + finishDecrypt: (success, fail, options) => { const taskId = get().taskId const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0 const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0 if (taskId) { taskProgressUpdateMeta.delete(taskId) - const status = normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed' + const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed') finishBackgroundTask(taskId, status, { - detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`, - progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}` + detail: options?.detail || `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`, + progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}` }) } diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts index 55cf199..a0f11da 100644 --- a/src/stores/batchTranscribeStore.ts +++ b/src/stores/batchTranscribeStore.ts @@ -1,7 +1,27 @@ import { create } from 'zustand' +import { + finishBackgroundTask, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' +import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask' export type BatchVoiceTaskType = 'transcribe' | 'decrypt' +interface BatchVoiceTaskControls { + cancelable?: boolean + resumable?: boolean + onCancel?: () => void | Promise + onPause?: () => void | Promise + onResume?: () => void | Promise +} + +interface BatchVoiceTaskFinishOptions { + status?: Extract + detail?: string + progressText?: string +} + export interface BatchTranscribeState { /** 是否正在批量转写 */ isBatchTranscribing: boolean @@ -18,17 +38,44 @@ export interface BatchTranscribeState { /** 当前转写的会话名 */ startTime: number sessionName: string + taskId: string | null // Actions - startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void + startTranscribe: ( + total: number, + sessionName: string, + taskType?: BatchVoiceTaskType, + sourcePage?: BackgroundTaskSourcePage, + controls?: BatchVoiceTaskControls + ) => void updateProgress: (current: number, total: number) => void - finishTranscribe: (success: number, fail: number) => void + setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void + finishTranscribe: (success: number, fail: number, options?: BatchVoiceTaskFinishOptions) => void setShowToast: (show: boolean) => void setShowResult: (show: boolean) => void reset: () => void } -export const useBatchTranscribeStore = create((set) => ({ +const clampProgress = (current: number, total: number): { current: number; total: number } => { + const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0 + const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0 + const normalizedCurrent = normalizedTotal > 0 + ? Math.min(normalizedCurrentRaw, normalizedTotal) + : normalizedCurrentRaw + return { current: normalizedCurrent, total: normalizedTotal } +} + +const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250 +const TASK_PROGRESS_UPDATE_MAX_STEPS = 100 + +const taskProgressUpdateMeta = new Map() + +const calcProgressStep = (total: number): number => { + if (total <= 0) return 1 + return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS)) +} + +export const useBatchTranscribeStore = create((set, get) => ({ isBatchTranscribing: false, taskType: 'transcribe', progress: { current: 0, total: 0 }, @@ -37,41 +84,151 @@ export const useBatchTranscribeStore = create((set) => ({ result: { success: 0, fail: 0 }, sessionName: '', startTime: 0, + taskId: null, - startTranscribe: (total, sessionName, taskType = 'transcribe') => set({ - isBatchTranscribing: true, - taskType, - showToast: true, - progress: { current: 0, total }, - showResult: false, - result: { success: 0, fail: 0 }, - sessionName, - startTime: Date.now() - }), + startTranscribe: (total, sessionName, taskType = 'transcribe', sourcePage = 'chat', controls) => { + const previousTaskId = get().taskId + if (previousTaskId) { + taskProgressUpdateMeta.delete(previousTaskId) + finishBackgroundTask(previousTaskId, 'canceled', { + detail: '已被新的语音批量任务替换', + progressText: '已替换' + }) + } - updateProgress: (current, total) => set({ - progress: { current, total } - }), + const normalizedProgress = clampProgress(0, total) + const normalizedSessionName = String(sessionName || '').trim() + const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写' + const title = normalizedSessionName + ? `${taskLabel}(${normalizedSessionName})` + : taskLabel + const taskId = registerBackgroundTask({ + sourcePage, + title, + detail: `正在准备${taskType === 'decrypt' ? '语音解密' : '语音转写'}任务...`, + progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`, + cancelable: controls?.cancelable !== false, + resumable: controls?.resumable === true, + onCancel: controls?.onCancel, + onPause: controls?.onPause, + onResume: controls?.onResume + }) + taskProgressUpdateMeta.set(taskId, { + lastAt: Date.now(), + lastBucket: 0, + step: calcProgressStep(normalizedProgress.total) + }) - finishTranscribe: (success, fail) => set({ - isBatchTranscribing: false, - showToast: false, - showResult: true, - result: { success, fail }, - startTime: 0 - }), + set({ + isBatchTranscribing: true, + taskType, + showToast: false, + progress: normalizedProgress, + showResult: false, + result: { success: 0, fail: 0 }, + sessionName: normalizedSessionName, + startTime: Date.now(), + taskId + }) + }, + + updateProgress: (current, total) => { + const previousProgress = get().progress + const normalizedProgress = clampProgress(current, total) + const taskId = get().taskId + let shouldCommitUi = true + if (taskId) { + const now = Date.now() + const meta = taskProgressUpdateMeta.get(taskId) + const step = meta?.step || calcProgressStep(normalizedProgress.total) + const bucket = Math.floor(normalizedProgress.current / step) + const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS) + const crossedBucket = !meta || bucket !== meta.lastBucket + const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total + const shouldPublish = crossedBucket || intervalReached || isFinal + shouldCommitUi = shouldPublish + if (shouldPublish) { + const taskVerb = get().taskType === 'decrypt' ? '解密语音' : '转写语音' + updateBackgroundTask(taskId, { + detail: `正在${taskVerb}(${normalizedProgress.current}/${normalizedProgress.total})`, + progressText: `${normalizedProgress.current} / ${normalizedProgress.total}` + }) + taskProgressUpdateMeta.set(taskId, { + lastAt: now, + lastBucket: bucket, + step + }) + } + } + if (shouldCommitUi && ( + previousProgress.current !== normalizedProgress.current || + previousProgress.total !== normalizedProgress.total + )) { + set({ + progress: normalizedProgress + }) + } + }, + + setTaskStatus: (detail, progressText, status) => { + const taskId = get().taskId + if (!taskId) return + const normalizedDetail = String(detail || '').trim() + if (!normalizedDetail) return + updateBackgroundTask(taskId, { + detail: normalizedDetail, + progressText, + status + }) + }, + + finishTranscribe: (success, fail, options) => { + const taskId = get().taskId + const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0 + const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0 + const taskType = get().taskType + if (taskId) { + taskProgressUpdateMeta.delete(taskId) + const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed') + const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写' + finishBackgroundTask(taskId, status, { + detail: options?.detail || `${taskLabel}完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`, + progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}` + }) + } + + set({ + isBatchTranscribing: false, + showToast: false, + showResult: false, + result: { success: normalizedSuccess, fail: normalizedFail }, + startTime: 0, + taskId: null + }) + }, setShowToast: (show) => set({ showToast: show }), setShowResult: (show) => set({ showResult: show }), - reset: () => set({ - isBatchTranscribing: false, - taskType: 'transcribe', - progress: { current: 0, total: 0 }, - showToast: false, - showResult: false, - result: { success: 0, fail: 0 }, - sessionName: '', - startTime: 0 - }) + reset: () => { + const taskId = get().taskId + if (taskId) { + taskProgressUpdateMeta.delete(taskId) + finishBackgroundTask(taskId, 'canceled', { + detail: '语音批量任务已重置', + progressText: '已停止' + }) + } + set({ + isBatchTranscribing: false, + taskType: 'transcribe', + progress: { current: 0, total: 0 }, + showToast: false, + showResult: false, + result: { success: 0, fail: 0 }, + sessionName: '', + startTime: 0, + taskId: null + }) + } })) diff --git a/src/types/backgroundTask.ts b/src/types/backgroundTask.ts index df8315e..ec5cabe 100644 --- a/src/types/backgroundTask.ts +++ b/src/types/backgroundTask.ts @@ -9,6 +9,8 @@ export type BackgroundTaskSourcePage = export type BackgroundTaskStatus = | 'running' + | 'pause_requested' + | 'paused' | 'cancel_requested' | 'completed' | 'failed' @@ -21,7 +23,9 @@ export interface BackgroundTaskRecord { detail?: string progressText?: string cancelable: boolean + resumable: boolean cancelRequested: boolean + pauseRequested: boolean status: BackgroundTaskStatus startedAt: number updatedAt: number @@ -34,7 +38,10 @@ export interface BackgroundTaskInput { detail?: string progressText?: string cancelable?: boolean + resumable?: boolean onCancel?: () => void | Promise + onPause?: () => void | Promise + onResume?: () => void | Promise } export interface BackgroundTaskUpdate { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 98ca3fa..ea18cb7 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -499,7 +499,10 @@ export interface ElectronAPI { force?: boolean preferFilePath?: boolean hardlinkOnly?: boolean - }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> + disableUpdateCheck?: boolean + allowCacheIndex?: boolean + suppressEvents?: boolean + }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }> resolveCache: (payload: { sessionId?: string imageMd5?: string @@ -509,19 +512,21 @@ export interface ElectronAPI { hardlinkOnly?: boolean disableUpdateCheck?: boolean allowCacheIndex?: boolean - }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }> + suppressEvents?: boolean + }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }> resolveCacheBatch: ( payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>, - options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean } + options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean } ) => Promise<{ success: boolean - rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> + rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }> error?: string }> preload: ( payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>, options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } ) => Promise + preloadHardlinkMd5s: (md5List: string[]) => Promise onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void onDecryptProgress: (callback: (payload: { diff --git a/src/types/exportAutomation.ts b/src/types/exportAutomation.ts index 2725f6c..cf2ffea 100644 --- a/src/types/exportAutomation.ts +++ b/src/types/exportAutomation.ts @@ -8,6 +8,7 @@ export type ExportAutomationSchedule = type: 'interval' intervalDays: number intervalHours: number + firstTriggerAt?: number } export interface ExportAutomationCondition {