refactor: 重构管理后台 AnalyticsPanel 组件并优化流量数据可视化面板布局
This commit is contained in:
1 parent
6857fe7074
commit
9eb478b6d2
1 file changed
+306
-103
@@ -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));
|
||||||
|
|||||||
Reference in new issue
Block a user