"use client"; /** * ModelCard — 模型卡片(雲端版) * * 來源:`local-tool/frontend/src/components/models/model-card.tsx`(雲端版精簡) * * 改動: * - 欄位對齊 api-spec §4 的 Model(targetChip / fileSize / source / status / createdAt) * 不再用 local-tool 的 accuracy / fps / supportedHardware(那些是內建 preset 的附加 metadata) * - 狀態 Badge 對齊 flow-model-upload §5.4(uploading / scanning / ready / rejected) * - 比較模式(Checkbox)保留,但預設關閉(雛形不做 comparison) * - Phase 0.9:可下載的 model(converted + ready)顯示「下載」按鈕,走 FAA delegated download。 * 整張卡片包在 裡 → 下載按鈕需 preventDefault + stopPropagation 避免觸發導航。 */ import { DownloadIcon } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { useT } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { isModelDownloadable, useModelStore, type ModelStatus, type ModelSummary, } from "@/stores/model-store"; interface ModelCardProps { model: ModelSummary; } /** 狀態 Badge variant 映射(對齊 flow-model-upload §5.4) */ const STATUS_VARIANT: Record = { uploading: { variant: "secondary", key: "models.status.uploading" }, scanning: { variant: "secondary", key: "models.status.scanning" }, ready: { variant: "default", key: "models.status.ready" }, rejected: { variant: "destructive", key: "models.status.rejected" }, }; function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; 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`; } export function ModelCard({ model }: ModelCardProps) { const t = useT(); const statusMeta = STATUS_VARIANT[model.status]; const downloadModel = useModelStore((s) => s.downloadModel); // per-card loading:只在「下載中的是這張卡」時顯示 spinner。 const isDownloading = useModelStore((s) => s.downloadingId === model.id); // 任一卡片下載中時,其他卡片的下載按鈕 disable(store 同時只允許一個下載)。 const isAnyDownloading = useModelStore((s) => s.downloadingId !== null); const [localBusy, setLocalBusy] = useState(false); const downloadable = isModelDownloadable(model); const handleDownload = async (e: React.MouseEvent) => { // 整張卡片是 → 阻止冒泡到外層導航。 e.preventDefault(); e.stopPropagation(); if (localBusy || isAnyDownloading) return; setLocalBusy(true); const result = await downloadModel(model); setLocalBusy(false); if (result.ok) { toast.success(t("models.download.toast.start"), { description: t("models.download.toast.hint"), }); } else { // 用 backend code 對應 i18n;找不到對應 key 時 t() 回 key 本身,仍退化到 unknown。 const key = `models.download.error.${result.code}`; const desc = t(key); toast.error(t("models.download.error.title"), { description: desc === key ? t("models.download.error.unknown") : desc, }); } }; return ( {model.name} {t(statusMeta.key)} {model.targetChip.toUpperCase()} {model.source === "preset" && ( {t("models.source.preset")} )} {model.source === "converted" && ( {t("models.source.converted")} )} {model.category && ( {model.category} )} {t("models.size")} {formatFileSize(model.fileSize)} {t("models.createdAt")} {model.createdAt ? new Date(model.createdAt).toLocaleDateString() : "—"} {downloadable && ( {isDownloading || localBusy ? ( <> {t("models.action.downloading")} > ) : ( <> {t("models.action.download")} > )} )} ); }
{t("models.size")}
{formatFileSize(model.fileSize)}
{t("models.createdAt")}
{model.createdAt ? new Date(model.createdAt).toLocaleDateString() : "—"}