This commit is contained in:
2025-04-02 21:36:13 +08:00
parent 95f230b996
commit 63f434fd93
5 changed files with 149 additions and 54 deletions

View File

@@ -11,11 +11,14 @@ export async function GET(request: NextRequest) {
const projectIds = searchParams.getAll('projectId'); const projectIds = searchParams.getAll('projectId');
const tagIds = searchParams.getAll('tagId'); 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, teamIds: teamIds.length > 0 ? teamIds : undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined, projectIds: projectIds.length > 0 ? projectIds : undefined,

View File

@@ -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;

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState } from 'react';
import { GeoData } from '@/app/api/types'; import { GeoData } from '@/app/api/types';
interface GeoAnalyticsProps { interface GeoAnalyticsProps {
@@ -7,6 +8,8 @@ interface GeoAnalyticsProps {
} }
export default function GeoAnalytics({ data }: GeoAnalyticsProps) { export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
const [viewMode, setViewMode] = useState<'country' | 'city' | 'region' | 'continent'>('country');
// 安全地格式化数字 // 安全地格式化数字
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,60 +24,107 @@ 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);
// Only change the local view mode, no callback to parent
};
return ( return (
<div className="overflow-x-auto"> <div>
<table className="min-w-full divide-y divide-gray-200"> {/* Tabs for geographic levels */}
<thead className="bg-gray-50"> <div className="flex border-b mb-6">
<tr> <button
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> onClick={() => handleViewModeChange('country')}
IP Address className={`px-4 py-2 ${viewMode === 'country' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
</th> >
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Countries
Visits </button>
</th> <button
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> onClick={() => handleViewModeChange('city')}
Unique Visitors className={`px-4 py-2 ${viewMode === 'city' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
</th> >
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Cities
% of Total </button>
</th> <button
</tr> onClick={() => handleViewModeChange('region')}
</thead> className={`px-4 py-2 ${viewMode === 'region' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
<tbody className="bg-white divide-y divide-gray-200"> >
{sortedData.length > 0 ? ( Regions
sortedData.map((item, index) => ( </button>
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}> <button
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> onClick={() => handleViewModeChange('continent')}
{item.location || 'Unknown'} className={`px-4 py-2 ${viewMode === 'continent' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-500'}`}
</td> >
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> Continents
{formatNumber(item.visits)} </button>
</td> </div>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visitors)} {/* Table with added area column */}
</td> <div className="overflow-x-auto">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <table className="min-w-full divide-y divide-gray-200">
<div className="flex items-center"> <thead className="bg-gray-50">
<div className="w-24 bg-gray-200 rounded-full h-2"> <tr>
<div <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
className="bg-blue-600 h-2 rounded-full" {viewMode === 'country' ? 'Country' :
style={{ width: `${item.percentage || 0}%` }} 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' ? 'Countries' :
viewMode === 'city' ? 'Cities' :
viewMode === 'region' ? 'Regions' : 'Continents'}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Visits
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Unique Visitors
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
% of Total
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedData.length > 0 ? (
sortedData.map((item, index) => (
<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">
{item.location || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{item.area || ''}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visits)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatNumber(item.visitors)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div className="flex items-center">
<div className="w-24 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${item.percentage || 0}%` }}
/>
</div>
<span className="ml-2">{formatPercent(item.percentage)}%</span>
</div> </div>
<span className="ml-2">{formatPercent(item.percentage)}%</span> </td>
</div> </tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
No location data available
</td> </td>
</tr> </tr>
)) )}
) : ( </tbody>
<tr> </table>
<td colSpan={4} className="px-6 py-4 text-center text-sm text-gray-500"> </div>
No IP data available
</td>
</tr>
)}
</tbody>
</table>
</div> </div>
); );
} }

View File

@@ -762,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>

View File

@@ -215,16 +215,24 @@ export async function getGeoAnalytics(params: {
startTime?: string; startTime?: string;
endTime?: string; endTime?: string;
linkId?: string; linkId?: string;
groupBy?: 'country' | 'city' | 'region' | 'continent';
teamIds?: string[]; teamIds?: string[];
projectIds?: string[]; projectIds?: string[];
tagIds?: string[]; tagIds?: string[];
}): Promise<GeoData[]> { }): Promise<GeoData[]> {
const filter = buildFilter(params); const filter = buildFilter(params);
// Use IP address as the grouping field // 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
ip_address as location, COALESCE(${groupByField}, 'Unknown') as location,
'' as area, /* Area column - empty for now */
count() as visits, count() as visits,
uniq(ip_address) as visitors, uniq(ip_address) as visitors,
count() * 100.0 / sum(count()) OVER () as percentage count() * 100.0 / sum(count()) OVER () as percentage