"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 | 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(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([]); // 添加项目选择状态 - 使用数组支持多选 const [selectedProjectIds, setSelectedProjectIds] = useState([]); // 添加标签名称状态 - 用于在UI中显示和API请求 const [selectedTagNames, setSelectedTagNames] = useState([]); // 添加子路径筛选状态 const [selectedSubpath, setSelectedSubpath] = useState(''); // 添加分页状态 const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [totalEvents, setTotalEvents] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [summary, setSummary] = useState(null); const [timeSeriesData, setTimeSeriesData] = useState([]); const [geoData, setGeoData] = useState([]); const [deviceData, setDeviceData] = useState(null); const [events, setEvents] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); // New state to track auto-refresh const [lastRefreshed, setLastRefreshed] = useState(null); // Track when data was last refreshed // 添加 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); setIsRefreshing(false); // Reset refreshing state setLastRefreshed(new Date()); // Update last refreshed timestamp } }; fetchData(); }, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagNames, selectedSubpath, currentPage, pageSize, selectedShortUrl, shouldFetchData]); // Add auto-refresh functionality useEffect(() => { if (!shouldFetchData) return; // Don't set up refresh until initial data load is triggered // Function to trigger a refresh of data const refreshData = () => { console.log('Auto-refreshing analytics data...'); // Only refresh if not already loading or refreshing if (!loading && !isRefreshing) { setIsRefreshing(true); // Create a new fetch function instead of reusing the effect's fetchData const fetchRefreshedData = async () => { 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() }); // Duplicate the parameters logic from the main fetch effect if (selectedShortUrl && selectedShortUrl.externalId) { params.append('linkId', selectedShortUrl.externalId); } else { const savedExternalId = sessionStorage.getItem('current_shorturl_external_id'); if (savedExternalId) { params.append('linkId', savedExternalId); } } if (selectedTeamIds.length > 0) { selectedTeamIds.forEach(teamId => { params.append('teamId', teamId); }); } 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); } // Build all URLs with the same parameters 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()}`; // Parallel requests for all data const [summaryRes, timeSeriesRes, geoRes, deviceRes, eventsRes] = await Promise.all([ fetch(summaryUrl), fetch(timeSeriesUrl), fetch(geoUrl), fetch(devicesUrl), fetch(eventsUrl) ]); const [summaryData, timeSeriesData, geoData, deviceData, eventsData] = await Promise.all([ summaryRes.json(), timeSeriesRes.json(), geoRes.json(), deviceRes.json(), eventsRes.json() ]); // Update state with fresh data if (summaryRes.ok) setSummary(summaryData.data); if (timeSeriesRes.ok) setTimeSeriesData(timeSeriesData.data); if (geoRes.ok) setGeoData(geoData.data); if (deviceRes.ok) setDeviceData(deviceData.data); if (eventsRes.ok) { setEvents(eventsData.data || []); // Update pagination info if (eventsData.meta) { const totalCount = parseInt(String(eventsData.meta.total), 10); if (!isNaN(totalCount)) { setTotalEvents(totalCount); } } } } catch (err) { console.error('Auto-refresh error:', err); // Don't show errors during auto-refresh to avoid disrupting the UI } finally { setIsRefreshing(false); setLastRefreshed(new Date()); // Update last refreshed timestamp } }; fetchRefreshedData(); } }; // Set up the interval for auto-refresh every 30 seconds const intervalId = setInterval(refreshData, 30000); // Clean up the interval when the component unmounts return () => { clearInterval(intervalId); }; }, [shouldFetchData, loading, isRefreshing, dateRange, selectedTeamIds, selectedProjectIds, selectedTagNames, selectedSubpath, currentPage, pageSize, selectedShortUrl]); // 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 && !isRefreshing) { return (
); } if (error) { return (
{error}
); } return (
{/* Notification Snackbar */} {isSnackbarOpen && (
URL filter cleared
)}

Analytics Dashboard

{lastRefreshed && (
Last updated: {format(lastRefreshed, 'MMM d, yyyy HH:mm:ss')} {isRefreshing ? ' · Refreshing...' : ' · Auto-refreshes every 30 seconds'}
)}
{/* Show refresh indicator */} {isRefreshing && (
Refreshing data...
)} {/* 如果有选定的 shorturl,可以显示一个提示,显示更多详细信息 */} {selectedShortUrl && (
{selectedShortUrl.title || 'Untitled'} - {selectedShortUrl.shortUrl}
Analytics filtered for this short URL only {selectedShortUrl.id && (ID: {selectedShortUrl.id.substring(0, 8)}...)} {selectedShortUrl.externalId && (External ID: {selectedShortUrl.externalId})}
{selectedShortUrl.tags && selectedShortUrl.tags.length > 0 && (
{selectedShortUrl.tags.map((tag, index) => ( {tag} ))}
)}
)} {/* 如果只有 URL 参数但没有完整数据,则显示简单提示 */} {selectedShortUrlString && !selectedShortUrl && (
Filtered by Short URL: {selectedShortUrlString}
)} {/* 如果有选定的 subpath,显示提示 */} {selectedSubpath && (
Filtered by Channel: {selectedSubpath}
)} {/* 只在没有选中 shorturl 时显示筛选选择器 */} {!selectedShortUrl && ( <> { 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} /> setSelectedProjectIds(Array.isArray(value) ? value : [value])} className="w-[250px]" multiple={true} teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined} /> { // TagSelector返回的是标签名称 if (Array.isArray(value)) { setSelectedTagNames(value); } else { setSelectedTagNames(value ? [value] : []); } }} className="w-[250px]" multiple={true} teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined} /> )}
{/* 仪表板内容 */} {summary && (

Total Events

{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}

Unique Visitors

{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}

Total Conversions

{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}

Avg. Time Spent

{summary.averageTimeSpent?.toFixed(1) || '0'}s

)} {/* 时间序列图表 */} {timeSeriesData && timeSeriesData.length > 0 && (

Traffic Over Time

)} {/* 设备分析图表 */} {deviceData && (

Device Analytics

)} {/* 地理分析 */} {geoData && geoData.length > 0 && (

Geographic Distribution

)} {/* UTM 参数分析 */}

UTM Parameters Analysis

{/* Path Analysis - 仅在选中特定链接时显示 */} {selectedShortUrl && selectedShortUrl.externalId && (

Path Analysis

)} {/* Recent Events Table */}

Recent Events

{events.map((event, index) => { const info = extractEventInfo(event); return ( ); })}
Time Link Name Original URL Full URL Event Type Tags User Team/Project IP/Location Device Info
{formatDate(info.eventTime)} {info.linkName}
ID: {event.link_id || '-'}
{info.originalUrl} {info.fullUrl} {info.eventType}
{info.tags && info.tags.length > 0 ? ( info.tags.map((tag, idx) => ( {tag} )) ) : ( - )}
{info.userInfo}
{info.visitorId}...
{info.teamName}
{info.projectName}
IP: {info.ipAddress} Location: {info.location}
Device: {info.device} Browser: {info.browser} OS: {info.os}
{/* 表格为空状态 */} {!loading && events.length === 0 && (
No events found
)} {/* 分页控件 */} {!loading && events.length > 0 && (

Showing {events.length > 0 ? ((currentPage - 1) * pageSize) + 1 : 0} to {events.length > 0 ? ((currentPage - 1) * pageSize) + events.length : 0} of{' '} {totalEvents} results

{/* 添加直接跳转到指定页的输入框 */}
Go to: { 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" /> of {Math.max(1, Math.ceil(totalEvents / pageSize))}
)}
); } // Main page component with Suspense export default function AnalyticsPage() { return (
}>
); }