visionA/visionA-frontend/src/app/models/[id]/model-detail-client.tsx
jim800121chen dc1c0dbee4 feat(visionA-frontend): B4 model 詳細頁顯示 metadata(input_shape/classes/framework)
對應 backend B4:model 詳細頁顯示轉檔帶來的 metadata。
- Model 型別加 inputShape/classes/framework(optional);normalize 雙吃 snake/camel
- input_shape 顯示成 1 × 3 × 224 × 224;classes 前 8 個 + N 折疊;framework 直接顯示
- 有值才顯示(沒值不渲染空白);中英 i18n
- tsc / lint / next build 綠

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:37:36 +08:00

238 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* 模型詳情 — /models/[id]
*
* 對齊 pages.md §8.2。F6 做資訊顯示 + 刪除;部署 / 比較 留到 F7+。
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useT } from "@/lib/i18n/context";
import { useModelStore } from "@/stores/model-store";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
function formatFileSize(bytes: number): string {
if (!bytes) return "—";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
/** input_shapeNCHW如 [1,3,224,224])→ 易讀字串 `1 × 3 × 224 × 224`。 */
function formatInputShape(shape: number[]): string {
return shape.join(" × ");
}
/** classes 多時只展示前幾個 Badge其餘以 `+N` 表示,避免塞爆版面。 */
const CLASSES_PREVIEW_LIMIT = 8;
interface ModelDetailClientProps {
id: string;
}
export function ModelDetailClient({ id }: ModelDetailClientProps) {
const t = useT();
const router = useRouter();
const selectedModel = useModelStore((s) => s.selectedModel);
const isLoading = useModelStore((s) => s.isLoading);
const fetchModel = useModelStore((s) => s.fetchModel);
const deleteModel = useModelStore((s) => s.deleteModel);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (id) void fetchModel(id);
}, [id, fetchModel]);
async function handleDelete() {
setDeleting(true);
const ok = await deleteModel(id);
setDeleting(false);
if (ok) {
toast.success(t("common.save"));
router.push("/models");
} else {
toast.error(t("common.error"));
}
}
if (isLoading && !selectedModel) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-48 rounded-lg" />
</div>
);
}
if (!selectedModel) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
<Link href="/models">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground text-sm">{t("common.loading")}</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 px-6 py-8">
<Link href="/models">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<h1 className="text-2xl font-bold">{selectedModel.name}</h1>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{selectedModel.targetChip.toUpperCase()}</Badge>
<Badge variant={selectedModel.status === "ready" ? "default" : "secondary"}>
{t(`models.status.${selectedModel.status}`)}
</Badge>
{selectedModel.source !== "uploaded" && (
<Badge variant="secondary">
{t(`models.source.${selectedModel.source}`)}
</Badge>
)}
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deleting}>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
<AlertDialogDescription>{selectedModel.name}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("models.detail.description")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{selectedModel.description ? (
<p className="text-sm">{selectedModel.description}</p>
) : (
<p className="text-muted-foreground text-sm"></p>
)}
<div className="grid grid-cols-2 gap-3 pt-3 text-sm">
<InfoRow label={t("models.size")} value={formatFileSize(selectedModel.fileSize)} />
<InfoRow
label={t("models.createdAt")}
value={selectedModel.createdAt ? new Date(selectedModel.createdAt).toLocaleString() : "—"}
/>
{/* B4 轉檔 metadata有值才顯示沒值時 backend omitemptynormalize 後為 undefined。 */}
{selectedModel.framework && (
<InfoRow
label={t("models.detail.framework")}
value={<span className="font-mono text-xs">{selectedModel.framework}</span>}
/>
)}
{selectedModel.inputShape && selectedModel.inputShape.length > 0 && (
<InfoRow
label={t("models.detail.inputShape")}
value={
<span className="font-mono text-xs">
{formatInputShape(selectedModel.inputShape)}
</span>
}
/>
)}
{selectedModel.version && (
<InfoRow label={t("models.detail.version")} value={selectedModel.version} />
)}
{selectedModel.checksum && (
<InfoRow
label={t("models.detail.checksum")}
value={<span className="font-mono text-xs">{selectedModel.checksum}</span>}
/>
)}
</div>
{/* classes 可能很長,獨立成 full-width 區塊,用 Badge 列出(超量只顯示前 N 個 + 餘量)。 */}
{selectedModel.classes && selectedModel.classes.length > 0 && (
<div className="space-y-2 border-t pt-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{t("models.detail.classes")}</span>
<span className="text-muted-foreground text-xs">
{selectedModel.classes.length} {t("models.detail.classesCountSuffix")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{selectedModel.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
<Badge key={`${i}-${c}`} variant="secondary" className="font-normal">
{c}
</Badge>
))}
{selectedModel.classes.length > CLASSES_PREVIEW_LIMIT && (
<Badge variant="outline" className="font-normal">
+{selectedModel.classes.length - CLASSES_PREVIEW_LIMIT}
</Badge>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className="text-right">{value}</span>
</div>
);
}