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 } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' import * as configService from '../services/config' 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' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats' type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | '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 } function GroupAnalyticsPage() { const location = useLocation() const [groups, setGroups] = useState([]) const [filteredGroups, setFilteredGroups] = useState([]) const [isLoading, setIsLoading] = useState(true) const [selectedGroup, setSelectedGroup] = useState(null) const [selectedFunction, setSelectedFunction] = useState(null) const [searchQuery, setSearchQuery] = useState('') // 功能数据 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 [selectedExportMemberUsername, setSelectedExportMemberUsername] = 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 [showMemberSelect, setShowMemberSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) const [memberSearchKeyword, setMemberSearchKeyword] = useState('') const memberSelectDropdownRef = 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: '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 selectedExportMember = useMemo( () => members.find(member => member.username === selectedExportMemberUsername) || null, [members, selectedExportMemberUsername] ) 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 filteredMemberOptions = useMemo(() => { const keyword = memberSearchKeyword.trim().toLowerCase() if (!keyword) return members return members.filter(member => { const fields = [ member.username, member.displayName, member.nickname, member.remark, member.alias ] return fields.some(field => String(field || '').toLowerCase().includes(keyword)) }) }, [memberSearchKeyword, members]) 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 () => { setIsLoading(true) try { const result = await window.electronAPI.groupAnalytics.getGroupChats() if (result.success && result.data) { setGroups(result.data) setFilteredGroups(result.data) } } catch (e) { console.error(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) { setSelectedExportMemberUsername('') return } const exists = members.some(member => member.username === selectedExportMemberUsername) if (!exists) { setSelectedExportMemberUsername(members[0].username) } }, [members, selectedExportMemberUsername]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { setShowMemberSelect(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, showMemberSelect]) 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) { setSelectedGroup(matchedGroup) 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' && selectedFunction !== 'memberExport') { setDateRangeReady(false) loadFunctionData(selectedFunction) } }, [dateRangeReady]) useEffect(() => { const handleChange = () => { setGroups([]) setFilteredGroups([]) setSelectedGroup(null) setSelectedFunction(null) setMembers([]) 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]) const handleGroupSelect = (group: GroupChatInfo) => { if (selectedGroup?.username !== group.username) { setSelectedGroup(group) setSelectedFunction(null) setSelectedExportMemberUsername('') setMemberSearchKeyword('') setShowMemberSelect(false) setShowFormatSelect(false) setShowDisplayNameSelect(false) } } const handleFunctionSelect = async (func: AnalysisFunction) => { if (!selectedGroup) return setSelectedFunction(func) await loadFunctionData(func) } const loadFunctionData = async (func: AnalysisFunction) => { if (!selectedGroup) return setFunctionLoading(true) // 计算时间戳 const startTime = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined const endTime = endDate ? Math.floor(new Date(endDate + 'T23:59:59').getTime() / 1000) : undefined try { switch (func) { case 'members': { const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) if (result.success && result.data) setMembers(result.data) break } case 'memberExport': { const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) if (result.success && result.data) setMembers(result.data) break } case 'ranking': { const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) if (result.success && result.data) setRankings(result.data) break } case 'activeHours': { const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime) if (result.success && result.data) setActiveHours(result.data.hourlyDistribution) break } case 'mediaStats': { const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime) if (result.success && result.data) setMediaStats(result.data) break } } } catch (e) { console.error(e) } finally { setFunctionLoading(false) } } const formatNumber = (num: number) => { if (num >= 10000) return (num / 10000).toFixed(1) + '万' return num.toLocaleString() } 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) { loadFunctionData(selectedFunction) } } const handleDateRangeComplete = () => { if (selectedFunction === 'memberExport') return setDateRangeReady(true) } const handleMemberClick = (member: GroupMember) => { setSelectedMember(member) setCopiedField(null) } 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) { alert(`导出成功,共 ${result.count ?? members.length} 人`) } else { alert(`导出失败:${result.error || '未知错误'}`) } } catch (e) { console.error('导出群成员失败:', e) alert(`导出失败:${String(e)}`) } 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 || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return const member = members.find(item => item.username === selectedExportMemberUsername) 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) { alert(`导出成功:${member.displayName || member.username}`) } else { alert(`导出失败:${result.error || '未知错误'}`) } } catch (e) { console.error('导出成员消息失败:', e) alert(`导出失败:${String(e)}`) } 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('memberExport')}> 成员消息导出
handleFunctionSelect('ranking')}> 群聊发言排行
handleFunctionSelect('activeHours')}> 群聊活跃时段
handleFunctionSelect('mediaStats')}> 媒体内容统计
) const renderFunctionContent = () => { const getFunctionTitle = () => { switch (selectedFunction) { case 'members': return '群成员查看' case 'memberExport': return '成员消息导出' case 'ranking': return '群聊发言排行' case 'activeHours': return '群聊活跃时段' case 'mediaStats': return '媒体内容统计' default: return '' } } const showDateRange = selectedFunction !== 'members' return (

{getFunctionTitle()}

{selectedGroup?.displayName}
{showDateRange && ( )} {selectedFunction === 'members' && ( )}
{functionLoading ? (
) : ( <> {selectedFunction === 'members' && (
{members.map(member => (
handleMemberClick(member)}>
{member.displayName}
))}
)} {selectedFunction === 'memberExport' && (
{members.length === 0 ? (
暂无群成员数据,请先刷新。
) : ( <>
导出成员 {showMemberSelect && (
setMemberSearchKeyword(e.target.value)} placeholder="搜索 wxid / 昵称 / 备注 / 微信号" />
{filteredMemberOptions.length === 0 ? (
无匹配成员
) : ( filteredMemberOptions.map(member => ( )) )}
)}
导出格式 {showFormatSelect && (
{memberExportFormatOptions.map(option => ( ))}
)}
导出目录
媒体导出
媒体类型
附加选项
显示名称规则 {showDisplayNameSelect && (
{displayNameOptions.map(option => ( ))}
)}
)}
)} {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 (!selectedGroup) { return (

请从左侧选择一个群聊进行分析

) } if (!selectedFunction) { return renderFunctionMenu() } return renderFunctionContent() } return (
{renderGroupList()}
setIsResizing(true)} />
{renderDetailPanel()}
{renderMemberModal()}
) } export default GroupAnalyticsPage