文件
tennis-training-hub/client/src/pages/VisionLab.tsx
2026-03-15 17:30:19 +08:00

424 行
16 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { useBackgroundTask } from "@/hooks/useBackgroundTask";
import { Database, Image as ImageIcon, Loader2, Microscope, ShieldCheck, Sparkles } from "lucide-react";
type ReferenceImage = {
id: number;
slug: string;
title: string;
exerciseType: string;
imageUrl: string;
sourcePageUrl: string;
sourceLabel: string;
author: string | null;
license: string | null;
expectedFocus: string[] | null;
tags: string[] | null;
notes: string | null;
};
type VisionRun = {
id: number;
taskId: string;
userId: number;
userName: string | null;
referenceImageId: number | null;
referenceTitle: string | null;
title: string;
exerciseType: string;
imageUrl: string;
status: "queued" | "succeeded" | "failed";
visionStatus: "pending" | "ok" | "fallback" | "failed";
configuredModel: string | null;
expectedFocus: string[] | null;
summary: string | null;
corrections: string | null;
warning: string | null;
error: string | null;
createdAt: Date;
updatedAt: Date;
};
const COMMONS_SPECIAL_FILE_PATH = "/wiki/Special:FilePath/";
const COMMONS_FILE_PAGE_PATH = "/wiki/File:";
function getCompressedVisionImageUrl(imageUrl: string, width = 960) {
try {
const url = new URL(imageUrl);
if (url.hostname !== "commons.wikimedia.org") {
return imageUrl;
}
let fileName: string | null = null;
if (url.pathname.startsWith(COMMONS_SPECIAL_FILE_PATH)) {
fileName = url.pathname.slice(COMMONS_SPECIAL_FILE_PATH.length);
} else if (url.pathname.startsWith(COMMONS_FILE_PAGE_PATH)) {
fileName = url.pathname.slice(COMMONS_FILE_PAGE_PATH.length);
}
if (!fileName) {
return imageUrl;
}
const decodedFileName = decodeURIComponent(fileName);
return `https://commons.wikimedia.org/wiki/Special:Redirect/file/${encodeURIComponent(decodedFileName)}?width=${width}`;
} catch {
return imageUrl;
}
}
function VisionPreviewImage({
src,
alt,
className,
width = 960,
}: {
src: string;
alt: string;
className: string;
width?: number;
}) {
const [displaySrc, setDisplaySrc] = useState(() => getCompressedVisionImageUrl(src, width));
useEffect(() => {
setDisplaySrc(getCompressedVisionImageUrl(src, width));
}, [src, width]);
return (
<img
src={displaySrc}
alt={alt}
className={className}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
if (displaySrc !== src) {
setDisplaySrc(src);
}
}}
/>
);
}
function statusBadge(run: VisionRun) {
if (run.status === "failed" || run.visionStatus === "failed") {
return <Badge variant="destructive"></Badge>;
}
if (run.status === "queued" || run.visionStatus === "pending") {
return <Badge variant="secondary"></Badge>;
}
if (run.visionStatus === "fallback") {
return <Badge variant="outline"></Badge>;
}
return <Badge className="bg-emerald-600 hover:bg-emerald-600"></Badge>;
}
export default function VisionLab() {
const { user } = useAuth();
const utils = trpc.useUtils();
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const activeTask = useBackgroundTask(activeTaskId);
const libraryQuery = trpc.vision.library.useQuery();
const runsQuery = trpc.vision.runs.useQuery(
{ limit: 50 },
{ refetchInterval: 4000 }
);
const seedMutation = trpc.vision.seedLibrary.useMutation({
onSuccess: (data) => {
toast.success(`标准图库已就绪,共 ${data.count}`);
utils.vision.library.invalidate();
},
onError: (error) => toast.error(`标准图库初始化失败: ${error.message}`),
});
const runReferenceMutation = trpc.vision.runReference.useMutation({
onSuccess: (data) => {
setActiveTaskId(data.taskId);
toast.success("视觉测试任务已提交");
utils.vision.runs.invalidate();
},
onError: (error) => toast.error(`视觉测试提交失败: ${error.message}`),
});
const runAllMutation = trpc.vision.runAll.useMutation({
onSuccess: (data) => {
toast.success(`已提交 ${data.count} 个视觉测试任务`);
if (data.queued[0]?.taskId) {
setActiveTaskId(data.queued[0].taskId);
}
utils.vision.runs.invalidate();
},
onError: (error) => toast.error(`批量视觉测试提交失败: ${error.message}`),
});
const retryRunMutation = trpc.vision.retryRun.useMutation({
onSuccess: () => {
toast.success("视觉记录已重新加入队列");
utils.vision.runs.invalidate();
},
onError: (error) => toast.error(`重新执行失败: ${error.message}`),
});
const retryFallbacksMutation = trpc.vision.retryFallbacks.useMutation({
onSuccess: (data) => {
toast.success(`已重新排队 ${data.count} 条历史视觉记录`);
utils.vision.runs.invalidate();
},
onError: (error) => toast.error(`批量修复失败: ${error.message}`),
});
useEffect(() => {
if (activeTask.data?.status === "succeeded" || activeTask.data?.status === "failed") {
utils.vision.runs.invalidate();
setActiveTaskId(null);
}
}, [activeTask.data, utils.vision.runs]);
const references = useMemo(() => (libraryQuery.data ?? []) as ReferenceImage[], [libraryQuery.data]);
const runs = useMemo(() => (runsQuery.data ?? []) as VisionRun[], [runsQuery.data]);
if (libraryQuery.isLoading && runsQuery.isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-64 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="text-sm text-muted-foreground mt-1">
访
</p>
</div>
<div className="flex flex-wrap gap-2">
{user?.role === "admin" ? (
<>
<Button
variant="outline"
onClick={() => seedMutation.mutate()}
disabled={seedMutation.isPending}
className="gap-2"
>
{seedMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
</Button>
<Button
variant="outline"
onClick={() => retryFallbacksMutation.mutate({ limit: 20 })}
disabled={retryFallbacksMutation.isPending}
className="gap-2"
>
{retryFallbacksMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</Button>
</>
) : null}
<Button
onClick={() => runAllMutation.mutate()}
disabled={runAllMutation.isPending || references.length === 0}
className="gap-2"
>
{runAllMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
</Button>
</div>
</div>
{user?.role === "admin" ? (
<Alert>
<ShieldCheck className="h-4 w-4" />
<AlertTitle>Admin </AlertTitle>
<AlertDescription>
`H1` `ADMIN_USERNAMES`
</AlertDescription>
</Alert>
) : (
<Alert>
<ImageIcon className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
</Alert>
)}
{activeTask.data?.status === "queued" || activeTask.data?.status === "running" ? (
<Alert>
<Sparkles className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>{activeTask.data.message || "视觉测试正在后台执行。"}</AlertDescription>
</Alert>
) : null}
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Badge variant="secondary">{references.length} </Badge>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{references.map((reference) => (
<Card key={reference.id} className="overflow-hidden border-0 shadow-sm">
<div className="aspect-[4/3] overflow-hidden bg-muted">
<VisionPreviewImage
src={reference.imageUrl}
alt={reference.title}
className="h-full w-full object-cover"
width={960}
/>
</div>
<CardHeader className="pb-3">
<CardTitle className="text-base">{reference.title}</CardTitle>
<CardDescription className="flex flex-wrap gap-2">
<Badge variant="outline">{reference.exerciseType}</Badge>
{reference.license ? <Badge variant="secondary">{reference.license}</Badge> : null}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{reference.notes ? (
<p className="text-sm text-muted-foreground">{reference.notes}</p>
) : null}
{reference.expectedFocus?.length ? (
<div className="flex flex-wrap gap-2">
{reference.expectedFocus.map((item) => (
<Badge key={item} variant="outline">{item}</Badge>
))}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<a
href={reference.sourcePageUrl}
target="_blank"
rel="noreferrer"
className="text-sm text-primary underline-offset-4 hover:underline"
>
</a>
<Button
size="sm"
className="gap-2"
onClick={() => runReferenceMutation.mutate({ referenceImageId: reference.id })}
disabled={runReferenceMutation.isPending}
>
{runReferenceMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<Badge variant="secondary">{runs.length} </Badge>
</div>
<div className="grid gap-4">
{runs.map((run) => (
<Card key={run.id} className="border-0 shadow-sm">
<CardContent className="pt-5 space-y-3">
<div className="flex flex-col gap-4 lg:flex-row">
<a
href={run.imageUrl}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-xl bg-muted lg:w-72 lg:flex-none"
>
<div className="aspect-[4/3]">
<VisionPreviewImage
src={run.imageUrl}
alt={run.title}
className="h-full w-full object-cover"
width={720}
/>
</div>
</a>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{run.title}</h3>
{statusBadge(run)}
<Badge variant="outline">{run.exerciseType}</Badge>
</div>
<p className="text-xs text-muted-foreground">
{formatDateTimeShanghai(run.createdAt)}
{user?.role === "admin" && run.userName ? ` · 提交人:${run.userName}` : ""}
</p>
</div>
{run.configuredModel ? (
<Badge variant="secondary">{run.configuredModel}</Badge>
) : null}
</div>
{run.summary ? <p className="text-sm">{run.summary}</p> : null}
{run.warning ? (
<p className="text-sm text-amber-700">{run.warning}</p>
) : null}
{run.error ? (
<p className="text-sm text-destructive">{run.error}</p>
) : null}
{(run.visionStatus === "fallback" || run.status === "failed") ? (
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
className="gap-2"
onClick={() => retryRunMutation.mutate({ runId: run.id })}
disabled={retryRunMutation.isPending}
>
{retryRunMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Microscope className="h-4 w-4" />}
</Button>
</div>
) : null}
{run.expectedFocus?.length ? (
<div className="flex flex-wrap gap-2">
{run.expectedFocus.map((item) => (
<Badge key={item} variant="outline">{item}</Badge>
))}
</div>
) : null}
{run.corrections ? (
<div className="rounded-xl bg-muted/50 p-3 text-sm leading-6 whitespace-pre-wrap">
{run.corrections}
</div>
) : null}
</div>
</div>
</CardContent>
</Card>
))}
{runs.length === 0 ? (
<Card className="border-dashed">
<CardContent className="pt-6 text-sm text-muted-foreground">
</CardContent>
</Card>
) : null}
</div>
</section>
</div>
);
}