更新下载次数记录和更新次数记录等
This commit is contained in:
246
src/app/admin/(dashboard)/launcher-online/page.tsx
Normal file
246
src/app/admin/(dashboard)/launcher-online/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user