Files
shorturl-analytics/app/(app)/events/page.tsx
2025-03-31 23:01:39 +08:00

365 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
import { Event, EventType } from '@/lib/types';
export default function EventsPage() {
const [dateRange, setDateRange] = useState({
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7天前
to: new Date() // 今天
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [events, setEvents] = useState<Event[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [totalEvents, setTotalEvents] = useState(0);
const [tags, setTags] = useState<string[]>([]);
// 过滤条件状态
const [filters, setFilters] = useState({
eventType: '',
linkId: '',
linkSlug: '',
userId: '',
teamId: '',
projectId: '',
tags: [] as string[],
searchSlug: '',
sortBy: 'event_time',
sortOrder: 'desc' as 'asc' | 'desc'
});
// 加载标签列表
const fetchTags = async () => {
try {
const response = await fetch('/api/events/tags');
const data = await response.json();
console.log('API response for tags:', data); // 添加完整日志查看API响应
if (data.success) {
// 处理嵌套的 data 结构
const tagsData = data.data.data || [];
console.log('Tags data:', tagsData); // 打印实际的标签数据
setTags(tagsData.map((tag: { tag_name: string }) => tag.tag_name));
}
} catch (err) {
console.error('Error fetching tags:', err);
}
};
// 获取事件列表
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({
page: pageNum.toString(),
pageSize: '20'
});
// 添加时间范围参数(如果有)
if (startTime) params.append('startTime', startTime);
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 data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch events');
}
if (pageNum === 1) {
setEvents(data.data || []);
} else {
setEvents(prev => [...prev, ...(data.data || [])]);
}
setTotalEvents(data.meta?.total || 0);
setHasMore(data.data && data.data.length === 20);
} 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(() => {
fetchTags();
}, []);
// 当过滤条件改变时重新加载数据
useEffect(() => {
setPage(1);
setEvents([]);
setLoading(true);
fetchEvents(1);
}, [dateRange, filters]);
// 加载更多数据
const loadMore = () => {
if (!loading && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
fetchEvents(nextPage);
}
};
// 重置过滤条件
const resetFilters = () => {
setFilters({
eventType: '',
linkId: '',
linkSlug: '',
userId: '',
teamId: '',
projectId: '',
tags: [],
searchSlug: '',
sortBy: 'event_time',
sortOrder: 'desc'
});
};
// 格式化日期
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="bg-white rounded-lg shadow p-6 mb-8">
<h2 className="text-xl font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<DateRangePicker
value={dateRange}
onChange={setDateRange}
/>
</div>
<div>
<select
value={filters.eventType}
onChange={(e) => setFilters(prev => ({ ...prev, eventType: 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=""></option>
{Object.values(EventType).map(type => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div>
<input
type="text"
placeholder="链接 ID"
value={filters.linkId}
onChange={(e) => setFilters(prev => ({ ...prev, linkId: 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.linkSlug}
onChange={(e) => setFilters(prev => ({ ...prev, linkSlug: 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.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.tags.length > 0 ? filters.tags[0] : ''}
onChange={(e) => {
const selectedTag = e.target.value;
setFilters(prev => ({
...prev,
tags: selectedTag ? [selectedTag] : []
}));
}}
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=""></option>
{tags.map(tag => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
{/* 说明: 标签过滤会返回包含所选标签的事件,使用 JSONHas 函数查询 link_tags 字段 */}
</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 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 className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h2 className="text-xl font-semibold"> ({totalEvents})</h2>
</div>
<div className="divide-y">
{events.map((event) => (
<div key={event.event_id} className="p-4">
<div className="flex justify-between items-start">
<div>
<span className="font-medium">{event.event_type}</span>
<span className="text-gray-500 ml-2">{formatDate(event.event_time)}</span>
</div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{event.device_type}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">: {event.link_slug}</p>
<p className="text-sm text-gray-600">: {event.user_name || event.user_id}</p>
</div>
<div>
<p className="text-sm text-gray-600">IP: {event.ip_address}</p>
<p className="text-sm text-gray-600">: {event.country} {event.city}</p>
</div>
</div>
{event.link_tags && (
<div className="mt-2 flex gap-2">
{(typeof event.link_tags === 'string' ?
(() => {
try {
return JSON.parse(event.link_tags);
} catch (e) {
console.error('Error parsing link_tags:', e);
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>
))}
</div>
)}
</div>
))}
</div>
{hasMore && (
<div className="p-4 text-center">
<button
onClick={loadMore}
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"
>
{loading ? '加载中...' : '加载更多'}
</button>
</div>
)}
{!loading && events.length === 0 && (
<div className="p-8 text-center text-gray-500">
</div>
)}
</div>
</div>
);
}