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

517 lines
20 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 { 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';
// 事件类型定义
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;
}
// 格式化日期函数
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 || '-',
eventType: event.event_type || '-',
visitorId: event.visitor_id?.substring(0, 8) || '-',
referrer: eventAttrs?.referrer || '-',
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 HomePage() {
// 默认日期范围为最近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[]>([]);
// 添加标签选择状态 - 使用数组支持多选
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
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[]>([]);
useEffect(() => {
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
});
// 添加团队ID参数 - 支持多个团队
if (selectedTeamIds.length > 0) {
selectedTeamIds.forEach(teamId => {
params.append('teamId', teamId);
});
}
// 添加项目ID参数 - 支持多个项目
if (selectedProjectIds.length > 0) {
selectedProjectIds.forEach(projectId => {
params.append('projectId', projectId);
});
}
// 添加标签ID参数 - 支持多个标签
if (selectedTagIds.length > 0) {
selectedTagIds.forEach(tagId => {
params.append('tagId', tagId);
});
}
// 并行获取所有数据
const [summaryRes, timeSeriesRes, geoRes, deviceRes, eventsRes] = await Promise.all([
fetch(`${baseUrl}/summary?${params.toString()}`),
fetch(`${baseUrl}/time-series?${params.toString()}`),
fetch(`${baseUrl}/geo?${params.toString()}`),
fetch(`${baseUrl}/devices?${params.toString()}`),
fetch(`${baseUrl}?${params.toString()}`)
]);
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 || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagIds]);
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">
<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">
<TeamSelector
value={selectedTeamIds}
onChange={(value) => setSelectedTeamIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
/>
<ProjectSelector
value={selectedProjectIds}
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined}
/>
<TagSelector
value={selectedTagIds}
onChange={(value) => setSelectedTagIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined}
/>
<DateRangePicker
value={dateRange}
onChange={setDateRange}
/>
</div>
</div>
{/* 显示团队选择信息 */}
{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>
)}
{/* 显示标签选择信息 */}
{selectedTagIds.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">
{selectedTagIds.length === 1 ? 'Tag filter:' : 'Tags filter:'}
</span>
<div className="flex flex-wrap gap-2">
{selectedTagIds.map(tagName => (
<span key={tagName} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
{tagName}
<button
onClick={() => setSelectedTagIds(selectedTagIds.filter(name => name !== tagName))}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
))}
{selectedTagIds.length > 0 && (
<button
onClick={() => setSelectedTagIds([])}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Clear all
</button>
)}
</div>
</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">
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">
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?.substring(0, 8) || '-'}
</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">
<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">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>
)}
</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>
)}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Event Trends</h2>
<div className="h-96">
<TimeSeriesChart data={timeSeriesData} />
</div>
</div>
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Device Analytics</h2>
{deviceData && <DevicePieCharts data={deviceData} />}
</div>
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2>
<GeoAnalytics data={geoData} />
</div>
</>
</div>
);
}