diff --git a/electron/services/exportHtml.css b/electron/services/exportHtml.css new file mode 100644 index 0000000..53698d2 --- /dev/null +++ b/electron/services/exportHtml.css @@ -0,0 +1,301 @@ +:root { + color-scheme: light; + --bg: #f6f7fb; + --card: #ffffff; + --text: #1f2a37; + --muted: #6b7280; + --accent: #4f46e5; + --sent: #dbeafe; + --received: #ffffff; + --border: #e5e7eb; + --shadow: 0 12px 30px rgba(15, 23, 42, 0.08); + --radius: 16px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); +} + +.page { + max-width: 1080px; + margin: 32px auto 60px; + padding: 0 20px; +} + +.header { + background: var(--card); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 24px; + margin-bottom: 24px; +} + +.title { + font-size: 24px; + font-weight: 600; + margin: 0 0 8px; +} + +.meta { + color: var(--muted); + font-size: 14px; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-top: 20px; +} + +.control { + display: flex; + flex-direction: column; + gap: 6px; +} + +.control label { + font-size: 13px; + color: var(--muted); +} + +.control input, +.control select, +.control button { + border-radius: 12px; + border: 1px solid var(--border); + padding: 10px 12px; + font-size: 14px; + font-family: inherit; +} + +.control button { + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; + transition: transform 0.1s ease; +} + +.control button:active { + transform: scale(0.98); +} + +.stats { + font-size: 13px; + color: var(--muted); + display: flex; + align-items: flex-end; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 18px; +} + +.message { + display: flex; + flex-direction: column; + gap: 8px; +} + +.message.hidden { + display: none; +} + +.message-time { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.message-row { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.message.sent .message-row { + flex-direction: row-reverse; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 12px; + background: #eef2ff; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + color: #475569; + font-weight: 600; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.bubble { + max-width: min(70%, 720px); + background: var(--received); + border-radius: 18px; + padding: 12px 14px; + border: 1px solid var(--border); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); +} + +.message.sent .bubble { + background: var(--sent); + border-color: transparent; +} + +.sender-name { + font-size: 12px; + color: var(--muted); + margin-bottom: 6px; +} + +.message-content { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 14px; + line-height: 1.6; +} + +.message-text { + word-break: break-word; +} + +.inline-emoji { + width: 22px; + height: 22px; + vertical-align: text-bottom; + margin: 0 2px; +} + +.message-media { + border-radius: 14px; + max-width: 100%; +} + +.previewable { + cursor: zoom-in; +} + +.message-media.image, +.message-media.emoji { + max-height: 260px; + object-fit: contain; + background: #f1f5f9; + padding: 6px; +} + +.message-media.emoji { + max-height: 160px; + width: auto; +} + +.message-media.video { + max-height: 360px; + background: #111827; +} + +.message-media.audio { + width: 260px; +} + +.image-preview { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.7); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 999; +} + +.image-preview.active { + opacity: 1; + pointer-events: auto; +} + +.image-preview img { + max-width: min(90vw, 1200px); + max-height: 90vh; + border-radius: 18px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); + background: #0f172a; + transition: transform 0.1s ease; + cursor: zoom-out; +} + +body[data-theme="cloud-dancer"] { + --accent: #6b8cff; + --sent: #e0e7ff; + --received: #ffffff; + --border: #d8e0f7; + --bg: #f6f7fb; +} + +body[data-theme="corundum-blue"] { + --accent: #2563eb; + --sent: #dbeafe; + --received: #ffffff; + --border: #c7d2fe; + --bg: #eef2ff; +} + +body[data-theme="kiwi-green"] { + --accent: #16a34a; + --sent: #dcfce7; + --received: #ffffff; + --border: #bbf7d0; + --bg: #f0fdf4; +} + +body[data-theme="spicy-red"] { + --accent: #e11d48; + --sent: #ffe4e6; + --received: #ffffff; + --border: #fecdd3; + --bg: #fff1f2; +} + +body[data-theme="teal-water"] { + --accent: #0f766e; + --sent: #ccfbf1; + --received: #ffffff; + --border: #99f6e4; + --bg: #f0fdfa; +} + +.highlight { + outline: 2px solid var(--accent); + outline-offset: 4px; + border-radius: 18px; +} + +.empty { + text-align: center; + color: var(--muted); + padding: 40px; +} diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 76c3633..88b79f3 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -4,10 +4,12 @@ import * as http from 'http' import * as https from 'https' import { fileURLToPath } from 'url' import ExcelJS from 'exceljs' +import { getEmojiPath } from 'wechat-emojis' import { ConfigService } from './config' import { wcdbService } from './wcdbService' import { imageDecryptService } from './imageDecryptService' import { chatService } from './chatService' +import { videoService } from './videoService' // ChatLab 格式类型定义 interface ChatLabHeader { @@ -72,12 +74,25 @@ 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' + kind: 'image' | 'voice' | 'emoji' | 'video' + posterDataUrl?: string } export interface ExportProgress { @@ -115,6 +130,8 @@ async function parallelLimit( class ExportService { private configService: ConfigService private contactCache: Map = new Map() + private inlineEmojiCache: Map = new Map() + private htmlStyleCache: string | null = null constructor() { this.configService = new ConfigService() @@ -204,6 +221,9 @@ class ExportService { if (!raw) return '' if (typeof raw === 'string') { if (raw.length === 0) return '' + if (/^[0-9]+$/.test(raw)) { + return raw + } if (this.looksLikeHex(raw)) { const bytes = Buffer.from(raw, 'hex') if (bytes.length > 0) return this.decodeBinaryContent(bytes) @@ -285,6 +305,121 @@ class ExportService { } } + private formatPlainExportContent( + content: string, + localType: number, + options: { exportVoiceAsText?: boolean }, + voiceTranscript?: string + ): string { + const safeContent = content || '' + + if (localType === 3) return '[图片]' + if (localType === 1) return this.stripSenderPrefix(safeContent) + if (localType === 34) { + if (options.exportVoiceAsText) { + return voiceTranscript || '[语音消息 - 转文字失败]' + } + return '[其他消息]' + } + if (localType === 42) { + const normalized = this.normalizeAppMessageContent(safeContent) + const nickname = + this.extractXmlValue(normalized, 'nickname') || + this.extractXmlValue(normalized, 'displayname') || + this.extractXmlValue(normalized, 'name') + return nickname ? `[名片]${nickname}` : '[名片]' + } + if (localType === 43) { + const normalized = this.normalizeAppMessageContent(safeContent) + const lengthValue = + this.extractXmlValue(normalized, 'playlength') || + this.extractXmlValue(normalized, 'playLength') || + this.extractXmlValue(normalized, 'length') || + this.extractXmlValue(normalized, 'duration') + const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null + return seconds ? `[视频]${seconds}s` : '[视频]' + } + if (localType === 48) { + const normalized = this.normalizeAppMessageContent(safeContent) + const location = + this.extractXmlValue(normalized, 'label') || + this.extractXmlValue(normalized, 'poiname') || + this.extractXmlValue(normalized, 'poiName') || + this.extractXmlValue(normalized, 'name') + return location ? `[定位]${location}` : '[定位]' + } + if (localType === 50) { + return this.parseVoipMessage(safeContent) + } + if (localType === 10000 || localType === 266287972401) { + return this.cleanSystemMessage(safeContent) + } + + const normalized = this.normalizeAppMessageContent(safeContent) + const isAppMessage = normalized.includes('') + if (localType === 49 || isAppMessage) { + const typeMatch = /(\d+)<\/type>/i.exec(normalized) + const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 + const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') + if (subType === 3 || normalized.includes('= 1000) return Math.round(numeric / 1000) + return Math.round(numeric) + } + + private extractAmountFromText(text: string): string | null { + if (!text) return null + const match = /([¥¥]\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?)/.exec(text) + return match ? match[1].replace(/\s+/g, '') : null + } + private stripSenderPrefix(content: string): string { return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '') } @@ -428,6 +563,124 @@ 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() + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + } + + private escapeAttribute(value: string): string { + return this.escapeHtml(value).replace(/`/g, '`') + } + + private getAvatarFallback(name: string): string { + if (!name) return '?' + return [...name][0] || '?' + } + + private renderMultilineText(value: string): string { + return this.escapeHtml(value).replace(/\r?\n/g, '
') + } + + private loadExportHtmlStyles(): string { + if (this.htmlStyleCache !== null) { + return this.htmlStyleCache + } + const candidates = [ + path.join(__dirname, 'exportHtml.css'), + path.join(process.cwd(), 'electron', 'services', 'exportHtml.css') + ] + for (const filePath of candidates) { + if (fs.existsSync(filePath)) { + try { + const content = fs.readFileSync(filePath, 'utf-8') + this.htmlStyleCache = content + return content + } catch { + continue + } + } + } + this.htmlStyleCache = '' + return '' + } + + private normalizeAppMessageContent(content: string): string { + if (!content) return '' + if (content.includes('<') && content.includes('>')) { + return content + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + } + return content + } + + private getInlineEmojiDataUrl(name: string): string | null { + if (!name) return null + const cached = this.inlineEmojiCache.get(name) + if (cached) return cached + const emojiPath = getEmojiPath(name as any) + if (!emojiPath) return null + const baseDir = path.dirname(require.resolve('wechat-emojis')) + const absolutePath = path.join(baseDir, emojiPath) + if (!fs.existsSync(absolutePath)) return null + try { + const buffer = fs.readFileSync(absolutePath) + const dataUrl = `data:image/png;base64,${buffer.toString('base64')}` + this.inlineEmojiCache.set(name, dataUrl) + return dataUrl + } catch { + return null + } + } + + private renderTextWithEmoji(text: string): string { + if (!text) return '' + const parts = text.split(/\[(.*?)\]/g) + const rendered = parts.map((part, index) => { + if (index % 2 === 1) { + const emojiDataUrl = this.getInlineEmojiDataUrl(part) + if (emojiDataUrl) { + return `[${this.escapeAttribute(part)}]` + } + return this.escapeHtml(`[${part}]`) + } + return this.escapeHtml(part) + }) + return rendered.join('') + } + + private formatHtmlMessageText(content: string, localType: number): string { + if (!content) return '' + + if (localType === 1) { + return this.stripSenderPrefix(content) + } + + if (localType === 34) { + return this.parseMessageContent(content, localType) || '' + } + + return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }) + } + /** * 导出媒体文件到指定目录 */ @@ -436,7 +689,14 @@ class ExportService { sessionId: string, mediaRootDir: string, mediaRelativePrefix: string, - options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } + options: { + exportImages?: boolean + exportVoices?: boolean + exportEmojis?: boolean + exportVoiceAsText?: boolean + includeVoiceWithTranscript?: boolean + exportVideos?: boolean + } ): Promise { const localType = msg.localType @@ -450,14 +710,13 @@ class ExportService { // 语音消息 if (localType === 34) { - // 如果开启了语音转文字,优先转文字(不导出语音文件) - if (options.exportVoiceAsText) { - return null // 转文字逻辑在消息内容处理中完成 - } - // 否则导出语音文件 - if (options.exportVoices) { + const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText + if (shouldKeepVoiceFile && options.exportVoices) { return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) } + if (options.exportVoiceAsText) { + return null + } } // 动画表情 @@ -468,6 +727,10 @@ class ExportService { return result } + if (localType === 43 && options.exportVideos) { + return this.exportVideo(msg, sessionId, mediaRootDir, mediaRelativePrefix) + } + return null } @@ -678,6 +941,47 @@ class ExportService { } } + /** + * 导出视频文件 + */ + private async exportVideo( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { + try { + const videoMd5 = msg.videoMd5 + if (!videoMd5) return null + + const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos') + if (!fs.existsSync(videosDir)) { + fs.mkdirSync(videosDir, { recursive: true }) + } + + const videoInfo = await videoService.getVideoInfo(videoMd5) + if (!videoInfo.exists || !videoInfo.videoUrl) { + return null + } + + const sourcePath = videoInfo.videoUrl + const fileName = path.basename(sourcePath) + const destPath = path.join(videosDir, fileName) + + if (!fs.existsSync(destPath)) { + fs.copyFileSync(sourcePath, destPath) + } + + return { + relativePath: path.posix.join(mediaRelativePrefix, 'videos', fileName), + kind: 'video', + posterDataUrl: videoInfo.coverUrl || videoInfo.thumbUrl + } + } catch (e) { + return null + } + } + /** * 从消息内容提取图片 MD5 */ @@ -736,6 +1040,16 @@ class ExportService { return match?.[1] } + private extractVideoMd5(content: string): string | undefined { + if (!content) return undefined + const attrMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (attrMatch) { + return attrMatch[1].toLowerCase() + } + const tagMatch = /([^<]+)<\/md5>/i.exec(content) + return tagMatch?.[1]?.toLowerCase() + } + /** * 从 data URL 获取扩展名 */ @@ -858,6 +1172,7 @@ class ExportService { let imageDatName: string | undefined let emojiCdnUrl: string | undefined let emojiMd5: string | undefined + let videoMd5: string | undefined if (localType === 3 && content) { // 图片消息 @@ -867,6 +1182,9 @@ class ExportService { // 动画表情 emojiCdnUrl = this.extractEmojiUrl(content) emojiMd5 = this.extractEmojiMd5(content) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = this.extractVideoMd5(content) } rows.push({ @@ -879,7 +1197,8 @@ class ExportService { imageMd5, imageDatName, emojiCdnUrl, - emojiMd5 + emojiMd5, + videoMd5 }) if (firstTime === null || createTime < firstTime) firstTime = createTime @@ -1747,10 +2066,6 @@ class ExportService { 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 senderRole: string let senderWxid: string @@ -1798,16 +2113,12 @@ class ExportService { const row = worksheet.getRow(currentRow) row.height = 24 - // 确定内容:优先使用预处理的缓存 - 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) || '' - } + const contentValue = this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + ) // 调试日志 if (msg.localType === 3 || msg.localType === 47) { @@ -1880,6 +2191,523 @@ 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 lines: string[] = [] + + for (let i = 0; i < sortedMessages.length; i++) { + const msg = sortedMessages[i] + const contentValue = this.formatPlainExportContent( + msg.content, + msg.localType, + options, + voiceTranscriptMap.get(msg.localId) + ) + + 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 + } + } + + lines.push(`${this.formatTimestamp(msg.createTime)} '${senderRole}'`) + lines.push(contentValue) + lines.push('') + + 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) } + } + } + + /** + * 导出单个会话为 HTML 格式 + */ + async exportSessionToHtml( + 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) + if (isGroup) { + await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) + } + 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) || + t === 43 + }) + : [] + + const mediaCache = new Map() + + if (mediaMessages.length > 0) { + onProgress?.({ + current: 20, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'exporting-media' + }) + + const MEDIA_CONCURRENCY = 6 + 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, + includeVoiceWithTranscript: true, + exportVideos: true + }) + mediaCache.set(mediaKey, mediaItem) + } + }) + } + + const useVoiceTranscript = options.exportVoiceAsText !== false + const voiceMessages = useVoiceTranscript + ? sortedMessages.filter(msg => msg.localType === 34) + : [] + const voiceTranscriptMap = new Map() + + if (voiceMessages.length > 0) { + onProgress?.({ + current: 40, + 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) + }) + } + + const avatarMap = options.exportAvatars + ? await this.exportAvatars( + [ + ...Array.from(collected.memberSet.entries()).map(([username, info]) => ({ + username, + avatarUrl: info.avatarUrl + })), + { username: sessionId, avatarUrl: sessionInfo.avatarUrl }, + { username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl } + ] + ) + : new Map() + + const renderedMessages = sortedMessages.map((msg, index) => { + const mediaKey = `${msg.localType}_${msg.localId}` + const mediaItem = mediaCache.get(mediaKey) || null + + const isSenderMe = msg.isSend + const senderInfo = collected.memberSet.get(msg.senderUsername)?.member + const senderName = isSenderMe + ? (myInfo.displayName || '我') + : (isGroup + ? (senderInfo?.groupNickname || senderInfo?.accountName || msg.senderUsername) + : (sessionInfo.displayName || sessionId)) + const avatarData = avatarMap.get(isSenderMe ? cleanedMyWxid : msg.senderUsername) + const avatarHtml = avatarData + ? `${this.escapeAttribute(senderName)}` + : `${this.escapeHtml(this.getAvatarFallback(senderName))}` + + const timeText = this.formatTimestamp(msg.createTime) + const typeName = this.getMessageTypeName(msg.localType) + + let textContent = this.formatHtmlMessageText(msg.content, msg.localType) + if (msg.localType === 34 && useVoiceTranscript) { + textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' + } + if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { + textContent = '' + } + + let mediaHtml = '' + if (mediaItem?.kind === 'image') { + const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath)) + mediaHtml = `${this.escapeAttribute(typeName)}` + } else if (mediaItem?.kind === 'emoji') { + const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath)) + mediaHtml = `${this.escapeAttribute(typeName)}` + } else if (mediaItem?.kind === 'voice') { + mediaHtml = `` + } else if (mediaItem?.kind === 'video') { + const posterAttr = mediaItem.posterDataUrl ? ` poster="${this.escapeAttribute(mediaItem.posterDataUrl)}"` : '' + mediaHtml = `` + } + + const textHtml = textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '' + const senderHtml = isGroup + ? `
${this.escapeHtml(senderName)}
` + : '' + const timeHtml = `
${this.escapeHtml(timeText)}
` + const messageBody = ` + ${timeHtml} + ${senderHtml} +
+ ${mediaHtml} + ${textHtml} +
+ ` + + return ` +
+
+
${avatarHtml}
+
+ ${messageBody} +
+
+
+ ` + }).join('\n') + + onProgress?.({ + current: 85, + total: 100, + currentSession: sessionInfo.displayName, + phase: 'writing' + }) + + const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup) + const htmlStyles = this.loadExportHtmlStyles() + const html = ` + + + + + ${this.escapeHtml(sessionInfo.displayName)} - 聊天记录 + + + +
+
+

${this.escapeHtml(sessionInfo.displayName)} 的聊天记录

+
+ 导出时间:${this.escapeHtml(this.formatTimestamp(exportMeta.chatlab.exportedAt))} + 消息数量:${sortedMessages.length} + 会话类型:${isGroup ? '群聊' : '私聊'} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ 共 ${sortedMessages.length} 条 +
+
+
+
+ ${renderedMessages || '
暂无消息
'} +
+
+
+ 预览 +
+ + +` + + fs.writeFileSync(outputPath, html, '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 +2758,8 @@ 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' + else if (options.format === 'html') ext = '.html' const outputPath = path.join(sessionDir, `${safeName}${ext}`) let result: { success: boolean; error?: string } @@ -1939,6 +2769,10 @@ 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 if (options.format === 'html') { + result = await this.exportSessionToHtml(sessionId, outputPath, options) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 9783061..088c0ef 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) @@ -209,6 +216,23 @@ function ExportPage() { return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) } + const handleFormatChange = (format: ExportOptions['format']) => { + setOptions((prev) => { + const next = { ...prev, format } + if (format === 'html') { + return { + ...next, + exportMedia: true, + exportImages: true, + exportVoices: true, + exportEmojis: true, + exportVoiceAsText: true + } + } + return next + }) + } + const openExportFolder = async () => { if (exportFolder) { await window.electronAPI.shell.openPath(exportFolder) @@ -233,6 +257,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 +266,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' || options.format === 'html') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, @@ -447,7 +472,7 @@ function ExportPage() {
setOptions({ ...options, format: fmt.value as any })} + onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])} > {fmt.label} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 999dc5f..18b2979 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -166,6 +166,7 @@ function SettingsPage() { await configService.setTranscribeLanguages(defaultLanguages) } + if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) } catch (e) { console.error('加载配置失败:', e) @@ -1074,6 +1075,7 @@ function SettingsPage() { )}
+ ) } @@ -1225,4 +1227,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 feea2cc..cce6785 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -352,6 +352,7 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' }