dashboard page good

This commit is contained in:
2025-03-25 21:02:17 +08:00
parent efdfe8bf8e
commit ecf21a812f
5 changed files with 90 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'");