Files
nanami-web/src/app/admin/(dashboard)/page.tsx

176 lines
5.9 KiB
TypeScript

import { prisma } from "@/lib/db";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Package, Download, FileUp, Eye, Users, TrendingUp, Monitor, Wifi } from "lucide-react";
export const dynamic = "force-dynamic";
function getLast7Days() {
const days: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
days.push(d.toISOString().slice(0, 10));
}
return days;
}
export default async function DashboardPage() {
const today = new Date().toISOString().slice(0, 10);
const days = getLast7Days();
const onlineThreshold = new Date(Date.now() - 3 * 60 * 1000);
const [
addonCount,
totalDownloads,
releaseCount,
recentReleases,
todayPV,
totalPV,
todayUV,
pvByDay,
launcherDownloads,
launcherUpdateDownloads,
onlineCount,
] = await Promise.all([
prisma.addon.count(),
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
prisma.release.count(),
prisma.release.findMany({
take: 5,
orderBy: { createdAt: "desc" },
include: { addon: { select: { name: true } } },
}),
prisma.pageView.count({ where: { date: today } }),
prisma.pageView.count(),
prisma.pageView.groupBy({
by: ["ip"],
where: { date: today, ip: { not: "" } },
}).then((r) => r.length),
prisma.pageView.groupBy({
by: ["date"],
where: { date: { in: days } },
_count: true,
orderBy: { date: "asc" },
}),
prisma.softwareVersion.aggregate({ _sum: { downloadCount: true } }),
prisma.softwareVersion.aggregate({ _sum: { launcherDownloadCount: true } }),
prisma.launcherOnline.count({ where: { lastSeen: { gte: onlineThreshold } } }),
]);
const pvMap = new Map(pvByDay.map((d) => [d.date, d._count]));
const chartData = days.map((d) => ({
date: d.slice(5),
pv: pvMap.get(d) || 0,
}));
const maxPV = Math.max(...chartData.map((d) => d.pv), 1);
const totalSwDownloads = launcherDownloads._sum.downloadCount || 0;
const totalLauncherUpdates = launcherUpdateDownloads._sum.launcherDownloadCount || 0;
const webDownloads = totalSwDownloads - totalLauncherUpdates;
const stats = [
{ title: "插件总数", value: addonCount, icon: Package },
{ title: "插件总下载量", value: totalDownloads._sum.totalDownloads || 0, icon: Download },
{ title: "版本发布数", value: releaseCount, icon: FileUp },
{ title: "启动器下载量 (网页)", value: webDownloads, icon: Monitor },
{ title: "启动器更新量 (客户端)", value: totalLauncherUpdates, icon: Download },
{ title: "启动器在线人数", value: onlineCount, icon: Wifi },
{ title: "今日访问 (PV)", value: todayPV, icon: Eye },
{ title: "今日独立访客 (UV)", value: todayUV, icon: Users },
{ title: "累计访问量", value: totalPV, icon: TrendingUp },
];
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold"></h1>
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{stat.title}
</CardTitle>
<stat.icon className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{stat.value.toLocaleString()}
</div>
</CardContent>
</Card>
))}
</div>
{/* 7-day PV chart */}
<Card>
<CardHeader>
<CardTitle> 7 访</CardTitle>
<CardDescription> (PV)</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-48 items-end gap-2">
{chartData.map((d) => (
<div key={d.date} className="flex flex-1 flex-col items-center gap-1">
<span className="text-xs font-medium text-muted-foreground">
{d.pv}
</span>
<div
className="w-full rounded-t bg-primary/80 transition-all"
style={{
height: `${Math.max((d.pv / maxPV) * 160, 4)}px`,
}}
/>
<span className="text-[10px] text-muted-foreground">
{d.date}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{recentReleases.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="space-y-4">
{recentReleases.map((release) => (
<div
key={release.id}
className="flex items-center justify-between border-b pb-3 last:border-0"
>
<div>
<p className="font-medium">{release.addon.name}</p>
<p className="text-sm text-muted-foreground">
v{release.version}
{release.gameVersion &&
` · WoW ${release.gameVersion}`}
</p>
</div>
<div className="text-right text-sm text-muted-foreground">
<p>{release.downloadCount} </p>
<p>{new Date(release.createdAt).toLocaleDateString("zh-CN")}</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}