diff --git a/electron/main.ts b/electron/main.ts index f686c4b..af89f08 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -974,6 +974,10 @@ function registerIpcHandlers() { return chatService.getSessionDetail(sessionId) }) + ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => { + return chatService.getExportSessionStats(sessionIds) + }) + ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => { return chatService.getImageData(sessionId, msgId) }) diff --git a/electron/preload.ts b/electron/preload.ts index dd087bb..99aceff 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -151,6 +151,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId), close: () => ipcRenderer.invoke('chat:close'), getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds), getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId), getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e188de8..171ac0b 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -136,9 +136,25 @@ export interface ContactInfo { type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +interface ExportSessionStats { + totalMessages: number + voiceMessages: number + imageMessages: number + videoMessages: number + emojiMessages: number + firstTimestamp?: number + lastTimestamp?: number + privateMutualGroups?: number + groupMemberCount?: number + groupMyMessages?: number + groupActiveSpeakers?: number + groupMutualFriends?: number +} + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() +const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) class ChatService { private configService: ConfigService @@ -1210,6 +1226,228 @@ class ChatService { return Number.isFinite(parsed) ? parsed : NaN } + private buildIdentityKeys(raw: string): string[] { + const value = String(raw || '').trim() + if (!value) return [] + const lowerRaw = value.toLowerCase() + const cleaned = this.cleanAccountDirName(value).toLowerCase() + if (cleaned && cleaned !== lowerRaw) { + return [cleaned, lowerRaw] + } + return [lowerRaw] + } + + private extractGroupMemberUsername(member: any): string { + if (!member) return '' + if (typeof member === 'string') return member.trim() + return String( + member.username || + member.userName || + member.user_name || + member.encryptUsername || + member.encryptUserName || + member.encrypt_username || + member.originalName || + '' + ).trim() + } + + private async getFriendIdentitySet(): Promise> { + const identities = new Set() + const contactResult = await wcdbService.execQuery( + 'contact', + null, + 'SELECT username, local_type, quan_pin FROM contact' + ) + if (!contactResult.success || !contactResult.rows) { + return identities + } + + for (const rowAny of contactResult.rows) { + const row = rowAny as Record + const username = String(row.username || '').trim() + if (!username || username.includes('@chatroom') || username.startsWith('gh_')) continue + if (FRIEND_EXCLUDE_USERNAMES.has(username)) continue + + const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) + if (localType !== 1) continue + + for (const key of this.buildIdentityKeys(username)) { + identities.add(key) + } + } + return identities + } + + private async forEachWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise + ): Promise { + if (items.length === 0) return + const concurrency = Math.max(1, Math.min(limit, items.length)) + let index = 0 + + const runners = Array.from({ length: concurrency }, async () => { + while (true) { + const current = index + index += 1 + if (current >= items.length) return + await worker(items[current]) + } + }) + + await Promise.all(runners) + } + + private async collectSessionExportStats( + sessionId: string, + selfIdentitySet: Set + ): Promise { + const stats: ExportSessionStats = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + if (sessionId.endsWith('@chatroom')) { + stats.groupMyMessages = 0 + stats.groupActiveSpeakers = 0 + } + + const senderIdentities = new Set() + const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 500, false, 0, 0) + if (!cursorResult.success || !cursorResult.cursor) { + return stats + } + + const cursor = cursorResult.cursor + try { + while (true) { + const batch = await wcdbService.fetchMessageBatch(cursor) + if (!batch.success) { + break + } + + const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] + for (const row of rows) { + stats.totalMessages += 1 + + const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + if (localType === 34) stats.voiceMessages += 1 + if (localType === 3) stats.imageMessages += 1 + if (localType === 43) stats.videoMessages += 1 + if (localType === 47) stats.emojiMessages += 1 + + const createTime = this.getRowInt( + row, + ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], + 0 + ) + if (createTime > 0) { + if (stats.firstTimestamp === undefined || createTime < stats.firstTimestamp) { + stats.firstTimestamp = createTime + } + if (stats.lastTimestamp === undefined || createTime > stats.lastTimestamp) { + stats.lastTimestamp = createTime + } + } + + if (sessionId.endsWith('@chatroom')) { + const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim() + const senderKeys = this.buildIdentityKeys(sender) + if (senderKeys.length > 0) { + senderIdentities.add(senderKeys[0]) + if (senderKeys.some((key) => selfIdentitySet.has(key))) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } else { + const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])) + if (Number.isFinite(isSend) && isSend === 1) { + stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 + } + } + } + } + + if (!batch.hasMore || rows.length === 0) { + break + } + } + } finally { + await wcdbService.closeMessageCursor(cursor) + } + + if (sessionId.endsWith('@chatroom')) { + stats.groupActiveSpeakers = senderIdentities.size + } + return stats + } + + private async buildGroupRelationStats( + groupSessionIds: string[], + privateSessionIds: string[], + selfIdentitySet: Set + ): Promise<{ + privateMutualGroupMap: Record + groupMutualFriendMap: Record + }> { + const privateMutualGroupMap: Record = {} + const groupMutualFriendMap: Record = {} + if (groupSessionIds.length === 0) { + return { privateMutualGroupMap, groupMutualFriendMap } + } + + const privateIndex = new Map>() + for (const sessionId of privateSessionIds) { + for (const key of this.buildIdentityKeys(sessionId)) { + const set = privateIndex.get(key) || new Set() + set.add(sessionId) + privateIndex.set(key, set) + } + privateMutualGroupMap[sessionId] = 0 + } + + const friendIdentitySet = await this.getFriendIdentitySet() + await this.forEachWithConcurrency(groupSessionIds, 4, async (groupId) => { + const membersResult = await wcdbService.getGroupMembers(groupId) + if (!membersResult.success || !membersResult.members) { + groupMutualFriendMap[groupId] = 0 + return + } + + const touchedPrivateSessions = new Set() + const friendMembers = new Set() + + for (const member of membersResult.members) { + const username = this.extractGroupMemberUsername(member) + const identityKeys = this.buildIdentityKeys(username) + if (identityKeys.length === 0) continue + const canonical = identityKeys[0] + + if (!selfIdentitySet.has(canonical) && friendIdentitySet.has(canonical)) { + friendMembers.add(canonical) + } + + for (const key of identityKeys) { + const linked = privateIndex.get(key) + if (!linked) continue + for (const sessionId of linked) { + touchedPrivateSessions.add(sessionId) + } + } + } + + groupMutualFriendMap[groupId] = friendMembers.size + for (const sessionId of touchedPrivateSessions) { + privateMutualGroupMap[sessionId] = (privateMutualGroupMap[sessionId] || 0) + 1 + } + }) + + return { privateMutualGroupMap, groupMutualFriendMap } + } + /** * HTTP API 复用消息解析逻辑,确保和应用内展示一致。 */ @@ -3407,6 +3645,108 @@ class ChatService { return { success: false, error: String(e) } } } + + async getExportSessionStats(sessionIds: string[]): Promise<{ + success: boolean + data?: Record + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const normalizedSessionIds = Array.from( + new Set( + (sessionIds || []) + .map((id) => String(id || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedSessionIds.length === 0) { + return { success: true, data: {} } + } + + const myWxid = this.configService.get('myWxid') || '' + const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) + + const resultMap: Record = {} + await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => { + try { + resultMap[sessionId] = await this.collectSessionExportStats(sessionId, selfIdentitySet) + } catch { + resultMap[sessionId] = { + totalMessages: 0, + voiceMessages: 0, + imageMessages: 0, + videoMessages: 0, + emojiMessages: 0 + } + } + }) + + const groupSessionIds = normalizedSessionIds.filter((id) => id.endsWith('@chatroom')) + const privateSessionIds = normalizedSessionIds.filter((id) => !id.endsWith('@chatroom')) + + for (const privateId of privateSessionIds) { + resultMap[privateId] = { + ...resultMap[privateId], + privateMutualGroups: resultMap[privateId]?.privateMutualGroups ?? 0 + } + } + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMyMessages: resultMap[groupId]?.groupMyMessages ?? 0, + groupActiveSpeakers: resultMap[groupId]?.groupActiveSpeakers ?? 0, + groupMemberCount: resultMap[groupId]?.groupMemberCount ?? 0, + groupMutualFriends: resultMap[groupId]?.groupMutualFriends ?? 0 + } + } + + if (groupSessionIds.length > 0) { + const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds) + const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {} + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMemberCount: typeof memberCountMap[groupId] === 'number' ? memberCountMap[groupId] : 0 + } + } + } + + if (groupSessionIds.length > 0) { + try { + const { privateMutualGroupMap, groupMutualFriendMap } = await this.buildGroupRelationStats( + groupSessionIds, + privateSessionIds, + selfIdentitySet + ) + + for (const privateId of privateSessionIds) { + resultMap[privateId] = { + ...resultMap[privateId], + privateMutualGroups: privateMutualGroupMap[privateId] || 0 + } + } + for (const groupId of groupSessionIds) { + resultMap[groupId] = { + ...resultMap[groupId], + groupMutualFriends: groupMutualFriendMap[groupId] || 0 + } + } + } catch { + // 群成员关系统计失败时保留默认值,避免影响主列表展示 + } + } + + return { success: true, data: resultMap } + } catch (e) { + console.error('ChatService: 获取导出会话统计失败:', e) + return { success: false, error: String(e) } + } + } /** * 获取图片数据(解密后的) */ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6855b60..52dcc6f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,8 +237,6 @@ function ExportPage() { const [isLoading, setIsLoading] = useState(true) const [sessions, setSessions] = useState([]) - const [contactMap, setContactMap] = useState>({}) - const [groupMemberCountMap, setGroupMemberCountMap] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') @@ -384,10 +382,9 @@ function ExportPage() { return } - const [sessionsResult, contactsResult, groupChatsResult] = await Promise.all([ + const [sessionsResult, contactsResult] = await Promise.all([ window.electronAPI.chat.getSessions(), - window.electronAPI.chat.getContacts(), - window.electronAPI.groupAnalytics.getGroupChats() + window.electronAPI.chat.getContacts() ]) const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] @@ -395,15 +392,6 @@ function ExportPage() { map[contact.username] = contact return map }, {}) - setContactMap(nextContactMap) - - const nextGroupMemberCountMap: Record = {} - if (groupChatsResult.success && groupChatsResult.data) { - for (const group of groupChatsResult.data) { - nextGroupMemberCountMap[group.username] = group.memberCount - } - } - setGroupMemberCountMap(nextGroupMemberCountMap) if (sessionsResult.success && sessionsResult.sessions) { const nextSessions = sessionsResult.sessions @@ -468,76 +456,46 @@ function ExportPage() { const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return + const updates: Record = {} for (const session of pending) { loadingMetricsRef.current.add(session.username) + updates[session.username] = {} } - const updates: Record = {} - - for (const session of pending) { - const metrics: SessionMetrics = {} - try { - const detailResult = await window.electronAPI.chat.getSessionDetail(session.username) - if (detailResult.success && detailResult.detail) { - metrics.totalMessages = detailResult.detail.messageCount - metrics.firstTimestamp = detailResult.detail.firstMessageTime - metrics.lastTimestamp = detailResult.detail.latestMessageTime - } - - const exportStats = await window.electronAPI.export.getExportStats([session.username], { - exportVoiceAsText: false, - exportMedia: true, - exportImages: true, - exportVoices: true, - exportVideos: true, - exportEmojis: true, - dateRange: null - }) - metrics.voiceMessages = exportStats.voiceMessages - if (metrics.totalMessages === undefined) { - metrics.totalMessages = exportStats.totalMessages - } - - if (session.kind === 'group') { - metrics.groupMemberCount = groupMemberCountMap[session.username] - - const [mediaStatsResult, rankingResult] = await Promise.all([ - window.electronAPI.groupAnalytics.getGroupMediaStats(session.username), - window.electronAPI.groupAnalytics.getGroupMessageRanking(session.username) - ]) - - if (mediaStatsResult.success && mediaStatsResult.data?.typeCounts) { - for (const item of mediaStatsResult.data.typeCounts) { - const n = item.name.toLowerCase() - if (n.includes('图片')) metrics.imageMessages = item.count - if (n.includes('视频')) metrics.videoMessages = item.count - if (n.includes('语音')) metrics.voiceMessages = item.count - if (n.includes('表情')) metrics.emojiMessages = item.count - } - } - - if (rankingResult.success && rankingResult.data) { - metrics.groupActiveSpeakers = rankingResult.data.length - const selfWxid = session.selfWxid || currentUser.wxid - const me = rankingResult.data.find(item => item.member.username === selfWxid) - if (me) { - metrics.groupMyMessages = me.messageCount - } + try { + const statsResult = await window.electronAPI.chat.getExportSessionStats(pending.map(session => session.username)) + if (statsResult.success && statsResult.data) { + for (const session of pending) { + const raw = statsResult.data[session.username] + if (!raw) continue + updates[session.username] = { + totalMessages: raw.totalMessages, + voiceMessages: raw.voiceMessages, + imageMessages: raw.imageMessages, + videoMessages: raw.videoMessages, + emojiMessages: raw.emojiMessages, + privateMutualGroups: raw.privateMutualGroups, + groupMemberCount: raw.groupMemberCount, + groupMyMessages: raw.groupMyMessages, + groupActiveSpeakers: raw.groupActiveSpeakers, + groupMutualFriends: raw.groupMutualFriends, + firstTimestamp: raw.firstTimestamp, + lastTimestamp: raw.lastTimestamp } } - } catch (error) { - console.error('加载会话统计失败:', session.username, error) - } finally { + } + } catch (error) { + console.error('加载会话统计失败:', error) + } finally { + for (const session of pending) { loadingMetricsRef.current.delete(session.username) } - - updates[session.username] = metrics } if (Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } - }, [sessionMetrics, groupMemberCountMap, currentUser.wxid]) + }, [sessionMetrics]) useEffect(() => { const targets = visibleSessions.slice(0, 40) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 45116aa..ff6a293 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -124,6 +124,24 @@ export interface ElectronAPI { } error?: string }> + getExportSessionStats: (sessionIds: string[]) => Promise<{ + success: boolean + data?: Record + error?: string + }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>