jim800121chen 53e8ab4ae1 feat(visionA-frontend): Phase 0.9 模型庫下載 — 前端對接 FAA delegated download
對齊 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>
2026-06-07 04:50:17 +08:00

171 lines
6.4 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";
/**
* ModelCard — 模型卡片(雲端版)
*
* 來源:`local-tool/frontend/src/components/models/model-card.tsx`(雲端版精簡)
*
* 改動:
* - 欄位對齊 api-spec §4 的 ModeltargetChip / fileSize / source / status / createdAt
* 不再用 local-tool 的 accuracy / fps / supportedHardware那些是內建 preset 的附加 metadata
* - 狀態 Badge 對齊 flow-model-upload §5.4uploading / scanning / ready / rejected
* - 比較模式Checkbox保留但預設關閉雛形不做 comparison
* - Phase 0.9:可下載的 modelconverted + 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);
// 任一卡片下載中時,其他卡片的下載按鈕 disablestore 同時只允許一個下載)。
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>
);
}