refactor: 改进 Cloudflare 缓存清理与独立域名的流量统计面板
This commit is contained in:
1 parent
fa8c04e297
commit
6ad717533c
10 files changed
+570
-559
No files matched your search
@@ -0,0 +1,50 @@
|
||||
export interface TrafficRow {
|
||||
count: number;
|
||||
sum: { edgeResponseBytes: number };
|
||||
dimensions: { date: string; cacheStatus: string; clientRequestPath: string };
|
||||
}
|
||||
|
||||
export interface TrafficScope {
|
||||
label: string;
|
||||
hostname: string;
|
||||
days: number;
|
||||
rows: TrafficRow[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
scopes: TrafficScope[];
|
||||
d1: { sum: { rowsRead: number; rowsWritten: number } }[];
|
||||
r2: { max: { payloadSize: number } }[];
|
||||
missingEnvVars: boolean;
|
||||
errorMsg?: string;
|
||||
}
|
||||
|
||||
export function summarizeTraffic(rows: TrafficRow[]) {
|
||||
let requests = 0;
|
||||
let bytes = 0;
|
||||
let hits = 0;
|
||||
let privateRequests = 0;
|
||||
const statuses: Record<string, number> = {};
|
||||
const daily = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
requests += row.count;
|
||||
bytes += row.sum.edgeResponseBytes;
|
||||
const status = row.dimensions.cacheStatus.toUpperCase();
|
||||
statuses[status] = (statuses[status] || 0) + row.count;
|
||||
if (status === 'HIT') hits += row.count;
|
||||
if (/^\/(admin|api)(\/|$)/.test(row.dimensions.clientRequestPath)) {
|
||||
privateRequests += row.count;
|
||||
}
|
||||
daily.set(row.dimensions.date, (daily.get(row.dimensions.date) || 0) + row.count);
|
||||
}
|
||||
return {
|
||||
requests,
|
||||
bytes,
|
||||
hits,
|
||||
privateRequests,
|
||||
hitRate: requests ? (hits / requests) * 100 : null,
|
||||
statuses,
|
||||
daily: [...daily].sort(([a], [b]) => a.localeCompare(b))
|
||||
};
|
||||
}
|
||||
@@ -1,180 +1,102 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
let { analyticsPromise } = $props<{
|
||||
analyticsPromise: Promise<any>;
|
||||
}>();
|
||||
|
||||
let chartCanvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||
let chartInstance: any = null;
|
||||
import type { AnalyticsData } from '$lib/analytics';
|
||||
import { summarizeTraffic } from '$lib/analytics';
|
||||
|
||||
let { analyticsPromise }: { analyticsPromise: Promise<AnalyticsData> } = $props();
|
||||
let data = $state<AnalyticsData | null>(null);
|
||||
let loading = $state(true);
|
||||
let trafficData: any[] = $state([]);
|
||||
let d1Data: any[] = $state([]);
|
||||
let r2Data: any[] = $state([]);
|
||||
let missingEnvVars = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
let totalRequests = $state(0);
|
||||
let totalCached = $state(0);
|
||||
let totalBytes = $state(0);
|
||||
let hitRate = $state(0);
|
||||
let totalPV = $state(0);
|
||||
let totalUV = $state(0);
|
||||
|
||||
let d1RowsRead = $state(0);
|
||||
let d1RowsWritten = $state(0);
|
||||
let r2PayloadSize = $state(0);
|
||||
|
||||
let d1ReadDaily = $derived(d1Data.length > 0 ? Math.round(d1RowsRead / 30) : 0);
|
||||
let d1ReadPercent = $derived(d1Data.length > 0 ? (d1ReadDaily / 5000000) * 100 : 0);
|
||||
let d1WriteDaily = $derived(d1Data.length > 0 ? Math.round(d1RowsWritten / 30) : 0);
|
||||
let d1WritePercent = $derived(d1Data.length > 0 ? (d1WriteDaily / 100000) * 100 : 0);
|
||||
let r2Percent = $derived(
|
||||
r2Data.length > 0 ? (r2PayloadSize / (10 * 1024 * 1024 * 1024)) * 100 : 0
|
||||
let chartCanvas = $state<HTMLCanvasElement>();
|
||||
let scopes = $derived(
|
||||
(data?.scopes || []).map((scope) => ({ ...scope, summary: summarizeTraffic(scope.rows) }))
|
||||
);
|
||||
let d1Data = $derived(data?.d1 || []);
|
||||
let r2Data = $derived(data?.r2 || []);
|
||||
let missingEnvVars = $derived(data?.missingEnvVars ?? true);
|
||||
let d1ReadDaily = $derived(
|
||||
Math.round(d1Data.reduce((sum, row) => sum + row.sum.rowsRead, 0) / 30)
|
||||
);
|
||||
let d1WriteDaily = $derived(
|
||||
Math.round(d1Data.reduce((sum, row) => sum + row.sum.rowsWritten, 0) / 30)
|
||||
);
|
||||
let d1ReadPercent = $derived((d1ReadDaily / 5000000) * 100);
|
||||
let d1WritePercent = $derived((d1WriteDaily / 100000) * 100);
|
||||
let r2PayloadSize = $derived(Math.max(0, ...r2Data.map((row) => row.max.payloadSize)));
|
||||
let r2Percent = $derived((r2PayloadSize / (10 * 1024 * 1024 * 1024)) * 100);
|
||||
|
||||
$effect(() => {
|
||||
let cancelled = false;
|
||||
loading = true;
|
||||
data = null;
|
||||
error = '';
|
||||
analyticsPromise
|
||||
.then((data: any) => {
|
||||
if (data) {
|
||||
trafficData = data.traffic || [];
|
||||
d1Data = data.d1 || [];
|
||||
r2Data = data.r2 || [];
|
||||
missingEnvVars = data.missingEnvVars || false;
|
||||
|
||||
if (trafficData.length > 0) {
|
||||
trafficData.forEach((day: any) => {
|
||||
totalRequests += day.sum.requests || 0;
|
||||
totalCached += day.sum.cachedRequests || 0;
|
||||
totalBytes += day.sum.bytes || 0;
|
||||
totalPV += day.sum.pageViews || 0;
|
||||
totalUV += day.uniq?.uniques || 0;
|
||||
});
|
||||
hitRate = totalRequests > 0 ? (totalCached / totalRequests) * 100 : 0;
|
||||
}
|
||||
|
||||
if (d1Data.length > 0) {
|
||||
d1Data.forEach((row: any) => {
|
||||
d1RowsRead += row.sum.rowsRead || 0;
|
||||
d1RowsWritten += row.sum.rowsWritten || 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (r2Data.length > 0) {
|
||||
r2PayloadSize = r2Data[0]?.max?.payloadSize || 0;
|
||||
}
|
||||
|
||||
if (data.errorMsg) {
|
||||
error = data.errorMsg;
|
||||
}
|
||||
.then((value) => {
|
||||
if (!cancelled) {
|
||||
data = value;
|
||||
loading = false;
|
||||
}
|
||||
loading = false;
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error(err);
|
||||
error = '获取数据失败,请检查 Cloudflare 环境变量配置。';
|
||||
loading = false;
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
error = '统计不可用,请检查网络和 Cloudflare 配置。';
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (chartCanvas && trafficData && trafficData.length > 0) {
|
||||
renderChart(trafficData);
|
||||
}
|
||||
const canvas = chartCanvas;
|
||||
const current = scopes.filter((scope) => !scope.error);
|
||||
if (!canvas || !current.length) return;
|
||||
let cancelled = false;
|
||||
let chart: import('chart.js').Chart | undefined;
|
||||
const labels = [
|
||||
...new Set(current.flatMap((scope) => scope.summary.daily.map(([date]) => date)))
|
||||
].sort();
|
||||
void import('chart.js/auto')
|
||||
.then(({ default: Chart }) => {
|
||||
if (cancelled) return;
|
||||
chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: current.map((scope, index) => ({
|
||||
label: `${scope.label}请求数`,
|
||||
data: labels.map((date) => new Map(scope.summary.daily).get(date) ?? null),
|
||||
borderColor: index === 0 ? '#333' : '#2196f3',
|
||||
tension: 0.3
|
||||
}))
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* The numeric metrics remain available if the chart cannot load. */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
chart?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
async function renderChart(data: any[]) {
|
||||
if (!chartCanvas) return;
|
||||
|
||||
const ChartJS = await import('chart.js/auto');
|
||||
const Chart = ChartJS.default || ChartJS;
|
||||
|
||||
// 格式化日期:去除年份,将 2026-09-27 转为 09-27,降低 X 轴文字密度
|
||||
const labels = data.map((d: any) => d.dimensions.date.substring(5));
|
||||
const requests = data.map((d: any) => d.sum.requests);
|
||||
const pvs = data.map((d: any) => d.sum.pageViews);
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
chartInstance = new Chart(chartCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '总请求数',
|
||||
data: requests,
|
||||
borderColor: '#333',
|
||||
backgroundColor: 'rgba(0,0,0,0.05)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: '页面浏览量 (PV)',
|
||||
data: pvs,
|
||||
borderColor: '#2196F3',
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
if (!bytes) return '0 B';
|
||||
const index = Math.min(4, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
return `${(bytes / 1024 ** index).toFixed(2)} ${['B', 'KB', 'MB', 'GB', 'TB'][index]}`;
|
||||
}
|
||||
|
||||
function formatNumber(num: number) {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||||
return Math.round(num).toString();
|
||||
function formatNumber(value: number) {
|
||||
return value >= 1000000
|
||||
? `${(value / 1000000).toFixed(1)}M`
|
||||
: value >= 1000
|
||||
? `${(value / 1000).toFixed(1)}K`
|
||||
: Math.round(value).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -182,129 +104,64 @@
|
||||
{#if loading}
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>正在从 Cloudflare 获取最近 30 天流量数据...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
{error}
|
||||
</div>
|
||||
{:else if !trafficData || trafficData.length === 0}
|
||||
<div class="empty-state">
|
||||
没有获取到有效的流量数据,请确保 CF_API_TOKEN 和 CF_ZONE_ID 配置正确,且网站已有访问。
|
||||
<p>正在获取博客与 R2 附件统计...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="section-title">30天访问流量</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
{#if error || data?.errorMsg}<div class="error-state">{error || data?.errorMsg}</div>{/if}
|
||||
<div class="section-title">项目访问流量</div>
|
||||
<p class="scope-note">
|
||||
仅统计配置的博客域名和 R2 附件域名,不包含文章中的外链资源。CDN 命中率为 HIT
|
||||
请求占比,不代表页面内部缓存命中率或数据库查询比例。数据可能由 Cloudflare 抽样估算。
|
||||
</p>
|
||||
{#each scopes as scope (scope.label)}
|
||||
<section class="scope-card">
|
||||
<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="canvas-wrapper">
|
||||
<canvas bind:this={chartCanvas}></canvas>
|
||||
</div>
|
||||
<div class="canvas-wrapper"><canvas bind:this={chartCanvas}></canvas></div>
|
||||
</div>
|
||||
<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>
|
||||
页面浏览 (PV)
|
||||
</div>
|
||||
<div class="metric-value" style="color: #2196F3">{totalPV.toLocaleString()}</div>
|
||||
<div class="metric-subtitle">近 30 天累计访问量</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>
|
||||
独立访客 (UV)
|
||||
</div>
|
||||
<div class="metric-value">{totalUV.toLocaleString()}</div>
|
||||
<div class="metric-subtitle">近 30 天去重访客数</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>
|
||||
消耗总带宽
|
||||
</div>
|
||||
<div class="metric-value">{formatBytes(totalBytes)}</div>
|
||||
<div class="metric-subtitle">包含网页及所有静态资源</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>
|
||||
缓存命中率
|
||||
</div>
|
||||
<div class="metric-value" style="color: #4caf50">{hitRate.toFixed(1)}%</div>
|
||||
<div class="metric-subtitle">由 Cloudflare CDN 成功拦截</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
<div class="section-title">Cloudflare 资源配额</div>
|
||||
{#if missingEnvVars}
|
||||
<div class="info-alert">
|
||||
@@ -336,7 +193,7 @@
|
||||
></div>
|
||||
</div>
|
||||
<div class="quota-footer">
|
||||
<span>{d1ReadPercent < 1 ? '极度充裕' : '健康'}</span>
|
||||
<span>{d1Data.length ? (d1ReadPercent < 1 ? '极度充裕' : '健康') : '暂无数据'}</span>
|
||||
<span
|
||||
>{d1ReadPercent < 0.01 && d1Data.length > 0
|
||||
? '< 0.01'
|
||||
@@ -367,7 +224,7 @@
|
||||
></div>
|
||||
</div>
|
||||
<div class="quota-footer">
|
||||
<span>{d1WritePercent < 1 ? '极度充裕' : '健康'}</span>
|
||||
<span>{d1Data.length ? (d1WritePercent < 1 ? '极度充裕' : '健康') : '暂无数据'}</span>
|
||||
<span
|
||||
>{d1WritePercent < 0.01 && d1Data.length > 0
|
||||
? '< 0.01'
|
||||
@@ -392,7 +249,7 @@
|
||||
></div>
|
||||
</div>
|
||||
<div class="quota-footer">
|
||||
<span>{r2Percent < 1 ? '空间海量' : '健康'}</span>
|
||||
<span>{r2Data.length ? (r2Percent < 1 ? '空间海量' : '健康') : '暂无数据'}</span>
|
||||
<span>{r2Percent < 0.01 && r2Data.length > 0 ? '< 0.01' : r2Percent.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,6 +258,46 @@
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -434,20 +331,6 @@
|
||||
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 {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
@@ -461,56 +344,6 @@
|
||||
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 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
@@ -520,31 +353,12 @@
|
||||
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 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
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 {
|
||||
border: 2px solid var(--text-color, #000);
|
||||
background: transparent;
|
||||
@@ -647,8 +461,7 @@
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
.error-state {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
border: 2px dashed var(--border-color, rgba(0, 0, 0, 0.2));
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchAnalytics, purgePublicCache } from './cloudflare';
|
||||
import { summarizeTraffic, type TrafficRow } from '$lib/analytics';
|
||||
|
||||
function platform(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
env: {
|
||||
CF_API_TOKEN: 'test-token',
|
||||
CF_ZONE_ID: 'blog-zone',
|
||||
CF_DOMAIN: 'https://blog.example.com',
|
||||
...extra
|
||||
}
|
||||
} as unknown as App.Platform;
|
||||
}
|
||||
function trafficResponse(rows: TrafficRow[] = []) {
|
||||
return Response.json({ data: { viewer: { zones: [{ httpRequestsAdaptiveGroups: rows }] } } });
|
||||
}
|
||||
const row = (count: number, status: string, path = '/'): TrafficRow => ({
|
||||
count,
|
||||
dimensions: { date: '2026-09-26', cacheStatus: status, clientRequestPath: path },
|
||||
sum: { edgeResponseBytes: count * 100 }
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('domain-scoped analytics', () => {
|
||||
it('queries only configured hosts and eyeball traffic, with separate asset zone support', async () => {
|
||||
const mock = vi.fn().mockImplementation(() => trafficResponse([row(2, 'hit')]));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
const result = await fetchAnalytics(
|
||||
platform({ CF_ASSETS_ZONE_ID: 'assets-zone' }),
|
||||
'https://assets.example.com'
|
||||
);
|
||||
const requests = mock.mock.calls.map((call) => JSON.parse(call[1].body));
|
||||
expect(requests.map((request) => request.variables.filter.clientRequestHTTPHost)).toEqual([
|
||||
'blog.example.com',
|
||||
'assets.example.com'
|
||||
]);
|
||||
expect(requests.map((request) => request.variables.zone)).toEqual(['blog-zone', 'assets-zone']);
|
||||
expect(requests.every((request) => request.variables.filter.requestSource === 'eyeball')).toBe(
|
||||
true
|
||||
);
|
||||
expect(result.scopes.map((scope) => scope.rows.length)).toEqual([1, 1]);
|
||||
});
|
||||
it('does not widen scope when an asset domain is missing', async () => {
|
||||
const mock = vi.fn().mockImplementation(() => trafficResponse());
|
||||
vi.stubGlobal('fetch', mock);
|
||||
const result = await fetchAnalytics(platform());
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
expect(result.scopes[1].error).toContain('未配置域名');
|
||||
});
|
||||
it('reports permission errors instead of displaying a false zero or zone-wide fallback', async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Response.json({ errors: [{ message: 'not authorized' }] }));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
|
||||
expect(mock).toHaveBeenCalledTimes(2);
|
||||
expect(result.scopes.every((scope) => scope.error === 'not authorized')).toBe(true);
|
||||
});
|
||||
it('retries smaller supported time windows and labels the actual period', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((_url, options) => {
|
||||
const { filter } = JSON.parse(options.body).variables;
|
||||
const days = (Date.parse(filter.datetime_lt) - Date.parse(filter.datetime_geq)) / 86400000;
|
||||
return days > 1
|
||||
? Response.json({ errors: [{ message: 'query time range is too large' }] })
|
||||
: trafficResponse([row(1, 'miss')]);
|
||||
})
|
||||
);
|
||||
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
|
||||
expect(result.scopes.map((scope) => [scope.days, scope.error])).toEqual([
|
||||
[1, undefined],
|
||||
[1, undefined]
|
||||
]);
|
||||
});
|
||||
it('does not present truncated results as complete statistics', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
trafficResponse(Array.from({ length: 10000 }, () => row(1, 'hit')))
|
||||
)
|
||||
);
|
||||
const result = await fetchAnalytics(platform(), 'https://assets.example.com');
|
||||
expect(result.scopes[0].error).toContain('查询上限');
|
||||
expect(result.scopes[0].rows).toEqual([]);
|
||||
});
|
||||
it('keeps domain traffic visible if account-resource queries fail', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockImplementation((_url, options) =>
|
||||
JSON.parse(options.body).query.includes('accounts(')
|
||||
? Response.json({ errors: [{ message: 'account denied' }] })
|
||||
: trafficResponse([row(1, 'hit')])
|
||||
)
|
||||
);
|
||||
const result = await fetchAnalytics(
|
||||
platform({ CF_ACCOUNT_ID: 'account', CF_D1_ID: 'database' }),
|
||||
'https://assets.example.com'
|
||||
);
|
||||
expect(result.errorMsg).toContain('account denied');
|
||||
expect(result.scopes[0].rows).toHaveLength(1);
|
||||
});
|
||||
it('computes weighted HIT rates without accumulating across reloads or claiming UV', () => {
|
||||
const rows = [row(2, 'hit'), row(98, 'dynamic', '/admin/settings')];
|
||||
expect(summarizeTraffic(rows).hitRate).toBe(2);
|
||||
expect(summarizeTraffic(rows).requests).toBe(100);
|
||||
expect(summarizeTraffic(rows).privateRequests).toBe(98);
|
||||
expect(summarizeTraffic([]).hitRate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('public cache invalidation', () => {
|
||||
it('purges only this blog tag, covering old slugs and query variants without clearing attachments', async () => {
|
||||
const mock = vi.fn().mockResolvedValue(Response.json({ success: true }));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
expect(await purgePublicCache(platform())).toBeNull();
|
||||
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({
|
||||
tags: ['blogflare-public:blog.example.com']
|
||||
});
|
||||
});
|
||||
it('persists a purge failure so saved content does not imply a successful refresh', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
Response.json({ success: false, errors: [{ message: 'rate limited' }] }, { status: 429 })
|
||||
)
|
||||
);
|
||||
const bind = vi.fn().mockReturnValue({ run: vi.fn().mockResolvedValue({ success: true }) });
|
||||
const error = await purgePublicCache(platform({ DB: { prepare: () => ({ bind }) } }));
|
||||
expect(error).toContain('rate limited');
|
||||
expect(bind).toHaveBeenCalledWith(error);
|
||||
});
|
||||
it('fails closed with missing credentials and does not clear an entire zone', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mock = vi.fn();
|
||||
vi.stubGlobal('fetch', mock);
|
||||
expect(await purgePublicCache(undefined)).toContain('缺少');
|
||||
expect(mock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+175
-131
@@ -1,76 +1,179 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export async function purgeCache(platform: App.Platform | undefined, paths: string[]) {
|
||||
// @ts-expect-error Cloudflare env types
|
||||
const token = platform?.env?.CF_API_TOKEN;
|
||||
// @ts-expect-error Cloudflare env types
|
||||
const zoneId = platform?.env?.CF_ZONE_ID;
|
||||
const domain = platform?.env?.CF_DOMAIN; // 必须是带协议的绝对域名,如 https://blog.example.com
|
||||
import type { AnalyticsData, TrafficRow, TrafficScope } from '$lib/analytics';
|
||||
import { configuredHostname, publicCacheTag } from './cache';
|
||||
|
||||
if (!token || !zoneId) {
|
||||
console.warn('未配置 CF_API_TOKEN 或 CF_ZONE_ID,跳过清除 Cloudflare 缓存');
|
||||
return;
|
||||
}
|
||||
// Optional dashboard secrets are not generated from wrangler.jsonc.
|
||||
type AnalyticsEnv = Env &
|
||||
Partial<
|
||||
Record<
|
||||
| 'CF_API_TOKEN'
|
||||
| 'CF_ZONE_ID'
|
||||
| 'CF_ACCOUNT_ID'
|
||||
| 'CF_D1_ID'
|
||||
| 'CF_R2_BUCKET'
|
||||
| 'CF_ASSETS_ZONE_ID',
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
if (!domain) {
|
||||
console.warn('未配置 CF_DOMAIN,无法精准拼接 URL,跳过清除缓存以保护其他项目。');
|
||||
return;
|
||||
}
|
||||
|
||||
// 将路径转换为带域名的绝对 URL,Cloudflare 的 purge_cache(files) 要求必须为绝对 URL
|
||||
const urls = paths.map((p) => {
|
||||
// 确保拼接正确,防止出现双斜杠或少斜杠
|
||||
const cleanDomain = domain.replace(/\/$/, '');
|
||||
const cleanPath = p.startsWith('/') ? p : `/${p}`;
|
||||
return `${cleanDomain}${cleanPath}`;
|
||||
async function graphql<T>(
|
||||
token: string,
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch('https://api.cloudflare.com/client/v4/graphql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(15000)
|
||||
});
|
||||
const result = (await response.json()) as { data?: T; errors?: { message: string }[] };
|
||||
if (!response.ok || result.errors?.length || !result.data) {
|
||||
throw new Error(
|
||||
result.errors?.map((error) => error.message).join('; ') ||
|
||||
`Cloudflare HTTP ${response.status}`
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function purgePublicCache(platform: App.Platform | undefined): Promise<string | null> {
|
||||
const env = platform?.env as AnalyticsEnv | undefined;
|
||||
let error: string | null = null;
|
||||
try {
|
||||
const body = { files: urls };
|
||||
|
||||
if (!env?.CF_API_TOKEN || !env.CF_ZONE_ID) throw new Error('缺少 CF_API_TOKEN 或 CF_ZONE_ID');
|
||||
const hostname = configuredHostname(env.CF_DOMAIN);
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
|
||||
`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(env.CF_ZONE_ID)}/purge_cache`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
Authorization: `Bearer ${env.CF_API_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify({ tags: [publicCacheTag(hostname)] }),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
}
|
||||
);
|
||||
|
||||
const result = (await response.json()) as any;
|
||||
if (result.success) {
|
||||
console.log(
|
||||
'✅ 成功清除 Cloudflare 缓存',
|
||||
urls ? `(URLs: ${urls.join(', ')})` : '(全部缓存)'
|
||||
const result = (await response.json()) as { success?: boolean; errors?: { message: string }[] };
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(
|
||||
`Cloudflare 清理失败(HTTP ${response.status}):${result.errors?.map((item) => item.message).join('; ') || '未返回成功确认'}`
|
||||
);
|
||||
} else {
|
||||
console.error('❌ 清除缓存失败:', result.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 调用 Cloudflare Purge API 时发生异常:', error);
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : '缓存清理失败';
|
||||
console.error('Public cache purge failed:', error);
|
||||
}
|
||||
// Persist failures so an asynchronous API failure is visible in the admin UI.
|
||||
try {
|
||||
await env?.DB.prepare(
|
||||
"INSERT INTO settings (key, value, updated_at) VALUES ('cache.purge_error', ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP"
|
||||
)
|
||||
.bind(error || '')
|
||||
.run();
|
||||
} catch (cause) {
|
||||
console.error('Failed to persist cache purge status', cause);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function fetchAnalytics(platform: App.Platform | undefined) {
|
||||
// @ts-expect-error Cloudflare env types
|
||||
const token = platform?.env?.CF_API_TOKEN;
|
||||
// @ts-expect-error Cloudflare env types
|
||||
const zoneId = platform?.env?.CF_ZONE_ID;
|
||||
// @ts-expect-error Cloudflare env types
|
||||
const accountId = platform?.env?.CF_ACCOUNT_ID;
|
||||
// @ts-expect-error Cloudflare env types
|
||||
const d1Id = platform?.env?.CF_D1_ID;
|
||||
// @ts-expect-error Cloudflare env types
|
||||
const r2Bucket = platform?.env?.CF_R2_BUCKET;
|
||||
|
||||
if (!token || !zoneId) {
|
||||
return null;
|
||||
async function fetchTraffic(
|
||||
token: string,
|
||||
zoneId: string,
|
||||
label: string,
|
||||
domain: string | undefined,
|
||||
now: Date
|
||||
): Promise<TrafficScope> {
|
||||
const scope: TrafficScope = { label, hostname: '', days: 30, rows: [] };
|
||||
try {
|
||||
scope.hostname = configuredHostname(domain);
|
||||
// Never fall back to zone-wide traffic: that would include unrelated external images.
|
||||
for (const days of [30, 7, 1]) {
|
||||
scope.days = days;
|
||||
try {
|
||||
const data = await graphql<{
|
||||
viewer: { zones: { httpRequestsAdaptiveGroups: TrafficRow[] }[] };
|
||||
}>(
|
||||
token,
|
||||
`
|
||||
query Traffic($zone: string, $filter: filter) {
|
||||
viewer {
|
||||
zones(filter: { zoneTag: $zone }) {
|
||||
httpRequestsAdaptiveGroups(limit: 10000, filter: $filter) {
|
||||
count
|
||||
sum {
|
||||
edgeResponseBytes
|
||||
}
|
||||
dimensions {
|
||||
date
|
||||
cacheStatus
|
||||
clientRequestPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
zone: zoneId,
|
||||
filter: {
|
||||
datetime_geq: new Date(now.getTime() - days * 86400000).toISOString(),
|
||||
datetime_lt: now.toISOString(),
|
||||
clientRequestHTTPHost: scope.hostname,
|
||||
requestSource: 'eyeball'
|
||||
}
|
||||
}
|
||||
);
|
||||
const rows = data.viewer.zones[0]?.httpRequestsAdaptiveGroups;
|
||||
if (!Array.isArray(rows))
|
||||
throw new Error('未返回该 Zone 的数据,请检查域名归属和 Token 权限');
|
||||
if (rows.length >= 10000) throw new Error('分组数量达到查询上限,无法显示完整统计');
|
||||
scope.rows = rows;
|
||||
return scope;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : '流量查询失败';
|
||||
if (
|
||||
days !== 1 &&
|
||||
/time range|time window|date range|older than|too old|retention|cannot request data before|query time|分组数量/i.test(
|
||||
message
|
||||
)
|
||||
)
|
||||
continue;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
} catch (cause) {
|
||||
scope.error = cause instanceof Error ? cause.message : '流量查询失败';
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
export async function fetchAnalytics(
|
||||
platform: App.Platform | undefined,
|
||||
assetsDomain?: string
|
||||
): Promise<AnalyticsData> {
|
||||
const env = platform?.env as AnalyticsEnv | undefined;
|
||||
const token = env?.CF_API_TOKEN;
|
||||
const zoneId = env?.CF_ZONE_ID;
|
||||
const accountId = env?.CF_ACCOUNT_ID;
|
||||
const d1Id = env?.CF_D1_ID;
|
||||
const r2Bucket = env?.CF_R2_BUCKET;
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().split('T')[0];
|
||||
const result: AnalyticsData = {
|
||||
scopes: [],
|
||||
d1: [],
|
||||
r2: [],
|
||||
missingEnvVars: !accountId || !d1Id || !r2Bucket
|
||||
};
|
||||
if (!token || !zoneId) {
|
||||
result.errorMsg = '统计不可用:请配置 CF_API_TOKEN 和 CF_ZONE_ID';
|
||||
return result;
|
||||
}
|
||||
const trafficPromise = Promise.all([
|
||||
fetchTraffic(token, zoneId, '博客', env?.CF_DOMAIN, now),
|
||||
fetchTraffic(token, env?.CF_ASSETS_ZONE_ID || zoneId, 'R2 附件', assetsDomain, now)
|
||||
]);
|
||||
let accountQuery = '';
|
||||
if (accountId) {
|
||||
let d1Query = '';
|
||||
@@ -78,7 +181,7 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
|
||||
d1Query = `
|
||||
d1AnalyticsAdaptiveGroups(
|
||||
limit: 10000,
|
||||
filter: { date_geq: "${thirtyDaysAgo}", databaseId: "${d1Id}" }
|
||||
filter: { date_geq: "${thirtyDaysAgo}", databaseId: ${JSON.stringify(d1Id)} }
|
||||
) {
|
||||
sum {
|
||||
readQueries
|
||||
@@ -95,7 +198,7 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
|
||||
r2Query = `
|
||||
r2StorageAdaptiveGroups(
|
||||
limit: 10000,
|
||||
filter: { datetime_geq: "${thirtyDaysAgo}T00:00:00Z", bucketName: "${r2Bucket}" }
|
||||
filter: { datetime_geq: "${thirtyDaysAgo}T00:00:00Z", bucketName: ${JSON.stringify(r2Bucket)} }
|
||||
) {
|
||||
max {
|
||||
payloadSize
|
||||
@@ -106,90 +209,31 @@ export async function fetchAnalytics(platform: App.Platform | undefined) {
|
||||
|
||||
if (d1Query || r2Query) {
|
||||
accountQuery = `
|
||||
accounts(filter: { accountTag: "${accountId}" }) {
|
||||
accounts(filter: { accountTag: ${JSON.stringify(accountId)} }) {
|
||||
${d1Query}
|
||||
${r2Query}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const trafficQuery = `
|
||||
query {
|
||||
viewer {
|
||||
zones(filter: { zoneTag: "${zoneId}" }) {
|
||||
httpRequests1dGroups(
|
||||
limit: 30,
|
||||
filter: { date_gt: "${thirtyDaysAgo}" }
|
||||
orderBy: [date_ASC]
|
||||
) {
|
||||
dimensions {
|
||||
date
|
||||
}
|
||||
sum {
|
||||
cachedBytes
|
||||
bytes
|
||||
cachedRequests
|
||||
requests
|
||||
pageViews
|
||||
}
|
||||
uniq {
|
||||
uniques
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const trafficRes = await fetch('https://api.cloudflare.com/client/v4/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ query: trafficQuery })
|
||||
});
|
||||
|
||||
const trafficResult = (await trafficRes.json()) as any;
|
||||
const trafficData = trafficResult?.data?.viewer?.zones?.[0]?.httpRequests1dGroups || [];
|
||||
|
||||
let d1Data: any[] = [];
|
||||
let r2Data: any[] = [];
|
||||
let accErrorMsg = '';
|
||||
|
||||
if (accountQuery) {
|
||||
const accQueryStr = `query { viewer { ${accountQuery} } }`;
|
||||
const accRes = await fetch('https://api.cloudflare.com/client/v4/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ query: accQueryStr })
|
||||
});
|
||||
const accResult = (await accRes.json()) as any;
|
||||
if (accResult.errors) {
|
||||
console.error('Account GraphQL Errors:', accResult.errors);
|
||||
accErrorMsg = '账户数据查询失败: ' + (accResult.errors[0]?.message || 'Unknown');
|
||||
}
|
||||
const accountData = accResult?.data?.viewer?.accounts?.[0];
|
||||
if (accountData) {
|
||||
d1Data = accountData.d1AnalyticsAdaptiveGroups || [];
|
||||
r2Data = accountData.r2StorageAdaptiveGroups || [];
|
||||
}
|
||||
const data = await graphql<{
|
||||
viewer: {
|
||||
accounts: {
|
||||
d1AnalyticsAdaptiveGroups?: AnalyticsData['d1'];
|
||||
r2StorageAdaptiveGroups?: AnalyticsData['r2'];
|
||||
}[];
|
||||
};
|
||||
}>(token, `query { viewer { ${accountQuery} } }`);
|
||||
const account = data.viewer.accounts[0];
|
||||
if (!account) throw new Error('未返回账户资源数据');
|
||||
result.d1 = account.d1AnalyticsAdaptiveGroups || [];
|
||||
result.r2 = account.r2StorageAdaptiveGroups || [];
|
||||
}
|
||||
|
||||
return {
|
||||
traffic: trafficData,
|
||||
d1: d1Data,
|
||||
r2: r2Data,
|
||||
missingEnvVars: !accountId || !d1Id || !r2Bucket,
|
||||
errorMsg: accErrorMsg ? accErrorMsg : undefined
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch analytics', err);
|
||||
return { errorMsg: '获取统计数据失败,请检查 Token 权限或网络环境' };
|
||||
} catch (cause) {
|
||||
result.errorMsg = `账户资源统计不可用:${cause instanceof Error ? cause.message : '查询失败'}`;
|
||||
}
|
||||
result.scopes = await trafficPromise;
|
||||
return result;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { purgeCache } from '$lib/server/cloudflare';
|
||||
|
||||
interface PostListRow {
|
||||
id: number;
|
||||
@@ -83,27 +82,12 @@ export const actions = {
|
||||
|
||||
try {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const { results: postsToDelete } = await db
|
||||
.prepare(`SELECT slug FROM posts WHERE id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<{ slug: string }>();
|
||||
|
||||
await db
|
||||
.prepare(`DELETE FROM posts WHERE id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.run();
|
||||
|
||||
try {
|
||||
const pathsToPurge = ['/', '/posts', ...postsToDelete.map((p) => `/posts/${p.slug}`)];
|
||||
if (platform?.context?.waitUntil) {
|
||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
||||
} else {
|
||||
await purgeCache(platform, pathsToPurge);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger cache purge:', err);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting post:', err);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
resolveSeriesWeight,
|
||||
SeriesValidationError
|
||||
} from '$lib/server/series';
|
||||
import { purgeCache } from '$lib/server/cloudflare';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, platform, parent }) => {
|
||||
const db = platform?.env?.DB;
|
||||
@@ -177,17 +176,6 @@ export const actions: Actions = {
|
||||
return fail(500, { error: '保存修改失败' });
|
||||
}
|
||||
|
||||
try {
|
||||
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
|
||||
if (platform?.context?.waitUntil) {
|
||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
||||
} else {
|
||||
await purgeCache(platform, pathsToPurge);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger cache purge:', err);
|
||||
}
|
||||
|
||||
redirect(303, '/admin/posts');
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
resolveSeriesWeight,
|
||||
SeriesValidationError
|
||||
} from '$lib/server/series';
|
||||
import { purgeCache } from '$lib/server/cloudflare';
|
||||
|
||||
export const load: PageServerLoad = async ({ platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
@@ -117,17 +116,6 @@ export const actions: Actions = {
|
||||
return fail(500, { error: '保存失败' });
|
||||
}
|
||||
|
||||
try {
|
||||
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
|
||||
if (platform?.context?.waitUntil) {
|
||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
||||
} else {
|
||||
await purgeCache(platform, pathsToPurge);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger cache purge:', err);
|
||||
}
|
||||
|
||||
redirect(303, '/admin/posts');
|
||||
}
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export const load: PageServerLoad = async ({ locals, platform }) => {
|
||||
platformEnv: db ? 'Cloudflare D1 (Connected)' : 'Local Mock',
|
||||
r2Env: r2 ? 'Cloudflare R2 (Connected)' : 'Local Mock',
|
||||
settings,
|
||||
analytics: fetchAnalytics(platform) // Streaming promise
|
||||
analytics: fetchAnalytics(platform, settings['r2.public_domain']) // Streaming promise
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -52,6 +52,13 @@
|
||||
bind:activeTab
|
||||
/>
|
||||
|
||||
{#if data.settings['cache.purge_error']}
|
||||
<p role="alert" class="cache-warning">
|
||||
内容已保存,但最近一次缓存刷新失败:{data.settings['cache.purge_error']}。列表页最多可能延迟 5
|
||||
分钟,详情页最多 1 小时更新。请检查缓存清理权限后重试保存。
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="settings-container">
|
||||
{#if activeTab === 'basic'}
|
||||
<div class="basic-top-row">
|
||||
@@ -357,6 +364,13 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cache-warning {
|
||||
padding: 16px;
|
||||
background: #fff3cd;
|
||||
color: #735400;
|
||||
border-radius: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.admin-page-header {
|
||||
margin-bottom: 25px;
|
||||
border-bottom: 2px solid var(--text-color);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { purgeCache } from '$lib/server/cloudflare';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, platform }) => {
|
||||
const db = platform?.env.DB;
|
||||
@@ -125,16 +124,6 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
|
||||
if (platform?.context?.waitUntil) {
|
||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
||||
} else {
|
||||
await purgeCache(platform, pathsToPurge);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger cache purge:', err);
|
||||
}
|
||||
return json({ success: true, slug });
|
||||
} else {
|
||||
return json({ error: 'Failed to update post' }, { status: 500 });
|
||||
@@ -164,16 +153,6 @@ export const DELETE: RequestHandler = async ({ params, platform }) => {
|
||||
const result = await db.prepare('DELETE FROM posts WHERE slug = ?').bind(slug).run();
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
const pathsToPurge = ['/', '/posts', `/posts/${slug}`];
|
||||
if (platform?.context?.waitUntil) {
|
||||
platform.context.waitUntil(purgeCache(platform, pathsToPurge));
|
||||
} else {
|
||||
await purgeCache(platform, pathsToPurge);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger cache purge:', err);
|
||||
}
|
||||
return json({ success: true });
|
||||
} else {
|
||||
return json({ error: 'Failed to delete post' }, { status: 500 });
|
||||
|
||||
Reference in new issue
Block a user