Compare commits

4 Commits

Author SHA1 Message Date
1755b44a39 style 2025-03-25 22:24:52 +08:00
e0ac87fb25 events 2025-03-25 21:12:03 +08:00
ecf21a812f dashboard page good 2025-03-25 21:02:17 +08:00
efdfe8bf8e front 2025-03-25 20:54:02 +08:00
16 changed files with 1526 additions and 235 deletions

View File

@@ -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<typeof events> = {
success: true,
data: events,
meta: {
total,
page: params.page,
pageSize: params.pageSize
}
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
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<typeof summary> = {
success: true,
data: summary
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
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<typeof data> = {
success: true,
data
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
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<typeof data> = {
success: true,
data
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
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<typeof data> = {
success: true,
data
};
res.json(response);
} catch (error) {
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
res.status(500).json(response);
}
});
export default router;

View File

@@ -0,0 +1,150 @@
"use client";
import { useState, useEffect } from 'react';
import { DeviceAnalytics } from '../../api/types';
export default function DeviceAnalyticsPage() {
const [deviceData, setDeviceData] = useState<DeviceAnalytics | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="container mx-auto px-4 py-8">
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground">Device Analytics</h1>
<p className="mt-2 text-text-secondary">Analyze visitor distribution by devices, browsers, and operating systems</p>
</div>
{/* 时间范围选择器 */}
<div className="bg-card-bg rounded-xl p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Start Date</label>
<input
type="date"
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
value={dateRange.from.toISOString().split('T')[0]}
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">End Date</label>
<input
type="date"
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
value={dateRange.to.toISOString().split('T')[0]}
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
/>
</div>
</div>
</div>
{/* 设备类型分析 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
{/* 设备类型 */}
<div className="bg-card-bg rounded-xl p-6">
<h3 className="text-lg font-medium text-white mb-4">Device Types</h3>
{deviceData?.deviceTypes.map(item => (
<div key={item.type} className="mb-4 last:mb-0">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-white">{item.type}</span>
<span className="text-sm text-white">{item.count} ({item.percentage.toFixed(1)}%)</span>
</div>
<div className="w-full bg-background rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
{/* 浏览器 */}
<div className="bg-card-bg rounded-xl p-6">
<h3 className="text-lg font-medium text-white mb-4">Browsers</h3>
{deviceData?.browsers.map(item => (
<div key={item.name} className="mb-4 last:mb-0">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-white">{item.name}</span>
<span className="text-sm text-white">{item.count} ({item.percentage.toFixed(1)}%)</span>
</div>
<div className="w-full bg-background rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
{/* 操作系统 */}
<div className="bg-card-bg rounded-xl p-6">
<h3 className="text-lg font-medium text-white mb-4">Operating Systems</h3>
{deviceData?.operatingSystems.map(item => (
<div key={item.name} className="mb-4 last:mb-0">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-white">{item.name}</span>
<span className="text-sm text-white">{item.count} ({item.percentage.toFixed(1)}%)</span>
</div>
<div className="w-full bg-background rounded-full h-2">
<div
className="bg-red-500 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
</div>
{/* 加载状态 */}
{isLoading && (
<div className="flex justify-center items-center p-8">
<div className="w-8 h-8 border-t-2 border-b-2 border-accent-blue rounded-full animate-spin"></div>
</div>
)}
{/* 错误状态 */}
{error && (
<div className="flex justify-center items-center p-8 text-accent-red">
<p>{error}</p>
</div>
)}
{/* 无数据状态 */}
{!isLoading && !error && !deviceData && (
<div className="flex justify-center items-center p-8 text-text-secondary">
<p>No device data available</p>
</div>
)}
</div>
);
}

137
app/analytics/geo/page.tsx Normal file
View File

@@ -0,0 +1,137 @@
"use client";
import { useState, useEffect } from 'react';
import { GeoData } from '../../api/types';
export default function GeoAnalyticsPage() {
const [geoData, setGeoData] = useState<GeoData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="container mx-auto px-4 py-8">
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">Geographic Analysis</h1>
<p className="mt-2 text-white">Analyze visitor distribution by location</p>
</div>
{/* 时间范围选择器 */}
<div className="bg-card-bg rounded-xl p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white mb-1">Start Date</label>
<input
type="date"
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm "
value={dateRange.from.toISOString().split('T')[0]}
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-1">End Date</label>
<input
type="date"
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm "
value={dateRange.to.toISOString().split('T')[0]}
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
/>
</div>
</div>
</div>
{/* 地理数据表格 */}
<div className="bg-card-bg rounded-xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-card-border">
<thead>
<tr className="bg-background/50">
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Location</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Visits</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Unique Visitors</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Percentage</th>
</tr>
</thead>
<tbody className="divide-y divide-card-border">
{geoData.map(item => (
<tr key={item.location} className="hover:bg-background/50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.location}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.visits}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.visitors}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex items-center">
<div className="w-full bg-background rounded-full h-2 mr-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
/>
</div>
<span className="text-white">{item.percentage.toFixed(1)}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 加载状态 */}
{isLoading && (
<div className="flex justify-center items-center p-8">
<div className="w-8 h-8 border-t-2 border-b-2 border-accent-blue rounded-full animate-spin"></div>
</div>
)}
{/* 错误状态 */}
{error && (
<div className="flex justify-center items-center p-8 text-accent-red">
<p>{error}</p>
</div>
)}
{/* 无数据状态 */}
{!isLoading && !error && geoData.length === 0 && (
<div className="flex justify-center items-center p-8 text-white">
<p>No geographic data available</p>
</div>
)}
</div>
{/* 提示信息 */}
<div className="mt-4 text-sm text-white">
<p>Note: Geographic data is based on IP addresses and may not be 100% accurate.</p>
</div>
</div>
);
}

169
app/api/types.ts Normal file
View File

@@ -0,0 +1,169 @@
// Event Types
export interface Event {
// 核心事件信息
event_id: string;
event_time: string;
event_type: string;
event_attributes: string;
// 链接信息
link_id: string;
link_slug: string;
link_label: string;
link_title: string;
link_original_url: string;
link_attributes: string;
link_created_at: string;
link_expires_at: string | null;
link_tags: string;
// 用户信息
user_id: string;
user_name: string;
user_email: string;
user_attributes: string;
// 团队信息
team_id: string;
team_name: string;
team_attributes: string;
// 项目信息
project_id: string;
project_name: string;
project_attributes: string;
// 二维码信息
qr_code_id: string;
qr_code_name: string;
qr_code_attributes: string;
// 访问者信息
visitor_id: string;
session_id: string;
ip_address: string;
country: string;
city: string;
device_type: string;
browser: string;
os: string;
user_agent: string;
// 来源信息
referrer: string;
utm_source: string;
utm_medium: string;
utm_campaign: string;
// 交互信息
time_spent_sec: number;
is_bounce: boolean;
is_qr_scan: boolean;
conversion_type: string;
conversion_value: number;
// 旧接口兼容字段
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;
};
}
// Analytics Types
export interface TimeSeriesData {
timestamp: string;
events: number;
visitors: number;
conversions: number;
}
export interface GeoData {
location?: string;
country?: string;
region?: string;
city?: string;
visits: number;
uniqueVisitors?: number;
visitors?: number;
percentage: number;
}
export type DeviceType = 'mobile' | 'desktop' | 'tablet' | 'other';
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;
}[];
}
export interface ConversionStats {
totalConversions: number;
conversionRate: number;
averageValue: number;
byType: {
type: string;
count: number;
percentage: number;
value: number;
}[];
}
export interface EventFilters {
startTime?: string;
endTime?: string;
eventType?: string;
linkId?: string;
linkSlug?: string;
page?: number;
pageSize?: number;
}

View File

@@ -0,0 +1,66 @@
"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 }[] }) {
// 安全地格式化数字
const formatNumber = (value: number | string | undefined | null): string => {
if (value === undefined || value === null) return '0';
return typeof value === 'number' ? value.toLocaleString() : String(value);
};
// 安全地格式化百分比
const formatPercent = (value: number | undefined | null): string => {
if (value === undefined || value === null) return '0';
return value.toFixed(1);
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{title}</h3>
<div className="space-y-4">
{items.map((item, index) => (
<div key={index}>
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
<span>{item.name || 'Unknown'}</span>
<span>{formatNumber(item.count)} ({formatPercent(item.percentage)}%)</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: `${item.percentage || 0}%` }}
/>
</div>
</div>
))}
</div>
</div>
);
}
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Device Types"
items={(data.deviceTypes || []).map(item => ({
name: item.type ? (item.type.charAt(0).toUpperCase() + item.type.slice(1)) : 'Unknown',
count: item.count,
percentage: item.percentage
}))}
/>
<StatCard
title="Browsers"
items={data.browsers || []}
/>
<StatCard
title="Operating Systems"
items={data.operatingSystems || []}
/>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { GeoData } from '../../api/types';
interface GeoAnalyticsProps {
data: GeoData[];
}
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
// 安全地格式化数字
const formatNumber = (value: any): string => {
if (value === undefined || value === null) return '0';
return typeof value === 'number' ? value.toLocaleString() : String(value);
};
// 安全地格式化百分比
const formatPercent = (value: any): string => {
if (value === undefined || value === null) return '0';
return typeof value === 'number' ? value.toFixed(2) : String(value);
};
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Location
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Visits
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Unique Visitors
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Percentage
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{data.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country || item.location || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatNumber(item.visits)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatNumber(item.uniqueVisitors || item.visitors)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div className="flex items-center">
<span className="mr-2">{formatPercent(item.percentage)}%</span>
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: `${item.percentage || 0}%` }}
/>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { useEffect, useRef } from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend,
Filler,
ChartData,
ChartOptions,
TooltipItem
} from 'chart.js';
import { TimeSeriesData } from '../../api/types';
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend,
Filler
);
interface TimeSeriesChartProps {
data: TimeSeriesData[];
}
export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
const chartRef = useRef<HTMLCanvasElement | null>(null);
const chartInstance = useRef<ChartJS | null>(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 => {
if (!item || !item.timestamp) return '';
const date = new Date(item.timestamp);
return date.toLocaleDateString();
});
const eventsData = data.map(item => {
if (!item || item.events === undefined || item.events === null) return 0;
return Number(item.events);
});
const visitorsData = data.map(item => {
if (!item || item.visitors === undefined || item.visitors === null) return 0;
return Number(item.visitors);
});
const conversionsData = data.map(item => {
if (!item || item.conversions === undefined || item.conversions === null) return 0;
return 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 && value !== 0) return '';
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}k`;
}
return value;
}
}
}
}
} as ChartOptions<'line'>
});
// 清理函数
return () => {
if (chartInstance.current) {
chartInstance.current.destroy();
}
};
}, [data]);
return (
<canvas ref={chartRef} />
);
}

View File

@@ -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<HTMLInputElement>) => {
const newFrom = e.target.value;
setFrom(newFrom);
onChange({
from: new Date(newFrom),
to: value.to
});
};
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTo = e.target.value;
setTo(newTo);
onChange({
from: value.from,
to: new Date(newTo)
});
};
return (
<div className="flex items-center space-x-4">
<div>
<label htmlFor="from" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
From
</label>
<input
type="date"
id="from"
value={from}
onChange={handleFromChange}
max={to}
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 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="to" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
To
</label>
<input
type="date"
id="to"
value={to}
onChange={handleToChange}
min={from}
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 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
);
}

140
app/dashboard/page.tsx Normal file
View File

@@ -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<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);
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 (
<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 dark:text-gray-100">Analytics Dashboard</h1>
<DateRangePicker
value={dateRange}
onChange={setDateRange}
/>
</div>
{summary && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Events</h3>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Unique Visitors</h3>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Conversions</h3>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
</p>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Avg. Time Spent</h3>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{summary.averageTimeSpent?.toFixed(1) || '0'}s
</p>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 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 dark:text-gray-100 mb-4">Device Analytics</h2>
{deviceData && <DeviceAnalytics data={deviceData} />}
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Geographic Distribution</h2>
<GeoAnalytics data={geoData} />
</div>
</div>
);
}

257
app/events/page.tsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { DateRangePicker } from '../components/ui/DateRangePicker';
import { Event } from '../api/types';
export default function EventsPage() {
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<string | null>(null);
const [events, setEvents] = useState<Event[]>([]);
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 [summary, setSummary] = useState<any>(null);
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');
}
const eventsData = data.data || data.events || [];
if (pageNum === 1) {
setEvents(eventsData);
} else {
setEvents(prev => [...prev, ...eventsData]);
}
setHasMore(Array.isArray(eventsData) && eventsData.length === 50);
} catch (err) {
console.error("Error fetching events:", err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching events');
setEvents([]);
} 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 (
<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 dark:text-gray-100">Events</h1>
<DateRangePicker
value={dateRange}
onChange={setDateRange}
/>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
Event Type
</label>
<select
value={filter.eventType}
onChange={e => setFilter(prev => ({ ...prev, eventType: e.target.value }))}
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"
>
<option value="">All Types</option>
<option value="click">Click</option>
<option value="conversion">Conversion</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
Link ID
</label>
<input
type="text"
value={filter.linkId}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
Link Slug
</label>
<input
type="text"
value={filter.linkSlug}
onChange={e => 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"
/>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Time
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Link
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Visitor
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Location
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Referrer
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Conversion
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{Array.isArray(events) && events.map((event, index) => (
<tr key={event.event_id || index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{event.event_time && formatDate(event.event_time)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
event.event_type === 'conversion' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' : 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100'
}`}>
{event.event_type || 'unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>
<div className="font-medium">{event.link_slug || '-'}</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.link_original_url || '-'}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>
<div>{event.browser || '-'}</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.os || '-'} / {event.device_type || '-'}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>
<div>{event.city || '-'}</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.country || '-'}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{event.referrer || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>
<div>{event.conversion_type || '-'}</div>
{event.conversion_value > 0 && (
<div className="text-gray-500 dark:text-gray-400 text-xs">Value: {event.conversion_value}</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{loading && (
<div className="flex justify-center p-4">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500" />
</div>
)}
{!loading && hasMore && (
<div className="flex justify-center p-4">
<button
onClick={loadMore}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Load More
</button>
</div>
)}
{!loading && Array.isArray(events) && events.length === 0 && (
<div className="flex justify-center p-8 text-gray-500 dark:text-gray-400">
No events found
</div>
)}
</div>
</div>
);
}

View File

@@ -1,37 +1,67 @@
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 (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}
>
<Navbar />
<main className="min-h-screen px-4 py-6">
{children}
</main>
<html lang="en">
<body className={inter.className}>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center">
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-gray-100">
ShortURL Analytics
</Link>
<div className="hidden md:block ml-10">
<div className="flex items-baseline space-x-4">
<Link
href="/dashboard"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
>
Dashboard
</Link>
<Link
href="/events"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
>
Events
</Link>
<Link
href="/analytics/geo"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
>
Geographic
</Link>
<Link
href="/analytics/devices"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
>
Devices
</Link>
</div>
</div>
</div>
</div>
</div>
</nav>
<main className="py-10">
{children}
</main>
</div>
</body>
</html>
);

View File

@@ -1,5 +1,85 @@
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: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
title: 'Events',
description: 'Track and analyze all events including clicks, conversions, and more.',
href: '/events',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
title: 'Geographic',
description: 'See where your visitors are coming from with detailed location data.',
href: '/analytics/geo',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
title: 'Devices',
description: 'Understand what devices, browsers, and operating systems your visitors use.',
href: '/analytics/devices',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
},
];
return (
<div className="container mx-auto px-4">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Welcome to ShortURL Analytics
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400">
Get detailed insights into your link performance and visitor behavior
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{sections.map((section) => (
<Link
key={section.title}
href={section.href}
className="group block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200"
>
<div className="flex items-center mb-4">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg mr-4">
<div className="text-blue-600 dark:text-blue-300">
{section.icon}
</div>
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
{section.title}
</h2>
</div>
<p className="text-gray-600 dark:text-gray-400">
{section.description}
</p>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -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)
]);
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');
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 => ({

View File

@@ -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
});
// 构建日期过滤条件

72
package-lock.json generated
View File

@@ -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",

View File

@@ -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",