成员消息导出单拎出来

This commit is contained in:
xuncha
2026-02-19 17:40:41 +08:00
parent c1a393eaf6
commit e88c859f4f
4 changed files with 604 additions and 124 deletions

View File

@@ -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()

View File

@@ -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;

View File

@@ -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) => (

View File

@@ -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