Files
shorturl-analytics/app/(app)/links/[id]/page.tsx
2025-03-31 21:21:17 +08:00

1682 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
LinkOverviewData,
ConversionFunnelData,
VisitTrendsData,
LinkPerformanceData,
PlatformDistributionData,
DeviceAnalysisData,
PopularReferrersData,
QrCodeAnalysisData,
DeviceItem,
ReferrerItem,
} from "@/app/api/types";
import { TimeGranularity } from "@/lib/analytics";
import {
PieChart, Pie, ResponsiveContainer, Tooltip, Cell, Legend,
BarChart, Bar, XAxis, YAxis, CartesianGrid, LineChart, Line,
LabelList
} from "recharts";
interface LinkDetails {
id: string;
name: string;
shortUrl: string;
originalUrl: string;
creator: string;
createdAt: string;
visits: number;
visitChange: number;
uniqueVisitors: number;
uniqueVisitorsChange: number;
avgTime: string;
avgTimeChange: number;
conversionRate: number;
conversionChange: number;
status: "active" | "inactive" | "expired";
tags: string[];
}
export default function LinkDetailsPage({
params,
}: {
params: { id: string };
}) {
const router = useRouter();
const [linkId, setLinkId] = useState<string>(params.id);
const [linkDetails, setLinkDetails] = useState<LinkDetails | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<
| "overview"
| "referrers"
| "devices"
| "locations"
| "performance"
| "qrCodes"
>("overview");
// 添加state变量存储分析数据
const [overviewData, setOverviewData] = useState<LinkOverviewData | null>(
null
);
const [funnelData, setFunnelData] = useState<ConversionFunnelData | null>(
null
);
const [trendsData, setTrendsData] = useState<VisitTrendsData | null>(null);
// 添加新的state变量存储新API的数据
const [performanceData, setPerformanceData] =
useState<LinkPerformanceData | null>(null);
const [platformData, setPlatformData] =
useState<PlatformDistributionData | null>(null);
const [deviceData, setDeviceData] = useState<DeviceAnalysisData | null>(null);
const [referrersData, setReferrersData] =
useState<PopularReferrersData | null>(null);
const [qrCodeData, setQrCodeData] = useState<QrCodeAnalysisData | null>(null);
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>(
TimeGranularity.DAY
);
const [dateRange, setDateRange] = useState({
startDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0], // 30天前
endDate: new Date().toISOString().split("T")[0], // 今天
});
// 定义fetchAnalyticsData函数在所有useEffect前
const fetchAnalyticsData = useCallback(async (id: string) => {
try {
// 构建查询参数
const queryParams = new URLSearchParams({
linkId: id,
startDate: dateRange.startDate,
endDate: dateRange.endDate,
});
// 并行获取所有数据
const [
overviewResponse,
funnelResponse,
trendsResponse,
performanceResponse,
platformResponse,
deviceResponse,
referrersResponse,
qrCodeResponse,
] = await Promise.all([
fetch(`/api/analytics/overview?${queryParams}`),
fetch(`/api/analytics/funnel?${queryParams}`),
fetch(
`/api/analytics/trends?${queryParams}&granularity=${timeGranularity}`
),
fetch(`/api/analytics/link-performance?${queryParams}`),
fetch(`/api/analytics/platform-distribution?${queryParams}`),
fetch(`/api/analytics/device-analysis?${queryParams}`),
fetch(`/api/analytics/popular-referrers?${queryParams}`),
fetch(`/api/analytics/qr-code-analysis?${queryParams}`),
]);
// 检查所有响应
if (
!overviewResponse.ok ||
!funnelResponse.ok ||
!trendsResponse.ok ||
!performanceResponse.ok ||
!platformResponse.ok ||
!deviceResponse.ok ||
!referrersResponse.ok ||
!qrCodeResponse.ok
) {
throw new Error("Failed to fetch analytics data");
}
// 解析所有响应数据
const [
overviewResult,
funnelResult,
trendsResult,
performanceResult,
platformResult,
deviceResult,
referrersResult,
qrCodeResult,
] = await Promise.all([
overviewResponse.json(),
funnelResponse.json(),
trendsResponse.json(),
performanceResponse.json(),
platformResponse.json(),
deviceResponse.json(),
referrersResponse.json(),
qrCodeResponse.json(),
]);
// 设置状态
setOverviewData(overviewResult);
setFunnelData(funnelResult);
setTrendsData(trendsResult);
setPerformanceData(performanceResult);
setPlatformData(platformResult);
setDeviceData(deviceResult);
setReferrersData(referrersResult);
setQrCodeData(qrCodeResult);
// 更新链接详情中的统计数据
setLinkDetails((prev) => {
if (!prev) return prev;
return {
...prev,
visits: overviewResult.totalVisits,
uniqueVisitors: overviewResult.uniqueVisitors,
avgTime: formatTime(overviewResult.averageTimeSpent),
conversionRate: funnelResult.conversionRate,
};
});
setLoading(false);
} catch (error) {
console.error("Failed to fetch analytics data:", error);
setLoading(false);
}
}, [dateRange, timeGranularity]);
// 格式化时间(秒转为分钟和秒)
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
// 将 fetchLinkDetails 移到 useEffect 外面
const fetchLinkDetails = useCallback(async (id: string) => {
setLoading(true);
try {
// 获取链接详情
const response = await fetch(`/api/links/${id}/details`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Failed to fetch link details");
}
const details = await response.json();
console.log("Link details:", details); // 添加日志以确认 API 响应
// 将 API 返回的数据映射到组件需要的格式
setLinkDetails({
id: details.link_id || id,
name: details.title || "Untitled Link",
shortUrl: details.short_url || window.location.hostname + "/" + details.link_id,
originalUrl: details.original_url || "",
creator: details.created_by || "Unknown",
createdAt: details.created_at || new Date().toISOString(),
visits: details.visits || 0,
visitChange: details.visit_change || 0,
uniqueVisitors: details.unique_visitors || 0,
uniqueVisitorsChange: details.unique_visitors_change || 0,
avgTime: formatTime(details.avg_time_spent || 0),
avgTimeChange: details.avg_time_change || 0,
conversionRate: details.conversion_rate || 0,
conversionChange: details.conversion_change || 0,
status: details.is_active ? "active" : "inactive",
tags: details.tags || [],
});
setLoading(false);
} catch (error) {
console.error("Failed to fetch link details:", error);
setLoading(false);
}
}, []);
// 获取并设置linkId - 移除异步获取参数的逻辑因为params已经可用
useEffect(() => {
// 直接使用params.id
if (params.id) {
setLinkId(params.id);
fetchLinkDetails(params.id);
fetchAnalyticsData(params.id);
}
}, [params.id, fetchLinkDetails, fetchAnalyticsData]);
// 获取链接详情数据 - 这个 useEffect 可以删除,已合并到上面的 useEffect
// 更新时间粒度并重新获取趋势数据
const updateTimeGranularity = (granularity: TimeGranularity) => {
setTimeGranularity(granularity);
if (linkId) {
const queryParams = new URLSearchParams({
linkId,
startDate: dateRange.startDate,
endDate: dateRange.endDate,
granularity,
});
fetch(`/api/analytics/trends?${queryParams}`)
.then((res) => res.json())
.then((data) => setTrendsData(data))
.catch((err) => console.error("Failed to update trends data:", err));
}
};
// 更新日期范围并重新获取所有数据
const updateDateRange = (startDate: string, endDate: string) => {
setDateRange({ startDate, endDate });
if (linkId) {
fetchAnalyticsData(linkId);
}
};
const goBack = () => {
router.back();
};
// 复制链接到剪贴板
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
// 可以添加一个复制成功的提示
};
// 添加加载和错误处理
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">...</h2>
<p className="text-text-secondary"></p>
</div>
</div>
);
}
if (!linkDetails) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h2 className="text-xl font-semibold mb-2"></h2>
<p className="text-text-secondary"></p>
<Link href="/links" className="mt-4 inline-block text-accent-blue hover:underline">
</Link>
</div>
</div>
);
}
// 只有当linkDetails存在时才渲染详情内容
return (
<div className="container px-4 py-8 mx-auto">
<div className="flex flex-col gap-8">
{/* 顶部导航栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<button
onClick={goBack}
className="flex items-center text-text-secondary hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-5 h-5 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
<span>Back to Links</span>
</button>
</div>
<div className="flex space-x-2">
<button className="px-4 py-2 text-white rounded-lg bg-accent-blue hover:bg-blue-600">
Edit Link
</button>
</div>
</div>
{/* 链接基本信息卡片 */}
<div className="overflow-hidden border rounded-lg shadow-sm bg-card-bg border-card-border">
<div className="flex flex-col gap-6 p-6 md:flex-row">
<div className="flex-1">
<h1 className="mb-2 text-2xl font-bold text-foreground">
{linkDetails.name}
</h1>
<div className="space-y-4">
<div>
<span className="text-xs font-medium uppercase text-text-secondary">
Short URL
</span>
<div className="flex items-center mt-1">
<a
href={`https://${linkDetails.shortUrl}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium break-all text-accent-blue hover:underline"
>
{linkDetails.shortUrl}
</a>
<button
type="button"
className="ml-2 text-text-secondary hover:text-foreground"
onClick={() =>
copyToClipboard(linkDetails.shortUrl)
}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 12h8m0 0v8m0-8h-8"
/>
</svg>
</button>
</div>
</div>
<div>
<span className="text-xs font-medium uppercase text-text-secondary">
Original URL
</span>
<div className="mt-1">
<a
href={linkDetails.originalUrl}
target="_blank"
rel="noopener noreferrer"
className="break-all text-foreground hover:underline"
>
{linkDetails.originalUrl}
</a>
</div>
</div>
</div>
</div>
<div className="md:w-1/3">
<div className="space-y-4">
<div>
<span className="text-xs font-medium uppercase text-text-secondary">
Created By
</span>
<p className="mt-1 text-foreground">
{linkDetails.creator}
</p>
</div>
<div>
<span className="text-xs font-medium uppercase text-text-secondary">
Created At
</span>
<p className="mt-1 text-foreground">
{linkDetails.createdAt}
</p>
</div>
<div>
<span className="text-xs font-medium uppercase text-text-secondary">
Status
</span>
<div className="mt-1">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
${
linkDetails.status === "active"
? "bg-green-500/10 text-accent-green"
: linkDetails.status === "inactive"
? "bg-gray-500/10 text-text-secondary"
: "bg-red-500/10 text-accent-red"
}`}
>
{linkDetails.status ? linkDetails.status.charAt(0).toUpperCase() +
linkDetails.status.slice(1) : 'Unknown'}
</span>
</div>
</div>
{linkDetails.tags && linkDetails.tags.length > 0 && (
<div>
<span className="text-xs font-medium uppercase text-text-secondary">
Tags
</span>
<div className="flex flex-wrap gap-2 mt-1">
{linkDetails.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-blue-500/10 rounded-full text-accent-blue"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* 性能指标卡片 */}
<div className="p-6 border-t border-card-border">
<h4 className="mb-4 text-lg font-medium text-foreground">
Performance Metrics
</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Total Visits */}
<div className="p-4 border rounded-lg bg-card-bg border-card-border">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-text-secondary">
Total Visits
</h5>
<div className="mt-2 flex items-center">
<div className={`flex items-center ${linkDetails?.visitChange >= 0 ? "text-accent-green" : "text-accent-red"}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`w-3 h-3 mr-1 ${linkDetails?.visitChange >= 0 ? "text-accent-green" : "text-accent-red transform rotate-180"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span className="text-xs font-medium">
{Math.abs(linkDetails?.visitChange || 0)}%
</span>
</div>
</div>
</div>
<div className="mt-2">
<p className="text-2xl font-bold text-foreground">
{linkDetails?.visits !== undefined
? linkDetails.visits.toLocaleString()
: '0'}
</p>
</div>
</div>
{/* Unique Visitors */}
<div className="p-4 border rounded-lg bg-card-bg border-card-border">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-text-secondary">
Unique Visitors
</h5>
<div className="mt-2 flex items-center">
<div className={`flex items-center ${linkDetails?.uniqueVisitorsChange >= 0 ? "text-accent-green" : "text-accent-red"}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`w-3 h-3 mr-1 ${linkDetails?.uniqueVisitorsChange >= 0 ? "text-accent-green" : "text-accent-red transform rotate-180"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span className="text-xs font-medium">
{Math.abs(linkDetails?.uniqueVisitorsChange || 0)}%
</span>
</div>
</div>
</div>
<div className="mt-2">
<p className="text-2xl font-bold text-foreground">
{linkDetails?.uniqueVisitors !== undefined
? linkDetails.uniqueVisitors.toLocaleString()
: '0'}
</p>
</div>
</div>
{/* Average Visit Time */}
<div className="p-4 border rounded-lg bg-card-bg border-card-border">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-text-secondary">
Avg. Time
</h5>
<div className="mt-2 flex items-center">
<div className={`flex items-center ${linkDetails?.avgTimeChange >= 0 ? "text-accent-green" : "text-accent-red"}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`w-3 h-3 mr-1 ${linkDetails?.avgTimeChange >= 0 ? "text-accent-green" : "text-accent-red transform rotate-180"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span className="text-xs font-medium">
{Math.abs(linkDetails?.avgTimeChange || 0)}%
</span>
</div>
</div>
</div>
<div className="mt-2">
<p className="text-2xl font-bold text-foreground">
{linkDetails?.avgTime || '0s'}
</p>
</div>
</div>
{/* Conversion Rate */}
<div className="p-4 border rounded-lg bg-card-bg border-card-border">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-text-secondary">
Conversion
</h5>
<div className="mt-2 flex items-center">
<div className={`flex items-center ${linkDetails?.conversionChange >= 0 ? "text-accent-green" : "text-accent-red"}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`w-3 h-3 mr-1 ${linkDetails?.conversionChange >= 0 ? "text-accent-green" : "text-accent-red transform rotate-180"}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
<span className="text-xs font-medium">
{Math.abs(linkDetails?.conversionChange || 0)}%
</span>
</div>
</div>
</div>
<div className="mt-2">
<p className="text-2xl font-bold text-foreground">
{linkDetails?.conversionRate !== undefined
? `${linkDetails.conversionRate}%`
: '0%'}
</p>
</div>
</div>
</div>
</div>
</div>
{/* 图表和详细数据部分 */}
<div className="overflow-hidden border rounded-lg shadow-sm bg-card-bg border-card-border">
<div className="border-b border-card-border">
<nav className="flex -mb-px">
{[
"overview",
"referrers",
"devices",
"locations",
"performance",
"qrCodes",
].map((tab) => (
<button
key={tab}
className={`py-4 px-6 font-medium text-sm border-b-2 ${
activeTab === tab
? "border-accent-blue text-accent-blue"
: "border-transparent text-text-secondary hover:text-foreground hover:border-card-border"
}`}
onClick={() =>
setActiveTab(
tab as
| "overview"
| "referrers"
| "devices"
| "locations"
| "performance"
| "qrCodes"
)
}
>
{tab ? tab.charAt(0).toUpperCase() + tab.slice(1) : ''}
</button>
))}
</nav>
</div>
<div className="p-6">
{activeTab === "overview" && (
<div className="space-y-8">
{/* 日期范围选择器 */}
<div className="flex items-center justify-between">
<div className="text-lg font-medium text-foreground">
Analytics Overview
</div>
<div className="flex space-x-2">
<div className="relative">
<input
type="date"
value={dateRange.startDate}
onChange={(e) =>
updateDateRange(e.target.value, dateRange.endDate)
}
className="px-3 py-2 text-sm border rounded-md bg-card-bg border-card-border"
/>
</div>
<span className="self-center text-text-secondary">
to
</span>
<div className="relative">
<input
type="date"
value={dateRange.endDate}
onChange={(e) =>
updateDateRange(
dateRange.startDate,
e.target.value
)
}
className="px-3 py-2 text-sm border rounded-md bg-card-bg border-card-border"
min={dateRange.startDate}
/>
</div>
</div>
</div>
{/* 设备类型分布 */}
{overviewData && (
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
Device Types
</h3>
{/* 饼图显示 */}
<div className="flex flex-col items-center">
<div className="w-full h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: 'Mobile', value: overviewData.deviceTypes.mobile, fill: "#3498db" },
{ name: 'Desktop', value: overviewData.deviceTypes.desktop, fill: "#2ecc71" },
{ name: 'Tablet', value: overviewData.deviceTypes.tablet, fill: "#f39c12" },
{ name: 'Other', value: overviewData.deviceTypes.other, fill: "#e74c3c" }
].filter(item => item.value > 0)}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
fill="#8884d8"
dataKey="value"
nameKey="name"
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
labelLine={true}
>
{/* 为每种设备类型设置不同的颜色 */}
{[
{ key: "mobile", name: 'Mobile', value: overviewData.deviceTypes.mobile, fill: "#3498db" },
{ key: "desktop", name: 'Desktop', value: overviewData.deviceTypes.desktop, fill: "#2ecc71" },
{ key: "tablet", name: 'Tablet', value: overviewData.deviceTypes.tablet, fill: "#f39c12" },
{ key: "other", name: 'Other', value: overviewData.deviceTypes.other, fill: "#e74c3c" }
]
.filter(item => item.value > 0)
.map(item => (
<Cell key={item.key} fill={item.fill} />
))
}
</Pie>
<Tooltip
formatter={(value) => [`${value} 访问`, '数量']}
separator=": "
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-4 gap-8 mt-4 text-center">
<div>
<div className="text-sm font-medium text-text-secondary">Mobile</div>
<div className="text-lg font-bold text-foreground">{overviewData.deviceTypes.mobile}</div>
<div className="text-xs text-text-secondary">
{overviewData.totalVisits ? Math.round((overviewData.deviceTypes.mobile / overviewData.totalVisits) * 100) : 0}%
</div>
</div>
<div>
<div className="text-sm font-medium text-text-secondary">Desktop</div>
<div className="text-lg font-bold text-foreground">{overviewData.deviceTypes.desktop}</div>
<div className="text-xs text-text-secondary">
{overviewData.totalVisits ? Math.round((overviewData.deviceTypes.desktop / overviewData.totalVisits) * 100) : 0}%
</div>
</div>
<div>
<div className="text-sm font-medium text-text-secondary">Tablet</div>
<div className="text-lg font-bold text-foreground">{overviewData.deviceTypes.tablet}</div>
<div className="text-xs text-text-secondary">
{overviewData.totalVisits ? Math.round((overviewData.deviceTypes.tablet / overviewData.totalVisits) * 100) : 0}%
</div>
</div>
<div>
<div className="text-sm font-medium text-text-secondary">Other</div>
<div className="text-lg font-bold text-foreground">{overviewData.deviceTypes.other}</div>
<div className="text-xs text-text-secondary">
{overviewData.totalVisits ? Math.round((overviewData.deviceTypes.other / overviewData.totalVisits) * 100) : 0}%
</div>
</div>
</div>
</div>
</div>
)}
{/* 转化漏斗 */}
{funnelData && (
<div className="bg-card-bg border border-card-border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-md font-medium text-foreground">
Conversion Funnel
</h3>
<div className="text-sm text-text-secondary">
Overall Conversion Rate:{" "}
<span className="font-medium text-accent-blue">
{(funnelData.conversionRate || 0).toFixed(2)}%
</span>
</div>
</div>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={funnelData.steps}
margin={{ top: 20, right: 30, left: 40, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis
dataKey="name"
type="category"
width={100}
tick={{ fontSize: 14 }}
/>
<Tooltip
formatter={(value: number, name: string, props: any) => [
`${value} (${props.payload.percent.toFixed(1)}%)`,
"Value"
]}
/>
<Bar
dataKey="value"
fill="#3498db"
radius={[0, 4, 4, 0]}
>
<LabelList
dataKey="percent"
position="right"
formatter={(value: number) => `${value.toFixed(1)}%`}
style={{ fill: "#666", fontSize: 12 }}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* 访问趋势 */}
{trendsData && (
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-md text-foreground">
Visit Trends
</h3>
<div className="flex space-x-2">
{Object.values(TimeGranularity).map(
(granularity) => (
<button
key={granularity}
onClick={() =>
updateTimeGranularity(granularity)
}
className={`px-4 py-2 text-sm font-medium rounded-md ${
timeGranularity === granularity
? "bg-accent-blue text-black shadow-sm"
: "bg-card-bg text-text-secondary border border-card-border hover:bg-gray-100"
}`}
>
{granularity.charAt(0).toUpperCase() +
granularity.slice(1)}
</button>
)
)}
</div>
</div>
<div className="flex flex-col space-y-4">
<div className="flex justify-between text-sm text-text-secondary">
<div>
Total Visits:{" "}
<span className="font-medium text-foreground">
{trendsData.totals.visits}
</span>
</div>
<div>
Unique Visitors:{" "}
<span className="font-medium text-foreground">
{trendsData.totals.uniqueVisitors}
</span>
</div>
</div>
{/* 图表展示访问趋势 */}
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={trendsData.trends}
margin={{ top: 20, right: 30, left: 20, bottom: 10 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12 }}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={(value: number) => value.toLocaleString()}
/>
<Tooltip
formatter={(value: number, name: string) => [
value.toLocaleString(),
name === "visits" ? "访问量" : "唯一访客"
]}
/>
<Legend
verticalAlign="top"
height={36}
formatter={(value: string) => value === "visits" ? "访问量" : "唯一访客"}
/>
<Line
type="monotone"
dataKey="visits"
stroke="#3498db"
strokeWidth={2}
activeDot={{ r: 6 }}
name="visits"
/>
<Line
type="monotone"
dataKey="uniqueVisitors"
stroke="#2ecc71"
strokeWidth={2}
dot={{ r: 4 }}
name="uniqueVisitors"
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
)}
</div>
)}
{activeTab === "performance" && performanceData && (
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
Link Performance
</h3>
{/* 添加图表展示 */}
<div className="mb-8">
<div className="flex flex-col md:flex-row gap-6">
{/* 柱状图展示总点击量和独立访客 */}
<div className="h-80 md:w-1/2">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={[
{ name: 'Total Clicks', value: performanceData.totalClicks },
{ name: 'Unique Visitors', value: performanceData.uniqueVisitors },
{ name: 'Active Days', value: performanceData.activeDays },
{ name: 'Unique Referrers', value: performanceData.uniqueReferrers }
]}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip formatter={(value: number) => [value.toLocaleString(), 'Count']} />
<Legend />
<Bar dataKey="value" name="Value" fill="#3498db" radius={[4, 4, 0, 0]}>
<LabelList dataKey="value" position="top" formatter={(value: number) => value.toLocaleString()} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* 饼图展示跳出率、转化率等比例数据 */}
<div className="h-80 md:w-1/2">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: 'Bounce Rate', value: performanceData.bounceRate, fill: '#e74c3c' },
{ name: 'Conversion Rate', value: performanceData.conversionRate, fill: '#2ecc71' },
{ name: 'Non-converting, Non-bounce Visits',
value: 100 - performanceData.bounceRate - performanceData.conversionRate,
fill: '#3498db' }
]}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
dataKey="value"
nameKey="name"
label={({name, value}: {name: string, value: number}) => `${name}: ${value}%`}
labelLine={true}
/>
<Tooltip formatter={(value: number) => [`${value}%`, 'Percentage']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Total Clicks
</div>
<div className="text-2xl font-bold text-foreground">
{performanceData.totalClicks}
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Unique Visitors
</div>
<div className="text-2xl font-bold text-foreground">
{performanceData.uniqueVisitors}
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Bounce Rate
</div>
<div className="text-2xl font-bold text-foreground">
{performanceData.bounceRate}%
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Conversion Rate
</div>
<div className="text-2xl font-bold text-foreground">
{performanceData.conversionRate}%
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Avg. Time Spent
</div>
<div className="text-2xl font-bold text-foreground">
{formatTime(performanceData.averageTimeSpent)}
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Active Days
</div>
<div className="text-2xl font-bold text-foreground">
{performanceData.activeDays}
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Unique Referrers
</div>
<div className="text-2xl font-bold text-foreground">
{performanceData.uniqueReferrers}
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Last Click
</div>
<div className="text-lg font-medium text-foreground">
{performanceData.lastClickTime
? new Date(
performanceData.lastClickTime
).toLocaleString()
: "Never"}
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === "referrers" && referrersData && (
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
Popular Referrers
</h3>
{/* 添加图表展示 */}
<div className="mb-8">
<div className="flex flex-col md:flex-row gap-4">
{/* 条形图展示访问量和独立访客 */}
<div className="h-80 md:w-2/3">
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={referrersData.referrers.slice(0, 10)} // 只显示前10个引荐来源
margin={{ top: 20, right: 30, left: 120, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" />
<YAxis
dataKey="source"
type="category"
tick={{ fontSize: 12 }}
width={120}
/>
<Tooltip
formatter={(value: number, name: string) => [
value,
name === "visitCount" ? "访问量" : "独立访客"
]}
/>
<Legend verticalAlign="top" height={36} />
<Bar
name="访问量"
dataKey="visitCount"
fill="#3498db"
radius={[0, 4, 4, 0]}
>
<LabelList
dataKey="percent"
position="right"
formatter={(value: number) => `${value}%`}
style={{ fill: "#666", fontSize: 12 }}
/>
</Bar>
<Bar
name="独立访客"
dataKey="uniqueVisitors"
fill="#2ecc71"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
{/* 饼图展示来源占比 */}
<div className="h-80 md:w-1/3">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={referrersData.referrers.slice(0, 6)} // 只显示前6个引荐来源
dataKey="visitCount"
nameKey="source"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
fill="#8884d8"
label={({source, percent}: {source: string, percent: number}) =>
`${source.substring(0, 10)}...: ${(percent * 100).toFixed(0)}%`
}
labelLine={false}
>
{referrersData.referrers.slice(0, 6).map((entry, index) => (
<Cell key={`cell-${index}`} fill={['#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#e74c3c', '#1abc9c'][index % 6]} />
))}
</Pie>
<Tooltip />
<Legend
formatter={(value: string) => value.length > 20 ? value.substring(0, 20) + '...' : value}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-card-border">
<thead>
<tr>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Source
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Visits
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Unique Visitors
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Conversion Rate
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Avg. Time Spent
</th>
</tr>
</thead>
<tbody className="divide-y bg-card-bg divide-card-border">
{referrersData.referrers.map(
(referrer: ReferrerItem, i: number) => (
<tr key={i}>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{referrer.source}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{referrer.visitCount} ({referrer.percent}%)
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{referrer.uniqueVisitors}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{referrer.conversionRate}%
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{formatTime(referrer.averageTimeSpent)}
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{activeTab === "devices" && deviceData && (
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
Device Types
</h3>
{/* 添加设备类型饼图 */}
<div className="mb-8">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={deviceData.deviceTypes}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
fill="#8884d8"
label={({name, percent}: {name: string, percent: number}) =>
`${name}: ${(percent * 100).toFixed(0)}%`
}
>
{deviceData.deviceTypes.map((entry, index) => (
<Cell key={`device-type-cell-${index}`} fill={['#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#e74c3c'][index % 5]} />
))}
</Pie>
<Tooltip formatter={(value: number) => [`${value} 次访问`, '访问量']} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{deviceData.deviceTypes.map(
(device: DeviceItem, i: number) => (
<div key={i} className="flex flex-col items-center">
<div className="mb-2 text-sm font-medium text-text-secondary">
{device.name}
</div>
<div className="text-2xl font-bold text-foreground">
{device.count}
</div>
<div className="mt-1 text-xs text-text-secondary">
{device.percent}%
</div>
</div>
)
)}
</div>
</div>
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
Device Brands
</h3>
{/* 添加设备品牌横向条形图 */}
<div className="mb-8">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={deviceData.deviceBrands.slice(0, 10)}
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" />
<YAxis
dataKey="name"
type="category"
tick={{ fontSize: 12 }}
width={100}
/>
<Tooltip
formatter={(value: number) => [`${value} 次访问`, '访问量']}
/>
<Bar
dataKey="count"
name="访问量"
fill="#3498db"
radius={[0, 4, 4, 0]}
>
<LabelList
dataKey="percent"
position="right"
formatter={(value: number) => `${value}%`}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-card-border">
<thead>
<tr>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Brand
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Count
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Percentage
</th>
</tr>
</thead>
<tbody className="divide-y bg-card-bg divide-card-border">
{deviceData.deviceBrands.map(
(brand: DeviceItem, i: number) => (
<tr key={i}>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{brand.name}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{brand.count}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{brand.percent}%
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{activeTab === "locations" && platformData && (
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
Platform Distribution
</h3>
{/* 添加图表展示 */}
<div className="mb-8">
<div className="flex flex-col md:flex-row gap-6">
{/* 操作系统分布饼图 */}
<div className="h-80 md:w-1/2">
<h4 className="mb-3 text-sm font-medium text-text-secondary text-center">
Operating Systems
</h4>
<ResponsiveContainer width="100%" height="90%">
<PieChart>
<Pie
data={platformData.platforms.slice(0, 6)}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
fill="#8884d8"
label={({name, percent}: {name: string, percent: number}) =>
`${name}: ${(percent * 100).toFixed(0)}%`
}
labelLine={true}
>
{platformData.platforms.slice(0, 6).map((entry, index) => (
<Cell key={`os-cell-${index}`} fill={['#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#e74c3c', '#1abc9c'][index % 6]} />
))}
</Pie>
<Tooltip formatter={(value: number, name: string) => [`${value} 次访问`, name]} />
<Legend formatter={(value: string) => value.length > 25 ? value.substring(0, 25) + '...' : value} />
</PieChart>
</ResponsiveContainer>
</div>
{/* 浏览器分布条形图 */}
<div className="h-80 md:w-1/2">
<h4 className="mb-3 text-sm font-medium text-text-secondary text-center">
Browsers
</h4>
<ResponsiveContainer width="100%" height="90%">
<BarChart
data={platformData.browsers.slice(0, 8)}
layout="vertical"
margin={{ top: 5, right: 30, left: 80, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" />
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 12 }}
width={80}
/>
<Tooltip formatter={(value: number) => [`${value} 次访问`, '数量']} />
<Legend />
<Bar
dataKey="count"
name="访问量"
fill="#3498db"
radius={[0, 4, 4, 0]}
>
<LabelList
dataKey="percent"
position="right"
formatter={(value: number) => `${value}%`}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<h4 className="mb-3 text-sm font-medium text-text-secondary">
Operating Systems
</h4>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-card-border">
<thead>
<tr>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
OS
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Visits
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Percentage
</th>
</tr>
</thead>
<tbody className="divide-y bg-card-bg divide-card-border">
{platformData.platforms.map((platform, i) => (
<tr key={i}>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{platform.name}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{platform.count}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{platform.percent}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div>
<h4 className="mb-3 text-sm font-medium text-text-secondary">
Browsers
</h4>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-card-border">
<thead>
<tr>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Browser
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Visits
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Percentage
</th>
</tr>
</thead>
<tbody className="divide-y bg-card-bg divide-card-border">
{platformData.browsers.map((browser, i) => (
<tr key={i}>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{browser.name}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{browser.count}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{browser.percent}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === "qrCodes" && qrCodeData && (
<div className="space-y-6">
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
QR Code Analysis
</h3>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Total Scans
</div>
<div className="text-2xl font-bold text-foreground">
{qrCodeData.overview.totalScans}
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Unique Scanners
</div>
<div className="text-2xl font-bold text-foreground">
{qrCodeData.overview.uniqueScanners}
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Conversion Rate
</div>
<div className="text-2xl font-bold text-foreground">
{qrCodeData.overview.conversionRate}%
</div>
</div>
<div className="flex flex-col">
<div className="mb-1 text-sm font-medium text-text-secondary">
Avg. Time Spent
</div>
<div className="text-2xl font-bold text-foreground">
{formatTime(qrCodeData.overview.averageTimeSpent)}
</div>
</div>
</div>
</div>
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
Scan Locations
</h3>
{/* 添加扫描位置饼图 */}
<div className="mb-8">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={qrCodeData.locations.slice(0, 8)}
dataKey="scanCount"
nameKey="city"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={2}
fill="#8884d8"
label={({city, country, percent}: {city: string, country: string, percent: number}) =>
`${city}: ${(percent * 100).toFixed(0)}%`
}
>
{qrCodeData.locations.slice(0, 8).map((entry, index) => (
<Cell key={`location-cell-${index}`} fill={['#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#e74c3c', '#1abc9c', '#34495e', '#16a085'][index % 8]} />
))}
</Pie>
<Tooltip formatter={(value: number, name: string) => [`${value} 次扫描`, name]} />
<Legend
formatter={(value: string) => {
const location = qrCodeData.locations.find(loc => loc.city === value);
return location ? `${location.city}, ${location.country}` : value;
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-card-border">
<thead>
<tr>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Location
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Scans
</th>
<th className="px-4 py-3 text-xs font-medium tracking-wider text-left uppercase text-text-secondary">
Percentage
</th>
</tr>
</thead>
<tbody className="divide-y bg-card-bg divide-card-border">
{qrCodeData.locations.map((location, i) => (
<tr key={i}>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{location.city}, {location.country}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{location.scanCount}
</td>
<td className="px-4 py-2 text-sm whitespace-nowrap text-foreground">
{location.percent}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="p-6 border rounded-lg bg-card-bg border-card-border">
<h3 className="mb-4 font-medium text-md text-foreground">
Scan Time Distribution
</h3>
{/* 添加扫描时间分布柱状图 */}
<div className="mb-8">
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={qrCodeData.hourlyDistribution}
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="hour"
label={{ value: 'Hour of Day', position: 'insideBottom', offset: -5 }}
/>
<YAxis
label={{ value: 'Scan Count', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
formatter={(value: number) => [`${value} 次扫描`, '扫描次数']}
labelFormatter={(hour) => `${hour}:00 - ${hour}:59`}
/>
<Bar
dataKey="scanCount"
name="扫描次数"
fill="#3498db"
radius={[4, 4, 0, 0]}
>
<LabelList
dataKey="percent"
position="top"
formatter={(value: number) => `${value.toFixed(1)}%`}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="grid grid-cols-1 gap-6">
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}