add path cont
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
80
app/api/events/path-analytics/route.ts
Normal file
80
app/api/events/path-analytics/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
111
app/components/analytics/PathAnalytics.tsx
Normal file
111
app/components/analytics/PathAnalytics.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user