This commit is contained in:
2025-03-25 20:54:02 +08:00
parent 92d82b18a0
commit efdfe8bf8e
17 changed files with 1553 additions and 235 deletions

View File

@@ -0,0 +1,54 @@
"use client";
import { DeviceAnalytics as DeviceAnalyticsType } from '../../api/types';
interface DeviceAnalyticsProps {
data: DeviceAnalyticsType;
}
function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{title}</h3>
<div className="space-y-4">
{items.map((item, index) => (
<div key={index}>
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
<span>{item.name}</span>
<span>{item.count.toLocaleString()} ({item.percentage.toFixed(1)}%)</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
))}
</div>
</div>
);
}
export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
title="Device Types"
items={data.deviceTypes.map(item => ({
name: item.type.charAt(0).toUpperCase() + item.type.slice(1),
count: item.count,
percentage: item.percentage
}))}
/>
<StatCard
title="Browsers"
items={data.browsers}
/>
<StatCard
title="Operating Systems"
items={data.operatingSystems}
/>
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import { GeoData } from '../../api/types';
interface GeoAnalyticsProps {
data: GeoData[];
}
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Location
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Visits
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Unique Visitors
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Percentage
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{data.map((item, index) => (
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{item.visits.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{item.uniqueVisitors.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div className="flex items-center">
<span className="mr-2">{item.percentage.toFixed(2)}%</span>
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
/>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,169 @@
"use client";
import { useEffect, useRef } from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
ChartData,
ChartOptions,
TooltipItem
} from 'chart.js';
import { TimeSeriesData } from '../../api/types';
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface TimeSeriesChartProps {
data: TimeSeriesData[];
}
export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
const chartRef = useRef<HTMLCanvasElement | null>(null);
const chartInstance = useRef<ChartJS | null>(null);
useEffect(() => {
if (!chartRef.current) return;
// 销毁旧的图表实例
if (chartInstance.current) {
chartInstance.current.destroy();
}
const ctx = chartRef.current.getContext('2d');
if (!ctx) return;
// 准备数据
const labels = data.map(item => {
const date = new Date(item.timestamp);
return date.toLocaleDateString();
});
const eventsData = data.map(item => Number(item.events));
const visitorsData = data.map(item => Number(item.visitors));
const conversionsData = data.map(item => Number(item.conversions));
// 创建新的图表实例
chartInstance.current = new ChartJS(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Events',
data: eventsData,
borderColor: 'rgb(59, 130, 246)', // blue-500
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Visitors',
data: visitorsData,
borderColor: 'rgb(16, 185, 129)', // green-500
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Conversions',
data: conversionsData,
borderColor: 'rgb(239, 68, 68)', // red-500
backgroundColor: 'rgba(239, 68, 68, 0.1)',
tension: 0.4,
fill: true
}
]
} as ChartData<'line'>,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
color: 'rgb(156, 163, 175)' // gray-400
}
},
tooltip: {
mode: 'index',
intersect: false,
backgroundColor: 'rgb(31, 41, 55)', // gray-800
titleColor: 'rgb(229, 231, 235)', // gray-200
bodyColor: 'rgb(229, 231, 235)', // gray-200
borderColor: 'rgb(75, 85, 99)', // gray-600
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
title: (items: TooltipItem<'line'>[]) => {
if (items.length > 0) {
const date = new Date(data[items[0].dataIndex].timestamp);
return date.toLocaleDateString();
}
return '';
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
color: 'rgb(156, 163, 175)' // gray-400
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgb(75, 85, 99, 0.1)' // gray-600 with opacity
},
ticks: {
color: 'rgb(156, 163, 175)', // gray-400
callback: (value: number) => {
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}k`;
}
return value;
}
}
}
}
} as ChartOptions<'line'>
});
// 清理函数
return () => {
if (chartInstance.current) {
chartInstance.current.destroy();
}
};
}, [data]);
return (
<canvas ref={chartRef} />
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useState, useEffect } from 'react';
import { format } from 'date-fns';
interface DateRangePickerProps {
value: {
from: Date;
to: Date;
};
onChange: (range: { from: Date; to: Date }) => void;
}
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
const [from, setFrom] = useState(format(value.from, 'yyyy-MM-dd'));
const [to, setTo] = useState(format(value.to, 'yyyy-MM-dd'));
useEffect(() => {
setFrom(format(value.from, 'yyyy-MM-dd'));
setTo(format(value.to, 'yyyy-MM-dd'));
}, [value]);
const handleFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFrom = e.target.value;
setFrom(newFrom);
onChange({
from: new Date(newFrom),
to: value.to
});
};
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTo = e.target.value;
setTo(newTo);
onChange({
from: value.from,
to: new Date(newTo)
});
};
return (
<div className="flex items-center space-x-4">
<div>
<label htmlFor="from" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
From
</label>
<input
type="date"
id="from"
value={from}
onChange={handleFromChange}
max={to}
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="to" className="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
To
</label>
<input
type="date"
id="to"
value={to}
onChange={handleToChange}
min={from}
className="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md text-sm text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
);
}