mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 23:35:49 +00:00
feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Message[]>([])
|
||||
const [memberAnalyticsData, setMemberAnalyticsData] = useState<any | null>(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() {
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
@@ -1080,6 +1156,11 @@ function GroupAnalyticsPage() {
|
||||
<span>成员消息筛选与导出</span>
|
||||
<small>按成员查看群聊消息,并支持导出当前成员记录</small>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('memberAnalytics')}>
|
||||
<PieChart size={32} />
|
||||
<span>群成员详细分析</span>
|
||||
<small>针对群聊内某一用户的群聊记录进行详细分析,如发信数量、活跃周期等</small>
|
||||
</div>
|
||||
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
||||
<BarChart3 size={32} />
|
||||
<span>群聊发言排行</span>
|
||||
@@ -1104,6 +1185,7 @@ function GroupAnalyticsPage() {
|
||||
switch (selectedFunction) {
|
||||
case 'members': return '群成员查看'
|
||||
case 'memberMessages': return '成员消息筛选与导出'
|
||||
case 'memberAnalytics': return '群成员详细分析'
|
||||
case 'ranking': return '群聊发言排行'
|
||||
case 'activeHours': return '群聊活跃时段'
|
||||
case 'mediaStats': return '媒体内容统计'
|
||||
@@ -1282,6 +1364,185 @@ function GroupAnalyticsPage() {
|
||||
)}
|
||||
</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' && (
|
||||
<div className="rankings-list">
|
||||
{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
|
||||
}>
|
||||
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: (
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
|
||||
Reference in New Issue
Block a user