Compare commits
46 Commits
only_event
...
6940d60510
| Author | SHA1 | Date | |
|---|---|---|---|
| 6940d60510 | |||
| 4e7266240d | |||
| db70602e9f | |||
| d0e83f697b | |||
| ed327ad3f0 | |||
| f782dba0c9 | |||
| 0c4a67e769 | |||
| 694e005101 | |||
| 523e99a001 | |||
| 33dbf62665 | |||
| 1a9e28bd7e | |||
| d1d21948b6 | |||
| f32a45d24a | |||
| d61b8a62ff | |||
| 0b41f3ea42 | |||
| 63f434fd93 | |||
| 95f230b996 | |||
| 0f8419778c | |||
| a6f7172ec4 | |||
| 8054b0235d | |||
| b0dbd088e7 | |||
| bf7c62fdc9 | |||
| 9cb9f62686 | |||
| 4b7fb7a887 | |||
| bdae5c164c | |||
| 9fa61ccf8d | |||
| b187bdefdf | |||
| 87c3803236 | |||
| 75adb36111 | |||
| a4ef2c3147 | |||
| 57e16144a9 | |||
| 1be6a6dbf0 | |||
| 36f22059e9 | |||
| a8d364be1f | |||
| 326a6c6d63 | |||
| 0a881fd180 | |||
| 1b901bda90 | |||
| 53822f1087 | |||
| 1978e0224e | |||
| c0649ce10f | |||
| 696a434b95 | |||
| b8e6180212 | |||
| 6beb6c3666 | |||
| 17b588e249 | |||
| 26db8fe76d | |||
| 4ad505cda1 |
@@ -1,309 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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-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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 '../globals.css';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Inter } from 'next/font/google';
|
import { Sidebar } from '@/app/components/Sidebar';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'ShortURL Analytics',
|
title: 'ShortURL Analytics',
|
||||||
description: 'Analytics dashboard for ShortURL service',
|
description: 'Analytics for your shortened URLs',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppLayout({
|
export default function AppLayout({
|
||||||
@@ -16,48 +13,13 @@ export default function AppLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={inter.className}>
|
<div className="flex h-screen bg-gray-50">
|
||||||
<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">
|
<Sidebar />
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<div className="flex items-center justify-between h-16">
|
{/* 主内容区域 */}
|
||||||
<div className="flex items-center">
|
<div className="flex-1 flex flex-col overflow-auto">
|
||||||
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
<main className="flex-1 overflow-y-auto">
|
||||||
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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,548 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +1,64 @@
|
|||||||
import Link from 'next/link';
|
export default function HomePage() {
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-2xl mx-auto py-16">
|
<div className="text-center">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8">
|
<h1 className="text-4xl font-bold text-gray-900 mb-8">
|
||||||
Welcome to ShortURL Analytics
|
Welcome to ShortURL Analytics
|
||||||
</h1>
|
</h1>
|
||||||
<div className="grid gap-6">
|
</div>
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
<a
|
||||||
>
|
href="/dashboard"
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||||
Dashboard
|
>
|
||||||
</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
Dashboard
|
||||||
View your overall analytics and key metrics
|
</h2>
|
||||||
</p>
|
|
||||||
</Link>
|
<p className="text-gray-600">
|
||||||
<Link
|
Get an overview of all your short URL analytics data.
|
||||||
href="/events"
|
</p>
|
||||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
</a>
|
||||||
>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<a
|
||||||
Events
|
href="/events"
|
||||||
</h2>
|
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
>
|
||||||
Track and analyze event data
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
</p>
|
Event Tracking
|
||||||
</Link>
|
</h2>
|
||||||
<Link
|
|
||||||
href="/analytics/geo"
|
<p className="text-gray-600">
|
||||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
View detailed events for all your short URLs.
|
||||||
>
|
</p>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
</a>
|
||||||
Geographic Analysis
|
|
||||||
</h2>
|
<a
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
href="/analytics"
|
||||||
Explore visitor locations and geographic patterns
|
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||||
</p>
|
>
|
||||||
</Link>
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
<Link
|
URL Analysis
|
||||||
href="/analytics/devices"
|
</h2>
|
||||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
|
||||||
>
|
<p className="text-gray-600">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
Analyze performance of specific short URLs.
|
||||||
Device Analytics
|
</p>
|
||||||
</h2>
|
</a>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Understand how users access your links
|
<a
|
||||||
</p>
|
href="/account"
|
||||||
</Link>
|
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||||
</div>
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,997 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import SwaggerUI from 'swagger-ui-react';
|
|
||||||
import 'swagger-ui-react/swagger-ui.css';
|
|
||||||
|
|
||||||
export default function SwaggerPage() {
|
|
||||||
useEffect(() => {
|
|
||||||
// 设置页面标题
|
|
||||||
document.title = 'API Documentation - ShortURL Analytics';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Swagger配置
|
|
||||||
const swaggerConfig = {
|
|
||||||
openapi: '3.0.0',
|
|
||||||
info: {
|
|
||||||
title: 'ShortURL Analytics API',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: 'API documentation for ShortURL Analytics service',
|
|
||||||
contact: {
|
|
||||||
name: 'API Support',
|
|
||||||
email: 'support@example.com',
|
|
||||||
},
|
|
||||||
license: {
|
|
||||||
name: 'MIT',
|
|
||||||
url: 'https://opensource.org/licenses/MIT',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: '/api',
|
|
||||||
description: 'API Server',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tags: [
|
|
||||||
{
|
|
||||||
name: 'events',
|
|
||||||
description: 'Event tracking and analytics endpoints',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
paths: {
|
|
||||||
'/events/track': {
|
|
||||||
post: {
|
|
||||||
tags: ['events'],
|
|
||||||
summary: 'Track new event',
|
|
||||||
description: 'Record a new event in the analytics system',
|
|
||||||
requestBody: {
|
|
||||||
required: true,
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
$ref: '#/components/schemas/EventInput',
|
|
||||||
},
|
|
||||||
examples: {
|
|
||||||
clickEvent: {
|
|
||||||
summary: 'Basic click event',
|
|
||||||
value: {
|
|
||||||
event_type: 'click',
|
|
||||||
link_id: 'link_123',
|
|
||||||
link_slug: 'promo2023',
|
|
||||||
link_original_url: 'https://example.com/promotion',
|
|
||||||
visitor_id: '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
conversionEvent: {
|
|
||||||
summary: 'Conversion event',
|
|
||||||
value: {
|
|
||||||
event_type: 'conversion',
|
|
||||||
link_id: 'link_123',
|
|
||||||
link_slug: 'promo2023',
|
|
||||||
visitor_id: '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b',
|
|
||||||
conversion_type: 'purchase',
|
|
||||||
conversion_value: 99.99
|
|
||||||
}
|
|
||||||
},
|
|
||||||
completeEvent: {
|
|
||||||
summary: 'Complete event with all fields',
|
|
||||||
value: {
|
|
||||||
// Core event fields
|
|
||||||
event_id: '123e4567-e89b-12d3-a456-426614174000',
|
|
||||||
event_time: '2025-03-26T10:30:00.000Z',
|
|
||||||
event_type: 'click',
|
|
||||||
event_attributes: '{"source":"email_campaign","campaign_id":"spring_sale_2025"}',
|
|
||||||
|
|
||||||
// Link information
|
|
||||||
link_id: 'link_abc123',
|
|
||||||
link_slug: 'summer-promo',
|
|
||||||
link_label: 'Summer Promotion 2025',
|
|
||||||
link_title: 'Summer Sale 50% Off',
|
|
||||||
link_original_url: 'https://example.com/summer-sale-2025',
|
|
||||||
link_attributes: '{"utm_campaign":"summer_2025","discount_code":"SUMMER50"}',
|
|
||||||
link_created_at: '2025-03-20T08:00:00.000Z',
|
|
||||||
link_expires_at: '2025-09-30T23:59:59.000Z',
|
|
||||||
link_tags: '["promotion","summer","sale"]',
|
|
||||||
|
|
||||||
// User information
|
|
||||||
user_id: 'user_12345',
|
|
||||||
user_name: 'John Doe',
|
|
||||||
user_email: 'john.doe@example.com',
|
|
||||||
user_attributes: '{"subscription_tier":"premium","account_created":"2024-01-15"}',
|
|
||||||
|
|
||||||
// Team information
|
|
||||||
team_id: 'team_67890',
|
|
||||||
team_name: 'Marketing Team',
|
|
||||||
team_attributes: '{"department":"marketing","region":"APAC"}',
|
|
||||||
|
|
||||||
// Project information
|
|
||||||
project_id: 'proj_54321',
|
|
||||||
project_name: 'Summer Campaign 2025',
|
|
||||||
project_attributes: '{"goals":"increase_sales","budget":"10000"}',
|
|
||||||
|
|
||||||
// QR code information
|
|
||||||
qr_code_id: 'qr_98765',
|
|
||||||
qr_code_name: 'Summer Flyer QR',
|
|
||||||
qr_code_attributes: '{"size":"large","color":"#FF5500","logo":true}',
|
|
||||||
|
|
||||||
// Visitor information
|
|
||||||
visitor_id: '6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b',
|
|
||||||
session_id: '7fc1bd8f-22d1-54eb-986f-3b9be5ecaf1c',
|
|
||||||
ip_address: '203.0.113.42',
|
|
||||||
country: 'United States',
|
|
||||||
city: 'San Francisco',
|
|
||||||
device_type: 'mobile',
|
|
||||||
browser: 'Chrome',
|
|
||||||
os: 'iOS',
|
|
||||||
user_agent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1',
|
|
||||||
|
|
||||||
// Referrer information
|
|
||||||
referrer: 'https://www.google.com/search?q=summer+sale',
|
|
||||||
utm_source: 'google',
|
|
||||||
utm_medium: 'organic',
|
|
||||||
utm_campaign: 'summer_promotion',
|
|
||||||
|
|
||||||
// Interaction information
|
|
||||||
time_spent_sec: 145,
|
|
||||||
is_bounce: false,
|
|
||||||
is_qr_scan: true,
|
|
||||||
conversion_type: 'signup',
|
|
||||||
conversion_value: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
'201': {
|
|
||||||
description: 'Event successfully tracked',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
success: {
|
|
||||||
type: 'boolean',
|
|
||||||
example: true
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: 'string',
|
|
||||||
example: 'Event tracked successfully'
|
|
||||||
},
|
|
||||||
event_id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
example: '123e4567-e89b-12d3-a456-426614174000'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'400': {
|
|
||||||
description: 'Bad request',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
$ref: '#/components/schemas/Error'
|
|
||||||
},
|
|
||||||
example: {
|
|
||||||
error: 'Missing required field: event_type'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'500': {
|
|
||||||
description: 'Server error',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
error: {
|
|
||||||
type: 'string'
|
|
||||||
},
|
|
||||||
details: {
|
|
||||||
type: 'string'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
example: {
|
|
||||||
error: 'Failed to track event',
|
|
||||||
details: 'Database connection error'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'/events': {
|
|
||||||
get: {
|
|
||||||
tags: ['events'],
|
|
||||||
summary: 'Get events',
|
|
||||||
description: 'Retrieve events within a specified time range with pagination support',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'startTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'Start time for events query (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'endTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'End time for events query (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'page',
|
|
||||||
in: 'query',
|
|
||||||
schema: {
|
|
||||||
type: 'integer',
|
|
||||||
default: 1,
|
|
||||||
minimum: 1,
|
|
||||||
},
|
|
||||||
description: 'Page number for pagination',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'pageSize',
|
|
||||||
in: 'query',
|
|
||||||
schema: {
|
|
||||||
type: 'integer',
|
|
||||||
default: 50,
|
|
||||||
minimum: 1,
|
|
||||||
maximum: 100,
|
|
||||||
},
|
|
||||||
description: 'Number of items per page',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
'200': {
|
|
||||||
description: 'Successful response',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
data: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
$ref: '#/components/schemas/Event',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pagination: {
|
|
||||||
$ref: '#/components/schemas/Pagination',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'400': {
|
|
||||||
description: 'Bad request',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
$ref: '#/components/schemas/Error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/events/summary': {
|
|
||||||
get: {
|
|
||||||
tags: ['events'],
|
|
||||||
summary: 'Get events summary',
|
|
||||||
description: 'Get aggregated statistics for events within a specified time range',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'startTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'Start time for summary (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'endTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'End time for summary (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
'200': {
|
|
||||||
description: 'Successful response',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
$ref: '#/components/schemas/EventsSummary',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'400': {
|
|
||||||
description: 'Bad request',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
$ref: '#/components/schemas/Error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/events/time-series': {
|
|
||||||
get: {
|
|
||||||
tags: ['events'],
|
|
||||||
summary: 'Get time series data',
|
|
||||||
description: 'Get time-based analytics data for events',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'startTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'Start time for time series data (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'endTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'End time for time series data (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
'200': {
|
|
||||||
description: 'Successful response',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
data: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
$ref: '#/components/schemas/TimeSeriesData',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'400': {
|
|
||||||
description: 'Bad request',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
$ref: '#/components/schemas/Error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/events/geo': {
|
|
||||||
get: {
|
|
||||||
tags: ['events'],
|
|
||||||
summary: 'Get geographic data',
|
|
||||||
description: 'Get geographic distribution of events',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'startTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'Start time for geographic data (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'endTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'End time for geographic data (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
'200': {
|
|
||||||
description: 'Successful response',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
data: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
$ref: '#/components/schemas/GeoData',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'400': {
|
|
||||||
description: 'Bad request',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
$ref: '#/components/schemas/Error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'/events/devices': {
|
|
||||||
get: {
|
|
||||||
tags: ['events'],
|
|
||||||
summary: 'Get device analytics data',
|
|
||||||
description: 'Get device-related analytics for events',
|
|
||||||
parameters: [
|
|
||||||
{
|
|
||||||
name: 'startTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'Start time for device analytics (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'endTime',
|
|
||||||
in: 'query',
|
|
||||||
required: true,
|
|
||||||
schema: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
},
|
|
||||||
description: 'End time for device analytics (ISO 8601 format)',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responses: {
|
|
||||||
'200': {
|
|
||||||
description: 'Successful response',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
data: {
|
|
||||||
$ref: '#/components/schemas/DeviceAnalytics',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'400': {
|
|
||||||
description: 'Bad request',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
$ref: '#/components/schemas/Error',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
schemas: {
|
|
||||||
EventInput: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['event_type'],
|
|
||||||
properties: {
|
|
||||||
// Core event fields
|
|
||||||
event_id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
description: '事件唯一标识符,用于唯一标识事件记录。若不提供则自动生成UUID'
|
|
||||||
},
|
|
||||||
event_time: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
description: '事件发生的时间戳(ISO 8601格式),记录事件发生的精确时间。若不提供则使用当前服务器时间'
|
|
||||||
},
|
|
||||||
event_type: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['click', 'conversion', 'redirect', 'error'],
|
|
||||||
description: '事件类型,用于分类不同的用户交互行为。click表示点击事件,conversion表示转化事件,redirect表示重定向事件,error表示错误事件'
|
|
||||||
},
|
|
||||||
event_attributes: {
|
|
||||||
type: 'string',
|
|
||||||
description: '事件附加属性的JSON字符串,用于存储与特定事件相关的自定义数据,例如事件来源、关联活动ID等'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Link information
|
|
||||||
link_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: '短链接的唯一标识符,用于关联事件与特定短链接'
|
|
||||||
},
|
|
||||||
link_slug: {
|
|
||||||
type: 'string',
|
|
||||||
description: '短链接的短码/slug部分,即URL路径中的短字符串,用于生成短链接URL'
|
|
||||||
},
|
|
||||||
link_label: {
|
|
||||||
type: 'string',
|
|
||||||
description: '短链接的标签名称,用于分类和组织管理短链接'
|
|
||||||
},
|
|
||||||
link_title: {
|
|
||||||
type: 'string',
|
|
||||||
description: '短链接的标题,用于在管理界面或分析报告中显示链接的易读名称'
|
|
||||||
},
|
|
||||||
link_original_url: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uri',
|
|
||||||
description: '短链接对应的原始目标URL,即用户访问短链接后将被重定向到的实际URL'
|
|
||||||
},
|
|
||||||
link_attributes: {
|
|
||||||
type: 'string',
|
|
||||||
description: '链接附加属性的JSON字符串,用于存储与链接相关的自定义数据,如营销活动信息、目标受众等'
|
|
||||||
},
|
|
||||||
link_created_at: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
description: '短链接创建时间,记录链接何时被创建'
|
|
||||||
},
|
|
||||||
link_expires_at: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
nullable: true,
|
|
||||||
description: '短链接过期时间,指定链接何时失效,值为null表示永不过期'
|
|
||||||
},
|
|
||||||
link_tags: {
|
|
||||||
type: 'string',
|
|
||||||
description: '链接标签的JSON数组字符串,用于通过标签对链接进行分类和过滤'
|
|
||||||
},
|
|
||||||
|
|
||||||
// User information
|
|
||||||
user_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: '创建链接的用户ID,用于跟踪哪个用户创建了短链接'
|
|
||||||
},
|
|
||||||
user_name: {
|
|
||||||
type: 'string',
|
|
||||||
description: '用户名称,用于在报表中展示更易读的用户身份'
|
|
||||||
},
|
|
||||||
user_email: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'email',
|
|
||||||
description: '用户电子邮件地址,可用于通知和报告分发'
|
|
||||||
},
|
|
||||||
user_attributes: {
|
|
||||||
type: 'string',
|
|
||||||
description: '用户附加属性的JSON字符串,存储用户相关的额外信息,如订阅级别、账户创建日期等'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Team information
|
|
||||||
team_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: '团队ID,用于标识链接归属的团队,支持多团队使用场景'
|
|
||||||
},
|
|
||||||
team_name: {
|
|
||||||
type: 'string',
|
|
||||||
description: '团队名称,用于在报表和管理界面中显示更友好的团队标识'
|
|
||||||
},
|
|
||||||
team_attributes: {
|
|
||||||
type: 'string',
|
|
||||||
description: '团队附加属性的JSON字符串,存储团队相关的额外信息,如部门、地区等'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Project information
|
|
||||||
project_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: '项目ID,用于将链接归类到特定项目下,便于项目级别的分析'
|
|
||||||
},
|
|
||||||
project_name: {
|
|
||||||
type: 'string',
|
|
||||||
description: '项目名称,提供更具描述性的项目标识,用于报表和管理界面'
|
|
||||||
},
|
|
||||||
project_attributes: {
|
|
||||||
type: 'string',
|
|
||||||
description: '项目附加属性的JSON字符串,存储项目相关的额外信息,如目标、预算等'
|
|
||||||
},
|
|
||||||
|
|
||||||
// QR code information
|
|
||||||
qr_code_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: '二维码ID,标识与事件关联的二维码,用于跟踪二维码的使用情况'
|
|
||||||
},
|
|
||||||
qr_code_name: {
|
|
||||||
type: 'string',
|
|
||||||
description: '二维码名称,提供更具描述性的二维码标识,便于管理和报表'
|
|
||||||
},
|
|
||||||
qr_code_attributes: {
|
|
||||||
type: 'string',
|
|
||||||
description: '二维码附加属性的JSON字符串,存储与二维码相关的额外信息,如尺寸、颜色、logo等'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Visitor information
|
|
||||||
visitor_id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
description: '访问者唯一标识符,用于跟踪和识别独立访问者,分析用户行为'
|
|
||||||
},
|
|
||||||
session_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: '会话标识符,用于将同一访问者的多个事件分组到同一会话中'
|
|
||||||
},
|
|
||||||
ip_address: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者的IP地址,用于地理位置分析和安全监控'
|
|
||||||
},
|
|
||||||
country: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者所在国家,用于地理分布分析'
|
|
||||||
},
|
|
||||||
city: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者所在城市,提供更精细的地理位置分析'
|
|
||||||
},
|
|
||||||
device_type: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者使用的设备类型(如mobile、desktop、tablet等),用于设备分布分析'
|
|
||||||
},
|
|
||||||
browser: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者使用的浏览器(如Chrome、Safari、Firefox等),用于浏览器分布分析'
|
|
||||||
},
|
|
||||||
os: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者使用的操作系统(如iOS、Android、Windows等),用于操作系统分布分析'
|
|
||||||
},
|
|
||||||
user_agent: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者的User-Agent字符串,包含有关浏览器、操作系统和设备的详细信息'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Referrer information
|
|
||||||
referrer: {
|
|
||||||
type: 'string',
|
|
||||||
description: '引荐来源URL,指示用户从哪个网站或页面访问短链接,用于分析流量来源'
|
|
||||||
},
|
|
||||||
utm_source: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'UTM来源参数,标识流量的来源渠道,如Google、Facebook、Newsletter等'
|
|
||||||
},
|
|
||||||
utm_medium: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'UTM媒介参数,标识营销媒介类型,如cpc、email、social等'
|
|
||||||
},
|
|
||||||
utm_campaign: {
|
|
||||||
type: 'string',
|
|
||||||
description: 'UTM活动参数,标识特定的营销活动名称,用于跟踪不同活动的效果'
|
|
||||||
},
|
|
||||||
|
|
||||||
// Interaction information
|
|
||||||
time_spent_sec: {
|
|
||||||
type: 'number',
|
|
||||||
description: '用户停留时间(秒),表示用户在目标页面上花费的时间,用于分析用户参与度'
|
|
||||||
},
|
|
||||||
is_bounce: {
|
|
||||||
type: 'boolean',
|
|
||||||
description: '是否为跳出访问,表示用户是否在查看单个页面后离开,不与网站进一步交互'
|
|
||||||
},
|
|
||||||
is_qr_scan: {
|
|
||||||
type: 'boolean',
|
|
||||||
description: '是否来自二维码扫描,用于区分和分析二维码带来的流量'
|
|
||||||
},
|
|
||||||
conversion_type: {
|
|
||||||
type: 'string',
|
|
||||||
description: '转化类型,表示事件触发的转化类型,如注册、购买、下载等,用于细分不同类型的转化'
|
|
||||||
},
|
|
||||||
conversion_value: {
|
|
||||||
type: 'number',
|
|
||||||
description: '转化价值,表示转化事件的经济价值或重要性,如购买金额、潜在客户价值等'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Event: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['event_id', 'event_type', 'event_time', 'visitor_id'],
|
|
||||||
properties: {
|
|
||||||
event_id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
description: '事件唯一标识符,用于唯一标识事件记录',
|
|
||||||
},
|
|
||||||
event_type: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['click', 'conversion'],
|
|
||||||
description: '事件类型,用于分类不同的用户交互行为。click表示点击事件,conversion表示转化事件',
|
|
||||||
},
|
|
||||||
event_time: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
description: '事件发生的时间戳,记录事件发生的精确时间',
|
|
||||||
},
|
|
||||||
link_id: {
|
|
||||||
type: 'string',
|
|
||||||
description: '短链接的唯一标识符,用于关联事件与特定短链接',
|
|
||||||
},
|
|
||||||
link_slug: {
|
|
||||||
type: 'string',
|
|
||||||
description: '短链接的短码/slug部分,即URL路径中的短字符串',
|
|
||||||
},
|
|
||||||
link_original_url: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uri',
|
|
||||||
description: '短链接对应的原始目标URL,即用户访问短链接后将被重定向到的实际URL',
|
|
||||||
},
|
|
||||||
visitor_id: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'uuid',
|
|
||||||
description: '访问者唯一标识符,用于跟踪和识别独立访问者,分析用户行为',
|
|
||||||
},
|
|
||||||
device_type: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者使用的设备类型(如mobile、desktop、tablet等),用于设备分布分析',
|
|
||||||
},
|
|
||||||
browser: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者使用的浏览器(如Chrome、Safari、Firefox等),用于浏览器分布分析',
|
|
||||||
},
|
|
||||||
os: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者使用的操作系统(如iOS、Android、Windows等),用于操作系统分布分析',
|
|
||||||
},
|
|
||||||
country: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者所在国家,用于地理分布分析',
|
|
||||||
},
|
|
||||||
region: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者所在地区/省份,提供中等精细度的地理位置分析',
|
|
||||||
},
|
|
||||||
city: {
|
|
||||||
type: 'string',
|
|
||||||
description: '访问者所在城市,提供更精细的地理位置分析',
|
|
||||||
},
|
|
||||||
referrer: {
|
|
||||||
type: 'string',
|
|
||||||
description: '引荐来源URL,指示用户从哪个网站或页面访问短链接,用于分析流量来源',
|
|
||||||
},
|
|
||||||
conversion_type: {
|
|
||||||
type: 'string',
|
|
||||||
description: '转化类型,表示事件触发的转化类型,如注册、购买、下载等(仅当event_type为conversion时有效)',
|
|
||||||
},
|
|
||||||
conversion_value: {
|
|
||||||
type: 'number',
|
|
||||||
description: '转化价值,表示转化事件的经济价值或重要性,如购买金额、潜在客户价值等(仅当event_type为conversion时有效)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
EventsSummary: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['totalEvents', 'uniqueVisitors'],
|
|
||||||
properties: {
|
|
||||||
totalEvents: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '时间段内的事件总数,包括所有类型的事件总计',
|
|
||||||
},
|
|
||||||
uniqueVisitors: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '时间段内的独立访问者数量,基于唯一访问者ID计算',
|
|
||||||
},
|
|
||||||
totalConversions: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '时间段内的转化事件总数,用于衡量营销效果',
|
|
||||||
},
|
|
||||||
averageTimeSpent: {
|
|
||||||
type: 'number',
|
|
||||||
description: '平均停留时间(秒),表示用户平均在目标页面上停留的时间,是用户参与度的重要指标',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
TimeSeriesData: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
timestamp: {
|
|
||||||
type: 'string',
|
|
||||||
format: 'date-time',
|
|
||||||
description: '时间序列中的时间点,表示数据采集的精确时间',
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
type: 'number',
|
|
||||||
description: '该时间点的事件数量,显示事件随时间的分布趋势',
|
|
||||||
},
|
|
||||||
visitors: {
|
|
||||||
type: 'number',
|
|
||||||
description: '该时间点的独立访问者数量,显示访问者随时间的分布趋势',
|
|
||||||
},
|
|
||||||
conversions: {
|
|
||||||
type: 'number',
|
|
||||||
description: '该时间点的转化数量,显示转化随时间的分布趋势',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
GeoData: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
location: {
|
|
||||||
type: 'string',
|
|
||||||
description: '位置标识符,可以是国家、地区或城市的组合标识',
|
|
||||||
},
|
|
||||||
country: {
|
|
||||||
type: 'string',
|
|
||||||
description: '国家名称,表示访问者所在的国家',
|
|
||||||
},
|
|
||||||
region: {
|
|
||||||
type: 'string',
|
|
||||||
description: '地区/省份名称,表示访问者所在的地区或省份',
|
|
||||||
},
|
|
||||||
city: {
|
|
||||||
type: 'string',
|
|
||||||
description: '城市名称,表示访问者所在的城市',
|
|
||||||
},
|
|
||||||
visits: {
|
|
||||||
type: 'number',
|
|
||||||
description: '来自该位置的访问次数,用于分析不同地区的流量分布',
|
|
||||||
},
|
|
||||||
visitors: {
|
|
||||||
type: 'number',
|
|
||||||
description: '来自该位置的独立访问者数量,用于分析不同地区的用户分布',
|
|
||||||
},
|
|
||||||
percentage: {
|
|
||||||
type: 'number',
|
|
||||||
description: '占总访问量的百分比,便于直观比较不同地区的流量占比',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DeviceAnalytics: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
deviceTypes: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
type: {
|
|
||||||
type: 'string',
|
|
||||||
description: '设备类型,如mobile、desktop、tablet等,用于设备类型分析',
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: 'number',
|
|
||||||
description: '使用该设备类型的访问次数,用于统计各类设备的使用情况',
|
|
||||||
},
|
|
||||||
percentage: {
|
|
||||||
type: 'number',
|
|
||||||
description: '该设备类型占总访问量的百分比,便于比较不同设备类型的使用占比',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
browsers: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
description: '浏览器名称,如Chrome、Safari、Firefox等,用于浏览器使用分析',
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: 'number',
|
|
||||||
description: '使用该浏览器的访问次数,用于统计各类浏览器的使用情况',
|
|
||||||
},
|
|
||||||
percentage: {
|
|
||||||
type: 'number',
|
|
||||||
description: '该浏览器占总访问量的百分比,便于比较不同浏览器的使用占比',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
operatingSystems: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
description: '操作系统名称,如iOS、Android、Windows等,用于操作系统使用分析',
|
|
||||||
},
|
|
||||||
count: {
|
|
||||||
type: 'number',
|
|
||||||
description: '使用该操作系统的访问次数,用于统计各类操作系统的使用情况',
|
|
||||||
},
|
|
||||||
percentage: {
|
|
||||||
type: 'number',
|
|
||||||
description: '该操作系统占总访问量的百分比,便于比较不同操作系统的使用占比',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Pagination: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['page', 'pageSize', 'totalItems', 'totalPages'],
|
|
||||||
properties: {
|
|
||||||
page: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '当前页码,表示结果集中的当前页面位置',
|
|
||||||
},
|
|
||||||
pageSize: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '每页项目数,表示每页显示的结果数量',
|
|
||||||
},
|
|
||||||
totalItems: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '总项目数,表示符合查询条件的结果总数',
|
|
||||||
},
|
|
||||||
totalPages: {
|
|
||||||
type: 'integer',
|
|
||||||
description: '总页数,基于总项目数和每页项目数计算得出',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Error: {
|
|
||||||
type: 'object',
|
|
||||||
required: ['code', 'message'],
|
|
||||||
properties: {
|
|
||||||
code: {
|
|
||||||
type: 'string',
|
|
||||||
description: '错误代码,用于标识特定类型的错误,便于客户端处理不同错误情况',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
type: 'string',
|
|
||||||
description: '错误消息,提供关于错误的人类可读描述,帮助理解错误原因',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto px-4 py-8">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold mb-2">API Documentation</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Explore and test the ShortURL Analytics API endpoints using the interactive documentation below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SwaggerUI spec={swaggerConfig} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
1193
app/analytics/page.tsx
Normal file
1193
app/analytics/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,19 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
// 获取团队、项目和标签筛选参数
|
||||||
|
const teamIds = searchParams.getAll('teamId');
|
||||||
|
const projectIds = searchParams.getAll('projectId');
|
||||||
|
const tagIds = searchParams.getAll('tagId');
|
||||||
|
|
||||||
const data = await getDeviceAnalytics({
|
const data = await getDeviceAnalytics({
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
startTime: searchParams.get('startTime') || undefined,
|
||||||
endTime: searchParams.get('endTime') || 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> = {
|
const response: ApiResponse<typeof data> = {
|
||||||
|
|||||||
@@ -6,11 +6,23 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
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({
|
const data = await getGeoAnalytics({
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
startTime: searchParams.get('startTime') || undefined,
|
||||||
endTime: searchParams.get('endTime') || undefined,
|
endTime: searchParams.get('endTime') || undefined,
|
||||||
linkId: searchParams.get('linkId') || 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> = {
|
const response: ApiResponse<typeof data> = {
|
||||||
|
|||||||
@@ -1,50 +1,68 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import type { ApiResponse, EventsQueryParams, EventType } from '@/lib/types';
|
import { getEvents, EventsQueryParams } from '@/lib/analytics';
|
||||||
import {
|
import { ApiResponse } from '@/lib/types';
|
||||||
getEvents,
|
|
||||||
getEventsSummary,
|
|
||||||
getTimeSeriesData,
|
|
||||||
getGeoAnalytics,
|
|
||||||
getDeviceAnalytics
|
|
||||||
} from '@/lib/analytics';
|
|
||||||
|
|
||||||
// 获取事件列表
|
// 获取事件列表
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
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 = {
|
const params: EventsQueryParams = {
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
page,
|
||||||
endTime: searchParams.get('endTime') || undefined,
|
pageSize,
|
||||||
eventType: searchParams.get('eventType') as EventType || undefined,
|
eventType,
|
||||||
linkId: searchParams.get('linkId') || undefined,
|
linkId,
|
||||||
linkSlug: searchParams.get('linkSlug') || undefined,
|
linkSlug,
|
||||||
userId: searchParams.get('userId') || undefined,
|
userId,
|
||||||
teamId: searchParams.get('teamId') || undefined,
|
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||||
projectId: searchParams.get('projectId') || undefined,
|
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||||
page: searchParams.has('page') ? parseInt(searchParams.get('page')!, 10) : 1,
|
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||||||
pageSize: searchParams.has('pageSize') ? parseInt(searchParams.get('pageSize')!, 10) : 20,
|
startTime,
|
||||||
sortBy: searchParams.get('sortBy') || undefined,
|
endTime,
|
||||||
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined
|
sortBy,
|
||||||
|
sortOrder
|
||||||
};
|
};
|
||||||
|
|
||||||
const { events, total } = await getEvents(params);
|
const result = await getEvents(params);
|
||||||
|
|
||||||
const response: ApiResponse<typeof events> = {
|
const response: ApiResponse<typeof result.events> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: events,
|
data: result.events,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total: result.total,
|
||||||
page: params.page,
|
page,
|
||||||
pageSize: params.pageSize
|
pageSize
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('获取事件列表失败:', error);
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: false,
|
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 });
|
return NextResponse.json(response, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,24 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
// 获取可能存在的多个团队、项目和标签ID
|
||||||
|
const teamIds = searchParams.getAll('teamId');
|
||||||
|
const projectIds = searchParams.getAll('projectId');
|
||||||
|
const tagIds = searchParams.getAll('tagId');
|
||||||
|
|
||||||
|
// Add debug log to check if linkId is being received
|
||||||
|
const linkId = searchParams.get('linkId');
|
||||||
|
console.log('Summary API received linkId:', linkId);
|
||||||
|
console.log('Summary API full parameters:', Object.fromEntries(searchParams.entries()));
|
||||||
|
console.log('Summary API URL:', request.url);
|
||||||
|
|
||||||
const summary = await getEventsSummary({
|
const summary = await getEventsSummary({
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
startTime: searchParams.get('startTime') || undefined,
|
||||||
endTime: searchParams.get('endTime') || 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> = {
|
const response: ApiResponse<typeof summary> = {
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ export async function GET(request: NextRequest) {
|
|||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取团队、项目和标签筛选参数
|
||||||
|
const teamIds = searchParams.getAll('teamId');
|
||||||
|
const projectIds = searchParams.getAll('projectId');
|
||||||
|
const tagIds = searchParams.getAll('tagId');
|
||||||
|
|
||||||
const data = await getTimeSeriesData({
|
const data = await getTimeSeriesData({
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
linkId: searchParams.get('linkId') || undefined,
|
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> = {
|
const response: ApiResponse<typeof data> = {
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export async function POST(req: NextRequest) {
|
|||||||
utm_source: eventData.utm_source || '',
|
utm_source: eventData.utm_source || '',
|
||||||
utm_medium: eventData.utm_medium || '',
|
utm_medium: eventData.utm_medium || '',
|
||||||
utm_campaign: eventData.utm_campaign || '',
|
utm_campaign: eventData.utm_campaign || '',
|
||||||
|
utm_term: eventData.utm_term || '',
|
||||||
|
utm_content: eventData.utm_content || '',
|
||||||
|
|
||||||
// Interaction information
|
// Interaction information
|
||||||
time_spent_sec: eventData.time_spent_sec || 0,
|
time_spent_sec: eventData.time_spent_sec || 0,
|
||||||
|
|||||||
157
app/api/events/utm/route.ts
Normal file
157
app/api/events/utm/route.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import clickhouse from '@/lib/clickhouse';
|
||||||
|
import type { ApiResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
interface UtmData {
|
||||||
|
utm_value: string;
|
||||||
|
clicks: number;
|
||||||
|
visitors: number;
|
||||||
|
avg_time_spent: number;
|
||||||
|
bounces: number;
|
||||||
|
conversions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期时间字符串为ClickHouse支持的格式
|
||||||
|
const formatDateTime = (dateStr: string): string => {
|
||||||
|
return dateStr.replace('T', ' ').replace('Z', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
// 获取过滤参数
|
||||||
|
const startTime = searchParams.get('startTime');
|
||||||
|
const endTime = searchParams.get('endTime');
|
||||||
|
const linkId = searchParams.get('linkId');
|
||||||
|
|
||||||
|
// 获取团队、项目和标签筛选参数
|
||||||
|
const teamIds = searchParams.getAll('teamId');
|
||||||
|
const projectIds = searchParams.getAll('projectId');
|
||||||
|
const tagIds = searchParams.getAll('tagId');
|
||||||
|
const tagNames = searchParams.getAll('tagName');
|
||||||
|
|
||||||
|
// 获取UTM类型参数
|
||||||
|
const utmType = searchParams.get('utmType') || 'source';
|
||||||
|
|
||||||
|
// 构建WHERE子句
|
||||||
|
let whereClause = '';
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (startTime) {
|
||||||
|
conditions.push(`event_time >= toDateTime('${formatDateTime(startTime)}')`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endTime) {
|
||||||
|
conditions.push(`event_time <= toDateTime('${formatDateTime(endTime)}')`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkId) {
|
||||||
|
conditions.push(`link_id = '${linkId}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加团队筛选
|
||||||
|
if (teamIds && teamIds.length > 0) {
|
||||||
|
// 如果只有一个团队ID
|
||||||
|
if (teamIds.length === 1) {
|
||||||
|
conditions.push(`team_id = '${teamIds[0]}'`);
|
||||||
|
} else {
|
||||||
|
// 多个团队ID
|
||||||
|
conditions.push(`team_id IN ('${teamIds.join("','")}')`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加项目筛选
|
||||||
|
if (projectIds && projectIds.length > 0) {
|
||||||
|
// 如果只有一个项目ID
|
||||||
|
if (projectIds.length === 1) {
|
||||||
|
conditions.push(`project_id = '${projectIds[0]}'`);
|
||||||
|
} else {
|
||||||
|
// 多个项目ID
|
||||||
|
conditions.push(`project_id IN ('${projectIds.join("','")}')`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加标签筛选
|
||||||
|
if ((tagIds && tagIds.length > 0) || (tagNames && tagNames.length > 0)) {
|
||||||
|
// 优先使用tagNames,如果有的话
|
||||||
|
const tagsToUse = tagNames.length > 0 ? tagNames : tagIds;
|
||||||
|
|
||||||
|
// 使用与buildFilter函数相同的处理方式
|
||||||
|
const tagConditions = tagsToUse.map(tag =>
|
||||||
|
`link_tags LIKE '%${tag}%'`
|
||||||
|
);
|
||||||
|
conditions.push(`(${tagConditions.join(' OR ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定要分组的UTM字段
|
||||||
|
let utmField;
|
||||||
|
switch (utmType) {
|
||||||
|
case 'source':
|
||||||
|
utmField = 'utm_source';
|
||||||
|
break;
|
||||||
|
case 'medium':
|
||||||
|
utmField = 'utm_medium';
|
||||||
|
break;
|
||||||
|
case 'campaign':
|
||||||
|
utmField = 'utm_campaign';
|
||||||
|
break;
|
||||||
|
case 'term':
|
||||||
|
utmField = 'utm_term';
|
||||||
|
break;
|
||||||
|
case 'content':
|
||||||
|
utmField = 'utm_content';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
utmField = 'utm_source';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建SQL查询
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
${utmField} AS utm_value,
|
||||||
|
COUNT(*) AS clicks,
|
||||||
|
uniqExact(visitor_id) AS visitors,
|
||||||
|
round(AVG(time_spent_sec), 2) AS avg_time_spent,
|
||||||
|
countIf(is_bounce = 1) AS bounces,
|
||||||
|
countIf(conversion_type IN ('visit', 'stay', 'interact', 'signup', 'subscription', 'purchase')) AS conversions
|
||||||
|
FROM shorturl_analytics.events
|
||||||
|
${whereClause}
|
||||||
|
${whereClause ? 'AND' : 'WHERE'} ${utmField} != ''
|
||||||
|
GROUP BY utm_value
|
||||||
|
ORDER BY clicks DESC
|
||||||
|
LIMIT 100
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
const result = await clickhouse.query({
|
||||||
|
query,
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取查询结果
|
||||||
|
const rows = await result.json();
|
||||||
|
const data = rows as UtmData[];
|
||||||
|
|
||||||
|
// 返回数据
|
||||||
|
const response: ApiResponse<UtmData[]> = {
|
||||||
|
success: true,
|
||||||
|
data
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching UTM data:', error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
140
app/api/shortlinks/[id]/route.ts
Normal file
140
app/api/shortlinks/[id]/route.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { executeQuery } from '@/lib/clickhouse';
|
||||||
|
import type { ApiResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Get the id from the URL parameters
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'ID parameter is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching shortlink by ID:', id);
|
||||||
|
|
||||||
|
// Query to fetch a single shortlink by id
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
external_id,
|
||||||
|
type,
|
||||||
|
slug,
|
||||||
|
original_url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
attributes,
|
||||||
|
schema_version,
|
||||||
|
creator_id,
|
||||||
|
creator_email,
|
||||||
|
creator_name,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
deleted_at,
|
||||||
|
projects,
|
||||||
|
teams,
|
||||||
|
tags,
|
||||||
|
qr_codes AS qr_codes,
|
||||||
|
channels,
|
||||||
|
favorites,
|
||||||
|
expires_at,
|
||||||
|
click_count,
|
||||||
|
unique_visitors
|
||||||
|
FROM shorturl_analytics.shorturl
|
||||||
|
WHERE id = '${id}' AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Executing query:', query);
|
||||||
|
|
||||||
|
// Execute the query
|
||||||
|
const result = await executeQuery(query);
|
||||||
|
|
||||||
|
// If no shortlink found with the specified ID
|
||||||
|
if (!Array.isArray(result) || result.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Shortlink not found'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the shortlink data
|
||||||
|
const shortlink = result[0] as any;
|
||||||
|
|
||||||
|
// Extract shortUrl from attributes
|
||||||
|
let shortUrl = '';
|
||||||
|
try {
|
||||||
|
if (shortlink.attributes && typeof shortlink.attributes === 'string') {
|
||||||
|
const attributes = JSON.parse(shortlink.attributes) as { shortUrl?: string };
|
||||||
|
shortUrl = attributes.shortUrl || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing shortlink attributes:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process teams
|
||||||
|
let teams: any[] = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.teams && typeof shortlink.teams === 'string') {
|
||||||
|
teams = JSON.parse(shortlink.teams);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing teams:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tags
|
||||||
|
let tags: any[] = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.tags && typeof shortlink.tags === 'string') {
|
||||||
|
tags = JSON.parse(shortlink.tags);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing tags:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process projects
|
||||||
|
let projects: any[] = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.projects && typeof shortlink.projects === 'string') {
|
||||||
|
projects = JSON.parse(shortlink.projects);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing projects:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the data to match what our store expects
|
||||||
|
const formattedShortlink = {
|
||||||
|
id: shortlink.id || '',
|
||||||
|
externalId: shortlink.external_id || '',
|
||||||
|
slug: shortlink.slug || '',
|
||||||
|
originalUrl: shortlink.original_url || '',
|
||||||
|
title: shortlink.title || '',
|
||||||
|
shortUrl: shortUrl,
|
||||||
|
teams: teams,
|
||||||
|
projects: projects,
|
||||||
|
tags: tags.map((tag: any) => tag.tag_name || ''),
|
||||||
|
createdAt: shortlink.created_at,
|
||||||
|
domain: new URL(shortUrl || 'https://example.com').hostname
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: ApiResponse<typeof formattedShortlink> = {
|
||||||
|
success: true,
|
||||||
|
data: formattedShortlink
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching shortlink by ID:', error);
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
return NextResponse.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/api/shortlinks/byUrl/route.ts
Normal file
142
app/api/shortlinks/byUrl/route.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { executeQuery } from '@/lib/clickhouse';
|
||||||
|
import type { ApiResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Get the url from query parameters
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const url = searchParams.get('url');
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'URL parameter is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching shortlink by URL:', url);
|
||||||
|
|
||||||
|
// Query to fetch a single shortlink by shortUrl in attributes
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
external_id,
|
||||||
|
type,
|
||||||
|
slug,
|
||||||
|
original_url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
attributes,
|
||||||
|
schema_version,
|
||||||
|
creator_id,
|
||||||
|
creator_email,
|
||||||
|
creator_name,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
deleted_at,
|
||||||
|
projects,
|
||||||
|
teams,
|
||||||
|
tags,
|
||||||
|
qr_codes AS qr_codes,
|
||||||
|
channels,
|
||||||
|
favorites,
|
||||||
|
expires_at,
|
||||||
|
click_count,
|
||||||
|
unique_visitors
|
||||||
|
FROM shorturl_analytics.shorturl
|
||||||
|
WHERE JSONHas(attributes, 'shortUrl')
|
||||||
|
AND JSONExtractString(attributes, 'shortUrl') = '${url}'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Executing query:', query);
|
||||||
|
|
||||||
|
// Execute the query
|
||||||
|
const result = await executeQuery(query);
|
||||||
|
|
||||||
|
// If no shortlink found with the specified URL
|
||||||
|
if (!Array.isArray(result) || result.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Shortlink not found'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the shortlink data
|
||||||
|
const shortlink = result[0];
|
||||||
|
|
||||||
|
// Extract shortUrl from attributes
|
||||||
|
let shortUrl = '';
|
||||||
|
try {
|
||||||
|
if (shortlink.attributes && typeof shortlink.attributes === 'string') {
|
||||||
|
const attributes = JSON.parse(shortlink.attributes);
|
||||||
|
shortUrl = attributes.shortUrl || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing shortlink attributes:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process teams
|
||||||
|
let teams = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.teams && typeof shortlink.teams === 'string') {
|
||||||
|
teams = JSON.parse(shortlink.teams);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing teams:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tags
|
||||||
|
let tags = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.tags && typeof shortlink.tags === 'string') {
|
||||||
|
tags = JSON.parse(shortlink.tags);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing tags:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process projects
|
||||||
|
let projects = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.projects && typeof shortlink.projects === 'string') {
|
||||||
|
projects = JSON.parse(shortlink.projects);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing projects:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the data to match what our store expects
|
||||||
|
const formattedShortlink = {
|
||||||
|
id: shortlink.id || '',
|
||||||
|
externalId: shortlink.external_id || '',
|
||||||
|
slug: shortlink.slug || '',
|
||||||
|
originalUrl: shortlink.original_url || '',
|
||||||
|
title: shortlink.title || '',
|
||||||
|
shortUrl: shortUrl,
|
||||||
|
teams: teams,
|
||||||
|
projects: projects,
|
||||||
|
tags: tags.map((tag) => tag.tag_name || ''),
|
||||||
|
createdAt: shortlink.created_at,
|
||||||
|
domain: new URL(shortUrl || 'https://example.com').hostname
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Shortlink data formatted with externalId:', shortlink.external_id, 'Final object:', formattedShortlink);
|
||||||
|
|
||||||
|
const response: ApiResponse<typeof formattedShortlink> = {
|
||||||
|
success: true,
|
||||||
|
data: formattedShortlink
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching shortlink by URL:', error);
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
return NextResponse.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/api/shortlinks/exact/route.ts
Normal file
142
app/api/shortlinks/exact/route.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { executeQuery } from '@/lib/clickhouse';
|
||||||
|
import type { ApiResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Get the url from query parameters
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const shortUrl = searchParams.get('shortUrl');
|
||||||
|
|
||||||
|
if (!shortUrl) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'shortUrl parameter is required'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetching shortlink by exact shortUrl:', shortUrl);
|
||||||
|
|
||||||
|
// Query to fetch a single shortlink by shortUrl in attributes
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
external_id,
|
||||||
|
type,
|
||||||
|
slug,
|
||||||
|
original_url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
attributes,
|
||||||
|
schema_version,
|
||||||
|
creator_id,
|
||||||
|
creator_email,
|
||||||
|
creator_name,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
deleted_at,
|
||||||
|
projects,
|
||||||
|
teams,
|
||||||
|
tags,
|
||||||
|
qr_codes AS qr_codes,
|
||||||
|
channels,
|
||||||
|
favorites,
|
||||||
|
expires_at,
|
||||||
|
click_count,
|
||||||
|
unique_visitors
|
||||||
|
FROM shorturl_analytics.shorturl
|
||||||
|
WHERE JSONHas(attributes, 'shortUrl')
|
||||||
|
AND JSONExtractString(attributes, 'shortUrl') = '${shortUrl}'
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('Executing query:', query);
|
||||||
|
|
||||||
|
// Execute the query
|
||||||
|
const result = await executeQuery(query);
|
||||||
|
|
||||||
|
// If no shortlink found with the specified URL
|
||||||
|
if (!Array.isArray(result) || result.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Shortlink not found'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the shortlink data
|
||||||
|
const shortlink = result[0] as Record<string, any>;
|
||||||
|
|
||||||
|
// Extract shortUrl from attributes
|
||||||
|
let shortUrlValue = '';
|
||||||
|
try {
|
||||||
|
if (shortlink.attributes && typeof shortlink.attributes === 'string') {
|
||||||
|
const attributes = JSON.parse(shortlink.attributes) as { shortUrl?: string };
|
||||||
|
shortUrlValue = attributes.shortUrl || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing shortlink attributes:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process teams
|
||||||
|
let teams: any[] = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.teams && typeof shortlink.teams === 'string') {
|
||||||
|
teams = JSON.parse(shortlink.teams);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing teams:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process tags
|
||||||
|
let tags: any[] = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.tags && typeof shortlink.tags === 'string') {
|
||||||
|
tags = JSON.parse(shortlink.tags);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing tags:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process projects
|
||||||
|
let projects: any[] = [];
|
||||||
|
try {
|
||||||
|
if (shortlink.projects && typeof shortlink.projects === 'string') {
|
||||||
|
projects = JSON.parse(shortlink.projects);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing projects:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the data to match what our store expects
|
||||||
|
const formattedShortlink = {
|
||||||
|
id: shortlink.id || '',
|
||||||
|
externalId: shortlink.external_id || '',
|
||||||
|
slug: shortlink.slug || '',
|
||||||
|
originalUrl: shortlink.original_url || '',
|
||||||
|
title: shortlink.title || '',
|
||||||
|
shortUrl: shortUrlValue,
|
||||||
|
teams: teams,
|
||||||
|
projects: projects,
|
||||||
|
tags: tags.map((tag: any) => tag.tag_name || ''),
|
||||||
|
createdAt: shortlink.created_at,
|
||||||
|
domain: new URL(shortUrlValue || 'https://example.com').hostname
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Formatted shortlink with externalId:', shortlink.external_id);
|
||||||
|
|
||||||
|
const response: ApiResponse<typeof formattedShortlink> = {
|
||||||
|
success: true,
|
||||||
|
data: formattedShortlink
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching shortlink by exact URL:', error);
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
return NextResponse.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/api/shortlinks/route.ts
Normal file
103
app/api/shortlinks/route.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { executeQuery } from '@/lib/clickhouse';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Get pagination and filter parameters from the URL
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||||
|
const pageSize = parseInt(searchParams.get('page_size') || '10', 10);
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
const team = searchParams.get('team');
|
||||||
|
|
||||||
|
// Calculate OFFSET
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
// Build WHERE conditions
|
||||||
|
const whereConditions = ['deleted_at IS NULL'];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
// Expand search to include more fields: slug, shortUrl in attributes, team name, tag name, original_url
|
||||||
|
whereConditions.push(`(
|
||||||
|
slug ILIKE '%${search}%' OR
|
||||||
|
original_url ILIKE '%${search}%' OR
|
||||||
|
title ILIKE '%${search}%' OR
|
||||||
|
JSONHas(attributes, 'shortUrl') AND JSONExtractString(attributes, 'shortUrl') ILIKE '%${search}%' OR
|
||||||
|
arrayExists(x -> JSONExtractString(x, 'team_name') ILIKE '%${search}%', JSONExtractArrayRaw(teams)) OR
|
||||||
|
arrayExists(x -> JSONExtractString(x, 'tag_name') ILIKE '%${search}%', JSONExtractArrayRaw(tags))
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
whereConditions.push(`arrayExists(x -> JSONExtractString(x, 'team_id') = '${team}', JSONExtractArrayRaw(teams))`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.join(' AND ');
|
||||||
|
|
||||||
|
// First query to get total count
|
||||||
|
const countQuery = `
|
||||||
|
SELECT count(*) as total
|
||||||
|
FROM shorturl_analytics.shorturl
|
||||||
|
WHERE ${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countResult = await executeQuery(countQuery);
|
||||||
|
// Handle the result safely by using an explicit type check
|
||||||
|
const total = Array.isArray(countResult) && countResult.length > 0 && typeof countResult[0] === 'object' && countResult[0] !== null && 'total' in countResult[0]
|
||||||
|
? Number(countResult[0].total)
|
||||||
|
: 0;
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
// Main query with pagination
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
external_id,
|
||||||
|
type,
|
||||||
|
slug,
|
||||||
|
original_url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
attributes,
|
||||||
|
schema_version,
|
||||||
|
creator_id,
|
||||||
|
creator_email,
|
||||||
|
creator_name,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
deleted_at,
|
||||||
|
projects,
|
||||||
|
teams,
|
||||||
|
tags,
|
||||||
|
qr_codes AS qr_codes,
|
||||||
|
channels,
|
||||||
|
favorites,
|
||||||
|
expires_at,
|
||||||
|
click_count,
|
||||||
|
unique_visitors
|
||||||
|
FROM shorturl_analytics.shorturl
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ${pageSize} OFFSET ${offset}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Execute the query using the shared client
|
||||||
|
const rows = await executeQuery(query);
|
||||||
|
|
||||||
|
// Return the data with pagination metadata
|
||||||
|
return NextResponse.json({
|
||||||
|
links: rows,
|
||||||
|
total: total,
|
||||||
|
total_pages: totalPages,
|
||||||
|
page: page,
|
||||||
|
page_size: pageSize
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching shortlinks from ClickHouse:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch shortlinks' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/teams/list/route.ts
Normal file
41
app/api/teams/list/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const supabase = createRouteHandlerClient({ cookies });
|
||||||
|
|
||||||
|
// 获取当前用户
|
||||||
|
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||||
|
if (userError || !user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户所属的所有团队
|
||||||
|
const { data: teams, error: teamsError } = await supabase
|
||||||
|
.from('teams')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
avatar_url
|
||||||
|
`)
|
||||||
|
.innerJoin('team_membership', 'teams.id = team_membership.team_id')
|
||||||
|
.eq('team_membership.user_id', user.id)
|
||||||
|
.is('teams.deleted_at', null);
|
||||||
|
|
||||||
|
if (teamsError) {
|
||||||
|
console.error('Error fetching teams:', teamsError);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch teams' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(teams);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in /api/teams/list:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,8 @@ export interface Event {
|
|||||||
utm_source: string;
|
utm_source: string;
|
||||||
utm_medium: string;
|
utm_medium: string;
|
||||||
utm_campaign: string;
|
utm_campaign: string;
|
||||||
|
utm_term: string;
|
||||||
|
utm_content: string;
|
||||||
|
|
||||||
// 交互信息
|
// 交互信息
|
||||||
time_spent_sec: number;
|
time_spent_sec: number;
|
||||||
@@ -93,13 +95,10 @@ export interface TimeSeriesData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GeoData {
|
export interface GeoData {
|
||||||
location?: string;
|
location: string;
|
||||||
country?: string;
|
area: string;
|
||||||
region?: string;
|
|
||||||
city?: string;
|
|
||||||
visits: number;
|
visits: number;
|
||||||
uniqueVisitors?: number;
|
visitors: number;
|
||||||
visitors?: number;
|
|
||||||
percentage: 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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { DeviceAnalytics as DeviceAnalyticsType } from '@/app/api/types';
|
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 {
|
interface DeviceAnalyticsProps {
|
||||||
data: DeviceAnalyticsType;
|
data: DeviceAnalyticsType;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) {
|
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
|
||||||
// 安全地格式化数字
|
const renderCategory = (items: CategoryItem[], title: string) => (
|
||||||
const formatNumber = (value: number | string | undefined | null): string => {
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
if (value === undefined || value === null) return '0';
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||||
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 安全地格式化百分比
|
|
||||||
const formatPercent = (value: number | undefined | null): string => {
|
|
||||||
if (value === undefined || value === null) return '0';
|
|
||||||
return value.toFixed(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{title}</h3>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
|
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||||
<span>{item.name || 'Unknown'}</span>
|
<span>{item.name}</span>
|
||||||
<span>{formatNumber(item.count)} ({formatPercent(item.percentage)}%)</span>
|
<span>{item.percentage.toFixed(1)}% ({item.count})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 h-2 rounded-full"
|
className="bg-blue-600 h-2 rounded-full"
|
||||||
style={{ width: `${item.percentage || 0}%` }}
|
style={{ width: `${item.percentage}%` }}
|
||||||
/>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<StatCard
|
{renderCategory(deviceItems, 'Device Types')}
|
||||||
title="Device Types"
|
{renderCategory(data.browsers, 'Browsers')}
|
||||||
items={(data.deviceTypes || []).map(item => ({
|
{renderCategory(data.operatingSystems, 'Operating Systems')}
|
||||||
name: item.type ? (item.type.charAt(0).toUpperCase() + item.type.slice(1)) : 'Unknown',
|
|
||||||
count: item.count,
|
|
||||||
percentage: item.percentage
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Browsers"
|
|
||||||
items={data.browsers || []}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
title="Operating Systems"
|
|
||||||
items={data.operatingSystems || []}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,72 +1,274 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { GeoData } from '@/app/api/types';
|
import { GeoData } from '@/app/api/types';
|
||||||
import { Chart, PieController, ArcElement, Tooltip, Legend } from 'chart.js';
|
import { getLocationsFromIPs } from '@/app/utils/ipLocation';
|
||||||
|
|
||||||
interface GeoAnalyticsProps {
|
interface GeoAnalyticsProps {
|
||||||
data: GeoData[];
|
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) {
|
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';
|
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';
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
{/* Tabs for geographic levels */}
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
<div className="flex border-b mb-6">
|
||||||
<tr>
|
<button
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
onClick={() => handleViewModeChange('country')}
|
||||||
Location
|
className={`px-4 py-2 ${viewMode === 'country' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
</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">
|
Countries
|
||||||
Visits
|
</button>
|
||||||
</th>
|
<button
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
onClick={() => handleViewModeChange('city')}
|
||||||
Unique Visitors
|
className={`px-4 py-2 ${viewMode === 'city' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
</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">
|
Cities
|
||||||
Percentage
|
</button>
|
||||||
</th>
|
<button
|
||||||
</tr>
|
onClick={() => handleViewModeChange('region')}
|
||||||
</thead>
|
className={`px-4 py-2 ${viewMode === 'region' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
>
|
||||||
{data.map((item, index) => (
|
Regions
|
||||||
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}>
|
</button>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
<button
|
||||||
{item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country || item.location || 'Unknown'}
|
onClick={() => handleViewModeChange('continent')}
|
||||||
</td>
|
className={`px-4 py-2 ${viewMode === 'continent' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
>
|
||||||
{formatNumber(item.visits)}
|
Continents
|
||||||
</td>
|
</button>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
</div>
|
||||||
{formatNumber(item.uniqueVisitors || item.visitors)}
|
|
||||||
</td>
|
{/* Loading indicator */}
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
{isLoading && (
|
||||||
<div className="flex items-center">
|
<div className="flex justify-center items-center py-2 mb-4">
|
||||||
<span className="mr-2">{formatPercent(item.percentage)}%</span>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
|
||||||
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<span className="text-sm text-gray-500">Loading location data...</span>
|
||||||
<div
|
</div>
|
||||||
className="bg-blue-500 h-2 rounded-full"
|
)}
|
||||||
style={{ width: `${item.percentage || 0}%` }}
|
|
||||||
/>
|
{/* Table with added area column */}
|
||||||
</div>
|
<div className="overflow-x-auto">
|
||||||
</div>
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
</td>
|
<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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
</table>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
203
app/components/analytics/UtmAnalytics.tsx
Normal file
203
app/components/analytics/UtmAnalytics.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface UtmData {
|
||||||
|
utm_value: string;
|
||||||
|
clicks: number;
|
||||||
|
visitors: number;
|
||||||
|
avg_time_spent: number;
|
||||||
|
bounces: number;
|
||||||
|
conversions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UtmAnalyticsProps {
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
linkId?: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UtmAnalytics({ startTime, endTime, linkId, teamIds, projectIds, tagIds }: UtmAnalyticsProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('source');
|
||||||
|
const [utmData, setUtmData] = useState<UtmData[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 加载UTM数据
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUtmData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建URL参数
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (startTime) params.append('startTime', startTime);
|
||||||
|
if (endTime) params.append('endTime', endTime);
|
||||||
|
if (linkId) params.append('linkId', linkId);
|
||||||
|
params.append('utmType', activeTab);
|
||||||
|
|
||||||
|
// 添加团队ID参数
|
||||||
|
if (teamIds && teamIds.length > 0) {
|
||||||
|
teamIds.forEach(id => params.append('teamId', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加项目ID参数
|
||||||
|
if (projectIds && projectIds.length > 0) {
|
||||||
|
projectIds.forEach(id => params.append('projectId', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加标签名称参数
|
||||||
|
if (tagIds && tagIds.length > 0) {
|
||||||
|
tagIds.forEach(tagName => params.append('tagName', tagName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await fetch(`/api/events/utm?${params}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch UTM data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setUtmData(result.data || []);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to fetch UTM data');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||||
|
console.error('Error fetching UTM data:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchUtmData();
|
||||||
|
}, [activeTab, startTime, endTime, linkId, teamIds, projectIds, tagIds]);
|
||||||
|
|
||||||
|
// 安全地格式化数字
|
||||||
|
const formatNumber = (value: number | undefined | null): string => {
|
||||||
|
if (value === undefined || value === null) return '0';
|
||||||
|
return value.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">UTM Parameters</h2>
|
||||||
|
|
||||||
|
<div className="mb-4 border-b">
|
||||||
|
<div className="flex">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('source')}
|
||||||
|
className={`px-4 py-2 ${activeTab === 'source' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Source
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('medium')}
|
||||||
|
className={`px-4 py-2 ${activeTab === 'medium' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Medium
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('campaign')}
|
||||||
|
className={`px-4 py-2 ${activeTab === 'campaign' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Campaign
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('term')}
|
||||||
|
className={`px-4 py-2 ${activeTab === 'term' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Term
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('content')}
|
||||||
|
className={`px-4 py-2 ${activeTab === 'content' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||||
|
<span className="ml-2 text-gray-500">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-red-500 text-center py-8">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
) : utmData.length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-center py-8">
|
||||||
|
No data available
|
||||||
|
</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">
|
||||||
|
{activeTab === 'source' ? 'Source' :
|
||||||
|
activeTab === 'medium' ? 'Medium' :
|
||||||
|
activeTab === 'campaign' ? 'Campaign' :
|
||||||
|
activeTab === 'term' ? 'Term' : 'Content'}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Clicks
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Visitors
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Avg. Time
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Bounce Rate
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Conversions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{utmData.map((item, index) => {
|
||||||
|
const bounceRate = item.clicks > 0 ? (item.bounces / item.clicks) * 100 : 0;
|
||||||
|
const conversionRate = item.clicks > 0 ? (item.conversions / item.clicks) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{item.utm_value || 'Unknown'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatNumber(item.clicks)}
|
||||||
|
</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">
|
||||||
|
{item.avg_time_spent.toFixed(1)}s
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{bounceRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatNumber(item.conversions)} ({conversionRate.toFixed(1)}%)
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -137,6 +137,11 @@ export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
},
|
||||||
|
label: (context) => {
|
||||||
|
const label = context.dataset.label || '';
|
||||||
|
const value = context.parsed.y;
|
||||||
|
return `${label}: ${Math.round(value)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,9 +165,9 @@ export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
|||||||
callback: (value: number) => {
|
callback: (value: number) => {
|
||||||
if (!value && value !== 0) return '';
|
if (!value && value !== 0) return '';
|
||||||
if (value >= 1000) {
|
if (value >= 1000) {
|
||||||
return `${(value / 1000).toFixed(1)}k`;
|
return `${Math.round(value / 1000)}k`;
|
||||||
}
|
}
|
||||||
return value;
|
return Math.round(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
app/components/layout/Header.tsx
Normal file
68
app/components/layout/Header.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'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="/analytics" 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>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<nav className="ml-6">
|
||||||
|
<ul className="flex space-x-4">
|
||||||
|
<li>
|
||||||
|
<Link href="/analytics" className="text-sm text-gray-700 hover:text-blue-500">
|
||||||
|
Analytics
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/links" className="text-sm text-gray-700 hover:text-blue-500">
|
||||||
|
Short Links
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</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,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import ThemeToggle from "../ui/ThemeToggle";
|
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
return (
|
return (
|
||||||
@@ -40,7 +39,6 @@ export default function Navbar() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<ThemeToggle />
|
|
||||||
<button className="p-2 text-sm text-foreground rounded-md gradient-border">
|
<button className="p-2 text-sm text-foreground rounded-md gradient-border">
|
||||||
Upgrade
|
Upgrade
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,48 +1,61 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRange {
|
||||||
value: {
|
from: Date;
|
||||||
from: Date;
|
to: Date;
|
||||||
to: Date;
|
|
||||||
};
|
|
||||||
onChange: (range: { from: Date; to: Date }) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
interface DateRangePickerProps {
|
||||||
const [from, setFrom] = useState(format(value.from, 'yyyy-MM-dd'));
|
value: DateRange;
|
||||||
const [to, setTo] = useState(format(value.to, 'yyyy-MM-dd'));
|
onChange: (value: DateRange) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
export function DateRangePicker({
|
||||||
setFrom(format(value.from, 'yyyy-MM-dd'));
|
value,
|
||||||
setTo(format(value.to, 'yyyy-MM-dd'));
|
onChange,
|
||||||
}, [value]);
|
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 handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newFrom = e.target.value;
|
const newFrom = e.target.value;
|
||||||
setFrom(newFrom);
|
setFrom(newFrom);
|
||||||
onChange({
|
|
||||||
from: new Date(newFrom),
|
if (newFrom) {
|
||||||
to: value.to
|
onChange({
|
||||||
});
|
from: new Date(newFrom),
|
||||||
|
to: value.to
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newTo = e.target.value;
|
const newTo = e.target.value;
|
||||||
setTo(newTo);
|
setTo(newTo);
|
||||||
onChange({
|
|
||||||
from: value.from,
|
if (newTo) {
|
||||||
to: new Date(newTo)
|
onChange({
|
||||||
});
|
from: value.from,
|
||||||
|
to: new Date(newTo)
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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>
|
<div>
|
||||||
<label htmlFor="from" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
<label htmlFor="from" className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
From
|
Start Date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -50,12 +63,12 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
|||||||
value={from}
|
value={from}
|
||||||
onChange={handleFromChange}
|
onChange={handleFromChange}
|
||||||
max={to}
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="to" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
<label htmlFor="to" className="block text-sm font-medium text-gray-500 mb-1">
|
||||||
To
|
End Date
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -63,7 +76,7 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
|||||||
value={to}
|
value={to}
|
||||||
onChange={handleToChange}
|
onChange={handleToChange}
|
||||||
min={from}
|
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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
app/components/ui/Select.tsx
Normal file
88
app/components/ui/Select.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ value, onChange, options, placeholder, className = '' }: SelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedOption = options.find(option => option.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} ref={containerRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span className="flex items-center">
|
||||||
|
{selectedOption?.icon && (
|
||||||
|
<img
|
||||||
|
src={selectedOption.icon}
|
||||||
|
alt=""
|
||||||
|
className="mr-2 h-4 w-4 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedOption?.label || placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80">
|
||||||
|
<div className="p-1">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => {
|
||||||
|
onChange?.(option.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground ${
|
||||||
|
option.value === value ? 'bg-accent text-accent-foreground' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.icon && (
|
||||||
|
<img
|
||||||
|
src={option.icon}
|
||||||
|
alt=""
|
||||||
|
className="mr-2 h-4 w-4 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
258
app/components/ui/TeamSelector.tsx
Normal file
258
app/components/ui/TeamSelector.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import type { Database } from '@/types/supabase';
|
||||||
|
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'];
|
||||||
|
|
||||||
|
// TeamSelector component with multi-select support
|
||||||
|
export function TeamSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
multiple = false,
|
||||||
|
}: {
|
||||||
|
value?: string | string[];
|
||||||
|
onChange?: (teamId: string | string[]) => void;
|
||||||
|
className?: string;
|
||||||
|
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;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
|
||||||
|
const { data: memberships, error: membershipError } = await supabase
|
||||||
|
.from('team_membership')
|
||||||
|
.select('team_id')
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (membershipError) throw membershipError;
|
||||||
|
|
||||||
|
if (!memberships || memberships.length === 0) {
|
||||||
|
if (isMounted) setTeams([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamIds = memberships.map(m => m.team_id);
|
||||||
|
const { data: teamsData, error: teamsError } = await supabase
|
||||||
|
.from('teams')
|
||||||
|
.select('*')
|
||||||
|
.in('id', teamIds)
|
||||||
|
.is('deleted_at', null);
|
||||||
|
|
||||||
|
if (teamsError) throw teamsError;
|
||||||
|
|
||||||
|
if (isMounted && teamsData) {
|
||||||
|
setTeams(teamsData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isMounted) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load teams');
|
||||||
|
}
|
||||||
|
} 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) {
|
||||||
|
fetchTeams(session.user.id);
|
||||||
|
} else if (event === 'SIGNED_OUT') {
|
||||||
|
setTeams([]);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
if (session?.user?.id) {
|
||||||
|
fetchTeams(session.user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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={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 (teams.length === 0) {
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
|
||||||
const [darkMode, setDarkMode] = useState(false);
|
|
||||||
|
|
||||||
// Initialize theme on component mount
|
|
||||||
useEffect(() => {
|
|
||||||
const isDarkMode = localStorage.getItem('darkMode') === 'true';
|
|
||||||
setDarkMode(isDarkMode);
|
|
||||||
|
|
||||||
if (isDarkMode) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update theme when darkMode state changes
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const newDarkMode = !darkMode;
|
|
||||||
setDarkMode(newDarkMode);
|
|
||||||
localStorage.setItem('darkMode', newDarkMode.toString());
|
|
||||||
|
|
||||||
if (newDarkMode) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="p-2 rounded-md bg-card-bg border border-card-border hover:bg-card-bg/80 transition-colors"
|
|
||||||
aria-label={darkMode ? "Switch to light mode" : "Switch to dark mode"}
|
|
||||||
>
|
|
||||||
{darkMode ? (
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-accent-yellow"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-foreground"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -30,30 +30,6 @@
|
|||||||
--gradient-red: linear-gradient(135deg, #f43f5e, #e11d48);
|
--gradient-red: linear-gradient(135deg, #f43f5e, #e11d48);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
|
||||||
/* Dark Mode */
|
|
||||||
--background: #0f172a;
|
|
||||||
--foreground: #ffffff;
|
|
||||||
|
|
||||||
/* Card colors */
|
|
||||||
--card-bg: #1e293b;
|
|
||||||
--card-border: #334155;
|
|
||||||
|
|
||||||
/* Vibrant accent colors */
|
|
||||||
--accent-blue: #3b82f6;
|
|
||||||
--accent-green: #10b981;
|
|
||||||
--accent-red: #f43f5e;
|
|
||||||
--accent-yellow: #f59e0b;
|
|
||||||
--accent-purple: #8b5cf6;
|
|
||||||
--accent-pink: #ec4899;
|
|
||||||
--accent-teal: #14b8a6;
|
|
||||||
--accent-orange: #f97316;
|
|
||||||
|
|
||||||
/* UI colors */
|
|
||||||
--text-secondary: #94a3b8;
|
|
||||||
--progress-bg: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
import '@radix-ui/themes/styles.css';
|
||||||
import type { Metadata } from 'next';
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: 'Link Management & Analytics',
|
title: 'ShortURL Analytics',
|
||||||
description: 'Track and analyze shortened links',
|
description: 'Track and analyze shortened links',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,7 +18,12 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
{children}
|
<Theme>
|
||||||
|
<AuthProvider>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
</AuthProvider>
|
||||||
|
</Theme>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
653
app/links/page.tsx
Normal file
653
app/links/page.tsx
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getSupabaseClient } from '../utils/supabase';
|
||||||
|
import { AuthChangeEvent } from '@supabase/supabase-js';
|
||||||
|
import { Loader2, ExternalLink, Search } from 'lucide-react';
|
||||||
|
import { TeamSelector } from '@/app/components/ui/TeamSelector';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useShortUrlStore, ShortUrlData } from '@/app/utils/store';
|
||||||
|
|
||||||
|
// Define attribute type to avoid using 'any'
|
||||||
|
interface LinkAttributes {
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
original_url?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
visits?: number;
|
||||||
|
click_count?: number;
|
||||||
|
team_id?: string;
|
||||||
|
team_name?: string;
|
||||||
|
tags?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 ShortLink 类型定义以匹配 ClickHouse 数据结构
|
||||||
|
interface ShortLink {
|
||||||
|
id: string;
|
||||||
|
external_id?: string;
|
||||||
|
type?: string;
|
||||||
|
slug?: string;
|
||||||
|
original_url?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
attributes: string | Record<string, unknown>;
|
||||||
|
schema_version?: number;
|
||||||
|
creator_id?: string;
|
||||||
|
creator_email?: string;
|
||||||
|
creator_name?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
projects?: string | Record<string, unknown>[];
|
||||||
|
teams?: string | Record<string, unknown>[];
|
||||||
|
tags?: string | Record<string, unknown>[];
|
||||||
|
qr_codes?: string | Record<string, unknown>[];
|
||||||
|
channels?: string | Record<string, unknown>[];
|
||||||
|
favorites?: string | Record<string, unknown>[];
|
||||||
|
expires_at?: string | null;
|
||||||
|
click_count?: number;
|
||||||
|
unique_visitors?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define ClickHouse shorturl type
|
||||||
|
interface ClickHouseShortUrl {
|
||||||
|
id: string;
|
||||||
|
external_id: string;
|
||||||
|
type: string;
|
||||||
|
slug: string;
|
||||||
|
original_url: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
attributes: string; // JSON string
|
||||||
|
schema_version: number;
|
||||||
|
creator_id: string;
|
||||||
|
creator_email: string;
|
||||||
|
creator_name: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
projects: string; // JSON string
|
||||||
|
teams: string; // JSON string
|
||||||
|
tags: string; // JSON string
|
||||||
|
qr_codes: string; // JSON string
|
||||||
|
channels: string; // JSON string
|
||||||
|
favorites: string; // JSON string
|
||||||
|
expires_at: string | null;
|
||||||
|
click_count: number;
|
||||||
|
unique_visitors: number;
|
||||||
|
link_attributes?: string; // Optional JSON string containing link-specific attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 示例团队数据 - 实际应用中应从API获取
|
||||||
|
const teams = [
|
||||||
|
{ id: 'marketing', name: 'Marketing' },
|
||||||
|
{ id: 'sales', name: 'Sales' },
|
||||||
|
{ id: 'product', name: 'Product' },
|
||||||
|
{ id: 'engineering', name: 'Engineering' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 将 ClickHouse 数据转换为 ShortLink 格式
|
||||||
|
const convertClickHouseToShortLink = (data: Record<string, unknown>): ShortLink => {
|
||||||
|
return {
|
||||||
|
...data as any, // 使用类型断言处理泛型记录转换
|
||||||
|
// 确保关键字段存在
|
||||||
|
id: data.id as string || '',
|
||||||
|
created_at: data.created_at as string || new Date().toISOString(),
|
||||||
|
attributes: data.attributes || '{}'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LinksPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [links, setLinks] = useState<ShortLink[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [teamFilter, setTeamFilter] = useState<string | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [totalLinks, setTotalLinks] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [searchDebounce, setSearchDebounce] = useState<NodeJS.Timeout | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 使用 Zustand store
|
||||||
|
const { setSelectedShortUrl } = useShortUrlStore();
|
||||||
|
|
||||||
|
// 处理点击链接行
|
||||||
|
const handleRowClick = (link: any) => {
|
||||||
|
// 解析 attributes 字符串为对象
|
||||||
|
let attributes: Record<string, any> = {};
|
||||||
|
try {
|
||||||
|
if (link.attributes && typeof link.attributes === 'string') {
|
||||||
|
attributes = JSON.parse(link.attributes || '{}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing link attributes:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 teams 字符串为数组
|
||||||
|
let teams: any[] = [];
|
||||||
|
try {
|
||||||
|
if (link.teams && typeof link.teams === 'string') {
|
||||||
|
teams = JSON.parse(link.teams || '[]');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing teams:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 projects 字符串为数组
|
||||||
|
let projects: any[] = [];
|
||||||
|
try {
|
||||||
|
if (link.projects && typeof link.projects === 'string') {
|
||||||
|
projects = JSON.parse(link.projects || '[]');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing projects:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 tags 字符串为数组
|
||||||
|
let tags: string[] = [];
|
||||||
|
try {
|
||||||
|
if (link.tags && typeof link.tags === 'string') {
|
||||||
|
const parsedTags = JSON.parse(link.tags);
|
||||||
|
if (Array.isArray(parsedTags)) {
|
||||||
|
tags = parsedTags.map((tag: { tag_name?: string }) => tag.tag_name || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing tags:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 shortUrl 存在
|
||||||
|
const shortUrlValue = attributes.shortUrl || '';
|
||||||
|
|
||||||
|
// 提取用于显示的字段
|
||||||
|
const shortUrlData = {
|
||||||
|
id: link.id,
|
||||||
|
externalId: link.external_id, // 明确添加 externalId 字段
|
||||||
|
slug: link.slug,
|
||||||
|
originalUrl: link.original_url,
|
||||||
|
title: link.title,
|
||||||
|
shortUrl: shortUrlValue,
|
||||||
|
teams: teams,
|
||||||
|
projects: projects,
|
||||||
|
tags: tags,
|
||||||
|
createdAt: link.created_at,
|
||||||
|
domain: shortUrlValue ? new URL(shortUrlValue).hostname : 'shorturl.example.com'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打印完整数据,确保 externalId 被包含
|
||||||
|
console.log('Setting shortURL data in store with externalId:', link.external_id);
|
||||||
|
|
||||||
|
// 将数据保存到 Zustand store
|
||||||
|
setSelectedShortUrl(shortUrlData);
|
||||||
|
|
||||||
|
// 导航到分析页面,并在 URL 中包含 shortUrl 参数
|
||||||
|
router.push(`/analytics?shorturl=${encodeURIComponent(shortUrlValue)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract link metadata from attributes
|
||||||
|
const getLinkMetadata = (link: ShortLink) => {
|
||||||
|
try {
|
||||||
|
// Parse attributes if it's a string
|
||||||
|
const attributes = typeof link.attributes === 'string'
|
||||||
|
? JSON.parse(link.attributes)
|
||||||
|
: link.attributes || {};
|
||||||
|
|
||||||
|
// Parse attributes to get domain if available
|
||||||
|
let domain = 'shorturl.example.com';
|
||||||
|
try {
|
||||||
|
// Extract domain from shortUrl in attributes if available
|
||||||
|
const attributesObj = typeof link.attributes === 'string'
|
||||||
|
? JSON.parse(link.attributes)
|
||||||
|
: link.attributes || {};
|
||||||
|
|
||||||
|
if (attributesObj.shortUrl) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(attributesObj.shortUrl);
|
||||||
|
domain = urlObj.hostname;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing shortUrl:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing attributes:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get team names
|
||||||
|
const teamNames: string[] = [];
|
||||||
|
try {
|
||||||
|
if (link.teams) {
|
||||||
|
const teams = typeof link.teams === 'string'
|
||||||
|
? JSON.parse(link.teams)
|
||||||
|
: link.teams || [];
|
||||||
|
|
||||||
|
if (Array.isArray(teams)) {
|
||||||
|
teams.forEach(team => {
|
||||||
|
if (team.team_name) {
|
||||||
|
teamNames.push(team.team_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing teams:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project names
|
||||||
|
const projectNames: string[] = [];
|
||||||
|
try {
|
||||||
|
if (link.projects) {
|
||||||
|
const projects = typeof link.projects === 'string'
|
||||||
|
? JSON.parse(link.projects)
|
||||||
|
: link.projects || [];
|
||||||
|
|
||||||
|
if (Array.isArray(projects)) {
|
||||||
|
projects.forEach(project => {
|
||||||
|
if (project.project_name) {
|
||||||
|
projectNames.push(project.project_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing projects:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tag names
|
||||||
|
const tagNames: string[] = [];
|
||||||
|
try {
|
||||||
|
if (link.tags) {
|
||||||
|
const tags = typeof link.tags === 'string'
|
||||||
|
? JSON.parse(link.tags)
|
||||||
|
: link.tags || [];
|
||||||
|
|
||||||
|
if (Array.isArray(tags)) {
|
||||||
|
tags.forEach(tag => {
|
||||||
|
if (tag.tag_name) {
|
||||||
|
tagNames.push(tag.tag_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing tags:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: link.title || attributes.title || 'Untitled',
|
||||||
|
slug: link.slug || attributes.slug || '',
|
||||||
|
domain: domain,
|
||||||
|
originalUrl: link.original_url || attributes.originalUrl || attributes.original_url || '',
|
||||||
|
teamNames: teamNames,
|
||||||
|
projectNames: projectNames,
|
||||||
|
tagNames: tagNames,
|
||||||
|
teamName: teamNames[0] || '', // Keep for backward compatibility
|
||||||
|
createdAt: new Date(link.created_at).toLocaleDateString(),
|
||||||
|
visits: link.click_count || 0
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing link metadata:', error);
|
||||||
|
return {
|
||||||
|
title: 'Error parsing data',
|
||||||
|
slug: '',
|
||||||
|
domain: 'shorturl.example.com',
|
||||||
|
originalUrl: '',
|
||||||
|
teamNames: [],
|
||||||
|
projectNames: [],
|
||||||
|
tagNames: [],
|
||||||
|
teamName: '',
|
||||||
|
createdAt: '',
|
||||||
|
visits: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const fetchLinks = async () => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch data from ClickHouse API with pagination parameters
|
||||||
|
const response = await fetch(`/api/shortlinks?page=${currentPage}&page_size=${pageSize}${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}${teamFilter ? `&team=${encodeURIComponent(teamFilter)}` : ''}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch links: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || !data.links || data.links.length === 0) {
|
||||||
|
if (isMounted) {
|
||||||
|
setLinks([]);
|
||||||
|
setTotalLinks(0);
|
||||||
|
setTotalPages(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ClickHouse data format to ShortLink format
|
||||||
|
const convertedLinks = data.links.map(convertClickHouseToShortLink);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setLinks(convertedLinks);
|
||||||
|
setTotalLinks(data.total || convertedLinks.length);
|
||||||
|
setTotalPages(data.total_pages || Math.ceil(data.total / pageSize) || 1);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isMounted) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load short URLs');
|
||||||
|
console.error("Error fetching links:", err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscribe to user auth state
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
|
(event: AuthChangeEvent) => {
|
||||||
|
if (event === 'SIGNED_IN' || event === 'USER_UPDATED') {
|
||||||
|
fetchLinks();
|
||||||
|
}
|
||||||
|
if (event === 'SIGNED_OUT') {
|
||||||
|
setLinks([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
fetchLinks();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [currentPage, pageSize, searchQuery, teamFilter]);
|
||||||
|
|
||||||
|
// Handle search input with debounce
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (searchDebounce) {
|
||||||
|
clearTimeout(searchDebounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the input value immediately for UI feedback
|
||||||
|
setSearchQuery(value);
|
||||||
|
|
||||||
|
// Set a timeout to actually perform the search
|
||||||
|
setSearchDebounce(setTimeout(() => {
|
||||||
|
setCurrentPage(1); // Reset to page 1 when searching
|
||||||
|
}, 500)); // 500ms debounce
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && links.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-96 w-full items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-96 w-full flex-col items-center justify-center text-red-500">
|
||||||
|
<p>Error loading shortcuts: {error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<h1 className="mb-6 text-2xl font-bold text-gray-900">Short URL Links</h1>
|
||||||
|
|
||||||
|
{/* Search and filters */}
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||||||
|
<div className="relative flex-grow">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search links..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setCurrentPage(1); // Reset to page 1 when searching
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TeamSelector
|
||||||
|
value={teamFilter || ''}
|
||||||
|
onChange={(value) => {
|
||||||
|
// 如果是多选模式,值将是数组。对于空数组,设置为 null
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
setTeamFilter(value.length > 0 ? value[0] : null);
|
||||||
|
} else {
|
||||||
|
setTeamFilter(value || null);
|
||||||
|
}
|
||||||
|
setCurrentPage(1); // Reset to page 1 when filtering
|
||||||
|
}}
|
||||||
|
className="w-64"
|
||||||
|
multiple={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links table */}
|
||||||
|
<div className="overflow-hidden rounded-lg border border-gray-200 shadow">
|
||||||
|
<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 uppercase tracking-wider text-gray-500">Link</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Original URL</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Team</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
|
{links.map(link => {
|
||||||
|
const metadata = getLinkMetadata(link);
|
||||||
|
const shortUrl = `https://${metadata.domain}/${metadata.slug}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={link.id} className="hover:bg-gray-50 cursor-pointer" onClick={() => handleRowClick(link)}>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<span className="font-medium text-gray-900">{metadata.title}</span>
|
||||||
|
<span className="text-xs text-blue-500">{shortUrl}</span>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{metadata.tagNames.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{metadata.tagNames.map((tag, index) => (
|
||||||
|
<span key={index} className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
<a
|
||||||
|
href={metadata.originalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center hover:text-blue-500"
|
||||||
|
>
|
||||||
|
<span className="max-w-xs truncate">{metadata.originalUrl}</span>
|
||||||
|
<ExternalLink className="ml-1 h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
{/* Teams */}
|
||||||
|
{metadata.teamNames.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{metadata.teamNames.map((team, index) => (
|
||||||
|
<span key={index} className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800">
|
||||||
|
{team}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>-</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Projects */}
|
||||||
|
{metadata.projectNames.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
{metadata.projectNames.map((project, index) => (
|
||||||
|
<span key={index} className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
|
||||||
|
{project}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{metadata.createdAt}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 0 && (
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalLinks)} of {totalLinks} results
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||||
|
// Create a window of 5 pages around current page
|
||||||
|
let pageNumber;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNumber = i + 1;
|
||||||
|
} else {
|
||||||
|
const start = Math.max(1, currentPage - 2);
|
||||||
|
const end = Math.min(totalPages, start + 4);
|
||||||
|
pageNumber = start + i;
|
||||||
|
if (pageNumber > end) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNumber}
|
||||||
|
onClick={() => setCurrentPage(pageNumber)}
|
||||||
|
className={`h-8 w-8 rounded-md text-sm ${
|
||||||
|
currentPage === pageNumber
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page input */}
|
||||||
|
<div className="ml-4 flex items-center space-x-1">
|
||||||
|
<span className="text-sm text-gray-500">Go to:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={totalPages}
|
||||||
|
value={currentPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
// Allow input to be cleared for typing
|
||||||
|
if (e.target.value === '') {
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// Ensure a valid value on blur
|
||||||
|
const value = parseInt(e.target.value, 10);
|
||||||
|
if (isNaN(value) || value < 1) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
} else if (value > totalPages) {
|
||||||
|
setCurrentPage(totalPages);
|
||||||
|
} else {
|
||||||
|
setCurrentPage(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const value = parseInt(e.currentTarget.value, 10);
|
||||||
|
if (!isNaN(value) && value >= 1 && value <= totalPages) {
|
||||||
|
setCurrentPage(value);
|
||||||
|
} else if (!isNaN(value) && value < 1) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
} else if (!isNaN(value) && value > totalPages) {
|
||||||
|
setCurrentPage(totalPages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-16 rounded-md border border-gray-300 px-2 py-1 text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">of {totalPages}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setCurrentPage(1); // Reset to page 1 when changing page size
|
||||||
|
}}
|
||||||
|
className="ml-4 rounded-md border border-gray-300 py-1.5 pl-3 pr-8 text-sm"
|
||||||
|
>
|
||||||
|
<option value="10">10 per page</option>
|
||||||
|
<option value="25">25 per page</option>
|
||||||
|
<option value="50">50 per page</option>
|
||||||
|
<option value="100">100 per page</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{links.length === 0 && (
|
||||||
|
<div className="mt-6 rounded-md bg-gray-50 p-6 text-center text-gray-500">
|
||||||
|
No links match your search criteria
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
app/login/page.tsx
Normal file
221
app/login/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { signIn, signInWithGitHub, signInWithGoogle, user } = useAuth();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState({ type: '', content: '' });
|
||||||
|
|
||||||
|
// 如果用户已登录,重定向到首页
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
}, [user, router]);
|
||||||
|
|
||||||
|
const handleEmailSignIn = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
content: 'Please enter both email and password'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setMessage({ type: '', content: '' });
|
||||||
|
|
||||||
|
const { error } = await signIn(email, password);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功后,会通过 useEffect 重定向
|
||||||
|
} catch (error) {
|
||||||
|
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">
|
||||||
|
<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">Login</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Sign in to your account to access analytics
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Welcome to ShortURL Analytics
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{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"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">Or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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" 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>
|
||||||
|
GitHub
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/page.tsx
86
app/page.tsx
@@ -1,85 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function Home() {
|
||||||
const sections = [
|
redirect('/analytics');
|
||||||
{
|
|
||||||
title: 'Dashboard',
|
|
||||||
description: 'Get an overview of your link performance with key metrics and trends.',
|
|
||||||
href: '/dashboard',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Events',
|
|
||||||
description: 'Track and analyze all events including clicks, conversions, and more.',
|
|
||||||
href: '/events',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Geographic',
|
|
||||||
description: 'See where your visitors are coming from with detailed location data.',
|
|
||||||
href: '/analytics/geo',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Devices',
|
|
||||||
description: 'Understand what devices, browsers, and operating systems your visitors use.',
|
|
||||||
href: '/analytics/devices',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
|
||||||
Welcome to ShortURL Analytics
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
|
||||||
Get detailed insights into your link performance and visitor behavior
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{sections.map((section) => (
|
|
||||||
<Link
|
|
||||||
key={section.title}
|
|
||||||
href={section.href}
|
|
||||||
className="group block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200"
|
|
||||||
>
|
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg mr-4">
|
|
||||||
<div className="text-blue-600 dark:text-blue-300">
|
|
||||||
{section.icon}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
|
|
||||||
{section.title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
{section.description}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
195
app/register/page.tsx
Normal file
195
app/register/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, FormEvent } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { signUp, signInWithGoogle } = useAuth();
|
||||||
|
|
||||||
|
// 处理注册表单提交
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('两次输入的密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码强度验证
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('密码长度至少为6个字符');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await signUp(email, password);
|
||||||
|
// 注册成功后会跳转到登录页面,提示用户验证邮箱
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
setError('注册失败,请稍后再试或使用其他邮箱');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理Google注册/登录
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await signInWithGoogle();
|
||||||
|
// 登录流程会重定向到Google,然后回到应用
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google sign in error:', error);
|
||||||
|
setError('Google登录失败,请稍后再试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="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">
|
||||||
|
创建您的帐户以访问分析仪表板
|
||||||
|
</p>
|
||||||
|
</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}
|
||||||
|
</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="new-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="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
确认密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{isLoading ? '注册中...' : '注册'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
使用Google账号注册
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
已有账号?{' '}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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';
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/utils/store.ts
Normal file
52
app/utils/store.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
|
// Define interface for team, project and tag objects
|
||||||
|
interface TeamData {
|
||||||
|
team_id: string;
|
||||||
|
team_name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectData {
|
||||||
|
project_id: string;
|
||||||
|
project_name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义 ShortUrl 数据类型
|
||||||
|
export interface ShortUrlData {
|
||||||
|
id: string;
|
||||||
|
externalId: string;
|
||||||
|
slug: string;
|
||||||
|
originalUrl: string;
|
||||||
|
title?: string;
|
||||||
|
shortUrl: string;
|
||||||
|
teams?: TeamData[];
|
||||||
|
projects?: ProjectData[];
|
||||||
|
tags?: string[];
|
||||||
|
createdAt?: string;
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义 store 类型
|
||||||
|
interface ShortUrlStore {
|
||||||
|
selectedShortUrl: ShortUrlData | null;
|
||||||
|
setSelectedShortUrl: (shortUrl: ShortUrlData | null) => void;
|
||||||
|
clearSelectedShortUrl: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 store 并使用 persist 中间件保存到 localStorage
|
||||||
|
export const useShortUrlStore = create<ShortUrlStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
selectedShortUrl: null,
|
||||||
|
setSelectedShortUrl: (shortUrl) => set({ selectedShortUrl: shortUrl }),
|
||||||
|
clearSelectedShortUrl: () => set({ selectedShortUrl: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'shorturl-storage', // localStorage 中的 key 名称
|
||||||
|
partialize: (state) => ({ selectedShortUrl: state.selectedShortUrl }), // 只持久化 selectedShortUrl
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
59
app/utils/supabase.ts
Normal file
59
app/utils/supabase.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "@/types/supabase";
|
||||||
|
|
||||||
|
let supabase: SupabaseClient<Database> | null = null;
|
||||||
|
|
||||||
|
// 简单的存储适配器,使用localStorage
|
||||||
|
const storageAdapter = {
|
||||||
|
getItem: async (key: string) => {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Storage get error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setItem: async (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Storage set error:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Storage remove error:", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSupabaseClient = (): SupabaseClient<Database> => {
|
||||||
|
if (!supabase) {
|
||||||
|
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
|
||||||
|
throw new Error('Missing Supabase environment variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
supabase = createClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||||
|
{
|
||||||
|
db: { schema: "limq" },
|
||||||
|
auth: {
|
||||||
|
storage: storageAdapter,
|
||||||
|
persistSession: true,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return supabase;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearSupabaseInstance = () => {
|
||||||
|
supabase = null;
|
||||||
|
};
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import { executeQuery, executeQuerySingle, buildFilter, buildPagination, buildOrderBy } from './clickhouse';
|
import { executeQuery, executeQuerySingle, buildFilter, buildPagination, buildOrderBy } from './clickhouse';
|
||||||
import type { Event, EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics, DeviceType } from './types';
|
import type { Event, EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics, DeviceType } from './types';
|
||||||
|
|
||||||
// 获取事件列表
|
// 时间粒度枚举
|
||||||
export async function getEvents(params: {
|
export enum TimeGranularity {
|
||||||
|
HOUR = 'hour',
|
||||||
|
DAY = 'day',
|
||||||
|
WEEK = 'week',
|
||||||
|
MONTH = 'month'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件查询参数类型
|
||||||
|
export interface EventsQueryParams {
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
@@ -11,11 +19,17 @@ export async function getEvents(params: {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc';
|
||||||
}): Promise<{ events: Event[]; total: number }> {
|
}
|
||||||
|
|
||||||
|
// 获取事件列表
|
||||||
|
export async function getEvents(params: EventsQueryParams): Promise<{ events: Event[]; total: number }> {
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
const pagination = buildPagination(params.page, params.pageSize);
|
const pagination = buildPagination(params.page, params.pageSize);
|
||||||
const orderBy = buildOrderBy(params.sortBy, params.sortOrder);
|
const orderBy = buildOrderBy(params.sortBy, params.sortOrder);
|
||||||
@@ -49,14 +63,19 @@ export async function getEventsSummary(params: {
|
|||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
}): Promise<EventsSummary> {
|
}): Promise<EventsSummary> {
|
||||||
|
console.log('getEventsSummary received params:', params);
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
|
console.log('getEventsSummary built filter:', filter);
|
||||||
|
|
||||||
// 获取基本统计数据
|
// 获取基本统计数据
|
||||||
const baseQuery = `
|
const baseQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
count() as totalEvents,
|
count() as totalEvents,
|
||||||
uniq(visitor_id) as uniqueVisitors,
|
uniq(ip_address) as uniqueVisitors,
|
||||||
countIf(event_type = 'conversion') as totalConversions,
|
countIf(event_type = 'conversion') as totalConversions,
|
||||||
avg(time_spent_sec) as averageTimeSpent,
|
avg(time_spent_sec) as averageTimeSpent,
|
||||||
|
|
||||||
@@ -164,6 +183,9 @@ export async function getTimeSeriesData(params: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
granularity: 'hour' | 'day' | 'week' | 'month';
|
granularity: 'hour' | 'day' | 'week' | 'month';
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
}): Promise<TimeSeriesData[]> {
|
}): Promise<TimeSeriesData[]> {
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
|
|
||||||
@@ -179,7 +201,7 @@ export async function getTimeSeriesData(params: {
|
|||||||
SELECT
|
SELECT
|
||||||
toStartOfInterval(event_time, INTERVAL ${interval}) as timestamp,
|
toStartOfInterval(event_time, INTERVAL ${interval}) as timestamp,
|
||||||
count() as events,
|
count() as events,
|
||||||
uniq(visitor_id) as visitors,
|
uniq(ip_address) as visitors,
|
||||||
countIf(event_type = 'conversion') as conversions
|
countIf(event_type = 'conversion') as conversions
|
||||||
FROM events
|
FROM events
|
||||||
${filter}
|
${filter}
|
||||||
@@ -195,23 +217,33 @@ export async function getGeoAnalytics(params: {
|
|||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
groupBy?: 'country' | 'city';
|
groupBy?: 'country' | 'city' | 'region' | 'continent';
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
}): Promise<GeoData[]> {
|
}): Promise<GeoData[]> {
|
||||||
const filter = buildFilter(params);
|
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 = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
${groupByField} as location,
|
COALESCE(${groupByField}, 'Unknown') as location,
|
||||||
|
'' as area, /* Area column - empty for now */
|
||||||
count() as visits,
|
count() as visits,
|
||||||
uniq(visitor_id) as visitors,
|
uniq(ip_address) as visitors,
|
||||||
count() * 100.0 / sum(count()) OVER () as percentage
|
count() * 100.0 / sum(count()) OVER () as percentage
|
||||||
FROM events
|
FROM events
|
||||||
${filter}
|
${filter}
|
||||||
GROUP BY ${groupByField}
|
GROUP BY location
|
||||||
HAVING location != ''
|
HAVING location != ''
|
||||||
ORDER BY visits DESC
|
ORDER BY visits DESC
|
||||||
LIMIT 10
|
LIMIT 20
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return executeQuery<GeoData>(query);
|
return executeQuery<GeoData>(query);
|
||||||
@@ -222,6 +254,9 @@ export async function getDeviceAnalytics(params: {
|
|||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
}): Promise<DeviceAnalytics> {
|
}): Promise<DeviceAnalytics> {
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
|
|
||||||
|
|||||||
287
lib/auth.tsx
Normal file
287
lib/auth.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Session, User } from '@supabase/supabase-js';
|
||||||
|
import supabase from './supabase';
|
||||||
|
|
||||||
|
// 定义用户类型
|
||||||
|
export type AuthUser = User | null;
|
||||||
|
|
||||||
|
// 定义验证上下文类型
|
||||||
|
export type AuthContextType = {
|
||||||
|
user: AuthUser;
|
||||||
|
session: Session | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
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>; // 添加自动注册测试用户函数
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建验证上下文
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// 测试账户常量 - 使用已验证的账户
|
||||||
|
const TEST_EMAIL = 'vitalitymailg@gmail.com';
|
||||||
|
const TEST_PASSWORD = 'password123';
|
||||||
|
|
||||||
|
// 验证提供者组件
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<AuthUser>(null);
|
||||||
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 初始化验证状态
|
||||||
|
useEffect(() => {
|
||||||
|
const getSession = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// 尝试从Supabase获取会话
|
||||||
|
const { data: { session }, error } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error getting session:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(session);
|
||||||
|
setUser(session?.user || null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unexpected error during getSession:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getSession();
|
||||||
|
|
||||||
|
// 监听验证状态变化
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
setSession(session);
|
||||||
|
setUser(session?.user || null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 登录函数
|
||||||
|
const signIn = async (email: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
console.log('尝试登录:', { email });
|
||||||
|
|
||||||
|
// 尝试通过Supabase登录
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('登录出错:', error);
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(data.session);
|
||||||
|
setUser(data.user);
|
||||||
|
router.push('/dashboard');
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录过程出错:', error);
|
||||||
|
return { error };
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Google登录函数
|
||||||
|
const signInWithGoogle = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// 尝试通过Supabase登录Google
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
|
provider: 'google',
|
||||||
|
options: {
|
||||||
|
redirectTo: `${window.location.origin}/auth/callback`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Google登录出错:', error);
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}; // Return empty object when successful
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Google登录过程出错:', 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注册函数
|
||||||
|
const signUp = async (email: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// 尝试通过Supabase注册
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('注册出错:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册成功后跳转到登录页面并显示确认消息
|
||||||
|
router.push('/login?message=注册成功,请查看邮箱确认账户');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('注册过程出错:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 登出函数
|
||||||
|
const signOut = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// 尝试通过Supabase登出
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
if (error) {
|
||||||
|
console.error('登出出错:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
setSession(null);
|
||||||
|
setUser(null);
|
||||||
|
router.push('/login');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登出过程出错:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自动注册测试用户函数
|
||||||
|
const autoRegisterTestUser = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
console.log('正在使用测试账户登录:', TEST_EMAIL);
|
||||||
|
|
||||||
|
// 使用测试账户直接登录
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('测试账户登录失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('测试账户登录成功!');
|
||||||
|
setSession(data.session);
|
||||||
|
setUser(data.user);
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('测试账户登录出错:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const contextValue: AuthContextType = {
|
||||||
|
user,
|
||||||
|
session,
|
||||||
|
isLoading,
|
||||||
|
signIn,
|
||||||
|
signInWithGoogle,
|
||||||
|
signInWithGitHub,
|
||||||
|
signUp,
|
||||||
|
signOut,
|
||||||
|
autoRegisterTestUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自定义钩子
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 受保护路由组件
|
||||||
|
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const { user, isLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !user) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [user, isLoading, router]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-lg text-gray-700 dark:text-gray-300">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthContext;
|
||||||
@@ -26,84 +26,112 @@ function buildDateFilter(startTime?: string, endTime?: string): string {
|
|||||||
|
|
||||||
// 构建通用过滤条件
|
// 构建通用过滤条件
|
||||||
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
||||||
|
console.log('buildFilter received params:', JSON.stringify(params));
|
||||||
const filters = [];
|
const filters = [];
|
||||||
|
|
||||||
// 时间范围过滤
|
// 添加日期过滤条件
|
||||||
if (params.startTime || params.endTime) {
|
if (params.startTime || params.endTime) {
|
||||||
const dateFilter = buildDateFilter(params.startTime, params.endTime).replace('WHERE ', '');
|
const dateFilter = buildDateFilter(params.startTime, params.endTime);
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
filters.push(dateFilter);
|
filters.push(dateFilter.replace('WHERE ', ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 事件类型过滤
|
// 添加事件类型过滤条件
|
||||||
if (params.eventType) {
|
if (params.eventType) {
|
||||||
filters.push(`event_type = '${params.eventType}'`);
|
filters.push(`event_type = '${params.eventType}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 链接ID过滤
|
// 添加链接ID过滤条件
|
||||||
if (params.linkId) {
|
if (params.linkId) {
|
||||||
|
console.log('Adding link_id filter:', params.linkId);
|
||||||
filters.push(`link_id = '${params.linkId}'`);
|
filters.push(`link_id = '${params.linkId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 链接短码过滤
|
// 添加链接Slug过滤条件
|
||||||
if (params.linkSlug) {
|
if (params.linkSlug) {
|
||||||
filters.push(`link_slug = '${params.linkSlug}'`);
|
filters.push(`link_slug = '${params.linkSlug}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户ID过滤
|
// 添加用户ID过滤条件
|
||||||
if (params.userId) {
|
if (params.userId) {
|
||||||
filters.push(`user_id = '${params.userId}'`);
|
filters.push(`user_id = '${params.userId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 团队ID过滤
|
// 添加团队ID过滤条件
|
||||||
if (params.teamId) {
|
if (params.teamId) {
|
||||||
filters.push(`team_id = '${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) {
|
if (params.projectId) {
|
||||||
filters.push(`project_id = '${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 ')}` : '';
|
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建分页
|
// 构建分页条件
|
||||||
export function buildPagination(page?: number, pageSize?: number): string {
|
export function buildPagination(page: number = 1, pageSize: number = 20): string {
|
||||||
const limit = pageSize || 20;
|
const offset = (page - 1) * pageSize;
|
||||||
const offset = ((page || 1) - 1) * limit;
|
return `LIMIT ${pageSize} OFFSET ${offset}`;
|
||||||
return `LIMIT ${limit} OFFSET ${offset}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建排序
|
// 构建排序条件
|
||||||
export function buildOrderBy(sortBy?: string, sortOrder?: 'asc' | 'desc'): string {
|
export function buildOrderBy(sortBy: string = 'event_time', sortOrder: string = 'desc'): string {
|
||||||
if (!sortBy) {
|
return `ORDER BY ${sortBy} ${sortOrder}`;
|
||||||
return 'ORDER BY event_time DESC';
|
|
||||||
}
|
|
||||||
return `ORDER BY ${sortBy} ${sortOrder || 'desc'}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行查询并处理错误
|
// 执行查询
|
||||||
export async function executeQuery<T>(query: string): Promise<T[]> {
|
export async function executeQuery(query: string) {
|
||||||
|
console.log('Executing query:', query); // 查询日志
|
||||||
try {
|
try {
|
||||||
const resultSet = await clickhouse.query({
|
const resultSet = await clickhouse.query({
|
||||||
query,
|
query,
|
||||||
format: 'JSONEachRow'
|
format: 'JSONEachRow',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = await resultSet.json<T>();
|
const rows = await resultSet.json();
|
||||||
return Array.isArray(rows) ? rows : [rows];
|
return rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ClickHouse query error:', error);
|
console.error('查询执行错误:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行查询并返回单个结果
|
// 执行返回单一结果的查询
|
||||||
export async function executeQuerySingle<T>(query: string): Promise<T | null> {
|
export async function executeQuerySingle(query: string) {
|
||||||
const results = await executeQuery<T>(query);
|
console.log('Executing single result query:', query); // 查询日志
|
||||||
return results.length > 0 ? results[0] : null;
|
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;
|
export default clickhouse;
|
||||||
65
lib/supabase.ts
Normal file
65
lib/supabase.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// 从环境变量获取Supabase配置
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || '';
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || '';
|
||||||
|
|
||||||
|
console.log('Supabase Configuration Check:', {
|
||||||
|
urlDefined: !!supabaseUrl,
|
||||||
|
keyDefined: !!supabaseAnonKey,
|
||||||
|
url: supabaseUrl,
|
||||||
|
// 打印部分key以便调试
|
||||||
|
keyPrefix: supabaseAnonKey ? supabaseAnonKey.substring(0, 20) + '...' : 'undefined',
|
||||||
|
keyLength: supabaseAnonKey ? supabaseAnonKey.length : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
console.error('Supabase URL and Anon Key are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解码JWT token并打印解码内容
|
||||||
|
try {
|
||||||
|
if (supabaseAnonKey) {
|
||||||
|
const parts = supabaseAnonKey.split('.');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const payload = parts[1];
|
||||||
|
const decoded = atob(payload);
|
||||||
|
console.log('JWT Payload:', decoded);
|
||||||
|
} else {
|
||||||
|
console.error('Invalid JWT format, expected 3 parts but got:', parts.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('JWT解码失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建Supabase客户端
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
auth: {
|
||||||
|
persistSession: true,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
detectSessionInUrl: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 测试Supabase连接
|
||||||
|
supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
console.log(`Supabase auth event: ${event}`, session ? 'Session exists' : 'No session');
|
||||||
|
if (session) {
|
||||||
|
console.log('Current user:', session.user.email);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 尝试执行健康检查
|
||||||
|
async function checkSupabaseHealth() {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.from('_health').select('*').limit(1);
|
||||||
|
console.log('Supabase health check:', error ? `Error: ${error.message}` : 'Success', data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Supabase health check error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSupabaseHealth();
|
||||||
|
|
||||||
|
export default supabase;
|
||||||
13
lib/types.ts
13
lib/types.ts
@@ -24,6 +24,16 @@ export enum DeviceType {
|
|||||||
OTHER = 'other'
|
OTHER = 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签类型
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
type?: string;
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
team_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// API 响应基础接口
|
// API 响应基础接口
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -45,7 +55,10 @@ export interface EventsQueryParams {
|
|||||||
linkSlug?: string;
|
linkSlug?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
|
teamIds?: string[]; // 团队ID数组,支持多选
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
projectIds?: string[]; // 项目ID数组,支持多选
|
||||||
|
tagIds?: string[]; // 标签ID数组,支持多选
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
|
|||||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
transpilePackages: ['swagger-ui-react'],
|
|
||||||
webpack: (config) => {
|
|
||||||
config.module.rules.push({
|
|
||||||
test: /\.css$/,
|
|
||||||
use: ['style-loader', 'css-loader'],
|
|
||||||
});
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = nextConfig;
|
|
||||||
@@ -3,7 +3,6 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
// 设置需要转译的包
|
// 设置需要转译的包
|
||||||
transpilePackages: [],
|
|
||||||
|
|
||||||
// 配置实验性选项
|
// 配置实验性选项
|
||||||
experimental: {
|
experimental: {
|
||||||
@@ -14,8 +13,8 @@ const nextConfig: NextConfig = {
|
|||||||
// 禁用严格模式,避免开发时重复渲染
|
// 禁用严格模式,避免开发时重复渲染
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
|
|
||||||
// 设置输出为独立应用
|
// 暂时禁用standalone输出模式,解决构建问题
|
||||||
output: 'standalone',
|
// output: 'standalone',
|
||||||
|
|
||||||
// 忽略ESLint错误,不会在构建时中断
|
// 忽略ESLint错误,不会在构建时中断
|
||||||
eslint: {
|
eslint: {
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
@@ -24,27 +24,36 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clickhouse/client": "^1.11.0",
|
"@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/chart.js": "^2.9.41",
|
||||||
"@types/recharts": "^1.8.29",
|
"@types/recharts": "^1.8.29",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.486.0",
|
||||||
"next": "15.2.3",
|
"next": "15.2.3",
|
||||||
|
"process": "^0.11.10",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.1",
|
||||||
"swagger-ui-dist": "^5.12.0",
|
"tailwind-merge": "^3.1.0",
|
||||||
"swagger-ui-react": "^5.12.0",
|
"uuid": "^10.0.0",
|
||||||
"uuid": "^10.0.0"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@supabase/supabase-js": "^2.49.4",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.2.3",
|
"eslint-config-next": "15.2.3",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
|
|||||||
2952
pnpm-lock.yaml
generated
2952
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
|||||||
|
|
||||||
获取所有表...
|
|
||||||
数据库 limq 中找到以下表:
|
|
||||||
- .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb
|
|
||||||
- .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc
|
|
||||||
- .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1
|
|
||||||
- .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0
|
|
||||||
- .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024
|
|
||||||
- .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea
|
|
||||||
- link_daily_stats
|
|
||||||
- link_events
|
|
||||||
- link_hourly_patterns
|
|
||||||
- links
|
|
||||||
- platform_distribution
|
|
||||||
- project_daily_stats
|
|
||||||
- projects
|
|
||||||
- qr_scans
|
|
||||||
- qrcode_daily_stats
|
|
||||||
- qrcodes
|
|
||||||
- sessions
|
|
||||||
- team_daily_stats
|
|
||||||
- team_members
|
|
||||||
- teams
|
|
||||||
|
|
||||||
所有ClickHouse表:
|
|
||||||
.inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb, .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc, .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1, .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0, .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024, .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea, link_daily_stats, link_events, link_hourly_patterns, links, platform_distribution, project_daily_stats, projects, qr_scans, qrcode_daily_stats, qrcodes, sessions, team_daily_stats, team_members, teams
|
|
||||||
|
|
||||||
获取表 .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea 的结构...
|
|
||||||
|
|
||||||
获取表 link_daily_stats 的结构...
|
|
||||||
表 link_daily_stats 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- total_clicks (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
- unique_sessions (UInt64, 无默认值)
|
|
||||||
- total_time_spent (UInt64, 无默认值)
|
|
||||||
- avg_time_spent (Float64, 无默认值)
|
|
||||||
- bounce_count (UInt64, 无默认值)
|
|
||||||
- conversion_count (UInt64, 无默认值)
|
|
||||||
- unique_referrers (UInt64, 无默认值)
|
|
||||||
- mobile_count (UInt64, 无默认值)
|
|
||||||
- tablet_count (UInt64, 无默认值)
|
|
||||||
- desktop_count (UInt64, 无默认值)
|
|
||||||
- qr_scan_count (UInt64, 无默认值)
|
|
||||||
- total_conversion_value (Float64, 无默认值)
|
|
||||||
|
|
||||||
获取表 link_events 的结构...
|
|
||||||
表 link_events 的列:
|
|
||||||
- event_id (UUID, 默认值: generateUUIDv4())
|
|
||||||
- event_time (DateTime64(3), 默认值: now64())
|
|
||||||
- date (Date, 默认值: toDate(event_time))
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- channel_id (String, 无默认值)
|
|
||||||
- visitor_id (String, 无默认值)
|
|
||||||
- session_id (String, 无默认值)
|
|
||||||
- event_type (Enum8('click' = 1, 'redirect' = 2, 'conversion' = 3, 'error' = 4), 无默认值)
|
|
||||||
- ip_address (String, 无默认值)
|
|
||||||
- country (String, 无默认值)
|
|
||||||
- city (String, 无默认值)
|
|
||||||
- referrer (String, 无默认值)
|
|
||||||
- utm_source (String, 无默认值)
|
|
||||||
- utm_medium (String, 无默认值)
|
|
||||||
- utm_campaign (String, 无默认值)
|
|
||||||
- user_agent (String, 无默认值)
|
|
||||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
|
||||||
- browser (String, 无默认值)
|
|
||||||
- os (String, 无默认值)
|
|
||||||
- time_spent_sec (UInt32, 默认值: 0)
|
|
||||||
- is_bounce (Bool, 默认值: true)
|
|
||||||
- is_qr_scan (Bool, 默认值: false)
|
|
||||||
- qr_code_id (String, 默认值: '')
|
|
||||||
- conversion_type (Enum8('visit' = 1, 'stay' = 2, 'interact' = 3, 'signup' = 4, 'subscription' = 5, 'purchase' = 6), 默认值: 'visit')
|
|
||||||
- conversion_value (Float64, 默认值: 0)
|
|
||||||
- custom_data (String, 默认值: '{}')
|
|
||||||
|
|
||||||
获取表 link_hourly_patterns 的结构...
|
|
||||||
表 link_hourly_patterns 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- hour (UInt8, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- visits (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 links 的结构...
|
|
||||||
表 links 的列:
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- original_url (String, 无默认值)
|
|
||||||
- created_at (DateTime64(3), 无默认值)
|
|
||||||
- created_by (String, 无默认值)
|
|
||||||
- title (String, 无默认值)
|
|
||||||
- description (String, 无默认值)
|
|
||||||
- tags (Array(String), 无默认值)
|
|
||||||
- is_active (Bool, 默认值: true)
|
|
||||||
- expires_at (Nullable(DateTime), 无默认值)
|
|
||||||
- team_id (String, 默认值: '')
|
|
||||||
- project_id (String, 默认值: '')
|
|
||||||
|
|
||||||
获取表 platform_distribution 的结构...
|
|
||||||
表 platform_distribution 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- utm_source (String, 无默认值)
|
|
||||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
|
||||||
- visits (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 project_daily_stats 的结构...
|
|
||||||
表 project_daily_stats 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- project_id (String, 无默认值)
|
|
||||||
- total_clicks (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
- conversion_count (UInt64, 无默认值)
|
|
||||||
- links_used (UInt64, 无默认值)
|
|
||||||
- qr_scan_count (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 projects 的结构...
|
|
||||||
表 projects 的列:
|
|
||||||
- project_id (String, 无默认值)
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- name (String, 无默认值)
|
|
||||||
- created_at (DateTime, 无默认值)
|
|
||||||
- created_by (String, 无默认值)
|
|
||||||
- description (String, 默认值: '')
|
|
||||||
- is_archived (Bool, 默认值: false)
|
|
||||||
- links_count (UInt32, 默认值: 0)
|
|
||||||
- total_clicks (UInt64, 默认值: 0)
|
|
||||||
- last_updated (DateTime, 默认值: now())
|
|
||||||
|
|
||||||
获取表 qr_scans 的结构...
|
|
||||||
表 qr_scans 的列:
|
|
||||||
- scan_id (UUID, 默认值: generateUUIDv4())
|
|
||||||
- qr_code_id (String, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- scan_time (DateTime64(3), 无默认值)
|
|
||||||
- visitor_id (String, 无默认值)
|
|
||||||
- location (String, 无默认值)
|
|
||||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
|
||||||
- led_to_conversion (Bool, 默认值: false)
|
|
||||||
|
|
||||||
获取表 qrcode_daily_stats 的结构...
|
|
||||||
表 qrcode_daily_stats 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- qr_code_id (String, 无默认值)
|
|
||||||
- total_scans (UInt64, 无默认值)
|
|
||||||
- unique_scanners (UInt64, 无默认值)
|
|
||||||
- conversions (UInt64, 无默认值)
|
|
||||||
- mobile_scans (UInt64, 无默认值)
|
|
||||||
- tablet_scans (UInt64, 无默认值)
|
|
||||||
- desktop_scans (UInt64, 无默认值)
|
|
||||||
- unique_locations (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 qrcodes 的结构...
|
|
||||||
表 qrcodes 的列:
|
|
||||||
- qr_code_id (String, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- project_id (String, 默认值: '')
|
|
||||||
- name (String, 无默认值)
|
|
||||||
- description (String, 默认值: '')
|
|
||||||
- created_at (DateTime, 无默认值)
|
|
||||||
- created_by (String, 无默认值)
|
|
||||||
- updated_at (DateTime, 默认值: now())
|
|
||||||
- qr_type (Enum8('standard' = 1, 'custom' = 2, 'dynamic' = 3), 默认值: 'standard')
|
|
||||||
- image_url (String, 默认值: '')
|
|
||||||
- design_config (String, 默认值: '{}')
|
|
||||||
- is_active (Bool, 默认值: true)
|
|
||||||
- total_scans (UInt64, 默认值: 0)
|
|
||||||
- unique_scanners (UInt32, 默认值: 0)
|
|
||||||
|
|
||||||
获取表 sessions 的结构...
|
|
||||||
表 sessions 的列:
|
|
||||||
- session_id (String, 无默认值)
|
|
||||||
- visitor_id (String, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- started_at (DateTime64(3), 无默认值)
|
|
||||||
- last_activity (DateTime64(3), 无默认值)
|
|
||||||
- ended_at (Nullable(DateTime64(3)), 无默认值)
|
|
||||||
- duration_sec (UInt32, 默认值: 0)
|
|
||||||
- session_pages (UInt8, 默认值: 1)
|
|
||||||
- is_completed (Bool, 默认值: false)
|
|
||||||
|
|
||||||
获取表 team_daily_stats 的结构...
|
|
||||||
表 team_daily_stats 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- total_clicks (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
- conversion_count (UInt64, 无默认值)
|
|
||||||
- links_used (UInt64, 无默认值)
|
|
||||||
- qr_scan_count (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 team_members 的结构...
|
|
||||||
表 team_members 的列:
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- user_id (String, 无默认值)
|
|
||||||
- role (Enum8('owner' = 1, 'admin' = 2, 'editor' = 3, 'viewer' = 4), 无默认值)
|
|
||||||
- joined_at (DateTime, 默认值: now())
|
|
||||||
- invited_by (String, 无默认值)
|
|
||||||
- is_active (Bool, 默认值: true)
|
|
||||||
- last_active (DateTime, 默认值: now())
|
|
||||||
|
|
||||||
获取表 teams 的结构...
|
|
||||||
表 teams 的列:
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- name (String, 无默认值)
|
|
||||||
- created_at (DateTime, 无默认值)
|
|
||||||
- created_by (String, 无默认值)
|
|
||||||
- description (String, 默认值: '')
|
|
||||||
- avatar_url (String, 默认值: '')
|
|
||||||
- is_active (Bool, 默认值: true)
|
|
||||||
- plan_type (Enum8('free' = 1, 'pro' = 2, 'enterprise' = 3), 无默认值)
|
|
||||||
- members_count (UInt32, 默认值: 1)
|
|
||||||
|
|
||||||
ClickHouse数据库结构检查完成
|
|
||||||
9
scripts/db/sql/clickhouse/add_req_full_path.sql
Normal file
9
scripts/db/sql/clickhouse/add_req_full_path.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- add_req_full_path.sql
|
||||||
|
-- Add req_full_path column to the shorturl_analytics.events table
|
||||||
|
ALTER TABLE
|
||||||
|
shorturl_analytics.events
|
||||||
|
ADD
|
||||||
|
COLUMN IF NOT EXISTS req_full_path String COMMENT 'Full request path including query parameters';
|
||||||
|
|
||||||
|
-- Display the updated table structure
|
||||||
|
DESCRIBE TABLE shorturl_analytics.events;
|
||||||
41
scripts/db/sql/clickhouse/add_utm_fields.sql
Normal file
41
scripts/db/sql/clickhouse/add_utm_fields.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- 添加缺失的UTM参数字段到shorturl_analytics.events表
|
||||||
|
-- 创建日期: 2024-07-02
|
||||||
|
-- 用途: 增强UTM参数追踪能力
|
||||||
|
-- 添加utm_term字段 (用于跟踪付费搜索关键词)
|
||||||
|
ALTER TABLE
|
||||||
|
shorturl_analytics.events
|
||||||
|
ADD
|
||||||
|
COLUMN utm_term String DEFAULT '' AFTER utm_campaign;
|
||||||
|
|
||||||
|
-- 添加utm_content字段 (用于区分相同广告的不同版本或A/B测试)
|
||||||
|
ALTER TABLE
|
||||||
|
shorturl_analytics.events
|
||||||
|
ADD
|
||||||
|
COLUMN utm_content String DEFAULT '' AFTER utm_term;
|
||||||
|
|
||||||
|
-- 验证字段添加成功
|
||||||
|
DESCRIBE TABLE shorturl_analytics.events;
|
||||||
|
|
||||||
|
-- 示例查询: 查看UTM参数分析数据
|
||||||
|
SELECT
|
||||||
|
utm_source,
|
||||||
|
utm_medium,
|
||||||
|
utm_campaign,
|
||||||
|
utm_term,
|
||||||
|
utm_content,
|
||||||
|
COUNT(*) as clicks
|
||||||
|
FROM
|
||||||
|
shorturl_analytics.events
|
||||||
|
WHERE
|
||||||
|
event_type = 'click'
|
||||||
|
AND utm_source != ''
|
||||||
|
GROUP BY
|
||||||
|
utm_source,
|
||||||
|
utm_medium,
|
||||||
|
utm_campaign,
|
||||||
|
utm_term,
|
||||||
|
utm_content
|
||||||
|
ORDER BY
|
||||||
|
clicks DESC
|
||||||
|
LIMIT
|
||||||
|
10;
|
||||||
46
scripts/db/sql/clickhouse/create_shorturl_table.sql
Normal file
46
scripts/db/sql/clickhouse/create_shorturl_table.sql
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
-- 使用shorturl_analytics数据库
|
||||||
|
USE shorturl_analytics;
|
||||||
|
|
||||||
|
-- 删除已存在的shorturl表
|
||||||
|
DROP TABLE IF EXISTS shorturl_analytics.shorturl;
|
||||||
|
|
||||||
|
-- 创建shorturl表
|
||||||
|
CREATE TABLE IF NOT EXISTS shorturl_analytics.shorturl (
|
||||||
|
-- 短链接基本信息(来源于resources表)
|
||||||
|
id String COMMENT '资源ID (resources.id)',
|
||||||
|
external_id String COMMENT '外部ID (resources.external_id)',
|
||||||
|
type String COMMENT '类型,值为shorturl',
|
||||||
|
slug String COMMENT '短链接slug (存储在attributes中)',
|
||||||
|
original_url String COMMENT '原始URL (存储在attributes中)',
|
||||||
|
title String COMMENT '标题 (存储在attributes中)',
|
||||||
|
description String COMMENT '描述 (存储在attributes中)',
|
||||||
|
attributes String DEFAULT '{}' COMMENT '资源属性JSON',
|
||||||
|
schema_version Int32 COMMENT 'Schema版本',
|
||||||
|
-- 创建者信息
|
||||||
|
creator_id String COMMENT '创建者ID (resources.creator_id)',
|
||||||
|
creator_email String COMMENT '创建者邮箱 (来自users表)',
|
||||||
|
creator_name String COMMENT '创建者名称 (来自users表)',
|
||||||
|
-- 时间信息
|
||||||
|
created_at DateTime64(3) COMMENT '创建时间 (resources.created_at)',
|
||||||
|
updated_at DateTime64(3) COMMENT '更新时间 (resources.updated_at)',
|
||||||
|
deleted_at Nullable(DateTime64(3)) COMMENT '删除时间 (resources.deleted_at)',
|
||||||
|
-- 项目关联 (project_resources表)
|
||||||
|
projects String DEFAULT '[]' COMMENT '项目关联信息数组。结构: [{project_id: String, project_name: String, project_description: String, assigned_at: DateTime64}]',
|
||||||
|
-- 团队关联 (通过项目关联到团队)
|
||||||
|
teams String DEFAULT '[]' COMMENT '团队关联信息数组。结构: [{team_id: String, team_name: String, team_description: String, via_project_id: String}]',
|
||||||
|
-- 标签关联 (resource_tags表)
|
||||||
|
tags String DEFAULT '[]' COMMENT '标签关联信息数组。结构: [{tag_id: String, tag_name: String, tag_type: String, created_at: DateTime64}]',
|
||||||
|
-- QR码关联 (qr_code表)
|
||||||
|
qr_codes String DEFAULT '[]' COMMENT 'QR码信息数组。结构: [{qr_id: String, scan_count: Int32, url: String, template_name: String, created_at: DateTime64}]',
|
||||||
|
-- 渠道关联 (channel表)
|
||||||
|
channels String DEFAULT '[]' COMMENT '渠道信息数组。结构: [{channel_id: String, channel_name: String, channel_path: String, is_user_created: Boolean}]',
|
||||||
|
-- 收藏关联 (favorite表)
|
||||||
|
favorites String DEFAULT '[]' COMMENT '收藏信息数组。结构: [{favorite_id: String, user_id: String, user_name: String, created_at: DateTime64}]',
|
||||||
|
-- 自定义过期时间 (存储在attributes中)
|
||||||
|
expires_at Nullable(DateTime64(3)) COMMENT '过期时间',
|
||||||
|
-- 统计信息 (分析时聚合计算)
|
||||||
|
click_count UInt32 DEFAULT 0 COMMENT '点击次数',
|
||||||
|
unique_visitors UInt32 DEFAULT 0 COMMENT '唯一访问者数'
|
||||||
|
) ENGINE = MergeTree() PARTITION BY toYYYYMM(created_at)
|
||||||
|
ORDER BY
|
||||||
|
(id, created_at) SETTINGS index_granularity = 8192 COMMENT '用于存储所有shorturl类型资源的统一表,集成了相关联的项目、团队、标签、QR码、渠道和收藏信息';
|
||||||
@@ -6,7 +6,7 @@ const config: Config = {
|
|||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
darkMode: 'class',
|
darkMode: false,
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
72
test-supabase-login.mjs
Normal file
72
test-supabase-login.mjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// 测试Supabase登录功能
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// 加载环境变量
|
||||||
|
config({ path: '.env.local' });
|
||||||
|
|
||||||
|
async function testSupabaseLogin() {
|
||||||
|
// 获取Supabase配置
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||||
|
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
console.log('Supabase Configuration:');
|
||||||
|
console.log('- URL defined:', !!supabaseUrl);
|
||||||
|
console.log('- Key defined:', !!supabaseKey);
|
||||||
|
console.log('- URL:', supabaseUrl);
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
|
console.error('缺少Supabase配置信息,请检查.env.local文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建Supabase客户端
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
console.log('Supabase客户端创建成功');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试获取会话状态
|
||||||
|
console.log('检查当前会话...');
|
||||||
|
const { data: sessionData, error: sessionError } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (sessionError) {
|
||||||
|
console.error('获取会话失败:', sessionError.message);
|
||||||
|
} else {
|
||||||
|
console.log('会话状态:', sessionData.session ? '已登录' : '未登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试使用测试账户登录
|
||||||
|
const testEmail = 'test@example.com';
|
||||||
|
const testPassword = 'password123';
|
||||||
|
|
||||||
|
console.log(`\n尝试使用测试账户登录: ${testEmail}`);
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email: testEmail,
|
||||||
|
password: testPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('登录失败:', error.message);
|
||||||
|
|
||||||
|
// 如果登录失败,尝试注册账户
|
||||||
|
console.log('\n尝试注册测试账户...');
|
||||||
|
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
|
||||||
|
email: testEmail,
|
||||||
|
password: testPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signUpError) {
|
||||||
|
console.error('注册失败:', signUpError.message);
|
||||||
|
} else {
|
||||||
|
console.log('注册成功:', signUpData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('登录成功!');
|
||||||
|
console.log('用户信息:', data.user);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发生错误:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testSupabaseLogin();
|
||||||
1
types/react-simple-maps.d.ts
vendored
Normal file
1
types/react-simple-maps.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
146
types/supabase.ts
Normal file
146
types/supabase.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
export type Json =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
teams: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
attributes: Json | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
deleted_at: string | null
|
||||||
|
schema_version: number | null
|
||||||
|
avatar_url: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
attributes?: Json | null
|
||||||
|
created_at?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
deleted_at?: string | null
|
||||||
|
schema_version?: number | null
|
||||||
|
avatar_url?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
description?: string | null
|
||||||
|
attributes?: Json | null
|
||||||
|
created_at?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
deleted_at?: string | null
|
||||||
|
schema_version?: number | null
|
||||||
|
avatar_url?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
team_membership: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
team_id: string
|
||||||
|
user_id: string
|
||||||
|
is_creator: boolean
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
team_id: string
|
||||||
|
user_id: string
|
||||||
|
is_creator?: boolean
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
team_id?: string
|
||||||
|
user_id?: string
|
||||||
|
is_creator?: boolean
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Views: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Functions: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
Enums: {
|
||||||
|
[_ in never]: never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
limq: {
|
||||||
|
Tables: {
|
||||||
|
teams: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
avatar_url: string | null
|
||||||
|
attributes: Json | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
deleted_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
avatar_url?: string | null
|
||||||
|
attributes?: Json | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
deleted_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
description?: string | null
|
||||||
|
avatar_url?: string | null
|
||||||
|
attributes?: Json | null
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
deleted_at?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
team_membership: {
|
||||||
|
Row: {
|
||||||
|
id: string
|
||||||
|
team_id: string
|
||||||
|
user_id: string
|
||||||
|
role: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
deleted_at: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
id?: string
|
||||||
|
team_id: string
|
||||||
|
user_id: string
|
||||||
|
role: string
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
deleted_at?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
id?: string
|
||||||
|
team_id?: string
|
||||||
|
user_id?: string
|
||||||
|
role?: string
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
deleted_at?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
641
windmill/sync_shorturl_to_clickhouse.ts
Normal file
641
windmill/sync_shorturl_to_clickhouse.ts
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
// Windmill script to sync shorturl data from PostgreSQL to ClickHouse
|
||||||
|
// 作者: AI Assistant
|
||||||
|
// 创建日期: 2023-10-30
|
||||||
|
// 描述: 此脚本从PostgreSQL数据库获取所有shorturl类型的资源及其关联数据,并同步到ClickHouse
|
||||||
|
|
||||||
|
import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
|
||||||
|
import { getResource, getVariable } from "https://deno.land/x/windmill@v1.183.0/mod.ts";
|
||||||
|
|
||||||
|
// 资源属性接口
|
||||||
|
interface ResourceAttributes {
|
||||||
|
slug?: string;
|
||||||
|
original_url?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClickHouse配置接口
|
||||||
|
interface ChConfig {
|
||||||
|
clickhouse_host: string;
|
||||||
|
clickhouse_port: number;
|
||||||
|
clickhouse_user: string;
|
||||||
|
clickhouse_password: string;
|
||||||
|
clickhouse_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL配置接口
|
||||||
|
interface PgConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
dbname?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windmill函数定义
|
||||||
|
export async function main(
|
||||||
|
/** PostgreSQL和ClickHouse同步脚本 */
|
||||||
|
params: {
|
||||||
|
/** 同步的资源数量限制,默认500 */
|
||||||
|
limit?: number;
|
||||||
|
/** 是否包含已删除资源 */
|
||||||
|
includeDeleted?: boolean;
|
||||||
|
/** 是否执行实际写入操作 */
|
||||||
|
dryRun?: boolean;
|
||||||
|
/** 开始时间(ISO格式)*/
|
||||||
|
startTime?: string;
|
||||||
|
/** 结束时间(ISO格式)*/
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
// 设置默认参数
|
||||||
|
const limit = params.limit || 500;
|
||||||
|
const includeDeleted = params.includeDeleted || false;
|
||||||
|
const dryRun = params.dryRun || false;
|
||||||
|
const startTime = params.startTime ? new Date(params.startTime) : undefined;
|
||||||
|
const endTime = params.endTime ? new Date(params.endTime) : undefined;
|
||||||
|
|
||||||
|
console.log(`开始同步PostgreSQL shorturl数据到ClickHouse`);
|
||||||
|
console.log(`参数: limit=${limit}, includeDeleted=${includeDeleted}, dryRun=${dryRun}`);
|
||||||
|
if (startTime) console.log(`开始时间: ${startTime.toISOString()}`);
|
||||||
|
if (endTime) console.log(`结束时间: ${endTime.toISOString()}`);
|
||||||
|
|
||||||
|
// 获取数据库配置
|
||||||
|
console.log("获取PostgreSQL数据库配置...");
|
||||||
|
const pgConfig = await getResource('f/limq/postgresql') as PgConfig;
|
||||||
|
console.log(`数据库连接配置: host=${pgConfig.host}, port=${pgConfig.port}, database=${pgConfig.dbname || 'postgres'}, user=${pgConfig.user}`);
|
||||||
|
|
||||||
|
let pgPool: Pool | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("创建PostgreSQL连接池...");
|
||||||
|
|
||||||
|
pgPool = new Pool({
|
||||||
|
hostname: pgConfig.host,
|
||||||
|
port: pgConfig.port,
|
||||||
|
user: pgConfig.user,
|
||||||
|
password: pgConfig.password,
|
||||||
|
database: pgConfig.dbname || 'postgres'
|
||||||
|
}, 3);
|
||||||
|
|
||||||
|
console.log("PostgreSQL连接池创建完成,尝试连接...");
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
const client = await pgPool.connect();
|
||||||
|
try {
|
||||||
|
console.log("连接成功,执行测试查询...");
|
||||||
|
const testResult = await client.queryObject(`SELECT 1 AS test`);
|
||||||
|
console.log(`测试查询结果: ${JSON.stringify(testResult.rows)}`);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有shorturl类型的资源
|
||||||
|
const shorturls = await fetchShorturlResources(pgPool, {
|
||||||
|
limit,
|
||||||
|
includeDeleted,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`获取到 ${shorturls.length} 个shorturl资源`);
|
||||||
|
|
||||||
|
if (shorturls.length === 0) {
|
||||||
|
return { synced: 0, message: "没有找到需要同步的shorturl资源" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个资源获取关联数据
|
||||||
|
const enrichedShorturls = await enrichShorturlData(pgPool, shorturls);
|
||||||
|
console.log(`已丰富 ${enrichedShorturls.length} 个shorturl资源的关联数据`);
|
||||||
|
|
||||||
|
// 转换为ClickHouse格式
|
||||||
|
const clickhouseData = formatForClickhouse(enrichedShorturls);
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
// 写入ClickHouse
|
||||||
|
const inserted = await insertToClickhouse(clickhouseData);
|
||||||
|
console.log(`成功写入 ${inserted} 条记录到ClickHouse`);
|
||||||
|
return { synced: inserted, message: "同步完成" };
|
||||||
|
} else {
|
||||||
|
console.log("Dry run模式 - 不执行实际写入");
|
||||||
|
console.log(`将写入 ${clickhouseData.length} 条记录到ClickHouse`);
|
||||||
|
// 输出示例数据
|
||||||
|
if (clickhouseData.length > 0) {
|
||||||
|
console.log("示例数据:");
|
||||||
|
console.log(JSON.stringify(clickhouseData[0], null, 2));
|
||||||
|
}
|
||||||
|
return { synced: 0, dryRun: true, sampleData: clickhouseData.slice(0, 1) };
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(`同步过程中发生错误: ${(error as Error).message}`);
|
||||||
|
console.error(`错误类型: ${(error as Error).name}`);
|
||||||
|
if ((error as Error).stack) {
|
||||||
|
console.error(`错误堆栈: ${(error as Error).stack}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (pgPool) {
|
||||||
|
await pgPool.end();
|
||||||
|
console.log("PostgreSQL连接池已关闭");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从PostgreSQL获取所有shorturl资源
|
||||||
|
async function fetchShorturlResources(
|
||||||
|
pgPool: Pool,
|
||||||
|
options: {
|
||||||
|
limit: number;
|
||||||
|
includeDeleted: boolean;
|
||||||
|
startTime?: Date;
|
||||||
|
endTime?: Date;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r.external_id,
|
||||||
|
r.type,
|
||||||
|
r.attributes,
|
||||||
|
r.schema_version,
|
||||||
|
r.creator_id,
|
||||||
|
r.created_at,
|
||||||
|
r.updated_at,
|
||||||
|
r.deleted_at,
|
||||||
|
u.email as creator_email,
|
||||||
|
u.first_name as creator_first_name,
|
||||||
|
u.last_name as creator_last_name
|
||||||
|
FROM
|
||||||
|
limq.resources r
|
||||||
|
LEFT JOIN
|
||||||
|
limq.users u ON r.creator_id = u.id
|
||||||
|
WHERE
|
||||||
|
r.type = 'shorturl'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
let paramCount = 1;
|
||||||
|
|
||||||
|
if (!options.includeDeleted) {
|
||||||
|
query += ` AND r.deleted_at IS NULL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.startTime) {
|
||||||
|
query += ` AND r.created_at >= $${paramCount}`;
|
||||||
|
params.push(options.startTime);
|
||||||
|
paramCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.endTime) {
|
||||||
|
query += ` AND r.created_at <= $${paramCount}`;
|
||||||
|
params.push(options.endTime);
|
||||||
|
paramCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY r.created_at DESC LIMIT $${paramCount}`;
|
||||||
|
params.push(options.limit);
|
||||||
|
|
||||||
|
const client = await pgPool.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.queryObject(query, params);
|
||||||
|
|
||||||
|
// 添加调试日志 - 显示获取的数据样本
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
console.log(`获取到 ${result.rows.length} 条shorturl记录`);
|
||||||
|
console.log(`第一条记录ID: ${result.rows[0].id}`);
|
||||||
|
console.log(`attributes类型: ${typeof result.rows[0].attributes}`);
|
||||||
|
console.log(`attributes内容示例: ${JSON.stringify(String(result.rows[0].attributes)).substring(0, 100)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个shorturl资源获取关联数据
|
||||||
|
async function enrichShorturlData(pgPool: Pool, shorturls: Record<string, unknown>[]) {
|
||||||
|
const client = await pgPool.connect();
|
||||||
|
const enriched = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const shorturl of shorturls) {
|
||||||
|
// 1. 获取项目关联
|
||||||
|
const projectsResult = await client.queryObject(`
|
||||||
|
SELECT
|
||||||
|
pr.resource_id, pr.project_id,
|
||||||
|
p.name as project_name, p.description as project_description,
|
||||||
|
pr.assigned_at
|
||||||
|
FROM
|
||||||
|
limq.project_resources pr
|
||||||
|
JOIN
|
||||||
|
limq.projects p ON pr.project_id = p.id
|
||||||
|
WHERE
|
||||||
|
pr.resource_id = $1
|
||||||
|
`, [shorturl.id]);
|
||||||
|
|
||||||
|
// 2. 获取团队关联(通过项目)
|
||||||
|
const teamIds = projectsResult.rows.map((p: Record<string, unknown>) => p.project_id);
|
||||||
|
const teamsResult = teamIds.length > 0 ? await client.queryObject(`
|
||||||
|
SELECT
|
||||||
|
tp.team_id, tp.project_id,
|
||||||
|
t.name as team_name, t.description as team_description
|
||||||
|
FROM
|
||||||
|
limq.team_projects tp
|
||||||
|
JOIN
|
||||||
|
limq.teams t ON tp.team_id = t.id
|
||||||
|
WHERE
|
||||||
|
tp.project_id = ANY($1::uuid[])
|
||||||
|
`, [teamIds]) : { rows: [] };
|
||||||
|
|
||||||
|
// 3. 获取标签关联
|
||||||
|
const tagsResult = await client.queryObject(`
|
||||||
|
SELECT
|
||||||
|
rt.resource_id, rt.tag_id, rt.created_at,
|
||||||
|
t.name as tag_name, t.type as tag_type
|
||||||
|
FROM
|
||||||
|
limq.resource_tags rt
|
||||||
|
JOIN
|
||||||
|
limq.tags t ON rt.tag_id = t.id
|
||||||
|
WHERE
|
||||||
|
rt.resource_id = $1
|
||||||
|
`, [shorturl.id]);
|
||||||
|
|
||||||
|
// 4. 获取QR码关联
|
||||||
|
const qrCodesResult = await client.queryObject(`
|
||||||
|
SELECT
|
||||||
|
id as qr_id, scan_count, url, template_name, created_at
|
||||||
|
FROM
|
||||||
|
limq.qr_code
|
||||||
|
WHERE
|
||||||
|
resource_id = $1
|
||||||
|
`, [shorturl.id]);
|
||||||
|
|
||||||
|
// 5. 获取渠道关联
|
||||||
|
const channelsResult = await client.queryObject(`
|
||||||
|
SELECT
|
||||||
|
id as channel_id, name as channel_name, path as channel_path,
|
||||||
|
"isUserCreated" as is_user_created
|
||||||
|
FROM
|
||||||
|
limq.channel
|
||||||
|
WHERE
|
||||||
|
"shortUrlId" = $1
|
||||||
|
`, [shorturl.id]);
|
||||||
|
|
||||||
|
// 6. 获取收藏关联
|
||||||
|
const favoritesResult = await client.queryObject(`
|
||||||
|
SELECT
|
||||||
|
f.id as favorite_id, f.user_id, f.created_at,
|
||||||
|
u.first_name, u.last_name
|
||||||
|
FROM
|
||||||
|
limq.favorite f
|
||||||
|
JOIN
|
||||||
|
limq.users u ON f.user_id = u.id
|
||||||
|
WHERE
|
||||||
|
f.favoritable_id = $1 AND f.favoritable_type = 'resource'
|
||||||
|
`, [shorturl.id]);
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
console.log(`\n处理资源ID: ${shorturl.id}`);
|
||||||
|
console.log(`attributes类型: ${typeof shorturl.attributes}`);
|
||||||
|
|
||||||
|
// 改进的attributes解析逻辑
|
||||||
|
let attributes: ResourceAttributes = {};
|
||||||
|
try {
|
||||||
|
if (typeof shorturl.attributes === 'string') {
|
||||||
|
// 如果是字符串,尝试解析为JSON
|
||||||
|
console.log(`尝试解析attributes字符串,长度: ${shorturl.attributes.length}`);
|
||||||
|
attributes = JSON.parse(shorturl.attributes);
|
||||||
|
} else if (typeof shorturl.attributes === 'object' && shorturl.attributes !== null) {
|
||||||
|
// 如果已经是对象,直接使用
|
||||||
|
console.log('attributes已经是对象类型');
|
||||||
|
attributes = shorturl.attributes as ResourceAttributes;
|
||||||
|
} else {
|
||||||
|
console.log(`无效的attributes类型: ${typeof shorturl.attributes}`);
|
||||||
|
attributes = {};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
console.warn(`无法解析资源 ${shorturl.id} 的attributes JSON:`, error.message);
|
||||||
|
// 尝试进行更多原始数据分析
|
||||||
|
if (typeof shorturl.attributes === 'string') {
|
||||||
|
console.log(`原始字符串前100字符: ${shorturl.attributes.substring(0, 100)}`);
|
||||||
|
}
|
||||||
|
attributes = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从QR码获取数据
|
||||||
|
let slugFromQr = "";
|
||||||
|
const urlFromQr = "";
|
||||||
|
|
||||||
|
if (qrCodesResult.rows.length > 0 && qrCodesResult.rows[0].url) {
|
||||||
|
const qrUrl = qrCodesResult.rows[0].url as string;
|
||||||
|
console.log(`找到QR码URL: ${qrUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlParts = qrUrl.split('/');
|
||||||
|
slugFromQr = urlParts[urlParts.length - 1];
|
||||||
|
console.log(`从QR码URL提取的slug: ${slugFromQr}`);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
console.log('无法从QR码URL提取slug:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日志输出实际字段值
|
||||||
|
console.log(`提取字段 - name: ${attributes.name || 'N/A'}, slug: ${attributes.slug || 'N/A'}`);
|
||||||
|
console.log(`提取字段 - originalUrl: ${attributes.originalUrl || 'N/A'}, original_url: ${attributes.original_url || 'N/A'}`);
|
||||||
|
|
||||||
|
// 整合所有数据
|
||||||
|
const slug = attributes.slug || attributes.name || slugFromQr || "";
|
||||||
|
const originalUrl = attributes.originalUrl || attributes.original_url || urlFromQr || "";
|
||||||
|
|
||||||
|
console.log(`最终使用的slug: ${slug}`);
|
||||||
|
console.log(`最终使用的originalUrl: ${originalUrl}`);
|
||||||
|
|
||||||
|
enriched.push({
|
||||||
|
...shorturl,
|
||||||
|
attributes,
|
||||||
|
projects: projectsResult.rows,
|
||||||
|
teams: teamsResult.rows,
|
||||||
|
tags: tagsResult.rows,
|
||||||
|
qrCodes: qrCodesResult.rows,
|
||||||
|
channels: channelsResult.rows,
|
||||||
|
favorites: favoritesResult.rows,
|
||||||
|
// 从attributes中提取特定字段 - 使用改进的顺序和QR码备选
|
||||||
|
slug,
|
||||||
|
originalUrl,
|
||||||
|
title: attributes.title || "",
|
||||||
|
description: attributes.description || "",
|
||||||
|
expiresAt: attributes.expires_at || attributes.expiresAt || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return enriched;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将PostgreSQL数据格式化为ClickHouse格式
|
||||||
|
function formatForClickhouse(shorturls: Record<string, unknown>[]) {
|
||||||
|
// 将日期格式化为ClickHouse兼容的DateTime64(3)格式
|
||||||
|
const formatDateTime = (date: Date | string | number | null | undefined): string | null => {
|
||||||
|
if (!date) return null;
|
||||||
|
// 转换为Date对象
|
||||||
|
const dateObj = date instanceof Date ? date : new Date(date);
|
||||||
|
// 返回格式化的字符串: YYYY-MM-DD HH:MM:SS.SSS
|
||||||
|
return dateObj.toISOString().replace('T', ' ').replace('Z', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`\n准备格式化 ${shorturls.length} 条记录为ClickHouse格式`);
|
||||||
|
|
||||||
|
return shorturls.map(shorturl => {
|
||||||
|
// 调试日志:输出关键字段
|
||||||
|
console.log(`处理资源: ${shorturl.id}`);
|
||||||
|
console.log(`slug: ${shorturl.slug || 'EMPTY'}`);
|
||||||
|
console.log(`originalUrl: ${shorturl.originalUrl || 'EMPTY'}`);
|
||||||
|
|
||||||
|
// 记录attributes状态
|
||||||
|
const attributesStr = JSON.stringify(shorturl.attributes || {});
|
||||||
|
const attributesPrev = attributesStr.length > 100 ?
|
||||||
|
attributesStr.substring(0, 100) + '...' :
|
||||||
|
attributesStr;
|
||||||
|
console.log(`attributes: ${attributesPrev}`);
|
||||||
|
|
||||||
|
const creatorName = [shorturl.creator_first_name, shorturl.creator_last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
// 格式化项目数据为JSON数组
|
||||||
|
const projects = JSON.stringify((shorturl.projects as Record<string, unknown>[]).map((p) => ({
|
||||||
|
project_id: p.project_id,
|
||||||
|
project_name: p.project_name,
|
||||||
|
project_description: p.project_description,
|
||||||
|
assigned_at: p.assigned_at
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 格式化团队数据为JSON数组
|
||||||
|
const teams = JSON.stringify((shorturl.teams as Record<string, unknown>[]).map((t) => ({
|
||||||
|
team_id: t.team_id,
|
||||||
|
team_name: t.team_name,
|
||||||
|
team_description: t.team_description,
|
||||||
|
via_project_id: t.project_id
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 格式化标签数据为JSON数组
|
||||||
|
const tags = JSON.stringify((shorturl.tags as Record<string, unknown>[]).map((t) => ({
|
||||||
|
tag_id: t.tag_id,
|
||||||
|
tag_name: t.tag_name,
|
||||||
|
tag_type: t.tag_type,
|
||||||
|
created_at: t.created_at
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 格式化QR码数据为JSON数组
|
||||||
|
const qrCodes = JSON.stringify((shorturl.qrCodes as Record<string, unknown>[]).map((q) => ({
|
||||||
|
qr_id: q.qr_id,
|
||||||
|
scan_count: q.scan_count,
|
||||||
|
url: q.url,
|
||||||
|
template_name: q.template_name,
|
||||||
|
created_at: q.created_at
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 格式化渠道数据为JSON数组
|
||||||
|
const channels = JSON.stringify((shorturl.channels as Record<string, unknown>[]).map((c) => ({
|
||||||
|
channel_id: c.channel_id,
|
||||||
|
channel_name: c.channel_name,
|
||||||
|
channel_path: c.channel_path,
|
||||||
|
is_user_created: c.is_user_created
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 格式化收藏数据为JSON数组
|
||||||
|
const favorites = JSON.stringify((shorturl.favorites as Record<string, unknown>[]).map((f) => ({
|
||||||
|
favorite_id: f.favorite_id,
|
||||||
|
user_id: f.user_id,
|
||||||
|
user_name: `${f.first_name || ""} ${f.last_name || ""}`.trim(),
|
||||||
|
created_at: f.created_at
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 统计信息(可通过events表聚合或在其他地方设置)
|
||||||
|
const clickCount = (shorturl.attributes as ResourceAttributes).click_count as number || 0;
|
||||||
|
const uniqueVisitors = 0;
|
||||||
|
|
||||||
|
// 返回ClickHouse格式数据
|
||||||
|
return {
|
||||||
|
id: shorturl.id,
|
||||||
|
external_id: shorturl.external_id || "",
|
||||||
|
type: shorturl.type,
|
||||||
|
slug: shorturl.slug || "",
|
||||||
|
original_url: shorturl.originalUrl || "",
|
||||||
|
title: shorturl.title || "",
|
||||||
|
description: shorturl.description || "",
|
||||||
|
attributes: JSON.stringify(shorturl.attributes || {}),
|
||||||
|
schema_version: shorturl.schema_version || 1,
|
||||||
|
creator_id: shorturl.creator_id || "",
|
||||||
|
creator_email: shorturl.creator_email || "",
|
||||||
|
creator_name: creatorName,
|
||||||
|
created_at: formatDateTime(shorturl.created_at as Date),
|
||||||
|
updated_at: formatDateTime(shorturl.updated_at as Date),
|
||||||
|
deleted_at: formatDateTime(shorturl.deleted_at as Date | null),
|
||||||
|
projects,
|
||||||
|
teams,
|
||||||
|
tags,
|
||||||
|
qr_codes: qrCodes,
|
||||||
|
channels,
|
||||||
|
favorites,
|
||||||
|
expires_at: formatDateTime(shorturl.expiresAt as Date | null),
|
||||||
|
click_count: clickCount,
|
||||||
|
unique_visitors: uniqueVisitors
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取ClickHouse配置
|
||||||
|
async function getClickHouseConfig(): Promise<ChConfig> {
|
||||||
|
try {
|
||||||
|
// 使用getVariable而不是getResource获取ClickHouse配置
|
||||||
|
const chConfigJson = await getVariable("f/shorturl_analytics/clickhouse");
|
||||||
|
console.log("原始ClickHouse配置:", typeof chConfigJson);
|
||||||
|
|
||||||
|
// 确保配置不为空
|
||||||
|
if (!chConfigJson) {
|
||||||
|
throw new Error("未找到ClickHouse配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析JSON字符串为对象
|
||||||
|
let chConfig: ChConfig;
|
||||||
|
if (typeof chConfigJson === 'string') {
|
||||||
|
try {
|
||||||
|
chConfig = JSON.parse(chConfigJson);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error("解析JSON失败:", parseError);
|
||||||
|
throw new Error("ClickHouse配置不是有效的JSON");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chConfig = chConfigJson as ChConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
if (!chConfig.clickhouse_url && chConfig.clickhouse_host && chConfig.clickhouse_port) {
|
||||||
|
chConfig.clickhouse_url = `http://${chConfig.clickhouse_host}:${chConfig.clickhouse_port}`;
|
||||||
|
console.log(`已构建ClickHouse URL: ${chConfig.clickhouse_url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chConfig.clickhouse_url) {
|
||||||
|
throw new Error("ClickHouse配置缺少URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
return chConfig;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取ClickHouse配置失败:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入数据到ClickHouse
|
||||||
|
async function insertToClickhouse(data: Record<string, unknown>[]) {
|
||||||
|
if (data.length === 0) return 0;
|
||||||
|
|
||||||
|
// 获取ClickHouse连接信息
|
||||||
|
const chConfig = await getClickHouseConfig();
|
||||||
|
|
||||||
|
// 确保URL有效
|
||||||
|
if (!chConfig.clickhouse_url) {
|
||||||
|
throw new Error("无效的ClickHouse URL: 未定义");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`准备写入数据到ClickHouse: ${chConfig.clickhouse_url}`);
|
||||||
|
|
||||||
|
// 构建INSERT查询
|
||||||
|
const columns = Object.keys(data[0]).join(", ");
|
||||||
|
|
||||||
|
// 收集所有记录的ID
|
||||||
|
const recordIds = data.map(record => record.id as string);
|
||||||
|
console.log(`需要处理的记录数: ${recordIds.length}`);
|
||||||
|
|
||||||
|
// 先删除可能存在的重复记录
|
||||||
|
try {
|
||||||
|
console.log(`删除可能存在的重复记录...`);
|
||||||
|
|
||||||
|
// 按批次处理删除,避免请求过大
|
||||||
|
const deleteBatchSize = 100;
|
||||||
|
for (let i = 0; i < recordIds.length; i += deleteBatchSize) {
|
||||||
|
const idBatch = recordIds.slice(i, i + deleteBatchSize);
|
||||||
|
const formattedIds = idBatch.map(id => `'${id}'`).join(', ');
|
||||||
|
|
||||||
|
const deleteQuery = `
|
||||||
|
ALTER TABLE shorturl_analytics.shorturl
|
||||||
|
DELETE WHERE id IN (${formattedIds})
|
||||||
|
`;
|
||||||
|
|
||||||
|
const response = await fetch(chConfig.clickhouse_url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Authorization": `Basic ${btoa(`${chConfig.clickhouse_user}:${chConfig.clickhouse_password}`)}`
|
||||||
|
},
|
||||||
|
body: deleteQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.warn(`删除记录时出错 (批次 ${i/deleteBatchSize + 1}): ${errorText}`);
|
||||||
|
// 继续执行,不中断流程
|
||||||
|
} else {
|
||||||
|
console.log(`成功删除批次 ${i/deleteBatchSize + 1}/${Math.ceil(recordIds.length/deleteBatchSize)}的潜在重复记录`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`删除重复记录时出错: ${(error as Error).message}`);
|
||||||
|
// 继续执行,不因为删除失败而中断整个过程
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO shorturl_analytics.shorturl (${columns})
|
||||||
|
FORMAT JSONEachRow
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 批量插入
|
||||||
|
let inserted = 0;
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += batchSize) {
|
||||||
|
const batch = data.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
// 使用JSONEachRow格式
|
||||||
|
const rows = batch.map(row => JSON.stringify(row)).join('\n');
|
||||||
|
|
||||||
|
// 使用HTTP接口执行查询
|
||||||
|
try {
|
||||||
|
console.log(`正在发送请求到: ${chConfig.clickhouse_url}`);
|
||||||
|
console.log(`认证信息: ${chConfig.clickhouse_user}:***`);
|
||||||
|
|
||||||
|
const response = await fetch(chConfig.clickhouse_url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Authorization": `Basic ${btoa(`${chConfig.clickhouse_user}:${chConfig.clickhouse_password}`)}`
|
||||||
|
},
|
||||||
|
body: `${query}\n${rows}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`ClickHouse插入失败: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted += batch.length;
|
||||||
|
console.log(`已插入 ${inserted}/${data.length} 条记录`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`请求ClickHouse时出错:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user