feat: 集成 Cloudflare Web Analytics 流量统计与图表展示
This commit is contained in:
1 parent
24b35694ed
commit
02dc3c155f
7 files changed
+411
-7
No files matched your search
@@ -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 } }[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 查询失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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: '数据库未连接' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user