793 lines
36 KiB
TypeScript
793 lines
36 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
|
||
type DatePreset = 'today' | 'last7' | 'last30' | 'custom'
|
||
|
||
interface PublicTrafficStats {
|
||
generatedAt: string
|
||
available: boolean
|
||
range: { start: string; end: string }
|
||
summary: {
|
||
responseBytes: number
|
||
requestBytes: number | null
|
||
requestBytesKnownCount: number
|
||
totalRequests: number
|
||
uniqueIpCount: number
|
||
statusCounts: Record<string, number>
|
||
firstSeen: string | null
|
||
lastSeen: string | null
|
||
}
|
||
byDay: Array<{
|
||
date: string
|
||
responseBytes: number
|
||
requestBytes: number | null
|
||
requestBytesKnownCount: number
|
||
totalRequests: number
|
||
}>
|
||
byDomain: Array<{
|
||
domain: string
|
||
responseBytes: number
|
||
requestBytes: number | null
|
||
requestBytesKnownCount: number
|
||
totalRequests: number
|
||
statusCounts: Record<string, number>
|
||
}>
|
||
byPurpose: Array<{
|
||
key: string
|
||
label: string
|
||
category: string
|
||
detail: string
|
||
responseBytes: number
|
||
requestBytes: number | null
|
||
requestBytesKnownCount: number
|
||
totalRequests: number
|
||
}>
|
||
topPaths: Array<{
|
||
path: string
|
||
method: string
|
||
purposeLabel: string
|
||
purposeCategory: string
|
||
purposeDetail: string
|
||
responseBytes: number
|
||
totalRequests: number
|
||
}>
|
||
source: {
|
||
logPaths: string[]
|
||
scannedFiles: number
|
||
scannedLines: number
|
||
matchedLines: number
|
||
skippedLines: number
|
||
truncated: boolean
|
||
issues: Array<{ path: string; reason: string }>
|
||
requestBytesAvailable: boolean
|
||
cache?: {
|
||
persisted: boolean
|
||
hit: boolean
|
||
stale: boolean
|
||
refreshInProgress: boolean
|
||
refreshedAt: string | null
|
||
expiresAt: string | null
|
||
}
|
||
}
|
||
}
|
||
|
||
interface PublicTrafficTabProps {
|
||
showToast: (message: string, type?: 'success' | 'error') => void
|
||
}
|
||
|
||
interface PublicTrafficCacheEntry {
|
||
stats: PublicTrafficStats
|
||
cachedAt: number
|
||
}
|
||
|
||
// ============ 工具函数 ============
|
||
function toDateInputValue(date: Date) {
|
||
const y = date.getFullYear()
|
||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||
const d = String(date.getDate()).padStart(2, '0')
|
||
return `${y}-${m}-${d}`
|
||
}
|
||
|
||
function presetRange(preset: Exclude<DatePreset, 'custom'>) {
|
||
const now = new Date()
|
||
const end = toDateInputValue(now)
|
||
const startDate = new Date(now)
|
||
if (preset === 'today') return { start: end, end }
|
||
startDate.setDate(now.getDate() - (preset === 'last7' ? 6 : 29))
|
||
return { start: toDateInputValue(startDate), end }
|
||
}
|
||
|
||
function formatBytes(bytes: number | null | undefined) {
|
||
if (bytes === null || bytes === undefined) return '—'
|
||
if (bytes < 1024) return `${bytes.toFixed(0)} B`
|
||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
|
||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(2)} MB`
|
||
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(2)} GB`
|
||
return `${(bytes / 1024 ** 4).toFixed(2)} TB`
|
||
}
|
||
|
||
function formatNumber(value: number) {
|
||
return new Intl.NumberFormat('zh-CN').format(value)
|
||
}
|
||
|
||
function formatTime(value: string | null) {
|
||
if (!value) return '—'
|
||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||
}
|
||
|
||
// 类别中文映射 + 图标 + 颜色。后端目前返回的是中文 category,这里兼容旧英文分类。
|
||
type CategoryMeta = { label: string; icon: string; barColor: string; badgeClass: string }
|
||
type CategoryNarrative = { title: string }
|
||
|
||
const CATEGORY_META: Record<string, CategoryMeta> = {
|
||
image_direct: {
|
||
label: '图片/文件直链',
|
||
icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||
barColor: 'bg-sky-500',
|
||
badgeClass: 'bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-300',
|
||
},
|
||
material_download: {
|
||
label: '素材下载',
|
||
icon: 'M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
|
||
barColor: 'bg-teal-500',
|
||
badgeClass: 'bg-teal-50 text-teal-700 dark:bg-teal-950/40 dark:text-teal-300',
|
||
},
|
||
api_request: {
|
||
label: '接口请求',
|
||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||
barColor: 'bg-violet-500',
|
||
badgeClass: 'bg-violet-50 text-violet-700 dark:bg-violet-950/40 dark:text-violet-300',
|
||
},
|
||
素材库: {
|
||
label: '素材库业务',
|
||
icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
|
||
barColor: 'bg-teal-500',
|
||
badgeClass: 'bg-teal-50 text-teal-700 dark:bg-teal-950/40 dark:text-teal-300',
|
||
},
|
||
外部接口: {
|
||
label: '外部接口',
|
||
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
|
||
barColor: 'bg-violet-500',
|
||
badgeClass: 'bg-violet-50 text-violet-700 dark:bg-violet-950/40 dark:text-violet-300',
|
||
},
|
||
产品设计平台: {
|
||
label: '产品设计平台',
|
||
icon: 'M9.75 3l-.75 5.25L3.75 9l5.25.75L9.75 15l.75-5.25L15.75 9l-5.25-.75L9.75 3zM18 12l-.5 3.5L14 16l3.5.5L18 20l.5-3.5L22 16l-3.5-.5L18 12z',
|
||
barColor: 'bg-indigo-500',
|
||
badgeClass: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-300',
|
||
},
|
||
资源直链: {
|
||
label: '资源直链',
|
||
icon: 'M13.19 8.688a4.5 4.5 0 016.364 6.364l-1.757 1.757a4.5 4.5 0 01-6.364 0M10.81 15.312a4.5 4.5 0 01-6.364-6.364l1.757-1.757a4.5 4.5 0 016.364 0',
|
||
barColor: 'bg-sky-500',
|
||
badgeClass: 'bg-sky-50 text-sky-700 dark:bg-sky-950/40 dark:text-sky-300',
|
||
},
|
||
直传资源: {
|
||
label: '直传资源',
|
||
icon: 'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 7.5L12 3m0 0l4.5 4.5M12 3v13.5',
|
||
barColor: 'bg-cyan-500',
|
||
badgeClass: 'bg-cyan-50 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-300',
|
||
},
|
||
历史上传资源: {
|
||
label: '历史上传资源',
|
||
icon: 'M12 6v6l4 2m6-2a10 10 0 11-3.07-7.07M21 3v6h-6',
|
||
barColor: 'bg-blue-500',
|
||
badgeClass: 'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-300',
|
||
},
|
||
账号: {
|
||
label: '账号登录',
|
||
icon: 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 20.25a8.25 8.25 0 1115 0',
|
||
barColor: 'bg-emerald-500',
|
||
badgeClass: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300',
|
||
},
|
||
后台: {
|
||
label: '后台管理',
|
||
icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||
barColor: 'bg-slate-500',
|
||
badgeClass: 'bg-slate-50 text-slate-700 dark:bg-slate-800 dark:text-slate-200',
|
||
},
|
||
页面加载: {
|
||
label: '页面加载',
|
||
icon: 'M3 8h18M8 3v18m8-18v18M3 16h18',
|
||
barColor: 'bg-orange-500',
|
||
badgeClass: 'bg-orange-50 text-orange-700 dark:bg-orange-950/40 dark:text-orange-300',
|
||
},
|
||
资源文件: {
|
||
label: '页面静态资源',
|
||
icon: 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5A3.375 3.375 0 0010.125 2.25H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z',
|
||
barColor: 'bg-lime-500',
|
||
badgeClass: 'bg-lime-50 text-lime-700 dark:bg-lime-950/40 dark:text-lime-300',
|
||
},
|
||
其他: {
|
||
label: '其他流量',
|
||
icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||
barColor: 'bg-amber-500',
|
||
badgeClass: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
|
||
},
|
||
other: {
|
||
label: '其他流量',
|
||
icon: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||
barColor: 'bg-amber-500',
|
||
badgeClass: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
|
||
},
|
||
}
|
||
|
||
function normalizeCategoryKey(category: string) {
|
||
const trimmed = (category || '').trim()
|
||
if (!trimmed) return 'other'
|
||
if (trimmed === '其它资源' || trimmed === '其他资源') return '其他'
|
||
return trimmed
|
||
}
|
||
|
||
function getCategoryMeta(category: string) {
|
||
const key = normalizeCategoryKey(category)
|
||
const meta = CATEGORY_META[key]
|
||
if (meta) return meta
|
||
return { ...CATEGORY_META.other, label: key }
|
||
}
|
||
|
||
function getCategoryNarrative(category: string): CategoryNarrative {
|
||
const key = normalizeCategoryKey(category)
|
||
const narratives: Record<string, CategoryNarrative> = {
|
||
素材库: {
|
||
title: '素材库业务',
|
||
},
|
||
历史上传资源: {
|
||
title: '历史上传图片/文件',
|
||
},
|
||
资源直链: {
|
||
title: '图片/文件直链访问',
|
||
},
|
||
直传资源: {
|
||
title: '直传资源访问',
|
||
},
|
||
产品设计平台: {
|
||
title: '产品设计平台',
|
||
},
|
||
外部接口: {
|
||
title: '外部平台接口',
|
||
},
|
||
后台: {
|
||
title: '后台管理',
|
||
},
|
||
账号: {
|
||
title: '登录与认证',
|
||
},
|
||
页面加载: {
|
||
title: '页面资源',
|
||
},
|
||
资源文件: {
|
||
title: '静态文件',
|
||
},
|
||
其他: {
|
||
title: '未归类',
|
||
},
|
||
other: {
|
||
title: '未归类',
|
||
},
|
||
}
|
||
|
||
return narratives[key] || { title: getCategoryMeta(key).label }
|
||
}
|
||
|
||
const PRESET_OPTIONS: Array<{ key: Exclude<DatePreset, 'custom'>; label: string }> = [
|
||
{ key: 'today', label: '今天' },
|
||
{ key: 'last7', label: '近7天' },
|
||
{ key: 'last30', label: '近30天' },
|
||
]
|
||
|
||
export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
|
||
const [preset, setPreset] = useState<DatePreset>('last7')
|
||
const initialRange = useMemo(() => presetRange('last7'), [])
|
||
const [startDate, setStartDate] = useState(initialRange.start)
|
||
const [endDate, setEndDate] = useState(initialRange.end)
|
||
const [loading, setLoading] = useState(false)
|
||
const [stats, setStats] = useState<PublicTrafficStats | null>(null)
|
||
const [statsRangeKey, setStatsRangeKey] = useState('')
|
||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||
const requestSeqRef = useRef(0)
|
||
const abortRef = useRef<AbortController | null>(null)
|
||
const statsCacheRef = useRef(new Map<string, PublicTrafficCacheEntry>())
|
||
const activeRangeKey = `${startDate}|${endDate}`
|
||
|
||
const loadStats = useCallback(async (options?: { forceRefresh?: boolean }) => {
|
||
const rangeKey = `${startDate}|${endDate}`
|
||
const today = toDateInputValue(new Date())
|
||
const cacheTtlMs = endDate >= today ? 60_000 : 10 * 60_000
|
||
const cached = statsCacheRef.current.get(rangeKey)
|
||
|
||
if (!options?.forceRefresh && cached && Date.now() - cached.cachedAt <= cacheTtlMs) {
|
||
requestSeqRef.current += 1
|
||
abortRef.current?.abort()
|
||
setStats(cached.stats)
|
||
setStatsRangeKey(rangeKey)
|
||
setLoading(false)
|
||
return
|
||
}
|
||
|
||
const requestSeq = requestSeqRef.current + 1
|
||
requestSeqRef.current = requestSeq
|
||
abortRef.current?.abort()
|
||
const controller = new AbortController()
|
||
abortRef.current = controller
|
||
|
||
if (!cached) {
|
||
setStatsRangeKey('')
|
||
}
|
||
setLoading(true)
|
||
try {
|
||
const params = new URLSearchParams()
|
||
params.set('start', startDate)
|
||
params.set('end', endDate)
|
||
if (options?.forceRefresh) params.set('refresh', '1')
|
||
const response = await fetch(`/api/admin/public-traffic?${params.toString()}`, {
|
||
cache: 'no-store',
|
||
signal: controller.signal,
|
||
})
|
||
if (!response.ok) {
|
||
const data = await response.json().catch(() => null)
|
||
throw new Error(data?.error || '加载公网流量统计失败')
|
||
}
|
||
const nextStats = await response.json()
|
||
if (requestSeq !== requestSeqRef.current) return
|
||
|
||
statsCacheRef.current.set(rangeKey, {
|
||
stats: nextStats,
|
||
cachedAt: Date.now(),
|
||
})
|
||
setStats(nextStats)
|
||
setStatsRangeKey(rangeKey)
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||
console.error('Load public traffic stats error:', error)
|
||
showToast(error instanceof Error ? error.message : '加载公网流量统计失败', 'error')
|
||
} finally {
|
||
if (requestSeq === requestSeqRef.current) {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
}, [endDate, showToast, startDate])
|
||
|
||
useEffect(() => { loadStats() }, [loadStats])
|
||
useEffect(() => () => abortRef.current?.abort(), [])
|
||
|
||
const handlePresetChange = (nextPreset: Exclude<DatePreset, 'custom'>) => {
|
||
const range = presetRange(nextPreset)
|
||
setPreset(nextPreset)
|
||
setStartDate(range.start)
|
||
setEndDate(range.end)
|
||
}
|
||
|
||
const togglePathExpand = (key: string) => {
|
||
setExpandedPaths((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(key)) next.delete(key)
|
||
else next.add(key)
|
||
return next
|
||
})
|
||
}
|
||
|
||
// 派生数据
|
||
const visibleStats = statsRangeKey === activeRangeKey ? stats : null
|
||
const responseBytes = visibleStats?.summary.responseBytes || 0
|
||
const requestBytes = visibleStats?.summary.requestBytes ?? null
|
||
const totalBytes = requestBytes === null ? responseBytes : responseBytes + requestBytes
|
||
const dayPeak = visibleStats?.byDay.reduce((max, item) => Math.max(max, item.responseBytes), 0) || 0
|
||
const peakDay = visibleStats?.byDay.find((d) => d.responseBytes === dayPeak)
|
||
|
||
// 按 category 分组 byPurpose
|
||
const purposeByCategory = useMemo(() => {
|
||
if (!visibleStats) return new Map<string, PublicTrafficStats['byPurpose'][number][]>()
|
||
const map = new Map<string, PublicTrafficStats['byPurpose'][number][]>()
|
||
for (const item of visibleStats.byPurpose) {
|
||
const cat = normalizeCategoryKey(item.category)
|
||
if (!map.has(cat)) map.set(cat, [])
|
||
map.get(cat)!.push(item)
|
||
}
|
||
// 每组内按流量降序
|
||
map.forEach((items) => {
|
||
items.sort((a: PublicTrafficStats['byPurpose'][number], b: PublicTrafficStats['byPurpose'][number]) => b.responseBytes - a.responseBytes)
|
||
})
|
||
return map
|
||
}, [visibleStats])
|
||
|
||
// category 合计
|
||
const categoryTotals = useMemo(() => {
|
||
if (!visibleStats) return [] as Array<{ category: string; bytes: number; requests: number }>
|
||
const map = new Map<string, { category: string; bytes: number; requests: number }>()
|
||
for (const item of visibleStats.byPurpose) {
|
||
const cat = normalizeCategoryKey(item.category)
|
||
const existing = map.get(cat) || { category: cat, bytes: 0, requests: 0 }
|
||
existing.bytes += item.responseBytes
|
||
existing.requests += item.totalRequests
|
||
map.set(cat, existing)
|
||
}
|
||
return Array.from(map.values()).sort((a, b) => b.bytes - a.bytes)
|
||
}, [visibleStats])
|
||
|
||
const categoryInsights = useMemo(() => {
|
||
return categoryTotals.map((cat, index) => {
|
||
const items: PublicTrafficStats['byPurpose'][number][] = purposeByCategory.get(cat.category) || []
|
||
const share = responseBytes > 0 ? (cat.bytes / responseBytes) * 100 : 0
|
||
return {
|
||
...cat,
|
||
rank: index + 1,
|
||
share,
|
||
meta: getCategoryMeta(cat.category),
|
||
narrative: getCategoryNarrative(cat.category),
|
||
topPurpose: items[0] || null,
|
||
}
|
||
})
|
||
}, [categoryTotals, purposeByCategory, responseBytes])
|
||
|
||
const visibleCategoryInsights = categoryInsights.filter((cat) => cat.rank <= 5 || cat.share >= 1)
|
||
|
||
if (!visibleStats) {
|
||
return (
|
||
<div className="flex min-h-[300px] items-center justify-center text-gray-400 dark:text-gray-500">
|
||
{loading ? '正在读取固化汇总...' : '暂无数据'}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* ===== 顶部:标题 + 时间筛选 ===== */}
|
||
<div className="card-apple p-5">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<div>
|
||
<h2 className="text-xl font-semibold tracking-tight text-gray-900 dark:text-white">公网流量看板</h2>
|
||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
按公网入口日志统计 · 不含七牛 CDN 流量(以七牛后台为准)
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<div className="inline-flex rounded-apple border border-gray-200 bg-gray-50 p-1 dark:border-gray-700 dark:bg-gray-900">
|
||
{PRESET_OPTIONS.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onClick={() => handlePresetChange(item.key)}
|
||
className={`rounded-[10px] px-3.5 py-1.5 text-sm font-medium transition-all ${
|
||
preset === item.key
|
||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
|
||
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
|
||
}`}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-300">
|
||
<span className="text-gray-400">起</span>
|
||
<input
|
||
type="date"
|
||
value={startDate}
|
||
onChange={(e) => { setPreset('custom'); setStartDate(e.target.value) }}
|
||
className="input-apple py-1.5 px-2.5 text-sm"
|
||
/>
|
||
</label>
|
||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-300">
|
||
<span className="text-gray-400">止</span>
|
||
<input
|
||
type="date"
|
||
value={endDate}
|
||
onChange={(e) => { setPreset('custom'); setEndDate(e.target.value) }}
|
||
className="input-apple py-1.5 px-2.5 text-sm"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={() => loadStats({ forceRefresh: true })}
|
||
disabled={loading}
|
||
className="btn-primary text-sm"
|
||
>
|
||
{loading ? '刷新中...' : '重新统计'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{!visibleStats.available && (
|
||
<div className="card-apple border-amber-200 bg-amber-50 p-4 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
|
||
暂未读取到公网入口日志。请配置 PUBLIC_TRAFFIC_LOG_PATHS 环境变量,或把穿透服务器 Nginx access log 同步到应用服务器。
|
||
</div>
|
||
)}
|
||
|
||
{visibleStats.available && (
|
||
<>
|
||
{/* ===== 顶部汇总卡片 ===== */}
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||
{/* 总流量(大卡,突出) */}
|
||
<div className="card-apple relative overflow-hidden p-5 pl-6 md:col-span-2">
|
||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-sky-400 to-sky-600" />
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">统计总流量</div>
|
||
<div className="mt-2 text-4xl font-bold tracking-tight text-gray-900 dark:text-white tabular-nums">
|
||
{formatBytes(totalBytes)}
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||
<span>响应 {formatBytes(responseBytes)}</span>
|
||
{requestBytes !== null && <span>入站 {formatBytes(requestBytes)}</span>}
|
||
{peakDay && <span className="text-sky-600 dark:text-sky-400">峰值 {peakDay.date}({formatBytes(peakDay.responseBytes)})</span>}
|
||
{requestBytes === null && <span>· 日志未含入站字节,按响应流量统计</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 请求次数 */}
|
||
<div className="card-apple p-5">
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">请求次数</div>
|
||
<div className="mt-2 text-3xl font-bold tabular-nums text-gray-900 dark:text-white">{formatNumber(visibleStats.summary.totalRequests)}</div>
|
||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{formatNumber(visibleStats.summary.uniqueIpCount)} 个客户端 IP</div>
|
||
</div>
|
||
|
||
{/* 日志时间范围 */}
|
||
<div className="card-apple p-5">
|
||
<div className="text-sm text-gray-500 dark:text-gray-400">日志范围</div>
|
||
<div className="mt-2 text-lg font-semibold tabular-nums text-gray-900 dark:text-white">
|
||
{formatTime(visibleStats.summary.firstSeen).slice(5, 17)}
|
||
</div>
|
||
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">至 {formatTime(visibleStats.summary.lastSeen).slice(5, 17)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== 流量来源分组(人类视角)===== */}
|
||
<div className="card-apple overflow-hidden">
|
||
<div className="border-b border-gray-200 px-5 py-4 dark:border-gray-700">
|
||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">流量来源</h3>
|
||
</div>
|
||
|
||
<div className="p-5">
|
||
{categoryInsights.length === 0 && (
|
||
<div className="py-8 text-center text-sm text-gray-400 dark:text-gray-500">当前范围没有流量记录</div>
|
||
)}
|
||
|
||
<div className="space-y-3">
|
||
{visibleCategoryInsights.map((cat) => {
|
||
return (
|
||
<div key={cat.category} className="rounded-apple border border-gray-100 px-4 py-3 dark:border-gray-800">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="flex min-w-0 items-center gap-3">
|
||
<span className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-apple ${cat.meta.badgeClass}`}>
|
||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d={cat.meta.icon} />
|
||
</svg>
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">{cat.narrative.title}</div>
|
||
<div className="mt-1 flex min-w-0 items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
|
||
<span className="tabular-nums">{formatNumber(cat.requests)} 次</span>
|
||
{cat.topPurpose && cat.topPurpose.label !== cat.narrative.title && (
|
||
<span className="hidden min-w-0 truncate sm:inline">· {cat.topPurpose.label}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
<div className="text-base font-semibold tabular-nums text-gray-900 dark:text-white">{formatBytes(cat.bytes)}</div>
|
||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||
{cat.share.toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3">
|
||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||
<div className={`h-full rounded-full ${cat.meta.barColor} transition-all duration-500`} style={{ width: `${Math.max(cat.share, 1)}%` }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 每类内的用途明细(展开式)*/}
|
||
{categoryInsights.length > 0 && (
|
||
<div className="border-t border-gray-100 px-5 pb-5 dark:border-gray-800">
|
||
<details>
|
||
<summary className="cursor-pointer py-3 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||
展开明细
|
||
</summary>
|
||
<div className="mt-2 space-y-3">
|
||
{categoryInsights.map((cat) => {
|
||
const items: PublicTrafficStats['byPurpose'][number][] = purposeByCategory.get(cat.category) || []
|
||
if (items.length === 0) return null
|
||
return (
|
||
<div key={cat.category} className="rounded-apple border border-gray-100 p-3 dark:border-gray-800">
|
||
<div className="mb-2 flex items-center gap-2 text-sm font-medium" style={{ color: undefined }}>
|
||
<span className={`inline-block h-3 w-1 rounded-full ${cat.meta.barColor}`} />
|
||
{cat.narrative.title}
|
||
</div>
|
||
<div className="space-y-2">
|
||
{items.map((item) => {
|
||
const itemPct = cat.bytes > 0 ? (item.responseBytes / cat.bytes) * 100 : 0
|
||
return (
|
||
<div key={item.key} className="flex items-center justify-between gap-3 text-xs">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="truncate text-gray-700 dark:text-gray-200">{item.label}</span>
|
||
{item.detail && (
|
||
<span className="hidden truncate text-gray-400 dark:text-gray-500 sm:block" title={item.detail}>
|
||
· {item.detail}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2 tabular-nums">
|
||
<span className="font-medium text-gray-900 dark:text-white">{formatBytes(item.responseBytes)}</span>
|
||
<span className="text-gray-400 dark:text-gray-500">{itemPct.toFixed(0)}%</span>
|
||
<span className="text-gray-400 dark:text-gray-500">· {formatNumber(item.totalRequests)}次</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</details>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ===== 两列:域名分布 + 每日趋势 ===== */}
|
||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||
{/* 域名分布 */}
|
||
<div className="card-apple overflow-hidden">
|
||
<div className="border-b border-gray-200 px-5 py-4 dark:border-gray-700">
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">按域名</h3>
|
||
</div>
|
||
<div className="space-y-3 p-5">
|
||
{visibleStats.byDomain.length === 0 && <div className="py-4 text-sm text-gray-400 dark:text-gray-500">无数据</div>}
|
||
{visibleStats.byDomain.map((item) => {
|
||
const pct = responseBytes > 0 ? Math.round((item.responseBytes / responseBytes) * 100) : 0
|
||
return (
|
||
<div key={item.domain}>
|
||
<div className="mb-1.5 flex items-center justify-between gap-2 text-sm">
|
||
<span className="font-medium text-gray-800 dark:text-gray-100">{item.domain}</span>
|
||
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-400 tabular-nums">
|
||
{formatBytes(item.responseBytes)} · {formatNumber(item.totalRequests)}次
|
||
</span>
|
||
</div>
|
||
<div className="h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||
<div className="h-full rounded-full bg-sky-500" style={{ width: `${Math.max(pct, 2)}%` }} />
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 每日趋势(迷你柱状)*/}
|
||
<div className="card-apple overflow-hidden">
|
||
<div className="border-b border-gray-200 px-5 py-4 dark:border-gray-700">
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">每日流量</h3>
|
||
</div>
|
||
<div className="p-5">
|
||
{visibleStats.byDay.length === 0 && <div className="py-4 text-sm text-gray-400 dark:text-gray-500">无数据</div>}
|
||
<div className="flex items-end gap-1" style={{ height: '120px' }}>
|
||
{visibleStats.byDay.map((item) => {
|
||
const heightPct = dayPeak > 0 ? (item.responseBytes / dayPeak) * 100 : 0
|
||
const isPeak = item.responseBytes === dayPeak && dayPeak > 0
|
||
return (
|
||
<div key={item.date} className="group relative flex flex-1 flex-col items-center justify-end" style={{ minWidth: '8px' }}>
|
||
<div
|
||
className={`w-full rounded-t transition-all duration-300 ${isPeak ? 'bg-teal-500' : 'bg-teal-300 dark:bg-teal-700'}`}
|
||
style={{ height: `${Math.max(heightPct, 2)}%` }}
|
||
title={`${item.date}: ${formatBytes(item.responseBytes)} · ${formatNumber(item.totalRequests)}次`}
|
||
/>
|
||
{/* hover tooltip */}
|
||
<div className="pointer-events-none absolute -top-12 z-10 hidden whitespace-nowrap rounded-lg bg-gray-900 px-2 py-1 text-xs text-white group-hover:block">
|
||
{item.date.slice(5)}: {formatBytes(item.responseBytes)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{visibleStats.byDay.length > 0 && (
|
||
<div className="mt-2 flex justify-between text-xs text-gray-400 dark:text-gray-500">
|
||
<span>{visibleStats.byDay[0]?.date.slice(5)}</span>
|
||
<span>{visibleStats.byDay[visibleStats.byDay.length - 1]?.date.slice(5)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== 高消耗资源 Top(可展开原始路径)===== */}
|
||
<div className="card-apple overflow-hidden">
|
||
<div className="flex items-center justify-between border-b border-gray-200 px-5 py-4 dark:border-gray-700">
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">高消耗资源 Top {visibleStats.topPaths.length}</h3>
|
||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">点击展开查看原始路径</p>
|
||
</div>
|
||
</div>
|
||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||
{visibleStats.topPaths.length === 0 && (
|
||
<div className="py-8 text-center text-sm text-gray-400 dark:text-gray-500">无数据</div>
|
||
)}
|
||
{visibleStats.topPaths.map((item, idx) => {
|
||
const meta = getCategoryMeta(item.purposeCategory)
|
||
const rowKey = `${item.method}-${item.path}`
|
||
const expanded = expandedPaths.has(rowKey)
|
||
const pct = responseBytes > 0 ? (item.responseBytes / responseBytes) * 100 : 0
|
||
return (
|
||
<div key={rowKey} className="px-5 py-3 transition-colors hover:bg-gray-50/60 dark:hover:bg-gray-800/30">
|
||
<button
|
||
type="button"
|
||
onClick={() => togglePathExpand(rowKey)}
|
||
className="flex w-full items-center gap-3 text-left"
|
||
>
|
||
{/* 排名 */}
|
||
<span className="shrink-0 inline-flex h-6 w-6 items-center justify-center rounded-apple bg-gray-100 text-xs font-bold text-gray-500 dark:bg-gray-700 dark:text-gray-300 tabular-nums">
|
||
{idx + 1}
|
||
</span>
|
||
{/* 类别标签 */}
|
||
<span className={`shrink-0 inline-flex items-center gap-1 rounded-apple px-2 py-0.5 text-xs font-medium ${meta.badgeClass}`}>
|
||
{meta.label}
|
||
</span>
|
||
{/* 用途描述 */}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{item.purposeLabel}</div>
|
||
{item.purposeDetail && (
|
||
<div className="truncate text-xs text-gray-400 dark:text-gray-500">{item.purposeDetail}</div>
|
||
)}
|
||
</div>
|
||
{/* 数值 */}
|
||
<div className="flex shrink-0 items-center gap-3 text-sm tabular-nums">
|
||
<div className="text-right">
|
||
<div className="font-semibold text-gray-900 dark:text-white">{formatBytes(item.responseBytes)}</div>
|
||
<div className="text-xs text-gray-400 dark:text-gray-500">{pct.toFixed(1)}% · {formatNumber(item.totalRequests)}次</div>
|
||
</div>
|
||
<svg className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${expanded ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</div>
|
||
</button>
|
||
{/* 展开后的原始路径 */}
|
||
{expanded && (
|
||
<div className="mt-2 ml-9 space-y-1 rounded-apple bg-gray-50 p-3 dark:bg-gray-800/50">
|
||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||
<span className="font-mono rounded bg-gray-200 px-1 py-0.5 text-[10px] dark:bg-gray-700">{item.method}</span>
|
||
<span className="text-gray-400 dark:text-gray-500">类别:</span>
|
||
<span>{item.purposeCategory}</span>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<code className="text-xs text-gray-600 dark:text-gray-300 break-all">{item.path}</code>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ===== 底部统计信息 ===== */}
|
||
<div className="card-apple p-4 text-xs text-gray-500 dark:text-gray-400">
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||
<span>{visibleStats.source.cache?.persisted ? '数据源:固化汇总表' : `已扫描 ${formatNumber(visibleStats.source.scannedFiles)} 个日志文件`}</span>
|
||
<span>·</span>
|
||
<span>{formatNumber(visibleStats.source.matchedLines)} 条有效请求</span>
|
||
{visibleStats.source.truncated && <span className="text-amber-600 dark:text-amber-400">· 读取达上限,结果可能不完整</span>}
|
||
</div>
|
||
{!visibleStats.source.requestBytesAvailable && (
|
||
<div className="mt-1 text-gray-400 dark:text-gray-500">
|
||
日志未含 request_length 字段,上传入站流量无法精确拆分。
|
||
</div>
|
||
)}
|
||
{visibleStats.source.cache && (
|
||
<div className="mt-1 text-gray-400 dark:text-gray-500">
|
||
数据缓存:
|
||
{visibleStats.source.cache.hit ? '已读取固化结果' : '本次重新统计'}
|
||
{visibleStats.source.cache.refreshedAt && `,更新时间 ${formatTime(visibleStats.source.cache.refreshedAt)}`}
|
||
{visibleStats.source.cache.stale && ',正在后台刷新'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|