162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
|
||
interface PathAnalyticsProps {
|
||
startTime: string;
|
||
endTime: string;
|
||
linkId?: string;
|
||
onPathClick?: (path: string) => void;
|
||
}
|
||
|
||
interface PathData {
|
||
path: string;
|
||
count: number;
|
||
percentage: number;
|
||
}
|
||
|
||
const PathAnalytics: React.FC<PathAnalyticsProps> = ({ startTime, endTime, linkId, onPathClick }) => {
|
||
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('Failed to fetch path analytics data');
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data) {
|
||
// 自定义处理路径数据,根据是否有子路径来分组
|
||
const rawData = result.data;
|
||
const pathMap = new Map<string, number>();
|
||
let totalClicks = 0;
|
||
|
||
rawData.forEach((item: PathData) => {
|
||
const urlPath = item.path.split('?')[0];
|
||
totalClicks += item.count;
|
||
|
||
// 解析路径,检查是否有子路径
|
||
const pathParts = urlPath.split('/').filter(Boolean);
|
||
|
||
// 基础路径(例如/5seaii)或者带有查询参数但没有子路径的路径视为同一个路径
|
||
// 子路径(例如/5seaii/bbbbb)单独统计
|
||
const groupKey = pathParts.length > 1 ? urlPath : `/${pathParts[0]}`;
|
||
|
||
const currentCount = pathMap.get(groupKey) || 0;
|
||
pathMap.set(groupKey, currentCount + item.count);
|
||
});
|
||
|
||
// 转换回数组并排序
|
||
const groupedPathData = Array.from(pathMap.entries())
|
||
.map(([path, count]) => ({
|
||
path,
|
||
count,
|
||
percentage: totalClicks > 0 ? count / totalClicks : 0,
|
||
}))
|
||
.sort((a, b) => b.count - a.count);
|
||
|
||
setPathData(groupedPathData);
|
||
} else {
|
||
setError(result.error || 'Failed to load path analytics');
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchPathData();
|
||
}, [startTime, endTime, linkId]);
|
||
|
||
const handlePathClick = (path: string, e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
console.log('====== PATH CLICK DEBUG ======');
|
||
console.log('Path value:', path);
|
||
console.log('Path type:', typeof path);
|
||
console.log('Path length:', path.length);
|
||
console.log('Path chars:', Array.from(path).map(c => c.charCodeAt(0)));
|
||
console.log('==============================');
|
||
if (onPathClick) {
|
||
onPathClick(path);
|
||
}
|
||
};
|
||
|
||
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">Select a specific link to view path analytics.</div>;
|
||
}
|
||
|
||
if (pathData.length === 0) {
|
||
return <div className="py-4 text-gray-500">No path data available for this link.</div>;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="text-sm text-gray-500 mb-4">
|
||
Note: Paths are grouped by subpath. URLs with different query parameters but the same base path (without subpath) are grouped together.
|
||
</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">Path</th>
|
||
<th className="px-6 py-3 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Clicks</th>
|
||
<th className="px-6 py-3 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Percentage</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">
|
||
<a
|
||
href="#"
|
||
className="hover:text-blue-600 hover:underline cursor-pointer"
|
||
onClick={(e) => handlePathClick(item.path, e)}
|
||
>
|
||
{item.path}
|
||
</a>
|
||
</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;
|