diff --git a/electron/main.ts b/electron/main.ts index 2ebe56b..bb9947d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 ( diff --git a/electron/preload.ts b/electron/preload.ts index b1c9c30..924bfd5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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, diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index 8d66ce9..1d322ae 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -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 + 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|)/i.exec(rawContent) + if (match && match[1]) { + return match[1].trim() + } + } + return '' } private parseSingleMessageRow(row: Record): 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() + 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 = {} + for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0 + const dailySet = new Set() + const textTypes = [1, 244813135921] + + const phraseCounts = new Map() + const emojiCounts = new Map() + + 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[] : [] + 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|)/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, diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index b1b0eab..e55c30f 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -834,11 +834,13 @@ } .member-export-panel, -.member-messages-panel { +.member-messages-panel, +.member-analytics-panel { display: flex; flex-direction: column; gap: 16px; min-height: 0; + flex: 1; .member-export-empty { padding: 20px; @@ -1521,29 +1523,73 @@ } } - .stats-cards { + .stats-overview { display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 12px; - margin-bottom: 20px; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; + padding-top: 10px; + } - .stat-card { - background: transparent; + .stat-card { + 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; - padding: 16px; - text-align: center; + color: var(--primary); + } - .value { - display: block; + .stat-info { + display: flex; + flex-direction: column; + gap: 4px; + + .stat-value { font-size: 24px; font-weight: 600; - color: var(--primary); - margin-bottom: 4px; + color: var(--text-primary); } - .label { + .stat-label { 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; } } } diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index db14c4d..a31dafc 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' 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 ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' @@ -37,7 +37,7 @@ interface GroupMessageRank { 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' interface MemberMessageExportOptions { @@ -167,6 +167,7 @@ function GroupAnalyticsPage() { const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) const [memberMessages, setMemberMessages] = useState([]) + const [memberAnalyticsData, setMemberAnalyticsData] = useState(null) const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) @@ -524,6 +525,7 @@ function GroupAnalyticsPage() { break } case 'memberMessages': { + resetMemberMessageState(false) updateBackgroundTask(taskId, { detail: '正在读取成员列表与消息', progressText: '成员消息' @@ -566,7 +568,55 @@ function GroupAnalyticsPage() { }) 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': { + setRankings([]) updateBackgroundTask(taskId, { detail: '正在计算群消息排行', progressText: '消息排行' @@ -584,6 +634,7 @@ function GroupAnalyticsPage() { break } case 'activeHours': { + setActiveHours({}) updateBackgroundTask(taskId, { detail: '正在计算群活跃时段', progressText: '活跃时段' @@ -601,6 +652,7 @@ function GroupAnalyticsPage() { break } case 'mediaStats': { + setMediaStats(null) updateBackgroundTask(taskId, { detail: '正在统计群消息类型', progressText: '消息类型' @@ -633,6 +685,12 @@ function GroupAnalyticsPage() { 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) => { return name.replace(/[<>:"/\\|?*]+/g, '_').trim() } @@ -764,6 +822,16 @@ function GroupAnalyticsPage() { 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 = () => { setShowMessageMemberSelect(false) setShowFormatSelect(false) @@ -982,6 +1050,14 @@ function GroupAnalyticsPage() { + + {showMessageMemberSelect && ( +
+
+ + setMessageMemberSearchKeyword(e.target.value)} + placeholder="搜索 wxid / 昵称 / 备注 / 微信号" + onClick={e => e.stopPropagation()} + /> +
+
+ {filteredMessageMemberOptions.length === 0 ? ( +
无匹配成员
+ ) : ( + filteredMessageMemberOptions.map(member => ( + + )) + )} +
+
+ )} + + + {memberAnalyticsData ? ( +
+
+
+
+
+ {formatNumber(memberAnalyticsData.statistics.sentMessages)} + 发信数量 +
+
+
+
+
+ {memberAnalyticsData.statistics.activeDays} + 活跃天数 +
+
+
+
+
+ + {formatDate(memberAnalyticsData.statistics.firstMessageTime)} - {formatDate(memberAnalyticsData.statistics.lastMessageTime)} + + 活跃周期 +
+
+
+ +
+
+

活跃时段

+
+ `${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%' }} + /> +
+
+ +
+

消息类型分布

+
+ item.value > 0), + label: { show: true, formatter: '{b} {d}%' } + }] + }} + style={{ height: '300px', width: '100%' }} + /> +
+
+
+
+

常用语

+
+ {memberAnalyticsData.commonPhrases && memberAnalyticsData.commonPhrases.length > 0 ? ( + memberAnalyticsData.commonPhrases.map((item: any, idx: number) => ( +
+ {item.phrase} + {item.count}次 +
+ )) + ) : ( + 暂无常用语数据 + )} +
+
+
+

常用表情

+
+ {memberAnalyticsData.commonEmojis && memberAnalyticsData.commonEmojis.length > 0 ? ( + memberAnalyticsData.commonEmojis.map((item: any, idx: number) => ( +
+ {item.emoji} + {item.count}次 +
+ )) + ) : ( + 暂无表情包数据 + )} +
+
+
+
+
+ ) : ( +
+ )} + + )} + + )} {selectedFunction === 'ranking' && (
{rankings.map((item, index) => ( diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8d96abe..41a578c 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -496,6 +496,28 @@ export interface ElectronAPI { } 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 + } + timeDistribution: Record + } + error?: string + }> getGroupMemberMessages: ( chatroomId: string, memberUsername: string,