diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 76c3633..875c961 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -72,9 +72,21 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' } +const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ + { id: 'index', label: '序号' }, + { id: 'time', label: '时间' }, + { id: 'senderRole', label: '发送者身份' }, + { id: 'messageType', label: '消息类型' }, + { id: 'content', label: '内容' }, + { id: 'senderNickname', label: '发送者昵称' }, + { id: 'senderWxid', label: '发送者微信ID' }, + { id: 'senderRemark', label: '发送者备注' } +] + interface MediaExportItem { relativePath: string kind: 'image' | 'voice' | 'emoji' @@ -428,6 +440,17 @@ class ExportService { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` } + private normalizeTxtColumns(columns?: string[] | null): string[] { + const fallback = ['index', 'time', 'senderRole', 'messageType', 'content'] + const selected = new Set((columns && columns.length > 0 ? columns : fallback).filter(Boolean)) + const ordered = TXT_COLUMN_DEFINITIONS.map((col) => col.id).filter((id) => selected.has(id)) + return ordered.length > 0 ? ordered : fallback + } + + private sanitizeTxtValue(value: string): string { + return value.replace(/\r?\n/g, ' ').replace(/\t/g, ' ').trim() + } + /** * 导出媒体文件到指定目录 */ @@ -1880,6 +1903,197 @@ class ExportService { } } + /** + * 导出单个会话为 TXT 格式(默认与 Excel 精简列一致) + */ + async exportSessionToTxt( + sessionId: string, + outputPath: string, + options: ExportOptions, + onProgress?: (progress: ExportProgress) => void + ): Promise<{ success: boolean; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + + const cleanedMyWxid = conn.cleanedWxid + const isGroup = sessionId.includes('@chatroom') + const sessionInfo = await this.getContactInfo(sessionId) + const myInfo = await this.getContactInfo(cleanedMyWxid) + + onProgress?.({ + current: 0, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'preparing' + }) + + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) + + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + const mediaMessages = exportMediaEnabled + ? sortedMessages.filter(msg => { + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 25, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) + + const MEDIA_CONCURRENCY = 8 + await parallelLimit(mediaMessages, MEDIA_CONCURRENCY, async (msg) => { + const mediaKey = `${msg.localType}_${msg.localId}` + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText + }) + mediaCache.set(mediaKey, mediaItem) + } + }) + } + + const voiceMessages = options.exportVoiceAsText + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 45, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-voice' + }) + + const VOICE_CONCURRENCY = 4 + await parallelLimit(voiceMessages, VOICE_CONCURRENCY, async (msg) => { + const transcript = await this.transcribeVoice(sessionId, String(msg.localId)) + voiceTranscriptMap.set(msg.localId, transcript) + }) + } + + onProgress?.({ + current: 60, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + + const columnOrder = this.normalizeTxtColumns(options.txtColumns) + const columnLabelMap = new Map(TXT_COLUMN_DEFINITIONS.map((col) => [col.id, col.label])) + const lines: string[] = [] + lines.push(columnOrder.map((id) => columnLabelMap.get(id) || id).join('\t')) + + for (let i = 0; i < sortedMessages.length; i++) { + const msg = sortedMessages[i] + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) || null + + let contentValue: string + if (mediaItem) { + contentValue = mediaItem.relativePath + } else if (msg.localType === 34 && options.exportVoiceAsText) { + contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } else { + contentValue = this.parseMessageContent(msg.content, msg.localType) || '' + } + + let senderRole: string + let senderWxid: string + let senderNickname: string + let senderRemark = '' + + if (msg.isSend) { + senderRole = '我' + senderWxid = cleanedMyWxid + senderNickname = myInfo.displayName || cleanedMyWxid + } else if (isGroup && msg.senderUsername) { + senderWxid = msg.senderUsername + const contactDetail = await wcdbService.getContact(msg.senderUsername) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || msg.senderUsername + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = msg.senderUsername + senderRole = msg.senderUsername + } + } else { + senderWxid = sessionId + const contactDetail = await wcdbService.getContact(sessionId) + if (contactDetail.success && contactDetail.contact) { + senderNickname = contactDetail.contact.nickName || sessionId + senderRemark = contactDetail.contact.remark || '' + senderRole = senderRemark || senderNickname + } else { + senderNickname = sessionInfo.displayName || sessionId + senderRole = senderNickname + } + } + + const values: Record = { + index: String(i + 1), + time: this.formatTimestamp(msg.createTime), + senderRole, + senderNickname, + senderWxid, + senderRemark, + messageType: this.getMessageTypeName(msg.localType), + content: contentValue + } + + const line = columnOrder + .map((id) => this.sanitizeTxtValue(values[id] ?? '')) + .join('\t') + lines.push(line) + + if ((i + 1) % 200 === 0) { + const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30) + onProgress?.({ + current: progress, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting' + }) + } + } + + onProgress?.({ + current: 92, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + fs.writeFileSync(outputPath, lines.join('\n'), 'utf-8') + + onProgress?.({ + current: 100, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'complete' + }) + + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 批量导出多个会话 */ @@ -1930,6 +2144,7 @@ class ExportService { let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' else if (options.format === 'excel') ext = '.xlsx' + else if (options.format === 'txt') ext = '.txt' const outputPath = path.join(sessionDir, `${safeName}${ext}`) let result: { success: boolean; error?: string } @@ -1939,6 +2154,8 @@ class ExportService { result = await this.exportSessionToChatLab(sessionId, outputPath, options) } else if (options.format === 'excel') { result = await this.exportSessionToExcel(sessionId, outputPath, options) + } else if (options.format === 'txt') { + result = await this.exportSessionToTxt(sessionId, outputPath, options) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 9783061..87f3bfa 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -22,6 +22,7 @@ interface ExportOptions { exportEmojis: boolean exportVoiceAsText: boolean excelCompactColumns: boolean + txtColumns: string[] } interface ExportResult { @@ -34,6 +35,7 @@ interface ExportResult { type SessionLayout = 'shared' | 'per-session' function ExportPage() { + const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const [sessions, setSessions] = useState([]) const [filteredSessions, setFilteredSessions] = useState([]) const [selectedSessions, setSelectedSessions] = useState>(new Set()) @@ -61,7 +63,8 @@ function ExportPage() { exportVoices: true, exportEmojis: true, exportVoiceAsText: true, - excelCompactColumns: true + excelCompactColumns: true, + txtColumns: defaultTxtColumns }) const buildDateRangeFromPreset = (preset: string) => { @@ -125,17 +128,20 @@ function ExportPage() { savedRange, savedMedia, savedVoiceAsText, - savedExcelCompactColumns + savedExcelCompactColumns, + savedTxtColumns ] = await Promise.all([ configService.getExportDefaultFormat(), configService.getExportDefaultDateRange(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), - configService.getExportDefaultExcelCompactColumns() + configService.getExportDefaultExcelCompactColumns(), + configService.getExportDefaultTxtColumns() ]) const preset = savedRange || 'today' const rangeDefaults = buildDateRangeFromPreset(preset) + const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions((prev) => ({ ...prev, @@ -144,7 +150,8 @@ function ExportPage() { dateRange: rangeDefaults.dateRange, exportMedia: savedMedia ?? false, exportVoiceAsText: savedVoiceAsText ?? true, - excelCompactColumns: savedExcelCompactColumns ?? true + excelCompactColumns: savedExcelCompactColumns ?? true, + txtColumns })) } catch (e) { console.error('加载导出默认设置失败:', e) @@ -233,6 +240,7 @@ function ExportPage() { exportEmojis: options.exportMedia && options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 excelCompactColumns: options.excelCompactColumns, + txtColumns: options.txtColumns, sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), @@ -241,7 +249,7 @@ function ExportPage() { } : null } - if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') { + if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 999dc5f..93c7484 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -61,6 +61,7 @@ function SettingsPage() { const [exportDefaultMedia, setExportDefaultMedia] = useState(false) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) + const [exportDefaultTxtColumns, setExportDefaultTxtColumns] = useState(['index', 'time', 'senderRole', 'messageType', 'content']) const [isLoading, setIsLoadingState] = useState(false) const [isTesting, setIsTesting] = useState(false) @@ -141,6 +142,8 @@ function SettingsPage() { const savedExportDefaultMedia = await configService.getExportDefaultMedia() const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText() const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() + const savedExportDefaultTxtColumns = await configService.getExportDefaultTxtColumns() + const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] if (savedKey) setDecryptKey(savedKey) if (savedPath) setDbPath(savedPath) @@ -158,6 +161,11 @@ function SettingsPage() { setExportDefaultMedia(savedExportDefaultMedia ?? false) setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true) setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) + setExportDefaultTxtColumns( + savedExportDefaultTxtColumns && savedExportDefaultTxtColumns.length > 0 + ? savedExportDefaultTxtColumns + : defaultTxtColumns + ) // 如果语言列表为空,保存默认值 if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { @@ -166,6 +174,10 @@ function SettingsPage() { await configService.setTranscribeLanguages(defaultLanguages) } + if (!savedExportDefaultTxtColumns || savedExportDefaultTxtColumns.length === 0) { + await configService.setExportDefaultTxtColumns(defaultTxtColumns) + } + if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) } catch (e) { console.error('加载配置失败:', e) @@ -899,6 +911,16 @@ function SettingsPage() { { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } ] + const exportTxtColumnOptions = [ + { value: 'index', label: '序号' }, + { value: 'time', label: '时间' }, + { value: 'senderRole', label: '发送者身份' }, + { value: 'messageType', label: '消息类型' }, + { value: 'content', label: '内容' }, + { value: 'senderNickname', label: '发送者昵称' }, + { value: 'senderWxid', label: '发送者微信ID' }, + { value: 'senderRemark', label: '发送者备注' } + ] const getOptionLabel = (options: { value: string; label: string }[], value: string) => { return options.find((option) => option.value === value)?.label ?? value @@ -1074,6 +1096,41 @@ function SettingsPage() { )} + +
+ + 默认与 Excel 精简列一致,可多选调整输出字段 +
+ {exportTxtColumnOptions.map((column) => { + const checked = exportDefaultTxtColumns.includes(column.value) + return ( + + ) + })} +
+
) } @@ -1225,4 +1282,3 @@ function SettingsPage() { } export default SettingsPage - diff --git a/src/services/config.ts b/src/services/config.ts index 9bc8a5e..f8361dd 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -27,7 +27,8 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', - EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns' + EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', + EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns' } as const // 获取解密密钥 @@ -306,3 +307,14 @@ export async function getExportDefaultExcelCompactColumns(): Promise { await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled) } + +// 获取导出默认 TXT 列配置 +export async function getExportDefaultTxtColumns(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS) + return Array.isArray(value) ? (value as string[]) : null +} + +// 设置导出默认 TXT 列配置 +export async function setExportDefaultTxtColumns(columns: string[]): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 7893cf7..d489692 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -351,6 +351,7 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' }