import { useState, useEffect, useCallback } from 'react' import { useLocation } from 'react-router-dom' import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react' import ReactECharts from 'echarts-for-react' import { useAnalyticsStore } from '../stores/analyticsStore' import { useThemeStore } from '../stores/themeStore' import './AnalyticsPage.scss' import { Avatar } from '../components/Avatar' interface ExcludeCandidate { username: string displayName: string avatarUrl?: string wechatId?: string } const normalizeUsername = (value: string) => value.trim().toLowerCase() function AnalyticsPage() { const [isLoading, setIsLoading] = useState(false) const [loadingStatus, setLoadingStatus] = useState('') const [error, setError] = useState(null) const [progress, setProgress] = useState(0) const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false) const [excludeCandidates, setExcludeCandidates] = useState([]) const [excludeQuery, setExcludeQuery] = useState('') const [excludeLoading, setExcludeLoading] = useState(false) const [excludeError, setExcludeError] = useState(null) const [excludedUsernames, setExcludedUsernames] = useState>(new Set()) const [draftExcluded, setDraftExcluded] = useState>(new Set()) const themeMode = useThemeStore((state) => state.themeMode) const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore() const loadExcludedUsernames = useCallback(async () => { try { const result = await window.electronAPI.analytics.getExcludedUsernames() if (result.success && result.data) { setExcludedUsernames(new Set(result.data.map(normalizeUsername))) } else { setExcludedUsernames(new Set()) } } catch (e) { console.warn('加载排除名单失败', e) setExcludedUsernames(new Set()) } }, []) const loadData = useCallback(async (forceRefresh = false) => { if (isLoaded && !forceRefresh) return setIsLoading(true) setError(null) setProgress(0) // 监听后台推送的进度 const removeListener = window.electronAPI.analytics.onProgress?.((payload: { status: string; progress: number }) => { setLoadingStatus(payload.status) setProgress(payload.progress) }) try { setLoadingStatus('正在统计消息数据...') const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh) if (statsResult.success && statsResult.data) { setStatistics(statsResult.data) } else { setError(statsResult.error || '加载统计数据失败') setIsLoading(false) return } setLoadingStatus('正在分析联系人排名...') const rankingsResult = await window.electronAPI.analytics.getContactRankings(20) if (rankingsResult.success && rankingsResult.data) { setRankings(rankingsResult.data) } setLoadingStatus('正在计算时间分布...') const timeResult = await window.electronAPI.analytics.getTimeDistribution() if (timeResult.success && timeResult.data) { setTimeDistribution(timeResult.data) } markLoaded() } catch (e) { setError(String(e)) } finally { setIsLoading(false) if (removeListener) removeListener() } }, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution]) const location = useLocation() useEffect(() => { const force = location.state?.forceRefresh === true loadData(force) }, [location.state, loadData]) useEffect(() => { const handleChange = () => { loadExcludedUsernames() loadData(true) } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [loadData, loadExcludedUsernames]) useEffect(() => { loadExcludedUsernames() }, [loadExcludedUsernames]) const handleRefresh = () => loadData(true) const loadExcludeCandidates = useCallback(async () => { setExcludeLoading(true) setExcludeError(null) try { const result = await window.electronAPI.analytics.getExcludeCandidates() if (result.success && result.data) { setExcludeCandidates(result.data) } else { setExcludeError(result.error || '加载好友列表失败') } } catch (e) { setExcludeError(String(e)) } finally { setExcludeLoading(false) } }, []) const openExcludeDialog = async () => { setExcludeQuery('') setDraftExcluded(new Set(excludedUsernames)) setIsExcludeDialogOpen(true) await loadExcludeCandidates() } const toggleExcluded = (username: string) => { const key = normalizeUsername(username) setDraftExcluded((prev) => { const next = new Set(prev) if (next.has(key)) { next.delete(key) } else { next.add(key) } return next }) } const handleApplyExcluded = async () => { const payload = Array.from(draftExcluded) setIsExcludeDialogOpen(false) try { const result = await window.electronAPI.analytics.setExcludedUsernames(payload) if (!result.success) { alert(result.error || '更新排除名单失败') return } setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername))) clearCache() await window.electronAPI.cache.clearAnalytics() await loadData(true) } catch (e) { alert(`更新排除名单失败:${String(e)}`) } } const visibleExcludeCandidates = excludeCandidates .filter((candidate) => { const query = excludeQuery.trim().toLowerCase() if (!query) return true const wechatId = candidate.wechatId || '' const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase() return haystack.includes(query) }) .sort((a, b) => { const aSelected = draftExcluded.has(normalizeUsername(a.username)) const bSelected = draftExcluded.has(normalizeUsername(b.username)) if (aSelected !== bSelected) return aSelected ? -1 : 1 return a.displayName.localeCompare(b.displayName, 'zh') }) const formatDate = (timestamp: number | null) => { if (!timestamp) return '-' const date = new Date(timestamp * 1000) return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` } const formatNumber = (num: number) => { if (num >= 10000) return (num / 10000).toFixed(1) + '万' return num.toLocaleString() } const getChartLabelColors = () => { if (typeof window === 'undefined') { return { text: '#333333', line: '#999999' } } const styles = getComputedStyle(document.documentElement) const text = styles.getPropertyValue('--text-primary').trim() || '#333333' const line = styles.getPropertyValue('--text-tertiary').trim() || '#999999' return { text, line } } const chartLabelColors = getChartLabelColors() const getTypeChartOption = () => { if (!statistics) return {} const data = [ { name: '文本', value: statistics.textMessages }, { name: '图片', value: statistics.imageMessages }, { name: '语音', value: statistics.voiceMessages }, { name: '视频', value: statistics.videoMessages }, { name: '表情', value: statistics.emojiMessages }, { name: '其他', value: statistics.otherMessages }, ].filter(d => d.value > 0) return { tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, series: [{ type: 'pie', radius: ['40%', '70%'], avoidLabelOverlap: false, itemStyle: { borderRadius: 8, borderColor: 'transparent', borderWidth: 0 }, label: { show: true, formatter: '{b}\n{d}%', textStyle: { color: chartLabelColors.text, textShadowBlur: 0, textShadowColor: 'transparent', textShadowOffsetX: 0, textShadowOffsetY: 0, textBorderWidth: 0, textBorderColor: 'transparent', }, }, labelLine: { lineStyle: { color: chartLabelColors.line, shadowBlur: 0, shadowColor: 'transparent', }, }, emphasis: { itemStyle: { shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0, }, label: { color: chartLabelColors.text, textShadowBlur: 0, textShadowColor: 'transparent', textBorderWidth: 0, textBorderColor: 'transparent', }, labelLine: { lineStyle: { color: chartLabelColors.line, shadowBlur: 0, shadowColor: 'transparent', }, }, }, data, }] } } const getSendReceiveOption = () => { if (!statistics) return {} return { tooltip: { trigger: 'item' }, series: [{ type: 'pie', radius: ['50%', '70%'], data: [ { name: '发送', value: statistics.sentMessages, itemStyle: { color: '#07c160' } }, { name: '接收', value: statistics.receivedMessages, itemStyle: { color: '#1989fa' } } ], label: { show: true, formatter: '{b}: {c}', textStyle: { color: chartLabelColors.text, textShadowBlur: 0, textShadowColor: 'transparent', textShadowOffsetX: 0, textShadowOffsetY: 0, textBorderWidth: 0, textBorderColor: 'transparent', }, }, labelLine: { lineStyle: { color: chartLabelColors.line, shadowBlur: 0, shadowColor: 'transparent', }, }, emphasis: { itemStyle: { shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0, }, label: { color: chartLabelColors.text, textShadowBlur: 0, textShadowColor: 'transparent', textBorderWidth: 0, textBorderColor: 'transparent', }, labelLine: { lineStyle: { color: chartLabelColors.line, shadowBlur: 0, shadowColor: 'transparent', }, }, }, }] } } const getHourlyOption = () => { if (!timeDistribution) return {} const hours = Array.from({ length: 24 }, (_, i) => i) const data = hours.map(h => timeDistribution.hourlyDistribution[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] } }] } } if (isLoading && !isLoaded) { return (

{loadingStatus}

{progress}%
) } if (error && !isLoaded) { return (

{error}

) } return ( <>

私聊分析

{formatNumber(statistics?.totalMessages || 0)} 总消息数
{formatNumber(statistics?.sentMessages || 0)} 发送消息
{formatNumber(statistics?.receivedMessages || 0)} 接收消息
{statistics?.activeDays || 0} 活跃天数
{statistics && (
数据范围: {formatDate(statistics.firstMessageTime)} - {formatDate(statistics.lastMessageTime)}
)}

消息类型分布

发送/接收比例

每小时消息分布

聊天排名 Top 20

{rankings.map((contact, index) => (
{index + 1}
{index < 3 &&
}
{contact.displayName} 发送 {contact.sentCount} / 接收 {contact.receivedCount}
{formatNumber(contact.messageCount)} 条
))}
{isExcludeDialogOpen && (
setIsExcludeDialogOpen(false)}>
e.stopPropagation()}>

选择不统计的好友

setExcludeQuery(e.target.value)} disabled={excludeLoading} /> {excludeQuery && ( )}
{excludeLoading && (
正在加载好友列表...
)} {!excludeLoading && excludeError && (
{excludeError}
)} {!excludeLoading && !excludeError && (
{visibleExcludeCandidates.map((candidate) => { const isChecked = draftExcluded.has(normalizeUsername(candidate.username)) const wechatId = candidate.wechatId?.trim() || candidate.username return ( ) })} {visibleExcludeCandidates.length === 0 && (
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
)}
)}
已排除 {draftExcluded.size} 人
)} ) } export default AnalyticsPage