對齊 ADR-017 v1.2 決策 2:model-card 下載按鈕 → 打 visionA endpoint 拿 url+token → fetch 跨 origin 直連 FAA + Authorization Bearer → blob 觸發下載(不經 visionA、不經 AWS)。 - src/lib/api/model-download.ts: getModelDownload(打 GET /api/models/:id/download)+ downloadModelFile(fetch + Bearer header + blob,credentials:"omit" 不帶 visionA cookie)+ triggerBlobDownload(延遲 revoke + finally 釋放)+ deriveDownloadFilename + ModelDownloadError - model-store: downloadingId 互斥(同時只一個下載、finally 必清)+ downloadModel action + isModelDownloadable(source==converted && status==ready) - model-card: 下載按鈕(converted+ready 才顯示、上傳類隱藏;preventDefault+stopPropagation 防觸發外層 Link 導航;loading;toast 錯誤) - i18n zh/en: models.action.download.* / models.download.*(各 error code 對應訊息) 關鍵差異:模型庫下載跨 origin + 需 Bearer header,不能用 <a href> navigation(無法帶 header), 必須 fetch+blob。與既有 conversion download(同 origin navigation)分流。 測試: 43 unit + 互動 test(mock fetch / 按鈕互動 / 顯示條件 / error 分流);tsc/lint/build 全綠。 Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過(ADR 決策 2 規格 7/7 符合)。 待 stage 實測: FAA 端須設 CORS(允許 visionA origin + preflight Allow-Headers: Authorization), 撞到會落 network_error。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
"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。
|
||
* 整張卡片包在 <Link> 裡 → 下載按鈕需 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<ModelStatus, { variant: "default" | "secondary" | "destructive" | "outline"; key: string }> = {
|
||
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) => {
|
||
// 整張卡片是 <Link> → 阻止冒泡到外層導航。
|
||
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 (
|
||
<Link href={`/models/${model.id}`} data-testid="model-card">
|
||
<Card
|
||
className={cn(
|
||
"hover:bg-accent/40 h-full cursor-pointer transition-shadow hover:shadow-md",
|
||
)}
|
||
>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<CardTitle className="text-base leading-tight">{model.name}</CardTitle>
|
||
<Badge variant={statusMeta.variant} className="shrink-0 text-xs">
|
||
{t(statusMeta.key)}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
<Badge variant="outline" className="text-xs">
|
||
{model.targetChip.toUpperCase()}
|
||
</Badge>
|
||
{model.source === "preset" && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
{t("models.source.preset")}
|
||
</Badge>
|
||
)}
|
||
{model.source === "converted" && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
{t("models.source.converted")}
|
||
</Badge>
|
||
)}
|
||
{model.category && (
|
||
<Badge variant="outline" className="text-xs">
|
||
{model.category}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||
<div>
|
||
<p className="text-muted-foreground">{t("models.size")}</p>
|
||
<p className="font-medium">{formatFileSize(model.fileSize)}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-muted-foreground">{t("models.createdAt")}</p>
|
||
<p className="font-medium">
|
||
{model.createdAt
|
||
? new Date(model.createdAt).toLocaleDateString()
|
||
: "—"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{downloadable && (
|
||
<div className="mt-3 flex justify-end">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleDownload}
|
||
disabled={isAnyDownloading || localBusy}
|
||
aria-label={t("models.action.download.aria")}
|
||
data-testid="model-card-download"
|
||
>
|
||
{isDownloading || localBusy ? (
|
||
<>
|
||
<Spinner size="sm" label={t("models.action.downloading")} />
|
||
{t("models.action.downloading")}
|
||
</>
|
||
) : (
|
||
<>
|
||
<DownloadIcon aria-hidden />
|
||
{t("models.action.download")}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</Link>
|
||
);
|
||
}
|