群聊单个成员消息导出

This commit is contained in:
xuncha
2026-02-19 16:49:00 +08:00
parent d5f0094025
commit 89783b4d45
6 changed files with 366 additions and 3 deletions

View File

@@ -1116,6 +1116,13 @@ function registerIpcHandlers() {
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
})
ipcMain.handle(
'groupAnalytics:exportGroupMemberMessages',
async (_, chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.exportGroupMemberMessages(chatroomId, memberUsername, outputPath, startTime, endTime)
}
)
// 打开协议窗口
ipcMain.handle('window:openAgreementWindow', async () => {
createAgreementWindow()
@@ -1350,7 +1357,8 @@ function registerIpcHandlers() {
ipcMain.handle('http:status', async () => {
return {
running: httpService.isRunning(),
port: httpService.getPort()
port: httpService.getPort(),
mediaExportPath: httpService.getDefaultMediaExportPath()
}
})

View File

@@ -216,7 +216,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
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),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
},
// 年度报告

View File

@@ -4,6 +4,7 @@ import ExcelJS from 'exceljs'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
import type { Message } from './chatService'
export interface GroupChatInfo {
username: string
@@ -339,6 +340,92 @@ class GroupAnalyticsService {
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
private formatUnixTime(createTime: number): string {
if (!Number.isFinite(createTime) || createTime <= 0) return ''
const milliseconds = createTime > 1e12 ? createTime : createTime * 1000
const date = new Date(milliseconds)
if (Number.isNaN(date.getTime())) return String(createTime)
return this.formatDateTime(date)
}
private getSimpleMessageTypeName(localType: number): string {
const typeMap: Record<number, string> = {
1: '文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情',
48: '位置',
49: '链接/文件',
50: '通话',
10000: '系统',
266287972401: '拍一拍',
8594229559345: '红包',
8589934592049: '转账'
}
return typeMap[localType] || `类型(${localType})`
}
private normalizeIdCandidates(values: Array<string | null | undefined>): string[] {
return this.buildIdCandidates(values).map(value => value.toLowerCase())
}
private isSameAccountIdentity(left: string | null | undefined, right: string | null | undefined): boolean {
const leftCandidates = this.normalizeIdCandidates([left])
const rightCandidates = this.normalizeIdCandidates([right])
if (leftCandidates.length === 0 || rightCandidates.length === 0) return false
const rightSet = new Set(rightCandidates)
for (const leftCandidate of leftCandidates) {
if (rightSet.has(leftCandidate)) return true
for (const rightCandidate of rightCandidates) {
if (leftCandidate.startsWith(`${rightCandidate}_`) || rightCandidate.startsWith(`${leftCandidate}_`)) {
return true
}
}
}
return false
}
private resolveExportMessageContent(message: Message): string {
const parsed = String(message.parsedContent || '').trim()
if (parsed) return parsed
const raw = String(message.rawContent || '').trim()
if (raw) return raw
return ''
}
private async collectMessagesByMember(
chatroomId: string,
memberUsername: string,
startTime: number,
endTime: number
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
const batchSize = 500
const matchedMessages: Message[] = []
let offset = 0
while (true) {
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
if (!batch.success || !batch.messages) {
return { success: false, error: batch.error || '获取群消息失败' }
}
for (const message of batch.messages) {
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
matchedMessages.push(message)
}
}
const fetchedCount = batch.messages.length
if (fetchedCount <= 0 || !batch.hasMore) break
offset += fetchedCount
}
return { success: true, data: matchedMessages }
}
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
try {
const conn = await this.ensureConnected()
@@ -611,6 +698,181 @@ class GroupAnalyticsService {
}
}
async exportGroupMemberMessages(
chatroomId: string,
memberUsername: string,
outputPath: string,
startTime?: number,
endTime?: number
): Promise<{ success: boolean; count?: number; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const normalizedChatroomId = String(chatroomId || '').trim()
const normalizedMemberUsername = String(memberUsername || '').trim()
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
const beginTimestamp = Number.isFinite(startTime) && typeof startTime === 'number'
? Math.max(0, Math.floor(startTime))
: 0
const endTimestampValue = Number.isFinite(endTime) && typeof endTime === 'number'
? Math.max(0, Math.floor(endTime))
: 0
const exportDate = new Date()
const exportTime = this.formatDateTime(exportDate)
const exportVersion = '0.0.2'
const exportGenerator = 'WeFlow'
const exportPlatform = 'wechat'
const groupDisplay = await wcdbService.getDisplayNames([normalizedChatroomId, normalizedMemberUsername])
const groupName = groupDisplay.success && groupDisplay.map
? (groupDisplay.map[normalizedChatroomId] || normalizedChatroomId)
: normalizedChatroomId
const defaultMemberDisplayName = groupDisplay.success && groupDisplay.map
? (groupDisplay.map[normalizedMemberUsername] || normalizedMemberUsername)
: normalizedMemberUsername
let memberDisplayName = defaultMemberDisplayName
let memberAlias = ''
let memberRemark = ''
let memberGroupNickname = ''
const membersResult = await this.getGroupMembers(normalizedChatroomId)
if (membersResult.success && membersResult.data) {
const matchedMember = membersResult.data.find((item) =>
this.isSameAccountIdentity(item.username, normalizedMemberUsername)
)
if (matchedMember) {
memberDisplayName = matchedMember.displayName || defaultMemberDisplayName
memberAlias = matchedMember.alias || ''
memberRemark = matchedMember.remark || ''
memberGroupNickname = matchedMember.groupNickname || ''
}
}
const collected = await this.collectMessagesByMember(
normalizedChatroomId,
normalizedMemberUsername,
beginTimestamp,
endTimestampValue
)
if (!collected.success || !collected.data) {
return { success: false, error: collected.error || '获取成员消息失败' }
}
const records = collected.data.map((message, index) => ({
index: index + 1,
time: this.formatUnixTime(message.createTime),
sender: message.senderUsername || '',
messageType: this.getSimpleMessageTypeName(message.localType),
content: this.resolveExportMessageContent(message)
}))
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
const ext = path.extname(outputPath).toLowerCase()
if (ext === '.csv') {
const infoTitleRow = ['会话信息']
const infoRow = ['群聊ID', normalizedChatroomId, '', '群聊名称', groupName, '成员wxid', normalizedMemberUsername, '']
const memberRow = ['成员显示名', memberDisplayName, '成员备注', memberRemark, '群昵称', memberGroupNickname, '微信号', memberAlias]
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
const csvRows: string[][] = [infoTitleRow, infoRow, memberRow, metaRow, header]
for (const record of records) {
csvRows.push([String(record.index), record.time, record.sender, record.messageType, record.content])
}
const csvLines = csvRows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
const content = '\ufeff' + csvLines.join('\n')
fs.writeFileSync(outputPath, content, 'utf8')
} else {
const workbook = new ExcelJS.Workbook()
const worksheet = workbook.addWorksheet(this.sanitizeWorksheetName('成员消息记录'))
worksheet.getCell(1, 1).value = '会话信息'
worksheet.getCell(1, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getRow(1).height = 24
worksheet.getCell(2, 1).value = '群聊ID'
worksheet.getCell(2, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(2, 2, 2, 3)
worksheet.getCell(2, 2).value = normalizedChatroomId
worksheet.getCell(2, 4).value = '群聊名称'
worksheet.getCell(2, 4).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(2, 5).value = groupName
worksheet.getCell(2, 6).value = '成员wxid'
worksheet.getCell(2, 6).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(2, 7, 2, 8)
worksheet.getCell(2, 7).value = normalizedMemberUsername
worksheet.getCell(3, 1).value = '成员显示名'
worksheet.getCell(3, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(3, 2).value = memberDisplayName
worksheet.getCell(3, 3).value = '成员备注'
worksheet.getCell(3, 3).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(3, 4).value = memberRemark
worksheet.getCell(3, 5).value = '群昵称'
worksheet.getCell(3, 5).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(3, 6).value = memberGroupNickname
worksheet.getCell(3, 7).value = '微信号'
worksheet.getCell(3, 7).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(3, 8).value = memberAlias
worksheet.getCell(4, 1).value = '导出工具'
worksheet.getCell(4, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(4, 2).value = exportGenerator
worksheet.getCell(4, 3).value = '导出版本'
worksheet.getCell(4, 3).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(4, 4).value = exportVersion
worksheet.getCell(4, 5).value = '平台'
worksheet.getCell(4, 5).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(4, 6).value = exportPlatform
worksheet.getCell(4, 7).value = '导出时间'
worksheet.getCell(4, 7).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(4, 8).value = exportTime
const headerRow = worksheet.getRow(5)
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
header.forEach((title, index) => {
const cell = headerRow.getCell(index + 1)
cell.value = title
cell.font = { name: 'Calibri', bold: true, size: 11 }
})
headerRow.height = 22
worksheet.getColumn(1).width = 10
worksheet.getColumn(2).width = 22
worksheet.getColumn(3).width = 30
worksheet.getColumn(4).width = 16
worksheet.getColumn(5).width = 90
worksheet.getColumn(6).width = 16
worksheet.getColumn(7).width = 20
worksheet.getColumn(8).width = 24
let currentRow = 6
for (const record of records) {
const row = worksheet.getRow(currentRow)
row.getCell(1).value = record.index
row.getCell(2).value = record.time
row.getCell(3).value = record.sender
row.getCell(4).value = record.messageType
row.getCell(5).value = record.content
row.alignment = { vertical: 'top', wrapText: true }
currentRow += 1
}
await workbook.xlsx.writeFile(outputPath)
}
return { success: true, count: records.length }
} catch (e) {
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()

View File

@@ -1143,6 +1143,38 @@
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;

View File

@@ -46,6 +46,7 @@ function GroupAnalyticsPage() {
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 [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
// 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
@@ -323,6 +324,43 @@ function GroupAnalyticsPage() {
}
}
const handleExportMemberMessages = async (member: GroupMember) => {
if (!selectedGroup || !member || isExportingMemberMessages) 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
)
if (result.success) {
alert(`导出成功,共 ${result.count ?? 0} 条消息`)
} else {
alert(`导出失败:${result.error || '未知错误'}`)
}
} catch (e) {
console.error('导出成员消息失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExportingMemberMessages(false)
}
}
const handleCopy = async (text: string, field: string) => {
try {
await navigator.clipboard.writeText(text)
@@ -351,6 +389,16 @@ function GroupAnalyticsPage() {
<Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
</div>
<h3 className="member-display-name">{selectedMember.displayName}</h3>
<div className="member-action-row">
<button
className="export-member-btn"
onClick={() => handleExportMemberMessages(selectedMember)}
disabled={isExportingMemberMessages}
>
{isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
<span>{isExportingMemberMessages ? '导出中...' : '导出该成员全部消息'}</span>
</button>
</div>
<div className="member-details">
<div className="detail-row">
<span className="detail-label">ID</span>

View File

@@ -273,6 +273,17 @@ export interface ElectronAPI {
count?: number
error?: string
}>
exportGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
outputPath: string,
startTime?: number,
endTime?: number
) => Promise<{
success: boolean
count?: number
error?: string
}>
}
annualReport: {
getAvailableYears: () => Promise<{
@@ -431,7 +442,7 @@ export interface ElectronAPI {
success: boolean
error?: string
}>
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean } }) => Promise<{
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean }; selectedUsernames?: string[] }) => Promise<{
success: boolean
successCount?: number
error?: string