import { useEffect, useState, useRef } from 'react'
import * as echarts from 'echarts'
import { Badge } from '@/components/atoms'
import { getSystemInfo, getDnsBlockStats, getMemberCompare, type SystemInfo, type DnsBlockCategory, type MemberCompareItem } from '@/lib/api'
import { formatBytes, formatDuration } from '@/lib/utils'
export function StatsPage() {
const [info, setInfo] = useState<SystemInfo | null>(null)
const [blockStats, setBlockStats] = useState<DnsBlockCategory[]>([])
const [memberCompare, setMemberCompare] = useState<MemberCompareItem[]>([])
const [loading, setLoading] = useState(true)
const chartRef = useRef<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
useEffect(() => {
Promise.all([getSystemInfo(), getDnsBlockStats(), getMemberCompare(30)])
.then(([i, b, m]) => { setInfo(i); setBlockStats(b); setMemberCompare(m) })
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// 成员对比图表
useEffect(() => {
if (!chartRef.current || memberCompare.length === 0) return
if (chartInstance.current) chartInstance.current.dispose()
const chart = echarts.init(chartRef.current)
chartInstance.current = chart
const names = memberCompare.map(m => m.displayName || m.name)
const onlineHours = memberCompare.map(m => Math.round(m.totalOnlineSeconds / 3600 * 10) / 10)
const traffics = memberCompare.map(m => Math.round((m.totalRxBytes + m.totalTxBytes) / (1024 * 1024 * 1024) * 10) / 10)
const blocked = memberCompare.map(m => m.totalBlockedCount)
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'var(--color-surface-0)',
borderColor: 'var(--color-border-subtle)',
textStyle: { color: 'var(--color-text-primary)', fontSize: 12 },
},
legend: {
data: ['在线时长(h)', '总流量(GB)', '拦截次数'],
bottom: 0,
textStyle: { color: 'var(--color-text-secondary)', fontSize: 11 },
},
grid: { left: 60, right: 20, top: 20, bottom: 40 },
xAxis: {
type: 'category',
data: names,
axisLabel: { color: 'var(--color-text-secondary)', fontSize: 11 },
axisLine: { lineStyle: { color: 'var(--color-border-subtle)' } },
},
yAxis: [
{
type: 'value',
name: '时长/流量',
axisLabel: { color: 'var(--color-text-tertiary)', fontSize: 10 },
splitLine: { lineStyle: { color: 'var(--color-border-subtle)', type: 'dashed' } },
},
{
type: 'value',
name: '次数',
axisLabel: { color: 'var(--color-text-tertiary)', fontSize: 10 },
splitLine: { show: false },
},
],
series: [
{
name: '在线时长(h)',
type: 'bar',
data: onlineHours,
itemStyle: { color: '#34D399' },
barMaxWidth: 24,
},
{
name: '总流量(GB)',
type: 'bar',
data: traffics,
itemStyle: { color: '#60A5FA' },
barMaxWidth: 24,
},
{
name: '拦截次数',
type: 'bar',
yAxisIndex: 1,
data: blocked,
itemStyle: { color: '#F87171' },
barMaxWidth: 24,
},
],
})
const handleResize = () => chart.resize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chart.dispose()
}
}, [memberCompare])
// 主题变化时重置图表
useEffect(() => {
const observer = new MutationObserver(() => {
if (chartInstance.current) {
chartInstance.current.dispose()
chartInstance.current = null
// 触发重建
if (chartRef.current && memberCompare.length > 0) {
const chart = echarts.init(chartRef.current)
chartInstance.current = chart
// 简化的 resize 触发重绘
chart.resize()
}
}
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [memberCompare.length])
if (loading) return <div className="p-6 text-[var(--color-text-tertiary)]">加载中…</div>
return (
<div className="p-6 max-w-5xl">
<h1 className="text-lg font-bold text-[var(--color-text-primary)] mb-4">数据统计</h1>
{/* 系统概览 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<StatCard label="平台" value={info?.isLinux ? 'Linux' : 'Windows/macOS'} />
<StatCard label="主机名" value={info?.hostName || '-'} />
<StatCard label=".NET 版本" value={info?.dotNetVersion || '-'} />
<StatCard label="系统" value={info?.osVersion?.split(' ')[0] || '-'} />
</div>
{/* 成员对比 */}
{memberCompare.length > 0 && (
<div className="glass-panel p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">
成员对比(近 30 天)
</h2>
<div ref={chartRef} className="w-full" style={{ height: 300 }} />
{/* 数据明细 */}
<div className="mt-4 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)]">
<th className="text-left py-2 px-2">成员</th>
<th className="text-right py-2 px-2">角色</th>
<th className="text-right py-2 px-2">设备数</th>
<th className="text-right py-2 px-2">在线时长</th>
<th className="text-right py-2 px-2">总流量</th>
<th className="text-right py-2 px-2">拦截次数</th>
<th className="text-right py-2 px-2">月度配额</th>
</tr>
</thead>
<tbody>
{memberCompare.map((m) => (
<tr key={m.memberId} className="border-b border-[var(--color-border-subtle)] hover:bg-[var(--color-surface-1)]">
<td className="py-2 px-2 text-[var(--color-text-primary)]">{m.displayName}</td>
<td className="py-2 px-2 text-right text-[var(--color-text-secondary)]">{m.role || '-'}</td>
<td className="py-2 px-2 text-right text-[var(--color-text-secondary)]">{m.deviceCount}</td>
<td className="py-2 px-2 text-right text-[var(--color-text-secondary)]">{formatDuration(m.totalOnlineSeconds)}</td>
<td className="py-2 px-2 text-right text-[var(--color-text-secondary)]">{formatBytes(m.totalRxBytes + m.totalTxBytes)}</td>
<td className="py-2 px-2 text-right">
<Badge variant={m.totalBlockedCount > 100 ? 'danger' : m.totalBlockedCount > 10 ? 'warning' : 'default'}>
{m.totalBlockedCount}
</Badge>
</td>
<td className="py-2 px-2 text-right text-[var(--color-text-secondary)]">
{m.monthlyQuota > 0 ? formatBytes(m.monthlyQuota) : '不限'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 功能支持 */}
{info?.capabilities && (
<div className="glass-panel p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">功能支持度</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(info.capabilities).map(([key, val]) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Badge variant={val ? 'success' : 'default'}>{val ? '支持' : '不支持'}</Badge>
<span className="text-[var(--color-text-secondary)]">{key}</span>
</div>
))}
</div>
</div>
)}
{/* DNS 拦截统计 */}
<div className="glass-panel p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">DNS 拦截统计</h2>
{blockStats.length === 0 ? (
<p className="text-sm text-[var(--color-text-tertiary)] text-center py-8">暂无拦截数据</p>
) : (
<div className="space-y-2">
{blockStats.map((s) => {
const total = blockStats.reduce((sum, x) => sum + x.count, 0)
const pct = total > 0 ? Math.round((s.count / total) * 100) : 0
return (
<div key={s.category}>
<div className="flex justify-between text-xs mb-1">
<span className="text-[var(--color-text-secondary)]">{s.category}</span>
<span className="text-[var(--color-text-tertiary)]">{s.count} 次 ({pct}%)</span>
</div>
<div className="h-2 rounded-full bg-[var(--color-surface-3)] overflow-hidden">
<div className="h-full rounded-full bg-[image:var(--gradient-brand)] transition-all duration-500" style={{ width: `${Math.max(pct, 2)}%` }} />
</div>
</div>
)
})}
</div>
)}
</div>
{/* 数据说明 */}
<div className="glass-panel p-5">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-3">数据说明</h2>
<ul className="text-xs text-[var(--color-text-tertiary)] space-y-1 list-disc list-inside">
<li>网口流量每分钟采集一次,数据按天分表存储</li>
<li>分钟级数据保留 7 天,小时级保留 90 天,日级保留 365 天</li>
<li>设备级别流量统计需要 Linux 环境,Windows/macOS 仅支持基础系统信息</li>
<li>详细统计图表请访问 <a href="/admin/Stats" className="text-[var(--color-brand-500)] hover:underline">Cube 后台统计</a></li>
</ul>
</div>
</div>
)
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="glass-panel p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-brand-500)]">{value}</p>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">{label}</p>
</div>
)
}
|