mv folder
This commit is contained in:
309
app/(app)/analytics/devices/page.tsx
Normal file
309
app/(app)/analytics/devices/page.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { fetchData } from '@/app/api/utils';
|
||||
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: 'white'
|
||||
}
|
||||
},
|
||||
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: 'white'
|
||||
}
|
||||
},
|
||||
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: 'white'
|
||||
}
|
||||
},
|
||||
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-white">Device Analytics</h1>
|
||||
<p className="mt-2 text-white">Analyze visitor distribution by devices, browsers, and operating systems</p>
|
||||
</div>
|
||||
|
||||
{/* 时间范围选择器 */}
|
||||
<div className="bg-card-bg rounded-xl p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
|
||||
value={dateRange.from.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
|
||||
value={dateRange.to.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备类型分析 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||||
{/* 设备类型 */}
|
||||
<div className="bg-card-bg rounded-xl p-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4">Device Types</h3>
|
||||
{deviceData && deviceData.deviceTypes.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<canvas ref={deviceTypesChartRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-64 text-text-secondary">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 浏览器 */}
|
||||
<div className="bg-card-bg rounded-xl p-6">
|
||||
<h3 className="text-lg font-medium text-white 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-text-secondary">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作系统 */}
|
||||
<div className="bg-card-bg rounded-xl p-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4">Operating Systems</h3>
|
||||
{deviceData && deviceData.operatingSystems.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<canvas ref={osChartRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-64 text-text-secondary">
|
||||
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-text-secondary">
|
||||
<p>No device data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
app/(app)/analytics/geo/page.tsx
Normal file
137
app/(app)/analytics/geo/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { GeoData } from '../../api/types';
|
||||
|
||||
export default function GeoAnalyticsPage() {
|
||||
const [geoData, setGeoData] = useState<GeoData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date('2024-02-01'),
|
||||
to: new Date('2025-03-05')
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchGeoData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/events/geo?startTime=${dateRange.from.toISOString().split('T')[0]}T00:00:00Z&endTime=${dateRange.to.toISOString().split('T')[0]}T23:59:59Z`);
|
||||
if (!response.ok) throw new Error('Failed to fetch geographic data');
|
||||
|
||||
const data = await response.json();
|
||||
setGeoData(data.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGeoData();
|
||||
}, [dateRange]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white">Geographic Analysis</h1>
|
||||
<p className="mt-2 text-white">Analyze visitor distribution by location</p>
|
||||
</div>
|
||||
|
||||
{/* 时间范围选择器 */}
|
||||
<div className="bg-card-bg rounded-xl p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm "
|
||||
value={dateRange.from.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm "
|
||||
value={dateRange.to.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 地理数据表格 */}
|
||||
<div className="bg-card-bg rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-card-border">
|
||||
<thead>
|
||||
<tr className="bg-background/50">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Location</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Visits</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Unique Visitors</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-card-border">
|
||||
{geoData.map(item => (
|
||||
<tr key={item.location} className="hover:bg-background/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.location}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.visits}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.visitors}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-full bg-background rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white">{item.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="w-8 h-8 border-t-2 border-b-2 border-accent-blue rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{error && (
|
||||
<div className="flex justify-center items-center p-8 text-accent-red">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无数据状态 */}
|
||||
{!isLoading && !error && geoData.length === 0 && (
|
||||
<div className="flex justify-center items-center p-8 text-white">
|
||||
<p>No geographic data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="mt-4 text-sm text-white">
|
||||
<p>Note: Geographic data is based on IP addresses and may not be 100% accurate.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
app/(app)/dashboard/page.tsx
Normal file
140
app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { addDays, 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);
|
||||
setTimeSeriesData(timeSeriesData.data);
|
||||
setGeoData(geoData.data);
|
||||
setDeviceData(deviceData.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [dateRange]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Analytics Dashboard</h1>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Events</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Unique Visitors</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Conversions</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Avg. Time Spent</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{summary.averageTimeSpent?.toFixed(1) || '0'}s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Event Trends</h2>
|
||||
<div className="h-96">
|
||||
<TimeSeriesChart data={timeSeriesData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Device Analytics</h2>
|
||||
{deviceData && <DeviceAnalytics data={deviceData} />}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Geographic Distribution</h2>
|
||||
<GeoAnalytics data={geoData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
app/(app)/events/page.tsx
Normal file
257
app/(app)/events/page.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
66
app/(app)/layout.tsx
Normal file
66
app/(app)/layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import '../globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ShortURL Analytics',
|
||||
description: 'Analytics dashboard for ShortURL service',
|
||||
};
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={inter.className}>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
ShortURL Analytics
|
||||
</Link>
|
||||
<div className="hidden md:block ml-10">
|
||||
<div className="flex items-baseline space-x-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/geo"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Geographic
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/devices"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="py-10">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1688
app/(app)/links/[id]/page.tsx
Normal file
1688
app/(app)/links/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
548
app/(app)/links/page.tsx
Normal file
548
app/(app)/links/page.tsx
Normal file
@@ -0,0 +1,548 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import CreateLinkModal from '../components/ui/CreateLinkModal';
|
||||
import { Link, StatsOverview, Tag } from '../api/types';
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
59
app/(app)/page.tsx
Normal file
59
app/(app)/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
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">
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user