优化公网流量来源展示

This commit is contained in:
chenpei 2026-07-08 15:26:18 +08:00
parent eb31e12f32
commit 8a39e6b9e8

View File

@ -118,6 +118,7 @@ function formatTime(value: string | null) {
// 类别中文映射 + 图标 + 颜色。后端目前返回的是中文 category,这里兼容旧英文分类。
type CategoryMeta = { label: string; icon: string; barColor: string; badgeClass: string }
type CategoryNarrative = { title: string; note: string; action: string }
const CATEGORY_META: Record<string, CategoryMeta> = {
image_direct: {
@ -226,9 +227,80 @@ function getCategoryMeta(category: string) {
return { ...CATEGORY_META.other, label: key }
}
// 简化类别名(用于分组标题)
function shortCategoryLabel(category: string) {
return getCategoryMeta(category).label
function getCategoryNarrative(category: string): CategoryNarrative {
const key = normalizeCategoryKey(category)
const narratives: Record<string, CategoryNarrative> = {
素材库: {
title: '素材库内浏览/下载',
note: '来自素材列表、详情、下载、考核调研等站内使用。',
action: '通常是正常业务流量',
},
历史上传资源: {
title: '历史上传图片/文件',
note: '旧上传目录里的图片、视频、压缩包被直接打开。',
action: '适合看 Top 资源',
},
资源直链: {
title: '图片/文件直链访问',
note: '用户或外部页面直接打开素材文件。',
action: '适合排查大文件',
},
直传资源: {
title: '直传资源访问',
note: '新直传目录里的图片、视频、压缩包被访问。',
action: '关注是否集中',
},
产品设计平台: {
title: '产品设计平台调用',
note: '产品设计平台访问素材库接口或取图。',
action: '看对接频率',
},
外部接口: {
title: '外部平台接口',
note: '外部系统调用素材库接口产生的流量。',
action: '看调用来源',
},
后台: {
title: '后台管理操作',
note: '管理员在后台查看、管理、导出等操作。',
action: '低频为正常',
},
账号: {
title: '登录与认证',
note: '账号登录、钉钉认证相关请求。',
action: '通常很小',
},
页面加载: {
title: '页面打开资源',
note: '前端页面脚本、样式等静态资源。',
action: '可看缓存效果',
},
资源文件: {
title: '页面静态文件',
note: '站点公共静态文件访问。',
action: '通常很小',
},
其他: {
title: '未归类流量',
note: '暂时没有归入明确业务场景的访问。',
action: '占比高时再拆',
},
other: {
title: '未归类流量',
note: '暂时没有归入明确业务场景的访问。',
action: '占比高时再拆',
},
}
return narratives[key] || { title: getCategoryMeta(key).label, note: '按当前规则识别出的访问来源。', action: '查看明细' }
}
function getCategorySignal(category: string, share: number, rank: number) {
const key = normalizeCategoryKey(category)
if (rank === 0 && share >= 50) return '主要来源'
if (['历史上传资源', '资源直链', '直传资源'].includes(key) && share >= 10) return '建议查看'
if (share < 1) return '低占比'
return '正常'
}
const PRESET_OPTIONS: Array<{ key: Exclude<DatePreset, 'custom'>; label: string }> = [
@ -366,6 +438,27 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
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),
signal: getCategorySignal(cat.category, share, index),
topPurpose: items[0] || null,
}
})
}, [categoryTotals, purposeByCategory, responseBytes])
const primaryInsight = categoryInsights[0]
const visibleCategoryInsights = categoryInsights.filter((cat) => cat.rank <= 5 || cat.share >= 1)
const hiddenCategoryInsights = categoryInsights.filter((cat) => !visibleCategoryInsights.includes(cat))
const hiddenCategoryBytes = hiddenCategoryInsights.reduce((total, cat) => total + cat.bytes, 0)
if (!visibleStats) {
return (
<div className="flex min-h-[300px] items-center justify-center text-gray-400 dark:text-gray-500">
@ -474,63 +567,112 @@ export default function PublicTrafficTab({ showToast }: PublicTrafficTabProps) {
</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>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">按资源类型分组,展示每类流量消耗和请求占比</p>
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">流量都花在哪</h3>
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">按真实使用场景归类,先看主要消耗,再看技术明细</p>
</div>
{primaryInsight && (
<div className="text-xs text-gray-500 dark:text-gray-400">
最大来源:<span className="font-medium text-gray-900 dark:text-white">{primaryInsight.narrative.title}</span>
<span className="ml-1 tabular-nums">{primaryInsight.share.toFixed(1)}%</span>
</div>
)}
</div>
</div>
{/* 分类条形图 */}
<div className="space-y-4 p-5">
{categoryTotals.length === 0 && (
<div className="p-5">
{categoryInsights.length === 0 && (
<div className="py-8 text-center text-sm text-gray-400 dark:text-gray-500">当前范围没有流量记录</div>
)}
{categoryTotals.map((cat) => {
const meta = getCategoryMeta(cat.category)
const pct = responseBytes > 0 ? (cat.bytes / responseBytes) * 100 : 0
return (
<div key={cat.category}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-apple ${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={meta.icon} />
</svg>
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{meta.label}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{formatNumber(cat.requests)} 次请求</span>
</div>
<div className="flex items-center gap-2 text-sm tabular-nums">
<span className="font-semibold text-gray-900 dark:text-white">{formatBytes(cat.bytes)}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{pct.toFixed(1)}%</span>
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div className={`h-full rounded-full ${meta.barColor} transition-all duration-500`} style={{ width: `${Math.max(pct, 1)}%` }} />
</div>
{primaryInsight && (
<div className="mb-5 rounded-apple border border-sky-100 bg-sky-50/70 p-4 dark:border-sky-900/50 dark:bg-sky-950/20">
<div className="text-xs font-medium text-sky-700 dark:text-sky-300">主要结论</div>
<div className="mt-1 text-sm text-gray-700 dark:text-gray-200">
这段时间主要消耗在
<span className="mx-1 font-semibold text-gray-950 dark:text-white">{primaryInsight.narrative.title}</span>
,用了 <span className="font-semibold tabular-nums text-gray-950 dark:text-white">{formatBytes(primaryInsight.bytes)}</span>
,占响应流量 <span className="font-semibold tabular-nums text-gray-950 dark:text-white">{primaryInsight.share.toFixed(1)}%</span>
{primaryInsight.topPurpose && (
<>;其中最多的是 <span className="font-medium text-gray-950 dark:text-white">{primaryInsight.topPurpose.label}</span></>
)}
。
</div>
)
})}
</div>
)}
<div className="space-y-3">
{visibleCategoryInsights.map((cat) => {
return (
<div key={cat.category} className="rounded-apple border border-gray-100 p-4 dark:border-gray-800">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 gap-3">
<span className={`mt-0.5 inline-flex h-9 w-9 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="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-gray-900 dark:text-white">{cat.narrative.title}</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{cat.signal}
</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{cat.narrative.note}</div>
{cat.topPurpose && (
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
最大细分:{cat.topPurpose.label} · {formatBytes(cat.topPurpose.responseBytes)}
</div>
)}
</div>
</div>
<div className="shrink-0 text-left lg: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)}% · {formatNumber(cat.requests)} 次
</div>
</div>
</div>
<div className="mt-3 flex items-center gap-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>
<span className="w-20 shrink-0 text-right text-xs text-gray-400 dark:text-gray-500">{cat.narrative.action}</span>
</div>
</div>
)
})}
</div>
{hiddenCategoryInsights.length > 0 && (
<div className="mt-4 rounded-apple border border-dashed border-gray-200 px-4 py-3 text-xs text-gray-500 dark:border-gray-800 dark:text-gray-400">
另有 {hiddenCategoryInsights.length} 类低占比来源合计 {formatBytes(hiddenCategoryBytes)},已收进下方明细。
</div>
)}
</div>
{/* 每类内的用途明细(展开式)*/}
{categoryTotals.length > 0 && (
{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">
{categoryTotals.map((cat) => {
const meta = getCategoryMeta(cat.category)
{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 ${meta.barColor}`} />
{meta.label}
<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) => {