diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index 3156fac..85e0fe8 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -7,6 +7,7 @@ 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 PathAnalytics from '@/app/components/analytics/PathAnalytics'; 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'; @@ -498,68 +499,6 @@ function AnalyticsContent() { )} - {/* Debug info - remove in production */} - {process.env.NODE_ENV !== 'production' && ( -
-

Debug Info:

-
- Hydrated: {isHydrated ? 'Yes' : 'No'} | - Should Fetch: {shouldFetchData ? 'Yes' : 'No'} | - Has ShortUrl: {selectedShortUrl ? 'Yes' : 'No'} -
- {selectedShortUrl && ( -
- ShortUrl ID: {selectedShortUrl.id} | - ExternalId: {selectedShortUrl.externalId || 'MISSING'} | - URL: {selectedShortUrl.shortUrl} -
- )} -
- IMPORTANT: - The events table uses external_id as link_id, not the UUID format. - External ID format sample: cm8x34sdr0007m11yh1xe6qc2 -
- - {/* Full link data for debugging */} - {selectedShortUrl && ( -
-
- Show Full Link Data -
- {JSON.stringify(selectedShortUrl, null, 2)} -
-
-
- )} - - {/* URL Parameters */} -
-
- API Request URLs -
-
Summary API URL: {`/api/events/summary?${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'"), - ...(selectedShortUrl?.externalId ? { linkId: selectedShortUrl.externalId } : {}) - }).toString()}`}
-
-
-
- - {/* Local Storage Data */} -
-
- LocalStorage Data -
- {typeof window !== 'undefined' && localStorage.getItem('shorturl-storage') ? - JSON.stringify(JSON.parse(localStorage.getItem('shorturl-storage') || '{}'), null, 2) : - 'No localStorage data'} -
-
-
-
- )} -

Analytics Dashboard

@@ -650,8 +589,6 @@ function AnalyticsContent() { } else { setSelectedTagNames(value ? [value] : []); } - // 我们需要将标签名称映射回ID,但由于TagSelector内部已经做了处理 - // 这里不需要额外的映射代码,selectedTagNames存储名称即可 }} className="w-[250px]" multiple={true} @@ -667,525 +604,86 @@ function AnalyticsContent() {
- {/* 仅在未选中 shorturl 且有选择的筛选条件时显示筛选条件标签 */} - {!selectedShortUrl && ( - <> - {/* 显示团队选择信息 */} - {selectedTeamIds.length > 0 && ( -
- - {selectedTeamIds.length === 1 ? 'Team filter:' : 'Teams filter:'} - -
- {selectedTeamIds.map(teamId => ( - - {teamId} - - - ))} - {selectedTeamIds.length > 0 && ( - - )} -
-
- )} - - {/* 显示项目选择信息 */} - {selectedProjectIds.length > 0 && ( -
- - {selectedProjectIds.length === 1 ? 'Project filter:' : 'Projects filter:'} - -
- {selectedProjectIds.map(projectId => ( - - {projectId} - - - ))} - {selectedProjectIds.length > 0 && ( - - )} -
-
- )} - - {/* 显示标签选择信息 */} - {selectedTagNames.length > 0 && ( -
- - {selectedTagNames.length === 1 ? 'Tag filter:' : 'Tags filter:'} - -
- {selectedTagNames.map(tagName => ( - - {tagName} - - - ))} - {selectedTagNames.length > 0 && ( - - )} -
-
- )} - + {/* 仪表板内容 */} + {summary && ( +
+
+

Total Events

+

+ {typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents} +

+
+
+

Unique Visitors

+

+ {typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors} +

+
+
+

Total Conversions

+

+ {typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions} +

+
+
+

Avg. Time Spent

+

+ {summary.averageTimeSpent?.toFixed(1) || '0'}s +

+
+
)} - {/* 仪表板内容 - 现在放在事件列表之后 */} - <> - {summary && ( -
-
-

Total Events

-

- {typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents} -

-
-
-

Unique Visitors

-

- {typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors} -

-
-
-

Total Conversions

-

- {typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions} -

-
-
-

Avg. Time Spent

-

- {summary.averageTimeSpent?.toFixed(1) || '0'}s -

-
-
- )} - - {/* 时间序列图表 */} - {timeSeriesData && timeSeriesData.length > 0 && ( -
-

Traffic Over Time

-
- -
-
- )} - - {/* 设备分析图表 */} - {deviceData && ( -
-

Device Analytics

- -
- )} - - {/* 地理分析 */} - {geoData && geoData.length > 0 && ( -
-

Geographic Distribution

- -
- )} - - {/* UTM 参数分析 */} + {/* 时间序列图表 */} + {timeSeriesData && timeSeriesData.length > 0 && (
-

UTM Parameters Analysis

- Traffic Over Time +
+ +
+
+ )} + + {/* 设备分析图表 */} + {deviceData && ( +
+

Device Analytics

+ +
+ )} + + {/* 地理分析 */} + {geoData && geoData.length > 0 && ( +
+

Geographic Distribution

+ +
+ )} + + {/* UTM 参数分析 */} +
+

UTM Parameters Analysis

+ +
+ + {/* 路径分析 - 仅在选中特定链接时显示 */} + {selectedShortUrl && selectedShortUrl.externalId && ( +
+

路径分析

+
- -
-
-

Recent Events

-
- -
- - - - - - - - - - - - - - - - - {events.map((event, index) => { - const info = extractEventInfo(event); - return ( - - - - - - - - - - - - - ); - })} - -
- Time - - Link Name - - Original URL - - Full URL - - Event Type - - Tags - - User - - Team/Project - - IP/Location - - Device Info -
- {formatDate(info.eventTime)} - - {info.linkName} -
- ID: {event.link_id || '-'} -
-
- - {info.originalUrl} - - - - {info.fullUrl} - - - - {info.eventType} - - -
- {info.tags && info.tags.length > 0 ? ( - info.tags.map((tag, idx) => ( - - {tag} - - )) - ) : ( - - - )} -
-
-
{info.userInfo}
-
{info.visitorId}...
-
-
{info.teamName}
-
{info.projectName}
-
-
- - IP: - {info.ipAddress} - - - Location: - {info.location} - -
-
-
- - Device: - {info.device} - - - Browser: - {info.browser} - - - OS: - {info.os} - -
-
-
- - {/* 表格为空状态 */} - {!loading && events.length === 0 && ( -
- No events found -
- )} - - {/* 分页控件 */} - {!loading && events.length > 0 && ( -
-
- - -
-
-
-

- Showing {events.length > 0 ? ((currentPage - 1) * pageSize) + 1 : 0} to {events.length > 0 ? ((currentPage - 1) * pageSize) + events.length : 0} of{' '} - {totalEvents} results -

-
-
-
- -
- - {/* 添加直接跳转到指定页的输入框 */} -
- Go to: - { - const page = parseInt(e.target.value); - if (!isNaN(page) && page >= 1 && page <= Math.ceil(totalEvents / pageSize)) { - setCurrentPage(page); - } - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - const input = e.target as HTMLInputElement; - const page = parseInt(input.value); - if (!isNaN(page) && page >= 1 && page <= Math.ceil(totalEvents / pageSize)) { - setCurrentPage(page); - } - } - }} - className="w-16 px-3 py-1 border border-gray-300 rounded-md text-sm" - /> - - of {Math.max(1, Math.ceil(totalEvents / pageSize))} - -
- - -
-
-
- )} -
- + )} ); } diff --git a/app/api/events/path-analytics/route.ts b/app/api/events/path-analytics/route.ts new file mode 100644 index 0000000..19a6921 --- /dev/null +++ b/app/api/events/path-analytics/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { ApiResponse } from '@/lib/types'; +import { executeQuery } from '@/lib/clickhouse'; + +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'); + + if (!startTime || !endTime || !linkId) { + return NextResponse.json({ + success: false, + error: '缺少必要参数' + }, { status: 400 }); + } + + // 查询链接的点击事件 + const query = ` + SELECT event_attributes + FROM events + WHERE link_id = '${linkId}' + AND event_time >= parseDateTimeBestEffort('${startTime}') + AND event_time <= parseDateTimeBestEffort('${endTime}') + AND event_type = 'click' + `; + + const events = await executeQuery(query); + + // 处理事件数据,按路径分组 + const pathMap = new Map(); + let totalClicks = 0; + + events.forEach((event: any) => { + try { + if (event.event_attributes) { + const attrs = JSON.parse(event.event_attributes); + if (attrs.full_url) { + // 提取URL的路径和参数部分 + const url = new URL(attrs.full_url); + const pathWithParams = url.pathname + (url.search || ''); + + // 更新路径计数 + const currentCount = pathMap.get(pathWithParams) || 0; + pathMap.set(pathWithParams, currentCount + 1); + totalClicks++; + } + } + } catch (error) { + // 忽略解析错误 + } + }); + + // 转换为数组并按点击数排序 + const pathData = Array.from(pathMap.entries()) + .map(([path, count]) => ({ + path, + count, + percentage: totalClicks > 0 ? count / totalClicks : 0, + })) + .sort((a, b) => b.count - a.count); + + const response: ApiResponse = { + success: true, + data: pathData, + meta: { total: totalClicks } + }; + + return NextResponse.json(response); + } catch (error) { + console.error('获取路径分析数据错误:', error); + const response: ApiResponse = { + success: false, + error: error instanceof Error ? error.message : '服务器内部错误' + }; + return NextResponse.json(response, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/events/track/readme.md b/app/api/events/track/readme.md index b48b51b..5458511 100644 --- a/app/api/events/track/readme.md +++ b/app/api/events/track/readme.md @@ -173,3 +173,36 @@ fetch('/api/events/track', { - 所有对象类型的字段(如 `event_attributes`)可以作为对象或预先格式化的JSON字符串传递 - 如果不提供 `event_id`、`visitor_id` 或 `session_id`,系统将自动生成 - 时间戳字段接受ISO格式的日期字符串,并会被转换为ClickHouse兼容的格式 + + +UTM 测试示例。1. 电子邮件营销链接 +https://short.domain.com/summer?utm_source=newsletter&utm_medium=email&utm_campaign=summer_promo&utm_term=discount&utm_content=header +说明: 用于电子邮件营销活动,跟踪用户从邮件头部横幅点击的流量。 + +2. 社交媒体广告链接 +https://short.domain.com/product?utm_source=instagram&utm_medium=social&utm_campaign=fall_collection&utm_content=story +说明: 用于 Instagram Story 广告,跟踪用户从社交媒体故事广告点击的情况。 + +3. 搜索引擎广告链接 +https://short.domain.com/service?utm_source=google&utm_medium=cpc&utm_campaign=brand_terms&utm_term=service+name +说明: 用于 Google Ads 广告,跟踪用户从搜索引擎付费广告点击的流量,特别是针对特定搜索词。 + +4. QR 码链接 +https://short.domain.com/event?utm_source=flyer&utm_medium=print&utm_campaign=local_event&utm_content=qr_code&source=qr +说明: 用于打印材料上的 QR 码,跟踪用户扫描实体宣传资料的情况。 + +5. 合作伙伴引荐链接 +https://short.domain.com/partner?utm_source=affiliate&utm_medium=referral&utm_campaign=partner_program&utm_content=banner +说明: 用于合作伙伴网站上的推广横幅,跟踪来自联盟营销的转化率。 + + +https://upj.to/5seaii?utm_source=newsletter&utm_medium=email&utm_campaign=summer_promo&utm_term=discount&utm_content=header + +https://upj.to/5seaii?utm_source=instagram&utm_medium=social&utm_campaign=fall_collection&utm_content=story + +https://upj.to/5seaii?utm_source=google&utm_medium=cpc&utm_campaign=brand_terms&utm_term=service+name + + +https://upj.to/5seaii?utm_source=flyer&utm_medium=print&utm_campaign=local_event&utm_content=qr_code&source=qr + +https://upj.to/5seaii?utm_source=affiliate&utm_medium=referral&utm_campaign=partner_program&utm_content=banner diff --git a/app/components/analytics/PathAnalytics.tsx b/app/components/analytics/PathAnalytics.tsx new file mode 100644 index 0000000..50c1298 --- /dev/null +++ b/app/components/analytics/PathAnalytics.tsx @@ -0,0 +1,111 @@ +import React, { useState, useEffect } from 'react'; + +interface PathAnalyticsProps { + startTime: string; + endTime: string; + linkId?: string; +} + +interface PathData { + path: string; + count: number; + percentage: number; +} + +const PathAnalytics: React.FC = ({ startTime, endTime, linkId }) => { + const [loading, setLoading] = useState(true); + const [pathData, setPathData] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (!linkId) { + setLoading(false); + return; + } + + const fetchPathData = async () => { + try { + const params = new URLSearchParams({ + startTime, + endTime, + linkId + }); + + const response = await fetch(`/api/events/path-analytics?${params.toString()}`); + + if (!response.ok) { + throw new Error('获取路径分析数据失败'); + } + + const result = await response.json(); + + if (result.success && result.data) { + setPathData(result.data); + } else { + setError(result.error || '加载路径分析失败'); + } + } catch (err) { + setError(err instanceof Error ? err.message : '发生错误'); + } finally { + setLoading(false); + } + }; + + fetchPathData(); + }, [startTime, endTime, linkId]); + + if (loading) { + return
+
+
; + } + + if (error) { + return
{error}
; + } + + if (!linkId) { + return
选择一个特定链接查看路径分析。
; + } + + if (pathData.length === 0) { + return
该链接暂无路径数据。
; + } + + return ( +
+
+ 注意:不同的URL参数组合会被视为不同的路径(例如 /abc?p=1 和 /abc?p=2 属于不同路径) +
+
+ + + + + + + + + + {pathData.map((item, index) => ( + + + + + + ))} + +
路径点击数百分比
{item.path}{item.count} +
+ {(item.percentage * 100).toFixed(1)}% +
+
+
+
+
+
+
+ ); +}; + +export default PathAnalytics; \ No newline at end of file