feats: add url stats charts
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}®ion=${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}®ion=${geo?.region}&country=${geo?.country}&latitude=${geo?.latitude}&longitude=${geo?.longitude}&flag=${geo?.flag}`,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1010
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';
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user