對應 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>
238 lines
8.3 KiB
TypeScript
238 lines
8.3 KiB
TypeScript
"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_shape(NCHW,如 [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 omitempty,normalize 後為 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>
|
||
);
|
||
}
|