mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
导出群成员第一版
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
// 年度报告
|
||||
|
||||
@@ -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<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let currentIndex = 0
|
||||
|
||||
async function runNext(): Promise<void> {
|
||||
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<string, string> {
|
||||
const nicknameMap = new Map<string, string>()
|
||||
|
||||
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<Map<string, string>> {
|
||||
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<string, string>()
|
||||
}
|
||||
|
||||
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<string, string>()
|
||||
}
|
||||
|
||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer)
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
}
|
||||
|
||||
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<string, { remark?: string; nickName?: string; alias?: string }>()
|
||||
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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Record<number, number>>({})
|
||||
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<GroupMember | null>(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' && (
|
||||
<button className="export-btn" onClick={handleExportMembers} disabled={functionLoading || isExportingMembers}>
|
||||
{isExportingMembers ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
|
||||
<span>导出成员</span>
|
||||
</button>
|
||||
)}
|
||||
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
|
||||
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
|
||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -232,6 +232,11 @@ export interface ElectronAPI {
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
|
||||
success: boolean
|
||||
count?: number
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
annualReport: {
|
||||
getAvailableYears: () => Promise<{
|
||||
|
||||
Reference in New Issue
Block a user