19 Commits

Author SHA1 Message Date
0b41f3ea42 geo main 2025-04-02 22:23:49 +08:00
63f434fd93 geo tab 2025-04-02 21:36:13 +08:00
95f230b996 geo 2025-04-02 20:50:10 +08:00
0f8419778c device ana 2025-04-02 20:18:08 +08:00
a6f7172ec4 fix filter 2025-04-02 20:05:33 +08:00
8054b0235d events trend filters 2025-04-02 18:05:45 +08:00
b0dbd088e7 rm no used page 2025-04-02 17:56:15 +08:00
bf7c62fdc9 events pages 2025-04-02 10:04:36 +08:00
9cb9f62686 events filter 2025-04-02 08:55:46 +08:00
4b7fb7a887 rearrange pages 2025-04-01 23:44:01 +08:00
bdae5c164c move events pos 2025-04-01 23:04:15 +08:00
9fa61ccf8d summary filter 2025-04-01 23:00:35 +08:00
b187bdefdf sidebar collapse 2025-04-01 22:51:11 +08:00
87c3803236 add filter 2025-04-01 22:40:33 +08:00
75adb36111 rm evets id filter 2025-04-01 22:35:10 +08:00
a4ef2c3147 events team 2025-04-01 22:34:07 +08:00
57e16144a9 team filter 2025-04-01 22:26:46 +08:00
1be6a6dbf0 move page 2025-04-01 22:18:25 +08:00
36f22059e9 move device 2025-04-01 21:22:23 +08:00
34 changed files with 2645 additions and 3588 deletions

View File

@@ -1,80 +0,0 @@
'use client';
import Link from 'next/link';
import { ProtectedRoute, useAuth } from '@/lib/auth';
export default function AppLayoutClient({
children,
}: {
children: React.ReactNode;
}) {
const { signOut, user } = useAuth();
const handleSignOut = async () => {
await signOut();
};
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<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">
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 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
Dashboard
</Link>
<Link
href="/analytics"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
Analytics
</Link>
<Link
href="/events"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
Events
</Link>
<Link
href="/analytics/geo"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
Geographic
</Link>
<Link
href="/analytics/devices"
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
Devices
</Link>
</div>
</div>
</div>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-4">{user?.email}</span>
<button
onClick={handleSignOut}
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
>
Sign Out
</button>
</div>
</div>
</div>
</nav>
<main className="container mx-auto px-4 py-8">
{children}
</main>
</div>
</ProtectedRoute>
);
}

View File

@@ -1,308 +0,0 @@
"use client";
import { useState, useEffect, useRef } from 'react';
import { DeviceAnalytics } from '@/app/api/types';
import { Chart, PieController, ArcElement, Tooltip, Legend, CategoryScale, LinearScale } from 'chart.js';
// 注册Chart.js组件
Chart.register(PieController, ArcElement, Tooltip, Legend, CategoryScale, LinearScale);
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')
});
// 创建图表引用
const deviceTypesChartRef = useRef<HTMLCanvasElement>(null);
const browsersChartRef = useRef<HTMLCanvasElement>(null);
const osChartRef = useRef<HTMLCanvasElement>(null);
// 图表实例引用
const deviceTypesChartInstance = useRef<Chart | null>(null);
const browsersChartInstance = useRef<Chart | null>(null);
const osChartInstance = useRef<Chart | null>(null);
// 颜色配置
const COLORS = {
deviceTypes: ['rgba(59, 130, 246, 0.8)', 'rgba(96, 165, 250, 0.8)', 'rgba(147, 197, 253, 0.8)', 'rgba(191, 219, 254, 0.8)', 'rgba(219, 234, 254, 0.8)'],
browsers: ['rgba(16, 185, 129, 0.8)', 'rgba(52, 211, 153, 0.8)', 'rgba(110, 231, 183, 0.8)', 'rgba(167, 243, 208, 0.8)', 'rgba(209, 250, 229, 0.8)'],
os: ['rgba(239, 68, 68, 0.8)', 'rgba(248, 113, 113, 0.8)', 'rgba(252, 165, 165, 0.8)', 'rgba(254, 202, 202, 0.8)', 'rgba(254, 226, 226, 0.8)']
};
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]);
// 初始化和更新图表
useEffect(() => {
if (!deviceData || isLoading) return;
// 销毁旧的图表实例
if (deviceTypesChartInstance.current) {
deviceTypesChartInstance.current.destroy();
}
if (browsersChartInstance.current) {
browsersChartInstance.current.destroy();
}
if (osChartInstance.current) {
osChartInstance.current.destroy();
}
// 创建设备类型图表
if (deviceTypesChartRef.current && deviceData.deviceTypes.length > 0) {
const ctx = deviceTypesChartRef.current.getContext('2d');
if (ctx) {
deviceTypesChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: deviceData.deviceTypes.map(item => item.type),
datasets: [{
data: deviceData.deviceTypes.map(item => item.count),
backgroundColor: COLORS.deviceTypes,
borderColor: COLORS.deviceTypes.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 创建浏览器图表
if (browsersChartRef.current && deviceData.browsers.length > 0) {
const ctx = browsersChartRef.current.getContext('2d');
if (ctx) {
browsersChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: deviceData.browsers.map(item => item.name),
datasets: [{
data: deviceData.browsers.map(item => item.count),
backgroundColor: COLORS.browsers,
borderColor: COLORS.browsers.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 创建操作系统图表
if (osChartRef.current && deviceData.operatingSystems.length > 0) {
const ctx = osChartRef.current.getContext('2d');
if (ctx) {
osChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: deviceData.operatingSystems.map(item => item.name),
datasets: [{
data: deviceData.operatingSystems.map(item => item.count),
backgroundColor: COLORS.os,
borderColor: COLORS.os.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 清理函数
return () => {
if (deviceTypesChartInstance.current) {
deviceTypesChartInstance.current.destroy();
}
if (browsersChartInstance.current) {
browsersChartInstance.current.destroy();
}
if (osChartInstance.current) {
osChartInstance.current.destroy();
}
};
}, [deviceData, isLoading]);
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-foreground">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-foreground 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 text-foreground"
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-foreground 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 text-foreground"
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-foreground mb-4">Device Types</h3>
{deviceData && deviceData.deviceTypes.length > 0 ? (
<div className="h-64">
<canvas ref={deviceTypesChartRef} />
</div>
) : (
<div className="flex justify-center items-center h-64 text-foreground">
No data available
</div>
)}
</div>
{/* 浏览器 */}
<div className="bg-card-bg rounded-xl p-6">
<h3 className="text-lg font-medium text-foreground mb-4">Browsers</h3>
{deviceData && deviceData.browsers.length > 0 ? (
<div className="h-64">
<canvas ref={browsersChartRef} />
</div>
) : (
<div className="flex justify-center items-center h-64 text-foreground">
No data available
</div>
)}
</div>
{/* 操作系统 */}
<div className="bg-card-bg rounded-xl p-6">
<h3 className="text-lg font-medium text-foreground mb-4">Operating Systems</h3>
{deviceData && deviceData.operatingSystems.length > 0 ? (
<div className="h-64">
<canvas ref={osChartRef} />
</div>
) : (
<div className="flex justify-center items-center h-64 text-foreground">
No data available
</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-foreground">
<p>No device data available</p>
</div>
)}
</div>
);
}

View File

@@ -1,137 +0,0 @@
"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-foreground">Geographic Analysis</h1>
<p className="mt-2 text-foreground">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-foreground 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 text-foreground"
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-foreground 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 text-foreground"
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-foreground uppercase tracking-wider">Location</th>
<th className="px-6 py-3 text-left text-xs font-medium text-foreground uppercase tracking-wider">Visits</th>
<th className="px-6 py-3 text-left text-xs font-medium text-foreground uppercase tracking-wider">Unique Visitors</th>
<th className="px-6 py-3 text-left text-xs font-medium text-foreground 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-foreground">
{item.location}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
{item.visits}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
{item.visitors}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex items-center">
<span className="mr-2 text-foreground">{item.percentage.toFixed(1)}%</span>
<div className="w-24 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
/>
</div>
</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-foreground">
<p>No geographic data available</p>
</div>
)}
</div>
{/* 提示信息 */}
<div className="mt-4 text-sm text-foreground">
<p>Note: Geographic data is based on IP addresses and may not be 100% accurate.</p>
</div>
</div>
);
}

View File

@@ -1,126 +0,0 @@
"use client";
import { useState } from 'react';
import { subDays } from 'date-fns';
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
import { TeamSelector } from '@/app/components/ui/TeamSelector';
import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
import { TagSelector } from '@/app/components/ui/TagSelector';
export default function AnalyticsPage() {
// 默认日期范围为最近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 hasNoSelection = selectedTeamIds.length === 0 &&
selectedProjectIds.length === 0 &&
selectedTagIds.length === 0;
return (
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
<h1 className="text-xl font-bold text-gray-900">Analytics</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>
{/* 如果没有选择任何项,显示提示信息 */}
{hasNoSelection && (
<div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg">
<p className="text-gray-500">
Please select teams, projects, or tags to view analytics
</p>
</div>
)}
{/* 显示团队相关的分析数据 */}
{selectedTeamIds.length > 0 && (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Team Analytics ({selectedTeamIds.length} selected)
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{selectedTeamIds.map((teamId) => (
<div key={teamId} className="p-4 border rounded-md">
<h3 className="font-medium text-gray-800">Team ID: {teamId}</h3>
<p className="text-gray-500 mt-2">Team analytics will appear here</p>
</div>
))}
</div>
</div>
)}
{/* 显示项目相关的分析数据 */}
{selectedProjectIds.length > 0 && (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Project Analytics ({selectedProjectIds.length} selected)
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{selectedProjectIds.map((projectId) => (
<div key={projectId} className="p-4 border rounded-md">
<h3 className="font-medium text-gray-800">Project ID: {projectId}</h3>
<p className="text-gray-500 mt-2">Project analytics will appear here</p>
</div>
))}
</div>
</div>
)}
{/* 显示标签相关的分析数据 */}
{selectedTagIds.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Tag Analytics ({selectedTagIds.length} selected)
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{selectedTagIds.map((tagId) => (
<div key={tagId} className="p-4 border rounded-md">
<h3 className="font-medium text-gray-800">Tag ID: {tagId}</h3>
<p className="text-gray-500 mt-2">Tag analytics will appear here</p>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,140 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { format } 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 DeviceAnalytics from '@/app/components/analytics/DeviceAnalytics';
import { EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics as DeviceAnalyticsType } from '@/app/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.data);
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">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 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 && <DeviceAnalytics data={deviceData} />}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2>
<GeoAnalytics data={geoData} />
</div>
</div>
);
}

View File

@@ -1,250 +0,0 @@
"use client";
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
// 更复杂的事件类型定义
interface Event {
event_id?: string;
url_id: string;
url: string;
event_type: string;
visitor_id: string;
created_at: string;
referrer?: string;
browser?: string;
os?: string;
device_type?: string;
country?: string;
city?: string;
}
// 创建获取事件的函数
const fetchEvents = async (
startTime?: string,
endTime?: string,
urlId?: string,
eventType?: string
): Promise<Event[]> => {
try {
// 构建查询参数
const params = new URLSearchParams();
if (startTime) params.append('startTime', startTime);
if (endTime) params.append('endTime', endTime);
if (urlId) params.append('urlId', urlId);
if (eventType) params.append('eventType', eventType);
// 发送请求
const response = await fetch(`/api/events?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch events');
}
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('Error fetching events:', error);
return [];
}
};
const formatDate = (dateString: string) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss');
} catch {
return dateString;
}
};
export default function EventsPage() {
// 状态定义
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState({
startDate: format(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),
urlId: '',
eventType: ''
});
// 加载事件数据
useEffect(() => {
const loadEvents = async () => {
setLoading(true);
setError(null);
try {
const startTime = `${filters.startDate}T00:00:00Z`;
const endTime = `${filters.endDate}T23:59:59Z`;
const eventsData = await fetchEvents(
startTime,
endTime,
filters.urlId || undefined,
filters.eventType || undefined
);
setEvents(eventsData);
} catch (err) {
setError('Failed to load events');
console.error(err);
} finally {
setLoading(false);
}
};
loadEvents();
}, [filters]);
// 处理筛选条件变化
const handleFilterChange = (name: string, value: string) => {
setFilters(prev => ({
...prev,
[name]: value
}));
};
return (
<div className="container mx-auto px-4 py-8">
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Events</h1>
<p className="mt-2 text-gray-600">View and analyze all events for your URLs</p>
</div>
{/* 过滤器面板 */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">
Start Date
</label>
<input
type="date"
value={filters.startDate}
onChange={e => handleFilterChange('startDate', e.target.value)}
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">
End Date
</label>
<input
type="date"
value={filters.endDate}
onChange={e => handleFilterChange('endDate', e.target.value)}
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">
URL ID
</label>
<input
type="text"
value={filters.urlId}
onChange={e => handleFilterChange('urlId', e.target.value)}
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
placeholder="Filter by URL ID"
/>
</div>
</div>
</div>
{/* 事件表格 */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<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">
URL ID
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
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">
Visitor ID
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Referrer
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Location
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event, index) => (
<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(event.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{event.url_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<a href={event.url} className="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">
{event.url}
</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 ${
event.event_type === 'click'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}>
{event.event_type}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{event.visitor_id.substring(0, 8)}...
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{event.referrer || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{event.country && event.city ? `${event.city}, ${event.country}` : (event.country || '-')}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 加载状态 */}
{loading && (
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div>
)}
{/* 错误状态 */}
{error && (
<div className="flex justify-center items-center p-8 text-red-500">
{error}
</div>
)}
{/* 空状态 */}
{!loading && !error && events.length === 0 && (
<div className="flex justify-center items-center p-8 text-gray-500">
No events found
</div>
)}
</div>
</div>
);
}

View File

@@ -1,13 +1,10 @@
import '../globals.css';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import AppLayoutClient from './AppLayoutClient';
const inter = Inter({ subsets: ['latin'] });
import { Sidebar } from '@/app/components/Sidebar';
export const metadata: Metadata = {
title: 'ShortURL Analytics',
description: 'Analytics dashboard for ShortURL service',
description: 'Analytics for your shortened URLs',
};
export default function AppLayout({
@@ -16,10 +13,16 @@ export default function AppLayout({
children: React.ReactNode;
}) {
return (
<div className={inter.className}>
<AppLayoutClient>
{children}
</AppLayoutClient>
<div className="flex h-screen bg-gray-50">
{/* 侧边栏 */}
<Sidebar />
{/* 主内容区域 */}
<div className="flex-1 flex flex-col overflow-auto">
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,574 +0,0 @@
"use client";
import { useState, useEffect, useCallback, useRef } from 'react';
import CreateLinkModal from '@/app/components/ui/CreateLinkModal';
// 自定义类型定义,替换原来的导入
interface Link {
link_id: string;
title?: string;
original_url: string;
visits: number;
unique_visits: number;
created_by: string;
created_at: string;
is_active: boolean;
tags?: string[];
}
interface StatsOverview {
totalLinks: number;
activeLinks: number;
totalVisits: number;
conversionRate: number;
}
interface Tag {
tag: string;
id: string;
name: string;
count: number;
}
// Define type for link data
interface LinkData {
name: string;
originalUrl: string;
customSlug: string;
expiresAt: string;
tags: string[];
}
// 映射API数据到UI所需格式
interface UILink {
id: string;
name: string;
shortUrl: string;
originalUrl: string;
creator: string;
createdAt: string;
visits: number;
visitChange: number;
uniqueVisitors: number;
uniqueVisitorsChange: number;
avgTime: string;
avgTimeChange: number;
conversionRate: number;
conversionChange: number;
status: string;
tags: string[];
}
export default function LinksPage() {
const [links, setLinks] = useState<UILink[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [stats, setStats] = useState<StatsOverview>({
totalLinks: 0,
activeLinks: 0,
totalVisits: 0,
conversionRate: 0
});
const [searchQuery, setSearchQuery] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 无限加载相关状态
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const observer = useRef<IntersectionObserver | null>(null);
const lastLinkElementRef = useRef<HTMLTableRowElement | null>(null);
// 映射API数据到UI所需格式的函数
const mapApiLinkToUiLink = (apiLink: Link): UILink => {
// 生成短URL显示 - 因为数据库中没有short_url字段
const shortUrlDisplay = generateShortUrlDisplay(apiLink.link_id, apiLink.original_url);
return {
id: apiLink.link_id,
name: apiLink.title || 'Untitled Link',
shortUrl: shortUrlDisplay,
originalUrl: apiLink.original_url,
creator: apiLink.created_by,
createdAt: new Date(apiLink.created_at).toLocaleDateString(),
visits: apiLink.visits,
visitChange: 0, // API doesn't provide change data yet
uniqueVisitors: apiLink.unique_visits,
uniqueVisitorsChange: 0,
avgTime: '0m 0s', // API doesn't provide average time yet
avgTimeChange: 0,
conversionRate: 0, // API doesn't provide conversion rate yet
conversionChange: 0,
status: apiLink.is_active ? 'active' : 'inactive',
tags: apiLink.tags || []
};
};
// 从link_id和原始URL生成短URL显示
const generateShortUrlDisplay = (linkId: string, originalUrl: string): string => {
try {
// 尝试从原始URL提取域名
const urlObj = new URL(originalUrl);
const domain = urlObj.hostname.replace('www.', '');
// 使用link_id的前8个字符作为短代码
const shortCode = linkId.substring(0, 8);
return `${domain}/${shortCode}`;
} catch {
// 如果URL解析失败返回一个基于linkId的默认值
return `short.link/${linkId.substring(0, 8)}`;
}
};
// 获取链接数据
const fetchLinks = useCallback(async (pageNum: number, isInitialLoad: boolean = false) => {
try {
if (isInitialLoad) {
setIsLoading(true);
} else {
setIsLoadingMore(true);
}
setError(null);
// 获取链接列表
const linksResponse = await fetch(`/api/links?page=${pageNum}&limit=20${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}`);
if (!linksResponse.ok) {
throw new Error(`Failed to fetch links: ${linksResponse.statusText}`);
}
const linksData = await linksResponse.json();
const uiLinks = linksData.data.map(mapApiLinkToUiLink);
if (isInitialLoad) {
setLinks(uiLinks);
} else {
setLinks(prevLinks => [...prevLinks, ...uiLinks]);
}
// 检查是否还有更多数据可加载
const { pagination } = linksData;
setHasMore(pagination.page < pagination.totalPages);
if (isInitialLoad) {
// 只在初始加载时获取标签和统计数据
const tagsResponse = await fetch('/api/tags');
if (!tagsResponse.ok) {
throw new Error(`Failed to fetch tags: ${tagsResponse.statusText}`);
}
const tagsData = await tagsResponse.json();
const statsResponse = await fetch('/api/stats');
if (!statsResponse.ok) {
throw new Error(`Failed to fetch stats: ${statsResponse.statusText}`);
}
const statsData = await statsResponse.json();
setAllTags(tagsData);
setStats(statsData);
}
} catch (err) {
console.error('Data loading failed:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
if (isInitialLoad) {
setIsLoading(false);
} else {
setIsLoadingMore(false);
}
}
}, [searchQuery]);
// 初始加载
useEffect(() => {
setPage(1);
fetchLinks(1, true);
}, [fetchLinks]);
// 搜索过滤变化时重新加载数据
useEffect(() => {
// 当搜索关键词变化时,重置页码和链接列表,然后重新获取数据
setLinks([]);
setPage(1);
fetchLinks(1, true);
}, [searchQuery, fetchLinks]);
// 设置Intersection Observer来检测滚动并加载更多数据
useEffect(() => {
// 如果正在加载或没有更多数据则不设置observer
if (isLoading || isLoadingMore || !hasMore) return;
// 断开之前的observer连接
if (observer.current) {
observer.current.disconnect();
}
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
// 当最后一个元素可见且有更多数据时,加载下一页
setPage(prevPage => prevPage + 1);
}
}, {
root: null,
rootMargin: '0px',
threshold: 0.5
});
if (lastLinkElementRef.current) {
observer.current.observe(lastLinkElementRef.current);
}
return () => {
if (observer.current) {
observer.current.disconnect();
}
};
}, [isLoading, isLoadingMore, hasMore, links]);
// 当页码变化时加载更多数据
useEffect(() => {
if (page > 1) {
fetchLinks(page, false);
}
}, [page, fetchLinks]);
const filteredLinks = links.filter(link =>
link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
link.shortUrl.toLowerCase().includes(searchQuery.toLowerCase()) ||
link.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
const handleOpenLinkDetails = (id: string) => {
window.location.href = `/links/${id}`;
};
const handleCreateLink = async (linkData: LinkData) => {
try {
setIsLoading(true);
// 在实际应用中,这里会发送 POST 请求到 API
console.log('创建链接:', linkData);
// 刷新链接列表
setPage(1);
fetchLinks(1, true);
setShowCreateModal(false);
} catch (err) {
console.error('创建链接失败:', err);
setError(err instanceof Error ? err.message : '未知错误');
} finally {
setIsLoading(false);
}
};
// 加载状态
if (isLoading && links.length === 0) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="p-4 text-center">
<div className="w-12 h-12 mx-auto border-4 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
<p className="mt-4 text-lg text-foreground">Loading data...</p>
</div>
</div>
);
}
// 错误状态
if (error && links.length === 0) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="p-6 text-center rounded-lg bg-red-500/10">
<svg className="w-12 h-12 mx-auto text-accent-red" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-xl font-bold text-foreground">Loading Failed</h2>
<p className="mt-2 text-text-secondary">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 mt-4 text-white rounded-lg bg-accent-blue hover:bg-blue-600"
>
Reload
</button>
</div>
</div>
);
}
return (
<div className="container px-4 py-8 mx-auto">
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:justify-between md:items-center">
<div>
<h1 className="text-2xl font-bold text-foreground">Link Management</h1>
<p className="mt-1 text-sm text-text-secondary">
View and manage all your shortened links
</p>
</div>
<div className="flex gap-2">
<div className="relative flex-1 min-w-[200px]">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg className="w-4 h-4 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="search"
className="block w-full p-2.5 pl-10 text-sm border rounded-lg bg-card-bg border-card-border text-foreground placeholder-text-secondary focus:ring-accent-blue focus:border-accent-blue"
placeholder="Search links..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2.5 bg-accent-blue text-white rounded-lg text-sm font-medium hover:bg-blue-600 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
<span className="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
New Link
</span>
</button>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<div className="flex items-center">
<div className="p-3 mr-4 rounded-full text-accent-blue bg-blue-500/10">
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 10-5.656-5.656l-1.102 1.101" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-text-secondary">Total Links</p>
<p className="text-2xl font-semibold text-foreground">{stats.totalLinks}</p>
</div>
</div>
</div>
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<div className="flex items-center">
<div className="p-3 mr-4 rounded-full text-accent-green bg-green-500/10">
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-text-secondary">Active Links</p>
<p className="text-2xl font-semibold text-foreground">{stats.activeLinks}</p>
</div>
</div>
</div>
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<div className="flex items-center">
<div className="p-3 mr-4 rounded-full text-accent-purple bg-purple-500/10">
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-text-secondary">Total Visits</p>
<p className="text-2xl font-semibold text-foreground">{stats.totalVisits.toLocaleString()}</p>
</div>
</div>
</div>
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<div className="flex items-center">
<div className="p-3 mr-4 rounded-full bg-amber-500/10 text-accent-yellow">
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-text-secondary">Conversion Rate</p>
<p className="text-2xl font-semibold text-foreground">{(stats.conversionRate * 100).toFixed(1)}%</p>
</div>
</div>
</div>
</div>
{/* Links Table */}
<div className="overflow-hidden border rounded-lg shadow bg-card-bg border-card-border">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-card-border">
<thead className="bg-card-bg-secondary">
<tr>
<th scope="col" className="px-6 py-3">Link Info</th>
<th scope="col" className="px-6 py-3">Visits</th>
<th scope="col" className="px-6 py-3">Unique Visitors</th>
<th scope="col" className="px-6 py-3">Avg Time</th>
<th scope="col" className="px-6 py-3">Conversion</th>
<th scope="col" className="px-6 py-3">Status</th>
<th scope="col" className="px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-card-border">
{filteredLinks.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-text-secondary">
No links found. Create one to get started.
</td>
</tr>
) : (
filteredLinks.map((link, index) => (
<tr
key={link.id}
onClick={() => handleOpenLinkDetails(link.id)}
className="transition-colors cursor-pointer hover:bg-card-bg-secondary"
ref={index === filteredLinks.length - 1 ? lastLinkElementRef : null}
>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.name}</div>
<div className="text-xs text-accent-blue">{link.shortUrl}</div>
</td>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.visits.toLocaleString()}</div>
<div className={`text-xs flex items-center ${link.visitChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
<svg
className={`w-3 h-3 mr-1 ${link.visitChange >= 0 ? '' : 'transform rotate-180'}`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
</svg>
{Math.abs(link.visitChange)}%
</div>
</td>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.uniqueVisitors.toLocaleString()}</div>
<div className={`text-xs flex items-center ${link.uniqueVisitorsChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
<svg
className={`w-3 h-3 mr-1 ${link.uniqueVisitorsChange >= 0 ? '' : 'transform rotate-180'}`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
</svg>
{Math.abs(link.uniqueVisitorsChange)}%
</div>
</td>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.avgTime}</div>
<div className={`text-xs flex items-center ${link.avgTimeChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
<svg
className={`w-3 h-3 mr-1 ${link.avgTimeChange >= 0 ? '' : 'transform rotate-180'}`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
</svg>
{Math.abs(link.avgTimeChange)}%
</div>
</td>
<td className="px-6 py-4">
<div className="font-medium text-foreground">{link.conversionRate}%</div>
<div className={`text-xs flex items-center ${link.conversionChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
<svg
className={`w-3 h-3 mr-1 ${link.conversionChange >= 0 ? '' : 'transform rotate-180'}`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
</svg>
{Math.abs(link.conversionChange)}%
</div>
</td>
<td className="px-6 py-4">
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
link.status === 'active'
? 'bg-green-500/10 text-accent-green'
: link.status === 'inactive'
? 'bg-gray-500/10 text-text-secondary'
: 'bg-red-500/10 text-accent-red'
}`}
>
{link.status === 'active' ? 'Active' : link.status === 'inactive' ? 'Inactive' : 'Expired'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
handleOpenLinkDetails(link.id);
}}
className="text-sm font-medium text-accent-blue hover:underline"
>
Details
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Loading more indicator */}
{isLoadingMore && (
<div className="p-4 text-center">
<div className="inline-block w-6 h-6 border-2 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
<p className="mt-2 text-sm text-text-secondary">Loading more links...</p>
</div>
)}
{/* End of results message */}
{!hasMore && links.length > 0 && (
<div className="p-4 text-center text-sm text-text-secondary">
No more links to load.
</div>
)}
</div>
{/* Tags Section */}
{allTags.length > 0 && (
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
<h2 className="mb-4 text-lg font-medium text-foreground">Tags</h2>
<div className="flex flex-wrap gap-2">
{allTags.map(tagItem => (
<span
key={tagItem.tag}
className="inline-flex items-center px-3 py-1 text-sm font-medium rounded-full text-accent-blue bg-blue-500/10"
onClick={() => setSearchQuery(tagItem.tag)}
style={{ cursor: 'pointer' }}
>
{tagItem.tag}
<span className="ml-1.5 text-xs bg-blue-500/20 px-1.5 py-0.5 rounded-full">
{tagItem.count}
</span>
</span>
))}
</div>
</div>
)}
</div>
{/* Create Link Modal */}
{showCreateModal && (
<CreateLinkModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateLink}
/>
)}
</div>
);
}

View File

@@ -6,10 +6,19 @@ export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// 获取团队、项目和标签筛选参数
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
const data = await getDeviceAnalytics({
startTime: searchParams.get('startTime') || undefined,
endTime: searchParams.get('endTime') || undefined,
linkId: searchParams.get('linkId') || undefined
linkId: searchParams.get('linkId') || undefined,
// 添加团队、项目和标签筛选
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined
});
const response: ApiResponse<typeof data> = {

View File

@@ -6,11 +6,23 @@ export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// 获取团队、项目和标签筛选参数
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
// Get the groupBy parameter
const groupBy = searchParams.get('groupBy') as 'country' | 'city' | 'region' | 'continent' | null;
const data = await getGeoAnalytics({
startTime: searchParams.get('startTime') || undefined,
endTime: searchParams.get('endTime') || undefined,
linkId: searchParams.get('linkId') || undefined,
groupBy: (searchParams.get('groupBy') || 'country') as 'country' | 'city'
groupBy: groupBy || undefined,
// 添加团队、项目和标签筛选
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined
});
const response: ApiResponse<typeof data> = {

View File

@@ -1,50 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import type { ApiResponse, EventsQueryParams, EventType } from '@/lib/types';
import {
getEvents,
getEventsSummary,
getTimeSeriesData,
getGeoAnalytics,
getDeviceAnalytics
} from '@/lib/analytics';
import { getEvents, EventsQueryParams } from '@/lib/analytics';
import { ApiResponse } from '@/lib/types';
// 获取事件列表
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const { searchParams } = new URL(request.url);
// 获取查询参数
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '20');
const eventType = searchParams.get('eventType') || undefined;
const linkId = searchParams.get('linkId') || undefined;
const linkSlug = searchParams.get('linkSlug') || undefined;
const userId = searchParams.get('userId') || undefined;
// 获取可能存在的多个团队、项目和标签ID
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
const startTime = searchParams.get('startTime') || undefined;
const endTime = searchParams.get('endTime') || undefined;
const sortBy = searchParams.get('sortBy') || undefined;
const sortOrder = (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined;
console.log("API接收到的tagIds:", tagIds); // 添加日志便于调试
// 获取事件列表
const params: EventsQueryParams = {
startTime: searchParams.get('startTime') || undefined,
endTime: searchParams.get('endTime') || undefined,
eventType: searchParams.get('eventType') as EventType || undefined,
linkId: searchParams.get('linkId') || undefined,
linkSlug: searchParams.get('linkSlug') || undefined,
userId: searchParams.get('userId') || undefined,
teamId: searchParams.get('teamId') || undefined,
projectId: searchParams.get('projectId') || undefined,
page: searchParams.has('page') ? parseInt(searchParams.get('page')!, 10) : 1,
pageSize: searchParams.has('pageSize') ? parseInt(searchParams.get('pageSize')!, 10) : 20,
sortBy: searchParams.get('sortBy') || undefined,
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined
page,
pageSize,
eventType,
linkId,
linkSlug,
userId,
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined,
startTime,
endTime,
sortBy,
sortOrder
};
const { events, total } = await getEvents(params);
const response: ApiResponse<typeof events> = {
const result = await getEvents(params);
const response: ApiResponse<typeof result.events> = {
success: true,
data: events,
data: result.events,
meta: {
total,
page: params.page,
pageSize: params.pageSize
total: result.total,
page,
pageSize
}
};
return NextResponse.json(response);
} catch (error) {
console.error('获取事件列表失败:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
data: null,
error: error instanceof Error ? error.message : '获取事件列表失败'
};
return NextResponse.json(response, { status: 500 });
}

View File

@@ -6,10 +6,18 @@ export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// 获取可能存在的多个团队、项目和标签ID
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
const summary = await getEventsSummary({
startTime: searchParams.get('startTime') || undefined,
endTime: searchParams.get('endTime') || undefined,
linkId: searchParams.get('linkId') || undefined
linkId: searchParams.get('linkId') || undefined,
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined
});
const response: ApiResponse<typeof summary> = {

View File

@@ -15,11 +15,20 @@ export async function GET(request: NextRequest) {
}, { status: 400 });
}
// 获取团队、项目和标签筛选参数
const teamIds = searchParams.getAll('teamId');
const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId');
const data = await getTimeSeriesData({
startTime,
endTime,
linkId: searchParams.get('linkId') || undefined,
granularity: (searchParams.get('granularity') || 'day') as 'hour' | 'day' | 'week' | 'month'
granularity: (searchParams.get('granularity') || 'day') as 'hour' | 'day' | 'week' | 'month',
// 添加团队、项目和标签筛选
teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
tagIds: tagIds.length > 0 ? tagIds : undefined
});
const response: ApiResponse<typeof data> = {

248
app/api/geo/batch/route.ts Normal file
View File

@@ -0,0 +1,248 @@
import { NextRequest, NextResponse } from 'next/server';
import type { ApiResponse } from '@/lib/types';
interface IpLocationData {
ip: string;
country_name: string;
country_code: string;
city: string;
region: string;
continent_code: string;
continent_name: string;
latitude: number;
longitude: number;
timestamp: number;
}
// Simple in-memory cache on the server side to reduce API calls
const serverCache: Record<string, IpLocationData> = {};
// Cache for IPs that have repeatedly failed to resolve
const failedIPsCache: Record<string, { attempts: number, lastAttempt: number }> = {};
// Cache expiration time (30 days in milliseconds)
const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000; // 30 days
// Max attempts to fetch an IP before considering it permanently failed
const MAX_RETRY_ATTEMPTS = 3;
// Retry timeout - how long to wait before trying a failed IP again (24 hours)
const RETRY_TIMEOUT = 24 * 60 * 60 * 1000;
/**
* Check if an IP has failed too many times and should be skipped
*/
function shouldSkipIP(ip: string): boolean {
if (!failedIPsCache[ip]) return false;
const now = Date.now();
// Skip if max attempts reached
if (failedIPsCache[ip].attempts >= MAX_RETRY_ATTEMPTS) {
return true;
}
// Skip if last attempt was recent
if (now - failedIPsCache[ip].lastAttempt < RETRY_TIMEOUT) {
return true;
}
return false;
}
/**
* Mark an IP as failed
*/
function markIPAsFailed(ip: string): void {
const now = Date.now();
if (failedIPsCache[ip]) {
failedIPsCache[ip] = {
attempts: failedIPsCache[ip].attempts + 1,
lastAttempt: now
};
} else {
failedIPsCache[ip] = {
attempts: 1,
lastAttempt: now
};
}
}
/**
* Get location data for a single IP using ipapi.co
*/
async function fetchIpLocation(ip: string): Promise<IpLocationData | null> {
try {
// Skip this IP if it has failed too many times
if (shouldSkipIP(ip)) {
console.log(`[Server] Skipping blacklisted IP: ${ip}`);
return null;
}
// Check server cache first
const now = Date.now();
if (serverCache[ip] && (now - serverCache[ip].timestamp) < CACHE_EXPIRATION) {
return serverCache[ip];
}
// Add delay to avoid rate limiting (100 requests per minute max)
await new Promise(resolve => setTimeout(resolve, 600)); // ~100 req/min = 1 req per 600ms
const response = await fetch(`https://ipapi.co/${ip}/json/`);
if (!response.ok) {
console.error(`Error fetching location for IP ${ip}: ${response.statusText}`);
markIPAsFailed(ip);
return null;
}
const data = await response.json();
if (data.error) {
console.error(`Error fetching location for IP ${ip}: ${data.reason}`);
markIPAsFailed(ip);
return null;
}
// Reset failed status if successful
if (failedIPsCache[ip]) {
delete failedIPsCache[ip];
}
const locationData: IpLocationData = {
ip: data.ip,
country_name: data.country_name || 'Unknown',
country_code: data.country_code || 'UN',
city: data.city || 'Unknown',
region: data.region || 'Unknown',
continent_code: data.continent_code || 'UN',
continent_name: getContinentName(data.continent_code) || 'Unknown',
latitude: data.latitude || 0,
longitude: data.longitude || 0,
timestamp: Date.now()
};
// Cache the result
serverCache[ip] = locationData;
return locationData;
} catch (error) {
console.error(`Error fetching location for IP ${ip}:`, error);
markIPAsFailed(ip);
return null;
}
}
/**
* Get continent name from continent code
*/
function getContinentName(code?: string): string {
if (!code) return 'Unknown';
const continents: Record<string, string> = {
'AF': 'Africa',
'AN': 'Antarctica',
'AS': 'Asia',
'EU': 'Europe',
'NA': 'North America',
'OC': 'Oceania',
'SA': 'South America'
};
return continents[code] || 'Unknown';
}
/**
* API route handler for batch IP location lookups
*/
export async function POST(request: NextRequest) {
try {
const { ips } = await request.json();
if (!ips || !Array.isArray(ips) || ips.length === 0) {
return NextResponse.json({
success: false,
error: 'Invalid or empty IP list'
}, { status: 400 });
}
// Limit batch size to 50 IPs to prevent abuse
const ipList = ips.slice(0, 50);
const results: Record<string, IpLocationData | null> = {};
// Filter out IPs that should be skipped
const validIPs = ipList.filter(ip => {
if (typeof ip !== 'string' || !ip.trim()) return false;
if (isPrivateIP(ip)) {
results[ip] = getPrivateIPData(ip);
return false;
}
if (shouldSkipIP(ip)) {
console.log(`[Server] Skipping blacklisted IP: ${ip}`);
results[ip] = null;
return false;
}
return true;
});
// Process remaining IPs sequentially to respect rate limits
for (const ip of validIPs) {
results[ip] = await fetchIpLocation(ip);
}
const response: ApiResponse<Record<string, IpLocationData | null>> = {
success: true,
data: results
};
return NextResponse.json(response);
} catch (error) {
console.error('Batch IP lookup error:', error);
const response: ApiResponse<null> = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
return NextResponse.json(response, { status: 500 });
}
}
/**
* Check if IP is a private/local address
*/
function isPrivateIP(ip: string): boolean {
return (
ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('127.') ||
ip === 'localhost' ||
ip === '::1'
);
}
/**
* Generate location data for private IP addresses
*/
function getPrivateIPData(ip: string): IpLocationData {
return {
ip,
country_name: 'Local Network',
country_code: 'LO',
city: 'Local',
region: 'Local',
continent_code: 'LO',
continent_name: 'Local',
latitude: 0,
longitude: 0,
timestamp: Date.now()
};
}

View File

@@ -94,6 +94,7 @@ export interface TimeSeriesData {
export interface GeoData {
location: string;
area: string;
visits: number;
visitors: number;
percentage: number;

111
app/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
BarChartIcon,
HomeIcon,
PersonIcon,
ChevronLeftIcon,
ChevronRightIcon
} from '@radix-ui/react-icons';
interface NavItemProps {
href: string;
label: string;
icon: React.ReactNode;
isCollapsed: boolean;
isActive?: boolean;
}
const NavItem = ({ href, label, icon, isCollapsed, isActive }: NavItemProps) => {
return (
<Link
href={href}
className={`flex items-center p-2 rounded-lg ${
isActive
? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
} transition-all duration-200 group`}
>
<div className="w-6 h-6 flex items-center justify-center">{icon}</div>
{!isCollapsed && (
<span className={`ml-3 whitespace-nowrap transition-opacity duration-200 ${
isCollapsed ? 'opacity-0 w-0' : 'opacity-100'
}`}>
{label}
</span>
)}
{isCollapsed && (
<span className="sr-only">{label}</span>
)}
</Link>
);
};
export function Sidebar() {
const [isCollapsed, setIsCollapsed] = useState(false);
const pathname = usePathname();
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
};
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: <HomeIcon className="w-5 h-5" /> },
{ name: 'Analytics', href: '/analytics', icon: <BarChartIcon className="w-5 h-5" /> },
{ name: 'Account', href: '/account', icon: <PersonIcon className="w-5 h-5" /> },
];
return (
<div className={`flex flex-col h-full transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-64'
} bg-white border-r border-gray-200 relative`}>
{/* 顶部Logo和标题 */}
<div className="flex items-center p-4 border-b border-gray-200">
<div className="flex-shrink-0 flex items-center justify-center w-8 h-8 bg-blue-500 text-white rounded">
<span className="font-bold">S</span>
</div>
{!isCollapsed && (
<span className="ml-3 font-medium text-gray-900 transition-opacity duration-200">
ShortURL Analytics
</span>
)}
</div>
{/* 导航菜单 */}
<div className="flex-grow p-4 overflow-y-auto">
<ul className="space-y-2">
{navigation.map((item) => (
<li key={item.name}>
<NavItem
href={item.href}
label={item.name}
icon={item.icon}
isCollapsed={isCollapsed}
isActive={pathname?.startsWith(item.href)}
/>
</li>
))}
</ul>
</div>
{/* 底部折叠按钮 */}
<div className="border-t border-gray-200 p-4">
<button
onClick={toggleSidebar}
className="w-full flex items-center justify-center p-2 rounded-lg text-gray-500 hover:bg-gray-100"
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{isCollapsed ? (
<ChevronRightIcon className="w-5 h-5" />
) : (
<ChevronLeftIcon className="w-5 h-5" />
)}
{!isCollapsed && <span className="ml-2">Collapse</span>}
</button>
</div>
</div>
);
}

View File

@@ -1,12 +1,33 @@
"use client";
import { useState, useEffect } from 'react';
import { GeoData } from '@/app/api/types';
import { getLocationsFromIPs } from '@/app/utils/ipLocation';
interface GeoAnalyticsProps {
data: GeoData[];
}
// Interface for IP location data in our cache
interface IpLocationDetail {
country: string;
city: string;
region: string;
continent: string;
}
// Cache for IP location data
interface LocationCache {
[key: string]: IpLocationDetail;
}
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
const [viewMode, setViewMode] = useState<'country' | 'city' | 'region' | 'continent'>('country');
const [locationCache, setLocationCache] = useState<LocationCache>({});
const [isLoading, setIsLoading] = useState(false);
// Track IPs that failed to resolve
const [failedIPs, setFailedIPs] = useState<Set<string>>(new Set());
// 安全地格式化数字
const formatNumber = (value: number | undefined | null): string => {
if (value === undefined || value === null) return '0';
@@ -21,52 +42,233 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
const sortedData = [...data].sort((a, b) => (b.visits || 0) - (a.visits || 0));
// Handle tab selection - only change local view mode
const handleViewModeChange = (mode: 'country' | 'city' | 'region' | 'continent') => {
setViewMode(mode);
};
// Load location data for all IPs when the data changes
useEffect(() => {
const fetchLocations = async () => {
if (sortedData.length === 0) return;
setIsLoading(true);
const tempCache: LocationCache = {...locationCache};
const tempFailedIPs = new Set(failedIPs);
// Get all unique IPs that aren't already in the cache and haven't failed
const uniqueIPs = [...new Set(sortedData.map(item => item.location))].filter(ip =>
ip &&
ip !== 'Unknown' &&
!tempCache[ip] &&
!tempFailedIPs.has(ip)
);
if (uniqueIPs.length === 0) {
setIsLoading(false);
return;
}
try {
// Use batch lookup for better performance
const batchResults = await getLocationsFromIPs(uniqueIPs);
// Convert results to our cache format
for (const [ip, data] of Object.entries(batchResults)) {
if (data) {
tempCache[ip] = {
country: data.country_name,
city: data.city,
region: data.region,
continent: data.continent_name
};
} else {
// Mark as failed
tempFailedIPs.add(ip);
}
}
setLocationCache(tempCache);
setFailedIPs(tempFailedIPs);
} catch (error) {
console.error('Error fetching location data:', error);
} finally {
setIsLoading(false);
}
};
fetchLocations();
}, [data]);
// Get the appropriate location value based on the current view mode
const getLocationValue = (item: GeoData): string => {
const ip = item.location || '';
// If there's no IP or it's "Unknown", return that value
if (!ip || ip === 'Unknown') return 'Unknown';
// If IP failed to resolve, return Unknown
if (failedIPs.has(ip)) {
return 'Unknown';
}
// Return from cache if available
if (locationCache[ip]) {
switch (viewMode) {
case 'country':
return locationCache[ip].country || 'Unknown';
case 'city':
return locationCache[ip].city || 'Unknown';
case 'region':
return locationCache[ip].region || 'Unknown';
case 'continent':
return locationCache[ip].continent || 'Unknown';
default:
return ip;
}
}
// Return placeholder if not in cache yet
return `Loading...`;
};
// Get the appropriate area value based on the current view mode
const getAreaValue = (item: GeoData): string => {
const ip = item.location || '';
// If there's no IP or it's "Unknown", return empty string
if (!ip || ip === 'Unknown' || failedIPs.has(ip)) return '';
// Return from cache if available
if (locationCache[ip]) {
switch (viewMode) {
case 'country':
// For country view, show the continent as area
return locationCache[ip].continent || '';
case 'city':
// For city view, show the country and region
return `${locationCache[ip].country}, ${locationCache[ip].region}`;
case 'region':
// For region view, show the country
return locationCache[ip].country || '';
case 'continent':
// For continent view, no additional area needed
return '';
default:
return '';
}
}
// Return empty if not in cache yet
return '';
};
return (
<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">
Location
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Visits
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Unique Visitors
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
% of Total
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedData.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.location || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visits)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visitors)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center">
<div className="w-24 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${item.percentage || 0}%` }}
/>
</div>
<span className="ml-2">{formatPercent(item.percentage)}%</span>
</div>
</td>
<div>
{/* Tabs for geographic levels */}
<div className="flex border-b mb-6">
<button
onClick={() => handleViewModeChange('country')}
className={`px-4 py-2 ${viewMode === 'country' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Countries
</button>
<button
onClick={() => handleViewModeChange('city')}
className={`px-4 py-2 ${viewMode === 'city' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Cities
</button>
<button
onClick={() => handleViewModeChange('region')}
className={`px-4 py-2 ${viewMode === 'region' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Regions
</button>
<button
onClick={() => handleViewModeChange('continent')}
className={`px-4 py-2 ${viewMode === 'continent' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
>
Continents
</button>
</div>
{/* Loading indicator */}
{isLoading && (
<div className="flex justify-center items-center py-2 mb-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
<span className="text-sm text-gray-500">Loading location data...</span>
</div>
)}
{/* Table with added area column */}
<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">
{viewMode === 'country' ? 'Country' :
viewMode === 'city' ? 'City' :
viewMode === 'region' ? 'Region' : 'Continent'}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{viewMode === 'country' ? 'Continent' :
viewMode === 'city' ? 'Location' :
viewMode === 'region' ? 'Country' : 'Area'}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Visits
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Unique Visitors
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
% of Total
</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedData.length > 0 ? (
sortedData.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{getLocationValue(item)}
{item.location && (
<div className="text-xs text-gray-500 mt-1">{item.location}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{getAreaValue(item)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visits)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visitors)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center">
<div className="w-24 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${item.percentage || 0}%` }}
/>
</div>
<span className="ml-2">{formatPercent(item.percentage)}%</span>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
No location data available
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useEffect, useRef } from 'react';
import { DeviceAnalytics } from '@/app/api/types';
import { Chart, PieController, ArcElement, Tooltip, Legend, CategoryScale, LinearScale } from 'chart.js';
// 注册Chart.js组件
Chart.register(PieController, ArcElement, Tooltip, Legend, CategoryScale, LinearScale);
interface DevicePieChartsProps {
data: DeviceAnalytics;
}
// 颜色配置
const COLORS = {
deviceTypes: ['rgba(59, 130, 246, 0.8)', 'rgba(96, 165, 250, 0.8)', 'rgba(147, 197, 253, 0.8)', 'rgba(191, 219, 254, 0.8)', 'rgba(219, 234, 254, 0.8)'],
browsers: ['rgba(16, 185, 129, 0.8)', 'rgba(52, 211, 153, 0.8)', 'rgba(110, 231, 183, 0.8)', 'rgba(167, 243, 208, 0.8)', 'rgba(209, 250, 229, 0.8)'],
os: ['rgba(239, 68, 68, 0.8)', 'rgba(248, 113, 113, 0.8)', 'rgba(252, 165, 165, 0.8)', 'rgba(254, 202, 202, 0.8)', 'rgba(254, 226, 226, 0.8)']
};
export default function DevicePieCharts({ data }: DevicePieChartsProps) {
// 创建图表引用
const deviceTypesChartRef = useRef<HTMLCanvasElement>(null);
const browsersChartRef = useRef<HTMLCanvasElement>(null);
const osChartRef = useRef<HTMLCanvasElement>(null);
// 图表实例引用
const deviceTypesChartInstance = useRef<Chart | null>(null);
const browsersChartInstance = useRef<Chart | null>(null);
const osChartInstance = useRef<Chart | null>(null);
// 初始化和更新图表
useEffect(() => {
if (!data) return;
// 销毁旧的图表实例
if (deviceTypesChartInstance.current) {
deviceTypesChartInstance.current.destroy();
}
if (browsersChartInstance.current) {
browsersChartInstance.current.destroy();
}
if (osChartInstance.current) {
osChartInstance.current.destroy();
}
// 创建设备类型图表
if (deviceTypesChartRef.current && data.deviceTypes.length > 0) {
const ctx = deviceTypesChartRef.current.getContext('2d');
if (ctx) {
deviceTypesChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: data.deviceTypes.map(item => item.type),
datasets: [{
data: data.deviceTypes.map(item => item.count),
backgroundColor: COLORS.deviceTypes,
borderColor: COLORS.deviceTypes.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 创建浏览器图表
if (browsersChartRef.current && data.browsers.length > 0) {
const ctx = browsersChartRef.current.getContext('2d');
if (ctx) {
browsersChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: data.browsers.map(item => item.name),
datasets: [{
data: data.browsers.map(item => item.count),
backgroundColor: COLORS.browsers,
borderColor: COLORS.browsers.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 创建操作系统图表
if (osChartRef.current && data.operatingSystems.length > 0) {
const ctx = osChartRef.current.getContext('2d');
if (ctx) {
osChartInstance.current = new Chart(ctx, {
type: 'pie',
data: {
labels: data.operatingSystems.map(item => item.name),
datasets: [{
data: data.operatingSystems.map(item => item.count),
backgroundColor: COLORS.os,
borderColor: COLORS.os.map(color => color.replace('0.8', '1')),
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: 'currentColor'
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw as number;
const total = (context.chart.data.datasets[0].data as number[]).reduce((a, b) => (a as number) + (b as number), 0);
const percentage = Math.round((value * 100) / total);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
}
}
// 清理函数
return () => {
if (deviceTypesChartInstance.current) {
deviceTypesChartInstance.current.destroy();
}
if (browsersChartInstance.current) {
browsersChartInstance.current.destroy();
}
if (osChartInstance.current) {
osChartInstance.current.destroy();
}
};
}, [data]);
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 设备类型 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Device Types</h3>
<div className="h-64">
<canvas ref={deviceTypesChartRef} />
</div>
</div>
{/* 浏览器 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Browsers</h3>
<div className="h-64">
<canvas ref={browsersChartRef} />
</div>
</div>
{/* 操作系统 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Operating Systems</h3>
<div className="h-64">
<canvas ref={osChartRef} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useState, useEffect } from 'react';
import { getLocationFromIP } from '@/app/utils/ipLocation';
interface LocationData {
ip: string;
country_name: string;
country_code: string;
city: string;
region: string;
continent_code: string;
continent_name: string;
latitude: number;
longitude: number;
}
export default function IpLocationTest() {
const [locationData, setLocationData] = useState<LocationData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const testIp = "120.244.39.90";
useEffect(() => {
async function fetchLocation() {
try {
setLoading(true);
setError(null);
const data = await getLocationFromIP(testIp);
setLocationData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
} finally {
setLoading(false);
}
}
fetchLocation();
}, []);
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">IP Location Test: {testIp}</h2>
{loading && (
<div className="flex items-center text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
Loading location data...
</div>
)}
{error && (
<div className="text-red-500">
Error: {error}
</div>
)}
{!loading && locationData && (
<div className="space-y-4">
<div>
<h3 className="font-medium">Location Data:</h3>
<pre className="mt-2 p-4 bg-gray-100 rounded overflow-auto">
{JSON.stringify(locationData, null, 2)}
</pre>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="border p-3 rounded">
<h4 className="font-medium">Country</h4>
<div>{locationData.country_name} ({locationData.country_code})</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">City</h4>
<div>{locationData.city || 'N/A'}</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">Region</h4>
<div>{locationData.region || 'N/A'}</div>
</div>
<div className="border p-3 rounded">
<h4 className="font-medium">Continent</h4>
<div>{locationData.continent_name} ({locationData.continent_code})</div>
</div>
<div className="border p-3 rounded col-span-2">
<h4 className="font-medium">Coordinates</h4>
<div>Latitude: {locationData.latitude}, Longitude: {locationData.longitude}</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import Link from 'next/link';
import { useAuth } from '@/lib/auth';
export default function Header() {
const { user, signOut } = useAuth();
const handleLogout = async () => {
await signOut();
};
return (
<header className="w-full py-4 border-b border-gray-200 bg-white">
<div className="container flex items-center justify-between px-4 mx-auto">
<div className="flex items-center space-x-4">
<Link href="/" className="flex items-center space-x-2">
<svg
className="w-6 h-6 text-blue-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
<span className="text-xl font-bold text-gray-900">ShortURL Analytics</span>
</Link>
</div>
{user && (
<div className="flex items-center space-x-4">
<div className="text-sm text-gray-700">
{user.email}
</div>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm text-white bg-blue-500 rounded hover:bg-blue-600"
>
Logout
</button>
</div>
)}
</div>
</header>
);
}

View File

@@ -18,6 +18,14 @@ interface Project {
deleted_at?: string | null;
schema_version?: number | null;
creator_id?: string | null;
team_name?: string;
}
// 添加需要的类型定义
interface ProjectWithTeam {
project_id: string;
projects: Project;
teams?: { name: string };
}
// ProjectSelector component with multi-select support
@@ -27,12 +35,14 @@ export function ProjectSelector({
className,
multiple = false,
teamId,
teamIds,
}: {
value?: string | string[];
onChange?: (projectId: string | string[]) => void;
className?: string;
multiple?: boolean;
teamId?: string; // Optional team ID to filter projects by team
teamIds?: string[]; // Optional array of team IDs to filter projects by multiple teams
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -41,6 +51,16 @@ export function ProjectSelector({
const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
// Normalize team IDs to ensure we're always working with an array
const effectiveTeamIds = React.useMemo(() => {
if (teamIds && teamIds.length > 0) {
return teamIds;
} else if (teamId) {
return [teamId];
}
return undefined;
}, [teamId, teamIds]);
// Initialize selected projects based on value prop
useEffect(() => {
if (value) {
@@ -82,44 +102,90 @@ export function ProjectSelector({
try {
const supabase = getSupabaseClient();
let projectsQuery;
if (teamId) {
// If a teamId is provided, fetch projects for that team
projectsQuery = supabase
if (effectiveTeamIds && effectiveTeamIds.length > 0) {
// If team IDs are provided, get projects for those teams
const { data: projectsData, error: projectsError } = await supabase
.from('team_projects')
.select('project_id, projects:project_id(*)')
.eq('team_id', teamId)
.select('project_id, projects:project_id(*), teams:team_id(name)')
.in('team_id', effectiveTeamIds)
.is('projects.deleted_at', null);
if (projectsError) throw projectsError;
if (!projectsData || projectsData.length === 0) {
if (isMounted) setProjects([]);
return;
}
// Extract projects from response with team info
if (isMounted) {
const projectList: Project[] = [];
for (const item of projectsData as ProjectWithTeam[]) {
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
const project = item.projects as Project;
if (item.teams && 'name' in item.teams) {
project.team_name = item.teams.name;
}
// Avoid duplicate projects from different teams
if (!projectList.some(p => p.id === project.id)) {
projectList.push(project);
}
}
}
setProjects(projectList);
}
} else {
// Otherwise, fetch projects the user is a member of
projectsQuery = supabase
// If no team IDs, get all user's projects
const { data: projectsData, error: projectsError } = await supabase
.from('user_projects')
.select('project_id, projects:project_id(*)')
.eq('user_id', userId)
.is('projects.deleted_at', null);
}
const { data: projectsData, error: projectsError } = await projectsQuery;
if (projectsError) throw projectsError;
if (projectsError) throw projectsError;
if (!projectsData || projectsData.length === 0) {
if (isMounted) setProjects([]);
return;
}
if (!projectsData || projectsData.length === 0) {
if (isMounted) setProjects([]);
return;
}
// Extract the project data from the query results
if (isMounted && projectsData && projectsData.length > 0) {
const projectList: Project[] = [];
// Fetch team info for these projects
const projectIds = projectsData.map(item => item.project_id);
for (const item of projectsData) {
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
projectList.push(item.projects as Project);
}
// Get team info for each project
const { data: teamProjectsData, error: teamProjectsError } = await supabase
.from('team_projects')
.select('project_id, teams:team_id(name)')
.in('project_id', projectIds);
if (teamProjectsError) throw teamProjectsError;
// Create project ID to team name mapping
const projectTeamMap: Record<string, string> = {};
if (teamProjectsData) {
teamProjectsData.forEach(item => {
if (item.teams && typeof item.teams === 'object' && 'name' in item.teams) {
projectTeamMap[item.project_id] = (item.teams as { name: string }).name;
}
});
}
setProjects(projectList);
// Extract projects with team names
if (isMounted && projectsData) {
const projectList: Project[] = [];
for (const item of projectsData) {
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
const project = item.projects as Project;
project.team_name = projectTeamMap[project.id];
projectList.push(project);
}
}
setProjects(projectList);
}
}
} catch (err) {
if (isMounted) {
@@ -153,7 +219,7 @@ export function ProjectSelector({
isMounted = false;
subscription.unsubscribe();
};
}, [teamId]);
}, [effectiveTeamIds]);
const handleToggle = () => {
if (!loading && !error && projects.length > 0) {
@@ -274,6 +340,11 @@ export function ProjectSelector({
>
<span className="flex flex-col">
<span className="font-medium">{project.name}</span>
{project.team_name && (
<span className="text-xs text-gray-500">
{project.team_name}
</span>
)}
{project.description && (
<span className="text-xs text-gray-500 truncate max-w-[250px]">
{project.description}

View File

@@ -30,14 +30,14 @@ export function TagSelector({
className,
multiple = false,
teamId,
tagType,
teamIds,
}: {
value?: string | string[];
onChange?: (tagId: string | string[]) => void;
onChange?: (tagIds: string | string[]) => void;
className?: string;
multiple?: boolean;
teamId?: string; // Optional team ID to filter tags by team
tagType?: string; // Optional tag type for filtering
teamId?: string; // Optional single team ID
teamIds?: string[]; // Optional array of team IDs
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -46,18 +46,57 @@ export function TagSelector({
const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
// Initialize selected tags based on value prop
useEffect(() => {
if (value) {
if (Array.isArray(value)) {
setSelectedIds(value);
} else {
setSelectedIds(value ? [value] : []);
}
} else {
setSelectedIds([]);
// Normalize team IDs to ensure we're always working with an array
const effectiveTeamIds = React.useMemo(() => {
if (teamIds && teamIds.length > 0) {
return teamIds;
} else if (teamId) {
return [teamId];
}
}, [value]);
return undefined;
}, [teamId, teamIds]);
// 标签名称与ID的映射函数
const getTagIdByName = (name: string): string | undefined => {
const tag = tags.find(t => t.name === name);
return tag?.id;
};
const getTagNameById = (id: string): string | undefined => {
const tag = tags.find(t => t.id === id);
return tag?.name;
};
// 从标签名称转换为标签ID
const nameToId = (nameOrNames: string | string[] | undefined): string[] => {
if (!nameOrNames) return [];
if (Array.isArray(nameOrNames)) {
return nameOrNames
.map(name => getTagIdByName(name))
.filter((id): id is string => !!id);
}
const id = getTagIdByName(nameOrNames);
return id ? [id] : [];
};
// 从标签ID转换为标签名称
const idToName = (idOrIds: string | string[] | undefined): string[] => {
if (!idOrIds) return [];
if (Array.isArray(idOrIds)) {
return idOrIds
.map(id => getTagNameById(id))
.filter((name): name is string => !!name);
}
const name = getTagNameById(idOrIds);
return name ? [name] : [];
};
// 初始化已选择的标签 - 从传入的名称转换为ID
useEffect(() => {
if (tags.length > 0 && value) {
setSelectedIds(nameToId(value));
}
}, [value, tags]);
// Add click outside listener to close dropdown
useEffect(() => {
@@ -90,13 +129,8 @@ export function TagSelector({
let query = supabase.from('tags').select('*').is('deleted_at', null);
// Filter by team if teamId is provided
if (teamId) {
query = query.eq('team_id', teamId);
}
// Filter by tag type if provided
if (tagType) {
query = query.eq('type', tagType);
if (effectiveTeamIds) {
query = query.in('team_id', effectiveTeamIds);
}
const { data: tagsData, error: tagsError } = await query;
@@ -141,7 +175,7 @@ export function TagSelector({
isMounted = false;
subscription.unsubscribe();
};
}, [teamId, tagType]);
}, [effectiveTeamIds]);
const handleToggle = () => {
if (!loading && !error && tags.length > 0) {
@@ -167,8 +201,10 @@ export function TagSelector({
setSelectedIds(newSelected);
// 传递标签名称而不是ID
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
const tagNames = idToName(newSelected);
onChange(multiple ? tagNames : tagNames[0] || '');
}
};
@@ -176,8 +212,11 @@ export function TagSelector({
e.stopPropagation();
const newSelected = selectedIds.filter(id => id !== tagId);
setSelectedIds(newSelected);
// 传递标签名称而不是ID
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
const tagNames = idToName(newSelected);
onChange(multiple ? tagNames : tagNames[0] || '');
}
};
@@ -214,6 +253,7 @@ export function TagSelector({
);
}
// 根据已选择的ID筛选出已选择的标签
const selectedTags = tags.filter(tag => selectedIds.includes(tag.id));
return (

10
app/ip-test/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import IpLocationTest from '../components/ipLocationTest';
export default function IpTestPage() {
return (
<div className="container mx-auto p-4 max-w-4xl">
<h1 className="text-2xl font-bold mb-6">IP to Location Test</h1>
<IpLocationTest />
</div>
);
}

View File

@@ -3,6 +3,7 @@ import '@radix-ui/themes/styles.css';
import type { Metadata } from 'next';
import { AuthProvider } from '@/lib/auth';
import { Theme } from '@radix-ui/themes';
import Header from '@/app/components/layout/Header';
export const metadata: Metadata = {
title: 'ShortURL Analytics',
@@ -19,6 +20,7 @@ export default function RootLayout({
<body>
<Theme>
<AuthProvider>
<Header />
{children}
</AuthProvider>
</Theme>

View File

@@ -14,10 +14,10 @@ export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: '', content: '' });
// 如果用户已登录,重定向到仪表板
// 如果用户已登录,重定向到首页
useEffect(() => {
if (user) {
router.push('/dashboard');
router.push('/');
}
}, [user, router]);

View File

@@ -1,111 +1,803 @@
import Link from 'next/link';
"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;
ip_address?: string;
}
// 格式化日期函数
const formatDate = (dateString: string | undefined) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss');
} catch {
return dateString;
}
};
// 解析JSON字符串
const parseJsonSafely = (jsonString: string) => {
if (!jsonString) return null;
try {
return JSON.parse(jsonString);
} catch {
return null;
}
};
// 获取用户可读名称
const getUserDisplayName = (user: Record<string, unknown> | null) => {
if (!user) return '-';
if (typeof user.full_name === 'string') return user.full_name;
if (typeof user.name === 'string') return user.name;
if (typeof user.email === 'string') return user.email;
return '-';
};
// 提取链接和事件的重要信息
const extractEventInfo = (event: Event) => {
// 解析事件属性
const eventAttrs = parseJsonSafely(event.event_attributes || '{}');
// 解析链接属性
const linkAttrs = parseJsonSafely(event.link_attributes || '{}');
// 解析用户属性
const userAttrs = parseJsonSafely(event.user_attributes || '{}');
// 解析标签信息
let tags: string[] = [];
try {
if (event.link_tags) {
const parsedTags = JSON.parse(event.link_tags);
if (Array.isArray(parsedTags)) {
tags = parsedTags;
}
}
} catch {
// 解析失败则保持空数组
}
return {
eventTime: event.created_at || event.event_time,
linkName: event.link_label || linkAttrs?.name || eventAttrs?.link_name || event.link_slug || '-',
originalUrl: event.link_original_url || eventAttrs?.origin_url || '-',
eventType: event.event_type || '-',
visitorId: event.visitor_id?.substring(0, 8) || '-',
referrer: eventAttrs?.referrer || '-',
ipAddress: event.ip_address || '-',
location: event.country ? (event.city ? `${event.city}, ${event.country}` : event.country) : '-',
device: event.device_type || '-',
browser: event.browser || '-',
os: event.os || '-',
userInfo: getUserDisplayName(userAttrs),
teamName: event.team_name || '-',
projectName: event.project_name || '-',
tags: tags
};
};
export default function 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>
),
},
];
// 默认日期范围为最近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 [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [totalEvents, setTotalEvents] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [summary, setSummary] = useState<EventsSummary | null>(null);
const [timeSeriesData, setTimeSeriesData] = useState<TimeSeriesData[]>([]);
const [geoData, setGeoData] = useState<GeoData[]>([]);
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
const [events, setEvents] = useState<Event[]>([]);
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,
page: currentPage.toString(),
pageSize: pageSize.toString()
});
// 添加团队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 || []);
// 设置总事件数量用于分页
if (eventsData.meta) {
// 确保将total转换为数字无论它是字符串还是数字
const totalCount = parseInt(String(eventsData.meta.total), 10);
if (!isNaN(totalCount)) {
setTotalEvents(totalCount);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
} finally {
setLoading(false);
}
};
fetchData();
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagIds, currentPage, pageSize]);
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="min-h-screen bg-gray-50 dark:bg-gray-900">
<header className="bg-white dark:bg-gray-800 shadow">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
ShortURL Analytics
</h1>
<div>
<Link
href="/login"
className="inline-block bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
</Link>
<Link
href="/register"
className="ml-4 inline-block bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-4 py-2 rounded-md text-sm font-medium hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</Link>
</div>
</div>
</div>
</header>
<div className="container mx-auto px-4 py-16">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Welcome to ShortURL Analytics
</h2>
<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 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) => {
const newTeamIds = Array.isArray(value) ? value : [value];
// Check if team selection has changed
if (JSON.stringify(newTeamIds) !== JSON.stringify(selectedTeamIds)) {
// Clear project selection when team changes
setSelectedProjectIds([]);
// Update team selection
setSelectedTeamIds(newTeamIds);
}
}}
className="w-[250px]"
multiple={true}
/>
<ProjectSelector
value={selectedProjectIds}
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
/>
<TagSelector
value={selectedTagIds}
onChange={(value) => setSelectedTagIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : 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>
)}
{/* 仪表板内容 - 现在放在事件列表之后 */}
<>
{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 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">
IP/Location
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Device Info
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event, index) => {
const info = extractEventInfo(event);
return (
<tr key={event.event_id || index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(info.eventTime)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span className="font-medium">{info.linkName}</span>
<div className="text-xs text-gray-500 mt-1 truncate max-w-xs">
ID: {event.link_id?.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">IP:</span>
<span className="ml-1">{info.ipAddress}</span>
</span>
<span className="text-xs inline-flex items-center">
<span className="font-medium">Location:</span>
<span className="ml-1">{info.location}</span>
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex flex-col">
<span className="text-xs inline-flex items-center mb-1">
<span className="font-medium">Device:</span>
<span className="ml-1">{info.device}</span>
</span>
<span className="text-xs inline-flex items-center mb-1">
<span className="font-medium">Browser:</span>
<span className="ml-1">{info.browser}</span>
</span>
<span className="text-xs inline-flex items-center">
<span className="font-medium">OS:</span>
<span className="ml-1">{info.os}</span>
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 表格为空状态 */}
{!loading && events.length === 0 && (
<div className="flex justify-center items-center p-8 text-gray-500">
No events found
</div>
)}
{/* 分页控件 - 删除totalEvents > 0条件改为events.length > 0 */}
{!loading && events.length > 0 && (
<div className="px-6 py-4 flex items-center justify-between border-t border-gray-200">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md ${
currentPage === 1
? 'text-gray-300 bg-gray-50'
: 'text-gray-700 bg-white hover:bg-gray-50'
}`}
>
Previous
</button>
<button
onClick={() => setCurrentPage(prev => (currentPage < Math.ceil(totalEvents / pageSize)) ? prev + 1 : prev)}
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
className={`ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md ${
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-700 bg-white hover:bg-gray-50'
}`}
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{events.length > 0 ? ((currentPage - 1) * pageSize) + 1 : 0}</span> to <span className="font-medium">{events.length > 0 ? ((currentPage - 1) * pageSize) + events.length : 0}</span> of{' '}
<span className="font-medium">{totalEvents}</span> results
</p>
</div>
<div className="flex items-center">
<div className="mr-4">
<select
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setCurrentPage(1); // 重置到第一页
}}
>
<option value="5">5 / page</option>
<option value="10">10 / page</option>
<option value="20">20 / page</option>
<option value="50">50 / page</option>
</select>
</div>
{/* 添加直接跳转到指定页的输入框 */}
<div className="mr-4 flex items-center">
<span className="text-sm text-gray-700 mr-2">Go to:</span>
<input
type="number"
min="1"
max={Math.max(1, Math.ceil(totalEvents / pageSize))}
value={currentPage}
onChange={(e) => {
const page = parseInt(e.target.value);
if (!isNaN(page) && page >= 1 && page <= Math.ceil(totalEvents / pageSize)) {
setCurrentPage(page);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const input = e.target as HTMLInputElement;
const page = parseInt(input.value);
if (!isNaN(page) && page >= 1 && page <= Math.ceil(totalEvents / pageSize)) {
setCurrentPage(page);
}
}
}}
className="w-16 px-3 py-1 border border-gray-300 rounded-md text-sm"
/>
<span className="text-sm text-gray-700 ml-2">
of {Math.max(1, Math.ceil(totalEvents / pageSize))}
</span>
</div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
{/* 首页按钮 */}
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${
currentPage === 1
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only">First</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 010 1.414zm-6 0a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 011.414 1.414L5.414 10l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
</button>
{/* 上一页按钮 */}
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className={`relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium ${
currentPage === 1
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only">Previous</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* 页码按钮 */}
{(() => {
const totalPages = Math.max(1, Math.ceil(totalEvents / pageSize));
const pageNumbers = [];
// 如果总页数小于等于7显示所有页码
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
// 总是显示首页
pageNumbers.push(1);
// 根据当前页显示中间页码
if (currentPage <= 3) {
// 当前页靠近开始
pageNumbers.push(2, 3, 4);
pageNumbers.push('ellipsis1');
} else if (currentPage >= totalPages - 2) {
// 当前页靠近结束
pageNumbers.push('ellipsis1');
pageNumbers.push(totalPages - 3, totalPages - 2, totalPages - 1);
} else {
// 当前页在中间
pageNumbers.push('ellipsis1');
pageNumbers.push(currentPage - 1, currentPage, currentPage + 1);
pageNumbers.push('ellipsis2');
}
// 总是显示尾页
pageNumbers.push(totalPages);
}
return pageNumbers.map((pageNum, idx) => {
if (pageNum === 'ellipsis1' || pageNum === 'ellipsis2') {
return (
<div key={`ellipsis-${idx}`} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
...
</div>
);
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(Number(pageNum))}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
);
});
})()}
{/* 下一页按钮 */}
<button
onClick={() => setCurrentPage(prev => (currentPage < Math.ceil(totalEvents / pageSize)) ? prev + 1 : prev)}
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
className={`relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium ${
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only">Next</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* 尾页按钮 */}
<button
onClick={() => setCurrentPage(Math.ceil(totalEvents / pageSize))}
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
? 'text-gray-300 cursor-not-allowed'
: 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="sr-only">Last</span>
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 15.707a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L8.586 10 4.293 14.293a1 1 0 000 1.414zm6 0a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L15.586 10l-4.293 4.293a1 1 0 000 1.414z" clipRule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</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}
onViewModeChange={(mode) => {
// 构建查询参数
const params = new URLSearchParams({
startTime: format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'"),
endTime: format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'"),
groupBy: mode
});
// 添加其他筛选参数
if (selectedTeamIds.length > 0) {
selectedTeamIds.forEach(id => params.append('teamId', id));
}
if (selectedProjectIds.length > 0) {
selectedProjectIds.forEach(id => params.append('projectId', id));
}
if (selectedTagIds.length > 0) {
selectedTagIds.forEach(id => params.append('tagId', id));
}
// 刷新地理位置数据
fetch(`/api/events/geo?${params}`)
.then(res => res.json())
.then(data => {
if (data.success) {
setGeoData(data.data);
}
})
.catch(error => console.error('Failed to fetch geo data:', error));
}}
/>
</div>
</>
</div>
);
}
}

484
app/utils/ipLocation.ts Normal file
View File

@@ -0,0 +1,484 @@
interface IpLocationData {
ip: string;
country_name: string;
country_code: string;
city: string;
region: string;
continent_code: string;
continent_name: string;
latitude: number;
longitude: number;
timestamp?: number; // When this data was fetched
}
// In-memory cache
let locationCache: Record<string, IpLocationData> = {};
// Blacklist for IPs that failed to resolve multiple times
let failedIPs: Record<string, { attempts: number, lastAttempt: number }> = {};
// Cache expiration time (30 days in milliseconds)
const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000;
// Max retries for a failed IP
const MAX_RETRY_ATTEMPTS = 3;
// Retry timeout (24 hours in milliseconds)
const RETRY_TIMEOUT = 24 * 60 * 60 * 1000;
// Max number of IPs to batch in a single request
const MAX_BATCH_SIZE = 10;
/**
* Initialize cache from localStorage
*/
const initializeCache = () => {
if (typeof window === 'undefined') return;
try {
// Load location cache
const cachedData = localStorage.getItem('ip-location-cache');
if (cachedData) {
const parsedCache = JSON.parse(cachedData);
// Filter out expired entries
const now = Date.now();
const validEntries = Object.entries(parsedCache).filter(([, data]) => {
const entry = data as IpLocationData;
return entry.timestamp && now - entry.timestamp < CACHE_EXPIRATION;
});
locationCache = Object.fromEntries(validEntries) as Record<string, IpLocationData>;
console.log(`Loaded ${validEntries.length} IP locations from cache`);
}
// Load failed IPs
const failedIPsData = localStorage.getItem('ip-location-blacklist');
if (failedIPsData) {
const parsedFailedIPs = JSON.parse(failedIPsData);
// Filter out expired blacklist entries
const now = Date.now();
const validFailedEntries = Object.entries(parsedFailedIPs).filter(([, data]) => {
const entry = data as { attempts: number, lastAttempt: number };
// Keep entries that have max attempts or haven't timed out yet
return entry.attempts >= MAX_RETRY_ATTEMPTS ||
now - entry.lastAttempt < RETRY_TIMEOUT;
});
failedIPs = Object.fromEntries(validFailedEntries) as Record<string, { attempts: number, lastAttempt: number }>;
console.log(`Loaded ${validFailedEntries.length} blacklisted IPs`);
}
} catch (error) {
console.error('Failed to load IP location cache:', error);
// Reset cache if corrupted
localStorage.removeItem('ip-location-cache');
localStorage.removeItem('ip-location-blacklist');
locationCache = {};
failedIPs = {};
}
};
/**
* Save cache to localStorage
*/
const saveCache = () => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('ip-location-cache', JSON.stringify(locationCache));
} catch (error) {
console.error('Failed to save IP location cache:', error);
// If localStorage is full, clear old entries
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
// Clear older entries - keep newest 100
const entries = Object.entries(locationCache)
.sort((a, b) => {
const timestampA = (a[1].timestamp || 0);
const timestampB = (b[1].timestamp || 0);
return timestampB - timestampA;
})
.slice(0, 100);
locationCache = Object.fromEntries(entries);
localStorage.setItem('ip-location-cache', JSON.stringify(locationCache));
}
}
};
/**
* Save failed IPs to localStorage
*/
const saveFailedIPs = () => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs));
} catch (error) {
console.error('Failed to save IP blacklist:', error);
// If localStorage is full, limit the size
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
// Keep only IPs with max attempts
const entries = Object.entries(failedIPs)
.filter(([, data]) => data.attempts >= MAX_RETRY_ATTEMPTS);
failedIPs = Object.fromEntries(entries);
localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs));
}
}
};
/**
* Check if IP is a private/local address
*/
const isPrivateIP = (ip: string): boolean => {
return (
ip.startsWith('10.') ||
ip.startsWith('192.168.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('127.') ||
ip === 'localhost' ||
ip === '::1'
);
};
/**
* Check if an IP should be skipped (blacklisted)
*/
const shouldSkipIP = (ip: string): boolean => {
// If not in failed list, don't skip
if (!failedIPs[ip]) return false;
const now = Date.now();
// If reached max attempts, skip
if (failedIPs[ip].attempts >= MAX_RETRY_ATTEMPTS) {
return true;
}
// If hasn't been long enough since last attempt, skip
if (now - failedIPs[ip].lastAttempt < RETRY_TIMEOUT) {
return true;
}
// Otherwise, we can try again
return false;
};
/**
* Mark IP as failed
*/
const markIPAsFailed = (ip: string): void => {
const now = Date.now();
if (failedIPs[ip]) {
failedIPs[ip] = {
attempts: failedIPs[ip].attempts + 1,
lastAttempt: now
};
} else {
failedIPs[ip] = {
attempts: 1,
lastAttempt: now
};
}
saveFailedIPs();
};
/**
* Get location data for a single IP address
*/
const fetchSingleIP = async (ip: string): Promise<IpLocationData | null> => {
// Skip blacklisted IPs
if (shouldSkipIP(ip)) {
console.log(`Skipping blacklisted IP: ${ip}`);
return null;
}
try {
const response = await fetch(`https://ipapi.co/${ip}/json/`);
if (!response.ok) {
console.error(`Error fetching location for IP ${ip}: ${response.statusText}`);
markIPAsFailed(ip);
return null;
}
const data = await response.json();
if (data.error) {
console.error(`Error fetching location for IP ${ip}: ${data.reason}`);
markIPAsFailed(ip);
return null;
}
// Reset failed attempts if successful
if (failedIPs[ip]) {
delete failedIPs[ip];
saveFailedIPs();
}
const locationData: IpLocationData = {
ip: data.ip,
country_name: data.country_name || 'Unknown',
country_code: data.country_code || 'UN',
city: data.city || 'Unknown',
region: data.region || 'Unknown',
continent_code: data.continent_code || 'UN',
continent_name: getContinentName(data.continent_code) || 'Unknown',
latitude: data.latitude || 0,
longitude: data.longitude || 0,
timestamp: Date.now()
};
return locationData;
} catch (error) {
console.error(`Error fetching location for IP ${ip}:`, error);
markIPAsFailed(ip);
return null;
}
};
/**
* Batch process multiple IPs at once using our own API endpoint
* This is a placeholder - we'll create a server API route for this
*/
const fetchBatchIPs = async (ips: string[]): Promise<Record<string, IpLocationData | null>> => {
try {
// Filter out blacklisted IPs
const validIPs = ips.filter(ip => !shouldSkipIP(ip));
if (validIPs.length === 0) {
return {};
}
const response = await fetch('/api/geo/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ips: validIPs }),
});
if (!response.ok) {
throw new Error(`Batch request failed: ${response.statusText}`);
}
const results = await response.json();
// Mark failed IPs from results
for (const [ip, data] of Object.entries(results.data)) {
if (!data) {
markIPAsFailed(ip);
} else if (failedIPs[ip]) {
// Reset failed attempts if successful
delete failedIPs[ip];
}
}
saveFailedIPs();
return results.data;
} catch (error) {
console.error('Error in batch IP lookup:', error);
// Fallback to individual requests
const results: Record<string, IpLocationData | null> = {};
for (const ip of ips) {
// Skip blacklisted IPs
if (shouldSkipIP(ip)) {
results[ip] = null;
continue;
}
// Add delays between requests to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 200));
results[ip] = await fetchSingleIP(ip);
}
return results;
}
};
/**
* Handle private IP addresses
*/
const getPrivateIPData = (ip: string): IpLocationData => ({
ip,
country_name: 'Local Network',
country_code: 'LO',
city: 'Local',
region: 'Local',
continent_code: 'LO',
continent_name: 'Local',
latitude: 0,
longitude: 0,
timestamp: Date.now()
});
/**
* Convert an IP address to location information
* Individual lookup for a single IP
*/
export async function getLocationFromIP(ip: string): Promise<IpLocationData | null> {
// Initialize cache from localStorage if needed
if (Object.keys(locationCache).length === 0) {
initializeCache();
}
// Handle private IP addresses
if (isPrivateIP(ip)) {
const privateIPData = getPrivateIPData(ip);
locationCache[ip] = privateIPData;
return privateIPData;
}
// Skip blacklisted IPs
if (shouldSkipIP(ip)) {
console.log(`Skipping blacklisted IP: ${ip}`);
return null;
}
// Return from cache if available and not expired
if (locationCache[ip]) {
const cachedData = locationCache[ip];
const now = Date.now();
// Return cached data if not expired
if (cachedData.timestamp && now - cachedData.timestamp < CACHE_EXPIRATION) {
return cachedData;
}
}
// Fetch new data
const locationData = await fetchSingleIP(ip);
// Save to cache if successful
if (locationData) {
locationCache[ip] = locationData;
saveCache();
}
return locationData;
}
/**
* Batch lookup for multiple IPs at once
* More efficient than calling getLocationFromIP multiple times
*/
export async function getLocationsFromIPs(ips: string[]): Promise<Record<string, IpLocationData | null>> {
// Initialize cache from localStorage if needed
if (Object.keys(locationCache).length === 0) {
initializeCache();
}
// Filter out IPs that are already in cache and not expired
const now = Date.now();
const cachedResults: Record<string, IpLocationData> = {};
const ipsToFetch: string[] = [];
for (const ip of ips) {
// Handle private IPs
if (isPrivateIP(ip)) {
cachedResults[ip] = getPrivateIPData(ip);
continue;
}
// Skip blacklisted IPs
if (shouldSkipIP(ip)) {
console.log(`Skipping blacklisted IP: ${ip}`);
continue;
}
// Check cache
if (locationCache[ip] && locationCache[ip].timestamp &&
now - locationCache[ip].timestamp < CACHE_EXPIRATION) {
cachedResults[ip] = locationCache[ip];
} else {
ipsToFetch.push(ip);
}
}
// If all IPs were cached or blacklisted, return immediately
if (ipsToFetch.length === 0) {
return cachedResults;
}
// Process IPs in batches to avoid overwhelming the API
const results: Record<string, IpLocationData | null> = { ...cachedResults };
// Process in smaller batches (e.g., 10 IPs at a time)
for (let i = 0; i < ipsToFetch.length; i += MAX_BATCH_SIZE) {
const batchIPs = ipsToFetch.slice(i, i + MAX_BATCH_SIZE);
// Batch request
const batchResults = await fetchBatchIPs(batchIPs);
// Update results and cache
for (const [ip, data] of Object.entries(batchResults)) {
results[ip] = data;
if (data) {
locationCache[ip] = data;
}
}
// Save updated cache
saveCache();
// Add delay between batches
if (i + MAX_BATCH_SIZE < ipsToFetch.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
/**
* Get continent name from continent code
*/
function getContinentName(code?: string): string {
if (!code) return 'Unknown';
const continents: Record<string, string> = {
'AF': 'Africa',
'AN': 'Antarctica',
'AS': 'Asia',
'EU': 'Europe',
'NA': 'North America',
'OC': 'Oceania',
'SA': 'South America'
};
return continents[code] || 'Unknown';
}
/**
* Get location information based on view mode
*/
export function getLocationByType(
locationData: IpLocationData | null,
viewMode: 'country' | 'city' | 'region' | 'continent'
): string {
if (!locationData) return 'Unknown';
switch (viewMode) {
case 'country':
return locationData.country_name || 'Unknown';
case 'city':
return locationData.city || 'Unknown';
case 'region':
return locationData.region || 'Unknown';
case 'continent':
return locationData.continent_name || 'Unknown';
default:
return 'Unknown';
}
}

View File

@@ -9,8 +9,8 @@ export enum TimeGranularity {
MONTH = 'month'
}
// 获取事件列表
export async function getEvents(params: {
// 事件查询参数类型
export interface EventsQueryParams {
startTime?: string;
endTime?: string;
eventType?: string;
@@ -19,11 +19,17 @@ export async function getEvents(params: {
userId?: string;
teamId?: string;
projectId?: string;
teamIds?: string[];
projectIds?: string[];
tagIds?: string[];
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}): Promise<{ events: Event[]; total: number }> {
}
// 获取事件列表
export async function getEvents(params: EventsQueryParams): Promise<{ events: Event[]; total: number }> {
const filter = buildFilter(params);
const pagination = buildPagination(params.page, params.pageSize);
const orderBy = buildOrderBy(params.sortBy, params.sortOrder);
@@ -57,6 +63,9 @@ export async function getEventsSummary(params: {
startTime?: string;
endTime?: string;
linkId?: string;
teamIds?: string[];
projectIds?: string[];
tagIds?: string[];
}): Promise<EventsSummary> {
const filter = buildFilter(params);
@@ -64,7 +73,7 @@ export async function getEventsSummary(params: {
const baseQuery = `
SELECT
count() as totalEvents,
uniq(visitor_id) as uniqueVisitors,
uniq(ip_address) as uniqueVisitors,
countIf(event_type = 'conversion') as totalConversions,
avg(time_spent_sec) as averageTimeSpent,
@@ -172,6 +181,9 @@ export async function getTimeSeriesData(params: {
endTime: string;
linkId?: string;
granularity: 'hour' | 'day' | 'week' | 'month';
teamIds?: string[];
projectIds?: string[];
tagIds?: string[];
}): Promise<TimeSeriesData[]> {
const filter = buildFilter(params);
@@ -187,7 +199,7 @@ export async function getTimeSeriesData(params: {
SELECT
toStartOfInterval(event_time, INTERVAL ${interval}) as timestamp,
count() as events,
uniq(visitor_id) as visitors,
uniq(ip_address) as visitors,
countIf(event_type = 'conversion') as conversions
FROM events
${filter}
@@ -203,23 +215,33 @@ export async function getGeoAnalytics(params: {
startTime?: string;
endTime?: string;
linkId?: string;
groupBy?: 'country' | 'city';
groupBy?: 'country' | 'city' | 'region' | 'continent';
teamIds?: string[];
projectIds?: string[];
tagIds?: string[];
}): Promise<GeoData[]> {
const filter = buildFilter(params);
const groupByField = 'ip_address'; // 暂时按 IP 地址分组
// Choose grouping field based on selected view
let groupByField = 'country';
if (params.groupBy === 'city') groupByField = 'city';
else if (params.groupBy === 'region') groupByField = 'region';
else if (params.groupBy === 'continent') groupByField = 'continent';
else if (!params.groupBy) groupByField = 'ip_address'; // Default to IP address if no groupBy is specified
const query = `
SELECT
${groupByField} as location,
COALESCE(${groupByField}, 'Unknown') as location,
'' as area, /* Area column - empty for now */
count() as visits,
uniq(visitor_id) as visitors,
uniq(ip_address) as visitors,
count() * 100.0 / sum(count()) OVER () as percentage
FROM events
${filter}
GROUP BY ${groupByField}
GROUP BY location
HAVING location != ''
ORDER BY visits DESC
LIMIT 10
LIMIT 20
`;
return executeQuery<GeoData>(query);
@@ -230,6 +252,9 @@ export async function getDeviceAnalytics(params: {
startTime?: string;
endTime?: string;
linkId?: string;
teamIds?: string[];
projectIds?: string[];
tagIds?: string[];
}): Promise<DeviceAnalytics> {
const filter = buildFilter(params);

View File

@@ -28,82 +28,108 @@ function buildDateFilter(startTime?: string, endTime?: string): string {
export function buildFilter(params: Partial<EventsQueryParams>): string {
const filters = [];
// 时间范围过滤
// 添加日期过滤条件
if (params.startTime || params.endTime) {
const dateFilter = buildDateFilter(params.startTime, params.endTime).replace('WHERE ', '');
const dateFilter = buildDateFilter(params.startTime, params.endTime);
if (dateFilter) {
filters.push(dateFilter);
filters.push(dateFilter.replace('WHERE ', ''));
}
}
// 事件类型过滤
// 添加事件类型过滤条件
if (params.eventType) {
filters.push(`event_type = '${params.eventType}'`);
}
// 链接ID过滤
// 添加链接ID过滤条件
if (params.linkId) {
filters.push(`link_id = '${params.linkId}'`);
}
// 链接短码过滤
// 添加链接Slug过滤条件
if (params.linkSlug) {
filters.push(`link_slug = '${params.linkSlug}'`);
}
// 用户ID过滤
// 添加用户ID过滤条件
if (params.userId) {
filters.push(`user_id = '${params.userId}'`);
}
// 团队ID过滤
// 添加团队ID过滤条件
if (params.teamId) {
filters.push(`team_id = '${params.teamId}'`);
}
// 项目ID过滤
// 处理多个团队ID
if (params.teamIds && params.teamIds.length > 0) {
filters.push(`team_id IN (${params.teamIds.map(id => `'${id}'`).join(', ')})`);
}
// 添加项目ID过滤条件
if (params.projectId) {
filters.push(`project_id = '${params.projectId}'`);
}
// 处理多个项目ID
if (params.projectIds && params.projectIds.length > 0) {
filters.push(`project_id IN (${params.projectIds.map(id => `'${id}'`).join(', ')})`);
}
// 处理标签过滤 - 使用LIKE来匹配标签字符串
if (params.tagIds && params.tagIds.length > 0) {
const tagConditions = params.tagIds.map(tag =>
`link_tags LIKE '%${tag}%'`
);
filters.push(`(${tagConditions.join(' OR ')})`);
}
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
}
// 构建分页
export function buildPagination(page?: number, pageSize?: number): string {
const limit = pageSize || 20;
const offset = ((page || 1) - 1) * limit;
return `LIMIT ${limit} OFFSET ${offset}`;
// 构建分页条件
export function buildPagination(page: number = 1, pageSize: number = 20): string {
const offset = (page - 1) * pageSize;
return `LIMIT ${pageSize} OFFSET ${offset}`;
}
// 构建排序
export function buildOrderBy(sortBy?: string, sortOrder?: 'asc' | 'desc'): string {
if (!sortBy) {
return 'ORDER BY event_time DESC';
}
return `ORDER BY ${sortBy} ${sortOrder || 'desc'}`;
// 构建排序条件
export function buildOrderBy(sortBy: string = 'event_time', sortOrder: string = 'desc'): string {
return `ORDER BY ${sortBy} ${sortOrder}`;
}
// 执行查询并处理错误
export async function executeQuery<T>(query: string): Promise<T[]> {
// 执行查询
export async function executeQuery(query: string) {
console.log('执行查询:', query); // 查询日志
try {
const resultSet = await clickhouse.query({
query,
format: 'JSONEachRow'
format: 'JSONEachRow',
});
const rows = await resultSet.json<T>();
return Array.isArray(rows) ? rows : [rows];
const rows = await resultSet.json();
return rows;
} catch (error) {
console.error('ClickHouse query error:', error);
console.error('查询执行错误:', error);
throw error;
}
}
// 执行查询并返回单结果
export async function executeQuerySingle<T>(query: string): Promise<T | null> {
const results = await executeQuery<T>(query);
return results.length > 0 ? results[0] : null;
// 执行返回单结果的查询
export async function executeQuerySingle(query: string) {
console.log('执行单一结果查询:', query); // 查询日志
try {
const resultSet = await clickhouse.query({
query,
format: 'JSONEachRow',
});
const rows = await resultSet.json();
return rows.length > 0 ? rows[0] : null;
} catch (error) {
console.error('单一结果查询执行错误:', error);
throw error;
}
}
export default clickhouse;

View File

@@ -24,6 +24,16 @@ export enum DeviceType {
OTHER = 'other'
}
// 标签类型
export interface Tag {
id: string;
name: string;
color?: string;
type?: string;
attributes?: Record<string, any>;
team_id?: string;
}
// API 响应基础接口
export interface ApiResponse<T> {
success: boolean;
@@ -45,7 +55,10 @@ export interface EventsQueryParams {
linkSlug?: string;
userId?: string;
teamId?: string;
teamIds?: string[]; // 团队ID数组支持多选
projectId?: string;
projectIds?: string[]; // 项目ID数组支持多选
tagIds?: string[]; // 标签ID数组支持多选
page?: number;
pageSize?: number;
sortBy?: string;

View File

@@ -24,6 +24,7 @@
},
"dependencies": {
"@clickhouse/client": "^1.11.0",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/themes": "^3.2.1",

12
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@clickhouse/client':
specifier: ^1.11.0
version: 1.11.0
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2(react@19.0.0)
'@radix-ui/react-popover':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -657,6 +660,11 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-icons@1.3.2':
resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==}
peerDependencies:
react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc
'@radix-ui/react-id@1.1.0':
resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==}
peerDependencies:
@@ -3531,6 +3539,10 @@ snapshots:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-icons@1.3.2(react@19.0.0)':
dependencies:
react: 19.0.0
'@radix-ui/react-id@1.1.0(@types/react@19.0.12)(react@19.0.0)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.12)(react@19.0.0)

1
types/react-simple-maps.d.ts vendored Normal file
View File

@@ -0,0 +1 @@