mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能
This commit is contained in:
@@ -2139,6 +2139,13 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
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(
|
ipcMain.handle(
|
||||||
'groupAnalytics:getGroupMemberMessages',
|
'groupAnalytics:getGroupMemberMessages',
|
||||||
async (
|
async (
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
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),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
|
||||||
getGroupMemberMessages: (
|
getGroupMemberMessages: (
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ConfigService } from './config'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { chatService } from './chatService'
|
import { chatService } from './chatService'
|
||||||
import type { Message } from './chatService'
|
import type { Message } from './chatService'
|
||||||
|
import type { ChatStatistics } from './analyticsService'
|
||||||
|
|
||||||
export interface GroupChatInfo {
|
export interface GroupChatInfo {
|
||||||
username: string
|
username: string
|
||||||
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
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 {
|
export interface GroupMemberMessagesPage {
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
@@ -791,13 +799,33 @@ class GroupAnalyticsService {
|
|||||||
if (normalizedValue) return normalizedValue
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||||
try {
|
try {
|
||||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
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 {
|
} catch {
|
||||||
return null
|
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(
|
async exportGroupMemberMessages(
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
|
|||||||
@@ -834,11 +834,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-export-panel,
|
.member-export-panel,
|
||||||
.member-messages-panel {
|
.member-messages-panel,
|
||||||
|
.member-analytics-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
.member-export-empty {
|
.member-export-empty {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -1521,29 +1523,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-cards {
|
.stats-overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 24px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: transparent;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--primary-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
color: var(--primary);
|
||||||
text-align: center;
|
}
|
||||||
|
|
||||||
.value {
|
.stat-info {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.stat-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react'
|
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare, Calendar, PieChart, Hash, Smile } 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'
|
||||||
@@ -37,7 +37,7 @@ interface GroupMessageRank {
|
|||||||
messageCount: number
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats'
|
type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||||
|
|
||||||
interface MemberMessageExportOptions {
|
interface MemberMessageExportOptions {
|
||||||
@@ -167,6 +167,7 @@ function GroupAnalyticsPage() {
|
|||||||
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||||
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
||||||
const [memberMessages, setMemberMessages] = useState<Message[]>([])
|
const [memberMessages, setMemberMessages] = useState<Message[]>([])
|
||||||
|
const [memberAnalyticsData, setMemberAnalyticsData] = useState<any | null>(null)
|
||||||
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
|
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
|
||||||
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
|
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
|
||||||
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
|
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
|
||||||
@@ -524,6 +525,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'memberMessages': {
|
case 'memberMessages': {
|
||||||
|
resetMemberMessageState(false)
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在读取成员列表与消息',
|
detail: '正在读取成员列表与消息',
|
||||||
progressText: '成员消息'
|
progressText: '成员消息'
|
||||||
@@ -566,7 +568,55 @@ function GroupAnalyticsPage() {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'memberAnalytics': {
|
||||||
|
setMemberAnalyticsData(null)
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取成员列表与消息分析',
|
||||||
|
progressText: '成员分析'
|
||||||
|
})
|
||||||
|
const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: result.error || '获取成员列表失败' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMembers(result.data)
|
||||||
|
let targetMember = preferredMemberUsername
|
||||||
|
? result.data.find(m => m.username === preferredMemberUsername)
|
||||||
|
: result.data.find(m => m.username === selectedMessageMemberUsername)
|
||||||
|
if (!targetMember && result.data.length > 0) {
|
||||||
|
targetMember = result.data[0]
|
||||||
|
setSelectedMessageMemberUsername(targetMember.username)
|
||||||
|
}
|
||||||
|
if (!targetMember) {
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: '找不到目标成员' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: `正在分析 ${targetMember.displayName || targetMember.username} 的发言记录`,
|
||||||
|
progressText: '统计分析'
|
||||||
|
})
|
||||||
|
const analyticsResult = await window.electronAPI.groupAnalytics.getGroupMemberAnalytics(targetGroup.username, targetMember.username, startTime, endTime)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (analyticsResult.success && analyticsResult.data) {
|
||||||
|
setMemberAnalyticsData(analyticsResult.data)
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: `分析完成,共计 ${analyticsResult.data.statistics?.totalMessages || 0} 条消息`,
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: analyticsResult.error || '分析失败' })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'ranking': {
|
case 'ranking': {
|
||||||
|
setRankings([])
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在计算群消息排行',
|
detail: '正在计算群消息排行',
|
||||||
progressText: '消息排行'
|
progressText: '消息排行'
|
||||||
@@ -584,6 +634,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'activeHours': {
|
case 'activeHours': {
|
||||||
|
setActiveHours({})
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在计算群活跃时段',
|
detail: '正在计算群活跃时段',
|
||||||
progressText: '活跃时段'
|
progressText: '活跃时段'
|
||||||
@@ -601,6 +652,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'mediaStats': {
|
case 'mediaStats': {
|
||||||
|
setMediaStats(null)
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在统计群消息类型',
|
detail: '正在统计群消息类型',
|
||||||
progressText: '消息类型'
|
progressText: '消息类型'
|
||||||
@@ -633,6 +685,12 @@ function GroupAnalyticsPage() {
|
|||||||
return num.toLocaleString()
|
return num.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return '-'
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizeFileName = (name: string) => {
|
const sanitizeFileName = (name: string) => {
|
||||||
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
|
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
|
||||||
}
|
}
|
||||||
@@ -764,6 +822,16 @@ function GroupAnalyticsPage() {
|
|||||||
await loadFunctionData('memberMessages', selectedGroup, member.username)
|
await loadFunctionData('memberMessages', selectedGroup, member.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleViewMemberAnalyticsFromModal = async (member: GroupMember) => {
|
||||||
|
if (!selectedGroup) return
|
||||||
|
setSelectedMember(null)
|
||||||
|
setSelectedFunction('memberAnalytics')
|
||||||
|
setSelectedMessageMemberUsername(member.username)
|
||||||
|
setMessageMemberSearchKeyword('')
|
||||||
|
setShowMessageMemberSelect(false)
|
||||||
|
await loadFunctionData('memberAnalytics', selectedGroup, member.username)
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenMemberExportModal = () => {
|
const handleOpenMemberExportModal = () => {
|
||||||
setShowMessageMemberSelect(false)
|
setShowMessageMemberSelect(false)
|
||||||
setShowFormatSelect(false)
|
setShowFormatSelect(false)
|
||||||
@@ -982,6 +1050,14 @@ function GroupAnalyticsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="member-modal-primary-btn"
|
className="member-modal-primary-btn"
|
||||||
|
onClick={() => void handleViewMemberAnalyticsFromModal(selectedMember)}
|
||||||
|
>
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
<span>分析该成员聊天</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="member-modal-secondary-btn"
|
||||||
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
|
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
|
||||||
>
|
>
|
||||||
<MessageSquare size={16} />
|
<MessageSquare size={16} />
|
||||||
@@ -1080,6 +1156,11 @@ function GroupAnalyticsPage() {
|
|||||||
<span>成员消息筛选与导出</span>
|
<span>成员消息筛选与导出</span>
|
||||||
<small>按成员查看群聊消息,并支持导出当前成员记录</small>
|
<small>按成员查看群聊消息,并支持导出当前成员记录</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="function-card" onClick={() => handleFunctionSelect('memberAnalytics')}>
|
||||||
|
<PieChart size={32} />
|
||||||
|
<span>群成员详细分析</span>
|
||||||
|
<small>针对群聊内某一用户的群聊记录进行详细分析,如发信数量、活跃周期等</small>
|
||||||
|
</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>
|
||||||
@@ -1104,6 +1185,7 @@ function GroupAnalyticsPage() {
|
|||||||
switch (selectedFunction) {
|
switch (selectedFunction) {
|
||||||
case 'members': return '群成员查看'
|
case 'members': return '群成员查看'
|
||||||
case 'memberMessages': return '成员消息筛选与导出'
|
case 'memberMessages': return '成员消息筛选与导出'
|
||||||
|
case 'memberAnalytics': return '群成员详细分析'
|
||||||
case 'ranking': return '群聊发言排行'
|
case 'ranking': return '群聊发言排行'
|
||||||
case 'activeHours': return '群聊活跃时段'
|
case 'activeHours': return '群聊活跃时段'
|
||||||
case 'mediaStats': return '媒体内容统计'
|
case 'mediaStats': return '媒体内容统计'
|
||||||
@@ -1282,6 +1364,185 @@ function GroupAnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedFunction === 'memberAnalytics' && (
|
||||||
|
<div className="member-analytics-panel">
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="member-message-empty">暂无群成员数据,请先刷新。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="member-message-toolbar" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="member-export-field" ref={messageMemberSelectDropdownRef}>
|
||||||
|
<span>分析成员</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger member-message-select-trigger ${showMessageMemberSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => setShowMessageMemberSelect(prev => !prev)}
|
||||||
|
>
|
||||||
|
<div className="member-select-trigger-value">
|
||||||
|
<Avatar
|
||||||
|
src={selectedMessageMember?.avatarUrl}
|
||||||
|
name={selectedMessageMember?.displayName || selectedMessageMember?.username || '?'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<span className="select-value">{selectedMessageMember?.displayName || selectedMessageMember?.username || '请选择成员'}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showMessageMemberSelect && (
|
||||||
|
<div className="select-dropdown member-select-dropdown">
|
||||||
|
<div className="member-select-search">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={messageMemberSearchKeyword}
|
||||||
|
onChange={e => setMessageMemberSearchKeyword(e.target.value)}
|
||||||
|
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="member-select-options">
|
||||||
|
{filteredMessageMemberOptions.length === 0 ? (
|
||||||
|
<div className="member-select-empty">无匹配成员</div>
|
||||||
|
) : (
|
||||||
|
filteredMessageMemberOptions.map(member => (
|
||||||
|
<button
|
||||||
|
key={member.username}
|
||||||
|
type="button"
|
||||||
|
className={`select-option member-select-option ${selectedMessageMemberUsername === member.username ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMessageMemberUsername(member.username)
|
||||||
|
setShowMessageMemberSelect(false)
|
||||||
|
if (selectedGroup) {
|
||||||
|
void loadFunctionData('memberAnalytics', selectedGroup, member.username)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
|
||||||
|
<span className="member-option-main">{member.displayName || member.username}</span>
|
||||||
|
<span className="member-option-meta">
|
||||||
|
wxid: {member.username}
|
||||||
|
{member.alias ? ` · 微信号: ${member.alias}` : ''}
|
||||||
|
{member.remark ? ` · 备注: ${member.remark}` : ''}
|
||||||
|
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
|
||||||
|
{member.groupNickname ? ` · 群昵称: ${member.groupNickname}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{memberAnalyticsData ? (
|
||||||
|
<div className="analytics-content-scrollable" style={{ padding: '0', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<div className="stats-overview">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon"><MessageSquare size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{formatNumber(memberAnalyticsData.statistics.sentMessages)}</span>
|
||||||
|
<span className="stat-label">发信数量</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon"><Clock size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{memberAnalyticsData.statistics.activeDays}</span>
|
||||||
|
<span className="stat-label">活跃天数</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<div className="stat-icon"><Calendar size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">
|
||||||
|
{formatDate(memberAnalyticsData.statistics.firstMessageTime)} - {formatDate(memberAnalyticsData.statistics.lastMessageTime)}
|
||||||
|
</span>
|
||||||
|
<span className="stat-label">活跃周期</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="charts-grid">
|
||||||
|
<div className="chart-card wide">
|
||||||
|
<h3>活跃时段</h3>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ReactECharts
|
||||||
|
option={{
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: Array.from({ length: 24 }, (_, i) => `${i}时`) },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [{ type: 'bar', data: Array.from({ length: 24 }, (_, i) => memberAnalyticsData.timeDistribution[i] || 0), itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }]
|
||||||
|
}}
|
||||||
|
style={{ height: '300px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-card wide">
|
||||||
|
<h3>消息类型分布</h3>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ReactECharts
|
||||||
|
option={{
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
data: [
|
||||||
|
{ name: '文本', value: memberAnalyticsData.statistics.textMessages, itemStyle: { color: '#3b82f6' } },
|
||||||
|
{ name: '图片', value: memberAnalyticsData.statistics.imageMessages, itemStyle: { color: '#22c55e' } },
|
||||||
|
{ name: '语音', value: memberAnalyticsData.statistics.voiceMessages, itemStyle: { color: '#f97316' } },
|
||||||
|
{ name: '视频', value: memberAnalyticsData.statistics.videoMessages, itemStyle: { color: '#a855f7' } },
|
||||||
|
{ name: '表情', value: memberAnalyticsData.statistics.emojiMessages, itemStyle: { color: '#ec4899' } },
|
||||||
|
{ name: '其他', value: memberAnalyticsData.statistics.otherMessages, itemStyle: { color: '#6b7280' } }
|
||||||
|
].filter(item => item.value > 0),
|
||||||
|
label: { show: true, formatter: '{b} {d}%' }
|
||||||
|
}]
|
||||||
|
}}
|
||||||
|
style={{ height: '300px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-card wide" style={{ display: 'flex', gap: '32px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Hash size={18} /> 常用语</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{memberAnalyticsData.commonPhrases && memberAnalyticsData.commonPhrases.length > 0 ? (
|
||||||
|
memberAnalyticsData.commonPhrases.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
|
||||||
|
<span style={{ color: 'var(--text-primary)' }}>{item.phrase}</span>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}次</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}>暂无常用语数据</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Smile size={18} /> 常用表情</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{memberAnalyticsData.commonEmojis && memberAnalyticsData.commonEmojis.length > 0 ? (
|
||||||
|
memberAnalyticsData.commonEmojis.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
|
||||||
|
<span style={{ color: 'var(--text-primary)' }}>{item.emoji}</span>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}次</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}>暂无表情包数据</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="content-loading"><Loader2 size={32} className="spin" /></div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedFunction === 'ranking' && (
|
{selectedFunction === 'ranking' && (
|
||||||
<div className="rankings-list">
|
<div className="rankings-list">
|
||||||
{rankings.map((item, index) => (
|
{rankings.map((item, index) => (
|
||||||
|
|||||||
22
src/types/electron.d.ts
vendored
22
src/types/electron.d.ts
vendored
@@ -496,6 +496,28 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
statistics: {
|
||||||
|
totalMessages: number
|
||||||
|
textMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
otherMessages: number
|
||||||
|
sentMessages: number
|
||||||
|
receivedMessages: number
|
||||||
|
firstMessageTime: number | null
|
||||||
|
lastMessageTime: number | null
|
||||||
|
activeDays: number
|
||||||
|
messageTypeCounts: Record<number, number>
|
||||||
|
}
|
||||||
|
timeDistribution: Record<number, number>
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
getGroupMemberMessages: (
|
getGroupMemberMessages: (
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user