front
This commit is contained in:
152
api/events.ts
152
api/events.ts
@@ -1,152 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import type { ApiResponse, EventsQueryParams } from '../lib/types';
|
|
||||||
import {
|
|
||||||
getEvents,
|
|
||||||
getEventsSummary,
|
|
||||||
getTimeSeriesData,
|
|
||||||
getGeoAnalytics,
|
|
||||||
getDeviceAnalytics
|
|
||||||
} from '../lib/analytics';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
// 获取事件列表
|
|
||||||
router.get('/', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const params: EventsQueryParams = {
|
|
||||||
startTime: req.query.startTime as string,
|
|
||||||
endTime: req.query.endTime as string,
|
|
||||||
eventType: req.query.eventType as string,
|
|
||||||
linkId: req.query.linkId as string,
|
|
||||||
linkSlug: req.query.linkSlug as string,
|
|
||||||
userId: req.query.userId as string,
|
|
||||||
teamId: req.query.teamId as string,
|
|
||||||
projectId: req.query.projectId as string,
|
|
||||||
page: req.query.page ? parseInt(req.query.page as string, 10) : 1,
|
|
||||||
pageSize: req.query.pageSize ? parseInt(req.query.pageSize as string, 10) : 20,
|
|
||||||
sortBy: req.query.sortBy as string,
|
|
||||||
sortOrder: req.query.sortOrder as 'asc' | 'desc'
|
|
||||||
};
|
|
||||||
|
|
||||||
const { events, total } = await getEvents(params);
|
|
||||||
|
|
||||||
const response: ApiResponse<typeof events> = {
|
|
||||||
success: true,
|
|
||||||
data: events,
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
const response: ApiResponse<null> = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
|
||||||
};
|
|
||||||
res.status(500).json(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取事件概览
|
|
||||||
router.get('/summary', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const summary = await getEventsSummary({
|
|
||||||
startTime: req.query.startTime as string,
|
|
||||||
endTime: req.query.endTime as string,
|
|
||||||
linkId: req.query.linkId as string
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ApiResponse<typeof summary> = {
|
|
||||||
success: true,
|
|
||||||
data: summary
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
const response: ApiResponse<null> = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
|
||||||
};
|
|
||||||
res.status(500).json(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取时间序列数据
|
|
||||||
router.get('/time-series', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const data = await getTimeSeriesData({
|
|
||||||
startTime: req.query.startTime as string,
|
|
||||||
endTime: req.query.endTime as string,
|
|
||||||
linkId: req.query.linkId as string,
|
|
||||||
granularity: (req.query.granularity || 'day') as 'hour' | 'day' | 'week' | 'month'
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ApiResponse<typeof data> = {
|
|
||||||
success: true,
|
|
||||||
data
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
const response: ApiResponse<null> = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
|
||||||
};
|
|
||||||
res.status(500).json(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取地理位置分析
|
|
||||||
router.get('/geo', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const data = await getGeoAnalytics({
|
|
||||||
startTime: req.query.startTime as string,
|
|
||||||
endTime: req.query.endTime as string,
|
|
||||||
linkId: req.query.linkId as string,
|
|
||||||
groupBy: (req.query.groupBy || 'country') as 'country' | 'city'
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ApiResponse<typeof data> = {
|
|
||||||
success: true,
|
|
||||||
data
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
const response: ApiResponse<null> = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
|
||||||
};
|
|
||||||
res.status(500).json(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取设备分析
|
|
||||||
router.get('/devices', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const data = await getDeviceAnalytics({
|
|
||||||
startTime: req.query.startTime as string,
|
|
||||||
endTime: req.query.endTime as string,
|
|
||||||
linkId: req.query.linkId as string
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: ApiResponse<typeof data> = {
|
|
||||||
success: true,
|
|
||||||
data
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
const response: ApiResponse<null> = {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
|
||||||
};
|
|
||||||
res.status(500).json(response);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
151
app/analytics/conversions/page.tsx
Normal file
151
app/analytics/conversions/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { addDays, format } from 'date-fns';
|
||||||
|
import { DateRangePicker } from '../../components/ui/DateRangePicker';
|
||||||
|
import TimeSeriesChart from '../../components/charts/TimeSeriesChart';
|
||||||
|
import { TimeSeriesData } from '../../api/types';
|
||||||
|
|
||||||
|
interface ConversionStats {
|
||||||
|
totalConversions: number;
|
||||||
|
conversionRate: number;
|
||||||
|
averageValue: number;
|
||||||
|
conversionsByType: {
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
value: number;
|
||||||
|
percentage: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversionsPage() {
|
||||||
|
const [dateRange, setDateRange] = useState({
|
||||||
|
from: new Date('2024-02-01'),
|
||||||
|
to: new Date('2025-03-05')
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [stats, setStats] = useState<ConversionStats | null>(null);
|
||||||
|
const [timeSeriesData, setTimeSeriesData] = useState<TimeSeriesData[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||||
|
const endTime = format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||||
|
|
||||||
|
const [statsRes, timeSeriesRes] = await Promise.all([
|
||||||
|
fetch(`/api/events/conversions/stats?startTime=${startTime}&endTime=${endTime}`),
|
||||||
|
fetch(`/api/events/conversions/time-series?startTime=${startTime}&endTime=${endTime}`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [statsData, timeSeriesData] = await Promise.all([
|
||||||
|
statsRes.json(),
|
||||||
|
timeSeriesRes.json()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!statsRes.ok) throw new Error(statsData.error || 'Failed to fetch conversion stats');
|
||||||
|
if (!timeSeriesRes.ok) throw new Error(timeSeriesData.error || 'Failed to fetch time series data');
|
||||||
|
|
||||||
|
setStats(statsData);
|
||||||
|
setTimeSeriesData(timeSeriesData.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Conversion Analytics</h1>
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 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 Conversions</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">{stats.totalConversions.toLocaleString()}</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">Conversion Rate</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">{stats.conversionRate.toFixed(2)}%</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">Average Value</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">${stats.averageValue.toFixed(2)}</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">Conversion Trends</h2>
|
||||||
|
<div className="h-96">
|
||||||
|
<TimeSeriesChart data={timeSeriesData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<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">Conversion Types</h2>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{stats.conversionsByType.map((item, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-900 dark:text-gray-100 font-medium">{item.type}</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 text-sm ml-2">
|
||||||
|
{item.count.toLocaleString()} conversions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
||||||
|
${item.value.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 text-sm ml-2">
|
||||||
|
({item.percentage.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</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}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
app/analytics/devices/page.tsx
Normal file
150
app/analytics/devices/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { DeviceAnalytics } from '../../api/types';
|
||||||
|
|
||||||
|
export default function DeviceAnalyticsPage() {
|
||||||
|
const [deviceData, setDeviceData] = useState<DeviceAnalytics | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dateRange, setDateRange] = useState({
|
||||||
|
from: new Date('2024-02-01'),
|
||||||
|
to: new Date('2025-03-05')
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDeviceData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/events/devices?startTime=${dateRange.from.toISOString().split('T')[0]}T00:00:00Z&endTime=${dateRange.to.toISOString().split('T')[0]}T23:59:59Z`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch device data');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setDeviceData(data.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDeviceData();
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Device Analytics</h1>
|
||||||
|
<p className="mt-2 text-text-secondary">Analyze visitor distribution by devices, browsers, and operating systems</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 时间范围选择器 */}
|
||||||
|
<div className="bg-card-bg rounded-xl p-6 mb-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-1">Start Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
|
||||||
|
value={dateRange.from.toISOString().split('T')[0]}
|
||||||
|
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-1">End Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
|
||||||
|
value={dateRange.to.toISOString().split('T')[0]}
|
||||||
|
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设备类型分析 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||||||
|
{/* 设备类型 */}
|
||||||
|
<div className="bg-card-bg rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Device Types</h3>
|
||||||
|
{deviceData?.deviceTypes.map(item => (
|
||||||
|
<div key={item.type} className="mb-4 last:mb-0">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-sm text-foreground">{item.type}</span>
|
||||||
|
<span className="text-sm text-text-secondary">{item.count} ({item.percentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-background rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-accent-blue h-2 rounded-full"
|
||||||
|
style={{ width: `${item.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 浏览器 */}
|
||||||
|
<div className="bg-card-bg rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Browsers</h3>
|
||||||
|
{deviceData?.browsers.map(item => (
|
||||||
|
<div key={item.name} className="mb-4 last:mb-0">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-sm text-foreground">{item.name}</span>
|
||||||
|
<span className="text-sm text-text-secondary">{item.count} ({item.percentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-background rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-accent-green h-2 rounded-full"
|
||||||
|
style={{ width: `${item.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作系统 */}
|
||||||
|
<div className="bg-card-bg rounded-xl p-6">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Operating Systems</h3>
|
||||||
|
{deviceData?.operatingSystems.map(item => (
|
||||||
|
<div key={item.name} className="mb-4 last:mb-0">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-sm text-foreground">{item.name}</span>
|
||||||
|
<span className="text-sm text-text-secondary">{item.count} ({item.percentage.toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-background rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-accent-red h-2 rounded-full"
|
||||||
|
style={{ width: `${item.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center items-center p-8">
|
||||||
|
<div className="w-8 h-8 border-t-2 border-b-2 border-accent-blue rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误状态 */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex justify-center items-center p-8 text-accent-red">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 无数据状态 */}
|
||||||
|
{!isLoading && !error && !deviceData && (
|
||||||
|
<div className="flex justify-center items-center p-8 text-text-secondary">
|
||||||
|
<p>No device data available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
app/analytics/geo/page.tsx
Normal file
137
app/analytics/geo/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { GeoData } from '../../api/types';
|
||||||
|
|
||||||
|
export default function GeoAnalyticsPage() {
|
||||||
|
const [geoData, setGeoData] = useState<GeoData[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dateRange, setDateRange] = useState({
|
||||||
|
from: new Date('2024-02-01'),
|
||||||
|
to: new Date('2025-03-05')
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchGeoData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/events/geo?startTime=${dateRange.from.toISOString().split('T')[0]}T00:00:00Z&endTime=${dateRange.to.toISOString().split('T')[0]}T23:59:59Z`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch geographic data');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setGeoData(data.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchGeoData();
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Geographic Analysis</h1>
|
||||||
|
<p className="mt-2 text-text-secondary">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-text-secondary mb-1">Start Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
|
||||||
|
value={dateRange.from.toISOString().split('T')[0]}
|
||||||
|
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-1">End Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm"
|
||||||
|
value={dateRange.to.toISOString().split('T')[0]}
|
||||||
|
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 地理数据表格 */}
|
||||||
|
<div className="bg-card-bg rounded-xl shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-card-border">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-background/50">
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">Location</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">Visits</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">Unique Visitors</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase tracking-wider">Percentage</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-card-border">
|
||||||
|
{geoData.map(item => (
|
||||||
|
<tr key={item.location} className="hover:bg-background/50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
||||||
|
{item.location}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
||||||
|
{item.visits}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-foreground">
|
||||||
|
{item.visitors}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-full bg-background rounded-full h-2 mr-2">
|
||||||
|
<div
|
||||||
|
className="bg-accent-blue h-2 rounded-full"
|
||||||
|
style={{ width: `${item.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-text-secondary">{item.percentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center items-center p-8">
|
||||||
|
<div className="w-8 h-8 border-t-2 border-b-2 border-accent-blue rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误状态 */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex justify-center items-center p-8 text-accent-red">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 无数据状态 */}
|
||||||
|
{!isLoading && !error && geoData.length === 0 && (
|
||||||
|
<div className="flex justify-center items-center p-8 text-text-secondary">
|
||||||
|
<p>No geographic data available</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<div className="mt-4 text-sm text-text-secondary">
|
||||||
|
<p>Note: Geographic data is based on IP addresses and may not be 100% accurate.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/api/types.ts
Normal file
85
app/api/types.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Event Types
|
||||||
|
export interface Event {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
type: string;
|
||||||
|
linkInfo: {
|
||||||
|
id: string;
|
||||||
|
shortUrl: string;
|
||||||
|
originalUrl: string;
|
||||||
|
};
|
||||||
|
visitor: {
|
||||||
|
id: string;
|
||||||
|
browser: string;
|
||||||
|
os: string;
|
||||||
|
device: string;
|
||||||
|
};
|
||||||
|
location: {
|
||||||
|
country: string;
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
};
|
||||||
|
referrer: string;
|
||||||
|
conversion?: {
|
||||||
|
type: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics Types
|
||||||
|
export interface TimeSeriesData {
|
||||||
|
timestamp: string;
|
||||||
|
events: number;
|
||||||
|
visitors: number;
|
||||||
|
conversions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoData {
|
||||||
|
country: string;
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
visits: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceAnalytics {
|
||||||
|
deviceTypes: {
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}[];
|
||||||
|
browsers: {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}[];
|
||||||
|
operatingSystems: {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventsSummary {
|
||||||
|
totalEvents: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
totalConversions: number;
|
||||||
|
averageTimeSpent: number;
|
||||||
|
deviceTypes: {
|
||||||
|
mobile: number;
|
||||||
|
desktop: number;
|
||||||
|
tablet: number;
|
||||||
|
other: number;
|
||||||
|
};
|
||||||
|
browsers: {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}[];
|
||||||
|
operatingSystems: {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
54
app/components/analytics/DeviceAnalytics.tsx
Normal file
54
app/components/analytics/DeviceAnalytics.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DeviceAnalytics as DeviceAnalyticsType } from '../../api/types';
|
||||||
|
|
||||||
|
interface DeviceAnalyticsProps {
|
||||||
|
data: DeviceAnalyticsType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) {
|
||||||
|
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">
|
||||||
|
{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}</span>
|
||||||
|
<span>{item.count.toLocaleString()} ({item.percentage.toFixed(1)}%)</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}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
|
||||||
|
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.charAt(0).toUpperCase() + item.type.slice(1),
|
||||||
|
count: item.count,
|
||||||
|
percentage: item.percentage
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Browsers"
|
||||||
|
items={data.browsers}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Operating Systems"
|
||||||
|
items={data.operatingSystems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
app/components/analytics/GeoAnalytics.tsx
Normal file
58
app/components/analytics/GeoAnalytics.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GeoData } from '../../api/types';
|
||||||
|
|
||||||
|
interface GeoAnalyticsProps {
|
||||||
|
data: GeoData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Location
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Visits
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Unique Visitors
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Percentage
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{item.visits.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{item.uniqueVisitors.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2">{item.percentage.toFixed(2)}%</span>
|
||||||
|
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full"
|
||||||
|
style={{ width: `${item.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
app/components/charts/TimeSeriesChart.tsx
Normal file
169
app/components/charts/TimeSeriesChart.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
ChartData,
|
||||||
|
ChartOptions,
|
||||||
|
TooltipItem
|
||||||
|
} from 'chart.js';
|
||||||
|
import { TimeSeriesData } from '../../api/types';
|
||||||
|
|
||||||
|
// 注册 Chart.js 组件
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TimeSeriesChartProps {
|
||||||
|
data: TimeSeriesData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
||||||
|
const chartRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const chartInstance = useRef<ChartJS | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartRef.current) return;
|
||||||
|
|
||||||
|
// 销毁旧的图表实例
|
||||||
|
if (chartInstance.current) {
|
||||||
|
chartInstance.current.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = chartRef.current.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// 准备数据
|
||||||
|
const labels = data.map(item => {
|
||||||
|
const date = new Date(item.timestamp);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventsData = data.map(item => Number(item.events));
|
||||||
|
const visitorsData = data.map(item => Number(item.visitors));
|
||||||
|
const conversionsData = data.map(item => Number(item.conversions));
|
||||||
|
|
||||||
|
// 创建新的图表实例
|
||||||
|
chartInstance.current = new ChartJS(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Events',
|
||||||
|
data: eventsData,
|
||||||
|
borderColor: 'rgb(59, 130, 246)', // blue-500
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Visitors',
|
||||||
|
data: visitorsData,
|
||||||
|
borderColor: 'rgb(16, 185, 129)', // green-500
|
||||||
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Conversions',
|
||||||
|
data: conversionsData,
|
||||||
|
borderColor: 'rgb(239, 68, 68)', // red-500
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
fill: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} as ChartData<'line'>,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20,
|
||||||
|
color: 'rgb(156, 163, 175)' // gray-400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
backgroundColor: 'rgb(31, 41, 55)', // gray-800
|
||||||
|
titleColor: 'rgb(229, 231, 235)', // gray-200
|
||||||
|
bodyColor: 'rgb(229, 231, 235)', // gray-200
|
||||||
|
borderColor: 'rgb(75, 85, 99)', // gray-600
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 12,
|
||||||
|
displayColors: true,
|
||||||
|
callbacks: {
|
||||||
|
title: (items: TooltipItem<'line'>[]) => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const date = new Date(data[items[0].dataIndex].timestamp);
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(156, 163, 175)' // gray-400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgb(75, 85, 99, 0.1)' // gray-600 with opacity
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(156, 163, 175)', // gray-400
|
||||||
|
callback: (value: number) => {
|
||||||
|
if (value >= 1000) {
|
||||||
|
return `${(value / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as ChartOptions<'line'>
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
if (chartInstance.current) {
|
||||||
|
chartInstance.current.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas ref={chartRef} />
|
||||||
|
);
|
||||||
|
}
|
||||||
71
app/components/ui/DateRangePicker.tsx
Normal file
71
app/components/ui/DateRangePicker.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface DateRangePickerProps {
|
||||||
|
value: {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
};
|
||||||
|
onChange: (range: { from: Date; to: Date }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFrom(format(value.from, 'yyyy-MM-dd'));
|
||||||
|
setTo(format(value.to, 'yyyy-MM-dd'));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newFrom = e.target.value;
|
||||||
|
setFrom(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)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="from" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
From
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="from"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="to" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
To
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="to"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
app/dashboard/page.tsx
Normal file
140
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { addDays, format } from 'date-fns';
|
||||||
|
import { DateRangePicker } from '../components/ui/DateRangePicker';
|
||||||
|
import TimeSeriesChart from '../components/charts/TimeSeriesChart';
|
||||||
|
import GeoAnalytics from '../components/analytics/GeoAnalytics';
|
||||||
|
import DeviceAnalytics from '../components/analytics/DeviceAnalytics';
|
||||||
|
import { EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics as DeviceAnalyticsType } from '../api/types';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [dateRange, setDateRange] = useState({
|
||||||
|
from: new Date('2024-02-01'),
|
||||||
|
to: new Date('2025-03-05')
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [summary, setSummary] = useState<EventsSummary | null>(null);
|
||||||
|
const [timeSeriesData, setTimeSeriesData] = useState<TimeSeriesData[]>([]);
|
||||||
|
const [geoData, setGeoData] = useState<GeoData[]>([]);
|
||||||
|
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||||
|
const endTime = format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||||
|
|
||||||
|
// 并行获取所有数据
|
||||||
|
const [summaryRes, timeSeriesRes, geoRes, deviceRes] = await Promise.all([
|
||||||
|
fetch(`/api/events/summary?startTime=${startTime}&endTime=${endTime}`),
|
||||||
|
fetch(`/api/events/time-series?startTime=${startTime}&endTime=${endTime}`),
|
||||||
|
fetch(`/api/events/geo?startTime=${startTime}&endTime=${endTime}`),
|
||||||
|
fetch(`/api/events/devices?startTime=${startTime}&endTime=${endTime}`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [summaryData, timeSeriesData, geoData, deviceData] = await Promise.all([
|
||||||
|
summaryRes.json(),
|
||||||
|
timeSeriesRes.json(),
|
||||||
|
geoRes.json(),
|
||||||
|
deviceRes.json()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!summaryRes.ok) throw new Error(summaryData.error || 'Failed to fetch summary data');
|
||||||
|
if (!timeSeriesRes.ok) throw new Error(timeSeriesData.error || 'Failed to fetch time series data');
|
||||||
|
if (!geoRes.ok) throw new Error(geoData.error || 'Failed to fetch geo data');
|
||||||
|
if (!deviceRes.ok) throw new Error(deviceData.error || 'Failed to fetch device data');
|
||||||
|
|
||||||
|
setSummary(summaryData);
|
||||||
|
setTimeSeriesData(timeSeriesData.data);
|
||||||
|
setGeoData(geoData.data);
|
||||||
|
setDeviceData(deviceData.data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Analytics Dashboard</h1>
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Events</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Unique Visitors</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Total Conversions</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">Avg. Time Spent</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{summary.averageTimeSpent?.toFixed(1) || '0'}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Event Trends</h2>
|
||||||
|
<div className="h-96">
|
||||||
|
<TimeSeriesChart data={timeSeriesData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Device Analytics</h2>
|
||||||
|
{deviceData && <DeviceAnalytics data={deviceData} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Geographic Distribution</h2>
|
||||||
|
<GeoAnalytics data={geoData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
app/events/page.tsx
Normal file
240
app/events/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { addDays, format } from 'date-fns';
|
||||||
|
import { DateRangePicker } from '../components/ui/DateRangePicker';
|
||||||
|
import { Event } from '../api/types';
|
||||||
|
|
||||||
|
export default function EventsPage() {
|
||||||
|
const [dateRange, setDateRange] = useState({
|
||||||
|
from: addDays(new Date(), -7),
|
||||||
|
to: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
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<EventFilters>({
|
||||||
|
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 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageNum === 1) {
|
||||||
|
setEvents(data.events);
|
||||||
|
} else {
|
||||||
|
setEvents(prev => [...prev, ...data.events]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasMore(data.events.length === 50);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching events');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setEvents([]);
|
||||||
|
setLoading(true);
|
||||||
|
fetchEvents(1);
|
||||||
|
}, [dateRange, filter]);
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (!loading && hasMore) {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
setPage(nextPage);
|
||||||
|
fetchEvents(nextPage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-red-500">{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Events</h1>
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Event Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filter.eventType}
|
||||||
|
onChange={e => setFilter(prev => ({ ...prev, eventType: e.target.value }))}
|
||||||
|
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="click">Click</option>
|
||||||
|
<option value="conversion">Conversion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Link ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filter.linkId}
|
||||||
|
onChange={e => setFilter(prev => ({ ...prev, linkId: e.target.value }))}
|
||||||
|
placeholder="Enter Link ID"
|
||||||
|
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Link Slug
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filter.linkSlug}
|
||||||
|
onChange={e => setFilter(prev => ({ ...prev, linkSlug: e.target.value }))}
|
||||||
|
placeholder="Enter Link Slug"
|
||||||
|
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Time
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Link
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Visitor
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Location
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Referrer
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{events.map((event, index) => (
|
||||||
|
<tr key={event.id} 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">
|
||||||
|
{formatDate(event.time)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
event.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.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.linkInfo.shortUrl}</div>
|
||||||
|
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.linkInfo.originalUrl}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
<div>
|
||||||
|
<div>{event.visitor.browser}</div>
|
||||||
|
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.visitor.os} / {event.visitor.device}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
<div>
|
||||||
|
<div>{event.location.city}</div>
|
||||||
|
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.location.region}, {event.location.country}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{event.referrer || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && hasMore && (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && events.length === 0 && (
|
||||||
|
<div className="flex justify-center p-8 text-gray-500 dark:text-gray-400">
|
||||||
|
No events found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,73 @@
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter } from 'next/font/google';
|
||||||
import Navbar from "./components/layout/Navbar";
|
import Link from 'next/link';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'ShortURL Analytics',
|
title: 'ShortURL Analytics',
|
||||||
description: 'Analytics dashboard for short URL management',
|
description: 'Analytics dashboard for ShortURL service',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en">
|
||||||
<body
|
<body className={inter.className}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}
|
<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">
|
||||||
<Navbar />
|
<div className="container mx-auto px-4">
|
||||||
<main className="min-h-screen px-4 py-6">
|
<div className="flex items-center justify-between h-16">
|
||||||
{children}
|
<div className="flex items-center">
|
||||||
</main>
|
<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>
|
||||||
|
<Link
|
||||||
|
href="/analytics/conversions"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Conversions
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main className="py-10">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
96
app/page.tsx
96
app/page.tsx
@@ -1,5 +1,95 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import Link from 'next/link';
|
||||||
|
|
||||||
export default function Home() {
|
export default function HomePage() {
|
||||||
redirect('/links');
|
const sections = [
|
||||||
|
{
|
||||||
|
title: 'Dashboard',
|
||||||
|
description: 'Get an overview of your link performance with key metrics and trends.',
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Events',
|
||||||
|
description: 'Track and analyze all events including clicks, conversions, and more.',
|
||||||
|
href: '/events',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Geographic',
|
||||||
|
description: 'See where your visitors are coming from with detailed location data.',
|
||||||
|
href: '/analytics/geo',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Devices',
|
||||||
|
description: 'Understand what devices, browsers, and operating systems your visitors use.',
|
||||||
|
href: '/analytics/devices',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Conversions',
|
||||||
|
description: 'Track conversion rates and analyze the performance of your links.',
|
||||||
|
href: '/analytics/conversions',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
Welcome to ShortURL Analytics
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||||
|
Get detailed insights into your link performance and visitor behavior
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<Link
|
||||||
|
key={section.title}
|
||||||
|
href={section.href}
|
||||||
|
className="group block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg mr-4">
|
||||||
|
<div className="text-blue-600 dark:text-blue-300">
|
||||||
|
{section.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200">
|
||||||
|
{section.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
120
lib/analytics.ts
120
lib/analytics.ts
@@ -91,57 +91,71 @@ export async function getEventsSummary(params: {
|
|||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [baseResult, browserResults, osResults] = await Promise.all([
|
try {
|
||||||
executeQuerySingle<{
|
const [baseResult, browserResults, osResults] = await Promise.all([
|
||||||
totalEvents: number;
|
executeQuerySingle<{
|
||||||
uniqueVisitors: number;
|
totalEvents: number;
|
||||||
totalConversions: number;
|
uniqueVisitors: number;
|
||||||
averageTimeSpent: number;
|
totalConversions: number;
|
||||||
mobileCount: number;
|
averageTimeSpent: number;
|
||||||
desktopCount: number;
|
mobileCount: number;
|
||||||
tabletCount: number;
|
desktopCount: number;
|
||||||
otherCount: number;
|
tabletCount: number;
|
||||||
}>(baseQuery),
|
otherCount: number;
|
||||||
executeQuery<{ name: string; count: number }>(browserQuery),
|
}>(baseQuery),
|
||||||
executeQuery<{ name: string; count: number }>(osQuery)
|
executeQuery<{ name: string; count: number }>(browserQuery),
|
||||||
]);
|
executeQuery<{ name: string; count: number }>(osQuery)
|
||||||
|
]);
|
||||||
if (!baseResult) {
|
|
||||||
throw new Error('Failed to get events summary');
|
if (!baseResult) {
|
||||||
|
throw new Error('Failed to get events summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全转换数字类型
|
||||||
|
const safeNumber = (value: any): number => {
|
||||||
|
if (value === null || value === undefined) return 0;
|
||||||
|
const num = Number(value);
|
||||||
|
return isNaN(num) ? 0 : num;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算百分比
|
||||||
|
const calculatePercentage = (count: number, total: number) => {
|
||||||
|
if (!total) return 0; // 防止除以零
|
||||||
|
return Number(((count / total) * 100).toFixed(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理浏览器数据
|
||||||
|
const browsers = browserResults.map(item => ({
|
||||||
|
name: item.name || 'Unknown',
|
||||||
|
count: safeNumber(item.count),
|
||||||
|
percentage: calculatePercentage(safeNumber(item.count), safeNumber(baseResult.totalEvents))
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 处理操作系统数据
|
||||||
|
const operatingSystems = osResults.map(item => ({
|
||||||
|
name: item.name || 'Unknown',
|
||||||
|
count: safeNumber(item.count),
|
||||||
|
percentage: calculatePercentage(safeNumber(item.count), safeNumber(baseResult.totalEvents))
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalEvents: safeNumber(baseResult.totalEvents),
|
||||||
|
uniqueVisitors: safeNumber(baseResult.uniqueVisitors),
|
||||||
|
totalConversions: safeNumber(baseResult.totalConversions),
|
||||||
|
averageTimeSpent: baseResult.averageTimeSpent ? Number(baseResult.averageTimeSpent.toFixed(2)) : 0,
|
||||||
|
deviceTypes: {
|
||||||
|
mobile: safeNumber(baseResult.mobileCount),
|
||||||
|
desktop: safeNumber(baseResult.desktopCount),
|
||||||
|
tablet: safeNumber(baseResult.tabletCount),
|
||||||
|
other: safeNumber(baseResult.otherCount)
|
||||||
|
},
|
||||||
|
browsers,
|
||||||
|
operatingSystems
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in getEventsSummary:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算百分比
|
|
||||||
const calculatePercentage = (count: number, total: number) =>
|
|
||||||
Number(((count / total) * 100).toFixed(2));
|
|
||||||
|
|
||||||
// 处理浏览器数据
|
|
||||||
const browsers = browserResults.map(item => ({
|
|
||||||
name: item.name,
|
|
||||||
count: item.count,
|
|
||||||
percentage: calculatePercentage(item.count, baseResult.totalEvents)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 处理操作系统数据
|
|
||||||
const operatingSystems = osResults.map(item => ({
|
|
||||||
name: item.name,
|
|
||||||
count: item.count,
|
|
||||||
percentage: calculatePercentage(item.count, baseResult.totalEvents)
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalEvents: baseResult.totalEvents,
|
|
||||||
uniqueVisitors: baseResult.uniqueVisitors,
|
|
||||||
totalConversions: baseResult.totalConversions,
|
|
||||||
averageTimeSpent: Number(baseResult.averageTimeSpent.toFixed(2)),
|
|
||||||
deviceTypes: {
|
|
||||||
mobile: baseResult.mobileCount,
|
|
||||||
desktop: baseResult.desktopCount,
|
|
||||||
tablet: baseResult.tabletCount,
|
|
||||||
other: baseResult.otherCount
|
|
||||||
},
|
|
||||||
browsers,
|
|
||||||
operatingSystems
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取时间序列数据
|
// 获取时间序列数据
|
||||||
@@ -263,8 +277,10 @@ export async function getDeviceAnalytics(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 计算百分比
|
// 计算百分比
|
||||||
const calculatePercentage = (count: number) =>
|
const calculatePercentage = (count: number) => {
|
||||||
Number(((count / totalResult.total) * 100).toFixed(2));
|
if (!totalResult || totalResult.total === 0) return 0;
|
||||||
|
return Number(((count / totalResult.total) * 100).toFixed(2));
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceTypes: deviceTypes.map(item => ({
|
deviceTypes: deviceTypes.map(item => ({
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { EventsQueryParams } from './types';
|
|||||||
|
|
||||||
// ClickHouse 客户端配置
|
// ClickHouse 客户端配置
|
||||||
const clickhouse = createClient({
|
const clickhouse = createClient({
|
||||||
url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
|
url: process.env.CLICKHOUSE_URL,
|
||||||
username: process.env.CLICKHOUSE_USER || 'admin',
|
username: process.env.CLICKHOUSE_USER ,
|
||||||
password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password',
|
password: process.env.CLICKHOUSE_PASSWORD ,
|
||||||
database: process.env.CLICKHOUSE_DB || 'shorturl_analytics'
|
database: process.env.CLICKHOUSE_DATABASE
|
||||||
});
|
});
|
||||||
|
|
||||||
// 构建日期过滤条件
|
// 构建日期过滤条件
|
||||||
|
|||||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -9,9 +9,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clickhouse/client": "^1.11.0",
|
"@clickhouse/client": "^1.11.0",
|
||||||
|
"@types/chart.js": "^2.9.41",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"chart.js": "^4.4.8",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"next": "15.2.3",
|
"next": "15.2.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -654,6 +659,12 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.7.tgz",
|
||||||
@@ -1136,6 +1147,15 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chart.js": {
|
||||||
|
"version": "2.9.41",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.41.tgz",
|
||||||
|
"integrity": "sha512-3dvkDvueckY83UyUXtJMalYoH6faOLkWQoaTlJgB4Djde3oORmNP0Jw85HtzTuXyliUHcdp704s0mZFQKio/KQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"moment": "^2.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
@@ -1187,6 +1207,12 @@
|
|||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.27.0",
|
"version": "8.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.27.0.tgz",
|
||||||
@@ -2003,6 +2029,18 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.8.tgz",
|
||||||
|
"integrity": "sha512-IkGZlVpXP+83QpMm4uxEiGqSI7jFizwVtF3+n5Pc3k7sMO+tkd0qxh2OzLhenM0K80xtmAONWGBn082EiBQSDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/client-only": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -2144,6 +2182,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||||
@@ -4228,6 +4276,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/moment": {
|
||||||
|
"version": "2.30.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
|
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -5584,6 +5641,19 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -24,7 +24,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clickhouse/client": "^1.11.0",
|
"@clickhouse/client": "^1.11.0",
|
||||||
|
"@types/chart.js": "^2.9.41",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"chart.js": "^4.4.8",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"next": "15.2.3",
|
"next": "15.2.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -42,4 +45,4 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
|
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user