Compare commits
4 Commits
d75110d6b8
...
8eb859ddde
| Author | SHA1 | Date | |
|---|---|---|---|
| 8eb859ddde | |||
| b425f5b987 | |||
| a2c6de8474 | |||
| e7b3b735e0 |
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { addDays, format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
||||||
import TimeSeriesChart from '@/app/components/charts/TimeSeriesChart';
|
import TimeSeriesChart from '@/app/components/charts/TimeSeriesChart';
|
||||||
import GeoAnalytics from '@/app/components/analytics/GeoAnalytics';
|
import GeoAnalytics from '@/app/components/analytics/GeoAnalytics';
|
||||||
@@ -21,57 +21,117 @@ export default function DashboardPage() {
|
|||||||
const [geoData, setGeoData] = useState<GeoData[]>([]);
|
const [geoData, setGeoData] = useState<GeoData[]>([]);
|
||||||
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
|
const [deviceData, setDeviceData] = useState<DeviceAnalyticsType | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// 获取统计数据
|
||||||
const fetchData = async () => {
|
const fetchSummary = async () => {
|
||||||
setLoading(true);
|
try {
|
||||||
setError(null);
|
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'");
|
||||||
try {
|
|
||||||
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
const response = await fetch(
|
||||||
const endTime = format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
`/api/events/summary?startTime=${startTime}&endTime=${endTime}`
|
||||||
|
);
|
||||||
// 并行获取所有数据
|
const data = await response.json();
|
||||||
const [summaryRes, timeSeriesRes, geoRes, deviceRes] = await Promise.all([
|
|
||||||
fetch(`/api/events/summary?startTime=${startTime}&endTime=${endTime}`),
|
if (!response.ok) {
|
||||||
fetch(`/api/events/time-series?startTime=${startTime}&endTime=${endTime}`),
|
throw new Error(data.error || 'Failed to fetch summary');
|
||||||
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();
|
if (data.success && data.data) {
|
||||||
|
console.log('Summary data:', data.data); // 添加日志
|
||||||
|
setSummary(data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching summary:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load summary');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取时间序列数据
|
||||||
|
const fetchTimeSeriesData = async () => {
|
||||||
|
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 response = await fetch(
|
||||||
|
`/api/events/time-series?startTime=${startTime}&endTime=${endTime}&granularity=day`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch time series data');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
setTimeSeriesData(data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching time series:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取地理位置数据
|
||||||
|
const fetchGeoData = async () => {
|
||||||
|
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 response = await fetch(
|
||||||
|
`/api/events/geo?startTime=${startTime}&endTime=${endTime}`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch geo data');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
setGeoData(data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching geo data:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取设备数据
|
||||||
|
const fetchDeviceData = async () => {
|
||||||
|
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 response = await fetch(
|
||||||
|
`/api/events/devices?startTime=${startTime}&endTime=${endTime}`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch device data');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success && data.data) {
|
||||||
|
setDeviceData(data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching device data:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载所有数据
|
||||||
|
const loadAllData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await Promise.all([
|
||||||
|
fetchSummary(),
|
||||||
|
fetchTimeSeriesData(),
|
||||||
|
fetchGeoData(),
|
||||||
|
fetchDeviceData()
|
||||||
|
]);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当日期范围改变时重新加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
loadAllData();
|
||||||
}, [dateRange]);
|
}, [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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
@@ -83,57 +143,89 @@ export default function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Analytics Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Analytics Dashboard</h1>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={setDateRange}
|
onChange={setDateRange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary && (
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<h3 className="text-sm font-medium text-gray-500">Total Events</h3>
|
||||||
<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">
|
||||||
<p className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
{loading ? (
|
||||||
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
|
<span className="text-gray-400">Loading...</span>
|
||||||
</p>
|
) : (
|
||||||
</div>
|
summary?.totalEvents?.toLocaleString() || '0'
|
||||||
<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>
|
||||||
<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>
|
||||||
)}
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">Unique Visitors</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{loading ? (
|
||||||
|
<span className="text-gray-400">Loading...</span>
|
||||||
|
) : (
|
||||||
|
summary?.uniqueVisitors?.toLocaleString() || '0'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">Total Conversions</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{loading ? (
|
||||||
|
<span className="text-gray-400">Loading...</span>
|
||||||
|
) : (
|
||||||
|
summary?.totalConversions?.toLocaleString() || '0'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">Avg. Time Spent</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{loading ? (
|
||||||
|
<span className="text-gray-400">Loading...</span>
|
||||||
|
) : (
|
||||||
|
`${summary?.averageTimeSpent?.toFixed(1) || '0'}s`
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Event Trends</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Event Trends</h2>
|
||||||
<div className="h-96">
|
<div className="h-96">
|
||||||
<TimeSeriesChart data={timeSeriesData} />
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-gray-400">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TimeSeriesChart data={timeSeriesData} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Device Analytics</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Device Analytics</h2>
|
||||||
{deviceData && <DeviceAnalytics data={deviceData} />}
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<span className="text-gray-400">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
deviceData && <DeviceAnalytics data={deviceData} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Geographic Distribution</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2>
|
||||||
<GeoAnalytics data={geoData} />
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<span className="text-gray-400">Loading...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<GeoAnalytics data={geoData} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { addDays, format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
||||||
import { Event } from '@/app/api/types';
|
import { Event, EventType } from '@/lib/types';
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
const [dateRange, setDateRange] = useState({
|
const [dateRange, setDateRange] = useState({
|
||||||
@@ -16,36 +16,62 @@ export default function EventsPage() {
|
|||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [filter, setFilter] = useState({
|
const [totalEvents, setTotalEvents] = useState(0);
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 过滤条件状态
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
eventType: '',
|
eventType: '',
|
||||||
linkId: '',
|
linkId: '',
|
||||||
linkSlug: ''
|
linkSlug: '',
|
||||||
|
userId: '',
|
||||||
|
teamId: '',
|
||||||
|
projectId: '',
|
||||||
|
tags: [] as string[],
|
||||||
|
searchSlug: '',
|
||||||
|
sortBy: 'event_time',
|
||||||
|
sortOrder: 'desc' as 'asc' | 'desc'
|
||||||
});
|
});
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
// 加载标签列表
|
||||||
startTime: format(new Date('2024-02-01'), "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
const fetchTags = async () => {
|
||||||
endTime: format(new Date('2025-03-05'), "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
try {
|
||||||
page: 1,
|
const response = await fetch('/api/events/tags');
|
||||||
pageSize: 20
|
const data = await response.json();
|
||||||
});
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
setTags(data.data.map((tag: { tag_name: string }) => tag.tag_name));
|
||||||
const [summary, setSummary] = useState<any>(null);
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching tags:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取事件列表
|
||||||
const fetchEvents = async (pageNum: number) => {
|
const fetchEvents = async (pageNum: number) => {
|
||||||
try {
|
try {
|
||||||
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
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 endTime = format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
page: pageNum.toString(),
|
page: pageNum.toString(),
|
||||||
pageSize: '50'
|
pageSize: '20'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filter.eventType) params.append('eventType', filter.eventType);
|
// 添加时间范围参数(如果有)
|
||||||
if (filter.linkId) params.append('linkId', filter.linkId);
|
if (startTime) params.append('startTime', startTime);
|
||||||
if (filter.linkSlug) params.append('linkSlug', filter.linkSlug);
|
if (endTime) params.append('endTime', endTime);
|
||||||
|
|
||||||
|
// 添加其他过滤参数
|
||||||
|
if (filters.eventType) params.append('eventType', filters.eventType);
|
||||||
|
if (filters.linkId) params.append('linkId', filters.linkId);
|
||||||
|
if (filters.linkSlug) params.append('linkSlug', filters.linkSlug);
|
||||||
|
if (filters.userId) params.append('userId', filters.userId);
|
||||||
|
if (filters.teamId) params.append('teamId', filters.teamId);
|
||||||
|
if (filters.projectId) params.append('projectId', filters.projectId);
|
||||||
|
if (filters.tags.length > 0) params.append('tags', filters.tags.join(','));
|
||||||
|
if (filters.searchSlug) params.append('searchSlug', filters.searchSlug);
|
||||||
|
if (filters.sortBy) params.append('sortBy', filters.sortBy);
|
||||||
|
if (filters.sortOrder) params.append('sortOrder', filters.sortOrder);
|
||||||
|
|
||||||
const response = await fetch(`/api/events?${params.toString()}`);
|
const response = await fetch(`/api/events?${params.toString()}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -54,15 +80,14 @@ export default function EventsPage() {
|
|||||||
throw new Error(data.error || 'Failed to fetch events');
|
throw new Error(data.error || 'Failed to fetch events');
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsData = data.data || data.events || [];
|
|
||||||
|
|
||||||
if (pageNum === 1) {
|
if (pageNum === 1) {
|
||||||
setEvents(eventsData);
|
setEvents(data.data || []);
|
||||||
} else {
|
} else {
|
||||||
setEvents(prev => [...prev, ...eventsData]);
|
setEvents(prev => [...prev, ...(data.data || [])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasMore(Array.isArray(eventsData) && eventsData.length === 50);
|
setTotalEvents(data.meta?.total || 0);
|
||||||
|
setHasMore(data.data && data.data.length === 20);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching events:", err);
|
console.error("Error fetching events:", err);
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred while fetching events');
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching events');
|
||||||
@@ -72,13 +97,20 @@ export default function EventsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 初始化加载
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 当过滤条件改变时重新加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchEvents(1);
|
fetchEvents(1);
|
||||||
}, [dateRange, filter]);
|
}, [dateRange, filters]);
|
||||||
|
|
||||||
|
// 加载更多数据
|
||||||
const loadMore = () => {
|
const loadMore = () => {
|
||||||
if (!loading && hasMore) {
|
if (!loading && hasMore) {
|
||||||
const nextPage = page + 1;
|
const nextPage = page + 1;
|
||||||
@@ -87,6 +119,23 @@ export default function EventsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 重置过滤条件
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters({
|
||||||
|
eventType: '',
|
||||||
|
linkId: '',
|
||||||
|
linkSlug: '',
|
||||||
|
userId: '',
|
||||||
|
teamId: '',
|
||||||
|
projectId: '',
|
||||||
|
tags: [],
|
||||||
|
searchSlug: '',
|
||||||
|
sortBy: 'event_time',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
@@ -102,153 +151,213 @@ export default function EventsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-8">
|
{/* 过滤器部分 */}
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Events</h1>
|
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
<DateRangePicker
|
<h2 className="text-xl font-semibold mb-4">过滤器</h2>
|
||||||
value={dateRange}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
onChange={setDateRange}
|
<div>
|
||||||
/>
|
<DateRangePicker
|
||||||
</div>
|
value={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-8">
|
/>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
|
||||||
Event Type
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={filter.eventType}
|
value={filters.eventType}
|
||||||
onChange={e => setFilter(prev => ({ ...prev, eventType: e.target.value }))}
|
onChange={(e) => setFilters(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"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">All Types</option>
|
<option value="">所有事件类型</option>
|
||||||
<option value="click">Click</option>
|
{Object.values(EventType).map(type => (
|
||||||
<option value="conversion">Conversion</option>
|
<option key={type} value={type}>{type}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
|
||||||
Link ID
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filter.linkId}
|
placeholder="链接 ID"
|
||||||
onChange={e => setFilter(prev => ({ ...prev, linkId: e.target.value }))}
|
value={filters.linkId}
|
||||||
placeholder="Enter Link ID"
|
onChange={(e) => setFilters(prev => ({ ...prev, linkId: 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"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
|
||||||
Link Slug
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filter.linkSlug}
|
placeholder="链接短码"
|
||||||
onChange={e => setFilter(prev => ({ ...prev, linkSlug: e.target.value }))}
|
value={filters.linkSlug}
|
||||||
placeholder="Enter Link Slug"
|
onChange={(e) => setFilters(prev => ({ ...prev, linkSlug: 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"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="用户 ID"
|
||||||
|
value={filters.userId}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, userId: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="团队 ID"
|
||||||
|
value={filters.teamId}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, teamId: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="项目 ID"
|
||||||
|
value={filters.projectId}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, projectId: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索短码"
|
||||||
|
value={filters.searchSlug}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, searchSlug: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={filters.sortBy}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, sortBy: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="event_time">事件时间</option>
|
||||||
|
<option value="event_type">事件类型</option>
|
||||||
|
<option value="link_slug">链接短码</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={filters.sortOrder}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, sortOrder: e.target.value as 'asc' | 'desc' }))}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="desc">降序</option>
|
||||||
|
<option value="asc">升序</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签选择 */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-sm font-medium mb-2">标签</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => {
|
||||||
|
setFilters(prev => ({
|
||||||
|
...prev,
|
||||||
|
tags: prev.tags.includes(tag)
|
||||||
|
? prev.tags.filter(t => t !== tag)
|
||||||
|
: [...prev.tags, tag]
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 rounded-full text-sm font-medium ${
|
||||||
|
filters.tags.includes(tag)
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={resetFilters}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
重置过滤器
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
{/* 事件列表 */}
|
||||||
<div className="overflow-x-auto">
|
<div className="bg-white rounded-lg shadow">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<div className="p-4 border-b">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
<h2 className="text-xl font-semibold">事件列表 ({totalEvents})</h2>
|
||||||
<tr>
|
</div>
|
||||||
<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
|
<div className="divide-y">
|
||||||
</th>
|
{events.map((event) => (
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<div key={event.event_id} className="p-4">
|
||||||
Type
|
<div className="flex justify-between items-start">
|
||||||
</th>
|
<div>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<span className="font-medium">{event.event_type}</span>
|
||||||
Link
|
<span className="text-gray-500 ml-2">{formatDate(event.event_time)}</span>
|
||||||
</th>
|
</div>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
Visitor
|
{event.device_type}
|
||||||
</th>
|
</span>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
</div>
|
||||||
Location
|
|
||||||
</th>
|
<div className="mt-2 grid grid-cols-2 gap-4">
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
<div>
|
||||||
Referrer
|
<p className="text-sm text-gray-600">链接: {event.link_slug}</p>
|
||||||
</th>
|
<p className="text-sm text-gray-600">用户: {event.user_name || event.user_id}</p>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
</div>
|
||||||
Conversion
|
<div>
|
||||||
</th>
|
<p className="text-sm text-gray-600">IP: {event.ip_address}</p>
|
||||||
</tr>
|
<p className="text-sm text-gray-600">位置: {event.country} {event.city}</p>
|
||||||
</thead>
|
</div>
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
</div>
|
||||||
{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'}>
|
{event.link_tags && (
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
<div className="mt-2 flex gap-2">
|
||||||
{event.event_time && formatDate(event.event_time)}
|
{(typeof event.link_tags === 'string' ?
|
||||||
</td>
|
(() => {
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
try {
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
return JSON.parse(event.link_tags);
|
||||||
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'
|
} catch (e) {
|
||||||
}`}>
|
console.error('Error parsing link_tags:', e);
|
||||||
{event.event_type || 'unknown'}
|
return [];
|
||||||
|
}
|
||||||
|
})() :
|
||||||
|
event.link_tags
|
||||||
|
).map((tag: string) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
))}
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
</div>
|
||||||
<div>
|
)}
|
||||||
<div className="font-medium">{event.link_slug || '-'}</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{hasMore && (
|
||||||
<div className="flex justify-center p-4">
|
<div className="p-4 text-center">
|
||||||
<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
|
<button
|
||||||
onClick={loadMore}
|
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"
|
disabled={loading}
|
||||||
|
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Load More
|
{loading ? '加载中...' : '加载更多'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && Array.isArray(events) && events.length === 0 && (
|
{!loading && events.length === 0 && (
|
||||||
<div className="flex justify-center p-8 text-gray-500 dark:text-gray-400">
|
<div className="p-8 text-center text-gray-500">
|
||||||
No events found
|
没有找到符合条件的事件
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,37 +17,37 @@ export default function AppLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={inter.className}>
|
<div className={inter.className}>
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
<nav className="bg-white border-b border-gray-200">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link href="/" className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
<Link href="/" className="text-xl font-bold text-gray-900">
|
||||||
ShortURL Analytics
|
ShortURL Analytics
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden md:block ml-10">
|
<div className="hidden md:block ml-10">
|
||||||
<div className="flex items-baseline space-x-4">
|
<div className="flex items-baseline space-x-4">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||||
>
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/events"
|
href="/events"
|
||||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||||
>
|
>
|
||||||
Events
|
Events
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/analytics/geo"
|
href="/analytics/geo"
|
||||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||||
>
|
>
|
||||||
Geographic
|
Geographic
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/analytics/devices"
|
href="/analytics/devices"
|
||||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-3 py-2 rounded-md text-sm font-medium"
|
className="text-gray-500 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium"
|
||||||
>
|
>
|
||||||
Devices
|
Devices
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,23 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import SwaggerUI from 'swagger-ui-react';
|
import SwaggerUI from 'swagger-ui-react';
|
||||||
|
import 'swagger-ui-react/swagger-ui.css';
|
||||||
|
|
||||||
export default function SwaggerPage() {
|
export default function SwaggerPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 设置页面标题
|
// 设置页面标题
|
||||||
document.title = 'API Documentation - ShortURL Analytics';
|
document.title = 'API Documentation - ShortURL Analytics';
|
||||||
|
|
||||||
// 动态添加Swagger UI CSS
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.type = 'text/css';
|
|
||||||
link.href = 'https://unpkg.com/swagger-ui-dist@5.20.1/swagger-ui.css';
|
|
||||||
document.head.appendChild(link);
|
|
||||||
|
|
||||||
// 清理函数
|
|
||||||
return () => {
|
|
||||||
document.head.removeChild(link);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Swagger配置
|
// Swagger配置
|
||||||
@@ -222,27 +211,93 @@ export default function SwaggerPage() {
|
|||||||
get: {
|
get: {
|
||||||
tags: ['events'],
|
tags: ['events'],
|
||||||
summary: 'Get events',
|
summary: 'Get events',
|
||||||
description: 'Retrieve events within a specified time range with pagination support',
|
description: 'Retrieve events with pagination and filtering support. If startTime and endTime are not provided, will return the most recent events.',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'startTime',
|
name: 'startTime',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
required: true,
|
required: false,
|
||||||
schema: {
|
schema: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
},
|
},
|
||||||
description: 'Start time for events query (ISO 8601 format)',
|
description: 'Start time for events query (ISO 8601 format). If not provided, no lower time bound will be applied.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'endTime',
|
name: 'endTime',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
required: true,
|
required: false,
|
||||||
schema: {
|
schema: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
},
|
},
|
||||||
description: 'End time for events query (ISO 8601 format)',
|
description: 'End time for events query (ISO 8601 format). If not provided, no upper time bound will be applied.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'eventType',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['click', 'redirect', 'conversion', 'error']
|
||||||
|
},
|
||||||
|
description: 'Filter events by type',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'linkId',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: 'Filter events by link ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'linkSlug',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: 'Filter events by link slug',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'userId',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: 'Filter events by user ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'teamId',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: 'Filter events by team ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'projectId',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: 'Filter events by project ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tags',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: 'Filter events by tags (comma-separated list)',
|
||||||
|
example: 'promo,vip,summer'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'searchSlug',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: 'Search events by partial link slug match',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'page',
|
name: 'page',
|
||||||
@@ -259,12 +314,31 @@ export default function SwaggerPage() {
|
|||||||
in: 'query',
|
in: 'query',
|
||||||
schema: {
|
schema: {
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
default: 50,
|
default: 20,
|
||||||
minimum: 1,
|
minimum: 1,
|
||||||
maximum: 100,
|
maximum: 100,
|
||||||
},
|
},
|
||||||
description: 'Number of items per page',
|
description: 'Number of items per page',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'sortBy',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['event_time', 'event_type', 'link_slug', 'conversion_value']
|
||||||
|
},
|
||||||
|
description: 'Field to sort by',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sortOrder',
|
||||||
|
in: 'query',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['asc', 'desc'],
|
||||||
|
default: 'desc'
|
||||||
|
},
|
||||||
|
description: 'Sort order',
|
||||||
|
}
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
'200': {
|
'200': {
|
||||||
@@ -274,31 +348,116 @@ export default function SwaggerPage() {
|
|||||||
schema: {
|
schema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
example: true
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
$ref: '#/components/schemas/Event',
|
$ref: '#/components/schemas/Event'
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
pagination: {
|
meta: {
|
||||||
$ref: '#/components/schemas/Pagination',
|
type: 'object',
|
||||||
},
|
properties: {
|
||||||
},
|
total: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Total number of events matching the filters'
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Current page number'
|
||||||
|
},
|
||||||
|
pageSize: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of items per page'
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tags: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string'
|
||||||
|
},
|
||||||
|
description: 'Applied tag filters'
|
||||||
|
},
|
||||||
|
teamId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Applied team filter'
|
||||||
|
},
|
||||||
|
projectId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Applied project filter'
|
||||||
|
},
|
||||||
|
searchSlug: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Applied slug search term'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
example: {
|
||||||
},
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
event_id: '123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
event_time: '2024-03-31T10:00:00.000Z',
|
||||||
|
event_type: 'click',
|
||||||
|
link_slug: 'summer-promo',
|
||||||
|
link_tags: ['promo', 'summer'],
|
||||||
|
team_id: 'team1',
|
||||||
|
project_id: 'proj1'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
meta: {
|
||||||
|
total: 150,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
filters: {
|
||||||
|
tags: ['promo', 'summer'],
|
||||||
|
teamId: 'team1',
|
||||||
|
projectId: 'proj1',
|
||||||
|
searchSlug: 'summer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'400': {
|
'400': {
|
||||||
description: 'Bad request',
|
description: 'Bad request',
|
||||||
content: {
|
content: {
|
||||||
'application/json': {
|
'application/json': {
|
||||||
schema: {
|
schema: {
|
||||||
$ref: '#/components/schemas/Error',
|
$ref: '#/components/schemas/Error'
|
||||||
},
|
},
|
||||||
},
|
example: {
|
||||||
},
|
success: false,
|
||||||
|
error: 'Invalid tags format'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
'500': {
|
||||||
|
description: 'Server error',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/events/summary': {
|
'/events/summary': {
|
||||||
@@ -526,6 +685,74 @@ export default function SwaggerPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'/events/tags': {
|
||||||
|
get: {
|
||||||
|
tags: ['events'],
|
||||||
|
summary: '获取标签列表',
|
||||||
|
description: '获取所有事件中的唯一标签列表。如果提供了 teamId,则只返回该团队的标签。',
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'teamId',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
description: '团队ID(可选)。如果提供,则只返回该团队的标签。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: '成功获取标签列表',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
tag_name: {
|
||||||
|
type: 'string',
|
||||||
|
example: 'marketing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
description: '服务器错误',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
example: false,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
example: 'Failed to fetch tags',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
|||||||
@@ -1,47 +1,72 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import type { ApiResponse, EventsQueryParams, EventType } from '@/lib/types';
|
import type { ApiResponse, EventsQueryParams, EventType } from '@/lib/types';
|
||||||
import {
|
import { getEvents } from '@/lib/analytics';
|
||||||
getEvents,
|
|
||||||
getEventsSummary,
|
|
||||||
getTimeSeriesData,
|
|
||||||
getGeoAnalytics,
|
|
||||||
getDeviceAnalytics
|
|
||||||
} from '@/lib/analytics';
|
|
||||||
|
|
||||||
// 获取事件列表
|
// 获取事件列表
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
const params: EventsQueryParams = {
|
// 构建查询参数,所有参数都是可选的
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
const params: Partial<EventsQueryParams> = {
|
||||||
endTime: searchParams.get('endTime') || undefined,
|
// 时间范围参数现在是可选的
|
||||||
eventType: searchParams.get('eventType') as EventType || undefined,
|
...(searchParams.has('startTime') && { startTime: searchParams.get('startTime')! }),
|
||||||
linkId: searchParams.get('linkId') || undefined,
|
...(searchParams.has('endTime') && { endTime: searchParams.get('endTime')! }),
|
||||||
linkSlug: searchParams.get('linkSlug') || undefined,
|
|
||||||
userId: searchParams.get('userId') || undefined,
|
// 其他过滤参数
|
||||||
teamId: searchParams.get('teamId') || undefined,
|
...(searchParams.has('eventType') && { eventType: searchParams.get('eventType') as EventType }),
|
||||||
projectId: searchParams.get('projectId') || undefined,
|
...(searchParams.has('linkId') && { linkId: searchParams.get('linkId')! }),
|
||||||
|
...(searchParams.has('linkSlug') && { linkSlug: searchParams.get('linkSlug')! }),
|
||||||
|
...(searchParams.has('userId') && { userId: searchParams.get('userId')! }),
|
||||||
|
...(searchParams.has('teamId') && { teamId: searchParams.get('teamId')! }),
|
||||||
|
...(searchParams.has('projectId') && { projectId: searchParams.get('projectId')! }),
|
||||||
|
...(searchParams.has('tags') && {
|
||||||
|
tags: searchParams.get('tags')!.split(',').filter(Boolean)
|
||||||
|
}),
|
||||||
|
...(searchParams.has('searchSlug') && { searchSlug: searchParams.get('searchSlug')! }),
|
||||||
|
|
||||||
|
// 分页和排序参数,设置默认值
|
||||||
page: searchParams.has('page') ? parseInt(searchParams.get('page')!, 10) : 1,
|
page: searchParams.has('page') ? parseInt(searchParams.get('page')!, 10) : 1,
|
||||||
pageSize: searchParams.has('pageSize') ? parseInt(searchParams.get('pageSize')!, 10) : 20,
|
pageSize: searchParams.has('pageSize') ? parseInt(searchParams.get('pageSize')!, 10) : 20,
|
||||||
sortBy: searchParams.get('sortBy') || undefined,
|
...(searchParams.has('sortBy') && { sortBy: searchParams.get('sortBy')! }),
|
||||||
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined
|
...(searchParams.has('sortOrder') && {
|
||||||
|
sortOrder: searchParams.get('sortOrder') as 'asc' | 'desc'
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 验证 tags 参数
|
||||||
|
if (params.tags?.some(tag => !tag.trim())) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid tags format'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取事件数据
|
||||||
const { events, total } = await getEvents(params);
|
const { events, total } = await getEvents(params);
|
||||||
|
|
||||||
|
// 构建响应
|
||||||
const response: ApiResponse<typeof events> = {
|
const response: ApiResponse<typeof events> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: events,
|
data: events,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page: params.page,
|
page: params.page || 1,
|
||||||
pageSize: params.pageSize
|
pageSize: params.pageSize || 20,
|
||||||
|
filters: {
|
||||||
|
startTime: params.startTime,
|
||||||
|
endTime: params.endTime,
|
||||||
|
tags: params.tags,
|
||||||
|
teamId: params.teamId,
|
||||||
|
projectId: params.projectId,
|
||||||
|
searchSlug: params.searchSlug
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error in GET /events:', error);
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
|||||||
99
app/api/events/tags/route.ts
Normal file
99
app/api/events/tags/route.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* @swagger
|
||||||
|
* /api/events/tags:
|
||||||
|
* get:
|
||||||
|
* summary: 获取标签列表
|
||||||
|
* description: 获取所有事件中的唯一标签列表。如果提供了 teamId,则只返回该团队的标签。
|
||||||
|
* tags:
|
||||||
|
* - Events
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: teamId
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* description: 团队ID(可选)。如果提供,则只返回该团队的标签。
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: 成功获取标签列表
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* success:
|
||||||
|
* type: boolean
|
||||||
|
* example: true
|
||||||
|
* data:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* tag_name:
|
||||||
|
* type: string
|
||||||
|
* example: "marketing"
|
||||||
|
* 500:
|
||||||
|
* description: 服务器错误
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* success:
|
||||||
|
* type: boolean
|
||||||
|
* example: false
|
||||||
|
* error:
|
||||||
|
* type: string
|
||||||
|
* example: "Failed to fetch tags"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import clickhouse from '@/lib/clickhouse';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 URL 获取查询参数
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const teamId = searchParams.get('teamId');
|
||||||
|
|
||||||
|
// 构建基础查询
|
||||||
|
let query = `
|
||||||
|
WITH
|
||||||
|
JSONExtractArrayRaw(link_tags) as tags_array,
|
||||||
|
arrayJoin(tags_array) as tag
|
||||||
|
SELECT DISTINCT
|
||||||
|
JSONExtractString(tag) as tag_name
|
||||||
|
FROM events
|
||||||
|
WHERE link_tags != '[]'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const queryParams: Record<string, string> = {};
|
||||||
|
|
||||||
|
// 如果提供了 teamId,添加团队过滤条件
|
||||||
|
if (teamId) {
|
||||||
|
query += ` AND team_id = {id:String}`;
|
||||||
|
queryParams.id = teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加排序
|
||||||
|
query += ` ORDER BY tag_name ASC`;
|
||||||
|
|
||||||
|
const result = await clickhouse.query({
|
||||||
|
query,
|
||||||
|
query_params: queryParams
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await result.json();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tags:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Failed to fetch tags' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { executeQuery, executeQuerySingle, buildFilter, buildPagination, buildOrderBy } from './clickhouse';
|
import { executeQuery, executeQuerySingle, buildFilter, buildPagination, buildOrderBy } from './clickhouse';
|
||||||
import type { Event, EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics, DeviceType } from './types';
|
import type { Event, EventsSummary, TimeSeriesData, GeoData, DeviceAnalytics, DeviceType, EventsQueryParams } from './types';
|
||||||
|
|
||||||
// 时间粒度枚举
|
// 时间粒度枚举
|
||||||
export enum TimeGranularity {
|
export enum TimeGranularity {
|
||||||
@@ -10,20 +10,7 @@ export enum TimeGranularity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取事件列表
|
// 获取事件列表
|
||||||
export async function getEvents(params: {
|
export async function getEvents(params: Partial<EventsQueryParams>): Promise<{ events: Event[]; total: number }> {
|
||||||
startTime?: string;
|
|
||||||
endTime?: string;
|
|
||||||
eventType?: string;
|
|
||||||
linkId?: string;
|
|
||||||
linkSlug?: string;
|
|
||||||
userId?: string;
|
|
||||||
teamId?: string;
|
|
||||||
projectId?: string;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: 'asc' | 'desc';
|
|
||||||
}): Promise<{ events: Event[]; total: number }> {
|
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
const pagination = buildPagination(params.page, params.pageSize);
|
const pagination = buildPagination(params.page, params.pageSize);
|
||||||
const orderBy = buildOrderBy(params.sortBy, params.sortOrder);
|
const orderBy = buildOrderBy(params.sortBy, params.sortOrder);
|
||||||
@@ -40,7 +27,48 @@ export async function getEvents(params: {
|
|||||||
|
|
||||||
// 获取事件列表
|
// 获取事件列表
|
||||||
const query = `
|
const query = `
|
||||||
SELECT *
|
SELECT
|
||||||
|
event_id,
|
||||||
|
event_time,
|
||||||
|
event_type,
|
||||||
|
event_attributes,
|
||||||
|
link_id,
|
||||||
|
link_slug,
|
||||||
|
link_label,
|
||||||
|
link_title,
|
||||||
|
link_original_url,
|
||||||
|
link_attributes,
|
||||||
|
link_created_at,
|
||||||
|
link_expires_at,
|
||||||
|
link_tags,
|
||||||
|
user_id,
|
||||||
|
user_name,
|
||||||
|
user_email,
|
||||||
|
user_attributes,
|
||||||
|
team_id,
|
||||||
|
team_name,
|
||||||
|
team_attributes,
|
||||||
|
project_id,
|
||||||
|
project_name,
|
||||||
|
project_attributes,
|
||||||
|
visitor_id,
|
||||||
|
session_id,
|
||||||
|
ip_address,
|
||||||
|
country,
|
||||||
|
city,
|
||||||
|
device_type,
|
||||||
|
browser,
|
||||||
|
os,
|
||||||
|
user_agent,
|
||||||
|
referrer,
|
||||||
|
utm_source,
|
||||||
|
utm_medium,
|
||||||
|
utm_campaign,
|
||||||
|
time_spent_sec,
|
||||||
|
is_bounce,
|
||||||
|
is_qr_scan,
|
||||||
|
conversion_type,
|
||||||
|
conversion_value
|
||||||
FROM events
|
FROM events
|
||||||
${filter}
|
${filter}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
@@ -49,7 +77,19 @@ export async function getEvents(params: {
|
|||||||
|
|
||||||
const events = await executeQuery<Event>(query);
|
const events = await executeQuery<Event>(query);
|
||||||
|
|
||||||
return { events, total };
|
// 处理 JSON 字符串字段
|
||||||
|
return {
|
||||||
|
events: events.map(event => ({
|
||||||
|
...event,
|
||||||
|
event_attributes: JSON.parse(event.event_attributes as unknown as string),
|
||||||
|
link_attributes: JSON.parse(event.link_attributes as unknown as string),
|
||||||
|
link_tags: JSON.parse(event.link_tags as unknown as string),
|
||||||
|
user_attributes: JSON.parse(event.user_attributes as unknown as string),
|
||||||
|
team_attributes: JSON.parse(event.team_attributes as unknown as string),
|
||||||
|
project_attributes: JSON.parse(event.project_attributes as unknown as string)
|
||||||
|
})),
|
||||||
|
total
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取事件概览
|
// 获取事件概览
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ function buildDateFilter(startTime?: string, endTime?: string): string {
|
|||||||
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 字符串转义函数
|
||||||
|
function escapeString(str: string): string {
|
||||||
|
return str.replace(/'/g, "\\'");
|
||||||
|
}
|
||||||
|
|
||||||
// 构建通用过滤条件
|
// 构建通用过滤条件
|
||||||
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
@@ -38,32 +43,45 @@ export function buildFilter(params: Partial<EventsQueryParams>): string {
|
|||||||
|
|
||||||
// 事件类型过滤
|
// 事件类型过滤
|
||||||
if (params.eventType) {
|
if (params.eventType) {
|
||||||
filters.push(`event_type = '${params.eventType}'`);
|
filters.push(`event_type = '${escapeString(params.eventType)}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 链接ID过滤
|
// 链接ID过滤
|
||||||
if (params.linkId) {
|
if (params.linkId) {
|
||||||
filters.push(`link_id = '${params.linkId}'`);
|
filters.push(`link_id = '${escapeString(params.linkId)}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 链接短码过滤
|
// 链接短码过滤
|
||||||
if (params.linkSlug) {
|
if (params.linkSlug) {
|
||||||
filters.push(`link_slug = '${params.linkSlug}'`);
|
filters.push(`link_slug = '${escapeString(params.linkSlug)}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户ID过滤
|
// 用户ID过滤
|
||||||
if (params.userId) {
|
if (params.userId) {
|
||||||
filters.push(`user_id = '${params.userId}'`);
|
filters.push(`user_id = '${escapeString(params.userId)}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 团队ID过滤
|
// 团队ID过滤
|
||||||
if (params.teamId) {
|
if (params.teamId) {
|
||||||
filters.push(`team_id = '${params.teamId}'`);
|
filters.push(`team_id = '${escapeString(params.teamId)}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 项目ID过滤
|
// 项目ID过滤
|
||||||
if (params.projectId) {
|
if (params.projectId) {
|
||||||
filters.push(`project_id = '${params.projectId}'`);
|
filters.push(`project_id = '${escapeString(params.projectId)}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签筛选
|
||||||
|
if (params.tags && params.tags.length > 0) {
|
||||||
|
const tagConditions = params.tags.map(tag =>
|
||||||
|
`JSONHas(JSONExtractArrayRaw(link_tags), JSON_QUOTE('${escapeString(tag)}'))`
|
||||||
|
);
|
||||||
|
filters.push(`(${tagConditions.join(' OR ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slug 模糊搜索
|
||||||
|
if (params.searchSlug) {
|
||||||
|
filters.push(`positionCaseInsensitive(link_slug, '${escapeString(params.searchSlug)}') > 0`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
|||||||
26
lib/types.ts
26
lib/types.ts
@@ -33,6 +33,14 @@ export interface ApiResponse<T> {
|
|||||||
total?: number;
|
total?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
filters?: {
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
tags?: string[];
|
||||||
|
teamId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
searchSlug?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,18 +54,26 @@ export interface EventsQueryParams {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
tags?: string[]; // 标签筛选
|
||||||
|
searchSlug?: string; // slug搜索关键词
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 属性值类型
|
||||||
|
export type AttributeValue = string | number | boolean | null | AttributeValue[] | { [key: string]: AttributeValue };
|
||||||
|
|
||||||
|
// 属性记录类型
|
||||||
|
export type AttributesRecord = Record<string, AttributeValue>;
|
||||||
|
|
||||||
// 事件基础信息
|
// 事件基础信息
|
||||||
export interface Event {
|
export interface Event {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
event_time: string;
|
event_time: string;
|
||||||
event_type: EventType;
|
event_type: EventType;
|
||||||
event_attributes: Record<string, any>;
|
event_attributes: AttributesRecord;
|
||||||
|
|
||||||
// 链接信息
|
// 链接信息
|
||||||
link_id: string;
|
link_id: string;
|
||||||
@@ -65,7 +81,7 @@ export interface Event {
|
|||||||
link_label: string;
|
link_label: string;
|
||||||
link_title: string;
|
link_title: string;
|
||||||
link_original_url: string;
|
link_original_url: string;
|
||||||
link_attributes: Record<string, any>;
|
link_attributes: AttributesRecord;
|
||||||
link_created_at: string;
|
link_created_at: string;
|
||||||
link_expires_at: string | null;
|
link_expires_at: string | null;
|
||||||
link_tags: string[];
|
link_tags: string[];
|
||||||
@@ -74,17 +90,17 @@ export interface Event {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string;
|
user_name: string;
|
||||||
user_email: string;
|
user_email: string;
|
||||||
user_attributes: Record<string, any>;
|
user_attributes: AttributesRecord;
|
||||||
|
|
||||||
// 团队信息
|
// 团队信息
|
||||||
team_id: string;
|
team_id: string;
|
||||||
team_name: string;
|
team_name: string;
|
||||||
team_attributes: Record<string, any>;
|
team_attributes: AttributesRecord;
|
||||||
|
|
||||||
// 项目信息
|
// 项目信息
|
||||||
project_id: string;
|
project_id: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
project_attributes: Record<string, any>;
|
project_attributes: AttributesRecord;
|
||||||
|
|
||||||
// 访问者信息
|
// 访问者信息
|
||||||
visitor_id: string;
|
visitor_id: string;
|
||||||
|
|||||||
15
next.config.js
Normal file
15
next.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// 启用 webpack 配置
|
||||||
|
webpack: (config) => {
|
||||||
|
// 添加 CSS 处理规则
|
||||||
|
config.module.rules.push({
|
||||||
|
test: /\.css$/,
|
||||||
|
use: ['style-loader', 'css-loader'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -45,8 +45,10 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/swagger-ui-react": "^4.18.3",
|
"@types/swagger-ui-react": "^4.18.3",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.2.3",
|
"eslint-config-next": "15.2.3",
|
||||||
|
"pg": "^8.14.0",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
113
pnpm-lock.yaml
generated
113
pnpm-lock.yaml
generated
@@ -69,12 +69,18 @@ importers:
|
|||||||
css-loader:
|
css-loader:
|
||||||
specifier: ^7.1.2
|
specifier: ^7.1.2
|
||||||
version: 7.1.2(webpack@5.98.0)
|
version: 7.1.2(webpack@5.98.0)
|
||||||
|
dotenv:
|
||||||
|
specifier: ^16.4.7
|
||||||
|
version: 16.4.7
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9
|
specifier: ^9
|
||||||
version: 9.22.0(jiti@2.4.2)
|
version: 9.22.0(jiti@2.4.2)
|
||||||
eslint-config-next:
|
eslint-config-next:
|
||||||
specifier: 15.2.3
|
specifier: 15.2.3
|
||||||
version: 15.2.3(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
|
version: 15.2.3(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2)
|
||||||
|
pg:
|
||||||
|
specifier: ^8.14.0
|
||||||
|
version: 8.14.0
|
||||||
style-loader:
|
style-loader:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0(webpack@5.98.0)
|
version: 4.0.0(webpack@5.98.0)
|
||||||
@@ -1173,6 +1179,10 @@ packages:
|
|||||||
dompurify@3.2.4:
|
dompurify@3.2.4:
|
||||||
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
resolution: {integrity: sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==}
|
||||||
|
|
||||||
|
dotenv@16.4.7:
|
||||||
|
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
drange@1.1.1:
|
drange@1.1.1:
|
||||||
resolution: {integrity: sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==}
|
resolution: {integrity: sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -2024,6 +2034,40 @@ packages:
|
|||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
|
pg-cloudflare@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
|
||||||
|
|
||||||
|
pg-connection-string@2.7.0:
|
||||||
|
resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==}
|
||||||
|
|
||||||
|
pg-int8@1.0.1:
|
||||||
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
pg-pool@3.8.0:
|
||||||
|
resolution: {integrity: sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==}
|
||||||
|
peerDependencies:
|
||||||
|
pg: '>=8.0'
|
||||||
|
|
||||||
|
pg-protocol@1.8.0:
|
||||||
|
resolution: {integrity: sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pg@8.14.0:
|
||||||
|
resolution: {integrity: sha512-nXbVpyoaXVmdqlKEzToFf37qzyeeh7mbiXsnoWvstSqohj88yaa/I/Rq/HEVn2QPSZEuLIJa/jSpRDyzjEx4FQ==}
|
||||||
|
engines: {node: '>= 8.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
pg-native: '>=3.0.1'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
pg-native:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -2078,6 +2122,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
|
resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
postgres-array@2.0.0:
|
||||||
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.0:
|
||||||
|
resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-date@1.0.7:
|
||||||
|
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2384,6 +2444,10 @@ packages:
|
|||||||
space-separated-tokens@1.1.5:
|
space-separated-tokens@1.1.5:
|
||||||
resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
|
resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
sprintf-js@1.0.3:
|
sprintf-js@1.0.3:
|
||||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||||
|
|
||||||
@@ -4013,6 +4077,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
|
dotenv@16.4.7: {}
|
||||||
|
|
||||||
drange@1.1.1: {}
|
drange@1.1.1: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
@@ -4983,6 +5049,41 @@ snapshots:
|
|||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
|
pg-cloudflare@1.1.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pg-connection-string@2.7.0: {}
|
||||||
|
|
||||||
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
|
pg-pool@3.8.0(pg@8.14.0):
|
||||||
|
dependencies:
|
||||||
|
pg: 8.14.0
|
||||||
|
|
||||||
|
pg-protocol@1.8.0: {}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
pg-int8: 1.0.1
|
||||||
|
postgres-array: 2.0.0
|
||||||
|
postgres-bytea: 1.0.0
|
||||||
|
postgres-date: 1.0.7
|
||||||
|
postgres-interval: 1.2.0
|
||||||
|
|
||||||
|
pg@8.14.0:
|
||||||
|
dependencies:
|
||||||
|
pg-connection-string: 2.7.0
|
||||||
|
pg-pool: 3.8.0(pg@8.14.0)
|
||||||
|
pg-protocol: 1.8.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
pgpass: 1.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
pg-cloudflare: 1.1.1
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
@@ -5031,6 +5132,16 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
postgres-array@2.0.0: {}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.0: {}
|
||||||
|
|
||||||
|
postgres-date@1.0.7: {}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prismjs@1.27.0: {}
|
prismjs@1.27.0: {}
|
||||||
@@ -5396,6 +5507,8 @@ snapshots:
|
|||||||
|
|
||||||
space-separated-tokens@1.1.5: {}
|
space-separated-tokens@1.1.5: {}
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
sprintf-js@1.0.3: {}
|
sprintf-js@1.0.3: {}
|
||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
|
|
||||||
获取所有表...
|
|
||||||
数据库 limq 中找到以下表:
|
|
||||||
- .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb
|
|
||||||
- .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc
|
|
||||||
- .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1
|
|
||||||
- .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0
|
|
||||||
- .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024
|
|
||||||
- .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea
|
|
||||||
- link_daily_stats
|
|
||||||
- link_events
|
|
||||||
- link_hourly_patterns
|
|
||||||
- links
|
|
||||||
- platform_distribution
|
|
||||||
- project_daily_stats
|
|
||||||
- projects
|
|
||||||
- qr_scans
|
|
||||||
- qrcode_daily_stats
|
|
||||||
- qrcodes
|
|
||||||
- sessions
|
|
||||||
- team_daily_stats
|
|
||||||
- team_members
|
|
||||||
- teams
|
|
||||||
|
|
||||||
所有ClickHouse表:
|
|
||||||
.inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb, .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc, .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1, .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0, .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024, .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea, link_daily_stats, link_events, link_hourly_patterns, links, platform_distribution, project_daily_stats, projects, qr_scans, qrcode_daily_stats, qrcodes, sessions, team_daily_stats, team_members, teams
|
|
||||||
|
|
||||||
获取表 .inner_id.5d9e5f95-ad7d-4750-ae56-bffea63e14fb 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.711eb652-7c90-4f9a-80a0-8979011080cc 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.abec445d-1704-4482-bc72-66c9eb67ecd1 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.c1eb844d-7f11-4cfc-8931-c433faaa16b0 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.f9640e70-5b7f-444c-80de-bc5b25848024 的结构...
|
|
||||||
|
|
||||||
获取表 .inner_id.fe81eeba-acc5-4260-ac9a-973c2f9ce1ea 的结构...
|
|
||||||
|
|
||||||
获取表 link_daily_stats 的结构...
|
|
||||||
表 link_daily_stats 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- total_clicks (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
- unique_sessions (UInt64, 无默认值)
|
|
||||||
- total_time_spent (UInt64, 无默认值)
|
|
||||||
- avg_time_spent (Float64, 无默认值)
|
|
||||||
- bounce_count (UInt64, 无默认值)
|
|
||||||
- conversion_count (UInt64, 无默认值)
|
|
||||||
- unique_referrers (UInt64, 无默认值)
|
|
||||||
- mobile_count (UInt64, 无默认值)
|
|
||||||
- tablet_count (UInt64, 无默认值)
|
|
||||||
- desktop_count (UInt64, 无默认值)
|
|
||||||
- qr_scan_count (UInt64, 无默认值)
|
|
||||||
- total_conversion_value (Float64, 无默认值)
|
|
||||||
|
|
||||||
获取表 link_events 的结构...
|
|
||||||
表 link_events 的列:
|
|
||||||
- event_id (UUID, 默认值: generateUUIDv4())
|
|
||||||
- event_time (DateTime64(3), 默认值: now64())
|
|
||||||
- date (Date, 默认值: toDate(event_time))
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- channel_id (String, 无默认值)
|
|
||||||
- visitor_id (String, 无默认值)
|
|
||||||
- session_id (String, 无默认值)
|
|
||||||
- event_type (Enum8('click' = 1, 'redirect' = 2, 'conversion' = 3, 'error' = 4), 无默认值)
|
|
||||||
- ip_address (String, 无默认值)
|
|
||||||
- country (String, 无默认值)
|
|
||||||
- city (String, 无默认值)
|
|
||||||
- referrer (String, 无默认值)
|
|
||||||
- utm_source (String, 无默认值)
|
|
||||||
- utm_medium (String, 无默认值)
|
|
||||||
- utm_campaign (String, 无默认值)
|
|
||||||
- user_agent (String, 无默认值)
|
|
||||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
|
||||||
- browser (String, 无默认值)
|
|
||||||
- os (String, 无默认值)
|
|
||||||
- time_spent_sec (UInt32, 默认值: 0)
|
|
||||||
- is_bounce (Bool, 默认值: true)
|
|
||||||
- is_qr_scan (Bool, 默认值: false)
|
|
||||||
- qr_code_id (String, 默认值: '')
|
|
||||||
- conversion_type (Enum8('visit' = 1, 'stay' = 2, 'interact' = 3, 'signup' = 4, 'subscription' = 5, 'purchase' = 6), 默认值: 'visit')
|
|
||||||
- conversion_value (Float64, 默认值: 0)
|
|
||||||
- custom_data (String, 默认值: '{}')
|
|
||||||
|
|
||||||
获取表 link_hourly_patterns 的结构...
|
|
||||||
表 link_hourly_patterns 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- hour (UInt8, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- visits (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 links 的结构...
|
|
||||||
表 links 的列:
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- original_url (String, 无默认值)
|
|
||||||
- created_at (DateTime64(3), 无默认值)
|
|
||||||
- created_by (String, 无默认值)
|
|
||||||
- title (String, 无默认值)
|
|
||||||
- description (String, 无默认值)
|
|
||||||
- tags (Array(String), 无默认值)
|
|
||||||
- is_active (Bool, 默认值: true)
|
|
||||||
- expires_at (Nullable(DateTime), 无默认值)
|
|
||||||
- team_id (String, 默认值: '')
|
|
||||||
- project_id (String, 默认值: '')
|
|
||||||
|
|
||||||
获取表 platform_distribution 的结构...
|
|
||||||
表 platform_distribution 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- utm_source (String, 无默认值)
|
|
||||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
|
||||||
- visits (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 project_daily_stats 的结构...
|
|
||||||
表 project_daily_stats 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- project_id (String, 无默认值)
|
|
||||||
- total_clicks (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
- conversion_count (UInt64, 无默认值)
|
|
||||||
- links_used (UInt64, 无默认值)
|
|
||||||
- qr_scan_count (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 projects 的结构...
|
|
||||||
表 projects 的列:
|
|
||||||
- project_id (String, 无默认值)
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- name (String, 无默认值)
|
|
||||||
- created_at (DateTime, 无默认值)
|
|
||||||
- created_by (String, 无默认值)
|
|
||||||
- description (String, 默认值: '')
|
|
||||||
- is_archived (Bool, 默认值: false)
|
|
||||||
- links_count (UInt32, 默认值: 0)
|
|
||||||
- total_clicks (UInt64, 默认值: 0)
|
|
||||||
- last_updated (DateTime, 默认值: now())
|
|
||||||
|
|
||||||
获取表 qr_scans 的结构...
|
|
||||||
表 qr_scans 的列:
|
|
||||||
- scan_id (UUID, 默认值: generateUUIDv4())
|
|
||||||
- qr_code_id (String, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- scan_time (DateTime64(3), 无默认值)
|
|
||||||
- visitor_id (String, 无默认值)
|
|
||||||
- location (String, 无默认值)
|
|
||||||
- device_type (Enum8('mobile' = 1, 'tablet' = 2, 'desktop' = 3, 'other' = 4), 无默认值)
|
|
||||||
- led_to_conversion (Bool, 默认值: false)
|
|
||||||
|
|
||||||
获取表 qrcode_daily_stats 的结构...
|
|
||||||
表 qrcode_daily_stats 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- qr_code_id (String, 无默认值)
|
|
||||||
- total_scans (UInt64, 无默认值)
|
|
||||||
- unique_scanners (UInt64, 无默认值)
|
|
||||||
- conversions (UInt64, 无默认值)
|
|
||||||
- mobile_scans (UInt64, 无默认值)
|
|
||||||
- tablet_scans (UInt64, 无默认值)
|
|
||||||
- desktop_scans (UInt64, 无默认值)
|
|
||||||
- unique_locations (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 qrcodes 的结构...
|
|
||||||
表 qrcodes 的列:
|
|
||||||
- qr_code_id (String, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- project_id (String, 默认值: '')
|
|
||||||
- name (String, 无默认值)
|
|
||||||
- description (String, 默认值: '')
|
|
||||||
- created_at (DateTime, 无默认值)
|
|
||||||
- created_by (String, 无默认值)
|
|
||||||
- updated_at (DateTime, 默认值: now())
|
|
||||||
- qr_type (Enum8('standard' = 1, 'custom' = 2, 'dynamic' = 3), 默认值: 'standard')
|
|
||||||
- image_url (String, 默认值: '')
|
|
||||||
- design_config (String, 默认值: '{}')
|
|
||||||
- is_active (Bool, 默认值: true)
|
|
||||||
- total_scans (UInt64, 默认值: 0)
|
|
||||||
- unique_scanners (UInt32, 默认值: 0)
|
|
||||||
|
|
||||||
获取表 sessions 的结构...
|
|
||||||
表 sessions 的列:
|
|
||||||
- session_id (String, 无默认值)
|
|
||||||
- visitor_id (String, 无默认值)
|
|
||||||
- link_id (String, 无默认值)
|
|
||||||
- started_at (DateTime64(3), 无默认值)
|
|
||||||
- last_activity (DateTime64(3), 无默认值)
|
|
||||||
- ended_at (Nullable(DateTime64(3)), 无默认值)
|
|
||||||
- duration_sec (UInt32, 默认值: 0)
|
|
||||||
- session_pages (UInt8, 默认值: 1)
|
|
||||||
- is_completed (Bool, 默认值: false)
|
|
||||||
|
|
||||||
获取表 team_daily_stats 的结构...
|
|
||||||
表 team_daily_stats 的列:
|
|
||||||
- date (Date, 无默认值)
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- total_clicks (UInt64, 无默认值)
|
|
||||||
- unique_visitors (UInt64, 无默认值)
|
|
||||||
- conversion_count (UInt64, 无默认值)
|
|
||||||
- links_used (UInt64, 无默认值)
|
|
||||||
- qr_scan_count (UInt64, 无默认值)
|
|
||||||
|
|
||||||
获取表 team_members 的结构...
|
|
||||||
表 team_members 的列:
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- user_id (String, 无默认值)
|
|
||||||
- role (Enum8('owner' = 1, 'admin' = 2, 'editor' = 3, 'viewer' = 4), 无默认值)
|
|
||||||
- joined_at (DateTime, 默认值: now())
|
|
||||||
- invited_by (String, 无默认值)
|
|
||||||
- is_active (Bool, 默认值: true)
|
|
||||||
- last_active (DateTime, 默认值: now())
|
|
||||||
|
|
||||||
获取表 teams 的结构...
|
|
||||||
表 teams 的列:
|
|
||||||
- team_id (String, 无默认值)
|
|
||||||
- name (String, 无默认值)
|
|
||||||
- created_at (DateTime, 无默认值)
|
|
||||||
- created_by (String, 无默认值)
|
|
||||||
- description (String, 默认值: '')
|
|
||||||
- avatar_url (String, 默认值: '')
|
|
||||||
- is_active (Bool, 默认值: true)
|
|
||||||
- plan_type (Enum8('free' = 1, 'pro' = 2, 'enterprise' = 3), 无默认值)
|
|
||||||
- members_count (UInt32, 默认值: 1)
|
|
||||||
|
|
||||||
ClickHouse数据库结构检查完成
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
|
||||||
|
获取所有表...
|
||||||
|
数据库 shorturl_analytics 中找到以下表:
|
||||||
|
- events
|
||||||
|
|
||||||
|
所有ClickHouse表:
|
||||||
|
events
|
||||||
|
|
||||||
|
获取表 events 的结构...
|
||||||
|
表 events 的列:
|
||||||
|
- event_id (String, 无默认值)
|
||||||
|
- event_time (DateTime64(3), 无默认值)
|
||||||
|
- 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 (DateTime64(3), 无默认值)
|
||||||
|
- link_expires_at (Nullable(DateTime64(3)), 无默认值)
|
||||||
|
- 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 (UInt32, 默认值: 0)
|
||||||
|
- is_bounce (Bool, 默认值: true)
|
||||||
|
- is_qr_scan (Bool, 默认值: false)
|
||||||
|
- conversion_type (String, 无默认值)
|
||||||
|
- conversion_value (Float64, 默认值: 0)
|
||||||
|
|
||||||
|
ClickHouse数据库结构检查完成
|
||||||
Reference in New Issue
Block a user