文件
tennis-training-hub/client/src/pages/Logs.tsx
2026-03-15 08:05:37 +08:00

260 行
11 KiB
TypeScript

import { useMemo } from "react";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react";
function formatTaskStatus(status: string) {
switch (status) {
case "running":
return "执行中";
case "succeeded":
return "已完成";
case "failed":
return "失败";
default:
return "排队中";
}
}
function formatNotificationState(isRead: number | boolean | null | undefined) {
return isRead ? "已读" : "未读";
}
function formatStructuredValue(value: unknown) {
if (!value) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function formatTaskTiming(task: {
createdAt: string | Date;
startedAt?: string | Date | null;
completedAt?: string | Date | null;
}) {
const createdAt = new Date(task.createdAt).getTime();
const startedAt = task.startedAt ? new Date(task.startedAt).getTime() : null;
const completedAt = task.completedAt ? new Date(task.completedAt).getTime() : null;
const durationMs = (completedAt ?? Date.now()) - (startedAt ?? createdAt);
const seconds = Math.max(0, Math.round(durationMs / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return `${minutes}m ${rest.toString().padStart(2, "0")}s`;
}
export default function Logs() {
const utils = trpc.useUtils();
const taskListQuery = trpc.task.list.useQuery(
{ limit: 50 },
{
retry: 3,
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
placeholderData: (previous) => previous,
refetchInterval: (query) => {
const hasActiveTask = (query.state.data ?? []).some((task) => task.status === "queued" || task.status === "running");
return hasActiveTask ? 3_000 : 10_000;
},
},
);
const notificationQuery = trpc.notification.list.useQuery({ limit: 50 });
const retryMutation = trpc.task.retry.useMutation({
onSuccess: () => {
utils.task.list.invalidate();
toast.success("任务已重新排队");
},
onError: (error) => toast.error(`任务重试失败: ${error.message}`),
});
const activeTaskCount = useMemo(
() => (taskListQuery.data ?? []).filter((task) => task.status === "queued" || task.status === "running").length,
[taskListQuery.data],
);
const failedTaskCount = useMemo(
() => (taskListQuery.data ?? []).filter((task) => task.status === "failed").length,
[taskListQuery.data],
);
const unreadNotificationCount = useMemo(
() => (notificationQuery.data ?? []).filter((item) => !item.isRead).length,
[notificationQuery.data],
);
if (taskListQuery.isLoading && notificationQuery.isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary"> {activeTaskCount}</Badge>
<Badge variant={failedTaskCount > 0 ? "destructive" : "secondary"}> {failedTaskCount}</Badge>
<Badge variant="outline"> {unreadNotificationCount}</Badge>
</div>
</div>
<Alert>
<ClipboardList className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
</AlertDescription>
</Alert>
{taskListQuery.isError ? (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
</AlertDescription>
</Alert>
) : null}
<Tabs defaultValue="tasks" className="space-y-4">
<TabsList>
<TabsTrigger value="tasks"></TabsTrigger>
<TabsTrigger value="notifications"></TabsTrigger>
</TabsList>
<TabsContent value="tasks">
<ScrollArea className="max-h-[70vh] pr-3">
<div className="space-y-4">
{(taskListQuery.data ?? []).length === 0 ? (
<Card className="border-dashed">
<CardContent className="pt-6 text-sm text-muted-foreground">
</CardContent>
</Card>
) : (
(taskListQuery.data ?? []).map((task) => (
<Card key={task.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<CardTitle className="text-base">{task.title}</CardTitle>
<CardDescription>
{new Date(task.createdAt).toLocaleString("zh-CN")} · {task.type}
</CardDescription>
</div>
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
{formatTaskStatus(task.status)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{task.message || formatTaskStatus(task.status)}</p>
{task.error ? (
<div className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span className="whitespace-pre-wrap break-words">{task.error}</span>
</div>
</div>
) : null}
{task.result ? (
<pre className="overflow-x-auto rounded-xl bg-muted/60 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
{formatStructuredValue(task.result)}
</pre>
) : null}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{task.progress}% · {task.attempts}/{task.maxAttempts} · {formatTaskTiming(task)}
</span>
{task.status === "failed" ? (
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() => retryMutation.mutate({ taskId: task.id })}
disabled={retryMutation.isPending}
>
{retryMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
</Button>
) : task.status === "succeeded" ? (
<span className="inline-flex items-center gap-1 text-emerald-600">
<CheckCircle2 className="h-3.5 w-3.5" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-primary">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</span>
)}
</div>
</CardContent>
</Card>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="notifications">
<ScrollArea className="max-h-[70vh] pr-3">
<div className="space-y-4">
{(notificationQuery.data ?? []).length === 0 ? (
<Card className="border-dashed">
<CardContent className="pt-6 text-sm text-muted-foreground">
</CardContent>
</Card>
) : (
(notificationQuery.data ?? []).map((item) => (
<Card key={item.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<CardTitle className="text-base">{item.title}</CardTitle>
<CardDescription>
{new Date(item.createdAt).toLocaleString("zh-CN")} · {item.notificationType}
</CardDescription>
</div>
<Badge variant={item.isRead ? "secondary" : "outline"}>
{formatNotificationState(item.isRead)}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 text-sm">
<BellRing className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<p className="whitespace-pre-wrap break-words">{item.message || "无附加内容"}</p>
</div>
</CardContent>
</Card>
))
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
);
}