Compare commits
4 Commits
92d82b18a0
...
1755b44a39
| Author | SHA1 | Date | |
|---|---|---|---|
| 1755b44a39 | |||
| e0ac87fb25 | |||
| ecf21a812f | |||
| efdfe8bf8e |
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;
|
||||
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-white 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-white">{item.type}</span>
|
||||
<span className="text-sm text-white">{item.count} ({item.percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-background rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 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-white 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-white">{item.name}</span>
|
||||
<span className="text-sm text-white">{item.count} ({item.percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-background rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 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-white 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-white">{item.name}</span>
|
||||
<span className="text-sm text-white">{item.count} ({item.percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-background rounded-full h-2">
|
||||
<div
|
||||
className="bg-red-500 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-white">Geographic Analysis</h1>
|
||||
<p className="mt-2 text-white">Analyze visitor distribution by location</p>
|
||||
</div>
|
||||
|
||||
{/* 时间范围选择器 */}
|
||||
<div className="bg-card-bg rounded-xl p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm "
|
||||
value={dateRange.from.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, from: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="block w-full px-3 py-2 bg-background border border-card-border rounded-md text-sm "
|
||||
value={dateRange.to.toISOString().split('T')[0]}
|
||||
onChange={e => setDateRange(prev => ({ ...prev, to: new Date(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 地理数据表格 */}
|
||||
<div className="bg-card-bg rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-card-border">
|
||||
<thead>
|
||||
<tr className="bg-background/50">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Location</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Visits</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Unique Visitors</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-card-border">
|
||||
{geoData.map(item => (
|
||||
<tr key={item.location} className="hover:bg-background/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.location}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.visits}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
|
||||
{item.visitors}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="w-full bg-background rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-white">{item.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
<div className="w-8 h-8 border-t-2 border-b-2 border-accent-blue rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{error && (
|
||||
<div className="flex justify-center items-center p-8 text-accent-red">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无数据状态 */}
|
||||
{!isLoading && !error && geoData.length === 0 && (
|
||||
<div className="flex justify-center items-center p-8 text-white">
|
||||
<p>No geographic data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="mt-4 text-sm text-white">
|
||||
<p>Note: Geographic data is based on IP addresses and may not be 100% accurate.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
app/api/types.ts
Normal file
169
app/api/types.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// Event Types
|
||||
export interface Event {
|
||||
// 核心事件信息
|
||||
event_id: string;
|
||||
event_time: string;
|
||||
event_type: string;
|
||||
event_attributes: string;
|
||||
|
||||
// 链接信息
|
||||
link_id: string;
|
||||
link_slug: string;
|
||||
link_label: string;
|
||||
link_title: string;
|
||||
link_original_url: string;
|
||||
link_attributes: string;
|
||||
link_created_at: string;
|
||||
link_expires_at: string | null;
|
||||
link_tags: string;
|
||||
|
||||
// 用户信息
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
user_attributes: string;
|
||||
|
||||
// 团队信息
|
||||
team_id: string;
|
||||
team_name: string;
|
||||
team_attributes: string;
|
||||
|
||||
// 项目信息
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
project_attributes: string;
|
||||
|
||||
// 二维码信息
|
||||
qr_code_id: string;
|
||||
qr_code_name: string;
|
||||
qr_code_attributes: string;
|
||||
|
||||
// 访问者信息
|
||||
visitor_id: string;
|
||||
session_id: string;
|
||||
ip_address: string;
|
||||
country: string;
|
||||
city: string;
|
||||
device_type: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
user_agent: string;
|
||||
|
||||
// 来源信息
|
||||
referrer: string;
|
||||
utm_source: string;
|
||||
utm_medium: string;
|
||||
utm_campaign: string;
|
||||
|
||||
// 交互信息
|
||||
time_spent_sec: number;
|
||||
is_bounce: boolean;
|
||||
is_qr_scan: boolean;
|
||||
conversion_type: string;
|
||||
conversion_value: number;
|
||||
|
||||
// 旧接口兼容字段
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// Analytics Types
|
||||
export interface TimeSeriesData {
|
||||
timestamp: string;
|
||||
events: number;
|
||||
visitors: number;
|
||||
conversions: number;
|
||||
}
|
||||
|
||||
export interface GeoData {
|
||||
location?: string;
|
||||
country?: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
visits: number;
|
||||
uniqueVisitors?: number;
|
||||
visitors?: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export type DeviceType = 'mobile' | 'desktop' | 'tablet' | 'other';
|
||||
|
||||
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;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ConversionStats {
|
||||
totalConversions: number;
|
||||
conversionRate: number;
|
||||
averageValue: number;
|
||||
byType: {
|
||||
type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
value: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface EventFilters {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
eventType?: string;
|
||||
linkId?: string;
|
||||
linkSlug?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
66
app/components/analytics/DeviceAnalytics.tsx
Normal file
66
app/components/analytics/DeviceAnalytics.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"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 }[] }) {
|
||||
// 安全地格式化数字
|
||||
const formatNumber = (value: number | string | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||
};
|
||||
|
||||
// 安全地格式化百分比
|
||||
const formatPercent = (value: number | undefined | null): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return value.toFixed(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{title}</h3>
|
||||
<div className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span>{item.name || 'Unknown'}</span>
|
||||
<span>{formatNumber(item.count)} ({formatPercent(item.percentage)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{ width: `${item.percentage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</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 ? (item.type.charAt(0).toUpperCase() + item.type.slice(1)) : 'Unknown',
|
||||
count: item.count,
|
||||
percentage: item.percentage
|
||||
}))}
|
||||
/>
|
||||
<StatCard
|
||||
title="Browsers"
|
||||
items={data.browsers || []}
|
||||
/>
|
||||
<StatCard
|
||||
title="Operating Systems"
|
||||
items={data.operatingSystems || []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
app/components/analytics/GeoAnalytics.tsx
Normal file
70
app/components/analytics/GeoAnalytics.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { GeoData } from '../../api/types';
|
||||
|
||||
interface GeoAnalyticsProps {
|
||||
data: GeoData[];
|
||||
}
|
||||
|
||||
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
||||
// 安全地格式化数字
|
||||
const formatNumber = (value: any): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||
};
|
||||
|
||||
// 安全地格式化百分比
|
||||
const formatPercent = (value: any): string => {
|
||||
if (value === undefined || value === null) return '0';
|
||||
return typeof value === 'number' ? value.toFixed(2) : String(value);
|
||||
};
|
||||
|
||||
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 || item.location || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatNumber(item.visits)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatNumber(item.uniqueVisitors || item.visitors)}
|
||||
</td>
|
||||
<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">{formatPercent(item.percentage)}%</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 || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
app/components/charts/TimeSeriesChart.tsx
Normal file
184
app/components/charts/TimeSeriesChart.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
ChartData,
|
||||
ChartOptions,
|
||||
TooltipItem
|
||||
} from 'chart.js';
|
||||
import { TimeSeriesData } from '../../api/types';
|
||||
|
||||
// 注册 Chart.js 组件
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
LineController,
|
||||
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 => {
|
||||
if (!item || !item.timestamp) return '';
|
||||
const date = new Date(item.timestamp);
|
||||
return date.toLocaleDateString();
|
||||
});
|
||||
|
||||
const eventsData = data.map(item => {
|
||||
if (!item || item.events === undefined || item.events === null) return 0;
|
||||
return Number(item.events);
|
||||
});
|
||||
|
||||
const visitorsData = data.map(item => {
|
||||
if (!item || item.visitors === undefined || item.visitors === null) return 0;
|
||||
return Number(item.visitors);
|
||||
});
|
||||
|
||||
const conversionsData = data.map(item => {
|
||||
if (!item || item.conversions === undefined || item.conversions === null) return 0;
|
||||
return 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 && value !== 0) return '';
|
||||
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>
|
||||
);
|
||||
}
|
||||
257
app/events/page.tsx
Normal file
257
app/events/page.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { DateRangePicker } from '../components/ui/DateRangePicker';
|
||||
import { Event } from '../api/types';
|
||||
|
||||
export default function EventsPage() {
|
||||
const [dateRange, setDateRange] = useState({
|
||||
from: new Date('2024-02-01'),
|
||||
to: new Date('2025-03-05')
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [filter, setFilter] = useState({
|
||||
eventType: '',
|
||||
linkId: '',
|
||||
linkSlug: ''
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
startTime: format(new Date('2024-02-01'), "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||
endTime: format(new Date('2025-03-05'), "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
const [summary, setSummary] = useState<any>(null);
|
||||
|
||||
const fetchEvents = async (pageNum: number) => {
|
||||
try {
|
||||
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
const endTime = format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
startTime,
|
||||
endTime,
|
||||
page: pageNum.toString(),
|
||||
pageSize: '50'
|
||||
});
|
||||
|
||||
if (filter.eventType) params.append('eventType', filter.eventType);
|
||||
if (filter.linkId) params.append('linkId', filter.linkId);
|
||||
if (filter.linkSlug) params.append('linkSlug', filter.linkSlug);
|
||||
|
||||
const response = await fetch(`/api/events?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch events');
|
||||
}
|
||||
|
||||
const eventsData = data.data || data.events || [];
|
||||
|
||||
if (pageNum === 1) {
|
||||
setEvents(eventsData);
|
||||
} else {
|
||||
setEvents(prev => [...prev, ...eventsData]);
|
||||
}
|
||||
|
||||
setHasMore(Array.isArray(eventsData) && eventsData.length === 50);
|
||||
} catch (err) {
|
||||
console.error("Error fetching events:", err);
|
||||
setError(err instanceof Error ? err.message : 'An error occurred while fetching events');
|
||||
setEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setEvents([]);
|
||||
setLoading(true);
|
||||
fetchEvents(1);
|
||||
}, [dateRange, filter]);
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading && hasMore) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchEvents(nextPage);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-red-500">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Events</h1>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Event Type
|
||||
</label>
|
||||
<select
|
||||
value={filter.eventType}
|
||||
onChange={e => setFilter(prev => ({ ...prev, eventType: e.target.value }))}
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="click">Click</option>
|
||||
<option value="conversion">Conversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Link ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.linkId}
|
||||
onChange={e => setFilter(prev => ({ ...prev, linkId: e.target.value }))}
|
||||
placeholder="Enter Link ID"
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Link Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.linkSlug}
|
||||
onChange={e => setFilter(prev => ({ ...prev, linkSlug: e.target.value }))}
|
||||
placeholder="Enter Link Slug"
|
||||
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Time
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Link
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Visitor
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Referrer
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Conversion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{Array.isArray(events) && events.map((event, index) => (
|
||||
<tr key={event.event_id || index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-800' : 'bg-gray-50 dark:bg-gray-900'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{event.event_time && formatDate(event.event_time)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
event.event_type === 'conversion' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' : 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-100'
|
||||
}`}>
|
||||
{event.event_type || 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div className="font-medium">{event.link_slug || '-'}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.link_original_url || '-'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div>{event.browser || '-'}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.os || '-'} / {event.device_type || '-'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div>{event.city || '-'}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">{event.country || '-'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{event.referrer || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<div>
|
||||
<div>{event.conversion_type || '-'}</div>
|
||||
{event.conversion_value > 0 && (
|
||||
<div className="text-gray-500 dark:text-gray-400 text-xs">Value: {event.conversion_value}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center p-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div className="flex justify-center p-4">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && Array.isArray(events) && events.length === 0 && (
|
||||
<div className="flex justify-center p-8 text-gray-500 dark:text-gray-400">
|
||||
No events found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,67 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Navbar from "./components/layout/Navbar";
|
||||
import { Inter } from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ShortURL Analytics',
|
||||
description: 'Analytics dashboard for short URL management',
|
||||
description: 'Analytics dashboard for ShortURL service',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background`}
|
||||
>
|
||||
<Navbar />
|
||||
<main className="min-h-screen px-4 py-6">
|
||||
{children}
|
||||
</main>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
ShortURL Analytics
|
||||
</Link>
|
||||
<div className="hidden md:block ml-10">
|
||||
<div className="flex items-baseline space-x-4">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
href="/events"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/geo"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Geographic
|
||||
</Link>
|
||||
<Link
|
||||
href="/analytics/devices"
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="py-10">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
86
app/page.tsx
86
app/page.tsx
@@ -1,5 +1,85 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/links');
|
||||
export default function HomePage() {
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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
|
||||
`;
|
||||
|
||||
const [baseResult, browserResults, osResults] = await Promise.all([
|
||||
executeQuerySingle<{
|
||||
totalEvents: number;
|
||||
uniqueVisitors: number;
|
||||
totalConversions: number;
|
||||
averageTimeSpent: number;
|
||||
mobileCount: number;
|
||||
desktopCount: number;
|
||||
tabletCount: number;
|
||||
otherCount: number;
|
||||
}>(baseQuery),
|
||||
executeQuery<{ name: string; count: number }>(browserQuery),
|
||||
executeQuery<{ name: string; count: number }>(osQuery)
|
||||
]);
|
||||
|
||||
if (!baseResult) {
|
||||
throw new Error('Failed to get events summary');
|
||||
try {
|
||||
const [baseResult, browserResults, osResults] = await Promise.all([
|
||||
executeQuerySingle<{
|
||||
totalEvents: number;
|
||||
uniqueVisitors: number;
|
||||
totalConversions: number;
|
||||
averageTimeSpent: number;
|
||||
mobileCount: number;
|
||||
desktopCount: number;
|
||||
tabletCount: number;
|
||||
otherCount: number;
|
||||
}>(baseQuery),
|
||||
executeQuery<{ name: string; count: number }>(browserQuery),
|
||||
executeQuery<{ name: string; count: number }>(osQuery)
|
||||
]);
|
||||
|
||||
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) =>
|
||||
Number(((count / totalResult.total) * 100).toFixed(2));
|
||||
const calculatePercentage = (count: number) => {
|
||||
if (!totalResult || totalResult.total === 0) return 0;
|
||||
return Number(((count / totalResult.total) * 100).toFixed(2));
|
||||
};
|
||||
|
||||
return {
|
||||
deviceTypes: deviceTypes.map(item => ({
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { EventsQueryParams } from './types';
|
||||
|
||||
// ClickHouse 客户端配置
|
||||
const clickhouse = createClient({
|
||||
url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
|
||||
username: process.env.CLICKHOUSE_USER || 'admin',
|
||||
password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password',
|
||||
database: process.env.CLICKHOUSE_DB || 'shorturl_analytics'
|
||||
url: process.env.CLICKHOUSE_URL,
|
||||
username: process.env.CLICKHOUSE_USER ,
|
||||
password: process.env.CLICKHOUSE_PASSWORD ,
|
||||
database: process.env.CLICKHOUSE_DATABASE
|
||||
});
|
||||
|
||||
// 构建日期过滤条件
|
||||
|
||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -9,9 +9,14 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -654,6 +659,12 @@
|
||||
"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": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.7.tgz",
|
||||
@@ -1136,6 +1147,15 @@
|
||||
"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": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||
@@ -1187,6 +1207,12 @@
|
||||
"@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": {
|
||||
"version": "8.27.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -2144,6 +2182,16 @@
|
||||
"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": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
@@ -4228,6 +4276,15 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5584,6 +5641,19 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -42,4 +45,4 @@
|
||||
"typescript": "^5"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user