From 64010ad86b20cc82a7d03cdc42e052f1a3b26338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9C=E5=8C=97=E5=B0=98?= <2678115663@qq.com> Date: Sat, 4 Apr 2026 19:45:05 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=B7=BB=E5=8A=A0=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 10 + electron/services/exportService.ts | 213 +++++++++++++++++- .../Export/ExportDefaultsSettingsForm.tsx | 22 +- src/pages/ExportPage.tsx | 78 +++++-- src/services/config.ts | 13 +- src/types/electron.d.ts | 2 + src/types/models.ts | 1 + 7 files changed, 308 insertions(+), 31 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 6069c38..5236ce2 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -75,6 +75,7 @@ export interface Message { fileName?: string // 文件名 fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 + fileMd5?: string // 文件 MD5 xmlType?: string // XML 中的 type 字段 appMsgKind?: string // 归一化 appmsg 类型 appMsgDesc?: string @@ -3796,6 +3797,7 @@ class ChatService { let fileName: string | undefined let fileSize: number | undefined let fileExt: string | undefined + let fileMd5: string | undefined let xmlType: string | undefined let appMsgKind: string | undefined let appMsgDesc: string | undefined @@ -3900,6 +3902,7 @@ class ChatService { fileName = type49Info.fileName fileSize = type49Info.fileSize fileExt = type49Info.fileExt + fileMd5 = type49Info.fileMd5 chatRecordTitle = type49Info.chatRecordTitle chatRecordList = type49Info.chatRecordList transferPayerUsername = type49Info.transferPayerUsername @@ -3923,6 +3926,7 @@ class ChatService { fileName = fileName || type49Info.fileName fileSize = fileSize ?? type49Info.fileSize fileExt = fileExt || type49Info.fileExt + fileMd5 = fileMd5 || type49Info.fileMd5 appMsgKind = appMsgKind || type49Info.appMsgKind appMsgDesc = appMsgDesc || type49Info.appMsgDesc appMsgAppName = appMsgAppName || type49Info.appMsgAppName @@ -3996,6 +4000,7 @@ class ChatService { fileName, fileSize, fileExt, + fileMd5, xmlType, appMsgKind, appMsgDesc, @@ -4599,6 +4604,7 @@ class ChatService { fileName?: string fileSize?: number fileExt?: string + fileMd5?: string transferPayerUsername?: string transferReceiverUsername?: string chatRecordTitle?: string @@ -4795,6 +4801,7 @@ class ChatService { // 提取文件扩展名 const fileExt = this.extractXmlValue(content, 'fileext') + const fileMd5 = this.extractXmlValue(content, 'md5') || this.extractXmlValue(content, 'filemd5') if (fileExt) { result.fileExt = fileExt } else if (result.fileName) { @@ -4804,6 +4811,9 @@ class ChatService { result.fileExt = match[1] } } + if (fileMd5) { + result.fileMd5 = fileMd5.toLowerCase() + } break } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 9e71159..a55f944 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -98,6 +98,8 @@ export interface ExportOptions { exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean + exportFiles?: boolean + maxFileSizeMb?: number exportVoiceAsText?: boolean excelCompactColumns?: boolean txtColumns?: string[] @@ -121,7 +123,7 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ interface MediaExportItem { relativePath: string - kind: 'image' | 'voice' | 'emoji' | 'video' + kind: 'image' | 'voice' | 'emoji' | 'video' | 'file' posterDataUrl?: string } @@ -136,6 +138,11 @@ interface ExportDisplayProfile { type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' +interface FileExportCandidate { + sourcePath: string + matchedBy: 'md5' | 'name' + yearMonth?: string +} export interface ExportProgress { current: number @@ -842,7 +849,7 @@ class ExportService { private isMediaExportEnabled(options: ExportOptions): boolean { return options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) } private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { @@ -880,7 +887,7 @@ class ExportService { if (options.exportImages) selected.add(3) if (options.exportVoices) selected.add(34) if (options.exportVideos) selected.add(43) - if (options.exportEmojis) selected.add(47) + if (options.exportFiles) selected.add(49) return selected } @@ -3416,6 +3423,8 @@ class ExportService { exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean + exportFiles?: boolean + maxFileSizeMb?: number exportVoiceAsText?: boolean includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean @@ -3469,6 +3478,16 @@ class ExportService { ) } + if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') { + return this.exportFileAttachment( + msg, + mediaRootDir, + mediaRelativePrefix, + options.maxFileSizeMb, + options.dirCache + ) + } + return null } @@ -3939,6 +3958,165 @@ class ExportService { return tagMatch?.[1]?.toLowerCase() } + private resolveFileAttachmentRoots(): string[] { + const dbPath = String(this.configService.get('dbPath') || '').trim() + const rawWxid = String(this.configService.get('myWxid') || '').trim() + const cleanedWxid = this.cleanAccountDirName(rawWxid) + if (!dbPath) return [] + + const normalized = dbPath.replace(/[\\/]+$/, '') + const roots = new Set() + const tryAddRoot = (candidate: string) => { + const fileRoot = path.join(candidate, 'msg', 'file') + if (fs.existsSync(fileRoot)) { + roots.add(fileRoot) + } + } + + tryAddRoot(normalized) + if (rawWxid) tryAddRoot(path.join(normalized, rawWxid)) + if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid)) + + const dbStoragePath = + this.resolveDbStoragePathForExport(normalized, cleanedWxid) || + this.resolveDbStoragePathForExport(normalized, rawWxid) + if (dbStoragePath) { + tryAddRoot(path.dirname(dbStoragePath)) + } + + return Array.from(roots) + } + + private buildPreferredFileYearMonths(createTime?: unknown): string[] { + const raw = Number(createTime) + if (!Number.isFinite(raw) || raw <= 0) return [] + const ts = raw > 1e12 ? raw : raw * 1000 + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return [] + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + return [`${y}-${m}`] + } + + private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise { + const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase() + if (!normalizedExpected) return true + if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true + try { + const hash = crypto.createHash('md5') + await new Promise((resolve, reject) => { + const stream = fs.createReadStream(sourcePath) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', () => resolve()) + stream.on('error', reject) + }) + return hash.digest('hex').toLowerCase() === normalizedExpected + } catch { + return false + } + } + + private async resolveFileAttachmentCandidates(msg: any): Promise { + const fileName = String(msg?.fileName || '').trim() + if (!fileName) return [] + + const roots = this.resolveFileAttachmentRoots() + if (roots.length === 0) return [] + + const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() + const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime) + const candidates: FileExportCandidate[] = [] + const seen = new Set() + + for (const root of roots) { + let monthDirs: string[] = [] + try { + monthDirs = fs.readdirSync(root) + .filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry))) + .sort() + } catch { + continue + } + + const orderedMonths = Array.from(new Set([ + ...preferredMonths, + ...monthDirs.slice().reverse() + ])) + + for (const month of orderedMonths) { + const sourcePath = path.join(root, month, fileName) + if (!fs.existsSync(sourcePath)) continue + const resolvedPath = path.resolve(sourcePath) + if (seen.has(resolvedPath)) continue + seen.add(resolvedPath) + + if (normalizedMd5) { + const ok = await this.verifyFileHash(resolvedPath, normalizedMd5) + if (ok) { + candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month }) + continue + } + } + + candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month }) + } + } + + return candidates + } + + private async exportFileAttachment( + msg: any, + mediaRootDir: string, + mediaRelativePrefix: string, + maxFileSizeMb?: number, + dirCache?: Set + ): Promise { + try { + const fileNameRaw = String(msg?.fileName || '').trim() + if (!fileNameRaw) return null + + const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files') + if (!dirCache?.has(filesDir)) { + await fs.promises.mkdir(filesDir, { recursive: true }) + dirCache?.add(filesDir) + } + + const candidates = await this.resolveFileAttachmentCandidates(msg) + if (candidates.length === 0) return null + + const maxBytes = Number.isFinite(maxFileSizeMb) + ? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024)) + : 0 + + const selected = candidates[0] + const stat = await fs.promises.stat(selected.sourcePath) + if (!stat.isFile()) return null + if (maxBytes > 0 && stat.size > maxBytes) return null + + const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() + if (normalizedMd5 && selected.matchedBy !== 'md5') { + const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5) + if (!verified) return null + } + + const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file' + const messageId = String(msg?.localId || Date.now()) + const destFileName = `${messageId}_${safeBaseName}` + const destPath = path.join(filesDir, destFileName) + const copied = await this.copyFileOptimized(selected.sourcePath, destPath) + if (!copied.success) return null + + this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size }) + return { + relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName), + kind: 'file' + } + } catch { + return null + } + } + private extractLocationMeta(content: string, localType: number): { locationLat?: number locationLng?: number @@ -3995,7 +4173,7 @@ class ExportService { mediaRelativePrefix: string } { const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) const outputDir = path.dirname(outputPath) const rawWriteLayout = this.configService.get('exportWriteLayout') const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' @@ -4932,7 +5110,8 @@ class ExportService { return (t === 3 && options.exportImages) || // 图片 (t === 47 && options.exportEmojis) || // 表情 (t === 43 && options.exportVideos) || // 视频 - (t === 34 && options.exportVoices) // 语音文件 + (t === 34 && options.exportVoices) || // 语音文件 + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -4973,6 +5152,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -5441,7 +5622,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -5481,6 +5663,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -6301,7 +6485,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -6341,6 +6526,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -7014,7 +7201,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -7054,6 +7242,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -7391,7 +7581,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -7431,6 +7622,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -7851,6 +8044,8 @@ class ExportService { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index 17090e2..6824e5b 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -66,7 +66,8 @@ export function ExportDefaultsSettingsForm({ images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) @@ -94,7 +95,8 @@ export function ExportDefaultsSettingsForm({ images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) @@ -292,7 +294,7 @@ export function ExportDefaultsSettingsForm({
- 控制图片、视频、语音、表情包的默认导出开关 + 控制图片、视频、语音、表情包、文件的默认导出开关
@@ -352,6 +354,20 @@ export function ExportDefaultsSettingsForm({ /> 表情包 +
diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index ae5f9e7..da420e6 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -67,7 +67,7 @@ import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' | 'sns' -type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' +type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' type ContentCardType = ContentType | 'sns' type SnsRankMode = 'likes' | 'comments' @@ -88,6 +88,8 @@ interface ExportOptions { exportVoices: boolean exportVideos: boolean exportEmojis: boolean + exportFiles: boolean + maxFileSizeMb: number exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] @@ -195,7 +197,8 @@ const contentTypeLabels: Record = { voice: '语音', image: '图片', video: '视频', - emoji: '表情包' + emoji: '表情包', + file: '文件' } const backgroundTaskSourceLabels: Record = { @@ -1598,7 +1601,8 @@ function ExportPage() { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) @@ -1617,7 +1621,9 @@ function ExportPage() { exportImages: true, exportVoices: true, exportVideos: true, - exportEmojis: true, + exportEmojis: true, + exportFiles: true, + maxFileSizeMb: 200, exportVoiceAsText: false, excelCompactColumns: true, txtColumns: defaultTxtColumns, @@ -2281,7 +2287,8 @@ function ExportPage() { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) @@ -2310,12 +2317,14 @@ function ExportPage() { (savedMedia?.images ?? prev.exportImages) || (savedMedia?.voices ?? prev.exportVoices) || (savedMedia?.videos ?? prev.exportVideos) || - (savedMedia?.emojis ?? prev.exportEmojis) + (savedMedia?.emojis ?? prev.exportEmojis) || + (savedMedia?.files ?? prev.exportFiles) ), exportImages: savedMedia?.images ?? prev.exportImages, exportVoices: savedMedia?.voices ?? prev.exportVoices, exportVideos: savedMedia?.videos ?? prev.exportVideos, exportEmojis: savedMedia?.emojis ?? prev.exportEmojis, + exportFiles: savedMedia?.files ?? prev.exportFiles, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, txtColumns, @@ -4088,12 +4097,15 @@ function ExportPage() { exportDefaultMedia.images || exportDefaultMedia.voices || exportDefaultMedia.videos || - exportDefaultMedia.emojis + exportDefaultMedia.emojis || + exportDefaultMedia.files ), exportImages: exportDefaultMedia.images, exportVoices: exportDefaultMedia.voices, exportVideos: exportDefaultMedia.videos, exportEmojis: exportDefaultMedia.emojis, + exportFiles: exportDefaultMedia.files, + maxFileSizeMb: prev.maxFileSizeMb, exportVoiceAsText: exportDefaultVoiceAsText, excelCompactColumns: exportDefaultExcelCompactColumns, exportConcurrency: exportDefaultConcurrency, @@ -4111,12 +4123,14 @@ function ExportPage() { next.exportVoices = false next.exportVideos = false next.exportEmojis = false + next.exportFiles = false } else { next.exportMedia = true next.exportImages = payload.contentType === 'image' next.exportVoices = payload.contentType === 'voice' next.exportVideos = payload.contentType === 'video' next.exportEmojis = payload.contentType === 'emoji' + next.exportFiles = payload.contentType === 'file' next.exportVoiceAsText = false } } @@ -4335,7 +4349,13 @@ function ExportPage() { const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' - const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + const exportMediaEnabled = Boolean( + options.exportImages || + options.exportVoices || + options.exportVideos || + options.exportEmojis || + options.exportFiles + ) const base: ElectronExportOptions = { format: options.format, @@ -4345,6 +4365,8 @@ function ExportPage() { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, excelCompactColumns: options.excelCompactColumns, txtColumns: options.txtColumns, @@ -4375,7 +4397,8 @@ function ExportPage() { exportImages: false, exportVoices: false, exportVideos: false, - exportEmojis: false + exportEmojis: false, + exportFiles: false } } @@ -4387,6 +4410,7 @@ function ExportPage() { exportVoices: contentType === 'voice', exportVideos: contentType === 'video', exportEmojis: contentType === 'emoji', + exportFiles: contentType === 'file', exportVoiceAsText: false } } @@ -4452,6 +4476,7 @@ function ExportPage() { if (opts.exportVoices) labels.push('语音') if (opts.exportVideos) labels.push('视频') if (opts.exportEmojis) labels.push('表情包') + if (opts.exportFiles) labels.push('文件') } return Array.from(new Set(labels)).join('、') }, []) @@ -4507,6 +4532,7 @@ function ExportPage() { if (opts.exportImages) types.push('image') if (opts.exportVideos) types.push('video') if (opts.exportEmojis) types.push('emoji') + if (opts.exportFiles) types.push('file') } return types } @@ -4937,7 +4963,8 @@ function ExportPage() { images: options.exportImages, voices: options.exportVoices, videos: options.exportVideos, - emojis: options.exportEmojis + emojis: options.exportEmojis, + files: options.exportFiles }) await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) @@ -6955,11 +6982,12 @@ function ExportPage() { setExportDefaultMedia(mediaPatch) setOptions(prev => ({ ...prev, - exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis), + exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis || mediaPatch.files), exportImages: mediaPatch.images, exportVoices: mediaPatch.voices, exportVideos: mediaPatch.videos, - exportEmojis: mediaPatch.emojis + exportEmojis: mediaPatch.emojis, + exportFiles: mediaPatch.files })) } if (typeof patch.voiceAsText === 'boolean') { @@ -8159,16 +8187,36 @@ function ExportPage() { + )} - {exportDialog.scope === 'sns' && ( -
全不勾选时仅导出文本信息,不导出媒体文件。
+ {exportDialog.scope !== 'sns' && options.exportFiles && ( +
文件导出会优先使用消息里的 MD5 做校验;若设置了大小上限,则仅导出不超过该值的文件。
)} )} - {shouldShowImageDeepSearchToggle && ( + {shouldShowMediaSection && exportDialog.scope !== 'sns' && options.exportFiles && ( +
+

文件大小上限

+
仅导出不超过该大小的文件,0 表示不限制。
+
+ { + const raw = Number(event.target.value) + setOptions(prev => ({ ...prev, maxFileSizeMb: Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0 })) + }} + /> + MB +
+
+ )} +
diff --git a/src/services/config.ts b/src/services/config.ts index 1f687e7..a0b7c54 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -94,6 +94,7 @@ export interface ExportDefaultMediaConfig { videos: boolean voices: boolean emojis: boolean + files: boolean } export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' @@ -104,7 +105,8 @@ const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true } // 获取解密密钥 @@ -423,7 +425,8 @@ export async function getExportDefaultMedia(): Promise