diff --git a/api/events.ts b/api/events.ts deleted file mode 100644 index 5f95fbe..0000000 --- a/api/events.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { Router } from 'express'; -import type { Request, Response } from 'express'; -import type { ApiResponse, EventsQueryParams } from '../lib/types'; -import { - getEvents, - getEventsSummary, - getTimeSeriesData, - getGeoAnalytics, - getDeviceAnalytics -} from '../lib/analytics'; - -const router = Router(); - -// 获取事件列表 -router.get('/', async (req: Request, res: Response) => { - try { - const params: EventsQueryParams = { - startTime: req.query.startTime as string, - endTime: req.query.endTime as string, - eventType: req.query.eventType as string, - linkId: req.query.linkId as string, - linkSlug: req.query.linkSlug as string, - userId: req.query.userId as string, - teamId: req.query.teamId as string, - projectId: req.query.projectId as string, - page: req.query.page ? parseInt(req.query.page as string, 10) : 1, - pageSize: req.query.pageSize ? parseInt(req.query.pageSize as string, 10) : 20, - sortBy: req.query.sortBy as string, - sortOrder: req.query.sortOrder as 'asc' | 'desc' - }; - - const { events, total } = await getEvents(params); - - const response: ApiResponse = { - success: true, - data: events, - meta: { - total, - page: params.page, - pageSize: params.pageSize - } - }; - - res.json(response); - } catch (error) { - const response: ApiResponse = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred' - }; - res.status(500).json(response); - } -}); - -// 获取事件概览 -router.get('/summary', async (req: Request, res: Response) => { - try { - const summary = await getEventsSummary({ - startTime: req.query.startTime as string, - endTime: req.query.endTime as string, - linkId: req.query.linkId as string - }); - - const response: ApiResponse = { - success: true, - data: summary - }; - - res.json(response); - } catch (error) { - const response: ApiResponse = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred' - }; - res.status(500).json(response); - } -}); - -// 获取时间序列数据 -router.get('/time-series', async (req: Request, res: Response) => { - try { - const data = await getTimeSeriesData({ - startTime: req.query.startTime as string, - endTime: req.query.endTime as string, - linkId: req.query.linkId as string, - granularity: (req.query.granularity || 'day') as 'hour' | 'day' | 'week' | 'month' - }); - - const response: ApiResponse = { - success: true, - data - }; - - res.json(response); - } catch (error) { - const response: ApiResponse = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred' - }; - res.status(500).json(response); - } -}); - -// 获取地理位置分析 -router.get('/geo', async (req: Request, res: Response) => { - try { - const data = await getGeoAnalytics({ - startTime: req.query.startTime as string, - endTime: req.query.endTime as string, - linkId: req.query.linkId as string, - groupBy: (req.query.groupBy || 'country') as 'country' | 'city' - }); - - const response: ApiResponse = { - success: true, - data - }; - - res.json(response); - } catch (error) { - const response: ApiResponse = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred' - }; - res.status(500).json(response); - } -}); - -// 获取设备分析 -router.get('/devices', async (req: Request, res: Response) => { - try { - const data = await getDeviceAnalytics({ - startTime: req.query.startTime as string, - endTime: req.query.endTime as string, - linkId: req.query.linkId as string - }); - - const response: ApiResponse = { - success: true, - data - }; - - res.json(response); - } catch (error) { - const response: ApiResponse = { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred' - }; - res.status(500).json(response); - } -}); - -export default router; \ No newline at end of file diff --git a/app/analytics/conversions/page.tsx b/app/analytics/conversions/page.tsx new file mode 100644 index 0000000..d20bef5 --- /dev/null +++ b/app/analytics/conversions/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { addDays, format } from 'date-fns'; +import { DateRangePicker } from '../../components/ui/DateRangePicker'; +import TimeSeriesChart from '../../components/charts/TimeSeriesChart'; +import { TimeSeriesData } from '../../api/types'; + +interface ConversionStats { + totalConversions: number; + conversionRate: number; + averageValue: number; + conversionsByType: { + type: string; + count: number; + value: number; + percentage: number; + }[]; +} + +export default function ConversionsPage() { + const [dateRange, setDateRange] = useState({ + from: new Date('2024-02-01'), + to: new Date('2025-03-05') + }); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [stats, setStats] = useState(null); + const [timeSeriesData, setTimeSeriesData] = useState([]); + + 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'"); + + const [statsRes, timeSeriesRes] = await Promise.all([ + fetch(`/api/events/conversions/stats?startTime=${startTime}&endTime=${endTime}`), + fetch(`/api/events/conversions/time-series?startTime=${startTime}&endTime=${endTime}`) + ]); + + const [statsData, timeSeriesData] = await Promise.all([ + statsRes.json(), + timeSeriesRes.json() + ]); + + if (!statsRes.ok) throw new Error(statsData.error || 'Failed to fetch conversion stats'); + if (!timeSeriesRes.ok) throw new Error(timeSeriesData.error || 'Failed to fetch time series data'); + + setStats(statsData); + setTimeSeriesData(timeSeriesData.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred while fetching data'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [dateRange]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+
+

Conversion Analytics

+ +
+ + {stats && ( +
+
+

Total Conversions

+

{stats.totalConversions.toLocaleString()}

+
+
+

Conversion Rate

+

{stats.conversionRate.toFixed(2)}%

+
+
+

Average Value

+

${stats.averageValue.toFixed(2)}

+
+
+ )} + +
+

Conversion Trends

+
+ +
+
+ + {stats && ( +
+

Conversion Types

+
+ {stats.conversionsByType.map((item, index) => ( +
+
+
+ {item.type} + + {item.count.toLocaleString()} conversions + +
+
+ + ${item.value.toFixed(2)} + + + ({item.percentage.toFixed(1)}%) + +
+
+
+
+
+
+ ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/analytics/devices/page.tsx b/app/analytics/devices/page.tsx new file mode 100644 index 0000000..b10d534 --- /dev/null +++ b/app/analytics/devices/page.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { DeviceAnalytics } from '../../api/types'; + +export default function DeviceAnalyticsPage() { + const [deviceData, setDeviceData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [dateRange, setDateRange] = useState({ + from: new Date('2024-02-01'), + to: new Date('2025-03-05') + }); + + useEffect(() => { + const fetchDeviceData = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await fetch(`/api/events/devices?startTime=${dateRange.from.toISOString().split('T')[0]}T00:00:00Z&endTime=${dateRange.to.toISOString().split('T')[0]}T23:59:59Z`); + if (!response.ok) throw new Error('Failed to fetch device data'); + + const data = await response.json(); + setDeviceData(data.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + fetchDeviceData(); + }, [dateRange]); + + return ( +
+ {/* 页面标题 */} +
+

Device Analytics

+

Analyze visitor distribution by devices, browsers, and operating systems

+
+ + {/* 时间范围选择器 */} +
+
+
+ + setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))} + /> +
+
+ + setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))} + /> +
+
+
+ + {/* 设备类型分析 */} +
+ {/* 设备类型 */} +
+

Device Types

+ {deviceData?.deviceTypes.map(item => ( +
+
+ {item.type} + {item.count} ({item.percentage.toFixed(1)}%) +
+
+
+
+
+ ))} +
+ + {/* 浏览器 */} +
+

Browsers

+ {deviceData?.browsers.map(item => ( +
+
+ {item.name} + {item.count} ({item.percentage.toFixed(1)}%) +
+
+
+
+
+ ))} +
+ + {/* 操作系统 */} +
+

Operating Systems

+ {deviceData?.operatingSystems.map(item => ( +
+
+ {item.name} + {item.count} ({item.percentage.toFixed(1)}%) +
+
+
+
+
+ ))} +
+
+ + {/* 加载状态 */} + {isLoading && ( +
+
+
+ )} + + {/* 错误状态 */} + {error && ( +
+

{error}

+
+ )} + + {/* 无数据状态 */} + {!isLoading && !error && !deviceData && ( +
+

No device data available

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/analytics/geo/page.tsx b/app/analytics/geo/page.tsx new file mode 100644 index 0000000..f29d60e --- /dev/null +++ b/app/analytics/geo/page.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { GeoData } from '../../api/types'; + +export default function GeoAnalyticsPage() { + const [geoData, setGeoData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [dateRange, setDateRange] = useState({ + from: new Date('2024-02-01'), + to: new Date('2025-03-05') + }); + + useEffect(() => { + const fetchGeoData = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await fetch(`/api/events/geo?startTime=${dateRange.from.toISOString().split('T')[0]}T00:00:00Z&endTime=${dateRange.to.toISOString().split('T')[0]}T23:59:59Z`); + if (!response.ok) throw new Error('Failed to fetch geographic data'); + + const data = await response.json(); + setGeoData(data.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setIsLoading(false); + } + }; + + fetchGeoData(); + }, [dateRange]); + + return ( +
+ {/* 页面标题 */} +
+

Geographic Analysis

+

Analyze visitor distribution by location

+
+ + {/* 时间范围选择器 */} +
+
+
+ + setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))} + /> +
+
+ + setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))} + /> +
+
+
+ + {/* 地理数据表格 */} +
+
+ + + + + + + + + + + {geoData.map(item => ( + + + + + + + ))} + +
LocationVisitsUnique VisitorsPercentage
+ {item.location} + + {item.visits} + + {item.visitors} + +
+
+
+
+ {item.percentage.toFixed(1)}% +
+
+
+ + {/* 加载状态 */} + {isLoading && ( +
+
+
+ )} + + {/* 错误状态 */} + {error && ( +
+

{error}

+
+ )} + + {/* 无数据状态 */} + {!isLoading && !error && geoData.length === 0 && ( +
+

No geographic data available

+
+ )} +
+ + {/* 提示信息 */} +
+

Note: Geographic data is based on IP addresses and may not be 100% accurate.

+
+
+ ); +} \ No newline at end of file diff --git a/app/api/types.ts b/app/api/types.ts new file mode 100644 index 0000000..96929c5 --- /dev/null +++ b/app/api/types.ts @@ -0,0 +1,85 @@ +// Event Types +export interface Event { + id: string; + time: string; + type: string; + linkInfo: { + id: string; + shortUrl: string; + originalUrl: string; + }; + visitor: { + id: string; + browser: string; + os: string; + device: string; + }; + location: { + country: string; + region: string; + city: string; + }; + referrer: string; + conversion?: { + type: string; + value: number; + }; +} + +// Analytics Types +export interface TimeSeriesData { + timestamp: string; + events: number; + visitors: number; + conversions: number; +} + +export interface GeoData { + country: string; + region: string; + city: string; + visits: number; + uniqueVisitors: number; + percentage: number; +} + +export interface DeviceAnalytics { + deviceTypes: { + type: string; + count: number; + percentage: number; + }[]; + browsers: { + name: string; + count: number; + percentage: number; + }[]; + operatingSystems: { + name: string; + count: number; + percentage: number; + }[]; +} + +export interface EventsSummary { + totalEvents: number; + uniqueVisitors: number; + totalConversions: number; + averageTimeSpent: number; + deviceTypes: { + mobile: number; + desktop: number; + tablet: number; + other: number; + }; + browsers: { + name: string; + count: number; + percentage: number; + }[]; + operatingSystems: { + name: string; + count: number; + percentage: number; + }[]; +} \ No newline at end of file diff --git a/app/components/analytics/DeviceAnalytics.tsx b/app/components/analytics/DeviceAnalytics.tsx new file mode 100644 index 0000000..a3d2e2c --- /dev/null +++ b/app/components/analytics/DeviceAnalytics.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { DeviceAnalytics as DeviceAnalyticsType } from '../../api/types'; + +interface DeviceAnalyticsProps { + data: DeviceAnalyticsType; +} + +function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) { + return ( +
+

{title}

+
+ {items.map((item, index) => ( +
+
+ {item.name} + {item.count.toLocaleString()} ({item.percentage.toFixed(1)}%) +
+
+
+
+
+ ))} +
+
+ ); +} + +export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) { + return ( +
+ ({ + name: item.type.charAt(0).toUpperCase() + item.type.slice(1), + count: item.count, + percentage: item.percentage + }))} + /> + + +
+ ); +} \ No newline at end of file diff --git a/app/components/analytics/GeoAnalytics.tsx b/app/components/analytics/GeoAnalytics.tsx new file mode 100644 index 0000000..d330276 --- /dev/null +++ b/app/components/analytics/GeoAnalytics.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { GeoData } from '../../api/types'; + +interface GeoAnalyticsProps { + data: GeoData[]; +} + +export default function GeoAnalytics({ data }: GeoAnalyticsProps) { + return ( +
+ + + + + + + + + + + {data.map((item, index) => ( + + + + + + + ))} + +
+ Location + + Visits + + Unique Visitors + + Percentage +
+ {item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country} + + {item.visits.toLocaleString()} + + {item.uniqueVisitors.toLocaleString()} + +
+ {item.percentage.toFixed(2)}% +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/charts/TimeSeriesChart.tsx b/app/components/charts/TimeSeriesChart.tsx new file mode 100644 index 0000000..f43c7fe --- /dev/null +++ b/app/components/charts/TimeSeriesChart.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useEffect, useRef } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler, + ChartData, + ChartOptions, + TooltipItem +} from 'chart.js'; +import { TimeSeriesData } from '../../api/types'; + +// 注册 Chart.js 组件 +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler +); + +interface TimeSeriesChartProps { + data: TimeSeriesData[]; +} + +export default function TimeSeriesChart({ data }: TimeSeriesChartProps) { + const chartRef = useRef(null); + const chartInstance = useRef(null); + + useEffect(() => { + if (!chartRef.current) return; + + // 销毁旧的图表实例 + if (chartInstance.current) { + chartInstance.current.destroy(); + } + + const ctx = chartRef.current.getContext('2d'); + if (!ctx) return; + + // 准备数据 + const labels = data.map(item => { + const date = new Date(item.timestamp); + return date.toLocaleDateString(); + }); + + const eventsData = data.map(item => Number(item.events)); + const visitorsData = data.map(item => Number(item.visitors)); + const conversionsData = data.map(item => Number(item.conversions)); + + // 创建新的图表实例 + chartInstance.current = new ChartJS(ctx, { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'Events', + data: eventsData, + borderColor: 'rgb(59, 130, 246)', // blue-500 + backgroundColor: 'rgba(59, 130, 246, 0.1)', + tension: 0.4, + fill: true + }, + { + label: 'Visitors', + data: visitorsData, + borderColor: 'rgb(16, 185, 129)', // green-500 + backgroundColor: 'rgba(16, 185, 129, 0.1)', + tension: 0.4, + fill: true + }, + { + label: 'Conversions', + data: conversionsData, + borderColor: 'rgb(239, 68, 68)', // red-500 + backgroundColor: 'rgba(239, 68, 68, 0.1)', + tension: 0.4, + fill: true + } + ] + } as ChartData<'line'>, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { + position: 'top', + labels: { + usePointStyle: true, + padding: 20, + color: 'rgb(156, 163, 175)' // gray-400 + } + }, + tooltip: { + mode: 'index', + intersect: false, + backgroundColor: 'rgb(31, 41, 55)', // gray-800 + titleColor: 'rgb(229, 231, 235)', // gray-200 + bodyColor: 'rgb(229, 231, 235)', // gray-200 + borderColor: 'rgb(75, 85, 99)', // gray-600 + borderWidth: 1, + padding: 12, + displayColors: true, + callbacks: { + title: (items: TooltipItem<'line'>[]) => { + if (items.length > 0) { + const date = new Date(data[items[0].dataIndex].timestamp); + return date.toLocaleDateString(); + } + return ''; + } + } + } + }, + scales: { + x: { + grid: { + display: false + }, + ticks: { + color: 'rgb(156, 163, 175)' // gray-400 + } + }, + y: { + beginAtZero: true, + grid: { + color: 'rgb(75, 85, 99, 0.1)' // gray-600 with opacity + }, + ticks: { + color: 'rgb(156, 163, 175)', // gray-400 + callback: (value: number) => { + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}k`; + } + return value; + } + } + } + } + } as ChartOptions<'line'> + }); + + // 清理函数 + return () => { + if (chartInstance.current) { + chartInstance.current.destroy(); + } + }; + }, [data]); + + return ( + + ); +} \ No newline at end of file diff --git a/app/components/ui/DateRangePicker.tsx b/app/components/ui/DateRangePicker.tsx new file mode 100644 index 0000000..4abc6cb --- /dev/null +++ b/app/components/ui/DateRangePicker.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { format } from 'date-fns'; + +interface DateRangePickerProps { + value: { + from: Date; + to: Date; + }; + onChange: (range: { from: Date; to: Date }) => void; +} + +export function DateRangePicker({ value, onChange }: DateRangePickerProps) { + const [from, setFrom] = useState(format(value.from, 'yyyy-MM-dd')); + const [to, setTo] = useState(format(value.to, 'yyyy-MM-dd')); + + useEffect(() => { + setFrom(format(value.from, 'yyyy-MM-dd')); + setTo(format(value.to, 'yyyy-MM-dd')); + }, [value]); + + const handleFromChange = (e: React.ChangeEvent) => { + const newFrom = e.target.value; + setFrom(newFrom); + onChange({ + from: new Date(newFrom), + to: value.to + }); + }; + + const handleToChange = (e: React.ChangeEvent) => { + const newTo = e.target.value; + setTo(newTo); + onChange({ + from: value.from, + to: new Date(newTo) + }); + }; + + return ( +
+
+ + +
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..4704fa5 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { addDays, format } from 'date-fns'; +import { DateRangePicker } from '../components/ui/DateRangePicker'; +import TimeSeriesChart from '../components/charts/TimeSeriesChart'; +import GeoAnalytics from '../components/analytics/GeoAnalytics'; +import DeviceAnalytics from '../components/analytics/DeviceAnalytics'; +import { EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics as DeviceAnalyticsType } from '../api/types'; + +export default function DashboardPage() { + const [dateRange, setDateRange] = useState({ + from: new Date('2024-02-01'), + to: new Date('2025-03-05') + }); + + 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); + + 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'"); + + // 并行获取所有数据 + const [summaryRes, timeSeriesRes, geoRes, deviceRes] = await Promise.all([ + fetch(`/api/events/summary?startTime=${startTime}&endTime=${endTime}`), + fetch(`/api/events/time-series?startTime=${startTime}&endTime=${endTime}`), + fetch(`/api/events/geo?startTime=${startTime}&endTime=${endTime}`), + fetch(`/api/events/devices?startTime=${startTime}&endTime=${endTime}`) + ]); + + const [summaryData, timeSeriesData, geoData, deviceData] = await Promise.all([ + summaryRes.json(), + timeSeriesRes.json(), + geoRes.json(), + deviceRes.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'); + + setSummary(summaryData); + setTimeSeriesData(timeSeriesData.data); + setGeoData(geoData.data); + setDeviceData(deviceData.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred while fetching data'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [dateRange]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+
+

Analytics Dashboard

+ +
+ + {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 +

+
+
+ )} + +
+

Event Trends

+
+ +
+
+ +
+

Device Analytics

+ {deviceData && } +
+ +
+

Geographic Distribution

+ +
+
+ ); +} \ No newline at end of file diff --git a/app/events/page.tsx b/app/events/page.tsx new file mode 100644 index 0000000..bbd164e --- /dev/null +++ b/app/events/page.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { addDays, format } from 'date-fns'; +import { DateRangePicker } from '../components/ui/DateRangePicker'; +import { Event } from '../api/types'; + +export default function EventsPage() { + const [dateRange, setDateRange] = useState({ + from: addDays(new Date(), -7), + to: new Date() + }); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [events, setEvents] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [filter, setFilter] = useState({ + eventType: '', + linkId: '', + linkSlug: '' + }); + + const [filters, setFilters] = useState({ + startTime: format(new Date('2024-02-01'), "yyyy-MM-dd'T'HH:mm:ss'Z'"), + endTime: format(new Date('2025-03-05'), "yyyy-MM-dd'T'HH:mm:ss'Z'"), + page: 1, + pageSize: 20 + }); + + const fetchEvents = async (pageNum: number) => { + 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'"); + + const params = new URLSearchParams({ + startTime, + endTime, + page: pageNum.toString(), + pageSize: '50' + }); + + if (filter.eventType) params.append('eventType', filter.eventType); + if (filter.linkId) params.append('linkId', filter.linkId); + if (filter.linkSlug) params.append('linkSlug', filter.linkSlug); + + const response = await fetch(`/api/events?${params.toString()}`); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to fetch events'); + } + + if (pageNum === 1) { + setEvents(data.events); + } else { + setEvents(prev => [...prev, ...data.events]); + } + + setHasMore(data.events.length === 50); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred while fetching events'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + setPage(1); + setEvents([]); + setLoading(true); + fetchEvents(1); + }, [dateRange, filter]); + + const loadMore = () => { + if (!loading && hasMore) { + const nextPage = page + 1; + setPage(nextPage); + fetchEvents(nextPage); + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString(); + }; + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+
+

Events

+ +
+ +
+
+
+ + +
+
+ + setFilter(prev => ({ ...prev, linkId: e.target.value }))} + placeholder="Enter Link ID" + className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100" + /> +
+
+ + setFilter(prev => ({ ...prev, linkSlug: e.target.value }))} + placeholder="Enter Link Slug" + className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100" + /> +
+
+
+ +
+
+ + + + + + + + + + + + + {events.map((event, index) => ( + + + + + + + + + ))} + +
+ Time + + Type + + Link + + Visitor + + Location + + Referrer +
+ {formatDate(event.time)} + + + {event.type} + + +
+
{event.linkInfo.shortUrl}
+
{event.linkInfo.originalUrl}
+
+
+
+
{event.visitor.browser}
+
{event.visitor.os} / {event.visitor.device}
+
+
+
+
{event.location.city}
+
{event.location.region}, {event.location.country}
+
+
+ {event.referrer || '-'} +
+
+ + {loading && ( +
+
+
+ )} + + {!loading && hasMore && ( +
+ +
+ )} + + {!loading && events.length === 0 && ( +
+ No events found +
+ )} +
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index ba6fea6..61f1c59 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,37 +1,73 @@ import './globals.css'; import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from "next/font/google"; -import Navbar from "./components/layout/Navbar"; +import { Inter } from 'next/font/google'; +import Link from 'next/link'; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: 'ShortURL Analytics', - description: 'Analytics dashboard for short URL management', + description: 'Analytics dashboard for ShortURL service', }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - - - -
- {children} -
+ + +
+ +
+ {children} +
+
); diff --git a/app/page.tsx b/app/page.tsx index 5e2dc21..fedad19 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,95 @@ -import { redirect } from 'next/navigation'; +import Link from 'next/link'; -export default function Home() { - redirect('/links'); +export default function HomePage() { + const sections = [ + { + title: 'Dashboard', + description: 'Get an overview of your link performance with key metrics and trends.', + href: '/dashboard', + icon: ( + + + + ), + }, + { + title: 'Events', + description: 'Track and analyze all events including clicks, conversions, and more.', + href: '/events', + icon: ( + + + + ), + }, + { + title: 'Geographic', + description: 'See where your visitors are coming from with detailed location data.', + href: '/analytics/geo', + icon: ( + + + + ), + }, + { + title: 'Devices', + description: 'Understand what devices, browsers, and operating systems your visitors use.', + href: '/analytics/devices', + icon: ( + + + + ), + }, + { + title: 'Conversions', + description: 'Track conversion rates and analyze the performance of your links.', + href: '/analytics/conversions', + icon: ( + + + + ), + }, + ]; + + return ( +
+
+
+

+ Welcome to ShortURL Analytics +

+

+ Get detailed insights into your link performance and visitor behavior +

+
+ +
+ {sections.map((section) => ( + +
+
+
+ {section.icon} +
+
+

+ {section.title} +

+
+

+ {section.description} +

+ + ))} +
+
+
+ ); } diff --git a/lib/analytics.ts b/lib/analytics.ts index b34974e..6232617 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -91,57 +91,71 @@ export async function getEventsSummary(params: { ORDER BY count DESC `; - const [baseResult, browserResults, osResults] = await Promise.all([ - executeQuerySingle<{ - totalEvents: number; - uniqueVisitors: number; - totalConversions: number; - averageTimeSpent: number; - mobileCount: number; - desktopCount: number; - tabletCount: number; - otherCount: number; - }>(baseQuery), - executeQuery<{ name: string; count: number }>(browserQuery), - executeQuery<{ name: string; count: number }>(osQuery) - ]); - - if (!baseResult) { - throw new Error('Failed to get events summary'); + try { + const [baseResult, browserResults, osResults] = await Promise.all([ + executeQuerySingle<{ + totalEvents: number; + uniqueVisitors: number; + totalConversions: number; + averageTimeSpent: number; + mobileCount: number; + desktopCount: number; + tabletCount: number; + otherCount: number; + }>(baseQuery), + executeQuery<{ name: string; count: number }>(browserQuery), + executeQuery<{ name: string; count: number }>(osQuery) + ]); + + if (!baseResult) { + throw new Error('Failed to get events summary'); + } + + // 安全转换数字类型 + const safeNumber = (value: any): number => { + if (value === null || value === undefined) return 0; + const num = Number(value); + return isNaN(num) ? 0 : num; + }; + + // 计算百分比 + const calculatePercentage = (count: number, total: number) => { + if (!total) return 0; // 防止除以零 + return Number(((count / total) * 100).toFixed(2)); + }; + + // 处理浏览器数据 + const browsers = browserResults.map(item => ({ + name: item.name || 'Unknown', + count: safeNumber(item.count), + percentage: calculatePercentage(safeNumber(item.count), safeNumber(baseResult.totalEvents)) + })); + + // 处理操作系统数据 + const operatingSystems = osResults.map(item => ({ + name: item.name || 'Unknown', + count: safeNumber(item.count), + percentage: calculatePercentage(safeNumber(item.count), safeNumber(baseResult.totalEvents)) + })); + + return { + totalEvents: safeNumber(baseResult.totalEvents), + uniqueVisitors: safeNumber(baseResult.uniqueVisitors), + totalConversions: safeNumber(baseResult.totalConversions), + averageTimeSpent: baseResult.averageTimeSpent ? Number(baseResult.averageTimeSpent.toFixed(2)) : 0, + deviceTypes: { + mobile: safeNumber(baseResult.mobileCount), + desktop: safeNumber(baseResult.desktopCount), + tablet: safeNumber(baseResult.tabletCount), + other: safeNumber(baseResult.otherCount) + }, + browsers, + operatingSystems + }; + } catch (error) { + console.error('Error in getEventsSummary:', error); + throw error; } - - // 计算百分比 - const calculatePercentage = (count: number, total: number) => - Number(((count / total) * 100).toFixed(2)); - - // 处理浏览器数据 - const browsers = browserResults.map(item => ({ - name: item.name, - count: item.count, - percentage: calculatePercentage(item.count, baseResult.totalEvents) - })); - - // 处理操作系统数据 - const operatingSystems = osResults.map(item => ({ - name: item.name, - count: item.count, - percentage: calculatePercentage(item.count, baseResult.totalEvents) - })); - - return { - totalEvents: baseResult.totalEvents, - uniqueVisitors: baseResult.uniqueVisitors, - totalConversions: baseResult.totalConversions, - averageTimeSpent: Number(baseResult.averageTimeSpent.toFixed(2)), - deviceTypes: { - mobile: baseResult.mobileCount, - desktop: baseResult.desktopCount, - tablet: baseResult.tabletCount, - other: baseResult.otherCount - }, - browsers, - operatingSystems - }; } // 获取时间序列数据 @@ -263,8 +277,10 @@ export async function getDeviceAnalytics(params: { } // 计算百分比 - const calculatePercentage = (count: number) => - Number(((count / totalResult.total) * 100).toFixed(2)); + const calculatePercentage = (count: number) => { + if (!totalResult || totalResult.total === 0) return 0; + return Number(((count / totalResult.total) * 100).toFixed(2)); + }; return { deviceTypes: deviceTypes.map(item => ({ diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 330f438..f990b57 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -3,10 +3,10 @@ import type { EventsQueryParams } from './types'; // ClickHouse 客户端配置 const clickhouse = createClient({ - url: process.env.CLICKHOUSE_URL || 'http://localhost:8123', - username: process.env.CLICKHOUSE_USER || 'admin', - password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password', - database: process.env.CLICKHOUSE_DB || 'shorturl_analytics' + url: process.env.CLICKHOUSE_URL, + username: process.env.CLICKHOUSE_USER , + password: process.env.CLICKHOUSE_PASSWORD , + database: process.env.CLICKHOUSE_DATABASE }); // 构建日期过滤条件 diff --git a/package-lock.json b/package-lock.json index 3d56314..0cd7232 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,14 @@ "version": "0.1.0", "dependencies": { "@clickhouse/client": "^1.11.0", + "@types/chart.js": "^2.9.41", + "@types/uuid": "^10.0.0", + "chart.js": "^4.4.8", + "date-fns": "^4.1.0", "next": "15.2.3", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "uuid": "^10.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -654,6 +659,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.7.tgz", @@ -1136,6 +1147,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chart.js": { + "version": "2.9.41", + "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz", + "integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.10.2" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1187,6 +1207,12 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.27.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz", @@ -2003,6 +2029,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz", + "integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -2144,6 +2182,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -4228,6 +4276,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5584,6 +5641,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index e3ebbaa..ae54f0a 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,10 @@ }, "dependencies": { "@clickhouse/client": "^1.11.0", + "@types/chart.js": "^2.9.41", "@types/uuid": "^10.0.0", + "chart.js": "^4.4.8", + "date-fns": "^4.1.0", "next": "15.2.3", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -42,4 +45,4 @@ "typescript": "^5" }, "packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0" -} \ No newline at end of file +}