refactor: 重构管理后台 AnalyticsPanel 组件并优化流量数据可视化面板布局

This commit is contained in:
seahi committed 2026-09-26 19:33:54 +08:00
1 parent 6857fe7074
commit 9eb478b6d2
1 file changed
+306 -103
+306 -103
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { AnalyticsData } from '$lib/analytics'; import type { AnalyticsData, TrafficScope } from '$lib/analytics';
import { summarizeTraffic } from '$lib/analytics'; import { summarizeTraffic } from '$lib/analytics';
let { analyticsPromise }: { analyticsPromise: Promise<AnalyticsData> } = $props(); let { analyticsPromise }: { analyticsPromise: Promise<AnalyticsData> } = $props();
@@ -10,6 +10,34 @@
let scopes = $derived( let scopes = $derived(
(data?.scopes || []).map((scope) => ({ ...scope, summary: summarizeTraffic(scope.rows) })) (data?.scopes || []).map((scope) => ({ ...scope, summary: summarizeTraffic(scope.rows) }))
); );
let blog = $derived(scopes.find((scope) => scope.label === '博客'));
let assets = $derived(scopes.find((scope) => scope.label === 'R2 附件'));
function requestCount(scope: typeof blog) {
return !scope || scope.error ? '—' : formatNumber(scope.summary.requests);
}
function hitRate(scope: typeof blog) {
return !scope || scope.error || scope.summary.hitRate === null
? '—'
: `${scope.summary.hitRate.toFixed(1)}%`;
}
function scopeDescription(scope: typeof blog) {
if (!scope) return '未配置域名';
if (scope.error) return '统计不可用';
return `近 ${scope.days} 天 · ${formatBytes(scope.summary.bytes)}`;
}
function scopeDetails(
scope: (TrafficScope & { summary: ReturnType<typeof summarizeTraffic> }) | undefined
) {
if (!scope) return '未配置域名';
if (scope.error) return `${scope.hostname || '未配置域名'}:${scope.error}`;
return `${scope.hostname} · UTC 最近 ${scope.days} 天${scope.days < 30 ? '(受查询范围或数据量限制)' : ''};后台及 API 请求:${scope.summary.privateRequests};${Object.entries(
scope.summary.statuses
)
.map(([status, count]) => `${status}: ${count}`)
.join(',')}。CDN 命中率为 HIT / 总请求数,不代表页面内部缓存命中率。`;
}
let d1Data = $derived(data?.d1 || []); let d1Data = $derived(data?.d1 || []);
let r2Data = $derived(data?.r2 || []); let r2Data = $derived(data?.r2 || []);
let missingEnvVars = $derived(data?.missingEnvVars ?? true); let missingEnvVars = $derived(data?.missingEnvVars ?? true);
@@ -62,18 +90,74 @@
chart = new Chart(canvas, { chart = new Chart(canvas, {
type: 'line', type: 'line',
data: { data: {
labels, labels: labels.map((date) => date.substring(5)),
datasets: current.map((scope, index) => ({ datasets: [
label: `${scope.label}请求数`, {
data: labels.map((date) => new Map(scope.summary.daily).get(date) ?? null), label: '博客请求数',
borderColor: index === 0 ? '#333' : '#2196f3', data: labels.map(
tension: 0.3 (date) =>
})) new Map(
current.find((scope) => scope.label === '博客')?.summary.daily || []
).get(date) ?? null
),
borderColor: '#333',
backgroundColor: 'rgba(0,0,0,0.05)',
tension: 0.3,
fill: true,
yAxisID: 'y'
},
{
label: 'R2 附件请求数',
data: labels.map(
(date) =>
new Map(
current.find((scope) => scope.label === 'R2 附件')?.summary.daily || []
).get(date) ?? null
),
borderColor: '#2196F3',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
tension: 0.3,
fill: true,
yAxisID: 'y1'
}
]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { y: { beginAtZero: true } } interaction: {
mode: 'index',
intersect: false
},
plugins: {
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
x: {
ticks: {
maxRotation: 45,
maxTicksLimit: 15 // 最多显示 15 个刻度,避免太拥挤
}
},
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: true
},
y1: {
type: 'linear',
display: true,
position: 'right',
beginAtZero: true,
grid: {
drawOnChartArea: false
}
}
}
} }
}); });
}) })
@@ -106,63 +190,138 @@
<div class="spinner"></div> <div class="spinner"></div>
<p>正在获取博客与 R2 附件统计...</p> <p>正在获取博客与 R2 附件统计...</p>
</div> </div>
{:else if error}
<div class="error-state">
{error}
</div>
{:else if scopes.length === 0}
<div class="empty-state">
{data?.errorMsg || '没有获取到有效的流量数据,请检查 Cloudflare 配置。'}
</div>
{:else} {:else}
{#if error || data?.errorMsg}<div class="error-state">{error || data?.errorMsg}</div>{/if} <div
<div class="section-title">项目访问流量</div> class="section-title"
<p class="scope-note"> title="仅统计配置的博客和 R2 附件域名,不包含外链资源;数据可能由 Cloudflare 抽样估算。"
仅统计配置的博客域名和 R2 附件域名,不包含文章中的外链资源。CDN 命中率为 HIT >
请求占比,不代表页面内部缓存命中率或数据库查询比例。数据可能由 Cloudflare 抽样估算。 项目访问流量
</p> </div>
{#each scopes as scope (scope.label)}
<section class="scope-card"> <div class="dashboard-grid">
<h3>{scope.label} <span>{scope.hostname || '未配置域名'}</span></h3>
{#if scope.error}
<div class="error-state">统计不可用:{scope.error}</div>
{:else}
<p class="scope-note">
最近 {scope.days} 天 · UTC{scope.days < 30
? ' · 受查询范围或数据量限制,已缩短统计时段'
: ''}
</p>
<div class="scope-metrics">
<div>
<div class="metric-title">总请求数</div>
<div class="metric-value">{formatNumber(scope.summary.requests)}</div>
</div>
<div>
<div class="metric-title">消耗带宽</div>
<div class="metric-value">{formatBytes(scope.summary.bytes)}</div>
</div>
<div>
<div class="metric-title">CDN 请求命中率</div>
<div class="metric-value">
{scope.summary.hitRate === null
? '暂无请求'
: `${scope.summary.hitRate.toFixed(1)}%`}
</div>
</div>
</div>
{#if scope.label === '博客'}<p class="scope-note">
后台及 API 请求:{formatNumber(
scope.summary.privateRequests
)};其他请求:{formatNumber(
scope.summary.requests - scope.summary.privateRequests
)}。均包含在总请求数中。
</p>{/if}
<div class="status-list" aria-label="缓存状态分布">
{#each Object.entries(scope.summary.statuses) as [status, count] (status)}<span
>{status}:{formatNumber(count)}</span
>{/each}
</div>
{/if}
</section>
{/each}
{#if scopes.some((scope) => !scope.error && scope.rows.length)}
<div class="chart-container"> <div class="chart-container">
<div class="canvas-wrapper"><canvas bind:this={chartCanvas}></canvas></div> <div class="canvas-wrapper">
<canvas bind:this={chartCanvas}></canvas>
</div>
</div> </div>
{/if} <div class="core-metrics">
<div class="metric-item">
<div class="metric-title">
<span class="metric-icon" style="color: #2196F3; background: rgba(33,150,243,0.1)">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle
cx="12"
cy="12"
r="3"
></circle></svg
>
</span>
博客请求数
</div>
<div class="metric-value" style="color: #2196F3">{requestCount(blog)}</div>
<div class="metric-subtitle" title={scopeDetails(blog)}>{scopeDescription(blog)}</div>
</div>
<div class="metric-item">
<div class="metric-title">
<span class="metric-icon">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle
cx="12"
cy="7"
r="4"
></circle></svg
>
</span>
附件请求数
</div>
<div class="metric-value">{requestCount(assets)}</div>
<div class="metric-subtitle" title={scopeDetails(assets)}>{scopeDescription(assets)}</div>
</div>
<div class="metric-item">
<div class="metric-title">
<span class="metric-icon">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect
x="2"
y="14"
width="20"
height="8"
rx="2"
ry="2"
></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line
x1="6"
y1="18"
x2="6.01"
y2="18"
></line></svg
>
</span>
博客 CDN 命中率
</div>
<div class="metric-value">{hitRate(blog)}</div>
<div class="metric-subtitle" title={scopeDetails(blog)}>
{blog?.error ? '统计不可用' : 'HIT 请求占比'}
</div>
</div>
<div class="metric-item">
<div class="metric-title">
<span class="metric-icon" style="color: #4caf50; background: rgba(76,175,80,0.1)">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg
>
</span>
附件 CDN 命中率
</div>
<div class="metric-value" style="color: #4caf50">{hitRate(assets)}</div>
<div class="metric-subtitle" title={scopeDetails(assets)}>
{assets?.error ? '统计不可用' : 'HIT 请求占比'}
</div>
</div>
</div>
</div>
<div class="section-title">Cloudflare 资源配额</div> <div class="section-title">Cloudflare 资源配额</div>
{#if data?.errorMsg}<div class="error-state">{data.errorMsg}</div>{/if}
{#if missingEnvVars} {#if missingEnvVars}
<div class="info-alert"> <div class="info-alert">
提示:如需正常获取 D1 和 R2 数据,请确保在环境变量中配置 <code>CF_ACCOUNT_ID</code>, 提示:如需正常获取 D1 和 R2 数据,请确保在环境变量中配置 <code>CF_ACCOUNT_ID</code>,
@@ -258,46 +417,6 @@
</div> </div>
<style> <style>
.scope-card {
border: 1px solid #e5e5e5;
border-radius: 12px;
padding: 24px;
}
.scope-card h3 {
margin: 0 0 12px;
font-size: 16px;
}
.scope-card h3 span {
color: #777;
font-size: 13px;
font-weight: 400;
overflow-wrap: anywhere;
}
.scope-note {
color: #666;
font-size: 13px;
line-height: 1.7;
margin: 0 0 12px;
}
.scope-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin: 20px 0;
}
.status-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: #666;
}
@media (max-width: 600px) {
.scope-metrics {
grid-template-columns: 1fr;
}
}
.analytics-panel { .analytics-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -331,6 +450,20 @@
font-family: monospace; font-family: monospace;
} }
.dashboard-grid {
display: grid;
grid-template-columns: 7fr 3fr; /* 增加图表宽度,缩小右侧网格 */
gap: 0;
border: 2px solid var(--text-color, #000);
background: transparent;
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.chart-container { .chart-container {
padding: 20px; padding: 20px;
display: flex; display: flex;
@@ -344,6 +477,56 @@
width: 100%; width: 100%;
} }
.core-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
border-left: 2px solid var(--text-color, #000);
}
@media (max-width: 900px) {
.core-metrics {
border-left: none;
border-top: 2px solid var(--text-color, #000);
}
}
.metric-item {
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
align-items: center;
text-align: center;
min-width: 0;
}
/* 为 2x2 网格绘制内部边框 */
.metric-item:nth-child(1),
.metric-item:nth-child(2) {
border-bottom: 2px solid var(--text-color, #000);
}
.metric-item:nth-child(1),
.metric-item:nth-child(3) {
border-right: 2px solid var(--text-color, #000);
}
@media (max-width: 600px) {
.core-metrics {
grid-template-columns: 1fr;
}
.metric-item:nth-child(1),
.metric-item:nth-child(3) {
border-right: none;
}
.metric-item:nth-child(1),
.metric-item:nth-child(2),
.metric-item:nth-child(3) {
border-bottom: 2px solid var(--text-color, #000);
}
}
.metric-title { .metric-title {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
@@ -353,12 +536,31 @@
justify-content: center; justify-content: center;
} }
.metric-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
margin-right: 6px;
color: var(--text-color, #000);
}
.metric-value { .metric-value {
font-size: 1.6rem; font-size: 1.6rem;
font-weight: 800; font-weight: 800;
font-family: monospace; font-family: monospace;
} }
.metric-subtitle {
font-size: 0.7rem;
color: var(--text-muted, rgba(0, 0, 0, 0.4));
margin-top: 2px;
text-align: center;
}
.quota-container { .quota-container {
border: 2px solid var(--text-color, #000); border: 2px solid var(--text-color, #000);
background: transparent; background: transparent;
@@ -461,7 +663,8 @@
} }
.loading-state, .loading-state,
.error-state { .error-state,
.empty-state {
padding: 40px; padding: 40px;
text-align: center; text-align: center;
border: 2px dashed var(--border-color, rgba(0, 0, 0, 0.2)); border: 2px dashed var(--border-color, rgba(0, 0, 0, 0.2));