Compare commits
10 Commits
only_event
...
1b901bda90
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b901bda90 | |||
| 53822f1087 | |||
| 1978e0224e | |||
| c0649ce10f | |||
| 696a434b95 | |||
| b8e6180212 | |||
| 6beb6c3666 | |||
| 17b588e249 | |||
| 26db8fe76d | |||
| 4ad505cda1 |
80
app/(app)/AppLayoutClient.tsx
Normal file
80
app/(app)/AppLayoutClient.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ProtectedRoute, useAuth } from '@/lib/auth';
|
||||
|
||||
export default function AppLayoutClient({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { signOut, user } = useAuth();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<nav className="bg-white border-b border-gray-200">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900">
|
||||
ShortURL Analytics
|
||||
</Link>
|
||||
<div className="hidden md:block ml-10">
|
||||
<div className="flex items-baseline space-x-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Analytics
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/geo"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Geographic
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/devices"
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-gray-500 mr-4">{user?.email}</span>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"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';
|
||||
|
||||
@@ -91,7 +90,7 @@ export default function DeviceAnalyticsPage() {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'white'
|
||||
color: 'currentColor'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
@@ -132,7 +131,7 @@ export default function DeviceAnalyticsPage() {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'white'
|
||||
color: 'currentColor'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
@@ -173,7 +172,7 @@ export default function DeviceAnalyticsPage() {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: 'white'
|
||||
color: 'currentColor'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
@@ -211,27 +210,27 @@ export default function DeviceAnalyticsPage() {
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-foreground">Device Analytics</h1>
|
||||
<p className="mt-2 text-foreground">Analyze visitor distribution by devices, browsers, and operating systems</p>
|
||||
</div>
|
||||
|
||||
{/* 时间范围选择器 */}
|
||||
<div className="bg-card-bg rounded-xl p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">Start Date</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm text-foreground"
|
||||
value={dateRange.from.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1">End Date</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm text-foreground"
|
||||
value={dateRange.to.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
|
||||
/>
|
||||
@@ -243,13 +242,13 @@ export default function DeviceAnalyticsPage() {
|
||||
<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>
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Device Types</h3>
|
||||
{deviceData && deviceData.deviceTypes.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<canvas ref={deviceTypesChartRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-64 text-text-secondary">
|
||||
<div className="flex justify-center items-center h-64 text-foreground">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
@@ -257,13 +256,13 @@ export default function DeviceAnalyticsPage() {
|
||||
|
||||
{/* 浏览器 */}
|
||||
<div className="bg-card-bg rounded-xl p-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4">Browsers</h3>
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Browsers</h3>
|
||||
{deviceData && deviceData.browsers.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<canvas ref={browsersChartRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-64 text-text-secondary">
|
||||
<div className="flex justify-center items-center h-64 text-foreground">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
@@ -271,13 +270,13 @@ export default function DeviceAnalyticsPage() {
|
||||
|
||||
{/* 操作系统 */}
|
||||
<div className="bg-card-bg rounded-xl p-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4">Operating Systems</h3>
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Operating Systems</h3>
|
||||
{deviceData && deviceData.operatingSystems.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<canvas ref={osChartRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-64 text-text-secondary">
|
||||
<div className="flex justify-center items-center h-64 text-foreground">
|
||||
No data available
|
||||
</div>
|
||||
)}
|
||||
@@ -300,7 +299,7 @@ export default function DeviceAnalyticsPage() {
|
||||
|
||||
{/* 无数据状态 */}
|
||||
{!isLoading && !error && !deviceData && (
|
||||
<div className="flex justify-center items-center p-8 text-text-secondary">
|
||||
<div className="flex justify-center items-center p-8 text-foreground">
|
||||
<p>No device data available</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,27 +37,27 @@ export default function GeoAnalyticsPage() {
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-foreground">Geographic Analysis</h1>
|
||||
<p className="mt-2 text-foreground">Analyze visitor distribution by location</p>
|
||||
</div>
|
||||
|
||||
{/* 时间范围选择器 */}
|
||||
<div className="bg-card-bg rounded-xl p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">Start Date</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm "
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm text-foreground"
|
||||
value={dateRange.from.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">End Date</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm "
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm text-foreground"
|
||||
value={dateRange.to.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
|
||||
/>
|
||||
@@ -71,33 +71,33 @@ export default function GeoAnalyticsPage() {
|
||||
<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>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-foreground uppercase tracking-wider">Location</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-foreground uppercase tracking-wider">Visits</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-foreground uppercase tracking-wider">Unique Visitors</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-foreground uppercase tracking-wider">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-card-border">
|
||||
{geoData.map(item => (
|
||||
<tr key={item.location} className="hover:bg-background/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
||||
{item.location}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
||||
{item.visits}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
||||
{item.visitors}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-full bg-background rounded-full h-2 mr-2">
|
||||
<span className="mr-2 text-foreground">{item.percentage.toFixed(1)}%</span>
|
||||
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white">{item.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -122,14 +122,14 @@ export default function GeoAnalyticsPage() {
|
||||
|
||||
{/* 无数据状态 */}
|
||||
{!isLoading && !error && geoData.length === 0 && (
|
||||
<div className="flex justify-center items-center p-8 text-white">
|
||||
<div className="flex justify-center items-center p-8 text-foreground">
|
||||
<p>No geographic data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="mt-4 text-sm text-white">
|
||||
<div className="mt-4 text-sm text-foreground">
|
||||
<p>Note: Geographic data is based on IP addresses and may not be 100% accurate.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
56
app/(app)/analytics/page.tsx
Normal file
56
app/(app)/analytics/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { subDays } from 'date-fns';
|
||||
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
||||
import { TeamSelector } from '@/app/components/ui/TeamSelector';
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
// 默认日期范围为最近7天
|
||||
const today = new Date();
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: subDays(today, 7), // 7天前
|
||||
to: today // 今天
|
||||
});
|
||||
|
||||
// 添加团队选择状态
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
|
||||
<h1 className="text-xl font-bold text-gray-900">Analytics</h1>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<TeamSelector
|
||||
value={selectedTeamId}
|
||||
onChange={setSelectedTeamId}
|
||||
className="w-[200px]"
|
||||
variant="surface"
|
||||
color="blue"
|
||||
/>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 如果没有选择团队,显示提示信息 */}
|
||||
{!selectedTeamId && (
|
||||
<div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">
|
||||
Please select a team to view analytics
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 如果选择了团队,这里可以显示团队相关的分析数据 */}
|
||||
{selectedTeamId && (
|
||||
<div className="space-y-6">
|
||||
{/* 这里添加实际的分析数据组件 */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { addDays, format } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
||||
import TimeSeriesChart from '@/app/components/charts/TimeSeriesChart';
|
||||
import GeoAnalytics from '@/app/components/analytics/GeoAnalytics';
|
||||
@@ -50,7 +50,7 @@ export default function DashboardPage() {
|
||||
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);
|
||||
setSummary(summaryData.data);
|
||||
setTimeSeriesData(timeSeriesData.data);
|
||||
setGeoData(geoData.data);
|
||||
setDeviceData(deviceData.data);
|
||||
@@ -83,7 +83,7 @@ export default function DashboardPage() {
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Analytics Dashboard</h1>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
@@ -92,47 +92,47 @@ export default function DashboardPage() {
|
||||
|
||||
{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">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total Events</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white 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">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Unique Visitors</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white 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">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Total Conversions</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white 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">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500">Avg. Time Spent</h3>
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{summary.averageTimeSpent?.toFixed(1) || '0'}s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white 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="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Event Trends</h2>
|
||||
<div className="h-96">
|
||||
<TimeSeriesChart data={timeSeriesData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Device Analytics</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 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>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2>
|
||||
<GeoAnalytics data={geoData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,227 +1,222 @@
|
||||
"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';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
// 更复杂的事件类型定义
|
||||
interface Event {
|
||||
event_id?: string;
|
||||
url_id: string;
|
||||
url: string;
|
||||
event_type: string;
|
||||
visitor_id: string;
|
||||
created_at: string;
|
||||
referrer?: string;
|
||||
browser?: string;
|
||||
os?: string;
|
||||
device_type?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
// 创建获取事件的函数
|
||||
const fetchEvents = async (
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
urlId?: string,
|
||||
eventType?: string
|
||||
): Promise<Event[]> => {
|
||||
try {
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams();
|
||||
if (startTime) params.append('startTime', startTime);
|
||||
if (endTime) params.append('endTime', endTime);
|
||||
if (urlId) params.append('urlId', urlId);
|
||||
if (eventType) params.append('eventType', eventType);
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(`/api/events?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch events');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss');
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export default function EventsPage() {
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date('2024-02-01'),
|
||||
to: new Date('2025-03-05')
|
||||
});
|
||||
|
||||
// 状态定义
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
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
|
||||
startDate: format(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), 'yyyy-MM-dd'),
|
||||
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
urlId: '',
|
||||
eventType: ''
|
||||
});
|
||||
|
||||
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 loadEvents = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const startTime = `${filters.startDate}T00:00:00Z`;
|
||||
const endTime = `${filters.endDate}T23:59:59Z`;
|
||||
|
||||
const eventsData = await fetchEvents(
|
||||
startTime,
|
||||
endTime,
|
||||
filters.urlId || undefined,
|
||||
filters.eventType || undefined
|
||||
);
|
||||
|
||||
setEvents(eventsData);
|
||||
} catch (err) {
|
||||
setError('Failed to load events');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEvents();
|
||||
}, [filters]);
|
||||
|
||||
// 处理筛选条件变化
|
||||
const handleFilterChange = (name: string, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
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 className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Events</h1>
|
||||
<p className="mt-2 text-gray-600">View and analyze all events for your URLs</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
{/* 过滤器面板 */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 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 className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Start Date
|
||||
</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"
|
||||
type="date"
|
||||
value={filters.startDate}
|
||||
onChange={e => handleFilterChange('startDate', e.target.value)}
|
||||
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Link Slug
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.endDate}
|
||||
onChange={e => handleFilterChange('endDate', e.target.value)}
|
||||
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 mb-1">
|
||||
URL ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={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"
|
||||
value={filters.urlId}
|
||||
onChange={e => handleFilterChange('urlId', e.target.value)}
|
||||
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
|
||||
placeholder="Filter by URL ID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
|
||||
{/* 事件表格 */}
|
||||
<div className="bg-white 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">
|
||||
<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 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
URL ID
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Link
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
URL
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Visitor
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Event Type
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Location
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Visitor ID
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Referrer
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Conversion
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white 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)}
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{events.map((event, index) => (
|
||||
<tr key={event.event_id || index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(event.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{event.url_id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<a href={event.url} className="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{event.url}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span className={`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'
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
event.event_type === 'click'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{event.event_type || 'unknown'}
|
||||
{event.event_type}
|
||||
</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 className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{event.visitor_id.substring(0, 8)}...
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-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">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{event.referrer || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-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 className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{event.country && event.city ? `${event.city}, ${event.country}` : (event.country || '-')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -229,25 +224,23 @@ export default function EventsPage() {
|
||||
</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 className="flex justify-center items-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
{/* 错误状态 */}
|
||||
{error && (
|
||||
<div className="flex justify-center items-center p-8 text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && Array.isArray(events) && events.length === 0 && (
|
||||
<div className="flex justify-center p-8 text-gray-500 dark:text-gray-400">
|
||||
{/* 空状态 */}
|
||||
{!loading && !error && events.length === 0 && (
|
||||
<div className="flex justify-center items-center p-8 text-gray-500">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import '../globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import AppLayoutClient from './AppLayoutClient';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
@@ -17,50 +17,9 @@ export default function AppLayout({
|
||||
}) {
|
||||
return (
|
||||
<div className={inter.className}>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
ShortURL Analytics
|
||||
</Link>
|
||||
<div className="hidden md:block ml-10">
|
||||
<div className="flex items-baseline space-x-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/geo"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Geographic
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/devices"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="py-10">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<AppLayoutClient>
|
||||
{children}
|
||||
</AppLayoutClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import CreateLinkModal from '../components/ui/CreateLinkModal';
|
||||
import { Link, StatsOverview, Tag } from '../api/types';
|
||||
import CreateLinkModal from '@/app/components/ui/CreateLinkModal';
|
||||
|
||||
// 自定义类型定义,替换原来的导入
|
||||
interface Link {
|
||||
link_id: string;
|
||||
title?: string;
|
||||
original_url: string;
|
||||
visits: number;
|
||||
unique_visits: number;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
is_active: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface StatsOverview {
|
||||
totalLinks: number;
|
||||
activeLinks: number;
|
||||
totalVisits: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
tag: string;
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Define type for link data
|
||||
interface LinkData {
|
||||
|
||||
@@ -1,58 +1,64 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto py-16">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-8">
|
||||
Welcome to ShortURL Analytics
|
||||
</h1>
|
||||
<div className="grid gap-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Dashboard
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
View your overall analytics and key metrics
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Events
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Track and analyze event data
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/geo"
|
||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Geographic Analysis
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Explore visitor locations and geographic patterns
|
||||
</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/devices"
|
||||
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Device Analytics
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Understand how users access your links
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Dashboard
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
Get an overview of all your short URL analytics data.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/events"
|
||||
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Event Tracking
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
View detailed events for all your short URLs.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/analytics"
|
||||
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
URL Analysis
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
Analyze performance of specific short URLs.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/account"
|
||||
className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Account Settings
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600">
|
||||
Manage your account and team settings.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,13 +93,9 @@ export interface TimeSeriesData {
|
||||
}
|
||||
|
||||
export interface GeoData {
|
||||
location?: string;
|
||||
country?: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
location: string;
|
||||
visits: number;
|
||||
uniqueVisitors?: number;
|
||||
visitors?: number;
|
||||
visitors: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,68 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { DeviceAnalytics as DeviceAnalyticsType } from '@/app/api/types';
|
||||
import { Chart, PieController, ArcElement, Tooltip, Legend } from 'chart.js';
|
||||
|
||||
interface CategoryItem {
|
||||
name: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface DeviceAnalyticsProps {
|
||||
data: DeviceAnalyticsType;
|
||||
}
|
||||
|
||||
function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) {
|
||||
// 安全地格式化数字
|
||||
const formatNumber = (value: number | string | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||
};
|
||||
|
||||
// 安全地格式化百分比
|
||||
const formatPercent = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return value.toFixed(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{title}</h3>
|
||||
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
|
||||
const renderCategory = (items: CategoryItem[], title: string) => (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||
<div className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span>{item.name || 'Unknown'}</span>
|
||||
<span>{formatNumber(item.count)} ({formatPercent(item.percentage)}%)</span>
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>{item.name}</span>
|
||||
<span>{item.percentage.toFixed(1)}% ({item.count})</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage || 0}%` }}
|
||||
/>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
|
||||
// Prepare device types data
|
||||
const deviceItems = data.deviceTypes.map(item => ({
|
||||
name: item.type || 'Unknown',
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<StatCard
|
||||
title="Device Types"
|
||||
items={(data.deviceTypes || []).map(item => ({
|
||||
name: item.type ? (item.type.charAt(0).toUpperCase() + item.type.slice(1)) : 'Unknown',
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
}))}
|
||||
/>
|
||||
<StatCard
|
||||
title="Browsers"
|
||||
items={data.browsers || []}
|
||||
/>
|
||||
<StatCard
|
||||
title="Operating Systems"
|
||||
items={data.operatingSystems || []}
|
||||
/>
|
||||
{renderCategory(deviceItems, 'Device Types')}
|
||||
{renderCategory(data.browsers, 'Browsers')}
|
||||
{renderCategory(data.operatingSystems, 'Operating Systems')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { GeoData } from '@/app/api/types';
|
||||
import { Chart, PieController, ArcElement, Tooltip, Legend } from 'chart.js';
|
||||
|
||||
interface GeoAnalyticsProps {
|
||||
data: GeoData[];
|
||||
@@ -10,57 +8,59 @@ interface GeoAnalyticsProps {
|
||||
|
||||
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
||||
// 安全地格式化数字
|
||||
const formatNumber = (value: any): string => {
|
||||
const formatNumber = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||
return value.toLocaleString();
|
||||
};
|
||||
|
||||
// 安全地格式化百分比
|
||||
const formatPercent = (value: any): string => {
|
||||
const formatPercent = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toFixed(2) : String(value);
|
||||
return value.toFixed(1);
|
||||
};
|
||||
|
||||
const sortedData = [...data].sort((a, b) => (b.visits || 0) - (a.visits || 0));
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<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 dark:text-gray-400 uppercase tracking-wider">
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<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 dark:text-gray-400 uppercase tracking-wider">
|
||||
<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 dark:text-gray-400 uppercase tracking-wider">
|
||||
Percentage
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
% of Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.map((item, index) => (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country || item.location || 'Unknown'}
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedData.map((item, index) => (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.location || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<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 dark:text-gray-100">
|
||||
{formatNumber(item.uniqueVisitors || item.visitors)}
|
||||
<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 dark:text-gray-100">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">{formatPercent(item.percentage)}%</span>
|
||||
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import ThemeToggle from "../ui/ThemeToggle";
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
@@ -40,7 +39,6 @@ export default function Navbar() {
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ThemeToggle />
|
||||
<button className="p-2 text-sm text-foreground rounded-md gradient-border">
|
||||
Upgrade
|
||||
</button>
|
||||
|
||||
@@ -1,48 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
onChange: (range: { from: Date; to: Date }) => void;
|
||||
interface DateRange {
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
||||
const [from, setFrom] = useState(format(value.from, 'yyyy-MM-dd'));
|
||||
const [to, setTo] = useState(format(value.to, 'yyyy-MM-dd'));
|
||||
interface DateRangePickerProps {
|
||||
value: DateRange;
|
||||
onChange: (value: DateRange) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setFrom(format(value.from, 'yyyy-MM-dd'));
|
||||
setTo(format(value.to, 'yyyy-MM-dd'));
|
||||
}, [value]);
|
||||
export function DateRangePicker({
|
||||
value,
|
||||
onChange,
|
||||
className
|
||||
}: DateRangePickerProps) {
|
||||
// Internal date state for validation
|
||||
const [from, setFrom] = useState<string>(
|
||||
value.from ? format(value.from, 'yyyy-MM-dd') : ''
|
||||
);
|
||||
const [to, setTo] = useState<string>(
|
||||
value.to ? format(value.to, 'yyyy-MM-dd') : ''
|
||||
);
|
||||
|
||||
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFrom = e.target.value;
|
||||
setFrom(newFrom);
|
||||
onChange({
|
||||
from: new Date(newFrom),
|
||||
to: value.to
|
||||
});
|
||||
|
||||
if (newFrom) {
|
||||
onChange({
|
||||
from: new Date(newFrom),
|
||||
to: value.to
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTo = e.target.value;
|
||||
setTo(newTo);
|
||||
onChange({
|
||||
from: value.from,
|
||||
to: new Date(newTo)
|
||||
});
|
||||
|
||||
if (newTo) {
|
||||
onChange({
|
||||
from: value.from,
|
||||
to: new Date(newTo)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-4 ${className}`}>
|
||||
<div>
|
||||
<label htmlFor="from" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
From
|
||||
<label htmlFor="from" className="block text-sm font-medium text-gray-500 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -50,12 +63,12 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
||||
value={from}
|
||||
onChange={handleFromChange}
|
||||
max={to}
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="to" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
To
|
||||
<label htmlFor="to" className="block text-sm font-medium text-gray-500 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -63,7 +76,7 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
||||
value={to}
|
||||
onChange={handleToChange}
|
||||
min={from}
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
164
app/components/ui/TeamSelector.tsx
Normal file
164
app/components/ui/TeamSelector.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Database } from '@/types/supabase';
|
||||
import { getSupabaseClient } from '../../utils/supabase';
|
||||
import { AuthChangeEvent, Session } from '@supabase/supabase-js';
|
||||
import { Select } from '@radix-ui/themes';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Team = Database['limq']['Tables']['teams']['Row'];
|
||||
type AccentColor =
|
||||
| 'ruby' | 'gray' | 'gold' | 'bronze' | 'brown' | 'yellow' | 'amber' | 'orange'
|
||||
| 'tomato' | 'red' | 'crimson' | 'pink' | 'plum' | 'purple' | 'violet'
|
||||
| 'iris' | 'indigo' | 'blue' | 'cyan' | 'teal' | 'jade' | 'green' | 'grass' | 'lime';
|
||||
|
||||
// TeamSelector component using Radix Themes
|
||||
export function TeamSelector({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
size = "2",
|
||||
variant = "surface",
|
||||
color,
|
||||
radius,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (teamId: string) => void;
|
||||
className?: string;
|
||||
size?: "1" | "2" | "3";
|
||||
variant?: "classic" | "surface" | "soft" | "ghost";
|
||||
color?: AccentColor;
|
||||
radius?: "none" | "small" | "medium" | "large" | "full";
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
|
||||
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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
size={size}
|
||||
>
|
||||
<Select.Trigger
|
||||
className={className}
|
||||
variant={variant}
|
||||
color={color}
|
||||
radius={radius}
|
||||
placeholder="Select a team"
|
||||
/>
|
||||
<Select.Content position="popper">
|
||||
{teams.map((team) => (
|
||||
<Select.Item key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import './globals.css';
|
||||
import '@radix-ui/themes/styles.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import { Theme } from '@radix-ui/themes';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Link Management & Analytics',
|
||||
title: 'ShortURL Analytics',
|
||||
description: 'Track and analyze shortened links',
|
||||
};
|
||||
|
||||
@@ -14,7 +17,11 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
{children}
|
||||
<Theme>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</Theme>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
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('/dashboard');
|
||||
}
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
84
app/page.tsx
84
app/page.tsx
@@ -45,39 +45,65 @@ export default function HomePage() {
|
||||
];
|
||||
|
||||
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 className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<header className="bg-white dark:bg-gray-800 shadow">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
ShortURL Analytics
|
||||
</h1>
|
||||
<div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-block bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="ml-4 inline-block bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-4 py-2 rounded-md text-sm font-medium hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
|
||||
>
|
||||
注册
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="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 className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Welcome to ShortURL Analytics
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Get detailed insights into your link performance and visitor behavior
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{sections.map((section) => (
|
||||
<Link
|
||||
key={section.title}
|
||||
href={section.href}
|
||||
className="group block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg mr-4">
|
||||
<div className="text-blue-600 dark:text-blue-300">
|
||||
{section.icon}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
|
||||
{section.title}
|
||||
</h2>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{section.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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,6 +1,14 @@
|
||||
import { executeQuery, executeQuerySingle, buildFilter, buildPagination, buildOrderBy } from './clickhouse';
|
||||
import type { Event, EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics, DeviceType } from './types';
|
||||
|
||||
// 时间粒度枚举
|
||||
export enum TimeGranularity {
|
||||
HOUR = 'hour',
|
||||
DAY = 'day',
|
||||
WEEK = 'week',
|
||||
MONTH = 'month'
|
||||
}
|
||||
|
||||
// 获取事件列表
|
||||
export async function getEvents(params: {
|
||||
startTime?: string;
|
||||
|
||||
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;
|
||||
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;
|
||||
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 = {
|
||||
/* config options here */
|
||||
// 设置需要转译的包
|
||||
transpilePackages: [],
|
||||
|
||||
// 配置实验性选项
|
||||
experimental: {
|
||||
@@ -14,8 +13,8 @@ const nextConfig: NextConfig = {
|
||||
// 禁用严格模式,避免开发时重复渲染
|
||||
reactStrictMode: false,
|
||||
|
||||
// 设置输出为独立应用
|
||||
output: 'standalone',
|
||||
// 暂时禁用standalone输出模式,解决构建问题
|
||||
// output: 'standalone',
|
||||
|
||||
// 忽略ESLint错误,不会在构建时中断
|
||||
eslint: {
|
||||
|
||||
14
package.json
14
package.json
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@@ -24,27 +24,33 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clickhouse/client": "^1.11.0",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||
"@types/chart.js": "^2.9.41",
|
||||
"@types/recharts": "^1.8.29",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"chart.js": "^4.4.8",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "15.2.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.1",
|
||||
"swagger-ui-dist": "^5.12.0",
|
||||
"swagger-ui-react": "^5.12.0",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"css-loader": "^7.1.2",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"style-loader": "^4.0.0",
|
||||
|
||||
2921
pnpm-lock.yaml
generated
2921
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ const config: Config = {
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
darkMode: false,
|
||||
theme: {
|
||||
extend: {
|
||||
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();
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user