gongxiangp2/components/admin/PublicTrafficTab.tsx
2026-07-08 15:59:11 +08:00

793 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}