This commit is contained in:
2025-04-01 19:43:30 +08:00
parent 53822f1087
commit 1b901bda90
14 changed files with 577 additions and 580 deletions

View File

@@ -16,43 +16,43 @@ export default function AppLayoutClient({
return ( return (
<ProtectedRoute> <ProtectedRoute>
<div className="min-h-screen bg-gray-50 dark:bg-gray-900"> <div className="min-h-screen bg-gray-50">
<nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700"> <nav className="bg-white border-b border-gray-200">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16"> <div className="flex items-center justify-between h-16">
<div className="flex items-center"> <div className="flex items-center">
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-gray-100"> <Link href="/" className="text-xl font-bold text-gray-900">
ShortURL Analytics ShortURL Analytics
</Link> </Link>
<div className="hidden md:block ml-10"> <div className="hidden md:block ml-10">
<div className="flex items-baseline space-x-4"> <div className="flex items-baseline space-x-4">
<Link <Link
href="/dashboard" 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" className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
> >
Dashboard Dashboard
</Link> </Link>
<Link <Link
href="/analytics" href="/analytics"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium" className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
> >
Analytics Analytics
</Link> </Link>
<Link <Link
href="/events" 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" className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
> >
Events Events
</Link> </Link>
<Link <Link
href="/analytics/geo" 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" className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
> >
Geographic Geographic
</Link> </Link>
<Link <Link
href="/analytics/devices" 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" className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
> >
Devices Devices
</Link> </Link>
@@ -60,24 +60,18 @@ export default function AppLayoutClient({
</div> </div>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
{user && ( <span className="text-sm text-gray-500 mr-4">{user?.email}</span>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-700 dark:text-gray-300">
{user.email}
</span>
<button <button
onClick={handleSignOut} onClick={handleSignOut}
className="text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md" className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
> >
退 Sign Out
</button> </button>
</div> </div>
)}
</div>
</div> </div>
</div> </div>
</nav> </nav>
<main className="py-10"> <main className="container mx-auto px-4 py-8">
{children} {children}
</main> </main>
</div> </div>

View File

@@ -92,7 +92,7 @@ export default function GeoAnalyticsPage() {
<td className="px-6 py-4 whitespace-nowrap text-sm"> <td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2 text-foreground">{item.percentage.toFixed(1)}%</span> <span className="mr-2 text-foreground">{item.percentage.toFixed(1)}%</span>
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2"> <div className="w-24 bg-gray-200 rounded-full h-2">
<div <div
className="bg-blue-500 h-2 rounded-full" className="bg-blue-500 h-2 rounded-full"
style={{ width: `${item.percentage}%` }} style={{ width: `${item.percentage}%` }}

View File

@@ -19,7 +19,7 @@ export default function AnalyticsPage() {
return ( return (
<div className="container mx-auto px-4 py-8"> <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"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Analytics</h1> <h1 className="text-xl font-bold text-gray-900">Analytics</h1>
<div className="flex flex-col gap-4 md:flex-row md:items-center"> <div className="flex flex-col gap-4 md:flex-row md:items-center">
<TeamSelector <TeamSelector
@@ -38,8 +38,8 @@ export default function AnalyticsPage() {
{/* 如果没有选择团队,显示提示信息 */} {/* 如果没有选择团队,显示提示信息 */}
{!selectedTeamId && ( {!selectedTeamId && (
<div className="flex items-center justify-center p-8 bg-gray-50 dark:bg-gray-800 rounded-lg"> <div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500">
Please select a team to view analytics Please select a team to view analytics
</p> </p>
</div> </div>

View File

@@ -83,7 +83,7 @@ export default function DashboardPage() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-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 <DateRangePicker
value={dateRange} value={dateRange}
onChange={setDateRange} onChange={setDateRange}
@@ -92,47 +92,47 @@ export default function DashboardPage() {
{summary && ( {summary && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> <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"> <div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Events</h3> <h3 className="text-sm font-medium text-gray-500">Total Events</h3>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100"> <p className="text-2xl font-semibold text-gray-900">
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents} {typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
</p> </p>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Unique Visitors</h3> <h3 className="text-sm font-medium text-gray-500">Unique Visitors</h3>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100"> <p className="text-2xl font-semibold text-gray-900">
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors} {typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
</p> </p>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Conversions</h3> <h3 className="text-sm font-medium text-gray-500">Total Conversions</h3>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100"> <p className="text-2xl font-semibold text-gray-900">
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions} {typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
</p> </p>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Avg. Time Spent</h3> <h3 className="text-sm font-medium text-gray-500">Avg. Time Spent</h3>
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100"> <p className="text-2xl font-semibold text-gray-900">
{summary.averageTimeSpent?.toFixed(1) || '0'}s {summary.averageTimeSpent?.toFixed(1) || '0'}s
</p> </p>
</div> </div>
</div> </div>
)} )}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8"> <div className="bg-white 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> <h2 className="text-lg font-semibold text-gray-900 mb-4">Event Trends</h2>
<div className="h-96"> <div className="h-96">
<TimeSeriesChart data={timeSeriesData} /> <TimeSeriesChart data={timeSeriesData} />
</div> </div>
</div> </div>
<div className="mb-8"> <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} />} {deviceData && <DeviceAnalytics data={deviceData} />}
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Geographic Distribution</h2> <h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2>
<GeoAnalytics data={geoData} /> <GeoAnalytics data={geoData} />
</div> </div>
</div> </div>

View File

@@ -1,227 +1,222 @@
"use client"; "use client";
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { addDays, format } from 'date-fns'; import { format } from 'date-fns';
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
import { Event } from '@/app/api/types';
export default function EventsPage() { // 更复杂的事件类型定义
const [dateRange, setDateRange] = useState({ interface Event {
from: new Date('2024-02-01'), event_id?: string;
to: new Date('2025-03-05') 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 [loading, setLoading] = useState(true); // 创建获取事件的函数
const [error, setError] = useState<string | null>(null); const fetchEvents = async (
const [events, setEvents] = useState<Event[]>([]); startTime?: string,
const [page, setPage] = useState(1); endTime?: string,
const [hasMore, setHasMore] = useState(true); urlId?: string,
const [filter, setFilter] = useState({ eventType?: string
eventType: '', ): Promise<Event[]> => {
linkId: '',
linkSlug: ''
});
const [filters, setFilters] = useState({
startTime: format(new Date('2024-02-01'), "yyyy-MM-dd'T'HH:mm:ss'Z'"),
endTime: format(new Date('2025-03-05'), "yyyy-MM-dd'T'HH:mm:ss'Z'"),
page: 1,
pageSize: 20
});
const [summary, setSummary] = useState<any>(null);
const fetchEvents = async (pageNum: number) => {
try { 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();
if (startTime) params.append('startTime', startTime);
const params = new URLSearchParams({ if (endTime) params.append('endTime', endTime);
startTime, if (urlId) params.append('urlId', urlId);
endTime, if (eventType) params.append('eventType', eventType);
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 response = await fetch(`/api/events?${params.toString()}`);
const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Failed to fetch events'); throw new Error('Failed to fetch events');
} }
const eventsData = data.data || data.events || []; const data = await response.json();
return data.data || [];
} catch (error) {
console.error('Error fetching events:', error);
return [];
}
};
const formatDate = (dateString: string) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'yyyy-MM-dd HH:mm:ss');
} catch {
return dateString;
}
};
export default function EventsPage() {
// 状态定义
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState({
startDate: format(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),
urlId: '',
eventType: ''
});
// 加载事件数据
useEffect(() => {
const loadEvents = async () => {
setLoading(true);
setError(null);
try {
const startTime = `${filters.startDate}T00:00:00Z`;
const endTime = `${filters.endDate}T23:59:59Z`;
const eventsData = await fetchEvents(
startTime,
endTime,
filters.urlId || undefined,
filters.eventType || undefined
);
if (pageNum === 1) {
setEvents(eventsData); setEvents(eventsData);
} else {
setEvents(prev => [...prev, ...eventsData]);
}
setHasMore(Array.isArray(eventsData) && eventsData.length === 50);
} catch (err) { } catch (err) {
console.error("Error fetching events:", err); setError('Failed to load events');
setError(err instanceof Error ? err.message : 'An error occurred while fetching events'); console.error(err);
setEvents([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { loadEvents();
setPage(1); }, [filters]);
setEvents([]);
setLoading(true);
fetchEvents(1);
}, [dateRange, filter]);
const loadMore = () => { // 处理筛选条件变化
if (!loading && hasMore) { const handleFilterChange = (name: string, value: string) => {
const nextPage = page + 1; setFilters(prev => ({
setPage(nextPage); ...prev,
fetchEvents(nextPage); [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 ( return (
<div className="container mx-auto px-4 py-8"> <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> <div className="mb-8">
<DateRangePicker <h1 className="text-2xl font-bold text-gray-900">Events</h1>
value={dateRange} <p className="mt-2 text-gray-600">View and analyze all events for your URLs</p>
onChange={setDateRange} </div>
{/* 过滤器面板 */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">
Start Date
</label>
<input
type="date"
value={filters.startDate}
onChange={e => handleFilterChange('startDate', e.target.value)}
className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
/> />
</div> </div>
<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> <div>
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"> <label className="block text-sm font-medium text-gray-500 mb-1">
Event Type End Date
</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> </label>
<input <input
type="text" type="date"
value={filter.linkId} value={filters.endDate}
onChange={e => setFilter(prev => ({ ...prev, linkId: e.target.value }))} onChange={e => handleFilterChange('endDate', e.target.value)}
placeholder="Enter Link ID" className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"> <label className="block text-sm font-medium text-gray-500 mb-1">
Link Slug URL ID
</label> </label>
<input <input
type="text" type="text"
value={filter.linkSlug} value={filters.urlId}
onChange={e => setFilter(prev => ({ ...prev, linkSlug: e.target.value }))} onChange={e => handleFilterChange('urlId', e.target.value)}
placeholder="Enter Link Slug" className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900"
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" placeholder="Filter by URL ID"
/> />
</div> </div>
</div> </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"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 dark:bg-gray-900"> <thead className="bg-gray-50">
<tr> <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 Time
</th> </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">
Type URL ID
</th> </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">
Link URL
</th> </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">
Visitor Event Type
</th> </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">
Location Visitor ID
</th> </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 Referrer
</th> </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">
Conversion Location
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-white divide-y divide-gray-200">
{Array.isArray(events) && events.map((event, index) => ( {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'}> <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-900 dark:text-gray-100"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{event.event_time && formatDate(event.event_time)} {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>
<td className="px-6 py-4 whitespace-nowrap text-sm"> <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 ${ <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
event.event_type === 'conversion' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' : 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100' event.event_type === 'click'
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}> }`}>
{event.event_type || 'unknown'} {event.event_type}
</span> </span>
</td> </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-500">
<div> {event.visitor_id.substring(0, 8)}...
<div className="font-medium">{event.link_slug || '-'}</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.link_original_url || '-'}</div>
</div>
</td> </td>
<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-500">
<div>
<div>{event.browser || '-'}</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.os || '-'} / {event.device_type || '-'}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div>
<div>{event.city || '-'}</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.country || '-'}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{event.referrer || '-'} {event.referrer || '-'}
</td> </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-500">
<div> {event.country && event.city ? `${event.city}, ${event.country}` : (event.country || '-')}
<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> </td>
</tr> </tr>
))} ))}
@@ -229,25 +224,23 @@ export default function EventsPage() {
</table> </table>
</div> </div>
{/* 加载状态 */}
{loading && ( {loading && (
<div className="flex justify-center p-4"> <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 className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div> </div>
)} )}
{!loading && hasMore && ( {/* 错误状态 */}
<div className="flex justify-center p-4"> {error && (
<button <div className="flex justify-center items-center p-8 text-red-500">
onClick={loadMore} {error}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Load More
</button>
</div> </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 No events found
</div> </div>
)} )}

View File

@@ -1,58 +1,64 @@
import Link from 'next/link'; export default function HomePage() {
export default function Home() {
return ( return (
<div className="container mx-auto px-4"> <div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto py-16"> <div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8"> <h1 className="text-4xl font-bold text-gray-900 mb-8">
Welcome to ShortURL Analytics Welcome to ShortURL Analytics
</h1> </h1>
<div className="grid gap-6"> </div>
<Link
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<a
href="/dashboard" href="/dashboard"
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow" className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
> >
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Dashboard Dashboard
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400">
View your overall analytics and key metrics <p className="text-gray-600">
Get an overview of all your short URL analytics data.
</p> </p>
</Link> </a>
<Link
<a
href="/events" href="/events"
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow" className="block p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
> >
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Events Event Tracking
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400">
Track and analyze event data <p className="text-gray-600">
View detailed events for all your short URLs.
</p> </p>
</Link> </a>
<Link
href="/analytics/geo" <a
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow" 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 dark:text-gray-100 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Geographic Analysis URL Analysis
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400">
Explore visitor locations and geographic patterns <p className="text-gray-600">
Analyze performance of specific short URLs.
</p> </p>
</Link> </a>
<Link
href="/analytics/devices" <a
className="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow" 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 dark:text-gray-100 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Device Analytics Account Settings
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400">
Understand how users access your links <p className="text-gray-600">
Manage your account and team settings.
</p> </p>
</Link> </a>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,68 +1,52 @@
"use client"; "use client";
import { useEffect, useRef } from 'react';
import { DeviceAnalytics as DeviceAnalyticsType } from '@/app/api/types'; import { DeviceAnalytics as DeviceAnalyticsType } from '@/app/api/types';
import { Chart, PieController, ArcElement, Tooltip, Legend } from 'chart.js';
interface CategoryItem {
name: string;
count: number;
percentage: number;
}
interface DeviceAnalyticsProps { interface DeviceAnalyticsProps {
data: DeviceAnalyticsType; data: DeviceAnalyticsType;
} }
function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) { export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
// 安全地格式化数字 const renderCategory = (items: CategoryItem[], title: string) => (
const formatNumber = (value: number | string | undefined | null): string => { <div className="bg-white rounded-lg shadow p-6">
if (value === undefined || value === null) return '0'; <h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
return typeof value === 'number' ? value.toLocaleString() : String(value);
};
// 安全地格式化百分比
const formatPercent = (value: number | undefined | null): string => {
if (value === undefined || value === null) return '0';
return value.toFixed(1);
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{title}</h3>
<div className="space-y-4"> <div className="space-y-4">
{items.map((item, index) => ( {items.map((item, index) => (
<div key={index}> <div key={index}>
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1"> <div className="flex justify-between text-sm text-gray-600 mb-1">
<span>{item.name || 'Unknown'}</span> <span>{item.name}</span>
<span>{formatNumber(item.count)} ({formatPercent(item.percentage)}%)</span> <span>{item.percentage.toFixed(1)}% ({item.count})</span>
</div> </div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"> <div className="w-full bg-gray-200 rounded-full h-2">
<div <div
className="bg-blue-500 h-2 rounded-full" className="bg-blue-600 h-2 rounded-full"
style={{ width: `${item.percentage || 0}%` }} style={{ width: `${item.percentage}%` }}
/> ></div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
); );
}
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) { // Prepare device types data
return ( const deviceItems = data.deviceTypes.map(item => ({
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> name: item.type || 'Unknown',
<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, count: item.count,
percentage: item.percentage percentage: item.percentage
}))} }));
/>
<StatCard return (
title="Browsers" <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
items={data.browsers || []} {renderCategory(deviceItems, 'Device Types')}
/> {renderCategory(data.browsers, 'Browsers')}
<StatCard {renderCategory(data.operatingSystems, 'Operating Systems')}
title="Operating Systems"
items={data.operatingSystems || []}
/>
</div> </div>
); );
} }

View File

@@ -1,8 +1,6 @@
"use client"; "use client";
import { useEffect, useRef } from 'react';
import { GeoData } from '@/app/api/types'; import { GeoData } from '@/app/api/types';
import { Chart, PieController, ArcElement, Tooltip, Legend } from 'chart.js';
interface GeoAnalyticsProps { interface GeoAnalyticsProps {
data: GeoData[]; data: GeoData[];
@@ -10,57 +8,59 @@ interface GeoAnalyticsProps {
export default function GeoAnalytics({ data }: 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'; if (value === undefined || value === null) return '0';
return typeof value === 'number' ? value.toLocaleString() : String(value); return value.toLocaleString();
}; };
// 安全地格式化百分比 // 安全地格式化百分比
const formatPercent = (value: any): string => { const formatPercent = (value: number | undefined | null): string => {
if (value === undefined || value === null) return '0'; if (value === undefined || value === null) return '0';
return typeof value === 'number' ? value.toFixed(2) : String(value); return value.toFixed(1);
}; };
const sortedData = [...data].sort((a, b) => (b.visits || 0) - (a.visits || 0));
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 dark:bg-gray-800"> <thead className="bg-gray-50">
<tr> <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 Location
</th> </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 Visits
</th> </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 Unique Visitors
</th> </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">
Percentage % of Total
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-white divide-y divide-gray-200">
{data.map((item, index) => ( {sortedData.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}> <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 dark:text-gray-100"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country || item.location || 'Unknown'} {item.location || 'Unknown'}
</td> </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)} {formatNumber(item.visits)}
</td> </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.uniqueVisitors || item.visitors)} {formatNumber(item.visitors)}
</td> </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"> <div className="flex items-center">
<span className="mr-2">{formatPercent(item.percentage)}%</span> <div className="w-24 bg-gray-200 rounded-full h-2">
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div <div
className="bg-blue-500 h-2 rounded-full" className="bg-blue-600 h-2 rounded-full"
style={{ width: `${item.percentage || 0}%` }} style={{ width: `${item.percentage || 0}%` }}
/> />
</div> </div>
<span className="ml-2">{formatPercent(item.percentage)}%</span>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -1,48 +1,61 @@
"use client"; "use client";
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
interface DateRangePickerProps { interface DateRange {
value: {
from: Date; from: Date;
to: Date; to: Date;
};
onChange: (range: { from: Date; to: Date }) => void;
} }
export function DateRangePicker({ value, onChange }: DateRangePickerProps) { interface DateRangePickerProps {
const [from, setFrom] = useState(format(value.from, 'yyyy-MM-dd')); value: DateRange;
const [to, setTo] = useState(format(value.to, 'yyyy-MM-dd')); onChange: (value: DateRange) => void;
className?: string;
}
useEffect(() => { export function DateRangePicker({
setFrom(format(value.from, 'yyyy-MM-dd')); value,
setTo(format(value.to, 'yyyy-MM-dd')); onChange,
}, [value]); className
}: DateRangePickerProps) {
// Internal date state for validation
const [from, setFrom] = useState<string>(
value.from ? format(value.from, 'yyyy-MM-dd') : ''
);
const [to, setTo] = useState<string>(
value.to ? format(value.to, 'yyyy-MM-dd') : ''
);
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFrom = e.target.value; const newFrom = e.target.value;
setFrom(newFrom); setFrom(newFrom);
if (newFrom) {
onChange({ onChange({
from: new Date(newFrom), from: new Date(newFrom),
to: value.to to: value.to
}); });
}
}; };
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTo = e.target.value; const newTo = e.target.value;
setTo(newTo); setTo(newTo);
if (newTo) {
onChange({ onChange({
from: value.from, from: value.from,
to: new Date(newTo) to: new Date(newTo)
}); });
}
}; };
return ( return (
<div className="flex items-center space-x-4"> <div className={`flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-4 ${className}`}>
<div> <div>
<label htmlFor="from" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"> <label htmlFor="from" className="block text-sm font-medium text-gray-500 mb-1">
From Start Date
</label> </label>
<input <input
type="date" type="date"
@@ -50,12 +63,12 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
value={from} value={from}
onChange={handleFromChange} onChange={handleFromChange}
max={to} max={to}
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/> />
</div> </div>
<div> <div>
<label htmlFor="to" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1"> <label htmlFor="to" className="block text-sm font-medium text-gray-500 mb-1">
To End Date
</label> </label>
<input <input
type="date" type="date"
@@ -63,7 +76,7 @@ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
value={to} value={to}
onChange={handleToChange} onChange={handleToChange}
min={from} min={from}
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="block w-full px-3 py-2 bg-white border border-gray-300 rounded-md text-sm text-gray-900 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/> />
</div> </div>
</div> </div>

View File

@@ -1,143 +1,130 @@
'use client'; 'use client';
import { useState, FormEvent, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/auth'; import { useAuth } from '@/lib/auth';
import supabase from '@/lib/supabase';
export default function LoginPage() { export default function LoginPage() {
const router = useRouter();
const { signIn, signInWithGitHub, signInWithGoogle, user } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState({ type: '', content: '' });
const [serverInfo, setServerInfo] = useState<string | null>(null);
const router = useRouter();
const searchParams = useSearchParams();
const { signIn, signInWithGoogle, user, autoRegisterTestUser } = useAuth();
// 从URL参数中获取消息 // 如果用户已登录,重定向到仪表板
useEffect(() => { useEffect(() => {
const message = searchParams.get('message');
if (message) {
setMessage(message);
}
// 如果用户已登录跳转到dashboard
if (user) { if (user) {
router.push('/dashboard'); router.push('/dashboard');
} }
}, [user, router]);
// 检查服务器状态 const handleEmailSignIn = async (e: React.FormEvent) => {
const checkServerStatus = async () => {
try {
const { data, error } = await supabase.auth.getSession();
if (error) {
console.log('Session check error:', error);
setServerInfo(`服务器连接状态: 错误 (${error.message})`);
} else {
console.log('Session check success:', data);
setServerInfo('服务器连接状态: 正常');
}
} catch (error) {
console.error('Server check error:', error);
setServerInfo('服务器连接状态: 无法连接');
}
};
checkServerStatus();
}, [searchParams, user, router]);
// 处理登录表单提交
const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null);
setIsLoading(true); if (!email || !password) {
setMessage({
type: 'error',
content: 'Please enter both email and password'
});
return;
}
try { try {
console.log('Attempting to sign in with:', { email, password }); setIsLoading(true);
await signIn(email, password); setMessage({ type: '', content: '' });
// 登录成功后会自动跳转到dashboard
} catch (error: unknown) { const { error } = await signIn(email, password);
console.error('Login error:', error);
// 显示更详细的错误信息 if (error) {
if (error instanceof Error) { throw new Error(error.message);
setError(`登录失败: ${error.message}`);
} else {
setError('登录失败,请检查邮箱和密码是否正确');
} }
} finally {
// 登录成功后,会通过 useEffect 重定向
} catch (error) {
console.error('Login error:', error);
setMessage({
type: 'error',
content: error instanceof Error ? error.message : 'Failed to sign in'
});
setIsLoading(false); setIsLoading(false);
} }
}; };
// 处理Google登录 const handleGitHubSignIn = async () => {
const handleGoogleSignIn = async () => {
setError(null);
try { try {
await signInWithGoogle(); setIsLoading(true);
// 登录流程会重定向到Google然后回到应用 setMessage({ type: '', content: '' });
} catch (error: unknown) {
console.error('Google sign in error:', error); const { error } = await signInWithGitHub();
if (error instanceof Error) {
setError(`Google登录失败: ${error.message}`); if (error) {
} else { throw new Error(error.message);
setError('Google登录失败请稍后再试');
} }
// 登录成功后,会通过 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 () => {
const handleUseTestAccount = async () => {
setError(null);
setIsLoading(true);
try { try {
// 使用AuthContext中的自动注册测试账户功能 setIsLoading(true);
await autoRegisterTestUser(); setMessage({ type: '', content: '' });
const { error } = await signInWithGoogle();
if (error) {
throw new Error(error.message);
}
// 登录成功后,会通过 useEffect 重定向
} catch (error) { } catch (error) {
console.error('测试账户处理错误:', error); console.error('Google login error:', error);
setError(error instanceof Error ? error.message : '测试账户创建失败'); setMessage({
} finally { type: 'error',
content: error instanceof Error ? error.message : 'Failed to sign in with Google'
});
setIsLoading(false); setIsLoading(false);
} }
}; };
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900"> <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 dark:bg-gray-800 rounded-lg shadow-md"> <div className="w-full max-w-md p-8 space-y-8 bg-white rounded-lg shadow-md">
<div className="text-center"> <div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"></h1> <h1 className="text-2xl font-bold text-gray-900">Login</h1>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400"> <p className="mt-2 text-sm text-gray-600">
访 Sign in to your account to access analytics
</p> </p>
{serverInfo && ( <div className="mt-2 text-xs text-gray-500">
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500"> Welcome to ShortURL Analytics
{serverInfo}
</div> </div>
)}
</div> </div>
{/* 消息提示 */} {/* Message display */}
{message && ( {message.content && (
<div className="p-4 mb-4 text-sm text-blue-700 bg-blue-100 dark:bg-blue-900 dark:text-blue-200 rounded-lg"> <div className={`p-4 mb-4 text-sm ${
{message} message.type === 'error'
? 'text-red-700 bg-red-100 rounded-lg'
: 'text-blue-700 bg-blue-100 rounded-lg'
}`}>
{message.content}
</div> </div>
)} )}
{/* 错误提示 */} <form onSubmit={handleEmailSignIn} className="mt-8 space-y-6">
{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> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label> </label>
<input <input
id="email" id="email"
@@ -147,13 +134,15 @@ export default function LoginPage() {
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} 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" 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" placeholder="your@email.com"
disabled={isLoading}
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label> </label>
<input <input
id="password" id="password"
@@ -163,88 +152,70 @@ export default function LoginPage() {
required required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} 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" 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="********" placeholder="••••••••"
disabled={isLoading}
/> />
</div> </div>
</div>
<div> <div>
<button <button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
> >
{isLoading ? '登录中...' : '登录'} {isLoading ? 'Signing in...' : 'Sign in'}
</button>
</div>
<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 className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={handleGoogleSignIn}
className="flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<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>
<button
type="button"
onClick={handleUseTestAccount}
className="flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
使
</button> </button>
</div> </div>
</form> </form>
<div className="mt-6 text-center"> <div className="mt-6">
<p className="text-sm text-gray-600 dark:text-gray-400"> <div className="relative">
{' '} <div className="absolute inset-0 flex items-center">
<Link <div className="w-full border-t border-gray-300"></div>
href="/register" </div>
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300" <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&apos;t have an account?{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
Register
</Link> </Link>
</p> </p>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -13,8 +13,9 @@ export type AuthContextType = {
user: AuthUser; user: AuthUser;
session: Session | null; session: Session | null;
isLoading: boolean; isLoading: boolean;
signIn: (email: string, password: string) => Promise<void>; signIn: (email: string, password: string) => Promise<{ error?: any }>;
signInWithGoogle: () => Promise<void>; signInWithGoogle: () => Promise<{ error?: any }>;
signInWithGitHub: () => Promise<{ error?: any }>;
signUp: (email: string, password: string) => Promise<void>; signUp: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
autoRegisterTestUser: () => Promise<void>; // 添加自动注册测试用户函数 autoRegisterTestUser: () => Promise<void>; // 添加自动注册测试用户函数
@@ -84,15 +85,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (error) { if (error) {
console.error('登录出错:', error); console.error('登录出错:', error);
throw error; return { error };
} }
setSession(data.session); setSession(data.session);
setUser(data.user); setUser(data.user);
router.push('/dashboard'); router.push('/dashboard');
return {};
} catch (error) { } catch (error) {
console.error('登录过程出错:', error); console.error('登录过程出错:', error);
throw error; return { error };
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -112,11 +114,39 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (error) { if (error) {
console.error('Google登录出错:', error); console.error('Google登录出错:', error);
throw error; return { error };
} }
return {}; // Return empty object when successful
} catch (error) { } catch (error) {
console.error('Google登录过程出错:', error); console.error('Google登录过程出错:', error);
throw error; return { error };
} finally {
setIsLoading(false);
}
};
// GitHub登录函数
const signInWithGitHub = async () => {
setIsLoading(true);
try {
// 尝试通过Supabase登录GitHub
const { error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) {
console.error('GitHub login error:', error);
return { error };
}
return {}; // Return empty object when successful
} catch (error) {
console.error('GitHub login process error:', error);
return { error };
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -203,6 +233,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
isLoading, isLoading,
signIn, signIn,
signInWithGoogle, signInWithGoogle,
signInWithGitHub,
signUp, signUp,
signOut, signOut,
autoRegisterTestUser, autoRegisterTestUser,

View File

@@ -24,6 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@clickhouse/client": "^1.11.0", "@clickhouse/client": "^1.11.0",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/themes": "^3.2.1", "@radix-ui/themes": "^3.2.1",
"@supabase/auth-helpers-nextjs": "^0.10.0", "@supabase/auth-helpers-nextjs": "^0.10.0",

3
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@clickhouse/client': '@clickhouse/client':
specifier: ^1.11.0 specifier: ^1.11.0
version: 1.11.0 version: 1.11.0
'@radix-ui/react-popover':
specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-select': '@radix-ui/react-select':
specifier: ^2.1.6 specifier: ^2.1.6
version: 2.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 2.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)

View File

@@ -6,6 +6,7 @@ const config: Config = {
"./components/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}", "./app/**/*.{js,ts,jsx,tsx,mdx}",
], ],
darkMode: false,
theme: { theme: {
extend: { extend: {
colors: { colors: {