feat: 集成 Cloudflare Web Analytics 流量统计与图表展示

This commit is contained in:
seahi committed 2026-09-26 20:06:29 +08:00
1 parent 24b35694ed
commit 02dc3c155f
7 files changed
+411 -7

No files matched your search

+11
View File
@@ -12,7 +12,18 @@ export interface TrafficScope {
error?: string;
}
export interface AudienceAnalytics {
scopeLabel: string;
days: number;
pageViews: number | null;
uniqueVisitors: number | null;
last7Days: { pageViews: number; uniqueVisitors: number } | null;
daily: { date: string; pageViews: number | null; uniqueVisitors: number | null }[];
error?: string;
}
export interface AnalyticsData {
audience?: AudienceAnalytics;
scopes: TrafficScope[];
d1: { sum: { rowsRead: number; rowsWritten: number } }[];
r2: { max: { payloadSize: number } }[];
+218 -4
View File
@@ -7,6 +7,8 @@
let loading = $state(true);
let error = $state('');
let chartCanvas = $state<HTMLCanvasElement>();
let audienceCanvas = $state<HTMLCanvasElement>();
let audience = $derived(data?.audience);
let scopes = $derived(
(data?.scopes || []).map((scope) => ({ ...scope, summary: summarizeTraffic(scope.rows) }))
);
@@ -170,6 +172,90 @@
};
});
$effect(() => {
const canvas = audienceCanvas;
const current = audience;
if (!canvas || !current || current.error || !current.daily.length) return;
let cancelled = false;
let chart: import('chart.js').Chart | undefined;
const rows = [...current.daily].sort((a, b) => a.date.localeCompare(b.date));
const labels = rows.map((row) => row.date);
void import('chart.js/auto')
.then(({ default: Chart }) => {
if (cancelled) return;
chart = new Chart(canvas, {
type: 'line',
data: {
labels: labels.map((date) => date.substring(5)),
datasets: [
{
label: '页面浏览量 (PV)',
data: rows.map((row) => row.pageViews),
borderColor: '#333',
backgroundColor: 'rgba(0,0,0,0.05)',
tension: 0.3,
fill: true,
yAxisID: 'y'
},
{
label: '每日独立访客 (UV)',
data: rows.map((row) => row.uniqueVisitors),
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
}
}
}
}
});
})
.catch(() => {
/* 图表加载失败时仍保留数值指标。 */
});
return () => {
cancelled = true;
chart?.destroy();
};
});
function formatBytes(bytes: number) {
if (!bytes) return '0 B';
const index = Math.min(4, Math.floor(Math.log(bytes) / Math.log(1024)));
@@ -194,10 +280,6 @@
<div class="error-state">
{error}
</div>
{:else if scopes.length === 0}
<div class="empty-state">
{data?.errorMsg || '没有获取到有效的流量数据,请检查 Cloudflare 配置。'}
</div>
{:else}
<div
class="section-title"
@@ -205,6 +287,11 @@
>
项目访问流量
</div>
{#if scopes.length === 0}
<div class="empty-state">
{data?.errorMsg || '没有获取到有效的流量数据,请检查 Cloudflare 配置。'}
</div>
{/if}
<div class="dashboard-grid">
<div class="chart-container">
@@ -320,6 +407,133 @@
</div>
</div>
<div class="section-title">页面浏览与独立访客 · 近 30 天趋势</div>
<div class="info-alert">
{audience?.scopeLabel || 'PV、UV 数据源待确认:已撤除站内采集,不使用本地数据表。'}
</div>
{#if audience?.error}<div class="error-state">{audience.error}</div>{/if}
<div class="dashboard-grid">
<div class="chart-container">
<div class="canvas-wrapper">
<canvas bind:this={audienceCanvas} aria-label="PV 与每日 UV 趋势图"></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">
{audience?.pageViews == null || audience.error ? '—' : formatNumber(audience.pageViews)}
</div>
<div class="metric-subtitle" title={audience?.scopeLabel}>
近 {audience?.days ?? 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">
{audience?.uniqueVisitors == null || audience.error
? '—'
: formatNumber(audience.uniqueVisitors)}
</div>
<div class="metric-subtitle" title={audience?.scopeLabel}>近 30 天去重访客</div>
</div>
<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">
{audience?.last7Days?.pageViews == null || audience.error
? '—'
: formatNumber(audience.last7Days.pageViews)}
</div>
<div class="metric-subtitle" title={audience?.scopeLabel}>近 7 天累计浏览</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">
{audience?.last7Days?.uniqueVisitors == null || audience.error
? '—'
: formatNumber(audience.last7Days.uniqueVisitors)}
</div>
<div class="metric-subtitle" title={audience?.scopeLabel}>近 7 天去重访客</div>
</div>
</div>
</div>
<div class="section-title">Cloudflare 资源配额</div>
{#if data?.errorMsg}<div class="error-state">{data.errorMsg}</div>{/if}
{#if missingEnvVars}
+116 -1
View File
@@ -150,7 +150,8 @@ async function fetchTraffic(
export async function fetchAnalytics(
platform: App.Platform | undefined,
assetsDomain?: string
assetsDomain?: string,
webAnalyticsToken?: string
): Promise<AnalyticsData> {
const env = platform?.env as AnalyticsEnv | undefined;
const token = env?.CF_API_TOKEN;
@@ -174,6 +175,22 @@ export async function fetchAnalytics(
fetchTraffic(token, zoneId, '博客', env?.CF_DOMAIN, now),
fetchTraffic(token, env?.CF_ASSETS_ZONE_ID || zoneId, 'R2 附件', assetsDomain, now)
]);
let audiencePromise: Promise<AnalyticsData['audience']> = Promise.resolve(undefined);
if (accountId && webAnalyticsToken) {
audiencePromise = fetchAudience(token, accountId, webAnalyticsToken, now);
} else if (!webAnalyticsToken) {
audiencePromise = Promise.resolve({
scopeLabel: 'Web Analytics (未配置 Token)',
days: 30,
pageViews: null,
uniqueVisitors: null,
last7Days: null,
daily: [],
error: '请在上方配置 Web Analytics Token / Site Tag 以拉取数据。'
});
}
let accountQuery = '';
if (accountId) {
let d1Query = '';
@@ -235,5 +252,103 @@ export async function fetchAnalytics(
result.errorMsg = `账户资源统计不可用:${cause instanceof Error ? cause.message : '查询失败'}`;
}
result.scopes = await trafficPromise;
result.audience = await audiencePromise;
return result;
}
async function fetchAudience(
token: string,
accountId: string,
siteTag: string,
now: Date
): Promise<AnalyticsData['audience']> {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().split('T')[0];
try {
const data = await graphql<{
viewer: {
accounts: {
rumPagesAdaptiveGroups: {
count: number;
sum: { visits: number };
dimensions: { date: string };
}[];
}[];
};
}>(
token,
`
query WebAnalytics($account: string, $site: string, $date: string) {
viewer {
accounts(filter: { accountTag: $account }) {
rumPagesAdaptiveGroups(
limit: 30,
filter: { siteTag: $site, datetime_geq: $date }
orderBy: [date_ASC]
) {
count
sum {
visits
}
dimensions {
date
}
}
}
}
}
`,
{
account: accountId,
site: siteTag,
date: `${thirtyDaysAgo}T00:00:00Z`
}
);
const rows = data.viewer.accounts[0]?.rumPagesAdaptiveGroups;
if (!rows) throw new Error('未返回数据,请检查 Token、Account ID 和 Site Tag 是否正确并具有权限');
let totalPageViews = 0;
let totalUniqueVisitors = 0;
let last7DaysPV = 0;
let last7DaysUV = 0;
const sevenDaysAgoStr = new Date(now.getTime() - 7 * 86400000).toISOString().split('T')[0];
const dailyMap = new Map(rows.map((row) => [row.dimensions.date, row]));
const daily = Array.from({ length: 30 }, (_, index) => {
const date = new Date(now.getTime() - (29 - index) * 86400000).toISOString().split('T')[0];
const row = dailyMap.get(date);
const pv = row ? row.count : 0;
const uv = row ? row.sum.visits : 0;
totalPageViews += pv;
totalUniqueVisitors += uv;
if (date >= sevenDaysAgoStr) {
last7DaysPV += pv;
last7DaysUV += uv;
}
return { date, pageViews: pv, uniqueVisitors: uv };
});
return {
scopeLabel: 'Web Analytics (RUM)',
days: 30,
pageViews: totalPageViews,
uniqueVisitors: totalUniqueVisitors,
last7Days: {
pageViews: last7DaysPV,
uniqueVisitors: last7DaysUV
},
daily
};
} catch (cause) {
console.error('Web Analytics query failed:', cause);
return {
scopeLabel: 'Web Analytics (RUM)',
days: 30,
pageViews: null,
uniqueVisitors: null,
last7Days: null,
daily: [],
error: cause instanceof Error ? cause.message : 'Web Analytics 查询失败'
};
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ export const load: LayoutServerLoad = async ({ platform }) => {
description: 'A high-performance blog powered by Cloudflare D1, R2, and KV.',
avatar_enabled: 'true',
avatar_url: '',
launch_date: ''
launch_date: '',
web_analytics_token: ''
};
if (db) {
+7
View File
@@ -49,6 +49,13 @@
>{$page.data.site?.title || 'BlogFlare'} - {$page.data.site?.description ||
'A Pure Cloudflare Blog'}</title
>
{#if $page.data.site?.web_analytics_token}
<script
defer
src="https://static.cloudflareinsights.com/beacon.min.js"
data-cf-beacon={JSON.stringify({ token: $page.data.site.web_analytics_token })}
></script>
{/if}
</svelte:head>
{#if $page.url.pathname.startsWith('/admin')}
+27 -1
View File
@@ -25,11 +25,37 @@ 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, settings['r2.public_domain']) // Streaming promise
analytics: fetchAnalytics(
platform,
settings['r2.public_domain'],
settings['site.web_analytics_token']
) // Streaming promise
};
};
export const actions: Actions = {
updateWebAnalytics: async ({ request, platform }) => {
const db = platform?.env?.DB;
if (!db) return fail(500, { error: '数据库未连接' });
const data = await request.formData();
const token = data.get('web_analytics_token')?.toString().trim() || '';
try {
await db
.prepare(
`INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
)
.bind('site.web_analytics_token', token)
.run();
return { success: true };
} catch (err: any) {
console.error('Failed to update web analytics token', err);
return fail(500, { error: '保存统计设置失败' });
}
},
updateUploads: async ({ request, platform }) => {
const db = platform?.env?.DB;
if (!db) return fail(500, { error: '数据库未连接' });
+30
View File
@@ -361,6 +361,36 @@
</form>
</div>
{:else if activeTab === 'analytics'}
<div class="settings-card full-width" style="margin-bottom: 25px;">
<h3>Web Analytics (流量统计)</h3>
<p class="desc">
配置 Cloudflare Web Analytics 站点 Token。配置后博客将自动采集 PV、UV。如果您通过 Cloudflare Pages 部署并在后台一键开启了统计,只需在这里填写对应生成的 Site Tag 即可拉取数据展示。
</p>
<form
method="POST"
action="?/updateWebAnalytics"
use:enhance={() => {
return async ({ update }) => {
update({ reset: false });
};
}}
class="alias-form"
>
<div class="form-group" style="max-width: 500px;">
<label for="web_analytics_token">Web Analytics Token / Site Tag</label>
<TextInput
type="text"
id="web_analytics_token"
name="web_analytics_token"
value={data.settings['site.web_analytics_token'] || ''}
placeholder="留空则禁用数据拉取和自动注入"
/>
</div>
<div class="form-actions" style="justify-content: flex-start;">
<Button type="submit" variant="primary">保存统计设置</Button>
</div>
</form>
</div>
<AnalyticsPanel analyticsPromise={data.analytics} />
{/if}
</div>