From e88c859f4fc23915aa338a378c24451b4de9ad6e Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 19 Feb 2026 17:40:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=90=E5=91=98=E6=B6=88=E6=81=AF=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8D=95=E6=8B=8E=E5=87=BA=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 189 ++++++++++++---- src/pages/GroupAnalyticsPage.scss | 185 ++++++++++++--- src/pages/GroupAnalyticsPage.tsx | 349 ++++++++++++++++++++++++----- src/types/electron.d.ts | 5 +- 4 files changed, 604 insertions(+), 124 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 3db210d..6a8a177 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -70,6 +70,8 @@ const MESSAGE_TYPE_MAP: Record = { export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' dateRange?: { start: number; end: number } | null + senderUsername?: string + fileNameSuffix?: string exportMedia?: boolean exportAvatars?: boolean exportImages?: boolean @@ -568,6 +570,52 @@ class ExportService { return `${payerName} 转账给 ${receiverName}` } + private isSameWxid(lhs?: string, rhs?: string): boolean { + const left = new Set(this.buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase())) + if (left.size === 0) return false + const right = this.buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase()) + return right.some((id) => left.has(id)) + } + + private getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' { + const normalizedContent = this.normalizeAppMessageContent(content || '') + if (!normalizedContent) return '[转账]' + + const paySubtype = this.extractXmlValue(normalizedContent, 'paysubtype') + // 转账消息在部分账号数据中 `payer_username` 可能为空,优先用 `paysubtype` 判定 + // 实测:1=发起侧,3=收款侧 + if (paySubtype === '3') return '[转账收款]' + if (paySubtype === '1') return '[转账]' + + const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username') + const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username') + const senderIsPayer = senderWxid ? this.isSameWxid(senderWxid, payerUsername) : false + const senderIsReceiver = senderWxid ? this.isSameWxid(senderWxid, receiverUsername) : false + + // 实测字段语义:sender 命中 receiver_username 为转账发起侧,命中 payer_username 为收款侧 + if (senderWxid) { + if (senderIsReceiver && !senderIsPayer) return '[转账]' + if (senderIsPayer && !senderIsReceiver) return '[转账收款]' + } + + // 兜底:按当前账号角色判断 + if (myWxid) { + if (this.isSameWxid(myWxid, receiverUsername)) return '[转账]' + if (this.isSameWxid(myWxid, payerUsername)) return '[转账收款]' + } + + return '[转账]' + } + + private isTransferExportContent(content: string): boolean { + return content.startsWith('[转账]') || content.startsWith('[转账收款]') + } + + private appendTransferDesc(content: string, transferDesc: string): string { + const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]' + return content.replace(prefix, `${prefix} (${transferDesc})`) + } + private looksLikeBase64(s: string): boolean { if (s.length % 4 !== 0) return false return /^[A-Za-z0-9+/=]+$/.test(s) @@ -577,7 +625,15 @@ class ExportService { * 解析消息内容为可读文本 * 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理 */ - private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null { + private parseMessageContent( + content: string, + localType: number, + sessionId?: string, + createTime?: number, + myWxid?: string, + senderWxid?: string, + isSend?: boolean + ): string | null { if (!content) return null // 检查 XML 中的 type 标签(支持大 localType 的情况) @@ -614,10 +670,11 @@ class ExportService { if (type === '2000') { const feedesc = this.extractXmlValue(content, 'feedesc') const payMemo = this.extractXmlValue(content, 'pay_memo') + const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) if (feedesc) { - return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}` + return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } - return '[转账]' + return transferPrefix } if (type === '6') return title ? `[文件] ${title}` : '[文件]' @@ -653,10 +710,11 @@ class ExportService { if (xmlType === '2000') { const feedesc = this.extractXmlValue(content, 'feedesc') const payMemo = this.extractXmlValue(content, 'pay_memo') + const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) if (feedesc) { - return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}` + return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } - return '[转账]' + return transferPrefix } // 其他类型 @@ -679,7 +737,10 @@ class ExportService { content: string, localType: number, options: { exportVoiceAsText?: boolean }, - voiceTranscript?: string + voiceTranscript?: string, + myWxid?: string, + senderWxid?: string, + isSend?: boolean ): string { const safeContent = content || '' @@ -745,8 +806,9 @@ class ExportService { if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) { const feedesc = this.extractXmlValue(normalized, 'feedesc') const payMemo = this.extractXmlValue(normalized, 'pay_memo') + const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend) if (feedesc) { - return payMemo ? `[转账]${feedesc} ${payMemo}` : `[转账]${feedesc}` + return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}` } const amount = this.extractAmountFromText( [ @@ -759,7 +821,7 @@ class ExportService { .filter(Boolean) .join(' ') ) - return amount ? `[转账]${amount}` : '[转账]' + return amount ? `${transferPrefix}${amount}` : transferPrefix } if (subType === 3 || normalized.includes('; firstTime: number | null; lastTime: number | null }> { const rows: any[] = [] const memberSet = new Map() @@ -1749,6 +1812,10 @@ class ExportService { } else { actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) } + + if (senderUsernameFilter && !this.isSameWxid(actualSender, senderUsernameFilter)) { + continue + } senderSet.add(actualSender) // 提取媒体相关字段 @@ -2177,7 +2244,7 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) const allMessages = collected.rows // 如果没有消息,不创建文件 @@ -2338,11 +2405,19 @@ class ExportService { // 使用预先转写的文字 content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' } else { - content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) + content = this.parseMessageContent( + msg.content, + msg.localType, + sessionId, + msg.createTime, + cleanedMyWxid, + msg.senderUsername, + msg.isSend + ) } // 转账消息:追加 "谁转账给谁" 信息 - if (content && content.startsWith('[转账]') && msg.content) { + if (content && this.isTransferExportContent(content) && msg.content) { const transferDesc = await this.resolveTransferDesc( msg.content, cleanedMyWxid, @@ -2353,7 +2428,7 @@ class ExportService { } ) if (transferDesc) { - content = content.replace('[转账]', `[转账] (${transferDesc})`) + content = this.appendTransferDesc(content, transferDesc) } } @@ -2564,7 +2639,7 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -2708,11 +2783,19 @@ class ExportService { } else if (mediaItem) { content = mediaItem.relativePath } else { - content = this.parseMessageContent(msg.content, msg.localType) + content = this.parseMessageContent( + msg.content, + msg.localType, + undefined, + undefined, + cleanedMyWxid, + msg.senderUsername, + msg.isSend + ) } // 转账消息:追加 "谁转账给谁" 信息 - if (content && content.startsWith('[转账]') && msg.content) { + if (content && this.isTransferExportContent(content) && msg.content) { const transferDesc = await this.resolveTransferDesc( msg.content, cleanedMyWxid, @@ -2726,7 +2809,7 @@ class ExportService { } ) if (transferDesc) { - content = content.replace('[转账]', `[转账] (${transferDesc})`) + content = this.appendTransferDesc(content, transferDesc) } } @@ -2890,7 +2973,7 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -3199,19 +3282,25 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId) + voiceTranscriptMap.get(msg.localId), + cleanedMyWxid, + msg.senderUsername, + msg.isSend ) : (mediaItem?.relativePath || this.formatPlainExportContent( msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId) + voiceTranscriptMap.get(msg.localId), + cleanedMyWxid, + msg.senderUsername, + msg.isSend )) // 转账消息:追加 "谁转账给谁" 信息 let enrichedContentValue = contentValue - if (contentValue.startsWith('[转账]') && msg.content) { + if (this.isTransferExportContent(contentValue) && msg.content) { const transferDesc = await this.resolveTransferDesc( msg.content, cleanedMyWxid, @@ -3225,7 +3314,7 @@ class ExportService { } ) if (transferDesc) { - enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`) + enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) } } @@ -3371,7 +3460,7 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -3510,19 +3599,25 @@ class ExportService { msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId) + voiceTranscriptMap.get(msg.localId), + cleanedMyWxid, + msg.senderUsername, + msg.isSend ) : (mediaItem?.relativePath || this.formatPlainExportContent( msg.content, msg.localType, options, - voiceTranscriptMap.get(msg.localId) + voiceTranscriptMap.get(msg.localId), + cleanedMyWxid, + msg.senderUsername, + msg.isSend )) // 转账消息:追加 "谁转账给谁" 信息 let enrichedContentValue = contentValue - if (contentValue.startsWith('[转账]') && msg.content) { + if (this.isTransferExportContent(contentValue) && msg.content) { const transferDesc = await this.resolveTransferDesc( msg.content, cleanedMyWxid, @@ -3536,7 +3631,7 @@ class ExportService { } ) if (transferDesc) { - enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`) + enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc) } } @@ -3645,7 +3740,7 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) if (collected.rows.length === 0) { return { success: false, error: '该会话在指定时间范围内没有消息' } } @@ -3808,7 +3903,15 @@ class ExportService { const msgText = msg.localType === 34 && options.exportVoiceAsText ? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]') - : (this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) || '') + : (this.parseMessageContent( + msg.content, + msg.localType, + sessionId, + msg.createTime, + cleanedMyWxid, + msg.senderUsername, + msg.isSend + ) || '') const src = this.getWeCloneSource(msg, typeName, mediaItem) const row = [ @@ -3979,7 +4082,7 @@ class ExportService { await this.ensureVoiceModel(onProgress) } - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) // 如果没有消息,不创建文件 if (collected.rows.length === 0) { @@ -4209,14 +4312,20 @@ class ExportService { const timeText = this.formatTimestamp(msg.createTime) const typeName = this.getMessageTypeName(msg.localType) - let textContent = this.formatHtmlMessageText(msg.content, msg.localType) + let textContent = this.formatHtmlMessageText( + msg.content, + msg.localType, + cleanedMyWxid, + msg.senderUsername, + msg.isSend + ) if (msg.localType === 34 && useVoiceTranscript) { textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' } if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { textContent = '' } - if (textContent.startsWith('[转账]') && msg.content) { + if (this.isTransferExportContent(textContent) && msg.content) { const transferDesc = await this.resolveTransferDesc( msg.content, cleanedMyWxid, @@ -4230,7 +4339,7 @@ class ExportService { } ) if (transferDesc) { - textContent = textContent.replace('[转账]', `[转账] (${transferDesc})`) + textContent = this.appendTransferDesc(textContent, transferDesc) } } @@ -4441,7 +4550,7 @@ class ExportService { for (const sessionId of sessionIds) { const sessionInfo = await this.getContactInfo(sessionId) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername) const msgs = collected.rows const voiceMsgs = msgs.filter(m => m.localType === 34) const mediaMsgs = msgs.filter(m => { @@ -4540,7 +4649,10 @@ class ExportService { phase: 'exporting' }) - const safeName = sessionInfo.displayName.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '') + const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() + const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' + const suffix = sanitizeName(options.fileNameSuffix || '') + const safeName = suffix ? `${baseName}_${suffix}` : baseName const useSessionFolder = sessionLayout === 'per-session' const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir @@ -4604,3 +4716,4 @@ class ExportService { } export const exportService = new ExportService() + diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 8bb980c..7dfc40f 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -777,6 +777,159 @@ } } +.member-export-panel { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; + + .member-export-empty { + padding: 20px; + border-radius: 12px; + background: var(--bg-tertiary); + color: var(--text-secondary); + text-align: center; + font-size: 14px; + } + + .member-export-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .member-export-field { + display: flex; + flex-direction: column; + gap: 6px; + + span { + font-size: 12px; + color: var(--text-secondary); + } + + select { + width: 100%; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 13px; + padding: 8px 10px; + outline: none; + } + } + + .member-export-folder { + grid-column: 1 / -1; + } + + .member-export-folder-row { + display: flex; + gap: 8px; + + input { + flex: 1; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 13px; + padding: 8px 10px; + outline: none; + } + + button { + border: none; + border-radius: 10px; + background: var(--bg-tertiary); + color: var(--text-primary); + padding: 0 12px; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + + &:hover { + background: var(--bg-hover); + } + } + } + + .member-export-options { + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 14px; + background: rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + gap: 12px; + } + + .member-export-switch { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: var(--text-primary); + + input { + width: 16px; + height: 16px; + cursor: pointer; + } + } + + .member-export-checkboxes { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + + label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); + + input { + width: 16px; + height: 16px; + cursor: pointer; + } + } + } + + .member-export-actions { + display: flex; + justify-content: flex-end; + } + + .member-export-start-btn { + display: inline-flex; + align-items: center; + gap: 8px; + border: none; + border-radius: 10px; + background: var(--primary); + color: #fff; + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + + &:hover { + opacity: 0.9; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + .rankings-list { display: flex; flex-direction: column; @@ -1143,38 +1296,6 @@ text-align: center; } - .member-action-row { - width: 100%; - margin-bottom: 16px; - } - - .export-member-btn { - width: 100%; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border: none; - border-radius: 10px; - padding: 10px 14px; - background: var(--bg-tertiary); - color: var(--text-primary); - cursor: pointer; - transition: all 0.15s; - font-size: 13px; - font-weight: 500; - - &:hover { - background: var(--bg-hover); - color: var(--primary); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } - .member-details { width: 100%; display: flex; diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 4671b85..6f889ca 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' +import * as configService from '../services/config' import './GroupAnalyticsPage.scss' interface GroupChatInfo { @@ -28,7 +29,20 @@ interface GroupMessageRank { messageCount: number } -type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats' +type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats' +type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' + +interface MemberMessageExportOptions { + format: MemberExportFormat + exportAvatars: boolean + exportMedia: boolean + exportImages: boolean + exportVoices: boolean + exportVideos: boolean + exportEmojis: boolean + exportVoiceAsText: boolean + displayNamePreference: 'group-nickname' | 'remark' | 'nickname' +} function GroupAnalyticsPage() { const location = useLocation() @@ -47,6 +61,19 @@ function GroupAnalyticsPage() { const [functionLoading, setFunctionLoading] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) + const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('') + const [exportFolder, setExportFolder] = useState('') + const [memberExportOptions, setMemberExportOptions] = useState({ + format: 'excel', + exportAvatars: true, + exportMedia: false, + exportImages: true, + exportVoices: true, + exportVideos: true, + exportEmojis: true, + exportVoiceAsText: false, + displayNamePreference: 'remark' + }) // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) @@ -75,9 +102,49 @@ function GroupAnalyticsPage() { .filter(Boolean) }, [location.state]) + const memberExportFormatOptions = useMemo>(() => ([ + { value: 'excel', label: 'Excel' }, + { value: 'txt', label: 'TXT' }, + { value: 'json', label: 'JSON' }, + { value: 'chatlab', label: 'ChatLab' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL' }, + { value: 'html', label: 'HTML' }, + { value: 'weclone', label: 'WeClone CSV' } + ]), []) + + const loadExportPath = useCallback(async () => { + try { + const savedPath = await configService.getExportPath() + if (savedPath) { + setExportFolder(savedPath) + return + } + const downloadsPath = await window.electronAPI.app.getDownloadsPath() + setExportFolder(downloadsPath) + } catch (e) { + console.error('加载导出路径失败:', e) + } + }, []) + + const loadGroups = useCallback(async () => { + setIsLoading(true) + try { + const result = await window.electronAPI.groupAnalytics.getGroupChats() + if (result.success && result.data) { + setGroups(result.data) + setFilteredGroups(result.data) + } + } catch (e) { + console.error(e) + } finally { + setIsLoading(false) + } + }, []) + useEffect(() => { loadGroups() - }, []) + loadExportPath() + }, [loadGroups, loadExportPath]) useEffect(() => { preselectAppliedRef.current = false @@ -91,6 +158,17 @@ function GroupAnalyticsPage() { } }, [searchQuery, groups]) + useEffect(() => { + if (members.length === 0) { + setSelectedExportMemberUsername('') + return + } + const exists = members.some(member => member.username === selectedExportMemberUsername) + if (!exists) { + setSelectedExportMemberUsername(members[0].username) + } + }, [members, selectedExportMemberUsername]) + useEffect(() => { if (preselectAppliedRef.current) return if (groups.length === 0 || preselectGroupIds.length === 0) return @@ -126,27 +204,12 @@ function GroupAnalyticsPage() { // 日期范围变化时自动刷新 useEffect(() => { - if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { + if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') { setDateRangeReady(false) loadFunctionData(selectedFunction) } }, [dateRangeReady]) - const loadGroups = useCallback(async () => { - setIsLoading(true) - try { - const result = await window.electronAPI.groupAnalytics.getGroupChats() - if (result.success && result.data) { - setGroups(result.data) - setFilteredGroups(result.data) - } - } catch (e) { - console.error(e) - } finally { - setIsLoading(false) - } - }, []) - useEffect(() => { const handleChange = () => { setGroups([]) @@ -158,15 +221,17 @@ function GroupAnalyticsPage() { setActiveHours({}) setMediaStats(null) void loadGroups() + void loadExportPath() } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [loadGroups]) + }, [loadExportPath, loadGroups]) const handleGroupSelect = (group: GroupChatInfo) => { if (selectedGroup?.username !== group.username) { setSelectedGroup(group) setSelectedFunction(null) + setSelectedExportMemberUsername('') } } @@ -192,6 +257,11 @@ function GroupAnalyticsPage() { if (result.success && result.data) setMembers(result.data) break } + case 'memberExport': { + const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + if (result.success && result.data) setMembers(result.data) + break + } case 'ranking': { const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) if (result.success && result.data) setRankings(result.data) @@ -287,6 +357,7 @@ function GroupAnalyticsPage() { } const handleDateRangeComplete = () => { + if (selectedFunction === 'memberExport') return setDateRangeReady(true) } @@ -324,32 +395,75 @@ function GroupAnalyticsPage() { } } - const handleExportMemberMessages = async (member: GroupMember) => { - if (!selectedGroup || !member || isExportingMemberMessages) return + const handleMemberExportFormatChange = (format: MemberExportFormat) => { + setMemberExportOptions(prev => { + const next = { ...prev, format } + if (format === 'html') { + return { + ...next, + exportMedia: true, + exportImages: true, + exportVoices: true, + exportVideos: true, + exportEmojis: true + } + } + return next + }) + } + + const handleChooseExportFolder = async () => { + try { + const result = await window.electronAPI.dialog.openDirectory({ + title: '选择导出目录' + }) + if (!result.canceled && result.filePaths.length > 0) { + setExportFolder(result.filePaths[0]) + await configService.setExportPath(result.filePaths[0]) + } + } catch (e) { + console.error('选择导出目录失败:', e) + alert(`选择导出目录失败:${String(e)}`) + } + } + + const handleExportMemberMessages = async () => { + if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return + const member = members.find(item => item.username === selectedExportMemberUsername) + if (!member) { + alert('请先选择成员') + return + } + setIsExportingMemberMessages(true) try { - const downloadsPath = await window.electronAPI.app.getDownloadsPath() - const memberName = member.displayName || member.username - const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_${memberName}_消息记录`) - const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/' - const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx` - const saveResult = await window.electronAPI.dialog.saveFile({ - title: `导出 ${memberName} 的群聊消息`, - defaultPath, - filters: [ - { name: 'Excel', extensions: ['xlsx'] }, - { name: 'CSV', extensions: ['csv'] } - ] - }) - if (!saveResult || saveResult.canceled || !saveResult.filePath) return - - const result = await window.electronAPI.groupAnalytics.exportGroupMemberMessages( - selectedGroup.username, - member.username, - saveResult.filePath + const hasDateRange = Boolean(startDate && endDate) + const result = await window.electronAPI.export.exportSessions( + [selectedGroup.username], + exportFolder, + { + format: memberExportOptions.format, + dateRange: hasDateRange + ? { + start: Math.floor(new Date(startDate).getTime() / 1000), + end: Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000) + } + : null, + exportAvatars: memberExportOptions.exportAvatars, + exportMedia: memberExportOptions.exportMedia, + exportImages: memberExportOptions.exportMedia && memberExportOptions.exportImages, + exportVoices: memberExportOptions.exportMedia && memberExportOptions.exportVoices, + exportVideos: memberExportOptions.exportMedia && memberExportOptions.exportVideos, + exportEmojis: memberExportOptions.exportMedia && memberExportOptions.exportEmojis, + exportVoiceAsText: memberExportOptions.exportVoiceAsText, + sessionLayout: memberExportOptions.exportMedia ? 'per-session' : 'shared', + displayNamePreference: memberExportOptions.displayNamePreference, + senderUsername: member.username, + fileNameSuffix: sanitizeFileName(member.displayName || member.username) + } ) - if (result.success) { - alert(`导出成功,共 ${result.count ?? 0} 条消息`) + if (result.success && (result.successCount ?? 0) > 0) { + alert(`导出成功:${member.displayName || member.username}`) } else { alert(`导出失败:${result.error || '未知错误'}`) } @@ -389,16 +503,6 @@ function GroupAnalyticsPage() {

{selectedMember.displayName}

-
- -
微信ID @@ -527,6 +631,10 @@ function GroupAnalyticsPage() { 群成员查看
+
handleFunctionSelect('memberExport')}> + + 成员消息导出 +
handleFunctionSelect('ranking')}> 群聊发言排行 @@ -547,6 +655,7 @@ function GroupAnalyticsPage() { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' + case 'memberExport': return '成员消息导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' @@ -602,6 +711,140 @@ function GroupAnalyticsPage() { ))}
)} + {selectedFunction === 'memberExport' && ( +
+ {members.length === 0 ? ( +
暂无群成员数据,请先刷新。
+ ) : ( + <> +
+ + +
+ 导出目录 +
+ + +
+
+
+ +
+ +
+ + + + + + +
+ +
+ +
+ +
+ + )} +
+ )} {selectedFunction === 'ranking' && (
{rankings.map((item, index) => ( diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 30cbd6c..6c4136c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -511,12 +511,15 @@ export interface ElectronAPI { } export interface ExportOptions { - format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' + format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' dateRange?: { start: number; end: number } | null + senderUsername?: string + fileNameSuffix?: string exportMedia?: boolean exportAvatars?: boolean exportImages?: boolean exportVoices?: boolean + exportVideos?: boolean exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean