Compare commits
24 Commits
1978e0224e
...
feature/a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b41f3ea42 | |||
| 63f434fd93 | |||
| 95f230b996 | |||
| 0f8419778c | |||
| a6f7172ec4 | |||
| 8054b0235d | |||
| b0dbd088e7 | |||
| bf7c62fdc9 | |||
| 9cb9f62686 | |||
| 4b7fb7a887 | |||
| bdae5c164c | |||
| 9fa61ccf8d | |||
| b187bdefdf | |||
| 87c3803236 | |||
| 75adb36111 | |||
| a4ef2c3147 | |||
| 57e16144a9 | |||
| 1be6a6dbf0 | |||
| 36f22059e9 | |||
| a8d364be1f | |||
| 326a6c6d63 | |||
| 0a881fd180 | |||
| 1b901bda90 | |||
| 53822f1087 |
@@ -1,86 +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 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
ShortURL Analytics
|
||||
</Link>
|
||||
<div className="hidden md:block ml-10">
|
||||
<div className="flex items-baseline space-x-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Analytics
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/geo"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Geographic
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/devices"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{user && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="py-10">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 dark:bg-gray-700 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>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +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';
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
// 默认日期范围为最近7天
|
||||
const today = new Date();
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: subDays(today, 7), // 7天前
|
||||
to: today // 今天
|
||||
});
|
||||
|
||||
// 添加团队选择状态
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>();
|
||||
|
||||
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 dark:text-gray-100">Analytics</h1>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<TeamSelector
|
||||
value={selectedTeamId}
|
||||
onChange={setSelectedTeamId}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 如果没有选择团队,显示提示信息 */}
|
||||
{!selectedTeamId && (
|
||||
<div className="flex items-center justify-center p-8 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Please select a team to view analytics
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 如果选择了团队,这里可以显示团队相关的分析数据 */}
|
||||
{selectedTeamId && (
|
||||
<div className="space-y-6">
|
||||
{/* 这里添加实际的分析数据组件 */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 dark:text-gray-100">Analytics Dashboard</h1>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Events</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Unique Visitors</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Conversions</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Avg. Time Spent</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{summary.averageTimeSpent?.toFixed(1) || '0'}s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Event Trends</h2>
|
||||
<div className="h-96">
|
||||
<TimeSeriesChart data={timeSeriesData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Device Analytics</h2>
|
||||
{deviceData && <DeviceAnalytics data={deviceData} />}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Geographic Distribution</h2>
|
||||
<GeoAnalytics data={geoData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { addDays, format } from 'date-fns';
|
||||
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
||||
import { Event } from '@/app/api/types';
|
||||
|
||||
export default function EventsPage() {
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date('2024-02-01'),
|
||||
to: new Date('2025-03-05')
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [filter, setFilter] = useState({
|
||||
eventType: '',
|
||||
linkId: '',
|
||||
linkSlug: ''
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
startTime: format(new Date('2024-02-01'), "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||
endTime: format(new Date('2025-03-05'), "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
const [summary, setSummary] = useState<any>(null);
|
||||
|
||||
const fetchEvents = async (pageNum: number) => {
|
||||
try {
|
||||
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
const endTime = format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
startTime,
|
||||
endTime,
|
||||
page: pageNum.toString(),
|
||||
pageSize: '50'
|
||||
});
|
||||
|
||||
if (filter.eventType) params.append('eventType', filter.eventType);
|
||||
if (filter.linkId) params.append('linkId', filter.linkId);
|
||||
if (filter.linkSlug) params.append('linkSlug', filter.linkSlug);
|
||||
|
||||
const response = await fetch(`/api/events?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch events');
|
||||
}
|
||||
|
||||
const eventsData = data.data || data.events || [];
|
||||
|
||||
if (pageNum === 1) {
|
||||
setEvents(eventsData);
|
||||
} else {
|
||||
setEvents(prev => [...prev, ...eventsData]);
|
||||
}
|
||||
|
||||
setHasMore(Array.isArray(eventsData) && eventsData.length === 50);
|
||||
} catch (err) {
|
||||
console.error("Error fetching events:", err);
|
||||
setError(err instanceof Error ? err.message : 'An error occurred while fetching events');
|
||||
setEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setEvents([]);
|
||||
setLoading(true);
|
||||
fetchEvents(1);
|
||||
}, [dateRange, filter]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading && hasMore) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchEvents(nextPage);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Events</h1>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Event Type
|
||||
</label>
|
||||
<select
|
||||
value={filter.eventType}
|
||||
onChange={e => setFilter(prev => ({ ...prev, eventType: e.target.value }))}
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="click">Click</option>
|
||||
<option value="conversion">Conversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Link ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.linkId}
|
||||
onChange={e => setFilter(prev => ({ ...prev, linkId: e.target.value }))}
|
||||
placeholder="Enter Link ID"
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Link Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.linkSlug}
|
||||
onChange={e => setFilter(prev => ({ ...prev, linkSlug: e.target.value }))}
|
||||
placeholder="Enter Link Slug"
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Link
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Visitor
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Referrer
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Conversion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{Array.isArray(events) && events.map((event, index) => (
|
||||
<tr key={event.event_id || index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{event.event_time && formatDate(event.event_time)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
event.event_type === 'conversion' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' : 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100'
|
||||
}`}>
|
||||
{event.event_type || 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div className="font-medium">{event.link_slug || '-'}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.link_original_url || '-'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div>{event.browser || '-'}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.os || '-'} / {event.device_type || '-'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div>{event.city || '-'}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.country || '-'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{event.referrer || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div>{event.conversion_type || '-'}</div>
|
||||
{event.conversion_value > 0 && (
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">Value: {event.conversion_value}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center p-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div className="flex justify-center p-4">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && Array.isArray(events) && events.length === 0 && (
|
||||
<div className="flex justify-center p-8 text-gray-500 dark:text-gray-400">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto py-16">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-8">
|
||||
Welcome to ShortURL Analytics
|
||||
</h1>
|
||||
<div className="grid gap-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Dashboard
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
View your overall analytics and key metrics
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Events
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Track and analyze event data
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/geo"
|
||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Geographic Analysis
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Explore visitor locations and geographic patterns
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/devices"
|
||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Device Analytics
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Understand how users access your links
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Dashboard
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
Get an overview of all your short URL analytics data.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/events"
|
||||
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Event Tracking
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
View detailed events for all your short URLs.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/analytics"
|
||||
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
URL Analysis
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
Analyze performance of specific short URLs.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/account"
|
||||
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Account Settings
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
Manage your account and team settings.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
248
app/api/geo/batch/route.ts
Normal 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()
|
||||
};
|
||||
}
|
||||
@@ -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
111
app/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { DeviceAnalytics as DeviceAnalyticsType } from '@/app/api/types';
|
||||
import { Chart, PieController, ArcElement, Tooltip, Legend } from 'chart.js';
|
||||
|
||||
interface CategoryItem {
|
||||
name: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface DeviceAnalyticsProps {
|
||||
data: DeviceAnalyticsType;
|
||||
}
|
||||
|
||||
function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) {
|
||||
// 安全地格式化数字
|
||||
const formatNumber = (value: number | string | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||
};
|
||||
|
||||
// 安全地格式化百分比
|
||||
const formatPercent = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return value.toFixed(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{title}</h3>
|
||||
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
|
||||
const renderCategory = (items: CategoryItem[], title: string) => (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||
<div className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span>{item.name || 'Unknown'}</span>
|
||||
<span>{formatNumber(item.count)} ({formatPercent(item.percentage)}%)</span>
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>{item.name}</span>
|
||||
<span>{item.percentage.toFixed(1)}% ({item.count})</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage || 0}%` }}
|
||||
/>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
|
||||
// Prepare device types data
|
||||
const deviceItems = data.deviceTypes.map(item => ({
|
||||
name: item.type || 'Unknown',
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<StatCard
|
||||
title="Device Types"
|
||||
items={(data.deviceTypes || []).map(item => ({
|
||||
name: item.type ? (item.type.charAt(0).toUpperCase() + item.type.slice(1)) : 'Unknown',
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
}))}
|
||||
/>
|
||||
<StatCard
|
||||
title="Browsers"
|
||||
items={data.browsers || []}
|
||||
/>
|
||||
<StatCard
|
||||
title="Operating Systems"
|
||||
items={data.operatingSystems || []}
|
||||
/>
|
||||
{renderCategory(deviceItems, 'Device Types')}
|
||||
{renderCategory(data.browsers, 'Browsers')}
|
||||
{renderCategory(data.operatingSystems, 'Operating Systems')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { GeoData } from '@/app/api/types';
|
||||
import { Chart, PieController, ArcElement, Tooltip, Legend } from 'chart.js';
|
||||
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: any): string => {
|
||||
const formatNumber = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
// 安全地格式化百分比
|
||||
const formatPercent = (value: any): string => {
|
||||
const formatPercent = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toFixed(2) : String(value);
|
||||
return value.toFixed(1);
|
||||
};
|
||||
|
||||
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 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Visits
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Unique Visitors
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Percentage
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.map((item, index) => (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country || item.location || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatNumber(item.visits)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatNumber(item.uniqueVisitors || item.visitors)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{formatPercent(item.percentage)}%</span>
|
||||
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
211
app/components/charts/DevicePieCharts.tsx
Normal file
211
app/components/charts/DevicePieCharts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
app/components/ipLocationTest.tsx
Normal file
100
app/components/ipLocationTest.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
app/components/layout/Header.tsx
Normal file
51
app/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
onChange: (range: { from: Date; to: Date }) => void;
|
||||
interface DateRange {
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
||||
const [from, setFrom] = useState(format(value.from, 'yyyy-MM-dd'));
|
||||
const [to, setTo] = useState(format(value.to, 'yyyy-MM-dd'));
|
||||
interface DateRangePickerProps {
|
||||
value: DateRange;
|
||||
onChange: (value: DateRange) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFrom(format(value.from, 'yyyy-MM-dd'));
|
||||
setTo(format(value.to, 'yyyy-MM-dd'));
|
||||
}, [value]);
|
||||
export function DateRangePicker({
|
||||
value,
|
||||
onChange,
|
||||
className
|
||||
}: DateRangePickerProps) {
|
||||
// Internal date state for validation
|
||||
const [from, setFrom] = useState<string>(
|
||||
value.from ? format(value.from, 'yyyy-MM-dd') : ''
|
||||
);
|
||||
const [to, setTo] = useState<string>(
|
||||
value.to ? format(value.to, 'yyyy-MM-dd') : ''
|
||||
);
|
||||
|
||||
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFrom = e.target.value;
|
||||
setFrom(newFrom);
|
||||
onChange({
|
||||
from: new Date(newFrom),
|
||||
to: value.to
|
||||
});
|
||||
|
||||
if (newFrom) {
|
||||
onChange({
|
||||
from: new Date(newFrom),
|
||||
to: value.to
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTo = e.target.value;
|
||||
setTo(newTo);
|
||||
onChange({
|
||||
from: value.from,
|
||||
to: new Date(newTo)
|
||||
});
|
||||
|
||||
if (newTo) {
|
||||
onChange({
|
||||
from: value.from,
|
||||
to: new Date(newTo)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-4 ${className}`}>
|
||||
<div>
|
||||
<label htmlFor="from" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
From
|
||||
<label htmlFor="from" className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -50,12 +63,12 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
||||
value={from}
|
||||
onChange={handleFromChange}
|
||||
max={to}
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="to" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
To
|
||||
<label htmlFor="to" className="block text-sm font-medium text-gray-500 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -63,7 +76,7 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
||||
value={to}
|
||||
onChange={handleToChange}
|
||||
min={from}
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
363
app/components/ui/ProjectSelector.tsx
Normal file
363
app/components/ui/ProjectSelector.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { getSupabaseClient } from '../../utils/supabase';
|
||||
import { AuthChangeEvent, Session } from '@supabase/supabase-js';
|
||||
import { Loader2, X, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Define our own Project type
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
attributes?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
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
|
||||
export function ProjectSelector({
|
||||
value,
|
||||
onChange,
|
||||
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);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
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) {
|
||||
if (Array.isArray(value)) {
|
||||
setSelectedIds(value);
|
||||
} else {
|
||||
setSelectedIds(value ? [value] : []);
|
||||
}
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Add click outside listener to close dropdown
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the event listener if the dropdown is open
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchProjects = async (userId: string) => {
|
||||
if (!isMounted) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
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(*), 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 {
|
||||
// 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);
|
||||
|
||||
if (projectsError) throw projectsError;
|
||||
|
||||
if (!projectsData || projectsData.length === 0) {
|
||||
if (isMounted) setProjects([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch team info for these projects
|
||||
const projectIds = projectsData.map(item => item.project_id);
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load projects');
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
|
||||
if (event === 'SIGNED_IN' && session?.user?.id) {
|
||||
fetchProjects(session.user.id);
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setProjects([]);
|
||||
setError(null);
|
||||
}
|
||||
});
|
||||
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (session?.user?.id) {
|
||||
fetchProjects(session.user.id);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [effectiveTeamIds]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!loading && !error && projects.length > 0) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectSelect = (projectId: string) => {
|
||||
let newSelected: string[];
|
||||
|
||||
if (multiple) {
|
||||
// For multi-select: toggle project in/out of selection
|
||||
if (selectedIds.includes(projectId)) {
|
||||
newSelected = selectedIds.filter(id => id !== projectId);
|
||||
} else {
|
||||
newSelected = [...selectedIds, projectId];
|
||||
}
|
||||
} else {
|
||||
// For single-select: replace selection with the new project
|
||||
newSelected = [projectId];
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
setSelectedIds(newSelected);
|
||||
|
||||
if (onChange) {
|
||||
onChange(multiple ? newSelected : newSelected[0] || '');
|
||||
}
|
||||
};
|
||||
|
||||
const removeProject = (e: React.MouseEvent, projectId: string) => {
|
||||
e.stopPropagation();
|
||||
const newSelected = selectedIds.filter(id => id !== projectId);
|
||||
setSelectedIds(newSelected);
|
||||
if (onChange) {
|
||||
onChange(multiple ? newSelected : newSelected[0] || '');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center justify-between rounded-md border px-3 py-2",
|
||||
className
|
||||
)}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive",
|
||||
className
|
||||
)}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
|
||||
className
|
||||
)}>
|
||||
No projects available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedProjects = projects.filter(project => selectedIds.includes(project.id));
|
||||
|
||||
return (
|
||||
<div className="relative" ref={selectorRef}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-h-10 items-center flex-wrap rounded-md border p-1 cursor-pointer",
|
||||
isOpen && "ring-2 ring-offset-2 ring-blue-500",
|
||||
className
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{selectedProjects.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedProjects.map(project => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center gap-1 bg-green-100 text-green-800 rounded-md px-2 py-1 text-sm"
|
||||
>
|
||||
{project.name}
|
||||
{multiple && (
|
||||
<X
|
||||
size={14}
|
||||
className="cursor-pointer hover:text-green-900"
|
||||
onClick={(e) => removeProject(e, project.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-1 text-gray-500">Select a project</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
|
||||
{projects.map(project => (
|
||||
<div
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
|
||||
selectedIds.includes(project.id) && "bg-green-50"
|
||||
)}
|
||||
onClick={() => handleProjectSelect(project.id)}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{selectedIds.includes(project.id) && (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
321
app/components/ui/TagSelector.tsx
Normal file
321
app/components/ui/TagSelector.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { getSupabaseClient } from '../../utils/supabase';
|
||||
import { AuthChangeEvent } from '@supabase/supabase-js';
|
||||
import { Loader2, X, Check, Tag } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Define Tag type based on the database schema
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string | null;
|
||||
attributes?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
deleted_at?: string | null;
|
||||
parent_tag_id?: string | null;
|
||||
team_id?: string | null;
|
||||
is_shared?: boolean;
|
||||
schema_version?: number | null;
|
||||
is_system?: boolean;
|
||||
}
|
||||
|
||||
// TagSelector component with multi-select support
|
||||
export function TagSelector({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
multiple = false,
|
||||
teamId,
|
||||
teamIds,
|
||||
}: {
|
||||
value?: string | string[];
|
||||
onChange?: (tagIds: string | string[]) => void;
|
||||
className?: string;
|
||||
multiple?: boolean;
|
||||
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);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
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]);
|
||||
|
||||
// 标签名称与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(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the event listener if the dropdown is open
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchTags = async () => {
|
||||
if (!isMounted) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
let query = supabase.from('tags').select('*').is('deleted_at', null);
|
||||
|
||||
// Filter by team if teamId is provided
|
||||
if (effectiveTeamIds) {
|
||||
query = query.in('team_id', effectiveTeamIds);
|
||||
}
|
||||
|
||||
const { data: tagsData, error: tagsError } = await query;
|
||||
|
||||
if (tagsError) throw tagsError;
|
||||
|
||||
if (!tagsData || tagsData.length === 0) {
|
||||
if (isMounted) setTags([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
setTags(tagsData as Tag[]);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load tags');
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => {
|
||||
if (event === 'SIGNED_IN') {
|
||||
fetchTags();
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setTags([]);
|
||||
setError(null);
|
||||
}
|
||||
});
|
||||
|
||||
supabase.auth.getSession().then(() => {
|
||||
fetchTags();
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [effectiveTeamIds]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!loading && !error && tags.length > 0) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagSelect = (tagId: string) => {
|
||||
let newSelected: string[];
|
||||
|
||||
if (multiple) {
|
||||
// For multi-select: toggle tag in/out of selection
|
||||
if (selectedIds.includes(tagId)) {
|
||||
newSelected = selectedIds.filter(id => id !== tagId);
|
||||
} else {
|
||||
newSelected = [...selectedIds, tagId];
|
||||
}
|
||||
} else {
|
||||
// For single-select: replace selection with the new tag
|
||||
newSelected = [tagId];
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
setSelectedIds(newSelected);
|
||||
|
||||
// 传递标签名称而不是ID
|
||||
if (onChange) {
|
||||
const tagNames = idToName(newSelected);
|
||||
onChange(multiple ? tagNames : tagNames[0] || '');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTag = (e: React.MouseEvent, tagId: string) => {
|
||||
e.stopPropagation();
|
||||
const newSelected = selectedIds.filter(id => id !== tagId);
|
||||
setSelectedIds(newSelected);
|
||||
|
||||
// 传递标签名称而不是ID
|
||||
if (onChange) {
|
||||
const tagNames = idToName(newSelected);
|
||||
onChange(multiple ? tagNames : tagNames[0] || '');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center justify-between rounded-md border px-3 py-2",
|
||||
className
|
||||
)}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive",
|
||||
className
|
||||
)}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (tags.length === 0) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
|
||||
className
|
||||
)}>
|
||||
No tags available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 根据已选择的ID筛选出已选择的标签
|
||||
const selectedTags = tags.filter(tag => selectedIds.includes(tag.id));
|
||||
|
||||
return (
|
||||
<div className="relative" ref={selectorRef}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-h-10 items-center flex-wrap rounded-md border p-1 cursor-pointer",
|
||||
isOpen && "ring-2 ring-offset-2 ring-purple-500",
|
||||
className
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{selectedTags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTags.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center gap-1 bg-purple-100 text-purple-800 rounded-md px-2 py-1 text-sm"
|
||||
>
|
||||
{tag.name}
|
||||
{multiple && (
|
||||
<X
|
||||
size={14}
|
||||
className="cursor-pointer hover:text-purple-900"
|
||||
onClick={(e) => removeTag(e, tag.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-1 text-gray-500">Select tags</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
|
||||
{tags.map(tag => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={cn(
|
||||
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
|
||||
selectedIds.includes(tag.id) && "bg-purple-50"
|
||||
)}
|
||||
onClick={() => handleTagSelect(tag.id)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-purple-500" />
|
||||
<span>{tag.name}</span>
|
||||
{tag.type && (
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{tag.type}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{selectedIds.includes(tag.id) && (
|
||||
<Check className="h-4 w-4 text-purple-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import type { Database } from '@/types/supabase';
|
||||
import { Select } from './Select';
|
||||
import { getSupabaseClient } from '../../utils/supabase';
|
||||
import { AuthChangeEvent, Session } from '@supabase/supabase-js';
|
||||
import { Loader2, X, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Team = Database['limq']['Tables']['teams']['Row'];
|
||||
|
||||
interface TeamSelectorProps {
|
||||
value?: string;
|
||||
onChange?: (teamId: string) => void;
|
||||
// TeamSelector component with multi-select support
|
||||
export function TeamSelector({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
multiple = false,
|
||||
}: {
|
||||
value?: string | string[];
|
||||
onChange?: (teamId: string | string[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TeamSelector({ value, onChange, className }: TeamSelectorProps) {
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
multiple?: boolean;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const selectorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initialize selected teams based on value prop
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
if (Array.isArray(value)) {
|
||||
setSelectedIds(value);
|
||||
} else {
|
||||
setSelectedIds(value ? [value] : []);
|
||||
}
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Add click outside listener to close dropdown
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the event listener if the dropdown is open
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchTeams = async (userId: string) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
console.log('Starting to fetch teams for user:', userId);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
// 获取用户的团队成员关系
|
||||
console.log('Fetching team memberships for user:', userId);
|
||||
|
||||
const { data: memberships, error: membershipError } = await supabase
|
||||
.from('team_membership')
|
||||
.select('team_id')
|
||||
.eq('user_id', userId);
|
||||
|
||||
console.log('Team memberships result:', { memberships, membershipError });
|
||||
|
||||
if (membershipError) {
|
||||
console.error('Membership error:', membershipError);
|
||||
throw membershipError;
|
||||
}
|
||||
if (membershipError) throw membershipError;
|
||||
|
||||
if (!memberships || memberships.length === 0) {
|
||||
console.log('No team memberships found');
|
||||
if (isMounted) {
|
||||
setTeams([]);
|
||||
}
|
||||
if (isMounted) setTeams([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取团队详细信息
|
||||
const teamIds = memberships.map(m => m.team_id);
|
||||
console.log('Fetching teams with IDs:', teamIds);
|
||||
const { data: teamsData, error: teamsError } = await supabase
|
||||
.from('teams')
|
||||
.select('*')
|
||||
.in('id', teamIds)
|
||||
.is('deleted_at', null);
|
||||
|
||||
console.log('Teams result:', { teamsData, teamsError });
|
||||
|
||||
if (teamsError) {
|
||||
console.error('Teams error:', teamsError);
|
||||
throw teamsError;
|
||||
}
|
||||
if (teamsError) throw teamsError;
|
||||
|
||||
if (isMounted && teamsData) {
|
||||
setTeams(teamsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching teams:', err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load teams');
|
||||
}
|
||||
@@ -86,10 +106,8 @@ export function TeamSelector({ value, onChange, className }: TeamSelectorProps)
|
||||
};
|
||||
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
// 设置认证状态监听器
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
|
||||
console.log('Auth state changed:', event, session?.user);
|
||||
if (event === 'SIGNED_IN' && session?.user?.id) {
|
||||
fetchTeams(session.user.id);
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
@@ -98,9 +116,7 @@ export function TeamSelector({ value, onChange, className }: TeamSelectorProps)
|
||||
}
|
||||
});
|
||||
|
||||
// 初始检查session
|
||||
supabase.auth.getSession().then(({ data: { session } }: { data: { session: Session | null } }) => {
|
||||
console.log('Initial session check:', session?.user);
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
if (session?.user?.id) {
|
||||
fetchTeams(session.user.id);
|
||||
}
|
||||
@@ -112,29 +128,131 @@ export function TeamSelector({ value, onChange, className }: TeamSelectorProps)
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!loading && !error && teams.length > 0) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTeamSelect = (teamId: string) => {
|
||||
let newSelected: string[];
|
||||
|
||||
if (multiple) {
|
||||
// For multi-select: toggle team in/out of selection
|
||||
if (selectedIds.includes(teamId)) {
|
||||
newSelected = selectedIds.filter(id => id !== teamId);
|
||||
} else {
|
||||
newSelected = [...selectedIds, teamId];
|
||||
}
|
||||
} else {
|
||||
// For single-select: replace selection with the new team
|
||||
newSelected = [teamId];
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
setSelectedIds(newSelected);
|
||||
|
||||
if (onChange) {
|
||||
onChange(multiple ? newSelected : newSelected[0] || '');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTeam = (e: React.MouseEvent, teamId: string) => {
|
||||
e.stopPropagation();
|
||||
const newSelected = selectedIds.filter(id => id !== teamId);
|
||||
setSelectedIds(newSelected);
|
||||
if (onChange) {
|
||||
onChange(multiple ? newSelected : newSelected[0] || '');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse h-10 bg-gray-200 rounded-md dark:bg-gray-700" />;
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center justify-between rounded-md border px-3 py-2",
|
||||
className
|
||||
)}>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500 text-sm">{error}</div>;
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive",
|
||||
className
|
||||
)}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (teams.length === 0) {
|
||||
return <div className="text-sm text-gray-500">No teams available</div>;
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
|
||||
className
|
||||
)}>
|
||||
No teams available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedTeams = teams.filter(team => selectedIds.includes(team.id));
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(newValue: string) => onChange?.(newValue)}
|
||||
options={teams.map(team => ({
|
||||
value: team.id,
|
||||
label: team.name,
|
||||
icon: team.avatar_url || undefined
|
||||
}))}
|
||||
placeholder="Select a team"
|
||||
/>
|
||||
<div className="relative" ref={selectorRef}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-h-10 items-center flex-wrap rounded-md border p-1 cursor-pointer",
|
||||
isOpen && "ring-2 ring-offset-2 ring-blue-500",
|
||||
className
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{selectedTeams.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTeams.map(team => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="flex items-center gap-1 bg-blue-100 text-blue-800 rounded-md px-2 py-1 text-sm"
|
||||
>
|
||||
{team.name}
|
||||
{multiple && (
|
||||
<X
|
||||
size={14}
|
||||
className="cursor-pointer hover:text-blue-900"
|
||||
onClick={(e) => removeTeam(e, team.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-1 text-gray-500">Select a team</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
|
||||
{teams.map(team => (
|
||||
<div
|
||||
key={team.id}
|
||||
className={cn(
|
||||
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
|
||||
selectedIds.includes(team.id) && "bg-blue-50"
|
||||
)}
|
||||
onClick={() => handleTeamSelect(team.id)}
|
||||
>
|
||||
<span>{team.name}</span>
|
||||
{selectedIds.includes(team.id) && (
|
||||
<Check className="h-4 w-4 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/ip-test/page.tsx
Normal file
10
app/ip-test/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import './globals.css';
|
||||
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',
|
||||
@@ -15,9 +18,12 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
<Theme>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</Theme>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,249 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useState, FormEvent, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import supabase from '@/lib/supabase';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { signIn, signInWithGitHub, signInWithGoogle, user } = useAuth();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [serverInfo, setServerInfo] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { signIn, signInWithGoogle, user, autoRegisterTestUser } = useAuth();
|
||||
|
||||
// 从URL参数中获取消息
|
||||
const [message, setMessage] = useState({ type: '', content: '' });
|
||||
|
||||
// 如果用户已登录,重定向到首页
|
||||
useEffect(() => {
|
||||
const message = searchParams.get('message');
|
||||
if (message) {
|
||||
setMessage(message);
|
||||
}
|
||||
|
||||
// 如果用户已登录,跳转到dashboard
|
||||
if (user) {
|
||||
router.push('/dashboard');
|
||||
router.push('/');
|
||||
}
|
||||
|
||||
// 检查服务器状态
|
||||
const checkServerStatus = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
if (error) {
|
||||
console.log('Session check error:', error);
|
||||
setServerInfo(`服务器连接状态: 错误 (${error.message})`);
|
||||
} else {
|
||||
console.log('Session check success:', data);
|
||||
setServerInfo('服务器连接状态: 正常');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server check error:', error);
|
||||
setServerInfo('服务器连接状态: 无法连接');
|
||||
}
|
||||
};
|
||||
|
||||
checkServerStatus();
|
||||
}, [searchParams, user, router]);
|
||||
|
||||
// 处理登录表单提交
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
}, [user, router]);
|
||||
|
||||
const handleEmailSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
console.log('Attempting to sign in with:', { email, password });
|
||||
await signIn(email, password);
|
||||
// 登录成功后会自动跳转到dashboard
|
||||
} catch (error: unknown) {
|
||||
console.error('Login error:', error);
|
||||
// 显示更详细的错误信息
|
||||
if (error instanceof Error) {
|
||||
setError(`登录失败: ${error.message}`);
|
||||
} else {
|
||||
setError('登录失败,请检查邮箱和密码是否正确');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
if (!email || !password) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
content: 'Please enter both email and password'
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理Google登录
|
||||
const handleGoogleSignIn = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
// 登录流程会重定向到Google,然后回到应用
|
||||
} catch (error: unknown) {
|
||||
console.error('Google sign in error:', error);
|
||||
if (error instanceof Error) {
|
||||
setError(`Google登录失败: ${error.message}`);
|
||||
} else {
|
||||
setError('Google登录失败,请稍后再试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 使用测试账户
|
||||
const handleUseTestAccount = async () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 使用AuthContext中的自动注册测试账户功能
|
||||
await autoRegisterTestUser();
|
||||
setIsLoading(true);
|
||||
setMessage({ type: '', content: '' });
|
||||
|
||||
const { error } = await signIn(email, password);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 登录成功后,会通过 useEffect 重定向
|
||||
} catch (error) {
|
||||
console.error('测试账户处理错误:', error);
|
||||
setError(error instanceof Error ? error.message : '测试账户创建失败');
|
||||
} finally {
|
||||
console.error('Login error:', error);
|
||||
setMessage({
|
||||
type: 'error',
|
||||
content: error instanceof Error ? error.message : 'Failed to sign in'
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleGitHubSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setMessage({ type: '', content: '' });
|
||||
|
||||
const { error } = await signInWithGitHub();
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 登录成功后,会通过 useEffect 重定向
|
||||
} catch (error) {
|
||||
console.error('GitHub login error:', error);
|
||||
setMessage({
|
||||
type: 'error',
|
||||
content: error instanceof Error ? error.message : 'Failed to sign in with GitHub'
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setMessage({ type: '', content: '' });
|
||||
|
||||
const { error } = await signInWithGoogle();
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 登录成功后,会通过 useEffect 重定向
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error);
|
||||
setMessage({
|
||||
type: 'error',
|
||||
content: error instanceof Error ? error.message : 'Failed to sign in with Google'
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="w-full max-w-md p-8 space-y-8 bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100">
|
||||
<div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">登录</h1>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
登录您的账户以访问分析面板
|
||||
<h1 className="text-2xl font-bold text-gray-900">Login</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Sign in to your account to access analytics
|
||||
</p>
|
||||
{serverInfo && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{serverInfo}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Welcome to ShortURL Analytics
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息提示 */}
|
||||
{message && (
|
||||
<div className="p-4 mb-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg">
|
||||
{message}
|
||||
|
||||
{/* Message display */}
|
||||
{message.content && (
|
||||
<div className={`p-4 mb-4 text-sm ${
|
||||
message.type === 'error'
|
||||
? 'text-red-700 bg-red-100 rounded-lg'
|
||||
: 'text-blue-700 bg-blue-100 rounded-lg'
|
||||
}`}>
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="p-4 mb-4 text-sm text-red-700 bg-red-100 dark:bg-red-900 dark:text-red-200 rounded-lg">
|
||||
{error}
|
||||
|
||||
<form onSubmit={handleEmailSignIn} className="mt-8 space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="your@email.com"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
邮箱地址
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 dark:bg-gray-700 dark:text-white focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="********"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
{isLoading ? '登录中...' : '登录'}
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600"></div>
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400">或</span>
|
||||
<span className="px-2 bg-white text-gray-500">Or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
className="flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
onClick={handleGitHubSignIn}
|
||||
disabled={isLoading}
|
||||
className="flex justify-center items-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
|
||||
/>
|
||||
</g>
|
||||
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Google登录
|
||||
GitHub
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUseTestAccount}
|
||||
className="flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isLoading}
|
||||
className="flex justify-center items-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
使用测试账号
|
||||
<svg className="h-5 w-5 mr-2" viewBox="0 0 24 24">
|
||||
<path d="M12.545 12.151L12.545 12.151L12.545 12.151C12.545 9.85553 14.0905 7.98375 16.088 7.98375C17.0865 7.98375 17.938 8.43025 18.5592 9.0514L21.3404 6.27019C19.7172 4.75612 18.0026 4 16.088 4C12.5405 4 9.5 6.67528 9.5 10.2505C9.5 12.0582 10.1533 13.4581 10.8634 14.4685C12.1453 16.3618 14.4737 18.501 16.088 18.501C19.9265 18.501 22 16.0057 22 12.4071C22 11.4245 21.9318 10.9113 21.7953 10.2505H16.088V12.151H12.545Z" fill="#4285F4" />
|
||||
<path d="M5.90607 10.2197C5.40834 11.1993 5.12343 12.2959 5.12343 13.4564C5.12343 14.6646 5.41958 15.782 5.92853 16.7831L5.92786 16.7818C6.91998 18.6136 8.81431 19.8018 11.0008 19.8018C12.5581 19.8018 13.8262 19.318 14.7997 18.5825L14.7976 18.5845C15.6806 17.9139 16.401 16.9218 16.6662 15.7257L16.6657 15.7276C16.7331 15.3933 16.7688 15.0493 16.7688 14.6895H11.0008C10.3375 14.6895 9.80078 14.1523 9.80078 13.4882V10.2197H5.90607Z" fill="#34A853" />
|
||||
<path d="M5.12207 6.25024C4 7.86024 3.33789 9.81535 3.33789 11.9339C3.33789 12.9995 3.55215 14.0269 3.94853 14.9805L5.90673 10.2197H9.80143V6.25024H5.12207Z" fill="#FBBC05" />
|
||||
<path d="M11.001 3.57764C12.4571 3.57764 13.778 4.11181 14.8023 5.06959L14.8028 5.0692L17.2711 2.60092L17.271 2.60082C15.5041 0.97625 13.3649 0 11.001 0C8.81453 0 6.91994 1.18824 5.92853 3.02125L9.80224 6.25031V6.25031H11.001V3.57764Z" fill="#EA4335" />
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
还没有账号?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
898
app/page.tsx
898
app/page.tsx
@@ -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
484
app/utils/ipLocation.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
43
lib/auth.tsx
43
lib/auth.tsx
@@ -13,8 +13,9 @@ export type AuthContextType = {
|
||||
user: AuthUser;
|
||||
session: Session | null;
|
||||
isLoading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<void>;
|
||||
signInWithGoogle: () => Promise<void>;
|
||||
signIn: (email: string, password: string) => Promise<{ error?: any }>;
|
||||
signInWithGoogle: () => Promise<{ error?: any }>;
|
||||
signInWithGitHub: () => Promise<{ error?: any }>;
|
||||
signUp: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
autoRegisterTestUser: () => Promise<void>; // 添加自动注册测试用户函数
|
||||
@@ -84,15 +85,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
if (error) {
|
||||
console.error('登录出错:', error);
|
||||
throw error;
|
||||
return { error };
|
||||
}
|
||||
|
||||
setSession(data.session);
|
||||
setUser(data.user);
|
||||
router.push('/dashboard');
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('登录过程出错:', error);
|
||||
throw error;
|
||||
return { error };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -112,11 +114,39 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
if (error) {
|
||||
console.error('Google登录出错:', error);
|
||||
throw error;
|
||||
return { error };
|
||||
}
|
||||
|
||||
return {}; // Return empty object when successful
|
||||
} catch (error) {
|
||||
console.error('Google登录过程出错:', error);
|
||||
throw error;
|
||||
return { error };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// GitHub登录函数
|
||||
const signInWithGitHub = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 尝试通过Supabase登录GitHub
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('GitHub login error:', error);
|
||||
return { error };
|
||||
}
|
||||
|
||||
return {}; // Return empty object when successful
|
||||
} catch (error) {
|
||||
console.error('GitHub login process error:', error);
|
||||
return { error };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -203,6 +233,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
isLoading,
|
||||
signIn,
|
||||
signInWithGoogle,
|
||||
signInWithGitHub,
|
||||
signUp,
|
||||
signOut,
|
||||
autoRegisterTestUser,
|
||||
|
||||
@@ -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;
|
||||
13
lib/types.ts
13
lib/types.ts
@@ -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;
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
},
|
||||
"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",
|
||||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||
"@types/chart.js": "^2.9.41",
|
||||
"@types/recharts": "^1.8.29",
|
||||
|
||||
1012
pnpm-lock.yaml
generated
1012
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ const config: Config = {
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: false,
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
1
types/react-simple-maps.d.ts
vendored
Normal file
1
types/react-simple-maps.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user