feats: add url stats charts

This commit is contained in:
oiov
2024-08-02 17:00:42 +08:00
parent fd3cb20f44
commit 4e87d5e2a7
9 changed files with 1140 additions and 14 deletions
+111 -3
View File
@@ -1,9 +1,14 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { UrlMeta } from "@prisma/client";
import { VisSingleContainer, VisTooltip, VisTopoJSONMap } from "@unovis/react";
import { Donut, MapData, TopoJSONMap } from "@unovis/ts";
import { WorldMapTopoJSON } from "@unovis/ts/maps";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { isLink } from "@/lib/utils";
import {
Card,
CardContent,
@@ -20,11 +25,11 @@ import {
const chartConfig = {
pv: {
label: "Views",
color: "hsl(var(--chart-1))",
color: "hsl(var(--chart-2))",
},
uv: {
label: "Visitors",
color: "hsl(var(--chart-2))",
color: "hsl(var(--chart-1))",
},
};
@@ -64,6 +69,49 @@ function calculateUVAndPV(logs: UrlMeta[]) {
};
}
interface Stat {
dimension: string;
clicks: number;
percentage: string;
}
function generateStatsList(
records: UrlMeta[],
dimension: keyof UrlMeta,
): Stat[] {
// 统计每个维度的点击总数
const dimensionCounts: { [key: string]: number } = {};
let totalClicks = 0;
records.forEach((record) => {
const dimValue = record[dimension] ?? ("(None)" as any);
const click = record.click;
if (!dimensionCounts[dimValue]) {
dimensionCounts[dimValue] = 0;
}
dimensionCounts[dimValue] += click;
totalClicks += click;
});
// 计算百分比并生成列表
const statsList: Stat[] = [];
for (const [dimValue, clicks] of Object.entries(dimensionCounts)) {
const percentage = (clicks / totalClicks) * 100;
statsList.push({
dimension: dimValue,
clicks,
percentage: percentage.toFixed(0) + "%",
});
}
statsList.sort((a, b) => parseFloat(b.percentage) - parseFloat(a.percentage));
return statsList;
}
export function DailyPVUVChart({ data }: { data: UrlMeta[] }) {
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("pv");
@@ -86,6 +134,14 @@ export function DailyPVUVChart({ data }: { data: UrlMeta[] }) {
.filter(Boolean)
.join(" ");
const areaData = data.map((item) => ({
id: item.country,
}));
const triggers = { [TopoJSONMap.selectors.feature]: (d) => d.id };
const refererStats = generateStatsList(data, "referer");
const countryStats = generateStatsList(data, "region");
return (
<Card className="rounded-t-none">
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
@@ -161,10 +217,62 @@ export function DailyPVUVChart({ data }: { data: UrlMeta[] }) {
/>
}
/>
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
<Bar dataKey="uv" fill={`var(--color-uv)`} stackId="a" />
<Bar dataKey="pv" fill={`var(--color-pv)`} stackId="a" />
{/* <Bar
dataKey={activeChart}
stackId="a"
fill={`var(--color-${activeChart})`}
/> */}
</BarChart>
</ChartContainer>
<div className="my-5 grid grid-cols-1 gap-6 sm:grid-cols-2">
<StatsList data={refererStats} title="Referrers" />
<StatsList data={countryStats} title="Regions" />
</div>
<VisSingleContainer data={{ areas: areaData }}>
<VisTopoJSONMap topojson={WorldMapTopoJSON} />
<VisTooltip triggers={triggers} />
</VisSingleContainer>
</CardContent>
</Card>
);
}
export function StatsList({ data, title }: { data: Stat[]; title: string }) {
return (
<div className="rounded-lg border p-4">
<h1 className="text-lg font-bold">{title}</h1>
{data.slice(0, 10).map((ref) => (
<div className="mt-1" key={ref.dimension}>
<div className="mb-0.5 flex items-center justify-between text-sm">
{isLink(ref.dimension) ? (
<Link
className="font-medium hover:after:content-['↗']"
href={ref.dimension}
>
{ref.dimension}
</Link>
) : (
<p className="font-medium">{ref.dimension}</p>
)}
<p className="text-slate-500">
{ref.clicks} ({ref.percentage})
</p>
</div>
<div className="w-full rounded-lg bg-neutral-200 dark:bg-neutral-600">
<div
className="rounded-lg bg-blue-400 p-0.5 text-center text-xs font-medium leading-none text-primary-foreground transition-all duration-300"
style={{ width: `${ref.percentage}` }}
>
{ref.percentage}
</div>
</div>
</div>
))}
</div>
);
}
+2 -10
View File
@@ -4,13 +4,6 @@ import { UrlMeta, User } from "@prisma/client";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
@@ -23,7 +16,7 @@ export interface UrlMetaProps {
}
export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
const { data, error, isLoading } = useSWR<UrlMeta[]>(
const { data, isLoading } = useSWR<UrlMeta[]>(
`${action}?id=${urlId}`,
fetcher,
);
@@ -31,8 +24,7 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
if (isLoading)
return (
<div className="space-y-2 p-2">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-40 w-full" />
</div>
);
+2
View File
@@ -6,6 +6,7 @@ export async function GET(req: NextRequest) {
try {
const url = new URL(req.url);
const slug = url.searchParams.get("slug");
const referer = url.searchParams.get("referer");
const ip = url.searchParams.get("ip");
const city = url.searchParams.get("city");
const region = url.searchParams.get("region");
@@ -35,6 +36,7 @@ export async function GET(req: NextRequest) {
country,
latitude,
longitude,
referer,
});
return Response.json(res.target);
}
+9
View File
@@ -207,3 +207,12 @@ export function generateUrlSuffix(length: number = 6): string {
return result;
}
export function isLink(str: string): boolean {
try {
const url = new URL(str);
return true;
} catch (_) {
return false;
}
}
+2 -1
View File
@@ -15,8 +15,9 @@ export default auth(async (req) => {
const geo = geolocation(req);
if (match) {
const referer = req.headers.get("referer") || "(None)";
const res = await fetch(
`${siteConfig.url}/api/s?slug=${match[0]}&ip=${ip}&city=${geo?.city}&region=${geo?.region}&country=${geo?.country}&latitude=${geo?.latitude}&longitude=${geo?.longitude}&flag=${geo?.flag}`,
`${siteConfig.url}/api/s?slug=${match[0]}&referer=${referer}&ip=${ip}&city=${geo?.city}&region=${geo?.region}&country=${geo?.country}&latitude=${geo?.latitude}&longitude=${geo?.longitude}&flag=${geo?.flag}`,
);
if (!res.ok) {
+2
View File
@@ -56,6 +56,8 @@
"@react-email/html": "0.0.8",
"@t3-oss/env-nextjs": "^0.11.0",
"@typescript-eslint/parser": "^7.16.1",
"@unovis/react": "^1.4.3",
"@unovis/ts": "^1.4.3",
"@vercel/analytics": "^1.3.1",
"@vercel/functions": "^1.4.0",
"@vercel/og": "^0.6.2",
+1010
View File
File diff suppressed because it is too large Load Diff
@@ -170,5 +170,6 @@ ALTER TABLE "url_metas" ADD COLUMN "country" TEXT;
ALTER TABLE "url_metas" ADD COLUMN "region" TEXT;
ALTER TABLE "url_metas" ADD COLUMN "latitude" TEXT;
ALTER TABLE "url_metas" ADD COLUMN "longitude" TEXT;
ALTER TABLE "url_metas" ADD COLUMN "referer" TEXT;
ALTER TABLE "user_url" ADD COLUMN "expiration" TEXT NOT NULL DEFAULT '-1';
+1
View File
@@ -132,6 +132,7 @@ model UrlMeta {
region String?
latitude String?
longitude String?
referer String?
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")