274 lines
9.5 KiB
TypeScript
274 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { GeoData } from '@/app/api/types';
|
|
import { getLocationsFromIPs } from '@/app/utils/ipLocation';
|
|
|
|
interface GeoAnalyticsProps {
|
|
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) {
|
|
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 => {
|
|
if (value === undefined || value === null) return '0';
|
|
return value.toLocaleString();
|
|
};
|
|
|
|
// 安全地格式化百分比
|
|
const formatPercent = (value: number | undefined | null): string => {
|
|
if (value === undefined || value === null) return '0';
|
|
return value.toFixed(1);
|
|
};
|
|
|
|
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 (
|
|
<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">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
{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 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">
|
|
{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 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>
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
|
|
No location data available
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|