import { useEffect, useState, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import axios from 'axios' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ScrollArea } from '@/components/ui/scroll-area' import { Skeleton } from '@/components/ui/skeleton' import { Progress } from '@/components/ui/progress' import { fetchWithAuth } from '@/lib/fetch-with-auth' import { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, type ChartConfig, } from '@/components/ui/chart' import { LineChart, Line, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, } from 'recharts' import { Activity, TrendingUp, DollarSign, Clock, MessageSquare, Zap, Database, RefreshCw, Power, RotateCcw, FileText, Settings, Puzzle, CheckCircle2, AlertCircle, ClipboardList, ClipboardCheck, } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Link } from '@tanstack/react-router' import { RestartProvider, useRestart } from '@/lib/restart-context' import { RestartOverlay } from '@/components/restart-overlay' import { ExpressionReviewer } from '@/components/expression-reviewer' import { getReviewStats } from '@/lib/expression-api' import { ZoomableChart } from '@/components/ui/zoomable-chart' // 主导出组件:包装 RestartProvider export function IndexPage() { return ( ) } // 机器人状态接口 interface BotStatus { running: boolean uptime: number version: string start_time: string } interface StatisticsSummary { total_requests: number total_cost: number total_tokens: number online_time: number total_messages: number total_replies: number avg_response_time: number cost_per_hour: number tokens_per_hour: number } interface ModelStatistics { model_name: string request_count: number total_cost: number total_tokens: number avg_response_time: number } interface TimeSeriesData { timestamp: string requests: number cost: number tokens: number } interface RecentActivity { timestamp: string model: string request_type: string tokens: number cost: number time_cost: number status: string } interface DashboardData { summary: StatisticsSummary model_stats: ModelStatistics[] hourly_data: TimeSeriesData[] daily_data: TimeSeriesData[] recent_activity: RecentActivity[] } // 为饼图生成更丰富的颜色方案 (HSL色相均匀分布) const generatePieColors = (count: number): string[] => { const colors: string[] = [] for (let i = 0; i < count; i++) { // 使用黄金角度分布色相,避免相邻颜色相似 const hue = (i * 137.508) % 360 colors.push(`hsl(${hue}, 70%, 55%)`) } return colors } // 内部实现组件 function IndexPageContent() { const { t } = useTranslation() const [dashboardData, setDashboardData] = useState(null) const [loading, setLoading] = useState(true) const [loadingProgress, setLoadingProgress] = useState(0) const [timeRange, setTimeRange] = useState(24) // 默认24小时 const [autoRefresh, setAutoRefresh] = useState(true) const [hitokoto, setHitokoto] = useState<{ hitokoto: string; from: string } | null>(null) const [hitokotoLoading, setHitokotoLoading] = useState(true) const [botStatus, setBotStatus] = useState(null) const [isReviewerOpen, setIsReviewerOpen] = useState(false) const [uncheckedCount, setUncheckedCount] = useState(0) const { triggerRestart, isRestarting } = useRestart() // 使用 ref 跟踪组件是否已卸载,防止内存泄漏 const isMountedRef = useRef(true) // 使用 ref 存储 interval ID,方便清理 const refreshIntervalRef = useRef | null>(null) // 组件卸载时清理 useEffect(() => { isMountedRef.current = true return () => { isMountedRef.current = false // 清理自动刷新定时器 if (refreshIntervalRef.current) { clearInterval(refreshIntervalRef.current) refreshIntervalRef.current = null } } }, []) // 获取审核统计 const fetchReviewStats = useCallback(async () => { try { const result = await getReviewStats() if (result.success && isMountedRef.current) { setUncheckedCount(result.data.unchecked) } } catch (error) { console.error('获取审核统计失败:', error) } }, []) // 获取一言 const fetchHitokoto = useCallback(async () => { try { setHitokotoLoading(true) const response = await axios.get('https://v1.hitokoto.cn/?c=a&c=b&c=c&c=d&c=h&c=i&c=k') if (isMountedRef.current) { setHitokoto({ hitokoto: response.data.hitokoto, from: response.data.from || response.data.from_who || t('home.unknownSource') }) } } catch (error) { console.error('获取一言失败:', error) if (isMountedRef.current) { setHitokoto({ hitokoto: t('home.hitokotoFallback'), from: t('home.hitokotoFallbackFrom') }) } } finally { if (isMountedRef.current) { setHitokotoLoading(false) } } }, [t]) // 获取机器人状态 const fetchBotStatus = useCallback(async () => { try { const response = await fetchWithAuth('/api/webui/system/status') if (!isMountedRef.current) return if (response.ok) { const data = await response.json() setBotStatus(data) } else { setBotStatus(null) } } catch (error) { console.error('获取机器人状态失败:', error) if (isMountedRef.current) { setBotStatus(null) } } }, []) // 重启机器人 const handleRestart = async () => { await triggerRestart() } const fetchDashboardData = useCallback(async () => { try { const response = await fetchWithAuth(`/api/webui/statistics/dashboard?hours=${timeRange}`) if (!isMountedRef.current) return if (response.ok) { const data = await response.json() setDashboardData(data) } setLoading(false) setLoadingProgress(100) } catch (error) { console.error('Failed to fetch dashboard data:', error) if (isMountedRef.current) { setLoading(false) setLoadingProgress(100) } } }, [timeRange]) // 伪加载进度条效果 useEffect(() => { if (!loading) return setLoadingProgress(0) // 快速到15% const timer1 = setTimeout(() => setLoadingProgress(15), 200) // 到30% const timer2 = setTimeout(() => setLoadingProgress(30), 800) // 到45% const timer3 = setTimeout(() => setLoadingProgress(45), 2000) // 到60% const timer4 = setTimeout(() => setLoadingProgress(60), 4000) // 到75% const timer5 = setTimeout(() => setLoadingProgress(75), 6500) // 到85% const timer6 = setTimeout(() => setLoadingProgress(85), 9000) // 到92% const timer7 = setTimeout(() => setLoadingProgress(92), 11000) return () => { clearTimeout(timer1) clearTimeout(timer2) clearTimeout(timer3) clearTimeout(timer4) clearTimeout(timer5) clearTimeout(timer6) clearTimeout(timer7) } }, [loading]) useEffect(() => { fetchDashboardData() fetchHitokoto() fetchBotStatus() fetchReviewStats() }, [fetchDashboardData, fetchHitokoto, fetchBotStatus, fetchReviewStats]) // 自动刷新 useEffect(() => { // 清理旧的定时器 if (refreshIntervalRef.current) { clearInterval(refreshIntervalRef.current) refreshIntervalRef.current = null } if (!autoRefresh) return refreshIntervalRef.current = setInterval(() => { if (isMountedRef.current) { fetchDashboardData() fetchBotStatus() } }, 30000) // 30秒刷新一次 return () => { if (refreshIntervalRef.current) { clearInterval(refreshIntervalRef.current) refreshIntervalRef.current = null } } }, [autoRefresh, fetchDashboardData, fetchBotStatus]) if (loading || !dashboardData) { return (

{t('home.loading')}

{t('home.loadingHint')}

{loadingProgress}%

) } // 解构数据,提供默认值以防止 undefined 错误 const { summary: rawSummary, model_stats = [], hourly_data = [], daily_data = [], recent_activity = [] } = dashboardData // 为 summary 提供默认值 const summary = rawSummary ?? { total_requests: 0, total_cost: 0, total_tokens: 0, online_time: 0, total_messages: 0, total_replies: 0, avg_response_time: 0, cost_per_hour: 0, tokens_per_hour: 0, } // 格式化时间显示 const formatTime = (seconds: number) => { const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) return t('home.time.hoursMinutes', { hours, minutes }) } // 格式化大数字(自动选择合适单位) const formatNumber = (num: number): { display: string; exact: string; needsExact: boolean } => { const exact = num.toLocaleString('zh-CN') if (num >= 1_000_000_000) { return { display: `${(num / 1_000_000_000).toFixed(2)}B`, exact, needsExact: true } } else if (num >= 1_000_000) { return { display: `${(num / 1_000_000).toFixed(2)}M`, exact, needsExact: true } } else if (num >= 10_000) { return { display: `${(num / 1_000).toFixed(1)}K`, exact, needsExact: true } } else if (num >= 1_000) { return { display: `${(num / 1_000).toFixed(2)}K`, exact, needsExact: true } } return { display: exact, exact, needsExact: false } } // 格式化金额(自动选择合适单位) const formatCurrency = (num: number): { display: string; exact: string; needsExact: boolean } => { const exact = `¥${num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` if (num >= 1_000_000) { return { display: `¥${(num / 1_000_000).toFixed(2)}M`, exact, needsExact: true } } else if (num >= 10_000) { return { display: `¥${(num / 1_000).toFixed(1)}K`, exact, needsExact: true } } else if (num >= 1_000) { return { display: `¥${(num / 1_000).toFixed(2)}K`, exact, needsExact: true } } return { display: exact, exact, needsExact: false } } // 格式化日期时间 const formatDateTime = (isoString: string) => { const date = new Date(isoString) return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) } // 准备饼图数据(模型请求分布)- 使用黄金角度分布避免相邻颜色相似 const pieColors = generatePieColors(model_stats.length) const modelPieData = model_stats.map((stat, index) => ({ name: stat.model_name, value: stat.request_count, fill: pieColors[index], })) // 图表配置 const chartConfig = { requests: { label: t('home.charts.requests'), color: 'hsl(var(--color-chart-1))', }, cost: { label: t('home.charts.cost'), color: 'hsl(var(--color-chart-2))', }, tokens: { label: 'Tokens', color: 'hsl(var(--color-chart-3))', }, } satisfies ChartConfig return (
{/* 标题和控制栏 */}

{t('home.title')}

{t('home.subtitle')}

setTimeRange(Number(v))}> {t('home.timeRange.24h')} {t('home.timeRange.7d')} {t('home.timeRange.30d')}
{/* 一言 */}
{hitokotoLoading ? ( ) : hitokoto ? (

"{hitokoto.hitokoto}" —— {hitokoto.from}

) : null}
{/* 机器人状态和快速操作 */}
{/* 机器人状态卡片 */} {t('home.botStatus.title')}
{botStatus?.running ? ( <>
{t('home.botStatus.running')} ) : ( <>
{t('home.botStatus.stopped')} )}
{botStatus && (
v{botStatus.version} | {t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })}
)}
{/* 快速操作卡片 */} {t('home.quickActions.title')}
{/* 问卷调查卡片 */} {t('home.survey.title')} {t('home.survey.description')}
{/* 核心指标卡片 */}
{t('home.stats.totalRequests')}
{formatNumber(summary.total_requests).display} {formatNumber(summary.total_requests).needsExact && ( ({formatNumber(summary.total_requests).exact}) )}

{t('home.stats.recentPeriod', { range: timeRange < 48 ? timeRange + t('home.stats.hours') : Math.floor(timeRange / 24) + t('home.stats.days') })}

{t('home.stats.totalCost')}
{formatCurrency(summary.total_cost).display} {formatCurrency(summary.total_cost).needsExact && ( ({formatCurrency(summary.total_cost).exact}) )}

{summary.cost_per_hour > 0 ? t('home.stats.perHour', { value: `¥${summary.cost_per_hour.toFixed(2)}` }) : t('home.stats.noData')}

{t('home.stats.tokenUsage')}
{formatNumber(summary.total_tokens).display} {formatNumber(summary.total_tokens).needsExact && ( ({formatNumber(summary.total_tokens).exact}) )}

{summary.tokens_per_hour > 0 ? t('home.stats.perHour', { value: formatNumber(summary.tokens_per_hour).display }) : t('home.stats.noData')}

{t('home.stats.avgResponse')}
{summary.avg_response_time.toFixed(2)}s

{t('home.stats.avgResponseDesc')}

{/* 次要指标 */}
{t('home.stats.onlineTime')}
{formatTime(summary.online_time)} ({summary.online_time.toLocaleString()}{t('home.stats.seconds')})
{t('home.stats.messageProcessing')}
{formatNumber(summary.total_messages).display} {formatNumber(summary.total_messages).needsExact && ( ({formatNumber(summary.total_messages).exact}) )}

{t('home.stats.replied', { num: formatNumber(summary.total_replies).display })} {formatNumber(summary.total_replies).needsExact && ( ({formatNumber(summary.total_replies).exact}) )}

{t('home.stats.costEfficiency')}
{summary.total_messages > 0 ? `¥${((summary.total_cost / summary.total_messages) * 100).toFixed(2)}` : '¥0.00'}

{t('home.stats.per100Messages')}

{/* 图表区域 */} {t('home.charts.tabs.trends')} {t('home.charts.tabs.models')} {t('home.charts.tabs.activity')} {t('home.charts.tabs.daily')} {/* 趋势图表 */} {t('home.charts.requestTrend')} {t('home.charts.requestTrendDesc', { hours: timeRange })} formatDateTime(value)} angle={-45} textAnchor="end" height={60} stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} /> formatDateTime(value as string)} />} />
{t('home.charts.costTrend')} {t('home.charts.costTrendDesc')} formatDateTime(value)} angle={-45} textAnchor="end" height={60} stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} /> formatDateTime(value as string)} />} /> {t('home.charts.tokenUsage')} {t('home.charts.tokenUsageDesc')} formatDateTime(value)} angle={-45} textAnchor="end" height={60} stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} /> formatDateTime(value as string)} />} />
{/* 模型统计 */}
{t('home.charts.modelDistribution')} {t('home.charts.modelDistributionDesc', { count: model_stats.length })} [ stat.model_name, { label: stat.model_name, color: pieColors[i], }, ]) ) as ChartConfig } className="h-[300px] sm:h-[400px] w-full aspect-auto" > } /> { // 只显示占比大于5%的标签,避免小块标签重叠 if (percent && percent < 0.05) return '' return `${name} ${percent ? (percent * 100).toFixed(0) : 0}%` }} outerRadius={100} dataKey="value" > {modelPieData.map((entry, index) => ( ))} {t('home.charts.modelDetails')} {t('home.charts.modelDetailsDesc')}
{model_stats.map((stat, index) => (

{stat.model_name}

{t('home.charts.requestCount')}: {stat.request_count.toLocaleString()}
{t('home.charts.costLabel')}: ¥{stat.total_cost.toFixed(2)}
Tokens: {(stat.total_tokens / 1000).toFixed(1)}K
{t('home.charts.avgTime')}: {stat.avg_response_time.toFixed(2)}s
))}
{t('home.charts.recentActivity')} {t('home.charts.recentActivityDesc')}
{recent_activity.map((activity, index) => (
{activity.model}
{activity.request_type}
{formatDateTime(activity.timestamp)}
Tokens: {activity.tokens}
{t('home.charts.costLabel')}: ¥{activity.cost.toFixed(4)}
{t('home.charts.timeCost')}: {activity.time_cost.toFixed(2)}s
{t('home.charts.status')}: {activity.status}
))}
{/* 日统计 */} {t('home.charts.dailyStats')} {t('home.charts.dailyStatsDesc')} { const date = new Date(value) return `${date.getMonth() + 1}/${date.getDate()}` }} stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} /> { const date = new Date(value as string) return date.toLocaleDateString('zh-CN') }} /> } /> } /> {/* 重启遮罩层 */} {/* 表达方式审核器 */} { setIsReviewerOpen(open) if (!open) { // 关闭审核器时刷新统计 fetchReviewStats() } }} />
) }