refactor: 枚举移入Models目录,命名空间更新为Rainbow.Entity.Models
大石头 authored at 2026-07-02 12:54:58
10.27 KiB
RainbowBridge
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>
  )
}