add path cont

This commit is contained in:
2025-04-10 11:44:03 +08:00
parent 8b407975e5
commit a8576121e9
4 changed files with 298 additions and 576 deletions

View File

@@ -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() {
</div>
)}
{/* Debug info - remove in production */}
{process.env.NODE_ENV !== 'production' && (
<div className="mb-4 p-3 bg-gray-100 rounded text-xs overflow-auto max-h-80">
<h3 className="font-bold mb-1">Debug Info:</h3>
<div>
<strong>Hydrated:</strong> {isHydrated ? 'Yes' : 'No'} |
<strong> Should Fetch:</strong> {shouldFetchData ? 'Yes' : 'No'} |
<strong> Has ShortUrl:</strong> {selectedShortUrl ? 'Yes' : 'No'}
</div>
{selectedShortUrl && (
<div className="mt-1">
<strong>ShortUrl ID:</strong> {selectedShortUrl.id} |
<strong> ExternalId:</strong> {selectedShortUrl.externalId || 'MISSING'} |
<strong> URL:</strong> {selectedShortUrl.shortUrl}
</div>
)}
<div className="mt-1 text-xs text-red-500">
<strong>IMPORTANT: </strong>
The events table uses <code>external_id</code> as <code>link_id</code>, not the UUID format.
External ID format sample: <code>cm8x34sdr0007m11yh1xe6qc2</code>
</div>
{/* Full link data for debugging */}
{selectedShortUrl && (
<div className="mt-3 border-t pt-2">
<details>
<summary className="cursor-pointer font-medium text-blue-600">Show Full Link Data</summary>
<div className="mt-2 p-2 bg-gray-800 text-green-400 rounded overflow-auto max-h-96 whitespace-pre">
{JSON.stringify(selectedShortUrl, null, 2)}
</div>
</details>
</div>
)}
{/* URL Parameters */}
<div className="mt-3 border-t pt-2">
<details>
<summary className="cursor-pointer font-medium text-blue-600">API Request URLs</summary>
<div className="mt-2">
<div><strong>Summary API URL:</strong> {`/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()}`}</div>
</div>
</details>
</div>
{/* Local Storage Data */}
<div className="mt-3 border-t pt-2">
<details>
<summary className="cursor-pointer font-medium text-blue-600">LocalStorage Data</summary>
<div className="mt-2 p-2 bg-gray-800 text-green-400 rounded overflow-auto max-h-96 whitespace-pre">
{typeof window !== 'undefined' && localStorage.getItem('shorturl-storage') ?
JSON.stringify(JSON.parse(localStorage.getItem('shorturl-storage') || '{}'), null, 2) :
'No localStorage data'}
</div>
</details>
</div>
</div>
)}
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">Analytics Dashboard</h1>
<div className="flex flex-col gap-4 md:flex-row md:items-center">
@@ -650,8 +589,6 @@ function AnalyticsContent() {
} else {
setSelectedTagNames(value ? [value] : []);
}
// 我们需要将标签名称映射回ID但由于TagSelector内部已经做了处理
// 这里不需要额外的映射代码selectedTagNames存储名称即可
}}
className="w-[250px]"
multiple={true}
@@ -667,106 +604,7 @@ function AnalyticsContent() {
</div>
</div>
{/* 仅在未选中 shorturl 且有选择的筛选条件时显示筛选条件标签 */}
{!selectedShortUrl && (
<>
{/* 显示团队选择信息 */}
{selectedTeamIds.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedTeamIds.length === 1 ? 'Team filter:' : 'Teams filter:'}
</span>
<div className="flex flex-wrap gap-2">
{selectedTeamIds.map(teamId => (
<span key={teamId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{teamId}
<button
onClick={() => setSelectedTeamIds(selectedTeamIds.filter(id => id !== teamId))}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
))}
{selectedTeamIds.length > 0 && (
<button
onClick={() => setSelectedTeamIds([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
)}
{/* 显示项目选择信息 */}
{selectedProjectIds.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedProjectIds.length === 1 ? 'Project filter:' : 'Projects filter:'}
</span>
<div className="flex flex-wrap gap-2">
{selectedProjectIds.map(projectId => (
<span key={projectId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{projectId}
<button
onClick={() => setSelectedProjectIds(selectedProjectIds.filter(id => id !== projectId))}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
))}
{selectedProjectIds.length > 0 && (
<button
onClick={() => setSelectedProjectIds([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
)}
{/* 显示标签选择信息 */}
{selectedTagNames.length > 0 && (
<div className="bg-blue-50 rounded-lg p-3 mb-6 flex items-center">
<span className="text-blue-700 font-medium mr-2">
{selectedTagNames.length === 1 ? 'Tag filter:' : 'Tags filter:'}
</span>
<div className="flex flex-wrap gap-2">
{selectedTagNames.map(tagName => (
<span key={tagName} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{tagName}
<button
onClick={() => {
// 移除对应的标签名称
setSelectedTagNames(selectedTagNames.filter(name => name !== tagName));
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
))}
{selectedTagNames.length > 0 && (
<button
onClick={() => setSelectedTagNames([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</div>
)}
</>
)}
{/* 仪表板内容 - 现在放在事件列表之后 */}
<>
{/* 仪表板内容 */}
{summary && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
@@ -835,358 +673,18 @@ function AnalyticsContent() {
/>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden mb-8">
<div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Events</h2>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Time
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Link Name
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Original URL
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Full URL
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Event Type
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tags
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Team/Project
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP/Location
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Device Info
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event, index) => {
const info = extractEventInfo(event);
return (
<tr key={event.event_id || index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(info.eventTime)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span className="font-medium">{info.linkName}</span>
<div className="text-xs text-gray-500 mt-1 truncate max-w-xs">
ID: {event.link_id || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-blue-600">
<a href={info.originalUrl} className="hover:underline truncate max-w-xs block" target="_blank" rel="noopener noreferrer">
{info.originalUrl}
</a>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-blue-600">
<a href={info.fullUrl} className="hover:underline truncate max-w-xs block" target="_blank" rel="noopener noreferrer">
{info.fullUrl}
</a>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
info.eventType === 'click'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}>
{info.eventType}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex flex-wrap gap-1">
{info.tags && info.tags.length > 0 ? (
info.tags.map((tag, idx) => (
<span
key={idx}
className="bg-gray-100 text-gray-800 text-xs px-2 py-0.5 rounded"
>
{tag}
</span>
))
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="font-medium">{info.userInfo}</div>
<div className="text-xs text-gray-400 mt-1">{info.visitorId}...</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="font-medium">{info.teamName}</div>
<div className="text-xs text-gray-400 mt-1">{info.projectName}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex flex-col">
<span className="text-xs inline-flex items-center mb-1">
<span className="font-medium">IP:</span>
<span className="ml-1">{info.ipAddress}</span>
</span>
<span className="text-xs inline-flex items-center">
<span className="font-medium">Location:</span>
<span className="ml-1">{info.location}</span>
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex flex-col">
<span className="text-xs inline-flex items-center mb-1">
<span className="font-medium">Device:</span>
<span className="ml-1">{info.device}</span>
</span>
<span className="text-xs inline-flex items-center mb-1">
<span className="font-medium">Browser:</span>
<span className="ml-1">{info.browser}</span>
</span>
<span className="text-xs inline-flex items-center">
<span className="font-medium">OS:</span>
<span className="ml-1">{info.os}</span>
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 表格为空状态 */}
{!loading && events.length === 0 && (
<div className="flex justify-center items-center p-8 text-gray-500">
No events found
</div>
)}
{/* 分页控件 */}
{!loading && events.length > 0 && (
<div className="px-6 py-4 flex items-center justify-between border-t border-gray-200">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md ${
currentPage === 1
? 'text-gray-300 bg-gray-50'
: 'text-gray-700 bg-white hover:bg-gray-50'
}`}
>
Previous
</button>
<button
onClick={() => setCurrentPage(prev => (currentPage < Math.ceil(totalEvents / pageSize)) ? prev + 1 : prev)}
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
className={`ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md ${
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-700 bg-white hover:bg-gray-50'
}`}
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{events.length > 0 ? ((currentPage - 1) * pageSize) + 1 : 0}</span> to <span className="font-medium">{events.length > 0 ? ((currentPage - 1) * pageSize) + events.length : 0}</span> of{' '}
<span className="font-medium">{totalEvents}</span> results
</p>
</div>
<div className="flex items-center">
<div className="mr-4">
<select
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setCurrentPage(1); // 重置到第一页
}}
>
<option value="5">5 / page</option>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
</select>
</div>
{/* 添加直接跳转到指定页的输入框 */}
<div className="mr-4 flex items-center">
<span className="text-sm text-gray-700 mr-2">Go to:</span>
<input
type="number"
min="1"
max={Math.max(1, Math.ceil(totalEvents / pageSize))}
value={currentPage}
onChange={(e) => {
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"
{/* 路径分析 - 仅在选中特定链接时显示 */}
{selectedShortUrl && selectedShortUrl.externalId && (
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-6"></h2>
<PathAnalytics
startTime={format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'")}
endTime={format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'")}
linkId={selectedShortUrl.externalId}
/>
<span className="text-sm text-gray-700 ml-2">
of {Math.max(1, Math.ceil(totalEvents / pageSize))}
</span>
</div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
{/* 首页按钮 */}
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${
currentPage === 1
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only">First</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 010 1.414zm-6 0a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 011.414 1.414L5.414 10l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
</button>
{/* 上一页按钮 */}
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className={`relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium ${
currentPage === 1
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only">Previous</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* 页码按钮 */}
{(() => {
const totalPages = Math.max(1, Math.ceil(totalEvents / pageSize));
const pageNumbers = [];
// 如果总页数小于等于7显示所有页码
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// 总是显示首页
pageNumbers.push(1);
// 根据当前页显示中间页码
if (currentPage <= 3) {
// 当前页靠近开始
pageNumbers.push(2, 3, 4);
pageNumbers.push('ellipsis1');
} else if (currentPage >= totalPages - 2) {
// 当前页靠近结束
pageNumbers.push('ellipsis1');
pageNumbers.push(totalPages - 3, totalPages - 2, totalPages - 1);
} else {
// 当前页在中间
pageNumbers.push('ellipsis1');
pageNumbers.push(currentPage - 1, currentPage, currentPage + 1);
pageNumbers.push('ellipsis2');
}
// 总是显示尾页
pageNumbers.push(totalPages);
}
return pageNumbers.map((pageNum, idx) => {
if (pageNum === 'ellipsis1' || pageNum === 'ellipsis2') {
return (
<div key={`ellipsis-${idx}`} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
...
</div>
);
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(Number(pageNum))}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
);
});
})()}
{/* 下一页按钮 */}
<button
onClick={() => setCurrentPage(prev => (currentPage < Math.ceil(totalEvents / pageSize)) ? prev + 1 : prev)}
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
className={`relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium ${
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only">Next</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* 尾页按钮 */}
<button
onClick={() => setCurrentPage(Math.max(1, Math.ceil(totalEvents / pageSize)))}
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only">Last</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 15.707a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L8.586 10 4.293 14.293a1 1 0 000 1.414zm6 0a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L14.586 10l-4.293 4.293a1 1 0 000 1.414z" clipRule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
)}
</div>
</>
</div>
);
}

View File

@@ -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<string, number>();
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<typeof pathData> = {
success: true,
data: pathData,
meta: { total: totalClicks }
};
return NextResponse.json(response);
} catch (error) {
console.error('获取路径分析数据错误:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : '服务器内部错误'
};
return NextResponse.json(response, { status: 500 });
}
}

View File

@@ -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

View File

@@ -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<PathAnalyticsProps> = ({ startTime, endTime, linkId }) => {
const [loading, setLoading] = useState(true);
const [pathData, setPathData] = useState<PathData[]>([]);
const [error, setError] = useState<string | null>(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 <div className="py-8 flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500" />
</div>;
}
if (error) {
return <div className="py-4 text-red-500">{error}</div>;
}
if (!linkId) {
return <div className="py-4 text-gray-500"></div>;
}
if (pathData.length === 0) {
return <div className="py-4 text-gray-500"></div>;
}
return (
<div>
<div className="text-sm text-gray-500 mb-4">
URL参数组合会被视为不同的路径 /abc?p=1 /abc?p=2
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th className="px-6 py-3 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th className="px-6 py-3 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pathData.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{item.path}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{item.count}</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end">
<span className="text-sm text-gray-500 mr-2">{(item.percentage * 100).toFixed(1)}%</span>
<div className="w-32 bg-gray-200 rounded-full h-2.5">
<div className="bg-blue-600 h-2.5 rounded-full" style={{ width: `${item.percentage * 100}%` }}></div>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export default PathAnalytics;