更新下载次数记录和更新次数记录等

This commit is contained in:
rucky
2026-04-07 18:30:49 +08:00
parent f459cc9ad0
commit 9dc6c0dcce
18 changed files with 665 additions and 591 deletions

View File

@@ -0,0 +1,246 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Users, Monitor, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
interface OnlineUser {
id: string;
deviceId: string;
ip: string;
location: string;
os: string;
osVersion: string;
appVersion: string;
lastSeen: string;
createdAt: string;
}
interface OsCount {
os: string;
count: number;
}
interface OnlineData {
users: OnlineUser[];
total: number;
osList: OsCount[];
}
const REFRESH_INTERVAL = 15_000;
export default function LauncherOnlinePage() {
const [data, setData] = useState<OnlineData | null>(null);
const [search, setSearch] = useState("");
const [osFilter, setOsFilter] = useState("");
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (osFilter) params.set("os", osFilter);
const res = await fetch(`/api/launcher/online?${params}`);
if (res.ok) {
setData(await res.json());
}
setLoading(false);
}, [search, osFilter]);
useEffect(() => {
fetchData();
const timer = setInterval(fetchData, REFRESH_INTERVAL);
return () => clearInterval(timer);
}, [fetchData]);
function formatLastSeen(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const sec = Math.floor(diff / 1000);
if (sec < 60) return `${sec} 秒前`;
return `${Math.floor(sec / 60)} 分钟前`;
}
function getOsBadgeVariant(os: string) {
const lower = os.toLowerCase();
if (lower.includes("windows")) return "default" as const;
if (lower.includes("mac") || lower.includes("darwin"))
return "secondary" as const;
if (lower.includes("linux")) return "outline" as const;
return "secondary" as const;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">线</h1>
<p className="mt-1 text-muted-foreground">
线 15
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
线
</CardTitle>
<Users className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{data?.total ?? "—"}
</div>
</CardContent>
</Card>
{data?.osList
.filter((o) => o.os)
.slice(0, 2)
.map((o) => (
<Card key={o.os}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{o.os}
</CardTitle>
<Monitor className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{o.count}</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>线</CardTitle>
<CardDescription>
3
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setLoading(true);
fetchData();
}}
disabled={loading}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
<div className="flex gap-3 pt-2">
<Input
placeholder="搜索 IP / 设备ID / 版本号…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
<Select value={osFilter} onValueChange={(v) => setOsFilter(v ?? "")}>
<SelectTrigger className="w-40">
<SelectValue placeholder="全部系统" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""></SelectItem>
{data?.osList
.filter((o) => o.os)
.map((o) => (
<SelectItem key={o.os} value={o.os}>
{o.os} ({o.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>IP </TableHead>
<TableHead></TableHead>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!data || data.users.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="h-24 text-center text-muted-foreground"
>
{loading ? "加载中…" : "暂无在线用户"}
</TableCell>
</TableRow>
) : (
data.users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-mono text-sm">
{user.ip || "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{user.location || "—"}
</TableCell>
<TableCell
className="max-w-[140px] truncate font-mono text-xs text-muted-foreground"
title={user.deviceId}
>
{user.deviceId}
</TableCell>
<TableCell>
<Badge variant={getOsBadgeVariant(user.os)}>
{user.os || "Unknown"}
</Badge>
</TableCell>
<TableCell className="text-sm">
{user.osVersion || "—"}
</TableCell>
<TableCell>
<Badge variant="outline">v{user.appVersion}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatLastSeen(user.lastSeen)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Package, Download, FileUp, Eye, Users, TrendingUp } from "lucide-react";
import { Package, Download, FileUp, Eye, Users, TrendingUp, Monitor, Wifi } from "lucide-react";
export const dynamic = "force-dynamic";
@@ -24,6 +24,8 @@ 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,
@@ -33,6 +35,9 @@ export default async function DashboardPage() {
totalPV,
todayUV,
pvByDay,
launcherDownloads,
launcherUpdateDownloads,
onlineCount,
] = await Promise.all([
prisma.addon.count(),
prisma.addon.aggregate({ _sum: { totalDownloads: true } }),
@@ -54,6 +59,9 @@ export default async function DashboardPage() {
_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]));
@@ -63,10 +71,17 @@ export default async function DashboardPage() {
}));
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: 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 },
@@ -76,7 +91,7 @@ export default async function DashboardPage() {
<div className="space-y-8">
<h1 className="text-3xl font-bold"></h1>
<div className="grid gap-4 md:grid-cols-3">
<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">