Files
WeFlow/src/pages/AnalyticsPage.tsx
2026-05-27 20:59:44 +08:00

858 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, type ReactNode } 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 {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './AnalyticsPage.scss'
import { Avatar } from '../components/Avatar'
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
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<string | null>(null)
const [progress, setProgress] = useState(0)
const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false)
const [excludeCandidates, setExcludeCandidates] = useState<ExcludeCandidate[]>([])
const [excludeQuery, setExcludeQuery] = useState('')
const [excludeLoading, setExcludeLoading] = useState(false)
const [excludeError, setExcludeError] = useState<string | null>(null)
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
const chartThemeSignature = useThemeStore((state) => `${state.currentTheme}-${state.themeMode}`)
const {
statistics,
rankings,
timeDistribution,
selfSentDailyDistribution,
isLoaded,
setStatistics,
setRankings,
setTimeDistribution,
setSelfSentDailyDistribution,
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) => {
const currentAnalyticsState = useAnalyticsStore.getState()
if (
currentAnalyticsState.isLoaded &&
!forceRefresh &&
currentAnalyticsState.statistics &&
currentAnalyticsState.timeDistribution &&
currentAnalyticsState.selfSentDailyDistribution
) return
const taskId = registerBackgroundTask({
sourcePage: 'analytics',
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
detail: '准备读取整体统计数据',
progressText: '整体统计',
cancelable: true
})
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('正在统计消息数据...')
updateBackgroundTask(taskId, {
detail: '正在统计消息数据',
progressText: '整体统计'
})
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前页面分析流程已结束'
})
setIsLoading(false)
return
}
if (statsResult.success && statsResult.data) {
setStatistics(statsResult.data)
} else {
setError(statsResult.error || '加载统计数据失败')
finishBackgroundTask(taskId, 'failed', {
detail: statsResult.error || '加载统计数据失败'
})
setIsLoading(false)
return
}
setLoadingStatus('正在分析联系人排名...')
updateBackgroundTask(taskId, {
detail: '正在分析联系人排名',
progressText: '联系人排名'
})
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,联系人排名后续步骤未继续'
})
setIsLoading(false)
return
}
if (rankingsResult.success && rankingsResult.data) {
setRankings(rankingsResult.data)
}
setLoadingStatus('正在计算时间分布...')
updateBackgroundTask(taskId, {
detail: '正在计算时间分布',
progressText: '时间分布'
})
const timeResult = await window.electronAPI.analytics.getTimeDistribution()
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,时间分布结果未继续写入'
})
setIsLoading(false)
return
}
if (timeResult.success && timeResult.data) {
setTimeDistribution(timeResult.data)
}
setLoadingStatus('正在统计每日发送分布...')
updateBackgroundTask(taskId, {
detail: '正在统计每日发送分布',
progressText: '每日发送'
})
const selfSentDailyResult = await window.electronAPI.analytics.getSelfSentDailyDistribution(0, 0, forceRefresh)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,每日发送分布结果未继续写入'
})
setIsLoading(false)
return
}
if (selfSentDailyResult.success && selfSentDailyResult.data) {
setSelfSentDailyDistribution(selfSentDailyResult.data)
}
markLoaded()
finishBackgroundTask(taskId, 'completed', {
detail: '分析看板数据加载完成',
progressText: '已完成'
})
} catch (e) {
setError(String(e))
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
} finally {
setIsLoading(false)
if (removeListener) removeListener()
}
}, [markLoaded, setRankings, setSelfSentDailyDistribution, 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 isNoSessionError = error?.includes('未找到消息会话') ?? false
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 toggleInvertSelection = () => {
setDraftExcluded((prev) => {
const allUsernames = new Set(excludeCandidates.map(c => normalizeUsername(c.username)))
const inverted = new Set<string>()
for (const u of allUsernames) {
if (!prev.has(u)) inverted.add(u)
}
return inverted
})
}
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 handleResetExcluded = async () => {
try {
const result = await window.electronAPI.analytics.setExcludedUsernames([])
if (!result.success) {
setError(result.error || '重置排除好友失败')
return
}
setExcludedUsernames(new Set())
setDraftExcluded(new Set())
clearCache()
await window.electronAPI.cache.clearAnalytics()
await loadData(true)
} catch (e) {
setError(`重置排除好友失败: ${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 getChartTheme = () => {
if (typeof window === 'undefined') {
return {
text: '#333333',
secondaryText: '#666666',
mutedText: '#999999',
line: '#e5e5e5',
surface: '#ffffff',
border: '#e5e5e5',
primary: '#10a37f',
primaryLight: 'rgba(16, 163, 127, 0.1)',
danger: '#ef4444',
warning: '#f59e0b',
success: '#10a37f',
info: '#3b82f6'
}
}
const styles = getComputedStyle(document.documentElement)
const cssVar = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback
return {
text: cssVar('--text-primary', '#333333'),
secondaryText: cssVar('--text-secondary', '#666666'),
mutedText: cssVar('--text-tertiary', '#999999'),
line: cssVar('--border-color', '#e5e5e5'),
surface: cssVar('--card-inner-bg', '#ffffff'),
border: cssVar('--border-color', '#e5e5e5'),
primary: cssVar('--primary', '#10a37f'),
primaryLight: cssVar('--primary-light', 'rgba(16, 163, 127, 0.1)'),
danger: cssVar('--danger', '#ef4444'),
warning: cssVar('--warning', '#f59e0b'),
success: cssVar('--primary', '#10a37f'),
info: '#3b82f6'
}
}
const chartTheme = getChartTheme()
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: chartTheme.text,
textShadowBlur: 0,
textShadowColor: 'transparent',
textShadowOffsetX: 0,
textShadowOffsetY: 0,
textBorderWidth: 0,
textBorderColor: 'transparent',
},
},
labelLine: {
lineStyle: {
color: chartTheme.mutedText,
shadowBlur: 0,
shadowColor: 'transparent',
},
},
emphasis: {
itemStyle: {
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
},
label: {
color: chartTheme.text,
textShadowBlur: 0,
textShadowColor: 'transparent',
textBorderWidth: 0,
textBorderColor: 'transparent',
},
labelLine: {
lineStyle: {
color: chartTheme.mutedText,
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: chartTheme.text,
textShadowBlur: 0,
textShadowColor: 'transparent',
textShadowOffsetX: 0,
textShadowOffsetY: 0,
textBorderWidth: 0,
textBorderColor: 'transparent',
},
},
labelLine: {
lineStyle: {
color: chartTheme.mutedText,
shadowBlur: 0,
shadowColor: 'transparent',
},
},
emphasis: {
itemStyle: {
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
},
label: {
color: chartTheme.text,
textShadowBlur: 0,
textShadowColor: 'transparent',
textBorderWidth: 0,
textBorderColor: 'transparent',
},
labelLine: {
lineStyle: {
color: chartTheme.mutedText,
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] } }]
}
}
const getSelfSentDailyRatioData = () => {
const entries = Object.entries(selfSentDailyDistribution?.dailyDistribution || {})
.sort(([a], [b]) => a.localeCompare(b))
const days = entries.map(([day]) => day)
const counts = entries.map(([, count]) => count)
const totalDays = Math.max(days.length, 1)
const total = counts.reduce((sum, count) => sum + count, 0)
const baseline = total > 0 ? total / totalDays : 0
const ratios = counts.map((count) => baseline > 0 ? Number((count / baseline * 100).toFixed(1)) : 0)
const movingAverage = ratios.map((_, index) => {
const start = Math.max(0, index - 6)
const windowValues = ratios.slice(start, index + 1)
const sum = windowValues.reduce((total, value) => total + value, 0)
return Number((sum / windowValues.length).toFixed(1))
})
return { days, counts, ratios, movingAverage, baseline, total }
}
const getSelfSentDailyRatioOption = () => {
if (!selfSentDailyDistribution) return {}
const { days, counts, ratios, movingAverage, baseline } = getSelfSentDailyRatioData()
const showZoom = days.length > 31
const zoomStart = showZoom ? Math.max(0, 100 - Math.min(100, 31 / days.length * 100)) : 0
const ratioBarColors = {
normal: chartTheme.primary,
high: chartTheme.warning,
spike: chartTheme.danger,
trend: chartTheme.secondaryText,
baseline: chartTheme.mutedText
}
return {
tooltip: {
trigger: 'axis',
backgroundColor: chartTheme.surface,
borderColor: chartTheme.border,
textStyle: { color: chartTheme.text },
extraCssText: 'box-shadow: var(--shadow-md); border-radius: 8px;',
axisPointer: {
type: 'shadow',
shadowStyle: { color: chartTheme.primaryLight }
},
formatter: (params: any) => {
const items = Array.isArray(params) ? params : [params]
const first = items[0]
const index = Number(first?.dataIndex || 0)
const lines = [
`${first?.axisValue || ''}`,
`当日发送:${formatNumber(counts[index] || 0)}`,
`相对日均:${formatNumber(ratios[index] || 0)}%`,
`7日均线${formatNumber(movingAverage[index] || 0)}%`,
`全期日均:${baseline.toFixed(1)} 条/天`
]
return lines.join('<br/>')
}
},
legend: {
data: ['单日比例', '7日均线'],
top: 0,
textStyle: { color: chartTheme.secondaryText }
},
grid: { left: 56, right: 40, top: 42, bottom: showZoom ? 58 : 32 },
xAxis: {
type: 'category',
data: days,
axisLine: { lineStyle: { color: chartTheme.line } },
axisTick: { lineStyle: { color: chartTheme.line } },
axisLabel: {
color: chartTheme.mutedText,
hideOverlap: true,
formatter: (value: string) => value.slice(5)
}
},
yAxis: {
type: 'value',
name: '相对日均',
nameTextStyle: { color: chartTheme.mutedText },
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: chartTheme.mutedText,
formatter: '{value}%'
},
splitLine: { lineStyle: { color: chartTheme.line, type: 'dashed' } }
},
dataZoom: showZoom ? [
{ type: 'inside', start: zoomStart, end: 100 },
{
type: 'slider',
height: 18,
bottom: 16,
start: zoomStart,
end: 100,
borderColor: chartTheme.border,
fillerColor: chartTheme.primaryLight,
handleStyle: { color: chartTheme.primary, borderColor: chartTheme.primary },
moveHandleStyle: { color: chartTheme.primaryLight },
dataBackground: {
lineStyle: { color: chartTheme.mutedText },
areaStyle: { color: chartTheme.primaryLight }
},
selectedDataBackground: {
lineStyle: { color: chartTheme.primary },
areaStyle: { color: chartTheme.primaryLight }
},
textStyle: { color: chartTheme.mutedText }
}
] : undefined,
series: [
{
name: '单日比例',
type: 'bar',
data: ratios,
itemStyle: {
color: (params: any) => {
const value = Number(params?.value || 0)
if (value >= 200) return ratioBarColors.spike
if (value >= 150) return ratioBarColors.high
return ratioBarColors.normal
},
borderRadius: [4, 4, 0, 0]
},
markLine: {
symbol: 'none',
data: [{ yAxis: 100, name: '日均基线' }],
label: {
position: 'middle',
formatter: '日均基线',
color: chartTheme.secondaryText,
backgroundColor: chartTheme.surface,
borderColor: chartTheme.border,
borderWidth: 1,
borderRadius: 4,
padding: [2, 6]
},
lineStyle: { type: 'dashed', color: ratioBarColors.baseline }
}
},
{
name: '7日均线',
type: 'line',
data: movingAverage,
smooth: true,
showSymbol: false,
lineStyle: { width: 2, color: ratioBarColors.trend },
itemStyle: { color: ratioBarColors.trend }
}
]
}
}
const selfSentDailyRatioData = getSelfSentDailyRatioData()
const renderPageShell = (content: ReactNode) => (
<div className="analytics-page-shell">
<ChatAnalysisHeader currentMode="private" />
{content}
</div>
)
const analyticsHeaderActions = (
<>
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
{isLoading ? '刷新中...' : '刷新'}
</button>
<button className="btn btn-secondary" onClick={openExcludeDialog}>
<UserMinus size={16} />
{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
</button>
</>
)
if (isLoading && !isLoaded) {
return renderPageShell(
<div className="loading-container">
<Loader2 size={48} className="spin" />
<p className="loading-status">{loadingStatus}</p>
<div className="progress-bar-wrapper">
<div className="progress-bar-fill" style={{ width: `${progress}%` }}></div>
</div>
<span className="progress-percent">{progress}%</span>
</div>
)
}
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
return renderPageShell(
<div className="error-container">
<p>{error}</p>
<div className="error-actions">
<button className="btn btn-secondary" onClick={handleResetExcluded}>
</button>
<button className="btn btn-primary" onClick={() => loadData(true)}>
</button>
</div>
</div>
)
}
if (error && !isLoaded) {
return renderPageShell(
<div className="error-container">
<p>{error}</p>
<button className="btn btn-primary" onClick={() => loadData(true)}></button>
</div>
)
}
return (
<div className="analytics-page-shell">
<ChatAnalysisHeader currentMode="private" actions={analyticsHeaderActions} />
<div className="page-scroll">
<section className="page-section">
<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(statistics?.totalMessages || 0)}</span>
<span className="stat-label"></span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"><Send size={24} /></div>
<div className="stat-info">
<span className="stat-value">{formatNumber(statistics?.sentMessages || 0)}</span>
<span className="stat-label"></span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"><Inbox size={24} /></div>
<div className="stat-info">
<span className="stat-value">{formatNumber(statistics?.receivedMessages || 0)}</span>
<span className="stat-label"></span>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"><Calendar size={24} /></div>
<div className="stat-info">
<span className="stat-value">{statistics?.activeDays || 0}</span>
<span className="stat-label"></span>
</div>
</div>
</div>
{statistics && (
<div className="time-range">
<Clock size={16} />
<span>: {formatDate(statistics.firstMessageTime)} - {formatDate(statistics.lastMessageTime)}</span>
</div>
)}
<div className="charts-grid">
<div className="chart-card"><h3></h3><ReactECharts option={getTypeChartOption()} style={{ height: 300 }} /></div>
<div className="chart-card"><h3>/</h3><ReactECharts option={getSendReceiveOption()} style={{ height: 300 }} /></div>
<div className="chart-card wide"><h3></h3><ReactECharts option={getHourlyOption()} style={{ height: 250 }} /></div>
<div className="chart-card wide self-sent-ratio-card">
<div className="chart-title-row">
<h3></h3>
<span> · 线{selfSentDailyRatioData.baseline.toFixed(1)} / · {formatNumber(selfSentDailyDistribution?.totalMessages || 0)} </span>
</div>
<div className="chart-note">
= ÷ 100% 线
</div>
<ReactECharts key={chartThemeSignature} option={getSelfSentDailyRatioOption()} style={{ height: 320 }} />
</div>
</div>
</section>
<section className="page-section">
<div className="section-header"><div><h2><Users size={20} /> Top 20</h2></div></div>
<div className="rankings-list">
{rankings.map((contact, index) => (
<div key={contact.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar">
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div>
<div className="contact-info">
<span className="contact-name">{contact.displayName}</span>
<span className="contact-stats"> {contact.sentCount} / {contact.receivedCount}</span>
</div>
<span className="message-count">{formatNumber(contact.messageCount)} </span>
</div>
))}
</div>
</section>
</div>
{isExcludeDialogOpen && (
<div className="exclude-modal-overlay" onClick={() => setIsExcludeDialogOpen(false)}>
<div className="exclude-modal" onClick={e => e.stopPropagation()}>
<div className="exclude-modal-header">
<h3></h3>
<button className="modal-close" onClick={() => setIsExcludeDialogOpen(false)}>
<X size={18} />
</button>
</div>
<div className="exclude-modal-search">
<Search size={16} />
<input
type="text"
placeholder="搜索好友"
value={excludeQuery}
onChange={e => setExcludeQuery(e.target.value)}
disabled={excludeLoading}
/>
{excludeQuery && (
<button className="clear-search" onClick={() => setExcludeQuery('')}>
<X size={14} />
</button>
)}
</div>
<div className="exclude-modal-body">
{excludeLoading && (
<div className="exclude-loading">
<Loader2 size={20} className="spin" />
<span>...</span>
</div>
)}
{!excludeLoading && excludeError && (
<div className="exclude-error">{excludeError}</div>
)}
{!excludeLoading && !excludeError && (
<div className="exclude-list">
{visibleExcludeCandidates.map((candidate) => {
const isChecked = draftExcluded.has(normalizeUsername(candidate.username))
const wechatId = candidate.wechatId?.trim() || candidate.username
return (
<label key={candidate.username} className={`exclude-item ${isChecked ? 'active' : ''}`}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleExcluded(candidate.username)}
/>
<div className="exclude-avatar">
<Avatar src={candidate.avatarUrl} name={candidate.displayName} size={32} />
</div>
<div className="exclude-info">
<span className="exclude-name">{candidate.displayName}</span>
<span className="exclude-username">{wechatId}</span>
</div>
</label>
)
})}
{visibleExcludeCandidates.length === 0 && (
<div className="exclude-empty">
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
</div>
)}
</div>
)}
</div>
<div className="exclude-modal-footer">
<div className="exclude-footer-left">
<span className="exclude-count"> {draftExcluded.size} </span>
<button className="btn btn-text" onClick={toggleInvertSelection} disabled={excludeLoading}>
</button>
</div>
<div className="exclude-actions">
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
</button>
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default AnalyticsPage