Merge branch 'main' into dev

This commit is contained in:
cc
2026-03-21 15:53:45 +08:00
committed by GitHub
6 changed files with 144 additions and 35 deletions

View File

@@ -34,6 +34,7 @@ interface ConfigSchema {
autoTranscribeVoice: boolean autoTranscribeVoice: boolean
transcribeLanguages: string[] transcribeLanguages: string[]
exportDefaultConcurrency: number exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[] analyticsExcludedUsernames: string[]
// 安全相关 // 安全相关
@@ -106,6 +107,7 @@ export class ConfigService {
autoTranscribeVoice: false, autoTranscribeVoice: false,
transcribeLanguages: ['zh'], transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4, exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [], analyticsExcludedUsernames: [],
authEnabled: false, authEnabled: false,
authPassword: '', authPassword: '',

View File

@@ -105,6 +105,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
} }
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -259,6 +260,7 @@ class ExportService {
private mediaFileCacheReadyDirs = new Set<string>() private mediaFileCacheReadyDirs = new Set<string>()
private mediaExportTelemetry: MediaExportTelemetry | null = null private mediaExportTelemetry: MediaExportTelemetry | null = null
private mediaRunSourceDedupMap = new Map<string, string>() private mediaRunSourceDedupMap = new Map<string, string>()
private mediaRunMissingImageKeys = new Set<string>()
private mediaFileCacheCleanupPending: Promise<void> | null = null private mediaFileCacheCleanupPending: Promise<void> | null = null
private mediaFileCacheLastCleanupAt = 0 private mediaFileCacheLastCleanupAt = 0
private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000 private readonly mediaFileCacheCleanupIntervalMs = 30 * 60 * 1000
@@ -524,11 +526,13 @@ class ExportService {
private resetMediaRuntimeState(): void { private resetMediaRuntimeState(): void {
this.mediaExportTelemetry = this.createEmptyMediaTelemetry() this.mediaExportTelemetry = this.createEmptyMediaTelemetry()
this.mediaRunSourceDedupMap.clear() this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
} }
private clearMediaRuntimeState(): void { private clearMediaRuntimeState(): void {
this.mediaExportTelemetry = null this.mediaExportTelemetry = null
this.mediaRunSourceDedupMap.clear() this.mediaRunSourceDedupMap.clear()
this.mediaRunMissingImageKeys.clear()
} }
private getMediaTelemetrySnapshot(): Partial<ExportProgress> { private getMediaTelemetrySnapshot(): Partial<ExportProgress> {
@@ -3325,6 +3329,7 @@ class ExportService {
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
includeVideoPoster?: boolean includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean includeVoiceWithTranscript?: boolean
imageDeepSearchOnMiss?: boolean
dirCache?: Set<string> dirCache?: Set<string>
} }
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
@@ -3332,7 +3337,14 @@ class ExportService {
// 图片消息 // 图片消息
if (localType === 3 && options.exportImages) { 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) { if (result) {
} }
return result return result
@@ -3378,7 +3390,8 @@ class ExportService {
sessionId: string, sessionId: string,
mediaRootDir: string, mediaRootDir: string,
mediaRelativePrefix: string, mediaRelativePrefix: string,
dirCache?: Set<string> dirCache?: Set<string>,
imageDeepSearchOnMiss = true
): Promise<MediaExportItem | null> { ): Promise<MediaExportItem | null> {
try { try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
@@ -3395,16 +3408,34 @@ class ExportService {
return null return null
} }
const missingRunCacheKey = this.getImageMissingRunCacheKey(
sessionId,
imageMd5,
imageDatName,
imageDeepSearchOnMiss
)
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
return null
}
const result = await imageDecryptService.decryptImage({ const result = await imageDecryptService.decryptImage({
sessionId, sessionId,
imageMd5, imageMd5,
imageDatName, imageDatName,
force: true, // 导出优先高清,失败再回退缩略图 force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true preferFilePath: true,
hardlinkOnly: !imageDeepSearchOnMiss
}) })
if (!result.success || !result.localPath) { if (!result.success || !result.localPath) {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`) 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({ const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId, sessionId,
@@ -3425,6 +3456,9 @@ class ExportService {
result.localPath = cachedThumb result.localPath = cachedThumb
} else { } else {
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null return null
} }
} }
@@ -4851,6 +4885,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -5353,6 +5388,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -6205,6 +6241,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -6912,6 +6949,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -7281,6 +7319,7 @@ class ExportService {
exportEmojis: options.exportEmojis, exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)
@@ -7702,6 +7741,7 @@ class ExportService {
includeVideoPoster: options.format === 'html', includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true, includeVoiceWithTranscript: true,
exportVideos: options.exportVideos, exportVideos: options.exportVideos,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache dirCache: mediaDirCache
}) })
mediaCache.set(mediaKey, mediaItem) mediaCache.set(mediaKey, mediaItem)

View File

@@ -64,6 +64,7 @@ type CachedImagePayload = {
type DecryptImagePayload = CachedImagePayload & { type DecryptImagePayload = CachedImagePayload & {
force?: boolean force?: boolean
hardlinkOnly?: boolean
} }
export class ImageDecryptService { export class ImageDecryptService {
@@ -158,7 +159,9 @@ export class ImageDecryptService {
} }
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> { async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
if (!payload.hardlinkOnly) {
await this.ensureCacheIndexed() await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload) const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0] const cacheKey = cacheKeys[0]
if (!cacheKey) { if (!cacheKey) {
@@ -180,6 +183,7 @@ export class ImageDecryptService {
} }
} }
if (!payload.hardlinkOnly) {
for (const key of cacheKeys) { for (const key of cacheKeys) {
const existingHd = this.findCachedOutput(key, true, payload.sessionId) const existingHd = this.findCachedOutput(key, true, payload.sessionId)
if (!existingHd || this.isThumbnailPath(existingHd)) continue if (!existingHd || this.isThumbnailPath(existingHd)) continue
@@ -190,6 +194,7 @@ export class ImageDecryptService {
return { success: true, localPath } return { success: true, localPath }
} }
} }
}
if (!payload.force) { if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey) const cached = this.resolvedCache.get(cacheKey)
@@ -255,7 +260,7 @@ export class ImageDecryptService {
payload: DecryptImagePayload, payload: DecryptImagePayload,
cacheKey: string cacheKey: string
): Promise<DecryptResult> { ): Promise<DecryptResult> {
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 { try {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
@@ -275,7 +280,11 @@ export class ImageDecryptService {
payload.imageMd5, payload.imageMd5,
payload.imageDatName, payload.imageDatName,
payload.sessionId, payload.sessionId,
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } {
allowThumbnail: !payload.force,
skipResolvedCache: Boolean(payload.force),
hardlinkOnly: payload.hardlinkOnly === true
}
) )
// 如果要求高清图但没找到,直接返回提示 // 如果要求高清图但没找到,直接返回提示
@@ -298,7 +307,8 @@ export class ImageDecryptService {
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb }
} }
// 查找已缓存的解密文件 // 查找已缓存的解密文件hardlink-only 模式下跳过全缓存目录扫描)
if (!payload.hardlinkOnly) {
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) { if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) }) this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
@@ -312,6 +322,7 @@ export class ImageDecryptService {
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb }
} }
} }
}
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置 // 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
const imageKeys = this.configService.getImageKeysForCurrentWxid() const imageKeys = this.configService.getImageKeysForCurrentWxid()
@@ -467,15 +478,17 @@ export class ImageDecryptService {
imageMd5?: string, imageMd5?: string,
imageDatName?: string, imageDatName?: string,
sessionId?: string, sessionId?: string,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean } options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
): Promise<string | null> { ): Promise<string | null> {
const allowThumbnail = options?.allowThumbnail ?? true const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false const skipResolvedCache = options?.skipResolvedCache ?? false
const hardlinkOnly = options?.hardlinkOnly ?? false
this.logInfo('[ImageDecrypt] resolveDatPath', { this.logInfo('[ImageDecrypt] resolveDatPath', {
imageMd5, imageMd5,
imageDatName, imageDatName,
allowThumbnail, allowThumbnail,
skipResolvedCache skipResolvedCache,
hardlinkOnly
}) })
if (!skipResolvedCache) { if (!skipResolvedCache) {
@@ -500,7 +513,7 @@ export class ImageDecryptService {
} }
// 1. 通过 MD5 快速定位 (MsgAttach 目录) // 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (imageMd5) { if (!hardlinkOnly && allowThumbnail && imageMd5) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail) const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
if (res) return res if (res) return res
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) { if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
@@ -510,7 +523,7 @@ export class ImageDecryptService {
} }
// 2. 如果 imageDatName 看起来像 MD5也尝试快速定位 // 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) const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (res) return res if (res) return res
} }
@@ -587,6 +600,11 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
if (hardlinkOnly) {
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
return null
}
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
if (!allowThumbnail) { if (!allowThumbnail) {
return null return null

View File

@@ -92,6 +92,7 @@ interface ExportOptions {
txtColumns: string[] txtColumns: string[]
displayNamePreference: DisplayNamePreference displayNamePreference: DisplayNamePreference
exportConcurrency: number exportConcurrency: number
imageDeepSearchOnMiss: boolean
} }
interface SessionRow extends AppChatSession { interface SessionRow extends AppChatSession {
@@ -1593,6 +1594,7 @@ function ExportPage() {
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'json', format: 'json',
@@ -1611,7 +1613,8 @@ function ExportPage() {
excelCompactColumns: true, excelCompactColumns: true,
txtColumns: defaultTxtColumns, txtColumns: defaultTxtColumns,
displayNamePreference: 'remark', displayNamePreference: 'remark',
exportConcurrency: 2 exportConcurrency: 2,
imageDeepSearchOnMiss: true
}) })
const [exportDialog, setExportDialog] = useState<ExportDialogState>({ const [exportDialog, setExportDialog] = useState<ExportDialogState>({
@@ -2138,7 +2141,7 @@ function ExportPage() {
setIsBaseConfigLoading(true) setIsBaseConfigLoading(true)
let isReady = true let isReady = true
try { 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.getExportPath(),
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(), configService.getExportDefaultAvatars(),
@@ -2147,6 +2150,7 @@ function ExportPage() {
configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(), configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(), configService.getExportDefaultConcurrency(),
configService.getExportDefaultImageDeepSearchOnMiss(),
configService.getExportLastSessionRunMap(), configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(), configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(), configService.getExportSessionRecordMap(),
@@ -2183,6 +2187,7 @@ function ExportPage() {
setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
setTimeRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange)
@@ -2215,7 +2220,8 @@ function ExportPage() {
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns, txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
})) }))
} catch (error) { } catch (error) {
isReady = false isReady = false
@@ -3989,7 +3995,8 @@ function ExportPage() {
exportEmojis: exportDefaultMedia.emojis, exportEmojis: exportDefaultMedia.emojis,
exportVoiceAsText: exportDefaultVoiceAsText, exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns, excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency exportConcurrency: exportDefaultConcurrency,
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
} }
if (payload.scope === 'sns') { if (payload.scope === 'sns') {
@@ -4022,7 +4029,8 @@ function ExportPage() {
exportDefaultAvatars, exportDefaultAvatars,
exportDefaultMedia, exportDefaultMedia,
exportDefaultVoiceAsText, exportDefaultVoiceAsText,
exportDefaultConcurrency exportDefaultConcurrency,
exportDefaultImageDeepSearchOnMiss
]) ])
const closeExportDialog = useCallback(() => { const closeExportDialog = useCallback(() => {
@@ -4241,6 +4249,7 @@ function ExportPage() {
txtColumns: options.txtColumns, txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference, displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency, exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
sessionLayout, sessionLayout,
sessionNameWithTypePrefix, sessionNameWithTypePrefix,
dateRange: options.useAllTime dateRange: options.useAllTime
@@ -4833,6 +4842,8 @@ function ExportPage() {
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns) await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency) await configService.setExportDefaultConcurrency(options.exportConcurrency)
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
} }
const openSingleExport = useCallback((session: SessionRow) => { const openSingleExport = useCallback((session: SessionRow) => {
@@ -6315,6 +6326,10 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog const shouldShowMediaSection = !isContentScopeDialog
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像' const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。' const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
const activeDialogFormatLabel = exportDialog.scope === 'sns' const activeDialogFormatLabel = exportDialog.scope === 'sns'
@@ -8031,6 +8046,26 @@ function ExportPage() {
</div> </div>
)} )}
{shouldShowImageDeepSearchToggle && (
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4></h4>
<div className="format-note"> hardlink </div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
)}
{isSessionScopeDialog && ( {isSessionScopeDialog && (
<div className="dialog-section"> <div className="dialog-section">
<div className="dialog-switch-row"> <div className="dialog-switch-row">

View File

@@ -34,6 +34,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns', EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency', EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout', EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled', EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', 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) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
} }
// 获取缺图时是否深度搜索(默认导出行为)
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
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<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
}
export type ExportWriteLayout = 'A' | 'B' | 'C' export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> { export async function getExportWriteLayout(): Promise<ExportWriteLayout> {

View File

@@ -863,6 +863,7 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
} }
export interface ExportProgress { export interface ExportProgress {