diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 27a66e5..66b0205 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -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 } }[]; diff --git a/src/lib/components/admin/AnalyticsPanel.svelte b/src/lib/components/admin/AnalyticsPanel.svelte index 38c6bfa..b30cadb 100644 --- a/src/lib/components/admin/AnalyticsPanel.svelte +++ b/src/lib/components/admin/AnalyticsPanel.svelte @@ -7,6 +7,8 @@ let loading = $state(true); let error = $state(''); let chartCanvas = $state(); + let audienceCanvas = $state(); + 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 @@
{error}
- {:else if scopes.length === 0} -
- {data?.errorMsg || '没有获取到有效的流量数据,请检查 Cloudflare 配置。'} -
{:else}
项目访问流量
+ {#if scopes.length === 0} +
+ {data?.errorMsg || '没有获取到有效的流量数据,请检查 Cloudflare 配置。'} +
+ {/if}
@@ -320,6 +407,133 @@
+
页面浏览与独立访客 · 近 30 天趋势
+
+ {audience?.scopeLabel || 'PV、UV 数据源待确认:已撤除站内采集,不使用本地数据表。'} +
+ {#if audience?.error}
{audience.error}
{/if} +
+
+
+ +
+
+
+
+
+ + + + 页面浏览量 (PV) +
+
+ {audience?.pageViews == null || audience.error ? '—' : formatNumber(audience.pageViews)} +
+
+ 近 {audience?.days ?? 30} 天累计浏览 +
+
+
+
+ + + + 独立访客 (UV) +
+
+ {audience?.uniqueVisitors == null || audience.error + ? '—' + : formatNumber(audience.uniqueVisitors)} +
+
近 30 天去重访客
+
+
+
+ + + + 页面浏览量 (PV) +
+
+ {audience?.last7Days?.pageViews == null || audience.error + ? '—' + : formatNumber(audience.last7Days.pageViews)} +
+
近 7 天累计浏览
+
+
+
+ + + + 独立访客 (UV) +
+
+ {audience?.last7Days?.uniqueVisitors == null || audience.error + ? '—' + : formatNumber(audience.last7Days.uniqueVisitors)} +
+
近 7 天去重访客
+
+
+
+
Cloudflare 资源配额
{#if data?.errorMsg}
{data.errorMsg}
{/if} {#if missingEnvVars} diff --git a/src/lib/server/cloudflare.ts b/src/lib/server/cloudflare.ts index b7ccae1..c37622d 100644 --- a/src/lib/server/cloudflare.ts +++ b/src/lib/server/cloudflare.ts @@ -150,7 +150,8 @@ async function fetchTraffic( export async function fetchAnalytics( platform: App.Platform | undefined, - assetsDomain?: string + assetsDomain?: string, + webAnalyticsToken?: string ): Promise { 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 = 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 { + 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 查询失败' + }; + } +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 0ddf7eb..a92a668 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -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) { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b56d9d9..3bc94fa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -49,6 +49,13 @@ >{$page.data.site?.title || 'BlogFlare'} - {$page.data.site?.description || 'A Pure Cloudflare Blog'} + {#if $page.data.site?.web_analytics_token} + + {/if} {#if $page.url.pathname.startsWith('/admin')} diff --git a/src/routes/admin/settings/+page.server.ts b/src/routes/admin/settings/+page.server.ts index 1818c59..51b9a63 100644 --- a/src/routes/admin/settings/+page.server.ts +++ b/src/routes/admin/settings/+page.server.ts @@ -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: '数据库未连接' }); diff --git a/src/routes/admin/settings/+page.svelte b/src/routes/admin/settings/+page.svelte index 4c4329a..bdac579 100644 --- a/src/routes/admin/settings/+page.svelte +++ b/src/routes/admin/settings/+page.svelte @@ -361,6 +361,36 @@ {:else if activeTab === 'analytics'} +
+

Web Analytics (流量统计)

+

+ 配置 Cloudflare Web Analytics 站点 Token。配置后博客将自动采集 PV、UV。如果您通过 Cloudflare Pages 部署并在后台一键开启了统计,只需在这里填写对应生成的 Site Tag 即可拉取数据展示。 +

+
{ + return async ({ update }) => { + update({ reset: false }); + }; + }} + class="alias-form" + > +
+ + +
+
+ +
+
+
{/if}