mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
成员消息导出单拎出来
This commit is contained in:
@@ -70,6 +70,8 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
|||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
|
senderUsername?: string
|
||||||
|
fileNameSuffix?: string
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
@@ -568,6 +570,52 @@ class ExportService {
|
|||||||
return `${payerName} 转账给 ${receiverName}`
|
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 {
|
private looksLikeBase64(s: string): boolean {
|
||||||
if (s.length % 4 !== 0) return false
|
if (s.length % 4 !== 0) return false
|
||||||
return /^[A-Za-z0-9+/=]+$/.test(s)
|
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
|
if (!content) return null
|
||||||
|
|
||||||
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
// 检查 XML 中的 type 标签(支持大 localType 的情况)
|
||||||
@@ -614,10 +670,11 @@ class ExportService {
|
|||||||
if (type === '2000') {
|
if (type === '2000') {
|
||||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
if (feedesc) {
|
||||||
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}`
|
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
|
||||||
}
|
}
|
||||||
return '[转账]'
|
return transferPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
if (type === '6') return title ? `[文件] ${title}` : '[文件]'
|
||||||
@@ -653,10 +710,11 @@ class ExportService {
|
|||||||
if (xmlType === '2000') {
|
if (xmlType === '2000') {
|
||||||
const feedesc = this.extractXmlValue(content, 'feedesc')
|
const feedesc = this.extractXmlValue(content, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
const payMemo = this.extractXmlValue(content, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
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,
|
content: string,
|
||||||
localType: number,
|
localType: number,
|
||||||
options: { exportVoiceAsText?: boolean },
|
options: { exportVoiceAsText?: boolean },
|
||||||
voiceTranscript?: string
|
voiceTranscript?: string,
|
||||||
|
myWxid?: string,
|
||||||
|
senderWxid?: string,
|
||||||
|
isSend?: boolean
|
||||||
): string {
|
): string {
|
||||||
const safeContent = content || ''
|
const safeContent = content || ''
|
||||||
|
|
||||||
@@ -745,8 +806,9 @@ class ExportService {
|
|||||||
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
|
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
|
||||||
const feedesc = this.extractXmlValue(normalized, 'feedesc')
|
const feedesc = this.extractXmlValue(normalized, 'feedesc')
|
||||||
const payMemo = this.extractXmlValue(normalized, 'pay_memo')
|
const payMemo = this.extractXmlValue(normalized, 'pay_memo')
|
||||||
|
const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend)
|
||||||
if (feedesc) {
|
if (feedesc) {
|
||||||
return payMemo ? `[转账]${feedesc} ${payMemo}` : `[转账]${feedesc}`
|
return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}`
|
||||||
}
|
}
|
||||||
const amount = this.extractAmountFromText(
|
const amount = this.extractAmountFromText(
|
||||||
[
|
[
|
||||||
@@ -759,7 +821,7 @@ class ExportService {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
)
|
)
|
||||||
return amount ? `[转账]${amount}` : '[转账]'
|
return amount ? `${transferPrefix}${amount}` : transferPrefix
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
|
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
|
||||||
@@ -1259,7 +1321,7 @@ class ExportService {
|
|||||||
return rendered.join('')
|
return rendered.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatHtmlMessageText(content: string, localType: number): string {
|
private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
|
|
||||||
if (localType === 1) {
|
if (localType === 1) {
|
||||||
@@ -1267,10 +1329,10 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (localType === 34) {
|
if (localType === 34) {
|
||||||
return this.parseMessageContent(content, localType) || ''
|
return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false })
|
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1688,7 +1750,8 @@ class ExportService {
|
|||||||
private async collectMessages(
|
private async collectMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
cleanedMyWxid: string,
|
cleanedMyWxid: string,
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null,
|
||||||
|
senderUsernameFilter?: string
|
||||||
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
||||||
const rows: any[] = []
|
const rows: any[] = []
|
||||||
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||||
@@ -1749,6 +1812,10 @@ class ExportService {
|
|||||||
} else {
|
} else {
|
||||||
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (senderUsernameFilter && !this.isSameWxid(actualSender, senderUsernameFilter)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
senderSet.add(actualSender)
|
senderSet.add(actualSender)
|
||||||
|
|
||||||
// 提取媒体相关字段
|
// 提取媒体相关字段
|
||||||
@@ -2177,7 +2244,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
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
|
const allMessages = collected.rows
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -2338,11 +2405,19 @@ class ExportService {
|
|||||||
// 使用预先转写的文字
|
// 使用预先转写的文字
|
||||||
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
} else {
|
} 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(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -2353,7 +2428,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
content = this.appendTransferDesc(content, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2564,7 +2639,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
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) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -2708,11 +2783,19 @@ class ExportService {
|
|||||||
} else if (mediaItem) {
|
} else if (mediaItem) {
|
||||||
content = mediaItem.relativePath
|
content = mediaItem.relativePath
|
||||||
} else {
|
} 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(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -2726,7 +2809,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
content = content.replace('[转账]', `[转账] (${transferDesc})`)
|
content = this.appendTransferDesc(content, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2890,7 +2973,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
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) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -3199,19 +3282,25 @@ class ExportService {
|
|||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
)
|
)
|
||||||
: (mediaItem?.relativePath
|
: (mediaItem?.relativePath
|
||||||
|| this.formatPlainExportContent(
|
|| this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
))
|
))
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
let enrichedContentValue = contentValue
|
let enrichedContentValue = contentValue
|
||||||
if (contentValue.startsWith('[转账]') && msg.content) {
|
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -3225,7 +3314,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3371,7 +3460,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
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) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -3510,19 +3599,25 @@ class ExportService {
|
|||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
)
|
)
|
||||||
: (mediaItem?.relativePath
|
: (mediaItem?.relativePath
|
||||||
|| this.formatPlainExportContent(
|
|| this.formatPlainExportContent(
|
||||||
msg.content,
|
msg.content,
|
||||||
msg.localType,
|
msg.localType,
|
||||||
options,
|
options,
|
||||||
voiceTranscriptMap.get(msg.localId)
|
voiceTranscriptMap.get(msg.localId),
|
||||||
|
cleanedMyWxid,
|
||||||
|
msg.senderUsername,
|
||||||
|
msg.isSend
|
||||||
))
|
))
|
||||||
|
|
||||||
// 转账消息:追加 "谁转账给谁" 信息
|
// 转账消息:追加 "谁转账给谁" 信息
|
||||||
let enrichedContentValue = contentValue
|
let enrichedContentValue = contentValue
|
||||||
if (contentValue.startsWith('[转账]') && msg.content) {
|
if (this.isTransferExportContent(contentValue) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -3536,7 +3631,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`)
|
enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3645,7 +3740,7 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
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) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
@@ -3808,7 +3903,15 @@ class ExportService {
|
|||||||
|
|
||||||
const msgText = msg.localType === 34 && options.exportVoiceAsText
|
const msgText = msg.localType === 34 && options.exportVoiceAsText
|
||||||
? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]')
|
? (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 src = this.getWeCloneSource(msg, typeName, mediaItem)
|
||||||
|
|
||||||
const row = [
|
const row = [
|
||||||
@@ -3979,7 +4082,7 @@ class ExportService {
|
|||||||
await this.ensureVoiceModel(onProgress)
|
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) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -4209,14 +4312,20 @@ class ExportService {
|
|||||||
const timeText = this.formatTimestamp(msg.createTime)
|
const timeText = this.formatTimestamp(msg.createTime)
|
||||||
const typeName = this.getMessageTypeName(msg.localType)
|
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) {
|
if (msg.localType === 34 && useVoiceTranscript) {
|
||||||
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
}
|
}
|
||||||
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
||||||
textContent = ''
|
textContent = ''
|
||||||
}
|
}
|
||||||
if (textContent.startsWith('[转账]') && msg.content) {
|
if (this.isTransferExportContent(textContent) && msg.content) {
|
||||||
const transferDesc = await this.resolveTransferDesc(
|
const transferDesc = await this.resolveTransferDesc(
|
||||||
msg.content,
|
msg.content,
|
||||||
cleanedMyWxid,
|
cleanedMyWxid,
|
||||||
@@ -4230,7 +4339,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
if (transferDesc) {
|
if (transferDesc) {
|
||||||
textContent = textContent.replace('[转账]', `[转账] (${transferDesc})`)
|
textContent = this.appendTransferDesc(textContent, transferDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4441,7 +4550,7 @@ class ExportService {
|
|||||||
|
|
||||||
for (const sessionId of sessionIds) {
|
for (const sessionId of sessionIds) {
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
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 msgs = collected.rows
|
||||||
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
||||||
const mediaMsgs = msgs.filter(m => {
|
const mediaMsgs = msgs.filter(m => {
|
||||||
@@ -4540,7 +4649,10 @@ class ExportService {
|
|||||||
phase: 'exporting'
|
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 useSessionFolder = sessionLayout === 'per-session'
|
||||||
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
|
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
|
||||||
|
|
||||||
@@ -4604,3 +4716,4 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const exportService = new ExportService()
|
export const exportService = new ExportService()
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
.rankings-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1143,38 +1296,6 @@
|
|||||||
text-align: center;
|
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 {
|
.member-details {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
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 { Avatar } from '../components/Avatar'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import DateRangePicker from '../components/DateRangePicker'
|
import DateRangePicker from '../components/DateRangePicker'
|
||||||
|
import * as configService from '../services/config'
|
||||||
import './GroupAnalyticsPage.scss'
|
import './GroupAnalyticsPage.scss'
|
||||||
|
|
||||||
interface GroupChatInfo {
|
interface GroupChatInfo {
|
||||||
@@ -28,7 +29,20 @@ interface GroupMessageRank {
|
|||||||
messageCount: number
|
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() {
|
function GroupAnalyticsPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -47,6 +61,19 @@ function GroupAnalyticsPage() {
|
|||||||
const [functionLoading, setFunctionLoading] = useState(false)
|
const [functionLoading, setFunctionLoading] = useState(false)
|
||||||
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||||
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
||||||
|
const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('')
|
||||||
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
|
const [memberExportOptions, setMemberExportOptions] = useState<MemberMessageExportOptions>({
|
||||||
|
format: 'excel',
|
||||||
|
exportAvatars: true,
|
||||||
|
exportMedia: false,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportVideos: true,
|
||||||
|
exportEmojis: true,
|
||||||
|
exportVoiceAsText: false,
|
||||||
|
displayNamePreference: 'remark'
|
||||||
|
})
|
||||||
|
|
||||||
// 成员详情弹框
|
// 成员详情弹框
|
||||||
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
|
||||||
@@ -75,9 +102,49 @@ function GroupAnalyticsPage() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
|
const memberExportFormatOptions = useMemo<Array<{ value: MemberExportFormat; label: string }>>(() => ([
|
||||||
|
{ 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(() => {
|
useEffect(() => {
|
||||||
loadGroups()
|
loadGroups()
|
||||||
}, [])
|
loadExportPath()
|
||||||
|
}, [loadGroups, loadExportPath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
preselectAppliedRef.current = false
|
preselectAppliedRef.current = false
|
||||||
@@ -91,6 +158,17 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}, [searchQuery, groups])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (preselectAppliedRef.current) return
|
if (preselectAppliedRef.current) return
|
||||||
if (groups.length === 0 || preselectGroupIds.length === 0) return
|
if (groups.length === 0 || preselectGroupIds.length === 0) return
|
||||||
@@ -126,27 +204,12 @@ function GroupAnalyticsPage() {
|
|||||||
|
|
||||||
// 日期范围变化时自动刷新
|
// 日期范围变化时自动刷新
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') {
|
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') {
|
||||||
setDateRangeReady(false)
|
setDateRangeReady(false)
|
||||||
loadFunctionData(selectedFunction)
|
loadFunctionData(selectedFunction)
|
||||||
}
|
}
|
||||||
}, [dateRangeReady])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setGroups([])
|
setGroups([])
|
||||||
@@ -158,15 +221,17 @@ function GroupAnalyticsPage() {
|
|||||||
setActiveHours({})
|
setActiveHours({})
|
||||||
setMediaStats(null)
|
setMediaStats(null)
|
||||||
void loadGroups()
|
void loadGroups()
|
||||||
|
void loadExportPath()
|
||||||
}
|
}
|
||||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
}, [loadGroups])
|
}, [loadExportPath, loadGroups])
|
||||||
|
|
||||||
const handleGroupSelect = (group: GroupChatInfo) => {
|
const handleGroupSelect = (group: GroupChatInfo) => {
|
||||||
if (selectedGroup?.username !== group.username) {
|
if (selectedGroup?.username !== group.username) {
|
||||||
setSelectedGroup(group)
|
setSelectedGroup(group)
|
||||||
setSelectedFunction(null)
|
setSelectedFunction(null)
|
||||||
|
setSelectedExportMemberUsername('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +257,11 @@ function GroupAnalyticsPage() {
|
|||||||
if (result.success && result.data) setMembers(result.data)
|
if (result.success && result.data) setMembers(result.data)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'memberExport': {
|
||||||
|
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
||||||
|
if (result.success && result.data) setMembers(result.data)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'ranking': {
|
case 'ranking': {
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
||||||
if (result.success && result.data) setRankings(result.data)
|
if (result.success && result.data) setRankings(result.data)
|
||||||
@@ -287,6 +357,7 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDateRangeComplete = () => {
|
const handleDateRangeComplete = () => {
|
||||||
|
if (selectedFunction === 'memberExport') return
|
||||||
setDateRangeReady(true)
|
setDateRangeReady(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,32 +395,75 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExportMemberMessages = async (member: GroupMember) => {
|
const handleMemberExportFormatChange = (format: MemberExportFormat) => {
|
||||||
if (!selectedGroup || !member || isExportingMemberMessages) return
|
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)
|
setIsExportingMemberMessages(true)
|
||||||
try {
|
try {
|
||||||
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
const hasDateRange = Boolean(startDate && endDate)
|
||||||
const memberName = member.displayName || member.username
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_${memberName}_消息记录`)
|
[selectedGroup.username],
|
||||||
const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
|
exportFolder,
|
||||||
const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx`
|
{
|
||||||
const saveResult = await window.electronAPI.dialog.saveFile({
|
format: memberExportOptions.format,
|
||||||
title: `导出 ${memberName} 的群聊消息`,
|
dateRange: hasDateRange
|
||||||
defaultPath,
|
? {
|
||||||
filters: [
|
start: Math.floor(new Date(startDate).getTime() / 1000),
|
||||||
{ name: 'Excel', extensions: ['xlsx'] },
|
end: Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000)
|
||||||
{ name: 'CSV', extensions: ['csv'] }
|
}
|
||||||
]
|
: null,
|
||||||
})
|
exportAvatars: memberExportOptions.exportAvatars,
|
||||||
if (!saveResult || saveResult.canceled || !saveResult.filePath) return
|
exportMedia: memberExportOptions.exportMedia,
|
||||||
|
exportImages: memberExportOptions.exportMedia && memberExportOptions.exportImages,
|
||||||
const result = await window.electronAPI.groupAnalytics.exportGroupMemberMessages(
|
exportVoices: memberExportOptions.exportMedia && memberExportOptions.exportVoices,
|
||||||
selectedGroup.username,
|
exportVideos: memberExportOptions.exportMedia && memberExportOptions.exportVideos,
|
||||||
member.username,
|
exportEmojis: memberExportOptions.exportMedia && memberExportOptions.exportEmojis,
|
||||||
saveResult.filePath
|
exportVoiceAsText: memberExportOptions.exportVoiceAsText,
|
||||||
|
sessionLayout: memberExportOptions.exportMedia ? 'per-session' : 'shared',
|
||||||
|
displayNamePreference: memberExportOptions.displayNamePreference,
|
||||||
|
senderUsername: member.username,
|
||||||
|
fileNameSuffix: sanitizeFileName(member.displayName || member.username)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
if (result.success) {
|
if (result.success && (result.successCount ?? 0) > 0) {
|
||||||
alert(`导出成功,共 ${result.count ?? 0} 条消息`)
|
alert(`导出成功:${member.displayName || member.username}`)
|
||||||
} else {
|
} else {
|
||||||
alert(`导出失败:${result.error || '未知错误'}`)
|
alert(`导出失败:${result.error || '未知错误'}`)
|
||||||
}
|
}
|
||||||
@@ -389,16 +503,6 @@ function GroupAnalyticsPage() {
|
|||||||
<Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
|
<Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="member-display-name">{selectedMember.displayName}</h3>
|
<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="member-details">
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
<span className="detail-label">微信ID</span>
|
<span className="detail-label">微信ID</span>
|
||||||
@@ -527,6 +631,10 @@ function GroupAnalyticsPage() {
|
|||||||
<Users size={32} />
|
<Users size={32} />
|
||||||
<span>群成员查看</span>
|
<span>群成员查看</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
|
||||||
|
<Download size={32} />
|
||||||
|
<span>成员消息导出</span>
|
||||||
|
</div>
|
||||||
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
||||||
<BarChart3 size={32} />
|
<BarChart3 size={32} />
|
||||||
<span>群聊发言排行</span>
|
<span>群聊发言排行</span>
|
||||||
@@ -547,6 +655,7 @@ function GroupAnalyticsPage() {
|
|||||||
const getFunctionTitle = () => {
|
const getFunctionTitle = () => {
|
||||||
switch (selectedFunction) {
|
switch (selectedFunction) {
|
||||||
case 'members': return '群成员查看'
|
case 'members': return '群成员查看'
|
||||||
|
case 'memberExport': return '成员消息导出'
|
||||||
case 'ranking': return '群聊发言排行'
|
case 'ranking': return '群聊发言排行'
|
||||||
case 'activeHours': return '群聊活跃时段'
|
case 'activeHours': return '群聊活跃时段'
|
||||||
case 'mediaStats': return '媒体内容统计'
|
case 'mediaStats': return '媒体内容统计'
|
||||||
@@ -602,6 +711,140 @@ function GroupAnalyticsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedFunction === 'memberExport' && (
|
||||||
|
<div className="member-export-panel">
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="member-export-empty">暂无群成员数据,请先刷新。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="member-export-grid">
|
||||||
|
<label className="member-export-field">
|
||||||
|
<span>导出成员</span>
|
||||||
|
<select
|
||||||
|
value={selectedExportMemberUsername}
|
||||||
|
onChange={e => setSelectedExportMemberUsername(e.target.value)}
|
||||||
|
>
|
||||||
|
{members.map(member => (
|
||||||
|
<option key={member.username} value={member.username}>
|
||||||
|
{member.displayName || member.username}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="member-export-field">
|
||||||
|
<span>导出格式</span>
|
||||||
|
<select
|
||||||
|
value={memberExportOptions.format}
|
||||||
|
onChange={e => handleMemberExportFormatChange(e.target.value as MemberExportFormat)}
|
||||||
|
>
|
||||||
|
{memberExportFormatOptions.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="member-export-field member-export-folder">
|
||||||
|
<span>导出目录</span>
|
||||||
|
<div className="member-export-folder-row">
|
||||||
|
<input value={exportFolder} readOnly placeholder="请选择导出目录" />
|
||||||
|
<button type="button" onClick={handleChooseExportFolder}>
|
||||||
|
选择目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="member-export-options">
|
||||||
|
<label className="member-export-switch">
|
||||||
|
<span>导出媒体文件</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberExportOptions.exportMedia}
|
||||||
|
onChange={e => setMemberExportOptions(prev => ({ ...prev, exportMedia: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="member-export-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberExportOptions.exportImages}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onChange={e => setMemberExportOptions(prev => ({ ...prev, exportImages: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>图片</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberExportOptions.exportVoices}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVoices: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>语音</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberExportOptions.exportVideos}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVideos: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>视频</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberExportOptions.exportEmojis}
|
||||||
|
disabled={!memberExportOptions.exportMedia}
|
||||||
|
onChange={e => setMemberExportOptions(prev => ({ ...prev, exportEmojis: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>表情</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberExportOptions.exportVoiceAsText}
|
||||||
|
onChange={e => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>语音转文字</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={memberExportOptions.exportAvatars}
|
||||||
|
onChange={e => setMemberExportOptions(prev => ({ ...prev, exportAvatars: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span>导出头像</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="member-export-field">
|
||||||
|
<span>显示名称规则</span>
|
||||||
|
<select
|
||||||
|
value={memberExportOptions.displayNamePreference}
|
||||||
|
onChange={e => setMemberExportOptions(prev => ({ ...prev, displayNamePreference: e.target.value as MemberMessageExportOptions['displayNamePreference'] }))}
|
||||||
|
>
|
||||||
|
<option value="group-nickname">群昵称优先</option>
|
||||||
|
<option value="remark">备注优先</option>
|
||||||
|
<option value="nickname">微信昵称</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="member-export-actions">
|
||||||
|
<button
|
||||||
|
className="member-export-start-btn"
|
||||||
|
onClick={handleExportMemberMessages}
|
||||||
|
disabled={isExportingMemberMessages || !selectedExportMemberUsername || !exportFolder}
|
||||||
|
>
|
||||||
|
{isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
|
||||||
|
<span>{isExportingMemberMessages ? '导出中...' : '开始导出'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedFunction === 'ranking' && (
|
{selectedFunction === 'ranking' && (
|
||||||
<div className="rankings-list">
|
<div className="rankings-list">
|
||||||
{rankings.map((item, index) => (
|
{rankings.map((item, index) => (
|
||||||
|
|||||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -511,12 +511,15 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportOptions {
|
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
|
dateRange?: { start: number; end: number } | null
|
||||||
|
senderUsername?: string
|
||||||
|
fileNameSuffix?: string
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
exportVoices?: boolean
|
exportVoices?: boolean
|
||||||
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user