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, Calendar, PieChart, Hash, Smile } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' import ChatAnalysisHeader from '../components/ChatAnalysisHeader' import * as configService from '../services/config' import type { Message } from '../types/models' import { finishBackgroundTask, isBackgroundTaskCancelRequested, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' import './GroupAnalyticsPage.scss' interface GroupChatInfo { username: string displayName: string memberCount: number avatarUrl?: string } interface GroupMember { username: string displayName: string avatarUrl?: string nickname?: string alias?: string remark?: string groupNickname?: string } interface GroupMessageRank { member: GroupMember messageCount: number } type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats' type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-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' } interface MemberExportFormatOption { value: MemberExportFormat label: string desc: string } interface GroupMemberMessagesPage { messages: Message[] hasMore: boolean nextCursor: number } const MEMBER_MESSAGE_PAGE_SIZE = 40 const filterMembersByKeyword = (members: GroupMember[], keyword: string) => { const normalizedKeyword = keyword.trim().toLowerCase() if (!normalizedKeyword) return members return members.filter(member => { const fields = [ member.username, member.displayName, member.nickname, member.remark, member.alias, member.groupNickname ] return fields.some(field => String(field || '').toLowerCase().includes(normalizedKeyword)) }) } const formatMemberMessageTime = (createTime: number) => { if (!createTime) return '-' return new Date(createTime * 1000).toLocaleString('zh-CN', { hour12: false }) } const getMemberMessageTypeLabel = (message: Message) => { switch (message.localType) { case 1: return '文本' case 3: return '图片' case 34: return '语音' case 42: return '名片' case 43: return '视频' case 47: return '表情' case 48: return '位置' case 49: return message.fileName ? '文件' : '链接' case 50: return '通话' case 10000: case 10002: return '系统' default: return `类型 ${message.localType}` } } const getMemberMessagePreview = (message: Message) => { const text = (message.parsedContent || message.content || message.rawContent || '').trim() switch (message.localType) { case 1: case 10000: case 10002: return text || '[空文本]' case 3: return text || '[图片]' case 34: return message.voiceDurationSeconds ? `[语音] ${message.voiceDurationSeconds} 秒` : '[语音]' case 42: return `[名片] ${message.cardNickname || message.cardUsername || text || '联系人名片'}` case 43: return text || '[视频]' case 47: return text || '[表情]' case 48: return `[位置] ${message.locationPoiname || message.locationLabel || text || '位置消息'}` case 49: if (message.fileName) return `[文件] ${message.fileName}` if (message.linkTitle) return `[链接] ${message.linkTitle}` return text || '[链接/文件]' case 50: return text || '[通话]' default: return text || `[消息类型 ${message.localType}]` } } function GroupAnalyticsPage() { const location = useLocation() const [groups, setGroups] = useState([]) const [filteredGroups, setFilteredGroups] = useState([]) const [isLoading, setIsLoading] = useState(true) const [selectedGroupId, setSelectedGroupId] = useState(null) const [selectedFunction, setSelectedFunction] = useState(null) const [searchQuery, setSearchQuery] = useState('') const selectedGroup = useMemo( () => (selectedGroupId ? groups.find(group => group.username === selectedGroupId) || null : null), [groups, selectedGroupId] ) // 功能数据 const [members, setMembers] = useState([]) const [rankings, setRankings] = useState([]) const [activeHours, setActiveHours] = useState>({}) const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null) const [functionLoading, setFunctionLoading] = useState(false) const [isExportingMembers, setIsExportingMembers] = useState(false) const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false) const [memberMessages, setMemberMessages] = useState([]) const [memberAnalyticsData, setMemberAnalyticsData] = useState(null) const [analyticsError, setAnalyticsError] = useState(null) const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false) const [memberMessagesCursor, setMemberMessagesCursor] = useState(0) const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false) const [selectedMessageMemberUsername, setSelectedMessageMemberUsername] = useState('') const [exportFolder, setExportFolder] = useState('') const [memberExportOptions, setMemberExportOptions] = useState({ format: 'excel', exportAvatars: true, exportMedia: false, exportImages: true, exportVoices: true, exportVideos: true, exportEmojis: true, exportVoiceAsText: false, displayNamePreference: 'remark' }) // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) const [showMemberExportModal, setShowMemberExportModal] = useState(false) const [exportResultDialog, setExportResultDialog] = useState<{ title: string message: string tone: 'success' | 'error' } | null>(null) const [showMessageMemberSelect, setShowMessageMemberSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) const [messageMemberSearchKeyword, setMessageMemberSearchKeyword] = useState('') const messageMemberSelectDropdownRef = useRef(null) const formatDropdownRef = useRef(null) const displayNameDropdownRef = useRef(null) // 时间范围 const [startDate, setStartDate] = useState('') const [endDate, setEndDate] = useState('') const [dateRangeReady, setDateRangeReady] = useState(false) // 拖动调整宽度 const [sidebarWidth, setSidebarWidth] = useState(300) const [isResizing, setIsResizing] = useState(false) const containerRef = useRef(null) const preselectAppliedRef = useRef(false) const preselectGroupIds = useMemo(() => { const state = location.state as { preselectGroupIds?: unknown; preselectGroupId?: unknown } | null const rawList = Array.isArray(state?.preselectGroupIds) ? state.preselectGroupIds : (typeof state?.preselectGroupId === 'string' ? [state.preselectGroupId] : []) return rawList .filter((item): item is string => typeof item === 'string') .map(item => item.trim()) .filter(Boolean) }, [location.state]) const memberExportFormatOptions = useMemo(() => ([ { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, { value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' }, { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' } ]), []) const displayNameOptions = useMemo>(() => ([ { value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' }, { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } ]), []) const selectedMessageMember = useMemo( () => members.find(member => member.username === selectedMessageMemberUsername) || null, [members, selectedMessageMemberUsername] ) const selectedFormatOption = useMemo( () => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0], [memberExportFormatOptions, memberExportOptions.format] ) const selectedDisplayNameOption = useMemo( () => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0], [displayNameOptions, memberExportOptions.displayNamePreference] ) const filteredMessageMemberOptions = useMemo(() => { return filterMembersByKeyword(members, messageMemberSearchKeyword) }, [members, messageMemberSearchKeyword]) const resetMemberMessageState = useCallback((clearSelection = true) => { setMemberMessages([]) setMemberMessagesHasMore(false) setMemberMessagesCursor(0) setMemberMessagesLoadingMore(false) setShowMessageMemberSelect(false) if (clearSelection) { setSelectedMessageMemberUsername('') setMessageMemberSearchKeyword('') } }, []) const getSelectedTimeRange = () => ({ startTime: startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined, endTime: endDate ? Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000) : undefined }) 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 () => { const taskId = registerBackgroundTask({ sourcePage: 'groupAnalytics', title: '群列表加载', detail: '正在读取群聊列表', progressText: '群聊列表', cancelable: true }) setIsLoading(true) try { const result = await window.electronAPI.groupAnalytics.getGroupChats() if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群聊列表结果未继续写入' }) return } if (result.success && result.data) { setGroups(result.data) setFilteredGroups(result.data) finishBackgroundTask(taskId, 'completed', { detail: `群聊列表加载完成,共 ${result.data.length} 个群`, progressText: `${result.data.length} 个群` }) } else { finishBackgroundTask(taskId, 'failed', { detail: result.error || '加载群聊列表失败' }) } } catch (e) { console.error(e) finishBackgroundTask(taskId, 'failed', { detail: String(e) }) } finally { setIsLoading(false) } }, []) useEffect(() => { loadGroups() loadExportPath() }, [loadGroups, loadExportPath]) useEffect(() => { preselectAppliedRef.current = false }, [location.key, preselectGroupIds]) useEffect(() => { if (searchQuery) { setFilteredGroups(groups.filter(g => g.displayName.toLowerCase().includes(searchQuery.toLowerCase()))) } else { setFilteredGroups(groups) } }, [searchQuery, groups]) useEffect(() => { if (members.length === 0) { setSelectedMessageMemberUsername('') return } const messageExists = members.some(member => member.username === selectedMessageMemberUsername) if (!messageExists) { setSelectedMessageMemberUsername(members[0].username) } }, [members, selectedMessageMemberUsername]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node if (showMessageMemberSelect && messageMemberSelectDropdownRef.current && !messageMemberSelectDropdownRef.current.contains(target)) { setShowMessageMemberSelect(false) } if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) { setShowFormatSelect(false) } if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) { setShowDisplayNameSelect(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [showDisplayNameSelect, showFormatSelect, showMessageMemberSelect]) useEffect(() => { if (preselectAppliedRef.current) return if (groups.length === 0 || preselectGroupIds.length === 0) return const matchedGroup = groups.find(group => preselectGroupIds.includes(group.username)) preselectAppliedRef.current = true if (matchedGroup) { setSelectedGroupId(matchedGroup.username) setSelectedFunction(null) setSearchQuery('') } }, [groups, preselectGroupIds]) // 拖动调整宽度 useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isResizing || !containerRef.current) return const containerRect = containerRef.current.getBoundingClientRect() const newWidth = e.clientX - containerRect.left setSidebarWidth(Math.max(250, Math.min(450, newWidth))) } const handleMouseUp = () => setIsResizing(false) if (isResizing) { document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) } return () => { document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } }, [isResizing]) // 日期范围变化时自动刷新 useEffect(() => { if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { setDateRangeReady(false) loadFunctionData(selectedFunction) } }, [dateRangeReady]) useEffect(() => { const handleChange = () => { setGroups([]) setFilteredGroups([]) setSelectedGroupId(null) setSelectedFunction(null) setMembers([]) resetMemberMessageState() setShowMemberExportModal(false) setRankings([]) setActiveHours({}) setMediaStats(null) void loadGroups() void loadExportPath() } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [loadExportPath, loadGroups, resetMemberMessageState]) const handleGroupSelect = (group: GroupChatInfo) => { setSelectedGroupId(group.username) setSelectedFunction(null) setSelectedMember(null) setShowMemberExportModal(false) resetMemberMessageState() setShowFormatSelect(false) setShowDisplayNameSelect(false) } const loadMemberMessagesPage = async ( targetGroup: GroupChatInfo, memberUsername: string, options?: { cursor?: number append?: boolean startTime?: number endTime?: number } ): Promise => { const result = await window.electronAPI.groupAnalytics.getGroupMemberMessages(targetGroup.username, memberUsername, { startTime: options?.startTime, endTime: options?.endTime, limit: MEMBER_MESSAGE_PAGE_SIZE, cursor: options?.cursor && options.cursor > 0 ? options.cursor : undefined }) if (!result.success || !result.data) { throw new Error(result.error || '读取成员消息失败') } setMemberMessages(prev => { if (!options?.append) return result.data!.messages const next = [...prev] const seen = new Set(prev.map(message => message.messageKey)) for (const message of result.data!.messages) { if (seen.has(message.messageKey)) continue seen.add(message.messageKey) next.push(message) } return next }) setMemberMessagesHasMore(result.data.hasMore) setMemberMessagesCursor(result.data.nextCursor || 0) return result.data } const handleFunctionSelect = async (func: AnalysisFunction) => { if (!selectedGroup) return setSelectedFunction(func) await loadFunctionData(func) } const loadFunctionData = async ( func: AnalysisFunction, targetGroup: GroupChatInfo | null = selectedGroup, preferredMemberUsername?: string ) => { if (!targetGroup) return const taskId = registerBackgroundTask({ sourcePage: 'groupAnalytics', title: `群分析:${func}`, detail: `正在读取 ${targetGroup.displayName || targetGroup.username} 的分析数据`, progressText: func, cancelable: true }) setFunctionLoading(true) const { startTime, endTime } = getSelectedTimeRange() try { switch (func) { case 'members': { 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) setMembers(result.data) finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { detail: result.success ? `群成员列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取群成员列表失败'), progressText: result.success ? `${result.data?.length || 0} 人` : '失败' }) break } case 'memberMessages': { resetMemberMessageState(false) 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) { resetMemberMessageState() finishBackgroundTask(taskId, 'failed', { detail: result.error || '读取群成员失败', progressText: '失败' }) break } setMembers(result.data) const targetMember = result.data.find(member => member.username === (preferredMemberUsername || selectedMessageMemberUsername)) || result.data[0] if (!targetMember) { resetMemberMessageState() finishBackgroundTask(taskId, 'completed', { detail: '当前群暂无可用成员数据', progressText: '0 条' }) break } setSelectedMessageMemberUsername(targetMember.username) updateBackgroundTask(taskId, { detail: `正在读取 ${targetMember.displayName || targetMember.username} 的发言记录`, progressText: '消息分页' }) const page = await loadMemberMessagesPage(targetGroup, targetMember.username, { startTime, endTime }) finishBackgroundTask(taskId, 'completed', { detail: `成员消息加载完成,已读取 ${page.messages.length} 条`, progressText: `${page.messages.length} 条` }) break } case 'memberAnalytics': { setMemberAnalyticsData(null) setAnalyticsError(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 { setAnalyticsError(analyticsResult.error || '分析失败') finishBackgroundTask(taskId, 'failed', { detail: analyticsResult.error || '分析失败' }) } break } case 'ranking': { setRankings([]) updateBackgroundTask(taskId, { detail: '正在计算群消息排行', progressText: '消息排行' }) const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(targetGroup.username, 20, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' }) return } if (result.success && result.data) setRankings(result.data) finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { detail: result.success ? `群消息排行加载完成,共 ${result.data?.length || 0} 条` : (result.error || '读取群消息排行失败'), progressText: result.success ? `${result.data?.length || 0} 条` : '失败' }) break } case 'activeHours': { setActiveHours({}) updateBackgroundTask(taskId, { detail: '正在计算群活跃时段', progressText: '活跃时段' }) const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(targetGroup.username, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' }) return } if (result.success && result.data) setActiveHours(result.data.hourlyDistribution) finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { detail: result.success ? '群活跃时段加载完成' : (result.error || '读取群活跃时段失败'), progressText: result.success ? '24 小时分布' : '失败' }) break } case 'mediaStats': { setMediaStats(null) updateBackgroundTask(taskId, { detail: '正在统计群消息类型', progressText: '消息类型' }) const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(targetGroup.username, startTime, endTime) if (isBackgroundTaskCancelRequested(taskId)) { finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' }) return } if (result.success && result.data) setMediaStats(result.data) finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { detail: result.success ? `群消息类型统计完成,共 ${result.data?.total || 0} 条` : (result.error || '读取群消息类型统计失败'), progressText: result.success ? `${result.data?.total || 0} 条` : '失败' }) break } } } catch (e) { console.error(e) finishBackgroundTask(taskId, 'failed', { detail: String(e) }) } finally { setFunctionLoading(false) } } const formatNumber = (num: number) => { if (num >= 10000) return (num / 10000).toFixed(1) + '万' 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() } const getHourlyOption = () => { const hours = Array.from({ length: 24 }, (_, i) => i) const data = hours.map(h => activeHours[h] || 0) return { tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: hours.map(h => `${h}时`) }, yAxis: { type: 'value' }, series: [{ type: 'bar', data, itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }] } } const getMediaOption = () => { if (!mediaStats || mediaStats.typeCounts.length === 0) return {} // 定义颜色映射 const colorMap: Record = { 1: '#3b82f6', // 文本 - 蓝色 3: '#22c55e', // 图片 - 绿色 34: '#f97316', // 语音 - 橙色 43: '#a855f7', // 视频 - 紫色 47: '#ec4899', // 表情包 - 粉色 49: '#14b8a6', // 链接/文件 - 青色 [-1]: '#6b7280', // 其他 - 灰色 } const data = mediaStats.typeCounts.map(item => ({ name: item.name, value: item.count, itemStyle: { color: colorMap[item.type] || '#6b7280' } })) return { tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, series: [{ type: 'pie', radius: ['40%', '70%'], center: ['50%', '50%'], itemStyle: { borderRadius: 8, borderColor: 'rgba(255,255,255,0.1)', borderWidth: 2 }, label: { show: true, formatter: (params: { name: string; percent: number }) => { // 只显示占比大于3%的标签 return params.percent > 3 ? `${params.name}\n${params.percent.toFixed(1)}%` : '' }, color: '#fff' }, labelLine: { show: true, length: 10, length2: 10 }, data }] } } const handleRefresh = () => { if (selectedFunction) { void loadFunctionData(selectedFunction) } } const handleDateRangeComplete = () => { setDateRangeReady(true) } const handleMemberClick = (member: GroupMember) => { setSelectedMember(member) setCopiedField(null) } const openSelectedGroupChat = () => { if (!selectedGroup) return void window.electronAPI.window.openSessionChatWindow(selectedGroup.username, { source: 'chat', initialDisplayName: selectedGroup.displayName || selectedGroup.username, initialAvatarUrl: selectedGroup.avatarUrl, initialContactType: 'group' }) } const handleMessageMemberSelect = async (memberUsername: string) => { if (!selectedGroup) return setSelectedMessageMemberUsername(memberUsername) setMessageMemberSearchKeyword('') setShowMessageMemberSelect(false) setFunctionLoading(true) try { const { startTime, endTime } = getSelectedTimeRange() await loadMemberMessagesPage(selectedGroup, memberUsername, { startTime, endTime }) } catch (e) { console.error('读取成员消息失败:', e) alert(`读取成员消息失败:${String(e)}`) } finally { setFunctionLoading(false) } } const handleLoadMoreMemberMessages = async () => { if (!selectedGroup || !selectedMessageMemberUsername || !memberMessagesHasMore || memberMessagesLoadingMore) return setMemberMessagesLoadingMore(true) try { const { startTime, endTime } = getSelectedTimeRange() await loadMemberMessagesPage(selectedGroup, selectedMessageMemberUsername, { cursor: memberMessagesCursor, append: true, startTime, endTime }) } catch (e) { console.error('加载更多成员消息失败:', e) alert(`加载更多成员消息失败:${String(e)}`) } finally { setMemberMessagesLoadingMore(false) } } const handleViewMemberMessagesFromModal = async (member: GroupMember) => { if (!selectedGroup) return setSelectedMember(null) setSelectedFunction('memberMessages') setSelectedMessageMemberUsername(member.username) setMessageMemberSearchKeyword('') setShowMessageMemberSelect(false) 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) setShowDisplayNameSelect(false) setShowMemberExportModal(true) } const handleExportMembers = async () => { if (!selectedGroup || isExportingMembers) return setIsExportingMembers(true) try { const downloadsPath = await window.electronAPI.app.getDownloadsPath() const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_群成员列表`) const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/' const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx` const saveResult = await window.electronAPI.dialog.saveFile({ title: '导出群成员列表', defaultPath, filters: [{ name: 'Excel', extensions: ['xlsx'] }] }) if (!saveResult || saveResult.canceled || !saveResult.filePath) return const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath) if (result.success) { setExportResultDialog({ title: '导出成功', message: `共导出 ${result.count ?? members.length} 人`, tone: 'success' }) } else { setExportResultDialog({ title: '导出失败', message: result.error || '未知错误', tone: 'error' }) } } catch (e) { console.error('导出群成员失败:', e) setExportResultDialog({ title: '导出失败', message: String(e), tone: 'error' }) } finally { setIsExportingMembers(false) } } const handleMemberExportFormatChange = (format: MemberExportFormat) => { 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 || !selectedMessageMemberUsername || !exportFolder || isExportingMemberMessages) return const member = members.find(item => item.username === selectedMessageMemberUsername) if (!member) { alert('请先选择成员') return } setIsExportingMemberMessages(true) try { const hasDateRange = Boolean(startDate && endDate) const result = await window.electronAPI.export.exportSessions( [selectedGroup.username], exportFolder, { format: memberExportOptions.format, dateRange: hasDateRange ? { start: Math.floor(new Date(startDate).getTime() / 1000), end: Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000) } : null, exportAvatars: memberExportOptions.exportAvatars, exportMedia: memberExportOptions.exportMedia, exportImages: memberExportOptions.exportMedia && memberExportOptions.exportImages, exportVoices: memberExportOptions.exportMedia && memberExportOptions.exportVoices, exportVideos: memberExportOptions.exportMedia && memberExportOptions.exportVideos, exportEmojis: memberExportOptions.exportMedia && memberExportOptions.exportEmojis, exportVoiceAsText: memberExportOptions.exportVoiceAsText, sessionLayout: memberExportOptions.exportMedia ? 'per-session' : 'shared', displayNamePreference: memberExportOptions.displayNamePreference, senderUsername: member.username, fileNameSuffix: sanitizeFileName(member.displayName || member.username) } ) if (result.success && (result.successCount ?? 0) > 0) { setShowMemberExportModal(false) setExportResultDialog({ title: '导出成功', message: `已导出 ${member.displayName || member.username}`, tone: 'success' }) } else { setExportResultDialog({ title: '导出失败', message: result.error || '未知错误', tone: 'error' }) } } catch (e) { console.error('导出成员消息失败:', e) setExportResultDialog({ title: '导出失败', message: String(e), tone: 'error' }) } finally { setIsExportingMemberMessages(false) } } const handleCopy = async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) setCopiedField(field) setTimeout(() => setCopiedField(null), 2000) } catch (e) { console.error('复制失败:', e) } } const renderMemberModal = () => { if (!selectedMember) return null const nickname = (selectedMember.nickname || '').trim() const alias = (selectedMember.alias || '').trim() const remark = (selectedMember.remark || '').trim() const groupNickname = (selectedMember.groupNickname || '').trim() return (
setSelectedMember(null)}>
e.stopPropagation()}>

{selectedMember.displayName}

微信ID {selectedMember.username}
昵称 {nickname || '未设置'} {nickname && ( )}
{alias && (
微信号 {alias}
)} {groupNickname && (
群昵称 {groupNickname}
)} {remark && (
备注 {remark}
)}
) } const renderGroupList = () => (
setSearchQuery(e.target.value)} /> {searchQuery && ( )}
{isLoading ? (
{[1, 2, 3, 4, 5].map(i => (
))}
) : filteredGroups.length === 0 ? (

{searchQuery ? '未找到匹配的群聊' : '暂无群聊数据'}

) : ( filteredGroups.map(group => (
handleGroupSelect(group)} >
{group.displayName} {group.memberCount} 位成员
)) )}
) const renderFunctionMenu = () => (
已选择群聊

{selectedGroup?.displayName}

{selectedGroup?.memberCount} 位成员

handleFunctionSelect('members')}> 群成员查看 查看群成员列表和基础资料
handleFunctionSelect('memberMessages')}> 成员消息筛选与导出 按成员查看群聊消息,并支持导出当前成员记录
handleFunctionSelect('memberAnalytics')}> 群成员详细分析 针对群聊内某一用户的群聊记录进行详细分析,如发信数量、活跃周期等
handleFunctionSelect('ranking')}> 群聊发言排行 统计成员发言数量排行
handleFunctionSelect('activeHours')}> 群聊活跃时段 查看全天活跃时间分布
handleFunctionSelect('mediaStats')}> 媒体内容统计 统计文本、图片、语音等类型
) const renderFunctionContent = () => { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' case 'memberMessages': return '成员消息筛选与导出' case 'memberAnalytics': return '群成员详细分析' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' default: return '' } } const showDateRange = selectedFunction !== 'members' return (

{getFunctionTitle()}

{selectedGroup?.displayName}
{showDateRange && ( )} {selectedFunction === 'members' && ( )} {selectedFunction === 'memberMessages' && ( )}
{functionLoading ? (
) : ( <> {selectedFunction === 'members' && (
{members.map(member => (
handleMemberClick(member)}>
{member.displayName}
))}
)} {selectedFunction === 'memberMessages' && (
{members.length === 0 ? (
暂无群成员数据,请先刷新。
) : ( <>
已加载 {memberMessages.length} 条消息
查看成员 {showMessageMemberSelect && (
setMessageMemberSearchKeyword(e.target.value)} placeholder="搜索 wxid / 昵称 / 备注 / 微信号" />
{filteredMessageMemberOptions.length === 0 ? (
无匹配成员
) : ( filteredMessageMemberOptions.map(member => ( )) )}
)}
{memberMessages.length === 0 ? (
当前时间范围内暂无该成员消息。
) : (
{memberMessages.map(message => (
{formatMemberMessageTime(message.createTime)} {getMemberMessageTypeLabel(message)}
{getMemberMessagePreview(message)}
))}
)} {(memberMessagesHasMore || memberMessages.length > 0) && (
{memberMessagesHasMore ? ( ) : ( 已显示当前可读取的全部消息 )}
)} )}
)} {selectedFunction === 'memberAnalytics' && (
{members.length === 0 ? (
暂无群成员数据,请先刷新。
) : ( <>
分析成员 {showMessageMemberSelect && (
setMessageMemberSearchKeyword(e.target.value)} placeholder="搜索 wxid / 昵称 / 备注 / 微信号" onClick={e => e.stopPropagation()} />
{filteredMessageMemberOptions.length === 0 ? (
无匹配成员
) : ( filteredMessageMemberOptions.map(member => ( )) )}
)}
{analyticsError ? (
{analyticsError}
) : 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) => (
{index + 1}
{index < 3 &&
}
{item.member.displayName}
{formatNumber(item.messageCount)} 条
))}
)} {selectedFunction === 'activeHours' && (
)} {selectedFunction === 'mediaStats' && mediaStats && (
{mediaStats.typeCounts.map(item => { const colorMap: Record = { 1: '#3b82f6', 3: '#22c55e', 34: '#f97316', 43: '#a855f7', 47: '#ec4899', 49: '#14b8a6', [-1]: '#6b7280' } const percentage = mediaStats.total > 0 ? ((item.count / mediaStats.total) * 100).toFixed(1) : '0' return (
{item.name} {formatNumber(item.count)} 条 ({percentage}%)
) })}
总计 {formatNumber(mediaStats.total)} 条
)} )}
) } const renderDetailPanel = () => { if (selectedFunction) { return renderFunctionContent() } if (!selectedGroup) { return ( <>