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>
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* ModelProfileClient — 模型 profile 頁(owner / 公開雙態)
|
||
*
|
||
* 對齊設計規格 §6 + API 契約 §2(GET /: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 download(lib/api/model-download),走同一 endpoint
|
||
* (契約 §3:download 已加共享權限檢查)。
|
||
*/
|
||
|
||
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-store(owner-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>
|
||
);
|
||
}
|