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

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>
);
}