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

1193 lines
53 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 { useState, useEffect } 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 { 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';
import { useRouter } from 'next/navigation';
// 事件类型定义
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
};
};
export default function AnalyticsPage() {
// 从 URL 获取查询参数
const searchParams = useSearchParams();
const router = useRouter();
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 [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);
});
}
// 记录构建的 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, currentPage, pageSize, selectedShortUrl, shouldFetchData]);
// Function to clear the shorturl filter
const handleClearShortUrlFilter = () => {
// Clear the shorturl from Zustand store
clearSelectedShortUrl();
// Create a new URL object to manipulate the current URL
const currentUrl = new URL(window.location.href);
// Remove the shorturl parameter
currentUrl.searchParams.delete('shorturl');
// Get all other parameters and preserve them
const newUrl = `/analytics${currentUrl.search}`;
// Navigate to the updated URL
router.push(newUrl);
// Show a message to the user
setIsSnackbarOpen(true);
};
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>
)}
{/* 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">
{/* 如果有选定的 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>
)}
{/* 只在没有选中 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] : []);
}
// 我们需要将标签名称映射回ID但由于TagSelector内部已经做了处理
// 这里不需要额外的映射代码selectedTagNames存储名称即可
}}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
/>
</>
)}
<DateRangePicker
value={dateRange}
onChange={setDateRange}
/>
</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">
<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}
/>
</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"
/>
<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>
);
}