feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能

This commit is contained in:
Leoluis0705
2026-03-25 18:24:05 +08:00
parent 2e1c0e6c54
commit 3214c2804e
6 changed files with 529 additions and 18 deletions

View File

@@ -2139,6 +2139,13 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
})
ipcMain.handle(
'groupAnalytics:getGroupMemberAnalytics',
async (_, chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.getGroupMemberAnalytics(chatroomId, memberUsername, startTime, endTime)
}
)
ipcMain.handle(
'groupAnalytics:getGroupMemberMessages',
async (

View File

@@ -297,6 +297,7 @@ 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),
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,

View File

@@ -5,6 +5,7 @@ import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
import type { Message } from './chatService'
import type { ChatStatistics } from './analyticsService'
export interface GroupChatInfo {
username: string
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
total: number
}
export interface GroupMemberAnalytics {
statistics: ChatStatistics
timeDistribution: Record<number, number>
commonPhrases?: Array<{ phrase: string; count: number }>
commonEmojis?: Array<{ emoji: string; count: number }>
}
export interface GroupMemberMessagesPage {
messages: Message[]
hasMore: boolean
@@ -791,13 +799,33 @@ class GroupAnalyticsService {
if (normalizedValue) return normalizedValue
}
}
// Fallback: fast extract from raw content to avoid full parse
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
if (rawContent) {
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
if (match && match[1]) {
return match[1].trim()
}
}
return ''
}
private parseSingleMessageRow(row: Record<string, any>): Message | null {
try {
const mapped = chatService.mapRowsToMessagesForApi([row])
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
if (Array.isArray(mapped) && mapped.length > 0) {
const msg = mapped[0]
if (!msg.localType) {
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
}
if (!msg.createTime) {
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
}
return msg
}
return null
} catch {
return null
}
@@ -1438,6 +1466,152 @@ class GroupAnalyticsService {
}
}
async getGroupMemberAnalytics(
chatroomId: string,
memberUsername: string,
startTime?: number,
endTime?: number
): Promise<{ success: boolean; data?: GroupMemberAnalytics; 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()
const batchSize = 10000
const senderMatchCache = new Map<string, boolean>()
const matchesTargetSender = (sender: string | null | undefined): boolean => {
const key = String(sender || '').trim().toLowerCase()
if (!key) return false
const cached = senderMatchCache.get(key)
if (typeof cached === 'boolean') return cached
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
senderMatchCache.set(key, matched)
return matched
}
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '创建游标失败' }
}
const cursor = cursorResult.cursor
const stats: ChatStatistics = {
totalMessages: 0,
textMessages: 0,
imageMessages: 0,
voiceMessages: 0,
videoMessages: 0,
emojiMessages: 0,
otherMessages: 0,
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
receivedMessages: 0, // No meaning here
firstMessageTime: null,
lastMessageTime: null,
activeDays: 0,
messageTypeCounts: {}
}
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
const dailySet = new Set<string>()
const textTypes = [1, 244813135921]
const phraseCounts = new Map<string, number>()
const emojiCounts = new Map<string, number>()
const myWxid = String(this.configService.get('myWxid') || '').trim()
try {
while (true) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) break
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) break
for (const row of rows) {
let senderFromRow = this.extractRowSenderUsername(row)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
if (isSend) {
senderFromRow = myWxid
}
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
continue
}
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
if (content) {
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
}
stats.totalMessages++
if (textTypes.includes(msgType)) {
stats.textMessages++
if (content) {
const text = content.trim()
if (text && text.length <= 20) {
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
}
const emojiMatches = text.match(/\[.*?\]/g)
if (emojiMatches) {
for (const em of emojiMatches) {
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
}
}
}
}
else if (msgType === 3) stats.imageMessages++
else if (msgType === 34) stats.voiceMessages++
else if (msgType === 43) stats.videoMessages++
else if (msgType === 47) stats.emojiMessages++
else stats.otherMessages++
stats.sentMessages++
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
if (createTime > 0) {
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
const d = new Date(createTime * 1000)
const hour = d.getHours()
hourlyDistribution[hour]++
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
}
}
if (!batch.hasMore) break
}
} finally {
await wcdbService.closeMessageCursor(cursor)
}
stats.activeDays = dailySet.size
const commonPhrases = Array.from(phraseCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([phrase, count]) => ({ phrase, count }))
const commonEmojis = Array.from(emojiCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([emoji, count]) => ({ emoji, count }))
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
} catch (e) {
return { success: false, error: String(e) }
}
}
async exportGroupMemberMessages(
chatroomId: string,
memberUsername: string,