From d61b8a62ffe30d4229f14d35a1c2935c3f7556bd Mon Sep 17 00:00:00 2001 From: William Tso Date: Thu, 3 Apr 2025 16:27:04 +0800 Subject: [PATCH] utm --- app/api/events/track/route.ts | 2 + app/api/events/utm/route.ts | 117 ++++++++++++ app/api/types.ts | 2 + app/components/analytics/UtmAnalytics.tsx | 185 +++++++++++++++++++ app/page.tsx | 42 +---- scripts/db/sql/clickhouse/add_utm_fields.sql | 41 ++++ 6 files changed, 355 insertions(+), 34 deletions(-) create mode 100644 app/api/events/utm/route.ts create mode 100644 app/components/analytics/UtmAnalytics.tsx create mode 100644 scripts/db/sql/clickhouse/add_utm_fields.sql diff --git a/app/api/events/track/route.ts b/app/api/events/track/route.ts index fd321f9..7f90a1e 100644 --- a/app/api/events/track/route.ts +++ b/app/api/events/track/route.ts @@ -81,6 +81,8 @@ export async function POST(req: NextRequest) { utm_source: eventData.utm_source || '', utm_medium: eventData.utm_medium || '', utm_campaign: eventData.utm_campaign || '', + utm_term: eventData.utm_term || '', + utm_content: eventData.utm_content || '', // Interaction information time_spent_sec: eventData.time_spent_sec || 0, diff --git a/app/api/events/utm/route.ts b/app/api/events/utm/route.ts new file mode 100644 index 0000000..95a4a5a --- /dev/null +++ b/app/api/events/utm/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from 'next/server'; +import clickhouse from '@/lib/clickhouse'; +import type { ApiResponse } from '@/lib/types'; + +interface UtmData { + utm_value: string; + clicks: number; + visitors: number; + avg_time_spent: number; + bounces: number; + conversions: number; +} + +// 格式化日期时间字符串为ClickHouse支持的格式 +const formatDateTime = (dateStr: string): string => { + return dateStr.replace('T', ' ').replace('Z', ''); +}; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + + // 获取过滤参数 + const startTime = searchParams.get('startTime'); + const endTime = searchParams.get('endTime'); + const linkId = searchParams.get('linkId'); + + // 获取UTM类型参数 + const utmType = searchParams.get('utmType') || 'source'; + + // 构建WHERE子句 + let whereClause = ''; + const conditions = []; + + if (startTime) { + conditions.push(`event_time >= toDateTime('${formatDateTime(startTime)}')`); + } + + if (endTime) { + conditions.push(`event_time <= toDateTime('${formatDateTime(endTime)}')`); + } + + if (linkId) { + conditions.push(`link_id = '${linkId}'`); + } + + if (conditions.length > 0) { + whereClause = `WHERE ${conditions.join(' AND ')}`; + } + + // 确定要分组的UTM字段 + let utmField; + switch (utmType) { + case 'source': + utmField = 'utm_source'; + break; + case 'medium': + utmField = 'utm_medium'; + break; + case 'campaign': + utmField = 'utm_campaign'; + break; + case 'term': + utmField = 'utm_term'; + break; + case 'content': + utmField = 'utm_content'; + break; + default: + utmField = 'utm_source'; + } + + // 构建SQL查询 + const query = ` + SELECT + ${utmField} AS utm_value, + COUNT(*) AS clicks, + uniqExact(visitor_id) AS visitors, + round(AVG(time_spent_sec), 2) AS avg_time_spent, + countIf(is_bounce = 1) AS bounces, + countIf(conversion_type IN ('visit', 'stay', 'interact', 'signup', 'subscription', 'purchase')) AS conversions + FROM shorturl_analytics.events + ${whereClause} + ${whereClause ? 'AND' : 'WHERE'} ${utmField} != '' + GROUP BY utm_value + ORDER BY clicks DESC + LIMIT 100 + `; + + // 执行查询 + const result = await clickhouse.query({ + query, + format: 'JSONEachRow', + }); + + // 获取查询结果 + const rows = await result.json(); + const data = rows as UtmData[]; + + // 返回数据 + const response: ApiResponse = { + success: true, + data + }; + + return NextResponse.json(response); + } catch (error) { + console.error('Error fetching UTM data:', error); + + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }; + + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/types.ts b/app/api/types.ts index 881b24a..77c2590 100644 --- a/app/api/types.ts +++ b/app/api/types.ts @@ -54,6 +54,8 @@ export interface Event { utm_source: string; utm_medium: string; utm_campaign: string; + utm_term: string; + utm_content: string; // 交互信息 time_spent_sec: number; diff --git a/app/components/analytics/UtmAnalytics.tsx b/app/components/analytics/UtmAnalytics.tsx new file mode 100644 index 0000000..35a3c7f --- /dev/null +++ b/app/components/analytics/UtmAnalytics.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useEffect } from 'react'; + +interface UtmData { + utm_value: string; + clicks: number; + visitors: number; + avg_time_spent: number; + bounces: number; + conversions: number; +} + +interface UtmAnalyticsProps { + startTime?: string; + endTime?: string; + linkId?: string; +} + +export default function UtmAnalytics({ startTime, endTime, linkId }: UtmAnalyticsProps) { + const [activeTab, setActiveTab] = useState('source'); + const [utmData, setUtmData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 加载UTM数据 + useEffect(() => { + const fetchUtmData = async () => { + setIsLoading(true); + setError(null); + + try { + // 构建URL参数 + const params = new URLSearchParams(); + if (startTime) params.append('startTime', startTime); + if (endTime) params.append('endTime', endTime); + if (linkId) params.append('linkId', linkId); + params.append('utmType', activeTab); + + // 发送请求 + const response = await fetch(`/api/events/utm?${params}`); + + if (!response.ok) { + throw new Error('Failed to fetch UTM data'); + } + + const result = await response.json(); + + if (result.success) { + setUtmData(result.data || []); + } else { + throw new Error(result.error || 'Failed to fetch UTM data'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error occurred'); + console.error('Error fetching UTM data:', err); + } finally { + setIsLoading(false); + } + }; + + fetchUtmData(); + }, [activeTab, startTime, endTime, linkId]); + + // 安全地格式化数字 + const formatNumber = (value: number | undefined | null): string => { + if (value === undefined || value === null) return '0'; + return value.toLocaleString(); + }; + + return ( +
+

UTM Parameters

+ +
+
+ + + + + +
+
+ + {isLoading ? ( +
+
+ Loading... +
+ ) : error ? ( +
+ Error: {error} +
+ ) : utmData.length === 0 ? ( +
+ No data available +
+ ) : ( +
+ + + + + + + + + + + + + {utmData.map((item, index) => { + const bounceRate = item.clicks > 0 ? (item.bounces / item.clicks) * 100 : 0; + const conversionRate = item.clicks > 0 ? (item.conversions / item.clicks) * 100 : 0; + + return ( + + + + + + + + + ); + })} + +
+ {activeTab === 'source' ? 'Source' : + activeTab === 'medium' ? 'Medium' : + activeTab === 'campaign' ? 'Campaign' : + activeTab === 'term' ? 'Term' : 'Content'} + + Clicks + + Visitors + + Avg. Time + + Bounce Rate + + Conversions +
+ {item.utm_value || 'Unknown'} + + {formatNumber(item.clicks)} + + {formatNumber(item.visitors)} + + {item.avg_time_spent.toFixed(1)}s + + {bounceRate.toFixed(1)}% + + {formatNumber(item.conversions)} ({conversionRate.toFixed(1)}%) +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index e2bb31e..8e8afe6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,6 +6,7 @@ import { DateRangePicker } from '@/app/components/ui/DateRangePicker'; import TimeSeriesChart from '@/app/components/charts/TimeSeriesChart'; import GeoAnalytics from '@/app/components/analytics/GeoAnalytics'; import DevicePieCharts from '@/app/components/charts/DevicePieCharts'; +import UtmAnalytics from '@/app/components/analytics/UtmAnalytics'; import { EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics as DeviceAnalyticsType } from '@/app/api/types'; import { TeamSelector } from '@/app/components/ui/TeamSelector'; import { ProjectSelector } from '@/app/components/ui/ProjectSelector'; @@ -762,41 +763,14 @@ export default function HomePage() {

Geographic Distribution

- { - // 构建查询参数 - const params = new URLSearchParams({ - startTime: format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'"), - endTime: format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'"), - groupBy: mode - }); - - // 添加其他筛选参数 - if (selectedTeamIds.length > 0) { - selectedTeamIds.forEach(id => params.append('teamId', id)); - } - - if (selectedProjectIds.length > 0) { - selectedProjectIds.forEach(id => params.append('projectId', id)); - } - - if (selectedTagIds.length > 0) { - selectedTagIds.forEach(id => params.append('tagId', id)); - } - - // 刷新地理位置数据 - fetch(`/api/events/geo?${params}`) - .then(res => res.json()) - .then(data => { - if (data.success) { - setGeoData(data.data); - } - }) - .catch(error => console.error('Failed to fetch geo data:', error)); - }} - /> +
+ + {/* 添加UTM分析组件 */} + ); diff --git a/scripts/db/sql/clickhouse/add_utm_fields.sql b/scripts/db/sql/clickhouse/add_utm_fields.sql new file mode 100644 index 0000000..6da1f9c --- /dev/null +++ b/scripts/db/sql/clickhouse/add_utm_fields.sql @@ -0,0 +1,41 @@ +-- 添加缺失的UTM参数字段到shorturl_analytics.events表 +-- 创建日期: 2024-07-02 +-- 用途: 增强UTM参数追踪能力 +-- 添加utm_term字段 (用于跟踪付费搜索关键词) +ALTER TABLE + shorturl_analytics.events +ADD + COLUMN utm_term String DEFAULT '' AFTER utm_campaign; + +-- 添加utm_content字段 (用于区分相同广告的不同版本或A/B测试) +ALTER TABLE + shorturl_analytics.events +ADD + COLUMN utm_content String DEFAULT '' AFTER utm_term; + +-- 验证字段添加成功 +DESCRIBE TABLE shorturl_analytics.events; + +-- 示例查询: 查看UTM参数分析数据 +SELECT + utm_source, + utm_medium, + utm_campaign, + utm_term, + utm_content, + COUNT(*) as clicks +FROM + shorturl_analytics.events +WHERE + event_type = 'click' + AND utm_source != '' +GROUP BY + utm_source, + utm_medium, + utm_campaign, + utm_term, + utm_content +ORDER BY + clicks DESC +LIMIT + 10; \ No newline at end of file