dashboard page good
This commit is contained in:
@@ -35,14 +35,18 @@ export interface TimeSeriesData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GeoData {
|
export interface GeoData {
|
||||||
country: string;
|
location?: string;
|
||||||
region: string;
|
country?: string;
|
||||||
city: string;
|
region?: string;
|
||||||
|
city?: string;
|
||||||
visits: number;
|
visits: number;
|
||||||
uniqueVisitors: number;
|
uniqueVisitors?: number;
|
||||||
|
visitors?: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeviceType = 'mobile' | 'desktop' | 'tablet' | 'other';
|
||||||
|
|
||||||
export interface DeviceAnalytics {
|
export interface DeviceAnalytics {
|
||||||
deviceTypes: {
|
deviceTypes: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -82,4 +86,26 @@ export interface EventsSummary {
|
|||||||
count: number;
|
count: number;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
}[];
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionStats {
|
||||||
|
totalConversions: number;
|
||||||
|
conversionRate: number;
|
||||||
|
averageValue: number;
|
||||||
|
byType: {
|
||||||
|
type: string;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
value: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventFilters {
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
eventType?: string;
|
||||||
|
linkId?: string;
|
||||||
|
linkSlug?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,18 @@ interface DeviceAnalyticsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) {
|
function StatCard({ title, items }: { title: string; items: { name: string; count: number; percentage: number }[] }) {
|
||||||
|
// 安全地格式化数字
|
||||||
|
const formatNumber = (value: number | string | undefined | null): string => {
|
||||||
|
if (value === undefined || value === null) return '0';
|
||||||
|
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 安全地格式化百分比
|
||||||
|
const formatPercent = (value: number | undefined | null): string => {
|
||||||
|
if (value === undefined || value === null) return '0';
|
||||||
|
return value.toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
<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>
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">{title}</h3>
|
||||||
@@ -14,13 +26,13 @@ function StatCard({ title, items }: { title: string; items: { name: string; coun
|
|||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
|
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
<span>{item.name}</span>
|
<span>{item.name || 'Unknown'}</span>
|
||||||
<span>{item.count.toLocaleString()} ({item.percentage.toFixed(1)}%)</span>
|
<span>{formatNumber(item.count)} ({formatPercent(item.percentage)}%)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 h-2 rounded-full"
|
className="bg-blue-500 h-2 rounded-full"
|
||||||
style={{ width: `${item.percentage}%` }}
|
style={{ width: `${item.percentage || 0}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,19 +47,19 @@ export default function DeviceAnalytics({ data }: DeviceAnalyticsProps) {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Device Types"
|
title="Device Types"
|
||||||
items={data.deviceTypes.map(item => ({
|
items={(data.deviceTypes || []).map(item => ({
|
||||||
name: item.type.charAt(0).toUpperCase() + item.type.slice(1),
|
name: item.type ? (item.type.charAt(0).toUpperCase() + item.type.slice(1)) : 'Unknown',
|
||||||
count: item.count,
|
count: item.count,
|
||||||
percentage: item.percentage
|
percentage: item.percentage
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Browsers"
|
title="Browsers"
|
||||||
items={data.browsers}
|
items={data.browsers || []}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Operating Systems"
|
title="Operating Systems"
|
||||||
items={data.operatingSystems}
|
items={data.operatingSystems || []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ interface GeoAnalyticsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
||||||
|
// 安全地格式化数字
|
||||||
|
const formatNumber = (value: any): string => {
|
||||||
|
if (value === undefined || value === null) return '0';
|
||||||
|
return typeof value === 'number' ? value.toLocaleString() : String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 安全地格式化百分比
|
||||||
|
const formatPercent = (value: any): string => {
|
||||||
|
if (value === undefined || value === null) return '0';
|
||||||
|
return typeof value === 'number' ? value.toFixed(2) : String(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
@@ -30,21 +42,21 @@ export default function GeoAnalytics({ data }: GeoAnalyticsProps) {
|
|||||||
{data.map((item, index) => (
|
{data.map((item, index) => (
|
||||||
<tr key={index} className={index % 2 === 0 ? 'bg-white dark:bg-gray-900' : 'bg-gray-50 dark:bg-gray-800'}>
|
<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">
|
<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}
|
{item.city ? `${item.city}, ${item.region}, ${item.country}` : item.region ? `${item.region}, ${item.country}` : item.country || item.location || 'Unknown'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
{item.visits.toLocaleString()}
|
{formatNumber(item.visits)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
{item.uniqueVisitors.toLocaleString()}
|
{formatNumber(item.uniqueVisitors || item.visitors)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-2">{item.percentage.toFixed(2)}%</span>
|
<span className="mr-2">{formatPercent(item.percentage)}%</span>
|
||||||
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<div className="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500 h-2 rounded-full"
|
className="bg-blue-500 h-2 rounded-full"
|
||||||
style={{ width: `${item.percentage}%` }}
|
style={{ width: `${item.percentage || 0}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
|
LineController,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
@@ -23,6 +24,7 @@ ChartJS.register(
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
PointElement,
|
PointElement,
|
||||||
LineElement,
|
LineElement,
|
||||||
|
LineController,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
@@ -50,13 +52,25 @@ export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
|||||||
|
|
||||||
// 准备数据
|
// 准备数据
|
||||||
const labels = data.map(item => {
|
const labels = data.map(item => {
|
||||||
|
if (!item || !item.timestamp) return '';
|
||||||
const date = new Date(item.timestamp);
|
const date = new Date(item.timestamp);
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventsData = data.map(item => Number(item.events));
|
const eventsData = data.map(item => {
|
||||||
const visitorsData = data.map(item => Number(item.visitors));
|
if (!item || item.events === undefined || item.events === null) return 0;
|
||||||
const conversionsData = data.map(item => Number(item.conversions));
|
return Number(item.events);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visitorsData = data.map(item => {
|
||||||
|
if (!item || item.visitors === undefined || item.visitors === null) return 0;
|
||||||
|
return Number(item.visitors);
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversionsData = data.map(item => {
|
||||||
|
if (!item || item.conversions === undefined || item.conversions === null) return 0;
|
||||||
|
return Number(item.conversions);
|
||||||
|
});
|
||||||
|
|
||||||
// 创建新的图表实例
|
// 创建新的图表实例
|
||||||
chartInstance.current = new ChartJS(ctx, {
|
chartInstance.current = new ChartJS(ctx, {
|
||||||
@@ -144,6 +158,7 @@ export default function TimeSeriesChart({ data }: TimeSeriesChartProps) {
|
|||||||
ticks: {
|
ticks: {
|
||||||
color: 'rgb(156, 163, 175)', // gray-400
|
color: 'rgb(156, 163, 175)', // gray-400
|
||||||
callback: (value: number) => {
|
callback: (value: number) => {
|
||||||
|
if (!value && value !== 0) return '';
|
||||||
if (value >= 1000) {
|
if (value >= 1000) {
|
||||||
return `${(value / 1000).toFixed(1)}k`;
|
return `${(value / 1000).toFixed(1)}k`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { addDays, format } from 'date-fns';
|
import { addDays, format } from 'date-fns';
|
||||||
import { DateRangePicker } from '../components/ui/DateRangePicker';
|
import { DateRangePicker } from '../components/ui/DateRangePicker';
|
||||||
import { Event } from '../api/types';
|
import { Event, EventFilters } from '../api/types';
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
const [dateRange, setDateRange] = useState({
|
const [dateRange, setDateRange] = useState({
|
||||||
@@ -29,6 +29,10 @@ export default function EventsPage() {
|
|||||||
pageSize: 20
|
pageSize: 20
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [summary, setSummary] = useState<any>(null);
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
const lastEventRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const fetchEvents = async (pageNum: number) => {
|
const fetchEvents = async (pageNum: number) => {
|
||||||
try {
|
try {
|
||||||
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
const startTime = format(dateRange.from, "yyyy-MM-dd'T'HH:mm:ss'Z'");
|
||||||
|
|||||||
Reference in New Issue
Block a user