diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index b89e68c..7e57c05 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -77,6 +77,7 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' } const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ @@ -170,23 +171,162 @@ class ExportService { return this.contactCache.get(username)! } - const [displayNames, avatarUrls] = await Promise.all([ + const [nameResult, avatarResult] = await Promise.all([ wcdbService.getDisplayNames([username]), wcdbService.getAvatarUrls([username]) ]) - const displayName = displayNames.success && displayNames.map - ? (displayNames.map[username] || username) - : username - const avatarUrl = avatarUrls.success && avatarUrls.map - ? avatarUrls.map[username] - : undefined + const displayName = (nameResult.success && nameResult.map ? nameResult.map[username] : null) || username + const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined const info = { displayName, avatarUrl } this.contactCache.set(username, info) return info } + /** + * 解析 ext_buffer 二进制数据,提取群成员的群昵称 + * ext_buffer 包含类似 protobuf 编码的数据,格式示例: + * wxid_xxx群昵称wxid_yyy群昵称... + */ + private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map { + const nicknameMap = new Map() + + try { + // 将 buffer 转为字符串,允许部分乱码 + const raw = buffer.toString('utf8') + + // 提取所有 wxid 格式的字符串: wxid_ 或 wxid_后跟字母数字下划线 + const wxidPattern = /wxid_[a-z0-9_]+/gi + const wxids = raw.match(wxidPattern) || [] + + // 对每个 wxid,尝试提取其后的群昵称 + for (const wxid of wxids) { + const wxidLower = wxid.toLowerCase() + const wxidIndex = raw.toLowerCase().indexOf(wxidLower) + + if (wxidIndex === -1) continue + + // 从 wxid 结束位置开始查找 + const afterWxid = raw.slice(wxidIndex + wxid.length) + + // 提取紧跟在 wxid 后面的可打印字符(中文、字母、数字等) + // 跳过前面的不可打印字符和特定控制字符 + let nickname = '' + let foundStart = false + + for (let i = 0; i < afterWxid.length && i < 100; i++) { + const char = afterWxid[i] + const code = char.charCodeAt(0) + + // 判断是否为可打印字符(中文、字母、数字、常见符号) + const isPrintable = ( + (code >= 0x4E00 && code <= 0x9FFF) || // 中文 + (code >= 0x3000 && code <= 0x303F) || // CJK 符号 + (code >= 0xFF00 && code <= 0xFFEF) || // 全角字符 + (code >= 0x20 && code <= 0x7E) // ASCII 可打印字符 + ) + + if (isPrintable && code !== 0x01 && code !== 0x18) { + foundStart = true + nickname += char + } else if (foundStart) { + // 遇到不可打印字符,停止 + break + } + } + + // 清理昵称:去除前后空白和特殊字符 + nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '') + + // 只保存有效的群昵称(长度 > 0 且 < 50) + if (nickname && nickname.length > 0 && nickname.length < 50) { + nicknameMap.set(wxidLower, nickname) + } + } + } catch (e) { + // 解析失败时返回空 Map + console.error('Failed to parse ext_buffer:', e) + } + + return nicknameMap + } + + /** + * 从 contact.db 的 chat_room 表获取群成员的群昵称 + * @param chatroomId 群聊ID (如 "xxxxx@chatroom") + * @returns Map + */ + async getGroupNicknamesForRoom(chatroomId: string): Promise> { + console.log('========== getGroupNicknamesForRoom START ==========', chatroomId) + try { + // 查询 contact.db 的 chat_room 表 + // path设为null,因为contact.db已经随handle一起打开了 + const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'` + console.log('执行SQL查询:', sql) + + const result = await wcdbService.execQuery('contact', null, sql) + console.log('execQuery结果:', { success: result.success, rowCount: result.rows?.length, error: result.error }) + + if (!result.success || !result.rows || result.rows.length === 0) { + console.log('❌ 群昵称查询失败或无数据:', chatroomId, result.error) + return new Map() + } + + let extBuffer = result.rows[0].ext_buffer + console.log('ext_buffer原始类型:', typeof extBuffer, 'isBuffer:', Buffer.isBuffer(extBuffer)) + + // execQuery返回的二进制数据会被编码为字符串(hex或base64) + // 需要转换回Buffer + if (typeof extBuffer === 'string') { + console.log('🔄 ext_buffer是字符串,尝试转换为Buffer...') + + // 尝试判断是hex还是base64 + if (this.looksLikeHex(extBuffer)) { + console.log('✅ 检测到hex编码,使用hex解码') + extBuffer = Buffer.from(extBuffer, 'hex') + } else if (this.looksLikeBase64(extBuffer)) { + console.log('✅ 检测到base64编码,使用base64解码') + extBuffer = Buffer.from(extBuffer, 'base64') + } else { + // 默认尝试hex + console.log('⚠️ 无法判断编码格式,默认尝试hex') + try { + extBuffer = Buffer.from(extBuffer, 'hex') + } catch (e) { + console.log('❌ hex解码失败,尝试base64') + extBuffer = Buffer.from(extBuffer, 'base64') + } + } + console.log('✅ 转换后的Buffer长度:', extBuffer.length) + } + + if (!extBuffer || !Buffer.isBuffer(extBuffer)) { + console.log('❌ ext_buffer转换失败,不是Buffer类型:', typeof extBuffer) + return new Map() + } + + console.log('✅ 开始解析ext_buffer, 长度:', extBuffer.length) + const nicknamesMap = this.parseGroupNicknamesFromExtBuffer(extBuffer) + console.log('✅ 解析完成, 找到', nicknamesMap.size, '个群昵称') + + // 打印前5个群昵称作为示例 + let count = 0 + for (const [wxid, nickname] of nicknamesMap.entries()) { + if (count++ < 5) { + console.log(` - ${wxid}: "${nickname}"`) + } + } + + return nicknamesMap + } catch (e) { + console.error('❌ getGroupNicknamesForRoom异常:', e) + return new Map() + } finally { + console.log('========== getGroupNicknamesForRoom END ==========') + } + } + /** * 转换微信消息类型到 ChatLab 类型 */ @@ -269,6 +409,28 @@ class ExportService { return /^[0-9a-fA-F]+$/.test(s) } + /** + * 根据用户偏好获取显示名称 + */ + private getPreferredDisplayName( + wxid: string, + nickname: string, + remark: string, + groupNickname: string, + preference: 'group-nickname' | 'remark' | 'nickname' = 'remark' + ): string { + switch (preference) { + case 'group-nickname': + return groupNickname || remark || nickname || wxid + case 'remark': + return remark || nickname || wxid + case 'nickname': + return nickname || wxid + default: + return nickname || wxid + } + } + private looksLikeBase64(s: string): boolean { if (s.length % 4 !== 0) return false return /^[A-Za-z0-9+/=]+$/.test(s) @@ -707,7 +869,7 @@ class ExportService { if (localType === 3 && options.exportImages) { const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { - } + } return result } @@ -726,7 +888,7 @@ class ExportService { if (localType === 47 && options.exportEmojis) { const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { - } + } return result } @@ -929,13 +1091,13 @@ class ExportService { // 下载表情 if (emojiUrl) { const downloaded = await this.downloadFile(emojiUrl, destPath) - if (downloaded) { - return { - relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), - kind: 'emoji' - } - } else { - } + if (downloaded) { + return { + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), + kind: 'emoji' + } + } else { + } } return null @@ -1016,7 +1178,7 @@ class ExportService { */ private extractEmojiUrl(content: string): string | undefined { if (!content) return undefined - // 参考 echotrace 的正则:cdnurl\s*=\s*['"]([^'"]+)['"] + // 参考 echotrace 的正则:cdnurl\s*=\s*['"]([^'"]+)['"] const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content) if (attrMatch) { // 解码 & 等实体 @@ -1181,14 +1343,14 @@ class ExportService { // 图片消息 imageMd5 = this.extractImageMd5(content) imageDatName = this.extractImageDatName(content) - } else if (localType === 47 && content) { + } else if (localType === 47 && content) { // 动画表情 emojiCdnUrl = this.extractEmojiUrl(content) emojiMd5 = this.extractEmojiMd5(content) - } else if (localType === 43 && content) { + } else if (localType === 43 && content) { // 视频消息 videoMd5 = this.extractVideoMd5(content) - } + } rows.push({ localId, @@ -1506,11 +1668,11 @@ class ExportService { // ========== 阶段1:并行导出媒体文件 ========== const mediaMessages = exportMediaEnabled ? allMessages.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || // 图片 - (t === 47 && options.exportEmojis) || // 表情 - (t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字) - }) + const t = msg.localType + return (t === 3 && options.exportImages) || // 图片 + (t === 47 && options.exportEmojis) || // 表情 + (t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字) + }) : [] const mediaCache = new Map() @@ -1693,11 +1855,11 @@ class ExportService { // ========== 阶段1:并行导出媒体文件 ========== const mediaMessages = exportMediaEnabled ? collected.rows.filter(msg => { - const t = msg.localType - return (t === 3 && options.exportImages) || - (t === 47 && options.exportEmojis) || - (t === 34 && options.exportVoices && !options.exportVoiceAsText) - }) + const t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) : [] const mediaCache = new Map() @@ -1747,6 +1909,11 @@ class ExportService { }) } + // ========== 预加载群昵称(用于名称显示偏好) ========== + const groupNicknamesMap = isGroup + ? await this.getGroupNicknamesForRoom(sessionId) + : new Map() + // ========== 阶段3:构建消息列表 ========== onProgress?.({ current: 55, @@ -1773,6 +1940,24 @@ class ExportService { content = this.parseMessageContent(msg.content, msg.localType) } + // 获取发送者信息用于名称显示 + const senderWxid = msg.senderUsername + const contact = await wcdbService.getContact(senderWxid) + const senderNickname = contact.success && contact.contact?.nickName + ? contact.contact.nickName + : (senderInfo.displayName || senderWxid) + const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : '' + const senderGroupNickname = groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || '' + + // 使用用户偏好的显示名称 + const senderDisplayName = this.getPreferredDisplayName( + senderWxid, + senderNickname, + senderRemark, + senderGroupNickname, + options.displayNamePreference || 'remark' + ) + allMessages.push({ localId: allMessages.length + 1, createTime: msg.createTime, @@ -1782,7 +1967,7 @@ class ExportService { content, isSend: msg.isSend ? 1 : 0, senderUsername: msg.senderUsername, - senderDisplayName: senderInfo.displayName, + senderDisplayName, source, senderAvatarKey: msg.senderUsername }) @@ -1799,14 +1984,33 @@ class ExportService { const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup) + // 获取会话的昵称和备注信息 + const sessionContact = await wcdbService.getContact(sessionId) + const sessionNickname = sessionContact.success && sessionContact.contact?.nickName + ? sessionContact.contact.nickName + : sessionInfo.displayName + const sessionRemark = sessionContact.success && sessionContact.contact?.remark + ? sessionContact.contact.remark + : '' + const sessionGroupNickname = isGroup + ? (groupNicknamesMap.get(sessionId.toLowerCase()) || '') + : '' + + // 使用用户偏好的显示名称 + const sessionDisplayName = this.getPreferredDisplayName( + sessionId, + sessionNickname, + sessionRemark, + sessionGroupNickname, + options.displayNamePreference || 'remark' + ) + const detailedExport: any = { - chatlab, - meta, session: { wxid: sessionId, - nickname: sessionInfo.displayName, - remark: sessionInfo.displayName, - displayName: sessionInfo.displayName, + nickname: sessionNickname, + remark: sessionRemark, + displayName: sessionDisplayName, type: isGroup ? '群聊' : '私聊', lastTimestamp: collected.lastTime, messageCount: allMessages.length, @@ -1886,6 +2090,7 @@ class ExportService { const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + onProgress?.({ current: 30, total: 100, @@ -1962,7 +2167,7 @@ class ExportService { // 表头行 const headers = useCompactColumns ? ['序号', '时间', '发送者身份', '消息类型', '内容'] - : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容'] + : ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '群昵称', '发送者身份', '消息类型', '内容'] const headerRow = worksheet.getRow(currentRow) headerRow.height = 22 @@ -1990,11 +2195,20 @@ class ExportService { worksheet.getColumn(3).width = 18 // 发送者昵称 worksheet.getColumn(4).width = 25 // 发送者微信ID worksheet.getColumn(5).width = 18 // 发送者备注 - worksheet.getColumn(6).width = 15 // 发送者身份 - worksheet.getColumn(7).width = 12 // 消息类型 - worksheet.getColumn(8).width = 50 // 内容 + worksheet.getColumn(6).width = 18 // 群昵称 + worksheet.getColumn(7).width = 15 // 发送者身份 + worksheet.getColumn(8).width = 12 // 消息类型 + worksheet.getColumn(9).width = 50 // 内容 } + // 预加载群昵称 (仅群聊且完整列模式) + console.log('🔍 预加载群昵称检查: isGroup=', isGroup, 'useCompactColumns=', useCompactColumns, 'sessionId=', sessionId) + const groupNicknamesMap = (isGroup && !useCompactColumns) + ? await this.getGroupNicknamesForRoom(sessionId) + : new Map() + console.log('🔍 群昵称Map大小:', groupNicknamesMap.size) + + // 填充数据 const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) @@ -2004,11 +2218,11 @@ class ExportService { // ========== 并行预处理:媒体文件 ========== 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 t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) : [] const mediaCache = new Map() @@ -2074,6 +2288,8 @@ class ExportService { let senderWxid: string let senderNickname: string let senderRemark: string = '' + let senderGroupNickname: string = '' // 群昵称 + if (msg.isSend) { // 我发送的消息 @@ -2113,6 +2329,12 @@ class ExportService { } } + // 获取群昵称 (仅群聊且完整列模式) + if (isGroup && !useCompactColumns && senderWxid) { + senderGroupNickname = groupNicknamesMap.get(senderWxid.toLowerCase()) || '' + } + + const row = worksheet.getRow(currentRow) row.height = 24 @@ -2125,7 +2347,7 @@ class ExportService { // 调试日志 if (msg.localType === 3 || msg.localType === 47) { - } + } worksheet.getCell(currentRow, 1).value = i + 1 worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) @@ -2137,13 +2359,14 @@ class ExportService { worksheet.getCell(currentRow, 3).value = senderNickname worksheet.getCell(currentRow, 4).value = senderWxid worksheet.getCell(currentRow, 5).value = senderRemark - worksheet.getCell(currentRow, 6).value = senderRole - worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 8).value = contentValue + worksheet.getCell(currentRow, 6).value = senderGroupNickname + worksheet.getCell(currentRow, 7).value = senderRole + worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType) + worksheet.getCell(currentRow, 9).value = contentValue } // 设置每个单元格的样式 - const maxColumns = useCompactColumns ? 5 : 8 + const maxColumns = useCompactColumns ? 5 : 9 for (let col = 1; col <= maxColumns; col++) { const cell = worksheet.getCell(currentRow, col) cell.font = { name: 'Calibri', size: 11 } @@ -2225,11 +2448,11 @@ class ExportService { 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 t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices && !options.exportVoiceAsText) + }) : [] const mediaCache = new Map() @@ -2399,12 +2622,12 @@ class ExportService { 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 t = msg.localType + return (t === 3 && options.exportImages) || + (t === 47 && options.exportEmojis) || + (t === 34 && options.exportVoices) || + t === 43 + }) : [] const mediaCache = new Map() @@ -2733,15 +2956,15 @@ class ExportService { fs.mkdirSync(outputDir, { recursive: true }) } - const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportEmojis) - const sessionLayout = exportMediaEnabled - ? (options.sessionLayout ?? 'per-session') - : 'shared' + const exportMediaEnabled = options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportEmojis) + const sessionLayout = exportMediaEnabled + ? (options.sessionLayout ?? 'per-session') + : 'shared' - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i] - const sessionInfo = await this.getContactInfo(sessionId) + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i] + const sessionInfo = await this.getContactInfo(sessionId) onProgress?.({ current: i + 1, @@ -2750,13 +2973,13 @@ class ExportService { phase: 'exporting' }) - const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') - const useSessionFolder = sessionLayout === 'per-session' - const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir + const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const useSessionFolder = sessionLayout === 'per-session' + const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir - if (useSessionFolder && !fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) - } + if (useSessionFolder && !fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }) + } let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index ddedbd9..cdbe7f6 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -396,6 +396,99 @@ } } + .select-field { + position: relative; + } + + .select-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .select-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-md); + z-index: 20; + max-height: 260px; + overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + + .select-option { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-size: 14px; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + } + + .option-label { + font-weight: 500; + } + + .option-desc { + font-size: 12px; + color: var(--text-tertiary); + } + + .select-option.active .option-desc { + color: var(--primary); + } + .media-options { display: flex; flex-wrap: wrap; @@ -1130,11 +1223,11 @@ } } - input:checked + .slider { + input:checked+.slider { background-color: var(--primary); } - input:checked + .slider::before { + input:checked+.slider::before { transform: translateX(20px); } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index bd9365b..d1f5bad 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import * as configService from '../services/config' import './ExportPage.scss' @@ -23,6 +23,7 @@ interface ExportOptions { exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] + displayNamePreference: 'group-nickname' | 'remark' | 'nickname' } interface ExportResult { @@ -49,6 +50,8 @@ function ExportPage() { const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) + const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) + const displayNameDropdownRef = useRef(null) const [options, setOptions] = useState({ format: 'excel', @@ -64,7 +67,8 @@ function ExportPage() { exportEmojis: true, exportVoiceAsText: true, excelCompactColumns: true, - txtColumns: defaultTxtColumns + txtColumns: defaultTxtColumns, + displayNamePreference: 'remark' }) const buildDateRangeFromPreset = (preset: string) => { @@ -189,6 +193,16 @@ function ExportPage() { removeListener?.() } }, []) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node + if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) { + setShowDisplayNameSelect(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showDisplayNameSelect]) useEffect(() => { if (!searchKeyword.trim()) { @@ -271,6 +285,7 @@ function ExportPage() { exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 excelCompactColumns: options.excelCompactColumns, txtColumns: options.txtColumns, + displayNamePreference: options.displayNamePreference, sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), @@ -402,6 +417,25 @@ function ExportPage() { { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } ] + const displayNameOptions = [ + { + value: 'group-nickname', + label: '群昵称优先', + desc: '仅群聊有效,私聊显示备注/昵称' + }, + { + value: 'remark', + label: '备注优先', + desc: '有备注显示备注,否则显示昵称' + }, + { + value: 'nickname', + label: '微信昵称', + desc: '始终显示微信昵称' + } + ] + const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference) + const displayNameLabel = displayNameOption?.label || '备注优先' return (
@@ -516,6 +550,44 @@ function ExportPage() {
+ {/* 发送者名称显示偏好 */} + {(options.format === 'html' || options.format === 'json' || options.format === 'txt') && ( +
+

发送者名称显示

+

选择导出时优先显示的名称

+
+ + {showDisplayNameSelect && ( +
+ {displayNameOptions.map(option => ( + + ))} +
+ )} +
+
+ )}

媒体文件

导出图片/语音/表情并在记录内写入相对路径

diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index cce6785..abd1d6f 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -354,6 +354,7 @@ export interface ExportOptions { excelCompactColumns?: boolean txtColumns?: string[] sessionLayout?: 'shared' | 'per-session' + displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' } export interface ExportProgress {