geo tab
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
35
app/page.tsx
35
app/page.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user