visionA/visionA-frontend/src/app/models/[id]/model-profile-client.tsx
jim800121chen 6a797d5eb5 feat(frontend): 設備管理三態/註冊 + 模型共享庫/公開設定(B + C)
B 設備管理:device-store 補 registeredAt + register/unregister actions;
deriveTriState 三態(已連接/未連接/已連接未註冊);三態 badge(warning token
+ icon + 文字不只色);排序/filter chips;註冊 UI(明確區分取消註冊≠移除)。

C 模型共享:/models/library cursor 無限捲動 + 搜尋/filter/排序;visibility badge
三態;公開設定 Dialog(RadioGroup + shares 管理 + public 警告 + 二次確認);
profile 頁 owner/公開雙態;radio-group 新元件;owner 用 name 不洩 email。

共用檔 types/api.ts(B error codes)+ i18n en/zh(devices.* B / models.* C)。
零新 Design Token。reviewer B(0C/0M) + C(0C/0M、設計12/12 API8/8) 通過。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:30:22 +08:00

338 lines
12 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";
/**
* ModelProfileClient — 模型 profile 頁owner / 公開雙態)
*
* 對齊設計規格 §6 + API 契約 §2GET /:id/profile。取代舊的 owner-only detail
* 用 `GET /api/models/:id/profile` 取詳情(後端依身份裁剪 + 權限檢查),依 `myAccess`
* 決定渲染 owner 版或公開 / 共享版。
*
* 雙態差異(設計規格 §6.2
* - owner 版myAccess==='owner'):下載(若可)+ 刪除 + 【新增】公開設定 Dialog
* - 公開 / 共享版僅下載canDownload 為 true 時);隱藏刪除 / 公開設定;顯示 ModelOwnerBar
*
* 錯誤(契約 §2無可見性 → 404防 enumeration→ 全頁「找不到 / 無權限」EmptyState。
*
* 下載沿用既有 FAA delegated downloadlib/api/model-download走同一 endpoint
* (契約 §3download 已加共享權限檢查)。
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, DownloadIcon, Globe, SearchX, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { ModelOwnerBar } from "@/components/models/model-owner-bar";
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
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 { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
getModelDownload,
ModelDownloadError,
triggerNavDownload,
} from "@/lib/api/model-download";
import { useT } from "@/lib/i18n/context";
import { useModelSharingStore } from "@/stores/model-sharing-store";
import { useModelStore } from "@/stores/model-store";
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`;
}
function formatInputShape(shape: number[]): string {
return shape.join(" × ");
}
const CLASSES_PREVIEW_LIMIT = 8;
interface ModelProfileClientProps {
id: string;
}
export function ModelProfileClient({ id }: ModelProfileClientProps) {
const t = useT();
const router = useRouter();
const profile = useModelSharingStore((s) => s.profile);
const isLoading = useModelSharingStore((s) => s.isProfileLoading);
const profileError = useModelSharingStore((s) => s.profileError);
const loadProfile = useModelSharingStore((s) => s.loadProfile);
const clearProfile = useModelSharingStore((s) => s.clearProfile);
// 刪除沿用既有 model-storeowner-only DELETE /api/models/:id
const deleteModel = useModelStore((s) => s.deleteModel);
const [deleting, setDeleting] = useState(false);
const [downloadBusy, setDownloadBusy] = useState(false);
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
useEffect(() => {
if (id) void loadProfile(id);
return () => clearProfile();
}, [id, loadProfile, clearProfile]);
const isOwner = profile?.myAccess === "owner";
async function handleDownload() {
if (!profile || downloadBusy) return;
setDownloadBusy(true);
try {
const grant = await getModelDownload(profile.id);
triggerNavDownload(grant.downloadUrl);
toast.success(t("models.download.toast.start"), {
description: t("models.download.toast.hint"),
});
} catch (err) {
const code = err instanceof ModelDownloadError ? err.code : "unknown";
const key = `models.download.error.${code}`;
const desc = t(key);
toast.error(t("models.download.error.title"), {
description: desc === key ? t("models.download.error.unknown") : desc,
});
} finally {
setDownloadBusy(false);
}
}
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"));
}
}
const backButton = (
<Link href="/models/library">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
);
// 載入中
if (isLoading && !profile) {
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>
);
}
// 無權限 / 找不到(契約:無可見性回 404合併「找不到 / 無權限」)。
if (profileError || !profile) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
{backButton}
<EmptyState
icon={SearchX}
title={t("models.profile.notFound.title")}
description={t("models.profile.notFound.description")}
action={{
label: t("models.profile.backToLibrary"),
onClick: () => router.push("/models/library"),
}}
/>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 px-6 py-8">
{backButton}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<h1 className="text-2xl font-bold">{profile.name}</h1>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{profile.targetChip.toUpperCase()}</Badge>
<Badge variant={profile.status === "ready" ? "default" : "secondary"}>
{t(`models.status.${profile.status === "ready" ? "ready" : "scanning"}`)}
</Badge>
{profile.source !== "uploaded" && (
<Badge variant="secondary">{t(`models.source.${profile.source}`)}</Badge>
)}
{/* owner 看到自己的 visibility非 owner 看到共享標示。 */}
<ModelVisibilityBadge
visibility={profile.visibility}
sharedWithMe={!isOwner}
/>
</div>
</div>
<div className="flex items-center gap-2">
{profile.canDownload && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloadBusy}
aria-label={t("models.action.download.aria")}
data-testid="profile-download"
>
{downloadBusy ? (
<>
<Spinner size="sm" label={t("models.action.downloading")} />
{t("models.action.downloading")}
</>
) : (
<>
<DownloadIcon aria-hidden className="mr-2 size-4" />
{t("models.action.download")}
</>
)}
</Button>
)}
{/* owner-only 操作:公開設定 + 刪除 */}
{isOwner && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setVisibilityDialogOpen(true)}
data-testid="profile-visibility"
>
<Globe aria-hidden className="mr-2 size-4" />
{t("models.visibility.title")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deleting}>
<Trash2 aria-hidden className="mr-2 size-4" />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
<AlertDialogDescription>{profile.name}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
</div>
{/* 非 owner擁有者資訊列。 */}
{!isOwner && (
<ModelOwnerBar ownerName={profile.owner.name} sharedAt={profile.updatedAt} />
)}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("models.detail.description")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{profile.description ? (
<p className="text-sm">{profile.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(profile.fileSize)} />
<InfoRow
label={t("models.createdAt")}
value={profile.createdAt ? new Date(profile.createdAt).toLocaleString() : "—"}
/>
{profile.framework && (
<InfoRow
label={t("models.detail.framework")}
value={<span className="font-mono text-xs">{profile.framework}</span>}
/>
)}
{profile.inputShape && profile.inputShape.length > 0 && (
<InfoRow
label={t("models.detail.inputShape")}
value={
<span className="font-mono text-xs">
{formatInputShape(profile.inputShape)}
</span>
}
/>
)}
</div>
{profile.classes && profile.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">
{profile.classes.length} {t("models.detail.classesCountSuffix")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{profile.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
<Badge key={`${i}-${c}`} variant="secondary" className="font-normal">
{c}
</Badge>
))}
{profile.classes.length > CLASSES_PREVIEW_LIMIT && (
<Badge variant="outline" className="font-normal">
+{profile.classes.length - CLASSES_PREVIEW_LIMIT}
</Badge>
)}
</div>
</div>
)}
</CardContent>
</Card>
{isOwner && (
<ModelVisibilityDialog
modelId={profile.id}
modelName={profile.name}
currentVisibility={profile.visibility}
open={visibilityDialogOpen}
onOpenChange={setVisibilityDialogOpen}
/>
)}
</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>
);
}