Files
shorturl-analytics/app/analytics/page.tsx
2025-04-10 18:07:10 +08:00

980 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useState, useEffect, Suspense } from 'react';
import { format, subDays } from 'date-fns';
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
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';
import { TagSelector } from '@/app/components/ui/TagSelector';
import { useSearchParams } from 'next/navigation';
import { useShortUrlStore } from '@/app/utils/store';
// 事件类型定义
interface Event {
event_id?: string;
url_id: string;
url: string;
event_type: string;
visitor_id: string;
created_at: string;
event_time?: string;
referrer?: string;
browser?: string;
os?: string;
device_type?: string;
country?: string;
city?: string;
event_attributes?: string;
link_attributes?: string;
user_attributes?: string;
link_label?: string;
link_original_url?: string;
team_name?: string;
project_name?: string;
link_id?: string;
link_slug?: string;
link_tags?: string;
ip_address?: string;
}
// 格式化日期函数
const formatDate = (dateString: string | undefined) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss');
} catch {
return dateString;
}
};
// 解析JSON字符串
const parseJsonSafely = (jsonString: string) => {
if (!jsonString) return null;
try {
return JSON.parse(jsonString);
} catch {
return null;
}
};
// 获取用户可读名称
const getUserDisplayName = (user: Record<string, unknown> | null) => {
if (!user) return '-';
if (typeof user.full_name === 'string') return user.full_name;
if (typeof user.name === 'string') return user.name;
if (typeof user.email === 'string') return user.email;
return '-';
};
// 提取链接和事件的重要信息
const extractEventInfo = (event: Event) => {
// 解析事件属性
const eventAttrs = parseJsonSafely(event.event_attributes || '{}');
// 解析链接属性
const linkAttrs = parseJsonSafely(event.link_attributes || '{}');
// 解析用户属性
const userAttrs = parseJsonSafely(event.user_attributes || '{}');
// 解析标签信息
let tags: string[] = [];
try {
if (event.link_tags) {
const parsedTags = JSON.parse(event.link_tags);
if (Array.isArray(parsedTags)) {
tags = parsedTags;
}
}
} catch {
// 解析失败则保持空数组
}
return {
eventTime: event.created_at || event.event_time,
linkName: event.link_label || linkAttrs?.name || eventAttrs?.link_name || event.link_slug || '-',
originalUrl: event.link_original_url || eventAttrs?.origin_url || '-',
fullUrl: eventAttrs?.full_url || '-',
eventType: event.event_type || '-',
visitorId: event.visitor_id?.substring(0, 8) || '-',
referrer: eventAttrs?.referrer || '-',
ipAddress: event.ip_address || '-',
location: event.country ? (event.city ? `${event.city}, ${event.country}` : event.country) : '-',
device: event.device_type || '-',
browser: event.browser || '-',
os: event.os || '-',
userInfo: getUserDisplayName(userAttrs),
teamName: event.team_name || '-',
projectName: event.project_name || '-',
tags: tags
};
};
// Analytics Content Component that uses URL parameters
function AnalyticsContent() {
// 从 URL 获取查询参数
const searchParams = useSearchParams();
const shorturlParam = searchParams.get('shorturl');
// 使用 Zustand store
const { selectedShortUrl, setSelectedShortUrl, clearSelectedShortUrl } = useShortUrlStore();
// 存储 shorturl 参数
const [selectedShortUrlString, setSelectedShortUrlString] = useState<string | null>(null);
// Track hydration state of Zustand persistence
const [isHydrated, setIsHydrated] = useState(false);
// Flag to trigger data fetching
const [shouldFetchData, setShouldFetchData] = useState(false);
// Set hydrated state after initial render
useEffect(() => {
const hydrateTimeout = setTimeout(() => {
setIsHydrated(true);
}, 100); // Small timeout to ensure store is hydrated
return () => clearTimeout(hydrateTimeout);
}, []);
// 从 API 加载短链接数据
useEffect(() => {
if (!isHydrated) return; // Skip if not hydrated yet
// 处理 URL 参数
if (shorturlParam) {
// 保存参数到状态
setSelectedShortUrlString(shorturlParam);
// 如果 store 中没有选中的短链接或者 store 中的短链接与 URL 参数不匹配,则从 localStorage 更新
if (!selectedShortUrl || selectedShortUrl.shortUrl !== shorturlParam) {
// 首先检查 localStorage 是否已有此数据
const localStorageData = localStorage.getItem('shorturl-storage');
if (localStorageData) {
try {
const parsedData = JSON.parse(localStorageData);
if (parsedData.state?.selectedShortUrl && parsedData.state.selectedShortUrl.shortUrl === shorturlParam) {
// 数据已存在于 localStorage 且匹配 URL 参数,直接从 localStorage 中设置
setSelectedShortUrl(parsedData.state.selectedShortUrl);
// Trigger data fetching
setShouldFetchData(true);
return;
}
} catch (e) {
console.error('Error parsing localStorage data:', e);
}
}
// 如果 localStorage 中没有匹配的数据,则从 API 获取
const fetchShortUrlData = async () => {
try {
let apiUrl = '';
// Check if shorturlParam is a URL or an ID
if (shorturlParam.startsWith('http')) {
// Direct match by shortUrl is more reliable than URL parsing
const exactApiUrl = `/api/shortlinks/exact?shortUrl=${encodeURIComponent(shorturlParam)}`;
console.log('Fetching shorturl by exact match:', exactApiUrl);
// Try the exact endpoint first
const response = await fetch(exactApiUrl);
if (response.ok) {
const result = await response.json();
if (result.success && result.data) {
console.log('Found shortlink by exact shortUrl match:', result.data);
console.log('External ID from exact API:', result.data.externalId);
if (result.data.externalId) {
// Save to sessionStorage for immediate use
sessionStorage.setItem('current_shorturl_external_id', result.data.externalId);
console.log('Saved external ID to sessionStorage:', result.data.externalId);
}
// Set in store and trigger data fetch
setSelectedShortUrl(result.data);
setTimeout(() => {
setShouldFetchData(true);
}, 100);
return;
}
}
// Fallback to old method if exact match fails
console.log('Exact match failed, trying byUrl endpoint');
apiUrl = `/api/shortlinks/byUrl?url=${encodeURIComponent(shorturlParam)}`;
} else {
// It might be an ID or slug, try the ID endpoint directly
apiUrl = `/api/shortlinks/${shorturlParam}`;
}
console.log('Fetching shorturl data from:', apiUrl);
// 使用 API 端点获取短链接数据
const response = await fetch(apiUrl);
if (!response.ok) {
console.error('Failed to fetch shorturl data:', response.statusText);
// Still trigger data fetching to show all data instead
setShouldFetchData(true);
return;
}
const result = await response.json();
// 如果找到匹配的短链接数据
if (result.success && result.data) {
console.log('Retrieved shortlink data:', result.data);
// Log the external ID explicitly for debugging
console.log('External ID from API:', result.data.externalId);
// 设置到 Zustand store (会自动更新到 localStorage)
setSelectedShortUrl(result.data);
// 强制保证 externalId 被设置到 params
const savedExternalId = result.data.externalId;
if (savedExternalId) {
// Save to sessionStorage for immediate use
sessionStorage.setItem('current_shorturl_external_id', savedExternalId);
console.log('Saved external ID to sessionStorage:', savedExternalId);
}
// Explicitly wait for the state update to be applied
// before triggering the data fetching
setTimeout(() => {
setShouldFetchData(true);
}, 100);
} else {
setShouldFetchData(true);
}
} catch (error) {
console.error('Error fetching shorturl data:', error);
// Trigger data fetching even if there was an error
setShouldFetchData(true);
}
};
fetchShortUrlData();
} else {
// If selectedShortUrl already matches URL parameter, trigger data fetching
setShouldFetchData(true);
}
} else {
// 如果 URL 没有参数,清除文本状态
setSelectedShortUrlString(null);
// 如果 URL 没有参数但 store 中有数据,我们保持 store 中的数据不变
// 这样用户在清除 URL 参数后仍能看到之前选择的短链接数据
// Trigger data fetching since no shorturl parameter in URL
setShouldFetchData(true);
}
}, [shorturlParam, selectedShortUrl, setSelectedShortUrl, isHydrated]);
// 默认日期范围为最近7天
const today = new Date();
const [dateRange, setDateRange] = useState({
from: subDays(today, 7), // 7天前
to: today // 今天
});
// 添加团队选择状态 - 使用数组支持多选
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>([]);
// 添加项目选择状态 - 使用数组支持多选
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
// 添加标签名称状态 - 用于在UI中显示和API请求
const [selectedTagNames, setSelectedTagNames] = useState<string[]>([]);
// 添加子路径筛选状态
const [selectedSubpath, setSelectedSubpath] = useState<string>('');
// 添加分页状态
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [totalEvents, setTotalEvents] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<EventsSummary | null>(null);
const [timeSeriesData, setTimeSeriesData] = useState<TimeSeriesData[]>([]);
const [geoData, setGeoData] = useState<GeoData[]>([]);
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
const [events, setEvents] = useState<Event[]>([]);
// 添加 Snackbar 状态
const [isSnackbarOpen, setIsSnackbarOpen] = useState(false);
useEffect(() => {
if (!shouldFetchData) return; // Don't fetch data until explicitly triggered
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
const endTime = format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'");
// 构建基础URL和查询参数
const baseUrl = '/api/events';
const params = new URLSearchParams({
startTime,
endTime,
page: currentPage.toString(),
pageSize: pageSize.toString()
});
// Verify the shortUrl data is loaded and the externalId exists
// before adding the linkId parameter
if (selectedShortUrl) {
console.log('Current selectedShortUrl data:', selectedShortUrl);
if (selectedShortUrl.externalId) {
params.append('linkId', selectedShortUrl.externalId);
console.log('Adding linkId (externalId) to requests:', selectedShortUrl.externalId);
} else {
// Try to get externalId from sessionStorage as backup
const savedExternalId = sessionStorage.getItem('current_shorturl_external_id');
if (savedExternalId) {
params.append('linkId', savedExternalId);
console.log('Adding linkId from sessionStorage:', savedExternalId);
} else {
// External ID is missing - this will result in no data being returned
console.warn('WARNING: externalId is missing in the shortUrl data - no results will be returned!', selectedShortUrl);
}
}
// We now know the events table exclusively uses external_id format, so never fall back to id
// Add an extra log to debug the issue
console.log('Complete shorturl data:', JSON.stringify({
id: selectedShortUrl.id,
externalId: selectedShortUrl.externalId,
shortUrl: selectedShortUrl.shortUrl
}));
}
// 添加团队ID参数 - 支持多个团队
if (selectedTeamIds.length > 0) {
selectedTeamIds.forEach(teamId => {
params.append('teamId', teamId);
});
}
// 添加项目ID参数 - 支持多个项目
if (selectedProjectIds.length > 0) {
selectedProjectIds.forEach(projectId => {
params.append('projectId', projectId);
});
}
// 添加标签名称参数 - 支持多个标签
if (selectedTagNames.length > 0) {
selectedTagNames.forEach(tagName => {
params.append('tagName', tagName);
});
}
// 添加子路径筛选参数
if (selectedSubpath) {
params.append('subpath', selectedSubpath);
}
// 记录构建的 URL以确保参数正确包含
const summaryUrl = `${baseUrl}/summary?${params.toString()}`;
const timeSeriesUrl = `${baseUrl}/time-series?${params.toString()}`;
const geoUrl = `${baseUrl}/geo?${params.toString()}`;
const devicesUrl = `${baseUrl}/devices?${params.toString()}`;
const eventsUrl = `${baseUrl}?${params.toString()}`;
console.log('Final API URLs being called:');
console.log('- Summary API:', summaryUrl);
console.log('- TimeSeries API:', timeSeriesUrl);
console.log(`- Params contain linkId? ${params.has('linkId')}`);
console.log(`- All params: ${params.toString()}`);
// 并行获取所有数据
const [summaryRes, timeSeriesRes, geoRes, deviceRes, eventsRes] = await Promise.all([
fetch(summaryUrl),
fetch(timeSeriesUrl),
fetch(geoUrl),
fetch(devicesUrl),
fetch(eventsUrl)
]);
// 添加额外日志,记录完整的 URL 请求
console.log('Summary API URL:', summaryUrl);
if (selectedShortUrl?.externalId) {
console.log('Verifying linkId is in params:',
`linkId=${selectedShortUrl.externalId}`,
`included: ${params.toString().includes(`linkId=${selectedShortUrl.externalId}`)}`
);
}
const [summaryData, timeSeriesData, geoData, deviceData, eventsData] = await Promise.all([
summaryRes.json(),
timeSeriesRes.json(),
geoRes.json(),
deviceRes.json(),
eventsRes.json()
]);
if (!summaryRes.ok) throw new Error(summaryData.error || 'Failed to fetch summary data');
if (!timeSeriesRes.ok) throw new Error(timeSeriesData.error || 'Failed to fetch time series data');
if (!geoRes.ok) throw new Error(geoData.error || 'Failed to fetch geo data');
if (!deviceRes.ok) throw new Error(deviceData.error || 'Failed to fetch device data');
if (!eventsRes.ok) throw new Error(eventsData.error || 'Failed to fetch events data');
setSummary(summaryData.data);
setTimeSeriesData(timeSeriesData.data);
setGeoData(geoData.data);
setDeviceData(deviceData.data);
setEvents(eventsData.data || []);
// 设置总事件数量用于分页
if (eventsData.meta) {
// 确保将total转换为数字无论它是字符串还是数字
const totalCount = parseInt(String(eventsData.meta.total), 10);
if (!isNaN(totalCount)) {
setTotalEvents(totalCount);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagNames, selectedSubpath, currentPage, pageSize, selectedShortUrl, shouldFetchData]);
// Function to clear the shorturl filter
const handleClearShortUrlFilter = () => {
// 先清除 store 中的数据
clearSelectedShortUrl();
// 直接从 localStorage 中清除 Zustand 存储的数据
localStorage.removeItem('shorturl-storage');
// 清除 sessionStorage 中的数据
sessionStorage.removeItem('current_shorturl_external_id');
// 创建新 URL移除 shorturl 参数
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('shorturl');
// 直接强制跳转到新 URL这会完全重新加载页面
window.location.href = newUrl.toString();
};
// 清除子路径筛选
const handleClearSubpathFilter = () => {
setSelectedSubpath('');
};
// 处理子路径点击
const handlePathClick = (path: string) => {
console.log('====== ANALYTICS PAGE PATH DEBUG ======');
console.log('Original path:', path);
// 从路径中提取 subpath 部分,移除前导斜杠
// 示例:如果路径是 "/slug/subpath",我们需要提取 "subpath" 部分
// 或者直接使用原始路径,取决于您的路径结构
const pathParts = path.split('/').filter(Boolean);
// 如果路径包含多个部分获取第二部分subpath
const subpath = pathParts.length > 1 ? pathParts[1] : path;
console.log('Extracted subpath:', subpath);
console.log('=====================================');
setSelectedSubpath(subpath);
// 重置到第一页
setCurrentPage(1);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-red-500">{error}</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Notification Snackbar */}
{isSnackbarOpen && (
<div className="fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-md shadow-lg z-50 flex items-center">
<span>URL filter cleared</span>
<button
onClick={() => setIsSnackbarOpen(false)}
className="ml-3 text-white hover:text-gray-200 p-1"
aria-label="Close notification"
>
×
</button>
</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">
{/* 如果有选定的 shorturl可以显示一个提示显示更多详细信息 */}
{selectedShortUrl && (
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-md text-sm flex flex-col">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="font-medium">{selectedShortUrl.title || 'Untitled'}</span>
<span className="mx-2">-</span>
<span>{selectedShortUrl.shortUrl}</span>
</div>
<button
onClick={handleClearShortUrlFilter}
className="ml-3 text-blue-700 hover:text-blue-900 p-1 rounded-full hover:bg-blue-200"
aria-label="Clear shorturl filter"
>
×
</button>
</div>
<div className="text-xs mt-1 text-blue-700">
<span>Analytics filtered for this short URL only</span>
{selectedShortUrl.id && <span className="ml-2 text-blue-500">(ID: {selectedShortUrl.id.substring(0, 8)}...)</span>}
{selectedShortUrl.externalId && <span className="ml-2 text-blue-500">(External ID: {selectedShortUrl.externalId})</span>}
</div>
{selectedShortUrl.tags && selectedShortUrl.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{selectedShortUrl.tags.map((tag, index) => (
<span key={index} className="bg-blue-50 text-blue-700 text-xs px-1.5 py-0.5 rounded">
{tag}
</span>
))}
</div>
)}
</div>
)}
{/* 如果只有 URL 参数但没有完整数据,则显示简单提示 */}
{selectedShortUrlString && !selectedShortUrl && (
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-md text-sm flex items-center justify-between">
<div>
<span>Filtered by Short URL:</span>
<span className="ml-2 font-medium">{selectedShortUrlString}</span>
</div>
<button
onClick={handleClearShortUrlFilter}
className="ml-3 text-blue-700 hover:text-blue-900 p-1 rounded-full hover:bg-blue-200"
aria-label="Clear shorturl filter"
>
×
</button>
</div>
)}
{/* 如果有选定的 subpath显示提示 */}
{selectedSubpath && (
<div className="bg-blue-100 text-blue-800 px-3 py-2 rounded-md text-sm flex items-center justify-between">
<div>
<span>Filtered by Channel:</span>
<span className="ml-2 font-medium">{selectedSubpath}</span>
</div>
<button
onClick={handleClearSubpathFilter}
className="ml-3 text-blue-700 hover:text-blue-900 p-1 rounded-full hover:bg-blue-200"
aria-label="Clear subpath filter"
>
×
</button>
</div>
)}
{/* 只在没有选中 shorturl 时显示筛选选择器 */}
{!selectedShortUrl && (
<>
<TeamSelector
value={selectedTeamIds}
onChange={(value) => {
const newTeamIds = Array.isArray(value) ? value : [value];
// Check if team selection has changed
if (JSON.stringify(newTeamIds) !== JSON.stringify(selectedTeamIds)) {
// Clear project selection when team changes
setSelectedProjectIds([]);
// Update team selection
setSelectedTeamIds(newTeamIds);
}
}}
className="w-[250px]"
multiple={true}
/>
<ProjectSelector
value={selectedProjectIds}
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
/>
<TagSelector
value={selectedTagNames}
onChange={(value) => {
// TagSelector返回的是标签名称
if (Array.isArray(value)) {
setSelectedTagNames(value);
} else {
setSelectedTagNames(value ? [value] : []);
}
}}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
/>
</>
)}
<DateRangePicker
value={dateRange}
onChange={setDateRange}
/>
</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">
<h3 className="text-sm font-medium text-gray-500">Total Events</h3>
<p className="text-2xl font-semibold text-gray-900">
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500">Unique Visitors</h3>
<p className="text-2xl font-semibold text-gray-900">
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500">Total Conversions</h3>
<p className="text-2xl font-semibold text-gray-900">
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500">Avg. Time Spent</h3>
<p className="text-2xl font-semibold text-gray-900">
{summary.averageTimeSpent?.toFixed(1) || '0'}s
</p>
</div>
</div>
)}
{/* 时间序列图表 */}
{timeSeriesData && timeSeriesData.length > 0 && (
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Traffic Over Time</h2>
<div className="h-80">
<TimeSeriesChart data={timeSeriesData} />
</div>
</div>
)}
{/* 设备分析图表 */}
{deviceData && (
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Device Analytics</h2>
<DevicePieCharts data={deviceData} />
</div>
)}
{/* 地理分析 */}
{geoData && geoData.length > 0 && (
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-6">Geographic Distribution</h2>
<GeoAnalytics data={geoData} />
</div>
)}
{/* UTM 参数分析 */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-6">UTM Parameters Analysis</h2>
<UtmAnalytics
startTime={format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'")}
endTime={format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'")}
teamIds={selectedTeamIds}
projectIds={selectedProjectIds}
tagIds={selectedTagNames}
linkId={selectedShortUrl?.externalId}
subpath={selectedSubpath}
/>
</div>
{/* Path Analysis - 仅在选中特定链接时显示 */}
{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">Path Analysis</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}
onPathClick={handlePathClick}
/>
</div>
)}
{/* Recent Events Table */}
<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"
/>
<span className="text-sm text-gray-700 ml-2">
of {Math.max(1, Math.ceil(totalEvents / pageSize))}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
// Main page component with Suspense
export default function AnalyticsPage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
</div>
}>
<AnalyticsContent />
</Suspense>
);
}