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,
BarChart3,
TrendingUp,
DollarSign,
Clock,
MessageSquare,
Zap,
Database,
RefreshCw,
Power,
RotateCcw,
FileText,
Settings,
Puzzle,
CheckCircle2,
AlertCircle,
ClipboardList,
ClipboardCheck,
ExternalLink,
} 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 { getBotConfig, getModelConfig } from '@/lib/config-api'
import { getReviewStats } from '@/lib/expression-api'
import { getDashboardVersionStatus, type DashboardVersionStatus } from '@/lib/system-api'
import { APP_VERSION } from '@/lib/version'
import { ZoomableChart } from '@/components/ui/zoomable-chart'
// 主导出组件:包装 RestartProvider
export function IndexPage() {
return (
)
}
// 机器人状态接口
interface BotStatus {
running: boolean
uptime: number
version: string
start_time: string
}
interface ReleaseStatus {
version: string
url: 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[]
}
interface FeatureStatus {
memoryEnabled: boolean
visualEnabled: boolean
}
// 为饼图生成更丰富的颜色方案 (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 FeatureStatusLight({ enabled, label }: { enabled: boolean; label: string }) {
return (
{label}
)
}
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(false)
const [hitokoto, setHitokoto] = useState<{ hitokoto: string; from: string } | null>(null)
const [hitokotoLoading, setHitokotoLoading] = useState(true)
const [botStatus, setBotStatus] = useState(null)
const [maibotStableRelease, setMaibotStableRelease] = useState(null)
const [maibotTestRelease, setMaibotTestRelease] = useState(null)
const [dashboardVersionStatus, setDashboardVersionStatus] = useState(null)
const [featureStatus, setFeatureStatus] = useState({
memoryEnabled: false,
visualEnabled: false,
})
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
}
}
}, [])
useEffect(() => {
let mounted = true
const loadLatestVersions = async () => {
try {
const response = await fetch('https://api.github.com/repos/Mai-with-u/MaiBot/releases?per_page=20', {
headers: { Accept: 'application/vnd.github+json' },
})
if (!response.ok) {
throw new Error(`GitHub release status ${response.status}`)
}
const releases = await response.json() as Array<{
draft?: boolean
prerelease?: boolean
tag_name?: string
html_url?: string
}>
const visibleReleases = releases.filter((release) => !release.draft)
const stableRelease = visibleReleases.find((release) => !release.prerelease)
const testRelease = visibleReleases[0]
if (mounted) {
if (stableRelease?.tag_name) {
setMaibotStableRelease({
version: String(stableRelease.tag_name).replace(/^v/i, '').trim(),
url: stableRelease.html_url || 'https://github.com/Mai-with-u/MaiBot/releases',
})
}
if (testRelease?.tag_name) {
setMaibotTestRelease({
version: String(testRelease.tag_name).replace(/^v/i, '').trim(),
url: testRelease.html_url || 'https://github.com/Mai-with-u/MaiBot/releases',
})
}
}
} catch (error) {
console.debug('检查 MaiBot 最新版本失败:', error)
}
try {
const status = await getDashboardVersionStatus(APP_VERSION)
if (mounted) {
setDashboardVersionStatus(status)
}
} catch (error) {
console.debug('妫€鏌?WebUI 鐗堟湰鏇存柊澶辫触:', error)
}
}
void loadLatestVersions()
return () => {
mounted = false
}
}, [])
// 获取审核统计
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 fetchFeatureStatus = useCallback(async () => {
try {
const [botConfigResult, modelConfigResult] = await Promise.all([
getBotConfig(),
getModelConfig(),
])
if (!isMountedRef.current || !botConfigResult.success) return
const botPayload = botConfigResult.data as { config?: Record } & Record
const botConfig = (botPayload.config ?? botPayload) as Record
const memorixConfig = (botConfig.a_memorix ?? {}) as Record
const memorixPlugin = (memorixConfig.plugin ?? {}) as Record
const modelPayload = modelConfigResult.success
? (modelConfigResult.data as { config?: Record } & Record)
: {}
const modelConfig = (modelPayload.config ?? modelPayload) as Record
const taskConfig = (modelConfig.model_task_config ?? {}) as Record
const vlmTask = (taskConfig.vlm ?? {}) as Record
const vlmModelList = Array.isArray(vlmTask.model_list) ? vlmTask.model_list : []
const hasVlmModel = vlmModelList.some((modelName) => String(modelName ?? '').trim().length > 0)
setFeatureStatus({
memoryEnabled: memorixPlugin.enabled === true,
visualEnabled: hasVlmModel,
})
} catch (error) {
console.error('获取功能启用状态失败:', error)
if (isMountedRef.current) {
setFeatureStatus({
memoryEnabled: false,
visualEnabled: false,
})
}
}
}, [])
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()
fetchFeatureStatus()
fetchReviewStats()
}, [fetchDashboardData, fetchHitokoto, fetchBotStatus, fetchFeatureStatus, fetchReviewStats])
// 自动刷新
useEffect(() => {
// 清理旧的定时器
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current)
refreshIntervalRef.current = null
}
if (!autoRefresh) return
refreshIntervalRef.current = setInterval(() => {
if (isMountedRef.current) {
fetchDashboardData()
fetchBotStatus()
fetchFeatureStatus()
}
}, 30000) // 30秒刷新一次
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current)
refreshIntervalRef.current = null
}
}
}, [autoRefresh, fetchDashboardData, fetchBotStatus, fetchFeatureStatus])
if (loading || !dashboardData) {
return (
{t('home.loading')}
{t('home.loadingHint')}
)
}
// 解构数据,提供默认值以防止 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}
{/* 机器人状态和快速操作 */}
{/* 机器人状态卡片 */}
麦麦版本
主程序版本
{botStatus?.version ? `v${botStatus.version}` : '未知'}
WebUI 版本
v{APP_VERSION}
最新版本 v{dashboardVersionStatus?.latest_version || APP_VERSION}
{t('home.botStatus.title')}
{botStatus?.running ? (
<>
{t('home.botStatus.running')}
>
) : (
<>
{t('home.botStatus.stopped')}
>
)}
{botStatus && (
{t('home.botStatus.uptime', { time: formatTime(botStatus.uptime) })}
)}
{/* 快速操作卡片 */}
{t('home.quickActions.title')}
{/* 问卷调查卡片 */}
{t('home.survey.title')}
{/* 核心指标卡片 */}
{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) => (
{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()
}
}}
/>
)
}