424 行
16 KiB
TypeScript
424 行
16 KiB
TypeScript
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>
|
||
);
|
||
}
|