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
-
-
-
-
-
-
- |
- Time
- |
-
- Link Name
- |
-
- Original URL
- |
-
- Full URL
- |
-
- Event Type
- |
-
- Tags
- |
-
- User
- |
-
- Team/Project
- |
-
- IP/Location
- |
-
- Device Info
- |
-
-
-
- {events.map((event, index) => {
- const info = extractEventInfo(event);
- return (
-
- |
- {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