diff --git a/electron/main.ts b/electron/main.ts index 6f1baea..4a219b8 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -906,6 +906,10 @@ function registerIpcHandlers() { return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) }) + ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => { + return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath) + }) + // 打开协议窗口 ipcMain.handle('window:openAgreementWindow', async () => { createAgreementWindow() diff --git a/electron/preload.ts b/electron/preload.ts index 1698bf5..c401232 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -183,7 +183,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), - getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime) + getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime), + exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath) }, // 年度报告 diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 628e0bb..8b7629b 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs' +import * as fs from 'fs' import { ConfigService } from './config' import { wcdbService } from './wcdbService' @@ -41,6 +43,30 @@ class GroupAnalyticsService { this.configService = new ConfigService() } + // 并发控制:限制同时执行的 Promise 数量 + private async parallelLimit( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise + ): Promise { + const results: R[] = new Array(items.length) + let currentIndex = 0 + + async function runNext(): Promise { + while (currentIndex < items.length) { + const index = currentIndex++ + results[index] = await fn(items[index], index) + } + } + + const workers = Array(Math.min(limit, items.length)) + .fill(null) + .map(() => runNext()) + + await Promise.all(workers) + return results + } + private cleanAccountDirName(name: string): string { const trimmed = name.trim() if (!trimmed) return trimmed @@ -65,6 +91,114 @@ class GroupAnalyticsService { return { success: true } } + private looksLikeHex(s: string): boolean { + if (s.length % 2 !== 0) return false + return /^[0-9a-fA-F]+$/.test(s) + } + + private looksLikeBase64(s: string): boolean { + if (s.length % 4 !== 0) return false + return /^[A-Za-z0-9+/=]+$/.test(s) + } + + /** + * 解析 ext_buffer 二进制数据,提取群成员的群昵称 + */ + private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map { + const nicknameMap = new Map() + + try { + const raw = buffer.toString('utf8') + const wxidPattern = /wxid_[a-z0-9_]+/gi + const wxids = raw.match(wxidPattern) || [] + + for (const wxid of wxids) { + const wxidLower = wxid.toLowerCase() + const wxidIndex = raw.toLowerCase().indexOf(wxidLower) + if (wxidIndex === -1) continue + + const afterWxid = raw.slice(wxidIndex + wxid.length) + 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) || + (code >= 0xFF00 && code <= 0xFFEF) || + (code >= 0x20 && code <= 0x7E) + ) + + if (isPrintable && code !== 0x01 && code !== 0x18) { + foundStart = true + nickname += char + } else if (foundStart) { + break + } + } + + nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '') + if (nickname && nickname.length < 50) { + nicknameMap.set(wxidLower, nickname) + } + } + } catch (e) { + console.error('Failed to parse ext_buffer:', e) + } + + return nicknameMap + } + + /** + * 从 contact.db 的 chat_room 表获取群成员的群昵称 + */ + private async getGroupNicknamesForRoom(chatroomId: string): Promise> { + try { + const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'` + const result = await wcdbService.execQuery('contact', null, sql) + + if (!result.success || !result.rows || result.rows.length === 0) { + return new Map() + } + + let extBuffer = result.rows[0].ext_buffer + + if (typeof extBuffer === 'string') { + if (this.looksLikeHex(extBuffer)) { + extBuffer = Buffer.from(extBuffer, 'hex') + } else if (this.looksLikeBase64(extBuffer)) { + extBuffer = Buffer.from(extBuffer, 'base64') + } else { + try { + extBuffer = Buffer.from(extBuffer, 'hex') + } catch { + extBuffer = Buffer.from(extBuffer, 'base64') + } + } + } + + if (!extBuffer || !Buffer.isBuffer(extBuffer)) { + return new Map() + } + + return this.parseGroupNicknamesFromExtBuffer(extBuffer) + } catch (e) { + console.error('getGroupNicknamesForRoom error:', e) + return new Map() + } + } + + private escapeCsvValue(value: string): string { + if (value == null) return '' + const str = String(value) + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str + } + async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { try { const conn = await this.ensureConnected() @@ -248,6 +382,68 @@ class GroupAnalyticsService { return { success: false, error: String(e) } } } + + async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> { + try { + const conn = await this.ensureConnected() + if (!conn.success) return { success: false, error: conn.error } + + const membersResult = await wcdbService.getGroupMembers(chatroomId) + if (!membersResult.success || !membersResult.members) { + return { success: false, error: membersResult.error || '获取群成员失败' } + } + + const members = membersResult.members as { username: string; avatarUrl?: string }[] + if (members.length === 0) { + return { success: false, error: '群成员为空' } + } + + const usernames = members.map((m) => m.username).filter(Boolean) + const [displayNames, groupNicknames] = await Promise.all([ + wcdbService.getDisplayNames(usernames), + this.getGroupNicknamesForRoom(chatroomId) + ]) + + const contactMap = new Map() + const concurrency = 6 + await this.parallelLimit(usernames, concurrency, async (username) => { + const result = await wcdbService.getContact(username) + if (result.success && result.contact) { + const contact = result.contact as any + contactMap.set(username, { + remark: contact.remark || '', + nickName: contact.nickName || contact.nick_name || '', + alias: contact.alias || '' + }) + } else { + contactMap.set(username, { remark: '', nickName: '', alias: '' }) + } + }) + + const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号'] + const rows: string[][] = [header] + + for (const member of members) { + const wxid = member.username + const contact = contactMap.get(wxid) + const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : '' + const nickName = contact?.nickName || fallbackName || '' + const remark = contact?.remark || '' + const groupNickname = groupNicknames.get(wxid.toLowerCase()) || '' + const alias = contact?.alias || '' + + rows.push([nickName, remark, groupNickname, wxid, alias]) + } + + const csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(',')) + const content = '\ufeff' + csvLines.join('\n') + fs.writeFileSync(outputPath, content, 'utf8') + + return { success: true, count: members.length } + } catch (e) { + return { success: false, error: String(e) } + } + } } export const groupAnalyticsService = new GroupAnalyticsService() diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 7993c7a..dcf6cec 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -656,6 +656,32 @@ cursor: not-allowed; } } + + .export-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: none; + background: var(--bg-tertiary); + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; + -webkit-app-region: no-drag; + font-size: 12px; + flex-shrink: 0; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } } .content-body { diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 1a8dfe1..0f00ab7 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, 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' @@ -39,6 +39,7 @@ function GroupAnalyticsPage() { const [activeHours, setActiveHours] = useState>({}) const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null) const [functionLoading, setFunctionLoading] = useState(false) + const [isExportingMembers, setIsExportingMembers] = useState(false) // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) @@ -181,6 +182,10 @@ function GroupAnalyticsPage() { return num.toLocaleString() } + const sanitizeFileName = (name: string) => { + return name.replace(/[<>:"/\\|?*]+/g, '_').trim() + } + const getHourlyOption = () => { const hours = Array.from({ length: 24 }, (_, i) => i) const data = hours.map(h => activeHours[h] || 0) @@ -252,6 +257,35 @@ function GroupAnalyticsPage() { setCopiedField(null) } + const handleExportMembers = async () => { + if (!selectedGroup || isExportingMembers) return + setIsExportingMembers(true) + try { + const downloadsPath = await window.electronAPI.app.getDownloadsPath() + const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_群成员列表`) + const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/' + const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.csv` : `${baseName}.csv` + const saveResult = await window.electronAPI.dialog.saveFile({ + title: '导出群成员列表', + defaultPath, + filters: [{ name: 'CSV', extensions: ['csv'] }] + }) + if (!saveResult || saveResult.canceled || !saveResult.filePath) return + + const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath) + if (result.success) { + alert(`导出成功,共 ${result.count ?? members.length} 人`) + } else { + alert(`导出失败:${result.error || '未知错误'}`) + } + } catch (e) { + console.error('导出群成员失败:', e) + alert(`导出失败:${String(e)}`) + } finally { + setIsExportingMembers(false) + } + } + const handleCopy = async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -423,6 +457,12 @@ function GroupAnalyticsPage() { onRangeComplete={handleDateRangeComplete} /> )} + {selectedFunction === 'members' && ( + + )} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8866bd9..2e424ea 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -232,6 +232,11 @@ export interface ElectronAPI { } error?: string }> + exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{ + success: boolean + count?: number + error?: string + }> } annualReport: { getAvailableYears: () => Promise<{