Compare commits
9 Commits
4b7fb7a887
...
feature/a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b41f3ea42 | |||
| 63f434fd93 | |||
| 95f230b996 | |||
| 0f8419778c | |||
| a6f7172ec4 | |||
| 8054b0235d | |||
| b0dbd088e7 | |||
| bf7c62fdc9 | |||
| 9cb9f62686 |
@@ -1,126 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { subDays } from 'date-fns';
|
|
||||||
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
|
||||||
import { TeamSelector } from '@/app/components/ui/TeamSelector';
|
|
||||||
import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
|
|
||||||
import { TagSelector } from '@/app/components/ui/TagSelector';
|
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
|
||||||
// 默认日期范围为最近7天
|
|
||||||
const today = new Date();
|
|
||||||
const [dateRange, setDateRange] = useState({
|
|
||||||
from: subDays(today, 7), // 7天前
|
|
||||||
to: today // 今天
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加团队选择状态 - 使用数组支持多选
|
|
||||||
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// 添加项目选择状态 - 使用数组支持多选
|
|
||||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// 添加标签选择状态 - 使用数组支持多选
|
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// 分析是否有任何选择
|
|
||||||
const hasNoSelection = selectedTeamIds.length === 0 &&
|
|
||||||
selectedProjectIds.length === 0 &&
|
|
||||||
selectedTagIds.length === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto px-4 py-8">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
|
|
||||||
<h1 className="text-xl font-bold text-gray-900">Analytics</h1>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
|
||||||
<TeamSelector
|
|
||||||
value={selectedTeamIds}
|
|
||||||
onChange={(value) => setSelectedTeamIds(Array.isArray(value) ? value : [value])}
|
|
||||||
className="w-[250px]"
|
|
||||||
multiple={true}
|
|
||||||
/>
|
|
||||||
<ProjectSelector
|
|
||||||
value={selectedProjectIds}
|
|
||||||
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
|
|
||||||
className="w-[250px]"
|
|
||||||
multiple={true}
|
|
||||||
teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined}
|
|
||||||
/>
|
|
||||||
<TagSelector
|
|
||||||
value={selectedTagIds}
|
|
||||||
onChange={(value) => setSelectedTagIds(Array.isArray(value) ? value : [value])}
|
|
||||||
className="w-[250px]"
|
|
||||||
multiple={true}
|
|
||||||
teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined}
|
|
||||||
/>
|
|
||||||
<DateRangePicker
|
|
||||||
value={dateRange}
|
|
||||||
onChange={setDateRange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 如果没有选择任何项,显示提示信息 */}
|
|
||||||
{hasNoSelection && (
|
|
||||||
<div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg">
|
|
||||||
<p className="text-gray-500">
|
|
||||||
Please select teams, projects, or tags to view analytics
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 显示团队相关的分析数据 */}
|
|
||||||
{selectedTeamIds.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
Team Analytics ({selectedTeamIds.length} selected)
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{selectedTeamIds.map((teamId) => (
|
|
||||||
<div key={teamId} className="p-4 border rounded-md">
|
|
||||||
<h3 className="font-medium text-gray-800">Team ID: {teamId}</h3>
|
|
||||||
<p className="text-gray-500 mt-2">Team analytics will appear here</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 显示项目相关的分析数据 */}
|
|
||||||
{selectedProjectIds.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
Project Analytics ({selectedProjectIds.length} selected)
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{selectedProjectIds.map((projectId) => (
|
|
||||||
<div key={projectId} className="p-4 border rounded-md">
|
|
||||||
<h3 className="font-medium text-gray-800">Project ID: {projectId}</h3>
|
|
||||||
<p className="text-gray-500 mt-2">Project analytics will appear here</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 显示标签相关的分析数据 */}
|
|
||||||
{selectedTagIds.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
|
||||||
Tag Analytics ({selectedTagIds.length} selected)
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{selectedTagIds.map((tagId) => (
|
|
||||||
<div key={tagId} className="p-4 border rounded-md">
|
|
||||||
<h3 className="font-medium text-gray-800">Tag ID: {tagId}</h3>
|
|
||||||
<p className="text-gray-500 mt-2">Tag analytics will appear here</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,574 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import CreateLinkModal from '@/app/components/ui/CreateLinkModal';
|
|
||||||
|
|
||||||
// 自定义类型定义,替换原来的导入
|
|
||||||
interface Link {
|
|
||||||
link_id: string;
|
|
||||||
title?: string;
|
|
||||||
original_url: string;
|
|
||||||
visits: number;
|
|
||||||
unique_visits: number;
|
|
||||||
created_by: string;
|
|
||||||
created_at: string;
|
|
||||||
is_active: boolean;
|
|
||||||
tags?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatsOverview {
|
|
||||||
totalLinks: number;
|
|
||||||
activeLinks: number;
|
|
||||||
totalVisits: number;
|
|
||||||
conversionRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Tag {
|
|
||||||
tag: string;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define type for link data
|
|
||||||
interface LinkData {
|
|
||||||
name: string;
|
|
||||||
originalUrl: string;
|
|
||||||
customSlug: string;
|
|
||||||
expiresAt: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 映射API数据到UI所需格式
|
|
||||||
interface UILink {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
shortUrl: string;
|
|
||||||
originalUrl: string;
|
|
||||||
creator: string;
|
|
||||||
createdAt: string;
|
|
||||||
visits: number;
|
|
||||||
visitChange: number;
|
|
||||||
uniqueVisitors: number;
|
|
||||||
uniqueVisitorsChange: number;
|
|
||||||
avgTime: string;
|
|
||||||
avgTimeChange: number;
|
|
||||||
conversionRate: number;
|
|
||||||
conversionChange: number;
|
|
||||||
status: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LinksPage() {
|
|
||||||
const [links, setLinks] = useState<UILink[]>([]);
|
|
||||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
|
||||||
const [stats, setStats] = useState<StatsOverview>({
|
|
||||||
totalLinks: 0,
|
|
||||||
activeLinks: 0,
|
|
||||||
totalVisits: 0,
|
|
||||||
conversionRate: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 无限加载相关状态
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
||||||
const observer = useRef<IntersectionObserver | null>(null);
|
|
||||||
const lastLinkElementRef = useRef<HTMLTableRowElement | null>(null);
|
|
||||||
|
|
||||||
// 映射API数据到UI所需格式的函数
|
|
||||||
const mapApiLinkToUiLink = (apiLink: Link): UILink => {
|
|
||||||
// 生成短URL显示 - 因为数据库中没有short_url字段
|
|
||||||
const shortUrlDisplay = generateShortUrlDisplay(apiLink.link_id, apiLink.original_url);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: apiLink.link_id,
|
|
||||||
name: apiLink.title || 'Untitled Link',
|
|
||||||
shortUrl: shortUrlDisplay,
|
|
||||||
originalUrl: apiLink.original_url,
|
|
||||||
creator: apiLink.created_by,
|
|
||||||
createdAt: new Date(apiLink.created_at).toLocaleDateString(),
|
|
||||||
visits: apiLink.visits,
|
|
||||||
visitChange: 0, // API doesn't provide change data yet
|
|
||||||
uniqueVisitors: apiLink.unique_visits,
|
|
||||||
uniqueVisitorsChange: 0,
|
|
||||||
avgTime: '0m 0s', // API doesn't provide average time yet
|
|
||||||
avgTimeChange: 0,
|
|
||||||
conversionRate: 0, // API doesn't provide conversion rate yet
|
|
||||||
conversionChange: 0,
|
|
||||||
status: apiLink.is_active ? 'active' : 'inactive',
|
|
||||||
tags: apiLink.tags || []
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从link_id和原始URL生成短URL显示
|
|
||||||
const generateShortUrlDisplay = (linkId: string, originalUrl: string): string => {
|
|
||||||
try {
|
|
||||||
// 尝试从原始URL提取域名
|
|
||||||
const urlObj = new URL(originalUrl);
|
|
||||||
const domain = urlObj.hostname.replace('www.', '');
|
|
||||||
|
|
||||||
// 使用link_id的前8个字符作为短代码
|
|
||||||
const shortCode = linkId.substring(0, 8);
|
|
||||||
|
|
||||||
return `${domain}/${shortCode}`;
|
|
||||||
} catch {
|
|
||||||
// 如果URL解析失败,返回一个基于linkId的默认值
|
|
||||||
return `short.link/${linkId.substring(0, 8)}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 获取链接数据
|
|
||||||
const fetchLinks = useCallback(async (pageNum: number, isInitialLoad: boolean = false) => {
|
|
||||||
try {
|
|
||||||
if (isInitialLoad) {
|
|
||||||
setIsLoading(true);
|
|
||||||
} else {
|
|
||||||
setIsLoadingMore(true);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// 获取链接列表
|
|
||||||
const linksResponse = await fetch(`/api/links?page=${pageNum}&limit=20${searchQuery ? `&search=${encodeURIComponent(searchQuery)}` : ''}`);
|
|
||||||
if (!linksResponse.ok) {
|
|
||||||
throw new Error(`Failed to fetch links: ${linksResponse.statusText}`);
|
|
||||||
}
|
|
||||||
const linksData = await linksResponse.json();
|
|
||||||
|
|
||||||
const uiLinks = linksData.data.map(mapApiLinkToUiLink);
|
|
||||||
|
|
||||||
if (isInitialLoad) {
|
|
||||||
setLinks(uiLinks);
|
|
||||||
} else {
|
|
||||||
setLinks(prevLinks => [...prevLinks, ...uiLinks]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否还有更多数据可加载
|
|
||||||
const { pagination } = linksData;
|
|
||||||
setHasMore(pagination.page < pagination.totalPages);
|
|
||||||
|
|
||||||
if (isInitialLoad) {
|
|
||||||
// 只在初始加载时获取标签和统计数据
|
|
||||||
const tagsResponse = await fetch('/api/tags');
|
|
||||||
if (!tagsResponse.ok) {
|
|
||||||
throw new Error(`Failed to fetch tags: ${tagsResponse.statusText}`);
|
|
||||||
}
|
|
||||||
const tagsData = await tagsResponse.json();
|
|
||||||
|
|
||||||
const statsResponse = await fetch('/api/stats');
|
|
||||||
if (!statsResponse.ok) {
|
|
||||||
throw new Error(`Failed to fetch stats: ${statsResponse.statusText}`);
|
|
||||||
}
|
|
||||||
const statsData = await statsResponse.json();
|
|
||||||
|
|
||||||
setAllTags(tagsData);
|
|
||||||
setStats(statsData);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Data loading failed:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
||||||
} finally {
|
|
||||||
if (isInitialLoad) {
|
|
||||||
setIsLoading(false);
|
|
||||||
} else {
|
|
||||||
setIsLoadingMore(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1);
|
|
||||||
fetchLinks(1, true);
|
|
||||||
}, [fetchLinks]);
|
|
||||||
|
|
||||||
// 搜索过滤变化时重新加载数据
|
|
||||||
useEffect(() => {
|
|
||||||
// 当搜索关键词变化时,重置页码和链接列表,然后重新获取数据
|
|
||||||
setLinks([]);
|
|
||||||
setPage(1);
|
|
||||||
fetchLinks(1, true);
|
|
||||||
}, [searchQuery, fetchLinks]);
|
|
||||||
|
|
||||||
// 设置Intersection Observer来检测滚动并加载更多数据
|
|
||||||
useEffect(() => {
|
|
||||||
// 如果正在加载或没有更多数据,则不设置observer
|
|
||||||
if (isLoading || isLoadingMore || !hasMore) return;
|
|
||||||
|
|
||||||
// 断开之前的observer连接
|
|
||||||
if (observer.current) {
|
|
||||||
observer.current.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
observer.current = new IntersectionObserver(entries => {
|
|
||||||
if (entries[0].isIntersecting && hasMore) {
|
|
||||||
// 当最后一个元素可见且有更多数据时,加载下一页
|
|
||||||
setPage(prevPage => prevPage + 1);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
root: null,
|
|
||||||
rootMargin: '0px',
|
|
||||||
threshold: 0.5
|
|
||||||
});
|
|
||||||
|
|
||||||
if (lastLinkElementRef.current) {
|
|
||||||
observer.current.observe(lastLinkElementRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (observer.current) {
|
|
||||||
observer.current.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isLoading, isLoadingMore, hasMore, links]);
|
|
||||||
|
|
||||||
// 当页码变化时加载更多数据
|
|
||||||
useEffect(() => {
|
|
||||||
if (page > 1) {
|
|
||||||
fetchLinks(page, false);
|
|
||||||
}
|
|
||||||
}, [page, fetchLinks]);
|
|
||||||
|
|
||||||
const filteredLinks = links.filter(link =>
|
|
||||||
link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
link.shortUrl.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
link.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenLinkDetails = (id: string) => {
|
|
||||||
window.location.href = `/links/${id}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateLink = async (linkData: LinkData) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
// 在实际应用中,这里会发送 POST 请求到 API
|
|
||||||
console.log('创建链接:', linkData);
|
|
||||||
|
|
||||||
// 刷新链接列表
|
|
||||||
setPage(1);
|
|
||||||
fetchLinks(1, true);
|
|
||||||
|
|
||||||
setShowCreateModal(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('创建链接失败:', err);
|
|
||||||
setError(err instanceof Error ? err.message : '未知错误');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载状态
|
|
||||||
if (isLoading && links.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="w-12 h-12 mx-auto border-4 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
|
|
||||||
<p className="mt-4 text-lg text-foreground">Loading data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误状态
|
|
||||||
if (error && links.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="p-6 text-center rounded-lg bg-red-500/10">
|
|
||||||
<svg className="w-12 h-12 mx-auto text-accent-red" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<h2 className="mt-4 text-xl font-bold text-foreground">Loading Failed</h2>
|
|
||||||
<p className="mt-2 text-text-secondary">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="px-4 py-2 mt-4 text-white rounded-lg bg-accent-blue hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
Reload
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container px-4 py-8 mx-auto">
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:justify-between md:items-center">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Link Management</h1>
|
|
||||||
<p className="mt-1 text-sm text-text-secondary">
|
|
||||||
View and manage all your shortened links
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1 min-w-[200px]">
|
|
||||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
||||||
<svg className="w-4 h-4 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
className="block w-full p-2.5 pl-10 text-sm border rounded-lg bg-card-bg border-card-border text-foreground placeholder-text-secondary focus:ring-accent-blue focus:border-accent-blue"
|
|
||||||
placeholder="Search links..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="px-4 py-2.5 bg-accent-blue text-white rounded-lg text-sm font-medium hover:bg-blue-600 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
New Link
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Summary */}
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="p-3 mr-4 rounded-full text-accent-blue bg-blue-500/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.172 13.828a4 4 0 005.656 0l4-4a4 4 0 10-5.656-5.656l-1.102 1.101" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-secondary">Total Links</p>
|
|
||||||
<p className="text-2xl font-semibold text-foreground">{stats.totalLinks}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="p-3 mr-4 rounded-full text-accent-green bg-green-500/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-secondary">Active Links</p>
|
|
||||||
<p className="text-2xl font-semibold text-foreground">{stats.activeLinks}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="p-3 mr-4 rounded-full text-accent-purple bg-purple-500/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-secondary">Total Visits</p>
|
|
||||||
<p className="text-2xl font-semibold text-foreground">{stats.totalVisits.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="p-3 mr-4 rounded-full bg-amber-500/10 text-accent-yellow">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-secondary">Conversion Rate</p>
|
|
||||||
<p className="text-2xl font-semibold text-foreground">{(stats.conversionRate * 100).toFixed(1)}%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Links Table */}
|
|
||||||
<div className="overflow-hidden border rounded-lg shadow bg-card-bg border-card-border">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-card-border">
|
|
||||||
<thead className="bg-card-bg-secondary">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" className="px-6 py-3">Link Info</th>
|
|
||||||
<th scope="col" className="px-6 py-3">Visits</th>
|
|
||||||
<th scope="col" className="px-6 py-3">Unique Visitors</th>
|
|
||||||
<th scope="col" className="px-6 py-3">Avg Time</th>
|
|
||||||
<th scope="col" className="px-6 py-3">Conversion</th>
|
|
||||||
<th scope="col" className="px-6 py-3">Status</th>
|
|
||||||
<th scope="col" className="px-6 py-3">
|
|
||||||
<span className="sr-only">Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-card-border">
|
|
||||||
{filteredLinks.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-6 py-12 text-center text-text-secondary">
|
|
||||||
No links found. Create one to get started.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredLinks.map((link, index) => (
|
|
||||||
<tr
|
|
||||||
key={link.id}
|
|
||||||
onClick={() => handleOpenLinkDetails(link.id)}
|
|
||||||
className="transition-colors cursor-pointer hover:bg-card-bg-secondary"
|
|
||||||
ref={index === filteredLinks.length - 1 ? lastLinkElementRef : null}
|
|
||||||
>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="font-medium text-foreground">{link.name}</div>
|
|
||||||
<div className="text-xs text-accent-blue">{link.shortUrl}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="font-medium text-foreground">{link.visits.toLocaleString()}</div>
|
|
||||||
<div className={`text-xs flex items-center ${link.visitChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 mr-1 ${link.visitChange >= 0 ? '' : 'transform rotate-180'}`}
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
{Math.abs(link.visitChange)}%
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="font-medium text-foreground">{link.uniqueVisitors.toLocaleString()}</div>
|
|
||||||
<div className={`text-xs flex items-center ${link.uniqueVisitorsChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 mr-1 ${link.uniqueVisitorsChange >= 0 ? '' : 'transform rotate-180'}`}
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
{Math.abs(link.uniqueVisitorsChange)}%
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="font-medium text-foreground">{link.avgTime}</div>
|
|
||||||
<div className={`text-xs flex items-center ${link.avgTimeChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 mr-1 ${link.avgTimeChange >= 0 ? '' : 'transform rotate-180'}`}
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
{Math.abs(link.avgTimeChange)}%
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="font-medium text-foreground">{link.conversionRate}%</div>
|
|
||||||
<div className={`text-xs flex items-center ${link.conversionChange >= 0 ? 'text-accent-green' : 'text-accent-red'}`}>
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 mr-1 ${link.conversionChange >= 0 ? '' : 'transform rotate-180'}`}
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path fillRule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clipRule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
{Math.abs(link.conversionChange)}%
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-1 text-xs font-medium rounded-full ${
|
|
||||||
link.status === 'active'
|
|
||||||
? 'bg-green-500/10 text-accent-green'
|
|
||||||
: link.status === 'inactive'
|
|
||||||
? 'bg-gray-500/10 text-text-secondary'
|
|
||||||
: 'bg-red-500/10 text-accent-red'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{link.status === 'active' ? 'Active' : link.status === 'inactive' ? 'Inactive' : 'Expired'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleOpenLinkDetails(link.id);
|
|
||||||
}}
|
|
||||||
className="text-sm font-medium text-accent-blue hover:underline"
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading more indicator */}
|
|
||||||
{isLoadingMore && (
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="inline-block w-6 h-6 border-2 rounded-full border-accent-blue border-t-transparent animate-spin"></div>
|
|
||||||
<p className="mt-2 text-sm text-text-secondary">Loading more links...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* End of results message */}
|
|
||||||
{!hasMore && links.length > 0 && (
|
|
||||||
<div className="p-4 text-center text-sm text-text-secondary">
|
|
||||||
No more links to load.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags Section */}
|
|
||||||
{allTags.length > 0 && (
|
|
||||||
<div className="p-4 border rounded-lg shadow bg-card-bg border-card-border">
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-foreground">Tags</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{allTags.map(tagItem => (
|
|
||||||
<span
|
|
||||||
key={tagItem.tag}
|
|
||||||
className="inline-flex items-center px-3 py-1 text-sm font-medium rounded-full text-accent-blue bg-blue-500/10"
|
|
||||||
onClick={() => setSearchQuery(tagItem.tag)}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{tagItem.tag}
|
|
||||||
<span className="ml-1.5 text-xs bg-blue-500/20 px-1.5 py-0.5 rounded-full">
|
|
||||||
{tagItem.count}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Link Modal */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<CreateLinkModal
|
|
||||||
onClose={() => setShowCreateModal(false)}
|
|
||||||
onSubmit={handleCreateLink}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,19 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
// 获取团队、项目和标签筛选参数
|
||||||
|
const teamIds = searchParams.getAll('teamId');
|
||||||
|
const projectIds = searchParams.getAll('projectId');
|
||||||
|
const tagIds = searchParams.getAll('tagId');
|
||||||
|
|
||||||
const data = await getDeviceAnalytics({
|
const data = await getDeviceAnalytics({
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
startTime: searchParams.get('startTime') || undefined,
|
||||||
endTime: searchParams.get('endTime') || undefined,
|
endTime: searchParams.get('endTime') || undefined,
|
||||||
linkId: searchParams.get('linkId') || undefined
|
linkId: searchParams.get('linkId') || undefined,
|
||||||
|
// 添加团队、项目和标签筛选
|
||||||
|
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||||
|
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||||
|
tagIds: tagIds.length > 0 ? tagIds : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ApiResponse<typeof data> = {
|
const response: ApiResponse<typeof data> = {
|
||||||
|
|||||||
@@ -6,11 +6,23 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
|
||||||
|
// 获取团队、项目和标签筛选参数
|
||||||
|
const teamIds = searchParams.getAll('teamId');
|
||||||
|
const projectIds = searchParams.getAll('projectId');
|
||||||
|
const tagIds = searchParams.getAll('tagId');
|
||||||
|
|
||||||
|
// Get the groupBy parameter
|
||||||
|
const groupBy = searchParams.get('groupBy') as 'country' | 'city' | 'region' | 'continent' | null;
|
||||||
|
|
||||||
const data = await getGeoAnalytics({
|
const data = await getGeoAnalytics({
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
startTime: searchParams.get('startTime') || undefined,
|
||||||
endTime: searchParams.get('endTime') || undefined,
|
endTime: searchParams.get('endTime') || undefined,
|
||||||
linkId: searchParams.get('linkId') || undefined,
|
linkId: searchParams.get('linkId') || undefined,
|
||||||
groupBy: (searchParams.get('groupBy') || 'country') as 'country' | 'city'
|
groupBy: groupBy || undefined,
|
||||||
|
// 添加团队、项目和标签筛选
|
||||||
|
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||||
|
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||||
|
tagIds: tagIds.length > 0 ? tagIds : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ApiResponse<typeof data> = {
|
const response: ApiResponse<typeof data> = {
|
||||||
|
|||||||
@@ -1,50 +1,68 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import type { ApiResponse, EventsQueryParams, EventType } from '@/lib/types';
|
import { getEvents, EventsQueryParams } from '@/lib/analytics';
|
||||||
import {
|
import { ApiResponse } from '@/lib/types';
|
||||||
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 } = new URL(request.url);
|
||||||
|
|
||||||
|
// 获取查询参数
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||||
|
const eventType = searchParams.get('eventType') || undefined;
|
||||||
|
const linkId = searchParams.get('linkId') || undefined;
|
||||||
|
const linkSlug = searchParams.get('linkSlug') || undefined;
|
||||||
|
const userId = searchParams.get('userId') || undefined;
|
||||||
|
|
||||||
|
// 获取可能存在的多个团队、项目和标签ID
|
||||||
|
const teamIds = searchParams.getAll('teamId');
|
||||||
|
const projectIds = searchParams.getAll('projectId');
|
||||||
|
const tagIds = searchParams.getAll('tagId');
|
||||||
|
|
||||||
|
const startTime = searchParams.get('startTime') || undefined;
|
||||||
|
const endTime = searchParams.get('endTime') || undefined;
|
||||||
|
const sortBy = searchParams.get('sortBy') || undefined;
|
||||||
|
const sortOrder = (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined;
|
||||||
|
|
||||||
|
console.log("API接收到的tagIds:", tagIds); // 添加日志便于调试
|
||||||
|
|
||||||
|
// 获取事件列表
|
||||||
const params: EventsQueryParams = {
|
const params: EventsQueryParams = {
|
||||||
startTime: searchParams.get('startTime') || undefined,
|
page,
|
||||||
endTime: searchParams.get('endTime') || undefined,
|
pageSize,
|
||||||
eventType: searchParams.get('eventType') as EventType || undefined,
|
eventType,
|
||||||
linkId: searchParams.get('linkId') || undefined,
|
linkId,
|
||||||
linkSlug: searchParams.get('linkSlug') || undefined,
|
linkSlug,
|
||||||
userId: searchParams.get('userId') || undefined,
|
userId,
|
||||||
teamId: searchParams.get('teamId') || undefined,
|
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||||
projectId: searchParams.get('projectId') || undefined,
|
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||||
page: searchParams.has('page') ? parseInt(searchParams.get('page')!, 10) : 1,
|
tagIds: tagIds.length > 0 ? tagIds : undefined,
|
||||||
pageSize: searchParams.has('pageSize') ? parseInt(searchParams.get('pageSize')!, 10) : 20,
|
startTime,
|
||||||
sortBy: searchParams.get('sortBy') || undefined,
|
endTime,
|
||||||
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || undefined
|
sortBy,
|
||||||
|
sortOrder
|
||||||
};
|
};
|
||||||
|
|
||||||
const { events, total } = await getEvents(params);
|
const result = await getEvents(params);
|
||||||
|
|
||||||
const response: ApiResponse<typeof events> = {
|
const response: ApiResponse<typeof result.events> = {
|
||||||
success: true,
|
success: true,
|
||||||
data: events,
|
data: result.events,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total: result.total,
|
||||||
page: params.page,
|
page,
|
||||||
pageSize: params.pageSize
|
pageSize
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('获取事件列表失败:', error);
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
data: null,
|
||||||
|
error: error instanceof Error ? error.message : '获取事件列表失败'
|
||||||
};
|
};
|
||||||
return NextResponse.json(response, { status: 500 });
|
return NextResponse.json(response, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ export async function GET(request: NextRequest) {
|
|||||||
}, { status: 400 });
|
}, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取团队、项目和标签筛选参数
|
||||||
|
const teamIds = searchParams.getAll('teamId');
|
||||||
|
const projectIds = searchParams.getAll('projectId');
|
||||||
|
const tagIds = searchParams.getAll('tagId');
|
||||||
|
|
||||||
const data = await getTimeSeriesData({
|
const data = await getTimeSeriesData({
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
linkId: searchParams.get('linkId') || undefined,
|
linkId: searchParams.get('linkId') || undefined,
|
||||||
granularity: (searchParams.get('granularity') || 'day') as 'hour' | 'day' | 'week' | 'month'
|
granularity: (searchParams.get('granularity') || 'day') as 'hour' | 'day' | 'week' | 'month',
|
||||||
|
// 添加团队、项目和标签筛选
|
||||||
|
teamIds: teamIds.length > 0 ? teamIds : undefined,
|
||||||
|
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||||
|
tagIds: tagIds.length > 0 ? tagIds : undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ApiResponse<typeof data> = {
|
const response: ApiResponse<typeof data> = {
|
||||||
|
|||||||
248
app/api/geo/batch/route.ts
Normal file
248
app/api/geo/batch/route.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import type { ApiResponse } from '@/lib/types';
|
||||||
|
|
||||||
|
interface IpLocationData {
|
||||||
|
ip: string;
|
||||||
|
country_name: string;
|
||||||
|
country_code: string;
|
||||||
|
city: string;
|
||||||
|
region: string;
|
||||||
|
continent_code: string;
|
||||||
|
continent_name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple in-memory cache on the server side to reduce API calls
|
||||||
|
const serverCache: Record<string, IpLocationData> = {};
|
||||||
|
|
||||||
|
// Cache for IPs that have repeatedly failed to resolve
|
||||||
|
const failedIPsCache: Record<string, { attempts: number, lastAttempt: number }> = {};
|
||||||
|
|
||||||
|
// Cache expiration time (30 days in milliseconds)
|
||||||
|
const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
|
// Max attempts to fetch an IP before considering it permanently failed
|
||||||
|
const MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
// Retry timeout - how long to wait before trying a failed IP again (24 hours)
|
||||||
|
const RETRY_TIMEOUT = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP has failed too many times and should be skipped
|
||||||
|
*/
|
||||||
|
function shouldSkipIP(ip: string): boolean {
|
||||||
|
if (!failedIPsCache[ip]) return false;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Skip if max attempts reached
|
||||||
|
if (failedIPsCache[ip].attempts >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if last attempt was recent
|
||||||
|
if (now - failedIPsCache[ip].lastAttempt < RETRY_TIMEOUT) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an IP as failed
|
||||||
|
*/
|
||||||
|
function markIPAsFailed(ip: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (failedIPsCache[ip]) {
|
||||||
|
failedIPsCache[ip] = {
|
||||||
|
attempts: failedIPsCache[ip].attempts + 1,
|
||||||
|
lastAttempt: now
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
failedIPsCache[ip] = {
|
||||||
|
attempts: 1,
|
||||||
|
lastAttempt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location data for a single IP using ipapi.co
|
||||||
|
*/
|
||||||
|
async function fetchIpLocation(ip: string): Promise<IpLocationData | null> {
|
||||||
|
try {
|
||||||
|
// Skip this IP if it has failed too many times
|
||||||
|
if (shouldSkipIP(ip)) {
|
||||||
|
console.log(`[Server] Skipping blacklisted IP: ${ip}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check server cache first
|
||||||
|
const now = Date.now();
|
||||||
|
if (serverCache[ip] && (now - serverCache[ip].timestamp) < CACHE_EXPIRATION) {
|
||||||
|
return serverCache[ip];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay to avoid rate limiting (100 requests per minute max)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600)); // ~100 req/min = 1 req per 600ms
|
||||||
|
|
||||||
|
const response = await fetch(`https://ipapi.co/${ip}/json/`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Error fetching location for IP ${ip}: ${response.statusText}`);
|
||||||
|
markIPAsFailed(ip);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
console.error(`Error fetching location for IP ${ip}: ${data.reason}`);
|
||||||
|
markIPAsFailed(ip);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failed status if successful
|
||||||
|
if (failedIPsCache[ip]) {
|
||||||
|
delete failedIPsCache[ip];
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationData: IpLocationData = {
|
||||||
|
ip: data.ip,
|
||||||
|
country_name: data.country_name || 'Unknown',
|
||||||
|
country_code: data.country_code || 'UN',
|
||||||
|
city: data.city || 'Unknown',
|
||||||
|
region: data.region || 'Unknown',
|
||||||
|
continent_code: data.continent_code || 'UN',
|
||||||
|
continent_name: getContinentName(data.continent_code) || 'Unknown',
|
||||||
|
latitude: data.latitude || 0,
|
||||||
|
longitude: data.longitude || 0,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
serverCache[ip] = locationData;
|
||||||
|
|
||||||
|
return locationData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching location for IP ${ip}:`, error);
|
||||||
|
markIPAsFailed(ip);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get continent name from continent code
|
||||||
|
*/
|
||||||
|
function getContinentName(code?: string): string {
|
||||||
|
if (!code) return 'Unknown';
|
||||||
|
|
||||||
|
const continents: Record<string, string> = {
|
||||||
|
'AF': 'Africa',
|
||||||
|
'AN': 'Antarctica',
|
||||||
|
'AS': 'Asia',
|
||||||
|
'EU': 'Europe',
|
||||||
|
'NA': 'North America',
|
||||||
|
'OC': 'Oceania',
|
||||||
|
'SA': 'South America'
|
||||||
|
};
|
||||||
|
|
||||||
|
return continents[code] || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API route handler for batch IP location lookups
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { ips } = await request.json();
|
||||||
|
|
||||||
|
if (!ips || !Array.isArray(ips) || ips.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid or empty IP list'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit batch size to 50 IPs to prevent abuse
|
||||||
|
const ipList = ips.slice(0, 50);
|
||||||
|
const results: Record<string, IpLocationData | null> = {};
|
||||||
|
|
||||||
|
// Filter out IPs that should be skipped
|
||||||
|
const validIPs = ipList.filter(ip => {
|
||||||
|
if (typeof ip !== 'string' || !ip.trim()) return false;
|
||||||
|
if (isPrivateIP(ip)) {
|
||||||
|
results[ip] = getPrivateIPData(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (shouldSkipIP(ip)) {
|
||||||
|
console.log(`[Server] Skipping blacklisted IP: ${ip}`);
|
||||||
|
results[ip] = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process remaining IPs sequentially to respect rate limits
|
||||||
|
for (const ip of validIPs) {
|
||||||
|
results[ip] = await fetchIpLocation(ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse<Record<string, IpLocationData | null>> = {
|
||||||
|
success: true,
|
||||||
|
data: results
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Batch IP lookup error:', error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP is a private/local address
|
||||||
|
*/
|
||||||
|
function isPrivateIP(ip: string): boolean {
|
||||||
|
return (
|
||||||
|
ip.startsWith('10.') ||
|
||||||
|
ip.startsWith('192.168.') ||
|
||||||
|
ip.startsWith('172.16.') ||
|
||||||
|
ip.startsWith('172.17.') ||
|
||||||
|
ip.startsWith('172.18.') ||
|
||||||
|
ip.startsWith('172.19.') ||
|
||||||
|
ip.startsWith('172.20.') ||
|
||||||
|
ip.startsWith('172.21.') ||
|
||||||
|
ip.startsWith('172.22.') ||
|
||||||
|
ip.startsWith('127.') ||
|
||||||
|
ip === 'localhost' ||
|
||||||
|
ip === '::1'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate location data for private IP addresses
|
||||||
|
*/
|
||||||
|
function getPrivateIPData(ip: string): IpLocationData {
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
country_name: 'Local Network',
|
||||||
|
country_code: 'LO',
|
||||||
|
city: 'Local',
|
||||||
|
region: 'Local',
|
||||||
|
continent_code: 'LO',
|
||||||
|
continent_name: 'Local',
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ export interface TimeSeriesData {
|
|||||||
|
|
||||||
export interface GeoData {
|
export interface GeoData {
|
||||||
location: string;
|
location: string;
|
||||||
|
area: string;
|
||||||
visits: number;
|
visits: number;
|
||||||
visitors: number;
|
visitors: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { GeoData } from '@/app/api/types';
|
import { GeoData } from '@/app/api/types';
|
||||||
|
import { getLocationsFromIPs } from '@/app/utils/ipLocation';
|
||||||
|
|
||||||
interface GeoAnalyticsProps {
|
interface GeoAnalyticsProps {
|
||||||
data: GeoData[];
|
data: GeoData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interface for IP location data in our cache
|
||||||
|
interface IpLocationDetail {
|
||||||
|
country: string;
|
||||||
|
city: string;
|
||||||
|
region: string;
|
||||||
|
continent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for IP location data
|
||||||
|
interface LocationCache {
|
||||||
|
[key: string]: IpLocationDetail;
|
||||||
|
}
|
||||||
|
|
||||||
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<'country' | 'city' | 'region' | 'continent'>('country');
|
||||||
|
const [locationCache, setLocationCache] = useState<LocationCache>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
// Track IPs that failed to resolve
|
||||||
|
const [failedIPs, setFailedIPs] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 安全地格式化数字
|
// 安全地格式化数字
|
||||||
const formatNumber = (value: number | undefined | null): string => {
|
const formatNumber = (value: number | undefined | null): string => {
|
||||||
if (value === undefined || value === null) return '0';
|
if (value === undefined || value === null) return '0';
|
||||||
@@ -21,13 +42,179 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
|||||||
|
|
||||||
const sortedData = [...data].sort((a, b) => (b.visits || 0) - (a.visits || 0));
|
const sortedData = [...data].sort((a, b) => (b.visits || 0) - (a.visits || 0));
|
||||||
|
|
||||||
|
// Handle tab selection - only change local view mode
|
||||||
|
const handleViewModeChange = (mode: 'country' | 'city' | 'region' | 'continent') => {
|
||||||
|
setViewMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load location data for all IPs when the data changes
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLocations = async () => {
|
||||||
|
if (sortedData.length === 0) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
const tempCache: LocationCache = {...locationCache};
|
||||||
|
const tempFailedIPs = new Set(failedIPs);
|
||||||
|
|
||||||
|
// Get all unique IPs that aren't already in the cache and haven't failed
|
||||||
|
const uniqueIPs = [...new Set(sortedData.map(item => item.location))].filter(ip =>
|
||||||
|
ip &&
|
||||||
|
ip !== 'Unknown' &&
|
||||||
|
!tempCache[ip] &&
|
||||||
|
!tempFailedIPs.has(ip)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueIPs.length === 0) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use batch lookup for better performance
|
||||||
|
const batchResults = await getLocationsFromIPs(uniqueIPs);
|
||||||
|
|
||||||
|
// Convert results to our cache format
|
||||||
|
for (const [ip, data] of Object.entries(batchResults)) {
|
||||||
|
if (data) {
|
||||||
|
tempCache[ip] = {
|
||||||
|
country: data.country_name,
|
||||||
|
city: data.city,
|
||||||
|
region: data.region,
|
||||||
|
continent: data.continent_name
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Mark as failed
|
||||||
|
tempFailedIPs.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocationCache(tempCache);
|
||||||
|
setFailedIPs(tempFailedIPs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching location data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLocations();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Get the appropriate location value based on the current view mode
|
||||||
|
const getLocationValue = (item: GeoData): string => {
|
||||||
|
const ip = item.location || '';
|
||||||
|
|
||||||
|
// If there's no IP or it's "Unknown", return that value
|
||||||
|
if (!ip || ip === 'Unknown') return 'Unknown';
|
||||||
|
|
||||||
|
// If IP failed to resolve, return Unknown
|
||||||
|
if (failedIPs.has(ip)) {
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return from cache if available
|
||||||
|
if (locationCache[ip]) {
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'country':
|
||||||
|
return locationCache[ip].country || 'Unknown';
|
||||||
|
case 'city':
|
||||||
|
return locationCache[ip].city || 'Unknown';
|
||||||
|
case 'region':
|
||||||
|
return locationCache[ip].region || 'Unknown';
|
||||||
|
case 'continent':
|
||||||
|
return locationCache[ip].continent || 'Unknown';
|
||||||
|
default:
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return placeholder if not in cache yet
|
||||||
|
return `Loading...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the appropriate area value based on the current view mode
|
||||||
|
const getAreaValue = (item: GeoData): string => {
|
||||||
|
const ip = item.location || '';
|
||||||
|
|
||||||
|
// If there's no IP or it's "Unknown", return empty string
|
||||||
|
if (!ip || ip === 'Unknown' || failedIPs.has(ip)) return '';
|
||||||
|
|
||||||
|
// Return from cache if available
|
||||||
|
if (locationCache[ip]) {
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'country':
|
||||||
|
// For country view, show the continent as area
|
||||||
|
return locationCache[ip].continent || '';
|
||||||
|
case 'city':
|
||||||
|
// For city view, show the country and region
|
||||||
|
return `${locationCache[ip].country}, ${locationCache[ip].region}`;
|
||||||
|
case 'region':
|
||||||
|
// For region view, show the country
|
||||||
|
return locationCache[ip].country || '';
|
||||||
|
case 'continent':
|
||||||
|
// For continent view, no additional area needed
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty if not in cache yet
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Tabs for geographic levels */}
|
||||||
|
<div className="flex border-b mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewModeChange('country')}
|
||||||
|
className={`px-4 py-2 ${viewMode === 'country' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Countries
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewModeChange('city')}
|
||||||
|
className={`px-4 py-2 ${viewMode === 'city' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Cities
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewModeChange('region')}
|
||||||
|
className={`px-4 py-2 ${viewMode === 'region' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Regions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewModeChange('continent')}
|
||||||
|
className={`px-4 py-2 ${viewMode === 'continent' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
Continents
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center items-center py-2 mb-4">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
|
||||||
|
<span className="text-sm text-gray-500">Loading location data...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table with added area column */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Location
|
{viewMode === 'country' ? 'Country' :
|
||||||
|
viewMode === 'city' ? 'City' :
|
||||||
|
viewMode === 'region' ? 'Region' : 'Continent'}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{viewMode === 'country' ? 'Continent' :
|
||||||
|
viewMode === 'city' ? 'Location' :
|
||||||
|
viewMode === 'region' ? 'Country' : 'Area'}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Visits
|
Visits
|
||||||
@@ -41,10 +228,17 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{sortedData.map((item, index) => (
|
{sortedData.length > 0 ? (
|
||||||
|
sortedData.map((item, index) => (
|
||||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{item.location || 'Unknown'}
|
{getLocationValue(item)}
|
||||||
|
{item.location && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">{item.location}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{getAreaValue(item)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{formatNumber(item.visits)}
|
{formatNumber(item.visits)}
|
||||||
@@ -64,9 +258,17 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||||
|
No location data available
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
100
app/components/ipLocationTest.tsx
Normal file
100
app/components/ipLocationTest.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getLocationFromIP } from '@/app/utils/ipLocation';
|
||||||
|
|
||||||
|
interface LocationData {
|
||||||
|
ip: string;
|
||||||
|
country_name: string;
|
||||||
|
country_code: string;
|
||||||
|
city: string;
|
||||||
|
region: string;
|
||||||
|
continent_code: string;
|
||||||
|
continent_name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IpLocationTest() {
|
||||||
|
const [locationData, setLocationData] = useState<LocationData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const testIp = "120.244.39.90";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLocation() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const data = await getLocationFromIP(testIp);
|
||||||
|
setLocationData(data);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchLocation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-white rounded-lg shadow">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">IP Location Test: {testIp}</h2>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center text-gray-500">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
|
||||||
|
Loading location data...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && locationData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Location Data:</h3>
|
||||||
|
<pre className="mt-2 p-4 bg-gray-100 rounded overflow-auto">
|
||||||
|
{JSON.stringify(locationData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="border p-3 rounded">
|
||||||
|
<h4 className="font-medium">Country</h4>
|
||||||
|
<div>{locationData.country_name} ({locationData.country_code})</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-3 rounded">
|
||||||
|
<h4 className="font-medium">City</h4>
|
||||||
|
<div>{locationData.city || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-3 rounded">
|
||||||
|
<h4 className="font-medium">Region</h4>
|
||||||
|
<div>{locationData.region || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-3 rounded">
|
||||||
|
<h4 className="font-medium">Continent</h4>
|
||||||
|
<div>{locationData.continent_name} ({locationData.continent_code})</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border p-3 rounded col-span-2">
|
||||||
|
<h4 className="font-medium">Coordinates</h4>
|
||||||
|
<div>Latitude: {locationData.latitude}, Longitude: {locationData.longitude}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,14 @@ interface Project {
|
|||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
schema_version?: number | null;
|
schema_version?: number | null;
|
||||||
creator_id?: string | null;
|
creator_id?: string | null;
|
||||||
|
team_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加需要的类型定义
|
||||||
|
interface ProjectWithTeam {
|
||||||
|
project_id: string;
|
||||||
|
projects: Project;
|
||||||
|
teams?: { name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectSelector component with multi-select support
|
// ProjectSelector component with multi-select support
|
||||||
@@ -27,12 +35,14 @@ export function ProjectSelector({
|
|||||||
className,
|
className,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
teamId,
|
teamId,
|
||||||
|
teamIds,
|
||||||
}: {
|
}: {
|
||||||
value?: string | string[];
|
value?: string | string[];
|
||||||
onChange?: (projectId: string | string[]) => void;
|
onChange?: (projectId: string | string[]) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
teamId?: string; // Optional team ID to filter projects by team
|
teamId?: string; // Optional team ID to filter projects by team
|
||||||
|
teamIds?: string[]; // Optional array of team IDs to filter projects by multiple teams
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -41,6 +51,16 @@ export function ProjectSelector({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const selectorRef = useRef<HTMLDivElement>(null);
|
const selectorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Normalize team IDs to ensure we're always working with an array
|
||||||
|
const effectiveTeamIds = React.useMemo(() => {
|
||||||
|
if (teamIds && teamIds.length > 0) {
|
||||||
|
return teamIds;
|
||||||
|
} else if (teamId) {
|
||||||
|
return [teamId];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [teamId, teamIds]);
|
||||||
|
|
||||||
// Initialize selected projects based on value prop
|
// Initialize selected projects based on value prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -82,25 +102,13 @@ export function ProjectSelector({
|
|||||||
try {
|
try {
|
||||||
const supabase = getSupabaseClient();
|
const supabase = getSupabaseClient();
|
||||||
|
|
||||||
let projectsQuery;
|
if (effectiveTeamIds && effectiveTeamIds.length > 0) {
|
||||||
|
// If team IDs are provided, get projects for those teams
|
||||||
if (teamId) {
|
const { data: projectsData, error: projectsError } = await supabase
|
||||||
// If a teamId is provided, fetch projects for that team
|
|
||||||
projectsQuery = supabase
|
|
||||||
.from('team_projects')
|
.from('team_projects')
|
||||||
.select('project_id, projects:project_id(*)')
|
.select('project_id, projects:project_id(*), teams:team_id(name)')
|
||||||
.eq('team_id', teamId)
|
.in('team_id', effectiveTeamIds)
|
||||||
.is('projects.deleted_at', null);
|
.is('projects.deleted_at', null);
|
||||||
} else {
|
|
||||||
// Otherwise, fetch projects the user is a member of
|
|
||||||
projectsQuery = supabase
|
|
||||||
.from('user_projects')
|
|
||||||
.select('project_id, projects:project_id(*)')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.is('projects.deleted_at', null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: projectsData, error: projectsError } = await projectsQuery;
|
|
||||||
|
|
||||||
if (projectsError) throw projectsError;
|
if (projectsError) throw projectsError;
|
||||||
|
|
||||||
@@ -109,18 +117,76 @@ export function ProjectSelector({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the project data from the query results
|
// Extract projects from response with team info
|
||||||
if (isMounted && projectsData && projectsData.length > 0) {
|
if (isMounted) {
|
||||||
const projectList: Project[] = [];
|
const projectList: Project[] = [];
|
||||||
|
|
||||||
for (const item of projectsData) {
|
for (const item of projectsData as ProjectWithTeam[]) {
|
||||||
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
|
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
|
||||||
projectList.push(item.projects as Project);
|
const project = item.projects as Project;
|
||||||
|
if (item.teams && 'name' in item.teams) {
|
||||||
|
project.team_name = item.teams.name;
|
||||||
|
}
|
||||||
|
// Avoid duplicate projects from different teams
|
||||||
|
if (!projectList.some(p => p.id === project.id)) {
|
||||||
|
projectList.push(project);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setProjects(projectList);
|
setProjects(projectList);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// If no team IDs, get all user's projects
|
||||||
|
const { data: projectsData, error: projectsError } = await supabase
|
||||||
|
.from('user_projects')
|
||||||
|
.select('project_id, projects:project_id(*)')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.is('projects.deleted_at', null);
|
||||||
|
|
||||||
|
if (projectsError) throw projectsError;
|
||||||
|
|
||||||
|
if (!projectsData || projectsData.length === 0) {
|
||||||
|
if (isMounted) setProjects([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch team info for these projects
|
||||||
|
const projectIds = projectsData.map(item => item.project_id);
|
||||||
|
|
||||||
|
// Get team info for each project
|
||||||
|
const { data: teamProjectsData, error: teamProjectsError } = await supabase
|
||||||
|
.from('team_projects')
|
||||||
|
.select('project_id, teams:team_id(name)')
|
||||||
|
.in('project_id', projectIds);
|
||||||
|
|
||||||
|
if (teamProjectsError) throw teamProjectsError;
|
||||||
|
|
||||||
|
// Create project ID to team name mapping
|
||||||
|
const projectTeamMap: Record<string, string> = {};
|
||||||
|
if (teamProjectsData) {
|
||||||
|
teamProjectsData.forEach(item => {
|
||||||
|
if (item.teams && typeof item.teams === 'object' && 'name' in item.teams) {
|
||||||
|
projectTeamMap[item.project_id] = (item.teams as { name: string }).name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract projects with team names
|
||||||
|
if (isMounted && projectsData) {
|
||||||
|
const projectList: Project[] = [];
|
||||||
|
|
||||||
|
for (const item of projectsData) {
|
||||||
|
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
|
||||||
|
const project = item.projects as Project;
|
||||||
|
project.team_name = projectTeamMap[project.id];
|
||||||
|
projectList.push(project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjects(projectList);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load projects');
|
setError(err instanceof Error ? err.message : 'Failed to load projects');
|
||||||
@@ -153,7 +219,7 @@ export function ProjectSelector({
|
|||||||
isMounted = false;
|
isMounted = false;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [teamId]);
|
}, [effectiveTeamIds]);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (!loading && !error && projects.length > 0) {
|
if (!loading && !error && projects.length > 0) {
|
||||||
@@ -274,6 +340,11 @@ export function ProjectSelector({
|
|||||||
>
|
>
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span className="font-medium">{project.name}</span>
|
<span className="font-medium">{project.name}</span>
|
||||||
|
{project.team_name && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{project.team_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<span className="text-xs text-gray-500 truncate max-w-[250px]">
|
<span className="text-xs text-gray-500 truncate max-w-[250px]">
|
||||||
{project.description}
|
{project.description}
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ export function TagSelector({
|
|||||||
className,
|
className,
|
||||||
multiple = false,
|
multiple = false,
|
||||||
teamId,
|
teamId,
|
||||||
tagType,
|
teamIds,
|
||||||
}: {
|
}: {
|
||||||
value?: string | string[];
|
value?: string | string[];
|
||||||
onChange?: (tagId: string | string[]) => void;
|
onChange?: (tagIds: string | string[]) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
teamId?: string; // Optional team ID to filter tags by team
|
teamId?: string; // Optional single team ID
|
||||||
tagType?: string; // Optional tag type for filtering
|
teamIds?: string[]; // Optional array of team IDs
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -46,18 +46,57 @@ export function TagSelector({
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const selectorRef = useRef<HTMLDivElement>(null);
|
const selectorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Initialize selected tags based on value prop
|
// Normalize team IDs to ensure we're always working with an array
|
||||||
|
const effectiveTeamIds = React.useMemo(() => {
|
||||||
|
if (teamIds && teamIds.length > 0) {
|
||||||
|
return teamIds;
|
||||||
|
} else if (teamId) {
|
||||||
|
return [teamId];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [teamId, teamIds]);
|
||||||
|
|
||||||
|
// 标签名称与ID的映射函数
|
||||||
|
const getTagIdByName = (name: string): string | undefined => {
|
||||||
|
const tag = tags.find(t => t.name === name);
|
||||||
|
return tag?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagNameById = (id: string): string | undefined => {
|
||||||
|
const tag = tags.find(t => t.id === id);
|
||||||
|
return tag?.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从标签名称转换为标签ID
|
||||||
|
const nameToId = (nameOrNames: string | string[] | undefined): string[] => {
|
||||||
|
if (!nameOrNames) return [];
|
||||||
|
if (Array.isArray(nameOrNames)) {
|
||||||
|
return nameOrNames
|
||||||
|
.map(name => getTagIdByName(name))
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
}
|
||||||
|
const id = getTagIdByName(nameOrNames);
|
||||||
|
return id ? [id] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从标签ID转换为标签名称
|
||||||
|
const idToName = (idOrIds: string | string[] | undefined): string[] => {
|
||||||
|
if (!idOrIds) return [];
|
||||||
|
if (Array.isArray(idOrIds)) {
|
||||||
|
return idOrIds
|
||||||
|
.map(id => getTagNameById(id))
|
||||||
|
.filter((name): name is string => !!name);
|
||||||
|
}
|
||||||
|
const name = getTagNameById(idOrIds);
|
||||||
|
return name ? [name] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化已选择的标签 - 从传入的名称转换为ID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value) {
|
if (tags.length > 0 && value) {
|
||||||
if (Array.isArray(value)) {
|
setSelectedIds(nameToId(value));
|
||||||
setSelectedIds(value);
|
|
||||||
} else {
|
|
||||||
setSelectedIds(value ? [value] : []);
|
|
||||||
}
|
}
|
||||||
} else {
|
}, [value, tags]);
|
||||||
setSelectedIds([]);
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
// Add click outside listener to close dropdown
|
// Add click outside listener to close dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,13 +129,8 @@ export function TagSelector({
|
|||||||
let query = supabase.from('tags').select('*').is('deleted_at', null);
|
let query = supabase.from('tags').select('*').is('deleted_at', null);
|
||||||
|
|
||||||
// Filter by team if teamId is provided
|
// Filter by team if teamId is provided
|
||||||
if (teamId) {
|
if (effectiveTeamIds) {
|
||||||
query = query.eq('team_id', teamId);
|
query = query.in('team_id', effectiveTeamIds);
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by tag type if provided
|
|
||||||
if (tagType) {
|
|
||||||
query = query.eq('type', tagType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: tagsData, error: tagsError } = await query;
|
const { data: tagsData, error: tagsError } = await query;
|
||||||
@@ -141,7 +175,7 @@ export function TagSelector({
|
|||||||
isMounted = false;
|
isMounted = false;
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [teamId, tagType]);
|
}, [effectiveTeamIds]);
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (!loading && !error && tags.length > 0) {
|
if (!loading && !error && tags.length > 0) {
|
||||||
@@ -167,8 +201,10 @@ export function TagSelector({
|
|||||||
|
|
||||||
setSelectedIds(newSelected);
|
setSelectedIds(newSelected);
|
||||||
|
|
||||||
|
// 传递标签名称而不是ID
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(multiple ? newSelected : newSelected[0] || '');
|
const tagNames = idToName(newSelected);
|
||||||
|
onChange(multiple ? tagNames : tagNames[0] || '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,8 +212,11 @@ export function TagSelector({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newSelected = selectedIds.filter(id => id !== tagId);
|
const newSelected = selectedIds.filter(id => id !== tagId);
|
||||||
setSelectedIds(newSelected);
|
setSelectedIds(newSelected);
|
||||||
|
|
||||||
|
// 传递标签名称而不是ID
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(multiple ? newSelected : newSelected[0] || '');
|
const tagNames = idToName(newSelected);
|
||||||
|
onChange(multiple ? tagNames : tagNames[0] || '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,6 +253,7 @@ export function TagSelector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 根据已选择的ID筛选出已选择的标签
|
||||||
const selectedTags = tags.filter(tag => selectedIds.includes(tag.id));
|
const selectedTags = tags.filter(tag => selectedIds.includes(tag.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
10
app/ip-test/page.tsx
Normal file
10
app/ip-test/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import IpLocationTest from '../components/ipLocationTest';
|
||||||
|
|
||||||
|
export default function IpTestPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-4 max-w-4xl">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">IP to Location Test</h1>
|
||||||
|
<IpLocationTest />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
356
app/page.tsx
356
app/page.tsx
@@ -36,6 +36,7 @@ interface Event {
|
|||||||
link_id?: string;
|
link_id?: string;
|
||||||
link_slug?: string;
|
link_slug?: string;
|
||||||
link_tags?: string;
|
link_tags?: string;
|
||||||
|
ip_address?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期函数
|
// 格式化日期函数
|
||||||
@@ -98,6 +99,7 @@ const extractEventInfo = (event: Event) => {
|
|||||||
eventType: event.event_type || '-',
|
eventType: event.event_type || '-',
|
||||||
visitorId: event.visitor_id?.substring(0, 8) || '-',
|
visitorId: event.visitor_id?.substring(0, 8) || '-',
|
||||||
referrer: eventAttrs?.referrer || '-',
|
referrer: eventAttrs?.referrer || '-',
|
||||||
|
ipAddress: event.ip_address || '-',
|
||||||
location: event.country ? (event.city ? `${event.city}, ${event.country}` : event.country) : '-',
|
location: event.country ? (event.city ? `${event.city}, ${event.country}` : event.country) : '-',
|
||||||
device: event.device_type || '-',
|
device: event.device_type || '-',
|
||||||
browser: event.browser || '-',
|
browser: event.browser || '-',
|
||||||
@@ -124,6 +126,11 @@ export default function HomePage() {
|
|||||||
// 添加标签选择状态 - 使用数组支持多选
|
// 添加标签选择状态 - 使用数组支持多选
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 添加分页状态
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const [pageSize, setPageSize] = useState<number>(10);
|
||||||
|
const [totalEvents, setTotalEvents] = useState<number>(0);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [summary, setSummary] = useState<EventsSummary | null>(null);
|
const [summary, setSummary] = useState<EventsSummary | null>(null);
|
||||||
@@ -145,7 +152,9 @@ export default function HomePage() {
|
|||||||
const baseUrl = '/api/events';
|
const baseUrl = '/api/events';
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startTime,
|
startTime,
|
||||||
endTime
|
endTime,
|
||||||
|
page: currentPage.toString(),
|
||||||
|
pageSize: pageSize.toString()
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加团队ID参数 - 支持多个团队
|
// 添加团队ID参数 - 支持多个团队
|
||||||
@@ -197,6 +206,15 @@ export default function HomePage() {
|
|||||||
setGeoData(geoData.data);
|
setGeoData(geoData.data);
|
||||||
setDeviceData(deviceData.data);
|
setDeviceData(deviceData.data);
|
||||||
setEvents(eventsData.data || []);
|
setEvents(eventsData.data || []);
|
||||||
|
|
||||||
|
// 设置总事件数量用于分页
|
||||||
|
if (eventsData.meta) {
|
||||||
|
// 确保将total转换为数字,无论它是字符串还是数字
|
||||||
|
const totalCount = parseInt(String(eventsData.meta.total), 10);
|
||||||
|
if (!isNaN(totalCount)) {
|
||||||
|
setTotalEvents(totalCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
setError(err instanceof Error ? err.message : 'An error occurred while fetching data');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -205,7 +223,7 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagIds]);
|
}, [dateRange, selectedTeamIds, selectedProjectIds, selectedTagIds, currentPage, pageSize]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -230,7 +248,18 @@ export default function HomePage() {
|
|||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||||
<TeamSelector
|
<TeamSelector
|
||||||
value={selectedTeamIds}
|
value={selectedTeamIds}
|
||||||
onChange={(value) => setSelectedTeamIds(Array.isArray(value) ? value : [value])}
|
onChange={(value) => {
|
||||||
|
const newTeamIds = Array.isArray(value) ? value : [value];
|
||||||
|
|
||||||
|
// Check if team selection has changed
|
||||||
|
if (JSON.stringify(newTeamIds) !== JSON.stringify(selectedTeamIds)) {
|
||||||
|
// Clear project selection when team changes
|
||||||
|
setSelectedProjectIds([]);
|
||||||
|
|
||||||
|
// Update team selection
|
||||||
|
setSelectedTeamIds(newTeamIds);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-[250px]"
|
className="w-[250px]"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
/>
|
/>
|
||||||
@@ -239,14 +268,14 @@ export default function HomePage() {
|
|||||||
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
|
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
|
||||||
className="w-[250px]"
|
className="w-[250px]"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined}
|
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
|
||||||
/>
|
/>
|
||||||
<TagSelector
|
<TagSelector
|
||||||
value={selectedTagIds}
|
value={selectedTagIds}
|
||||||
onChange={(value) => setSelectedTagIds(Array.isArray(value) ? value : [value])}
|
onChange={(value) => setSelectedTagIds(Array.isArray(value) ? value : [value])}
|
||||||
className="w-[250px]"
|
className="w-[250px]"
|
||||||
multiple={true}
|
multiple={true}
|
||||||
teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined}
|
teamIds={selectedTeamIds.length > 0 ? selectedTeamIds : undefined}
|
||||||
/>
|
/>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
@@ -322,11 +351,11 @@ export default function HomePage() {
|
|||||||
{selectedTagIds.length === 1 ? 'Tag filter:' : 'Tags filter:'}
|
{selectedTagIds.length === 1 ? 'Tag filter:' : 'Tags filter:'}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedTagIds.map(tagId => (
|
{selectedTagIds.map(tagName => (
|
||||||
<span key={tagId} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
<span key={tagName} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
|
||||||
{tagId}
|
{tagName}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedTagIds(selectedTagIds.filter(id => id !== tagId))}
|
onClick={() => setSelectedTagIds(selectedTagIds.filter(name => name !== tagName))}
|
||||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -345,7 +374,37 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 事件列表部分 - 现在放在最上面 */}
|
{/* 仪表板内容 - 现在放在事件列表之后 */}
|
||||||
|
<>
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">Total Events</h3>
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
|
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
|
||||||
|
</p>
|
||||||
|
</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">
|
||||||
|
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
|
||||||
|
</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">
|
||||||
|
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
|
||||||
|
</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">
|
||||||
|
{summary.averageTimeSpent?.toFixed(1) || '0'}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden mb-8">
|
<div className="bg-white rounded-lg shadow overflow-hidden mb-8">
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b border-gray-200">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Events</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recent Events</h2>
|
||||||
@@ -376,6 +435,9 @@ export default function HomePage() {
|
|||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Team/Project
|
Team/Project
|
||||||
</th>
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
IP/Location
|
||||||
|
</th>
|
||||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Device Info
|
Device Info
|
||||||
</th>
|
</th>
|
||||||
@@ -433,6 +495,18 @@ export default function HomePage() {
|
|||||||
<div className="font-medium">{info.teamName}</div>
|
<div className="font-medium">{info.teamName}</div>
|
||||||
<div className="text-xs text-gray-400 mt-1">{info.projectName}</div>
|
<div className="text-xs text-gray-400 mt-1">{info.projectName}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-xs inline-flex items-center mb-1">
|
||||||
|
<span className="font-medium">IP:</span>
|
||||||
|
<span className="ml-1">{info.ipAddress}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs inline-flex items-center">
|
||||||
|
<span className="font-medium">Location:</span>
|
||||||
|
<span className="ml-1">{info.location}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs inline-flex items-center mb-1">
|
<span className="text-xs inline-flex items-center mb-1">
|
||||||
@@ -462,38 +536,217 @@ export default function HomePage() {
|
|||||||
No events found
|
No events found
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 分页控件 - 删除totalEvents > 0条件,改为events.length > 0 */}
|
||||||
|
{!loading && events.length > 0 && (
|
||||||
|
<div className="px-6 py-4 flex items-center justify-between border-t border-gray-200">
|
||||||
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className={`relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md ${
|
||||||
|
currentPage === 1
|
||||||
|
? 'text-gray-300 bg-gray-50'
|
||||||
|
: 'text-gray-700 bg-white hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => (currentPage < Math.ceil(totalEvents / pageSize)) ? prev + 1 : prev)}
|
||||||
|
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
|
||||||
|
className={`ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md ${
|
||||||
|
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
|
||||||
|
? 'text-gray-300 cursor-not-allowed'
|
||||||
|
: 'text-gray-700 bg-white hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Showing <span className="font-medium">{events.length > 0 ? ((currentPage - 1) * pageSize) + 1 : 0}</span> to <span className="font-medium">{events.length > 0 ? ((currentPage - 1) * pageSize) + events.length : 0}</span> of{' '}
|
||||||
|
<span className="font-medium">{totalEvents}</span> results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-4">
|
||||||
|
<select
|
||||||
|
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number(e.target.value));
|
||||||
|
setCurrentPage(1); // 重置到第一页
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="5">5 / page</option>
|
||||||
|
<option value="10">10 / page</option>
|
||||||
|
<option value="20">20 / page</option>
|
||||||
|
<option value="50">50 / page</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 仪表板内容 - 现在放在事件列表之后 */}
|
{/* 添加直接跳转到指定页的输入框 */}
|
||||||
<>
|
<div className="mr-4 flex items-center">
|
||||||
{summary && (
|
<span className="text-sm text-gray-700 mr-2">Go to:</span>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
<input
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
type="number"
|
||||||
<h3 className="text-sm font-medium text-gray-500">Total Events</h3>
|
min="1"
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
max={Math.max(1, Math.ceil(totalEvents / pageSize))}
|
||||||
{typeof summary.totalEvents === 'number' ? summary.totalEvents.toLocaleString() : summary.totalEvents}
|
value={currentPage}
|
||||||
</p>
|
onChange={(e) => {
|
||||||
|
const page = parseInt(e.target.value);
|
||||||
|
if (!isNaN(page) && page >= 1 && page <= Math.ceil(totalEvents / pageSize)) {
|
||||||
|
setCurrentPage(page);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
const page = parseInt(input.value);
|
||||||
|
if (!isNaN(page) && page >= 1 && page <= Math.ceil(totalEvents / pageSize)) {
|
||||||
|
setCurrentPage(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-16 px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 ml-2">
|
||||||
|
of {Math.max(1, Math.ceil(totalEvents / pageSize))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h3 className="text-sm font-medium text-gray-500">Unique Visitors</h3>
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
{/* 首页按钮 */}
|
||||||
{typeof summary.uniqueVisitors === 'number' ? summary.uniqueVisitors.toLocaleString() : summary.uniqueVisitors}
|
<button
|
||||||
</p>
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className={`relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium ${
|
||||||
|
currentPage === 1
|
||||||
|
? 'text-gray-300 cursor-not-allowed'
|
||||||
|
: 'text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="sr-only">First</span>
|
||||||
|
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 010 1.414zm-6 0a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 011.414 1.414L5.414 10l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 上一页按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className={`relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium ${
|
||||||
|
currentPage === 1
|
||||||
|
? 'text-gray-300 cursor-not-allowed'
|
||||||
|
: 'text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Previous</span>
|
||||||
|
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 页码按钮 */}
|
||||||
|
{(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalEvents / pageSize));
|
||||||
|
const pageNumbers = [];
|
||||||
|
|
||||||
|
// 如果总页数小于等于7,显示所有页码
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
pageNumbers.push(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 总是显示首页
|
||||||
|
pageNumbers.push(1);
|
||||||
|
|
||||||
|
// 根据当前页显示中间页码
|
||||||
|
if (currentPage <= 3) {
|
||||||
|
// 当前页靠近开始
|
||||||
|
pageNumbers.push(2, 3, 4);
|
||||||
|
pageNumbers.push('ellipsis1');
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
// 当前页靠近结束
|
||||||
|
pageNumbers.push('ellipsis1');
|
||||||
|
pageNumbers.push(totalPages - 3, totalPages - 2, totalPages - 1);
|
||||||
|
} else {
|
||||||
|
// 当前页在中间
|
||||||
|
pageNumbers.push('ellipsis1');
|
||||||
|
pageNumbers.push(currentPage - 1, currentPage, currentPage + 1);
|
||||||
|
pageNumbers.push('ellipsis2');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总是显示尾页
|
||||||
|
pageNumbers.push(totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageNumbers.map((pageNum, idx) => {
|
||||||
|
if (pageNum === 'ellipsis1' || pageNum === 'ellipsis2') {
|
||||||
|
return (
|
||||||
|
<div key={`ellipsis-${idx}`} className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
|
||||||
|
...
|
||||||
</div>
|
</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">
|
|
||||||
{typeof summary.totalConversions === 'number' ? summary.totalConversions.toLocaleString() : summary.totalConversions}
|
return (
|
||||||
</p>
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => setCurrentPage(Number(pageNum))}
|
||||||
|
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||||
|
currentPage === pageNum
|
||||||
|
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||||
|
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 下一页按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(prev => (currentPage < Math.ceil(totalEvents / pageSize)) ? prev + 1 : prev)}
|
||||||
|
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
|
||||||
|
className={`relative inline-flex items-center px-2 py-2 border border-gray-300 bg-white text-sm font-medium ${
|
||||||
|
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
|
||||||
|
? 'text-gray-300 cursor-not-allowed'
|
||||||
|
: 'text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Next</span>
|
||||||
|
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 尾页按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(Math.ceil(totalEvents / pageSize))}
|
||||||
|
disabled={currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize}
|
||||||
|
className={`relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium ${
|
||||||
|
currentPage >= Math.ceil(totalEvents / pageSize) || events.length < pageSize
|
||||||
|
? 'text-gray-300 cursor-not-allowed'
|
||||||
|
: 'text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Last</span>
|
||||||
|
<svg className="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M4.293 15.707a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L8.586 10 4.293 14.293a1 1 0 000 1.414zm6 0a1 1 0 001.414 0l5-5a1 1 0 000-1.414l-5-5a1 1 0 00-1.414 1.414L15.586 10l-4.293 4.293a1 1 0 000 1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</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">
|
|
||||||
{summary.averageTimeSpent?.toFixed(1) || '0'}s
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white 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 mb-4">Event Trends</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Event Trends</h2>
|
||||||
@@ -509,7 +762,40 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<div className="bg-white 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 mb-4">Geographic Distribution</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Geographic Distribution</h2>
|
||||||
<GeoAnalytics data={geoData} />
|
<GeoAnalytics
|
||||||
|
data={geoData}
|
||||||
|
onViewModeChange={(mode) => {
|
||||||
|
// 构建查询参数
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
startTime: format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||||
|
endTime: format(dateRange.to, "yyyy-MM-dd'T'HH:mm:ss'Z'"),
|
||||||
|
groupBy: mode
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加其他筛选参数
|
||||||
|
if (selectedTeamIds.length > 0) {
|
||||||
|
selectedTeamIds.forEach(id => params.append('teamId', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedProjectIds.length > 0) {
|
||||||
|
selectedProjectIds.forEach(id => params.append('projectId', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTagIds.length > 0) {
|
||||||
|
selectedTagIds.forEach(id => params.append('tagId', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新地理位置数据
|
||||||
|
fetch(`/api/events/geo?${params}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
setGeoData(data.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Failed to fetch geo data:', error));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
484
app/utils/ipLocation.ts
Normal file
484
app/utils/ipLocation.ts
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
interface IpLocationData {
|
||||||
|
ip: string;
|
||||||
|
country_name: string;
|
||||||
|
country_code: string;
|
||||||
|
city: string;
|
||||||
|
region: string;
|
||||||
|
continent_code: string;
|
||||||
|
continent_name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
timestamp?: number; // When this data was fetched
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory cache
|
||||||
|
let locationCache: Record<string, IpLocationData> = {};
|
||||||
|
|
||||||
|
// Blacklist for IPs that failed to resolve multiple times
|
||||||
|
let failedIPs: Record<string, { attempts: number, lastAttempt: number }> = {};
|
||||||
|
|
||||||
|
// Cache expiration time (30 days in milliseconds)
|
||||||
|
const CACHE_EXPIRATION = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Max retries for a failed IP
|
||||||
|
const MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
// Retry timeout (24 hours in milliseconds)
|
||||||
|
const RETRY_TIMEOUT = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Max number of IPs to batch in a single request
|
||||||
|
const MAX_BATCH_SIZE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize cache from localStorage
|
||||||
|
*/
|
||||||
|
const initializeCache = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load location cache
|
||||||
|
const cachedData = localStorage.getItem('ip-location-cache');
|
||||||
|
if (cachedData) {
|
||||||
|
const parsedCache = JSON.parse(cachedData);
|
||||||
|
|
||||||
|
// Filter out expired entries
|
||||||
|
const now = Date.now();
|
||||||
|
const validEntries = Object.entries(parsedCache).filter(([, data]) => {
|
||||||
|
const entry = data as IpLocationData;
|
||||||
|
return entry.timestamp && now - entry.timestamp < CACHE_EXPIRATION;
|
||||||
|
});
|
||||||
|
|
||||||
|
locationCache = Object.fromEntries(validEntries) as Record<string, IpLocationData>;
|
||||||
|
console.log(`Loaded ${validEntries.length} IP locations from cache`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load failed IPs
|
||||||
|
const failedIPsData = localStorage.getItem('ip-location-blacklist');
|
||||||
|
if (failedIPsData) {
|
||||||
|
const parsedFailedIPs = JSON.parse(failedIPsData);
|
||||||
|
|
||||||
|
// Filter out expired blacklist entries
|
||||||
|
const now = Date.now();
|
||||||
|
const validFailedEntries = Object.entries(parsedFailedIPs).filter(([, data]) => {
|
||||||
|
const entry = data as { attempts: number, lastAttempt: number };
|
||||||
|
// Keep entries that have max attempts or haven't timed out yet
|
||||||
|
return entry.attempts >= MAX_RETRY_ATTEMPTS ||
|
||||||
|
now - entry.lastAttempt < RETRY_TIMEOUT;
|
||||||
|
});
|
||||||
|
|
||||||
|
failedIPs = Object.fromEntries(validFailedEntries) as Record<string, { attempts: number, lastAttempt: number }>;
|
||||||
|
console.log(`Loaded ${validFailedEntries.length} blacklisted IPs`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load IP location cache:', error);
|
||||||
|
// Reset cache if corrupted
|
||||||
|
localStorage.removeItem('ip-location-cache');
|
||||||
|
localStorage.removeItem('ip-location-blacklist');
|
||||||
|
locationCache = {};
|
||||||
|
failedIPs = {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save cache to localStorage
|
||||||
|
*/
|
||||||
|
const saveCache = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ip-location-cache', JSON.stringify(locationCache));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save IP location cache:', error);
|
||||||
|
|
||||||
|
// If localStorage is full, clear old entries
|
||||||
|
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||||
|
// Clear older entries - keep newest 100
|
||||||
|
const entries = Object.entries(locationCache)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const timestampA = (a[1].timestamp || 0);
|
||||||
|
const timestampB = (b[1].timestamp || 0);
|
||||||
|
return timestampB - timestampA;
|
||||||
|
})
|
||||||
|
.slice(0, 100);
|
||||||
|
|
||||||
|
locationCache = Object.fromEntries(entries);
|
||||||
|
localStorage.setItem('ip-location-cache', JSON.stringify(locationCache));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save failed IPs to localStorage
|
||||||
|
*/
|
||||||
|
const saveFailedIPs = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save IP blacklist:', error);
|
||||||
|
|
||||||
|
// If localStorage is full, limit the size
|
||||||
|
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||||
|
// Keep only IPs with max attempts
|
||||||
|
const entries = Object.entries(failedIPs)
|
||||||
|
.filter(([, data]) => data.attempts >= MAX_RETRY_ATTEMPTS);
|
||||||
|
|
||||||
|
failedIPs = Object.fromEntries(entries);
|
||||||
|
localStorage.setItem('ip-location-blacklist', JSON.stringify(failedIPs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP is a private/local address
|
||||||
|
*/
|
||||||
|
const isPrivateIP = (ip: string): boolean => {
|
||||||
|
return (
|
||||||
|
ip.startsWith('10.') ||
|
||||||
|
ip.startsWith('192.168.') ||
|
||||||
|
ip.startsWith('172.16.') ||
|
||||||
|
ip.startsWith('172.17.') ||
|
||||||
|
ip.startsWith('172.18.') ||
|
||||||
|
ip.startsWith('172.19.') ||
|
||||||
|
ip.startsWith('172.20.') ||
|
||||||
|
ip.startsWith('172.21.') ||
|
||||||
|
ip.startsWith('172.22.') ||
|
||||||
|
ip.startsWith('127.') ||
|
||||||
|
ip === 'localhost' ||
|
||||||
|
ip === '::1'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IP should be skipped (blacklisted)
|
||||||
|
*/
|
||||||
|
const shouldSkipIP = (ip: string): boolean => {
|
||||||
|
// If not in failed list, don't skip
|
||||||
|
if (!failedIPs[ip]) return false;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// If reached max attempts, skip
|
||||||
|
if (failedIPs[ip].attempts >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hasn't been long enough since last attempt, skip
|
||||||
|
if (now - failedIPs[ip].lastAttempt < RETRY_TIMEOUT) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, we can try again
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark IP as failed
|
||||||
|
*/
|
||||||
|
const markIPAsFailed = (ip: string): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (failedIPs[ip]) {
|
||||||
|
failedIPs[ip] = {
|
||||||
|
attempts: failedIPs[ip].attempts + 1,
|
||||||
|
lastAttempt: now
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
failedIPs[ip] = {
|
||||||
|
attempts: 1,
|
||||||
|
lastAttempt: now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFailedIPs();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location data for a single IP address
|
||||||
|
*/
|
||||||
|
const fetchSingleIP = async (ip: string): Promise<IpLocationData | null> => {
|
||||||
|
// Skip blacklisted IPs
|
||||||
|
if (shouldSkipIP(ip)) {
|
||||||
|
console.log(`Skipping blacklisted IP: ${ip}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://ipapi.co/${ip}/json/`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Error fetching location for IP ${ip}: ${response.statusText}`);
|
||||||
|
markIPAsFailed(ip);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
console.error(`Error fetching location for IP ${ip}: ${data.reason}`);
|
||||||
|
markIPAsFailed(ip);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failed attempts if successful
|
||||||
|
if (failedIPs[ip]) {
|
||||||
|
delete failedIPs[ip];
|
||||||
|
saveFailedIPs();
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationData: IpLocationData = {
|
||||||
|
ip: data.ip,
|
||||||
|
country_name: data.country_name || 'Unknown',
|
||||||
|
country_code: data.country_code || 'UN',
|
||||||
|
city: data.city || 'Unknown',
|
||||||
|
region: data.region || 'Unknown',
|
||||||
|
continent_code: data.continent_code || 'UN',
|
||||||
|
continent_name: getContinentName(data.continent_code) || 'Unknown',
|
||||||
|
latitude: data.latitude || 0,
|
||||||
|
longitude: data.longitude || 0,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
return locationData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching location for IP ${ip}:`, error);
|
||||||
|
markIPAsFailed(ip);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch process multiple IPs at once using our own API endpoint
|
||||||
|
* This is a placeholder - we'll create a server API route for this
|
||||||
|
*/
|
||||||
|
const fetchBatchIPs = async (ips: string[]): Promise<Record<string, IpLocationData | null>> => {
|
||||||
|
try {
|
||||||
|
// Filter out blacklisted IPs
|
||||||
|
const validIPs = ips.filter(ip => !shouldSkipIP(ip));
|
||||||
|
|
||||||
|
if (validIPs.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/geo/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ips: validIPs }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Batch request failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
|
||||||
|
// Mark failed IPs from results
|
||||||
|
for (const [ip, data] of Object.entries(results.data)) {
|
||||||
|
if (!data) {
|
||||||
|
markIPAsFailed(ip);
|
||||||
|
} else if (failedIPs[ip]) {
|
||||||
|
// Reset failed attempts if successful
|
||||||
|
delete failedIPs[ip];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFailedIPs();
|
||||||
|
return results.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in batch IP lookup:', error);
|
||||||
|
|
||||||
|
// Fallback to individual requests
|
||||||
|
const results: Record<string, IpLocationData | null> = {};
|
||||||
|
for (const ip of ips) {
|
||||||
|
// Skip blacklisted IPs
|
||||||
|
if (shouldSkipIP(ip)) {
|
||||||
|
results[ip] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delays between requests to avoid rate limiting
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
results[ip] = await fetchSingleIP(ip);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle private IP addresses
|
||||||
|
*/
|
||||||
|
const getPrivateIPData = (ip: string): IpLocationData => ({
|
||||||
|
ip,
|
||||||
|
country_name: 'Local Network',
|
||||||
|
country_code: 'LO',
|
||||||
|
city: 'Local',
|
||||||
|
region: 'Local',
|
||||||
|
continent_code: 'LO',
|
||||||
|
continent_name: 'Local',
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an IP address to location information
|
||||||
|
* Individual lookup for a single IP
|
||||||
|
*/
|
||||||
|
export async function getLocationFromIP(ip: string): Promise<IpLocationData | null> {
|
||||||
|
// Initialize cache from localStorage if needed
|
||||||
|
if (Object.keys(locationCache).length === 0) {
|
||||||
|
initializeCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle private IP addresses
|
||||||
|
if (isPrivateIP(ip)) {
|
||||||
|
const privateIPData = getPrivateIPData(ip);
|
||||||
|
locationCache[ip] = privateIPData;
|
||||||
|
return privateIPData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip blacklisted IPs
|
||||||
|
if (shouldSkipIP(ip)) {
|
||||||
|
console.log(`Skipping blacklisted IP: ${ip}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return from cache if available and not expired
|
||||||
|
if (locationCache[ip]) {
|
||||||
|
const cachedData = locationCache[ip];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached data if not expired
|
||||||
|
if (cachedData.timestamp && now - cachedData.timestamp < CACHE_EXPIRATION) {
|
||||||
|
return cachedData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch new data
|
||||||
|
const locationData = await fetchSingleIP(ip);
|
||||||
|
|
||||||
|
// Save to cache if successful
|
||||||
|
if (locationData) {
|
||||||
|
locationCache[ip] = locationData;
|
||||||
|
saveCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
return locationData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch lookup for multiple IPs at once
|
||||||
|
* More efficient than calling getLocationFromIP multiple times
|
||||||
|
*/
|
||||||
|
export async function getLocationsFromIPs(ips: string[]): Promise<Record<string, IpLocationData | null>> {
|
||||||
|
// Initialize cache from localStorage if needed
|
||||||
|
if (Object.keys(locationCache).length === 0) {
|
||||||
|
initializeCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out IPs that are already in cache and not expired
|
||||||
|
const now = Date.now();
|
||||||
|
const cachedResults: Record<string, IpLocationData> = {};
|
||||||
|
const ipsToFetch: string[] = [];
|
||||||
|
|
||||||
|
for (const ip of ips) {
|
||||||
|
// Handle private IPs
|
||||||
|
if (isPrivateIP(ip)) {
|
||||||
|
cachedResults[ip] = getPrivateIPData(ip);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip blacklisted IPs
|
||||||
|
if (shouldSkipIP(ip)) {
|
||||||
|
console.log(`Skipping blacklisted IP: ${ip}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (locationCache[ip] && locationCache[ip].timestamp &&
|
||||||
|
now - locationCache[ip].timestamp < CACHE_EXPIRATION) {
|
||||||
|
cachedResults[ip] = locationCache[ip];
|
||||||
|
} else {
|
||||||
|
ipsToFetch.push(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all IPs were cached or blacklisted, return immediately
|
||||||
|
if (ipsToFetch.length === 0) {
|
||||||
|
return cachedResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process IPs in batches to avoid overwhelming the API
|
||||||
|
const results: Record<string, IpLocationData | null> = { ...cachedResults };
|
||||||
|
|
||||||
|
// Process in smaller batches (e.g., 10 IPs at a time)
|
||||||
|
for (let i = 0; i < ipsToFetch.length; i += MAX_BATCH_SIZE) {
|
||||||
|
const batchIPs = ipsToFetch.slice(i, i + MAX_BATCH_SIZE);
|
||||||
|
|
||||||
|
// Batch request
|
||||||
|
const batchResults = await fetchBatchIPs(batchIPs);
|
||||||
|
|
||||||
|
// Update results and cache
|
||||||
|
for (const [ip, data] of Object.entries(batchResults)) {
|
||||||
|
results[ip] = data;
|
||||||
|
if (data) {
|
||||||
|
locationCache[ip] = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated cache
|
||||||
|
saveCache();
|
||||||
|
|
||||||
|
// Add delay between batches
|
||||||
|
if (i + MAX_BATCH_SIZE < ipsToFetch.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get continent name from continent code
|
||||||
|
*/
|
||||||
|
function getContinentName(code?: string): string {
|
||||||
|
if (!code) return 'Unknown';
|
||||||
|
|
||||||
|
const continents: Record<string, string> = {
|
||||||
|
'AF': 'Africa',
|
||||||
|
'AN': 'Antarctica',
|
||||||
|
'AS': 'Asia',
|
||||||
|
'EU': 'Europe',
|
||||||
|
'NA': 'North America',
|
||||||
|
'OC': 'Oceania',
|
||||||
|
'SA': 'South America'
|
||||||
|
};
|
||||||
|
|
||||||
|
return continents[code] || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location information based on view mode
|
||||||
|
*/
|
||||||
|
export function getLocationByType(
|
||||||
|
locationData: IpLocationData | null,
|
||||||
|
viewMode: 'country' | 'city' | 'region' | 'continent'
|
||||||
|
): string {
|
||||||
|
if (!locationData) return 'Unknown';
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'country':
|
||||||
|
return locationData.country_name || 'Unknown';
|
||||||
|
case 'city':
|
||||||
|
return locationData.city || 'Unknown';
|
||||||
|
case 'region':
|
||||||
|
return locationData.region || 'Unknown';
|
||||||
|
case 'continent':
|
||||||
|
return locationData.continent_name || 'Unknown';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ export enum TimeGranularity {
|
|||||||
MONTH = 'month'
|
MONTH = 'month'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取事件列表
|
// 事件查询参数类型
|
||||||
export async function getEvents(params: {
|
export interface EventsQueryParams {
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
@@ -19,11 +19,17 @@ export async function getEvents(params: {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc';
|
||||||
}): Promise<{ events: Event[]; total: number }> {
|
}
|
||||||
|
|
||||||
|
// 获取事件列表
|
||||||
|
export async function getEvents(params: EventsQueryParams): 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);
|
||||||
@@ -67,7 +73,7 @@ export async function getEventsSummary(params: {
|
|||||||
const baseQuery = `
|
const baseQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
count() as totalEvents,
|
count() as totalEvents,
|
||||||
uniq(visitor_id) as uniqueVisitors,
|
uniq(ip_address) as uniqueVisitors,
|
||||||
countIf(event_type = 'conversion') as totalConversions,
|
countIf(event_type = 'conversion') as totalConversions,
|
||||||
avg(time_spent_sec) as averageTimeSpent,
|
avg(time_spent_sec) as averageTimeSpent,
|
||||||
|
|
||||||
@@ -175,6 +181,9 @@ export async function getTimeSeriesData(params: {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
granularity: 'hour' | 'day' | 'week' | 'month';
|
granularity: 'hour' | 'day' | 'week' | 'month';
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
}): Promise<TimeSeriesData[]> {
|
}): Promise<TimeSeriesData[]> {
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
|
|
||||||
@@ -190,7 +199,7 @@ export async function getTimeSeriesData(params: {
|
|||||||
SELECT
|
SELECT
|
||||||
toStartOfInterval(event_time, INTERVAL ${interval}) as timestamp,
|
toStartOfInterval(event_time, INTERVAL ${interval}) as timestamp,
|
||||||
count() as events,
|
count() as events,
|
||||||
uniq(visitor_id) as visitors,
|
uniq(ip_address) as visitors,
|
||||||
countIf(event_type = 'conversion') as conversions
|
countIf(event_type = 'conversion') as conversions
|
||||||
FROM events
|
FROM events
|
||||||
${filter}
|
${filter}
|
||||||
@@ -206,23 +215,33 @@ export async function getGeoAnalytics(params: {
|
|||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
groupBy?: 'country' | 'city';
|
groupBy?: 'country' | 'city' | 'region' | 'continent';
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
}): Promise<GeoData[]> {
|
}): Promise<GeoData[]> {
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
const groupByField = 'ip_address'; // 暂时按 IP 地址分组
|
|
||||||
|
// Choose grouping field based on selected view
|
||||||
|
let groupByField = 'country';
|
||||||
|
if (params.groupBy === 'city') groupByField = 'city';
|
||||||
|
else if (params.groupBy === 'region') groupByField = 'region';
|
||||||
|
else if (params.groupBy === 'continent') groupByField = 'continent';
|
||||||
|
else if (!params.groupBy) groupByField = 'ip_address'; // Default to IP address if no groupBy is specified
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
${groupByField} as location,
|
COALESCE(${groupByField}, 'Unknown') as location,
|
||||||
|
'' as area, /* Area column - empty for now */
|
||||||
count() as visits,
|
count() as visits,
|
||||||
uniq(visitor_id) as visitors,
|
uniq(ip_address) as visitors,
|
||||||
count() * 100.0 / sum(count()) OVER () as percentage
|
count() * 100.0 / sum(count()) OVER () as percentage
|
||||||
FROM events
|
FROM events
|
||||||
${filter}
|
${filter}
|
||||||
GROUP BY ${groupByField}
|
GROUP BY location
|
||||||
HAVING location != ''
|
HAVING location != ''
|
||||||
ORDER BY visits DESC
|
ORDER BY visits DESC
|
||||||
LIMIT 10
|
LIMIT 20
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return executeQuery<GeoData>(query);
|
return executeQuery<GeoData>(query);
|
||||||
@@ -233,6 +252,9 @@ export async function getDeviceAnalytics(params: {
|
|||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
linkId?: string;
|
linkId?: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
projectIds?: string[];
|
||||||
|
tagIds?: string[];
|
||||||
}): Promise<DeviceAnalytics> {
|
}): Promise<DeviceAnalytics> {
|
||||||
const filter = buildFilter(params);
|
const filter = buildFilter(params);
|
||||||
|
|
||||||
|
|||||||
@@ -28,95 +28,108 @@ function buildDateFilter(startTime?: string, endTime?: string): string {
|
|||||||
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
export function buildFilter(params: Partial<EventsQueryParams>): string {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
|
|
||||||
// 时间范围过滤
|
// 添加日期过滤条件
|
||||||
if (params.startTime || params.endTime) {
|
if (params.startTime || params.endTime) {
|
||||||
const dateFilter = buildDateFilter(params.startTime, params.endTime).replace('WHERE ', '');
|
const dateFilter = buildDateFilter(params.startTime, params.endTime);
|
||||||
if (dateFilter) {
|
if (dateFilter) {
|
||||||
filters.push(dateFilter);
|
filters.push(dateFilter.replace('WHERE ', ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 事件类型过滤
|
// 添加事件类型过滤条件
|
||||||
if (params.eventType) {
|
if (params.eventType) {
|
||||||
filters.push(`event_type = '${params.eventType}'`);
|
filters.push(`event_type = '${params.eventType}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 链接ID过滤
|
// 添加链接ID过滤条件
|
||||||
if (params.linkId) {
|
if (params.linkId) {
|
||||||
filters.push(`link_id = '${params.linkId}'`);
|
filters.push(`link_id = '${params.linkId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 链接短码过滤
|
// 添加链接Slug过滤条件
|
||||||
if (params.linkSlug) {
|
if (params.linkSlug) {
|
||||||
filters.push(`link_slug = '${params.linkSlug}'`);
|
filters.push(`link_slug = '${params.linkSlug}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用户ID过滤
|
// 添加用户ID过滤条件
|
||||||
if (params.userId) {
|
if (params.userId) {
|
||||||
filters.push(`user_id = '${params.userId}'`);
|
filters.push(`user_id = '${params.userId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 团队ID过滤 - 支持多选
|
// 添加团队ID过滤条件
|
||||||
if (params.teamIds && params.teamIds.length > 0) {
|
if (params.teamId) {
|
||||||
const teamValues = params.teamIds.map(id => `'${id}'`).join(', ');
|
|
||||||
filters.push(`team_id IN (${teamValues})`);
|
|
||||||
} else if (params.teamId) {
|
|
||||||
filters.push(`team_id = '${params.teamId}'`);
|
filters.push(`team_id = '${params.teamId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 项目ID过滤 - 支持多选
|
// 处理多个团队ID
|
||||||
if (params.projectIds && params.projectIds.length > 0) {
|
if (params.teamIds && params.teamIds.length > 0) {
|
||||||
const projectValues = params.projectIds.map(id => `'${id}'`).join(', ');
|
filters.push(`team_id IN (${params.teamIds.map(id => `'${id}'`).join(', ')})`);
|
||||||
filters.push(`project_id IN (${projectValues})`);
|
}
|
||||||
} else if (params.projectId) {
|
|
||||||
|
// 添加项目ID过滤条件
|
||||||
|
if (params.projectId) {
|
||||||
filters.push(`project_id = '${params.projectId}'`);
|
filters.push(`project_id = '${params.projectId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标签ID过滤 - 支持多选
|
// 处理多个项目ID
|
||||||
|
if (params.projectIds && params.projectIds.length > 0) {
|
||||||
|
filters.push(`project_id IN (${params.projectIds.map(id => `'${id}'`).join(', ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理标签过滤 - 使用LIKE来匹配标签字符串
|
||||||
if (params.tagIds && params.tagIds.length > 0) {
|
if (params.tagIds && params.tagIds.length > 0) {
|
||||||
// 假设我们在link_tags字段存储标签ID的JSON数组
|
const tagConditions = params.tagIds.map(tag =>
|
||||||
const tagConditions = params.tagIds.map(id => `arrayExists(x -> x = '${id}', JSONExtractArrayRaw(link_tags))`);
|
`link_tags LIKE '%${tag}%'`
|
||||||
|
);
|
||||||
filters.push(`(${tagConditions.join(' OR ')})`);
|
filters.push(`(${tagConditions.join(' OR ')})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
return filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建分页
|
// 构建分页条件
|
||||||
export function buildPagination(page?: number, pageSize?: number): string {
|
export function buildPagination(page: number = 1, pageSize: number = 20): string {
|
||||||
const limit = pageSize || 20;
|
const offset = (page - 1) * pageSize;
|
||||||
const offset = ((page || 1) - 1) * limit;
|
return `LIMIT ${pageSize} OFFSET ${offset}`;
|
||||||
return `LIMIT ${limit} OFFSET ${offset}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建排序
|
// 构建排序条件
|
||||||
export function buildOrderBy(sortBy?: string, sortOrder?: 'asc' | 'desc'): string {
|
export function buildOrderBy(sortBy: string = 'event_time', sortOrder: string = 'desc'): string {
|
||||||
if (!sortBy) {
|
return `ORDER BY ${sortBy} ${sortOrder}`;
|
||||||
return 'ORDER BY event_time DESC';
|
|
||||||
}
|
|
||||||
return `ORDER BY ${sortBy} ${sortOrder || 'desc'}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行查询并处理错误
|
// 执行查询
|
||||||
export async function executeQuery<T>(query: string): Promise<T[]> {
|
export async function executeQuery(query: string) {
|
||||||
|
console.log('执行查询:', query); // 查询日志
|
||||||
try {
|
try {
|
||||||
const resultSet = await clickhouse.query({
|
const resultSet = await clickhouse.query({
|
||||||
query,
|
query,
|
||||||
format: 'JSONEachRow'
|
format: 'JSONEachRow',
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = await resultSet.json<T>();
|
const rows = await resultSet.json();
|
||||||
return Array.isArray(rows) ? rows : [rows];
|
return rows;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('ClickHouse query error:', error);
|
console.error('查询执行错误:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行查询并返回单个结果
|
// 执行返回单一结果的查询
|
||||||
export async function executeQuerySingle<T>(query: string): Promise<T | null> {
|
export async function executeQuerySingle(query: string) {
|
||||||
const results = await executeQuery<T>(query);
|
console.log('执行单一结果查询:', query); // 查询日志
|
||||||
return results.length > 0 ? results[0] : null;
|
try {
|
||||||
|
const resultSet = await clickhouse.query({
|
||||||
|
query,
|
||||||
|
format: 'JSONEachRow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await resultSet.json();
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('单一结果查询执行错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default clickhouse;
|
export default clickhouse;
|
||||||
10
lib/types.ts
10
lib/types.ts
@@ -24,6 +24,16 @@ export enum DeviceType {
|
|||||||
OTHER = 'other'
|
OTHER = 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标签类型
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
type?: string;
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
team_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// API 响应基础接口
|
// API 响应基础接口
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user