diff --git a/electron/services/config.ts b/electron/services/config.ts index bb6b9f5..4229069 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -34,6 +34,7 @@ interface ConfigSchema { autoTranscribeVoice: boolean transcribeLanguages: string[] exportDefaultConcurrency: number + exportDefaultImageDeepSearchOnMiss: boolean analyticsExcludedUsernames: string[] // 安全相关 @@ -106,6 +107,7 @@ export class ConfigService { autoTranscribeVoice: false, transcribeLanguages: ['zh'], exportDefaultConcurrency: 4, + exportDefaultImageDeepSearchOnMiss: true, analyticsExcludedUsernames: [], authEnabled: false, authPassword: '', diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 6d75921..0a13243 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -105,6 +105,7 @@ export interface ExportOptions { sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number + imageDeepSearchOnMiss?: boolean } const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ @@ -259,6 +260,7 @@ class ExportService { private mediaFileCacheReadyDirs = new Set() private mediaExportTelemetry: MediaExportTelemetry | null = null private mediaRunSourceDedupMap = new Map() + private mediaRunMissingImageKeys = new Set() private mediaFileCacheCleanupPending: Promise | null = null private mediaFileCacheLastCleanupAt = 0 private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 @@ -524,11 +526,13 @@ class ExportService { private resetMediaRuntimeState(): void { this.mediaExportTelemetry = this.createEmptyMediaTelemetry() this.mediaRunSourceDedupMap.clear() + this.mediaRunMissingImageKeys.clear() } private clearMediaRuntimeState(): void { this.mediaExportTelemetry = null this.mediaRunSourceDedupMap.clear() + this.mediaRunMissingImageKeys.clear() } private getMediaTelemetrySnapshot(): Partial { @@ -3325,6 +3329,7 @@ class ExportService { exportVoiceAsText?: boolean includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean + imageDeepSearchOnMiss?: boolean dirCache?: Set } ): Promise { @@ -3332,7 +3337,14 @@ class ExportService { // 图片消息 if (localType === 3 && options.exportImages) { - const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache) + const result = await this.exportImage( + msg, + sessionId, + mediaRootDir, + mediaRelativePrefix, + options.dirCache, + options.imageDeepSearchOnMiss !== false + ) if (result) { } return result @@ -3378,7 +3390,8 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - dirCache?: Set + dirCache?: Set, + imageDeepSearchOnMiss = true ): Promise { try { const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') @@ -3395,16 +3408,34 @@ class ExportService { return null } + const missingRunCacheKey = this.getImageMissingRunCacheKey( + sessionId, + imageMd5, + imageDatName, + imageDeepSearchOnMiss + ) + if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) { + return null + } + const result = await imageDecryptService.decryptImage({ sessionId, imageMd5, imageDatName, force: true, // 导出优先高清,失败再回退缩略图 - preferFilePath: true + preferFilePath: true, + hardlinkOnly: !imageDeepSearchOnMiss }) if (!result.success || !result.localPath) { console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) + if (!imageDeepSearchOnMiss) { + console.log(`[Export] 未命中 hardlink(已关闭缺图深度搜索)→ 将显示 [图片] 占位符`) + if (missingRunCacheKey) { + this.mediaRunMissingImageKeys.add(missingRunCacheKey) + } + return null + } // 尝试获取缩略图 const thumbResult = await imageDecryptService.resolveCachedImage({ sessionId, @@ -3425,6 +3456,9 @@ class ExportService { result.localPath = cachedThumb } else { console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) + if (missingRunCacheKey) { + this.mediaRunMissingImageKeys.add(missingRunCacheKey) + } return null } } @@ -4851,6 +4885,7 @@ class ExportService { exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -5353,6 +5388,7 @@ class ExportService { exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -6205,6 +6241,7 @@ class ExportService { exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -6912,6 +6949,7 @@ class ExportService { exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -7281,6 +7319,7 @@ class ExportService { exportEmojis: options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) @@ -7702,6 +7741,7 @@ class ExportService { includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, exportVideos: options.exportVideos, + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, dirCache: mediaDirCache }) mediaCache.set(mediaKey, mediaItem) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 32a2ae0..a16ef52 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -64,6 +64,7 @@ type CachedImagePayload = { type DecryptImagePayload = CachedImagePayload & { force?: boolean + hardlinkOnly?: boolean } export class ImageDecryptService { @@ -158,7 +159,9 @@ export class ImageDecryptService { } async decryptImage(payload: DecryptImagePayload): Promise { - await this.ensureCacheIndexed() + if (!payload.hardlinkOnly) { + await this.ensureCacheIndexed() + } const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { @@ -180,14 +183,16 @@ export class ImageDecryptService { } } - for (const key of cacheKeys) { - const existingHd = this.findCachedOutput(key, true, payload.sessionId) - if (!existingHd || this.isThumbnailPath(existingHd)) continue - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) - this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) - const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) - return { success: true, localPath } + if (!payload.hardlinkOnly) { + for (const key of cacheKeys) { + const existingHd = this.findCachedOutput(key, true, payload.sessionId) + if (!existingHd || this.isThumbnailPath(existingHd)) continue + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd) + this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) + const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath)) + return { success: true, localPath } + } } } @@ -255,7 +260,7 @@ export class ImageDecryptService { payload: DecryptImagePayload, cacheKey: string ): Promise { - this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force }) + this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true }) try { const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') @@ -275,7 +280,11 @@ export class ImageDecryptService { payload.imageMd5, payload.imageDatName, payload.sessionId, - { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } + { + allowThumbnail: !payload.force, + skipResolvedCache: Boolean(payload.force), + hardlinkOnly: payload.hardlinkOnly === true + } ) // 如果要求高清图但没找到,直接返回提示 @@ -298,18 +307,20 @@ export class ImageDecryptService { return { success: true, localPath, isThumb } } - // 查找已缓存的解密文件 - const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) - if (existing) { - this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) - const isHd = this.isHdPath(existing) - // 如果要求高清但找到的是缩略图,继续解密高清图 - if (!(payload.force && !isHd)) { - this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) - const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) - const isThumb = this.isThumbnailPath(existing) - this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) - return { success: true, localPath, isThumb } + // 查找已缓存的解密文件(hardlink-only 模式下跳过全缓存目录扫描) + if (!payload.hardlinkOnly) { + const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) + if (existing) { + this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) + const isHd = this.isHdPath(existing) + // 如果要求高清但找到的是缩略图,继续解密高清图 + if (!(payload.force && !isHd)) { + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing) + const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath) + const isThumb = this.isThumbnailPath(existing) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath)) + return { success: true, localPath, isThumb } + } } } @@ -467,15 +478,17 @@ export class ImageDecryptService { imageMd5?: string, imageDatName?: string, sessionId?: string, - options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean } + options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean } ): Promise { const allowThumbnail = options?.allowThumbnail ?? true const skipResolvedCache = options?.skipResolvedCache ?? false + const hardlinkOnly = options?.hardlinkOnly ?? false this.logInfo('[ImageDecrypt] resolveDatPath', { imageMd5, imageDatName, allowThumbnail, - skipResolvedCache + skipResolvedCache, + hardlinkOnly }) if (!skipResolvedCache) { @@ -500,7 +513,7 @@ export class ImageDecryptService { } // 1. 通过 MD5 快速定位 (MsgAttach 目录) - if (imageMd5) { + if (!hardlinkOnly && allowThumbnail && imageMd5) { const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) if (res) return res if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) { @@ -510,7 +523,7 @@ export class ImageDecryptService { } // 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位 - if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { + if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) { const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail) if (res) return res } @@ -587,6 +600,11 @@ export class ImageDecryptService { this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } + if (hardlinkOnly) { + this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName }) + return null + } + // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) if (!allowThumbnail) { return null diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index d5e6566..549b4ea 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -92,6 +92,7 @@ interface ExportOptions { txtColumns: string[] displayNamePreference: DisplayNamePreference exportConcurrency: number + imageDeepSearchOnMiss: boolean } interface SessionRow extends AppChatSession { @@ -1593,6 +1594,7 @@ function ExportPage() { const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) + const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true) const [options, setOptions] = useState({ format: 'json', @@ -1611,7 +1613,8 @@ function ExportPage() { excelCompactColumns: true, txtColumns: defaultTxtColumns, displayNamePreference: 'remark', - exportConcurrency: 2 + exportConcurrency: 2, + imageDeepSearchOnMiss: true }) const [exportDialog, setExportDialog] = useState({ @@ -2138,7 +2141,7 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ + const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultAvatars(), @@ -2147,6 +2150,7 @@ function ExportPage() { configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultTxtColumns(), configService.getExportDefaultConcurrency(), + configService.getExportDefaultImageDeepSearchOnMiss(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), configService.getExportSessionRecordMap(), @@ -2183,6 +2187,7 @@ function ExportPage() { setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultConcurrency(savedConcurrency ?? 2) + setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true) const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange) @@ -2215,7 +2220,8 @@ function ExportPage() { exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, txtColumns, - exportConcurrency: savedConcurrency ?? prev.exportConcurrency + exportConcurrency: savedConcurrency ?? prev.exportConcurrency, + imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss })) } catch (error) { isReady = false @@ -3989,7 +3995,8 @@ function ExportPage() { exportEmojis: exportDefaultMedia.emojis, exportVoiceAsText: exportDefaultVoiceAsText, excelCompactColumns: exportDefaultExcelCompactColumns, - exportConcurrency: exportDefaultConcurrency + exportConcurrency: exportDefaultConcurrency, + imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss } if (payload.scope === 'sns') { @@ -4022,7 +4029,8 @@ function ExportPage() { exportDefaultAvatars, exportDefaultMedia, exportDefaultVoiceAsText, - exportDefaultConcurrency + exportDefaultConcurrency, + exportDefaultImageDeepSearchOnMiss ]) const closeExportDialog = useCallback(() => { @@ -4241,6 +4249,7 @@ function ExportPage() { txtColumns: options.txtColumns, displayNamePreference: options.displayNamePreference, exportConcurrency: options.exportConcurrency, + imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, sessionLayout, sessionNameWithTypePrefix, dateRange: options.useAllTime @@ -4833,6 +4842,8 @@ function ExportPage() { await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) await configService.setExportDefaultTxtColumns(options.txtColumns) await configService.setExportDefaultConcurrency(options.exportConcurrency) + await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss) + setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss) } const openSingleExport = useCallback((session: SessionRow) => { @@ -6315,6 +6326,10 @@ function ExportPage() { const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog + const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && ( + (isSessionScopeDialog && options.exportImages) || + (isContentScopeDialog && exportDialog.contentType === 'image') + ) const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。' const activeDialogFormatLabel = exportDialog.scope === 'sns' @@ -8031,6 +8046,26 @@ function ExportPage() { )} + {shouldShowImageDeepSearchToggle && ( +
+
+
+

缺图时深度搜索

+
关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。
+
+ +
+
+ )} + {isSessionScopeDialog && (
diff --git a/src/services/config.ts b/src/services/config.ts index 7d57bff..f5bfb53 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -34,6 +34,7 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', + EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss', EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled', EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', @@ -462,6 +463,18 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise< await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency) } +// 获取缺图时是否深度搜索(默认导出行为) +export async function getExportDefaultImageDeepSearchOnMiss(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS) + if (typeof value === 'boolean') return value + return null +} + +// 设置缺图时是否深度搜索(默认导出行为) +export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled) +} + export type ExportWriteLayout = 'A' | 'B' | 'C' export async function getExportWriteLayout(): Promise { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b36fefa..8d96abe 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -863,6 +863,7 @@ export interface ExportOptions { sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' exportConcurrency?: number + imageDeepSearchOnMiss?: boolean } export interface ExportProgress {