'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 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 }> 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) { 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 = { 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 = { 素材库: { 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; label: string }> = [ { key: 'today', label: '今天' }, { key: 'last7', label: '近7天' }, { key: 'last30', label: '近30天' }, ] export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) { const [preset, setPreset] = useState('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(null) const [statsRangeKey, setStatsRangeKey] = useState('') const [expandedPaths, setExpandedPaths] = useState>(new Set()) const requestSeqRef = useRef(0) const abortRef = useRef(null) const statsCacheRef = useRef(new Map()) 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) => { 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() const map = new Map() 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() 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 (
{loading ? '正在读取固化汇总...' : '暂无数据'}
) } return (
{/* ===== 顶部:标题 + 时间筛选 ===== */}

公网流量看板

按公网入口日志统计 · 不含七牛 CDN 流量(以七牛后台为准)

{PRESET_OPTIONS.map((item) => ( ))}
{!visibleStats.available && (
暂未读取到公网入口日志。请配置 PUBLIC_TRAFFIC_LOG_PATHS 环境变量,或把穿透服务器 Nginx access log 同步到应用服务器。
)} {visibleStats.available && ( <> {/* ===== 顶部汇总卡片 ===== */}
{/* 总流量(大卡,突出) */}
统计总流量
{formatBytes(totalBytes)}
响应 {formatBytes(responseBytes)} {requestBytes !== null && 入站 {formatBytes(requestBytes)}} {peakDay && 峰值 {peakDay.date}({formatBytes(peakDay.responseBytes)})} {requestBytes === null && · 日志未含入站字节,按响应流量统计}
{/* 请求次数 */}
请求次数
{formatNumber(visibleStats.summary.totalRequests)}
{formatNumber(visibleStats.summary.uniqueIpCount)} 个客户端 IP
{/* 日志时间范围 */}
日志范围
{formatTime(visibleStats.summary.firstSeen).slice(5, 17)}
至 {formatTime(visibleStats.summary.lastSeen).slice(5, 17)}
{/* ===== 流量来源分组(人类视角)===== */}

流量来源

{categoryInsights.length === 0 && (
当前范围没有流量记录
)}
{visibleCategoryInsights.map((cat) => { return (
{cat.narrative.title}
{formatNumber(cat.requests)} 次 {cat.topPurpose && cat.topPurpose.label !== cat.narrative.title && ( · {cat.topPurpose.label} )}
{formatBytes(cat.bytes)}
{cat.share.toFixed(1)}%
) })}
{/* 每类内的用途明细(展开式)*/} {categoryInsights.length > 0 && (
展开明细
{categoryInsights.map((cat) => { const items: PublicTrafficStats['byPurpose'][number][] = purposeByCategory.get(cat.category) || [] if (items.length === 0) return null return (
{cat.narrative.title}
{items.map((item) => { const itemPct = cat.bytes > 0 ? (item.responseBytes / cat.bytes) * 100 : 0 return (
{item.label} {item.detail && ( · {item.detail} )}
{formatBytes(item.responseBytes)} {itemPct.toFixed(0)}% · {formatNumber(item.totalRequests)}次
) })}
) })}
)}
{/* ===== 两列:域名分布 + 每日趋势 ===== */}
{/* 域名分布 */}

按域名

{visibleStats.byDomain.length === 0 &&
无数据
} {visibleStats.byDomain.map((item) => { const pct = responseBytes > 0 ? Math.round((item.responseBytes / responseBytes) * 100) : 0 return (
{item.domain} {formatBytes(item.responseBytes)} · {formatNumber(item.totalRequests)}次
) })}
{/* 每日趋势(迷你柱状)*/}

每日流量

{visibleStats.byDay.length === 0 &&
无数据
}
{visibleStats.byDay.map((item) => { const heightPct = dayPeak > 0 ? (item.responseBytes / dayPeak) * 100 : 0 const isPeak = item.responseBytes === dayPeak && dayPeak > 0 return (
{/* hover tooltip */}
{item.date.slice(5)}: {formatBytes(item.responseBytes)}
) })}
{visibleStats.byDay.length > 0 && (
{visibleStats.byDay[0]?.date.slice(5)} {visibleStats.byDay[visibleStats.byDay.length - 1]?.date.slice(5)}
)}
{/* ===== 高消耗资源 Top(可展开原始路径)===== */}

高消耗资源 Top {visibleStats.topPaths.length}

点击展开查看原始路径

{visibleStats.topPaths.length === 0 && (
无数据
)} {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 (
{/* 展开后的原始路径 */} {expanded && (
{item.method} 类别: {item.purposeCategory}
{item.path}
)}
) })}
{/* ===== 底部统计信息 ===== */}
{visibleStats.source.cache?.persisted ? '数据源:固化汇总表' : `已扫描 ${formatNumber(visibleStats.source.scannedFiles)} 个日志文件`} · {formatNumber(visibleStats.source.matchedLines)} 条有效请求 {visibleStats.source.truncated && · 读取达上限,结果可能不完整}
{!visibleStats.source.requestBytesAvailable && (
日志未含 request_length 字段,上传入站流量无法精确拆分。
)} {visibleStats.source.cache && (
数据缓存: {visibleStats.source.cache.hit ? '已读取固化结果' : '本次重新统计'} {visibleStats.source.cache.refreshedAt && `,更新时间 ${formatTime(visibleStats.source.cache.refreshedAt)}`} {visibleStats.source.cache.stale && ',正在后台刷新'}
)}
)}
) }