diff --git a/visionA-frontend/src/app/models/[id]/model-profile-client.test.tsx b/visionA-frontend/src/app/models/[id]/model-profile-client.test.tsx
new file mode 100644
index 0000000..fef3240
--- /dev/null
+++ b/visionA-frontend/src/app/models/[id]/model-profile-client.test.tsx
@@ -0,0 +1,147 @@
+/**
+ * ModelProfileClient 雙態測試
+ *
+ * 覆蓋:
+ * - owner 版(myAccess=owner):顯示公開設定 + 刪除按鈕
+ * - 公開 / 共享版(myAccess=viewer):隱藏公開設定 / 刪除;顯示 owner 資訊列
+ * - canDownload → 顯示下載鈕
+ * - 無權限 / 404(profileError)→ 全頁 EmptyState「找不到 / 無權限」
+ *
+ * 走 store mock 模式,直接以 _setProfile / setState 注入 profile,不打真實 API。
+ */
+
+import { render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { LocaleProvider } from "@/lib/i18n/context";
+import type { ModelProfile } from "@/lib/api/model-sharing";
+import { useModelSharingStore } from "@/stores/model-sharing-store";
+
+vi.mock("sonner", () => ({
+ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
+}));
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ replace: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ refresh: vi.fn(),
+ prefetch: vi.fn(),
+ }),
+}));
+
+import { ModelProfileClient } from "./model-profile-client";
+
+const ownerProfile: ModelProfile = {
+ id: "p1",
+ name: "我的模型",
+ targetChip: "kl520",
+ fileSize: 1024 * 1024,
+ source: "converted",
+ status: "ready",
+ visibility: "private",
+ owner: { id: "me", name: "我", isMe: true },
+ myAccess: "owner",
+ canDownload: true,
+ createdAt: "2026-07-01T00:00:00Z",
+ updatedAt: "2026-07-02T00:00:00Z",
+};
+
+const viewerProfile: ModelProfile = {
+ ...ownerProfile,
+ id: "p2",
+ name: "共享模型",
+ visibility: "public",
+ owner: { id: "alice", name: "Alice", isMe: false },
+ myAccess: "viewer",
+};
+
+/**
+ * 讓 loadProfile 直接把注入的 profile 放進 store(不打 API),避免 useEffect 覆蓋。
+ */
+function stubLoadProfile(profile: ModelProfile | null, error: string | null = null) {
+ useModelSharingStore.setState({
+ _mockMode: true,
+ loadProfile: async () => {
+ useModelSharingStore.setState({
+ profile,
+ profileError: error,
+ isProfileLoading: false,
+ });
+ },
+ clearProfile: () => {
+ /* 測試中保留注入的 profile,不清空 */
+ },
+ });
+}
+
+function renderProfile(id: string) {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ useModelSharingStore.setState({
+ profile: null,
+ isProfileLoading: false,
+ profileError: null,
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ // 還原 store 被 stub 的 actions
+ useModelSharingStore.setState(useModelSharingStore.getInitialState?.() ?? {});
+});
+
+describe("owner 版", () => {
+ it("顯示公開設定 + 刪除 + 下載", async () => {
+ stubLoadProfile(ownerProfile);
+ renderProfile("p1");
+ await waitFor(() => expect(screen.getByText("我的模型")).toBeInTheDocument());
+
+ expect(screen.getByTestId("profile-visibility")).toBeInTheDocument();
+ expect(screen.getByTestId("profile-download")).toBeInTheDocument();
+ // 刪除鈕(common.delete)
+ expect(screen.getByText("刪除")).toBeInTheDocument();
+ // owner 不顯示擁有者資訊列
+ expect(screen.queryByTestId("model-owner-bar")).not.toBeInTheDocument();
+ });
+});
+
+describe("公開 / 共享版(非 owner)", () => {
+ it("隱藏公開設定 / 刪除;顯示 owner 資訊列", async () => {
+ stubLoadProfile(viewerProfile);
+ renderProfile("p2");
+ await waitFor(() => expect(screen.getByText("共享模型")).toBeInTheDocument());
+
+ expect(screen.queryByTestId("profile-visibility")).not.toBeInTheDocument();
+ // 下載仍可(canDownload=true)
+ expect(screen.getByTestId("profile-download")).toBeInTheDocument();
+ // 擁有者資訊列
+ expect(screen.getByTestId("model-owner-bar")).toBeInTheDocument();
+ expect(screen.getByTestId("model-owner-bar")).toHaveTextContent("Alice");
+ });
+
+ it("canDownload=false → 不顯示下載鈕", async () => {
+ stubLoadProfile({ ...viewerProfile, canDownload: false });
+ renderProfile("p2");
+ await waitFor(() => expect(screen.getByText("共享模型")).toBeInTheDocument());
+ expect(screen.queryByTestId("profile-download")).not.toBeInTheDocument();
+ });
+});
+
+describe("無權限 / 404", () => {
+ it("profileError=not_found → 全頁 EmptyState「找不到 / 無權限」", async () => {
+ stubLoadProfile(null, "not_found");
+ renderProfile("nope");
+ await waitFor(() =>
+ expect(screen.getByText("找不到模型或沒有存取權")).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/visionA-frontend/src/app/models/[id]/model-profile-client.tsx b/visionA-frontend/src/app/models/[id]/model-profile-client.tsx
new file mode 100644
index 0000000..10c731e
--- /dev/null
+++ b/visionA-frontend/src/app/models/[id]/model-profile-client.tsx
@@ -0,0 +1,337 @@
+"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 = (
+
+
+
+ );
+
+ // 載入中
+ if (isLoading && !profile) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ // 無權限 / 找不到(契約:無可見性回 404,合併「找不到 / 無權限」)。
+ if (profileError || !profile) {
+ return (
+
+ {backButton}
+ router.push("/models/library"),
+ }}
+ />
+
+ );
+ }
+
+ return (
+
+ {backButton}
+
+
+
+
{profile.name}
+
+ {profile.targetChip.toUpperCase()}
+
+ {t(`models.status.${profile.status === "ready" ? "ready" : "scanning"}`)}
+
+ {profile.source !== "uploaded" && (
+ {t(`models.source.${profile.source}`)}
+ )}
+ {/* owner 看到自己的 visibility;非 owner 看到共享標示。 */}
+
+
+
+
+
+ {profile.canDownload && (
+
+ )}
+
+ {/* owner-only 操作:公開設定 + 刪除 */}
+ {isOwner && (
+ <>
+
+
+
+
+
+
+
+ {t("common.confirm")}
+ {profile.name}
+
+
+ {t("common.cancel")}
+
+ {t("common.delete")}
+
+
+
+
+ >
+ )}
+
+
+
+ {/* 非 owner:擁有者資訊列。 */}
+ {!isOwner && (
+
+ )}
+
+
+
+ {t("models.detail.description")}
+
+
+ {profile.description ? (
+ {profile.description}
+ ) : (
+ —
+ )}
+
+
+
+ {profile.framework && (
+ {profile.framework}}
+ />
+ )}
+ {profile.inputShape && profile.inputShape.length > 0 && (
+
+ {formatInputShape(profile.inputShape)}
+
+ }
+ />
+ )}
+
+
+ {profile.classes && profile.classes.length > 0 && (
+
+
+ {t("models.detail.classes")}
+
+ {profile.classes.length} {t("models.detail.classesCountSuffix")}
+
+
+
+ {profile.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
+
+ {c}
+
+ ))}
+ {profile.classes.length > CLASSES_PREVIEW_LIMIT && (
+
+ +{profile.classes.length - CLASSES_PREVIEW_LIMIT}
+
+ )}
+
+
+ )}
+
+
+
+ {isOwner && (
+
+ )}
+
+ );
+}
+
+function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/visionA-frontend/src/app/models/[id]/page.tsx b/visionA-frontend/src/app/models/[id]/page.tsx
index 3741db9..a32182e 100644
--- a/visionA-frontend/src/app/models/[id]/page.tsx
+++ b/visionA-frontend/src/app/models/[id]/page.tsx
@@ -1,10 +1,16 @@
-import { ModelDetailClient } from "./model-detail-client";
+import { ModelProfileClient } from "./model-profile-client";
+/**
+ * 模型 profile 頁 — /models/[id]
+ *
+ * 模型共享功能後改用 ModelProfileClient(owner / 公開雙態,走 GET /:id/profile,
+ * 支援非 owner 依權限檢視)。舊的 owner-only ModelDetailClient 保留於同目錄但不再掛路由。
+ */
export default async function ModelDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
- return ;
+ return ;
}
diff --git a/visionA-frontend/src/app/models/library/library-client.test.tsx b/visionA-frontend/src/app/models/library/library-client.test.tsx
new file mode 100644
index 0000000..1e38d85
--- /dev/null
+++ b/visionA-frontend/src/app/models/library/library-client.test.tsx
@@ -0,0 +1,129 @@
+/**
+ * LibraryClient 測試(共享模型庫 + cursor 無限捲動)
+ *
+ * 覆蓋:
+ * - 首屏載入 → skeleton
+ * - 載入完成 → 卡片網格 + 哨兵(hasMore)
+ * - 觸發哨兵(模擬 IntersectionObserver)→ loadMore append
+ * - 空狀態(無資料)
+ * - 搜尋無結果空狀態
+ *
+ * IntersectionObserver 在 jsdom 需 mock:這裡用可手動觸發的假 observer,
+ * 讓測試主動「讓哨兵進入視窗」以驗證 loadMore。走 store mock 模式(fixtures)。
+ */
+
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { LocaleProvider } from "@/lib/i18n/context";
+import {
+ DEFAULT_LIBRARY_FILTERS,
+ useModelSharingStore,
+} from "@/stores/model-sharing-store";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+// 可手動觸發的假 IntersectionObserver。
+let intersectCallbacks: IntersectionObserverCallback[] = [];
+class FakeIntersectionObserver {
+ constructor(cb: IntersectionObserverCallback) {
+ intersectCallbacks.push(cb);
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ takeRecords(): IntersectionObserverEntry[] {
+ return [];
+ }
+ root = null;
+ rootMargin = "";
+ thresholds = [];
+}
+
+function triggerIntersect() {
+ for (const cb of intersectCallbacks) {
+ cb(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ );
+ }
+}
+
+import { LibraryClient } from "./library-client";
+
+function resetStore() {
+ useModelSharingStore.setState({
+ items: [],
+ filters: { ...DEFAULT_LIBRARY_FILTERS },
+ cursor: null,
+ hasMore: false,
+ isLoading: false,
+ isLoadingMore: false,
+ listError: null,
+ _mockMode: true,
+ });
+}
+
+beforeEach(() => {
+ intersectCallbacks = [];
+ vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
+ resetStore();
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+function renderLibrary() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("首屏載入", () => {
+ it("載入完成 → 顯示卡片網格 + 哨兵(hasMore)", async () => {
+ renderLibrary();
+ // mock loadFirstPage 是 async;等網格出現
+ await waitFor(() => expect(screen.getByTestId("library-grid")).toBeInTheDocument());
+ // 首頁 24 筆 → hasMore=true → 哨兵存在
+ expect(screen.getByTestId("library-sentinel")).toBeInTheDocument();
+ });
+});
+
+describe("cursor 無限捲動", () => {
+ it("哨兵進入視窗 → loadMore append(總數增加)", async () => {
+ renderLibrary();
+ await waitFor(() => expect(screen.getByTestId("library-grid")).toBeInTheDocument());
+
+ const before = useModelSharingStore.getState().items.length;
+ expect(before).toBe(24);
+
+ // 模擬捲到底:哨兵進入視窗
+ await act(async () => {
+ triggerIntersect();
+ // 等 store loadMore 完成
+ await new Promise((r) => setTimeout(r, 0));
+ });
+
+ const after = useModelSharingStore.getState().items.length;
+ expect(after).toBeGreaterThan(before);
+ expect(after).toBe(30); // fixtures 共 30 筆,第二頁補齊
+ });
+});
+
+describe("空狀態", () => {
+ it("搜尋無結果 → 顯示搜尋空狀態", async () => {
+ useModelSharingStore.setState({
+ filters: { ...DEFAULT_LIBRARY_FILTERS, q: "zzz-no-such" },
+ });
+ renderLibrary();
+ await waitFor(() =>
+ expect(screen.getByText("找不到符合條件的模型")).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/visionA-frontend/src/app/models/library/library-client.tsx b/visionA-frontend/src/app/models/library/library-client.tsx
new file mode 100644
index 0000000..7692775
--- /dev/null
+++ b/visionA-frontend/src/app/models/library/library-client.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+/**
+ * LibraryClient — 共享模型庫列表頁(cursor 無限捲動)
+ *
+ * 對齊設計規格 §4 + API 契約 §1。使用者拍板「cursor 無限捲動」(往下捲自動載入更多)。
+ *
+ * 狀態機(設計規格 §7):
+ * - 首屏載入 → skeleton 網格
+ * - 有資料 → 卡片網格 + 底部哨兵(IntersectionObserver 觸發 loadMore)
+ * - 續載中 → 底部補 skeleton 卡片
+ * - 空(無共享模型) → EmptyState
+ * - 搜尋無結果 → EmptyState + 清除搜尋 CTA
+ * - 列表錯誤 → 錯誤提示 + 重試
+ *
+ * 搜尋 debounce 300ms(設計規格 §4.4):local searchInput → debounce → store.setFilters({ q })。
+ */
+
+import { useEffect, useRef, useState } from "react";
+import { Boxes, SearchX, Users } from "lucide-react";
+
+import { LibraryModelCard } from "@/components/models/library-model-card";
+import { LibraryToolbar } from "@/components/models/library-toolbar";
+import { Button } from "@/components/ui/button";
+import { EmptyState } from "@/components/ui/empty-state";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { useT } from "@/lib/i18n/context";
+import { useModelSharingStore } from "@/stores/model-sharing-store";
+
+const SEARCH_DEBOUNCE_MS = 300;
+
+function SkeletonGrid({ count = 8 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export function LibraryClient() {
+ const t = useT();
+
+ const items = useModelSharingStore((s) => s.items);
+ const filters = useModelSharingStore((s) => s.filters);
+ const hasMore = useModelSharingStore((s) => s.hasMore);
+ const isLoading = useModelSharingStore((s) => s.isLoading);
+ const isLoadingMore = useModelSharingStore((s) => s.isLoadingMore);
+ const listError = useModelSharingStore((s) => s.listError);
+ const loadFirstPage = useModelSharingStore((s) => s.loadFirstPage);
+ const loadMore = useModelSharingStore((s) => s.loadMore);
+ const setFilters = useModelSharingStore((s) => s.setFilters);
+
+ // 搜尋框 local state(受控),debounce 後才推進 store filters。
+ const [searchInput, setSearchInput] = useState(filters.q);
+ const debounceRef = useRef | null>(null);
+
+ // 首次掛載載入首頁。
+ useEffect(() => {
+ void loadFirstPage();
+ }, [loadFirstPage]);
+
+ // 搜尋 debounce → setFilters(會重置分頁重載)。
+ useEffect(() => {
+ if (searchInput === filters.q) return;
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ setFilters({ q: searchInput });
+ }, SEARCH_DEBOUNCE_MS);
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ };
+ }, [searchInput, filters.q, setFilters]);
+
+ const { sentinelRef } = useInfiniteScroll({
+ enabled: hasMore && !isLoading && !isLoadingMore,
+ onLoadMore: loadMore,
+ });
+
+ const isEmpty = !isLoading && items.length === 0;
+ const isSearchActive =
+ filters.q.trim() !== "" ||
+ filters.targetChip !== "all" ||
+ filters.visibility !== "all" ||
+ filters.owned !== "all";
+
+ return (
+
+
+
{t("models.library.title")}
+
{t("models.library.subtitle")}
+
+
+
+
+ {/* 搜尋結果數(無障礙播報)。 */}
+
+ {t("models.library.resultCount").replace("{n}", String(items.length))}
+
+
+ {/* 首屏載入 */}
+ {isLoading &&
}
+
+ {/* 列表錯誤(首屏) */}
+ {!isLoading && listError && items.length === 0 && (
+
void loadFirstPage(),
+ }}
+ />
+ )}
+
+ {/* 空狀態 */}
+ {isEmpty && !listError && (
+ isSearchActive ? (
+ {
+ setSearchInput("");
+ setFilters({
+ q: "",
+ targetChip: "all",
+ visibility: "all",
+ owned: "all",
+ });
+ },
+ }}
+ />
+ ) : (
+
+ )
+ )}
+
+ {/* 卡片網格 */}
+ {!isLoading && items.length > 0 && (
+ <>
+
+ {items.map((model) => (
+
+ ))}
+
+
+ {/* 續載中 skeleton */}
+ {isLoadingMore && (
+
+
+
+ )}
+
+ {/* 續載錯誤(已有資料時)→ 重試按鈕 */}
+ {listError && !isLoadingMore && (
+
+
+
+ )}
+
+ {/* 無限捲動哨兵(有下一頁且無錯誤時掛載) */}
+ {hasMore && !listError && (
+
+ )}
+
+ {/* 到底提示 */}
+ {!hasMore && (
+
+
+ {t("models.library.end")}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/visionA-frontend/src/app/models/library/page.tsx b/visionA-frontend/src/app/models/library/page.tsx
new file mode 100644
index 0000000..db51f16
--- /dev/null
+++ b/visionA-frontend/src/app/models/library/page.tsx
@@ -0,0 +1,11 @@
+import { LibraryClient } from "./library-client";
+
+/**
+ * 共享模型庫 — /models/library
+ *
+ * 依身份權限可見的模型列表(我的 ∪ 公開 ∪ 同租戶 ∪ 分享給我 ∪ preset),
+ * cursor 無限捲動分頁。對齊 api-model-sharing.md §1、feature-model-sharing-design.md §4。
+ */
+export default function ModelLibraryPage() {
+ return ;
+}
diff --git a/visionA-frontend/src/app/models/page.tsx b/visionA-frontend/src/app/models/page.tsx
index d07bf87..cf815b9 100644
--- a/visionA-frontend/src/app/models/page.tsx
+++ b/visionA-frontend/src/app/models/page.tsx
@@ -12,6 +12,8 @@
*/
import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
+import { Users } from "lucide-react";
import {
ModelFilters,
@@ -19,6 +21,7 @@ import {
} from "@/components/models/model-filters";
import { ModelSection } from "@/components/models/model-section";
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
+import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context";
import {
type ModelSource,
@@ -83,7 +86,15 @@ export default function ModelsPage() {
{t("models.title")}
{t("models.subtitle")}
-
+
+
+
+
+
+
diff --git a/visionA-frontend/src/components/cloud/remote-device-badge.tsx b/visionA-frontend/src/components/cloud/remote-device-badge.tsx
index da8ff0b..d3efa04 100644
--- a/visionA-frontend/src/components/cloud/remote-device-badge.tsx
+++ b/visionA-frontend/src/components/cloud/remote-device-badge.tsx
@@ -20,6 +20,7 @@
import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
+import { formatRelativeTime } from "@/lib/format/relative-time";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { RemoteStatus } from "@/stores/device-store";
@@ -36,30 +37,6 @@ export interface RemoteDeviceBadgeProps {
className?: string;
}
-/**
- * 格式化相對時間(components.md §10.3 規格)。
- * - < 60 秒 → 「剛剛」
- * - < 60 分 → 「X 分鐘前」
- * - < 24 時 → 「X 小時前」
- * - ≥ 24 時 → 絕對時間「MM/DD HH:mm」
- */
-function formatRelativeTime(isoString: string, nowMs: number, t: (k: string) => string): string {
- const ts = Date.parse(isoString);
- if (Number.isNaN(ts)) return "";
- const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
- if (diffSec < 60) return t("remote.lastSeen.justNow");
- const diffMin = Math.floor(diffSec / 60);
- if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
- const diffHour = Math.floor(diffMin / 60);
- if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
- const d = new Date(ts);
- const mm = String(d.getMonth() + 1).padStart(2, "0");
- const dd = String(d.getDate()).padStart(2, "0");
- const hh = String(d.getHours()).padStart(2, "0");
- const mi = String(d.getMinutes()).padStart(2, "0");
- return `${mm}/${dd} ${hh}:${mi}`;
-}
-
export function RemoteDeviceBadge({
status,
lastSeenAt,
diff --git a/visionA-frontend/src/components/devices/device-card.test.tsx b/visionA-frontend/src/components/devices/device-card.test.tsx
index 7d43502..26d5049 100644
--- a/visionA-frontend/src/components/devices/device-card.test.tsx
+++ b/visionA-frontend/src/components/devices/device-card.test.tsx
@@ -63,3 +63,52 @@ describe("DeviceCard — serial 路由 gating(WP-C / ADR-018)", () => {
).not.toBeInTheDocument();
});
});
+
+describe("DeviceCard — 三態分色 + 註冊 UI(TDD §5)", () => {
+ it("已連接未註冊(online + registeredAt null)→ warning 標記(文字「未註冊」+ icon,不只靠色)", () => {
+ renderCard({ ...baseDevice, remoteStatus: "online", registeredAt: null });
+ const badge = screen.getByTestId("unregistered-badge");
+ // 不只靠顏色:badge 有文字「未註冊」
+ expect(badge).toHaveTextContent("未註冊");
+ // 卡片 data-tri-state 標記便於測試/樣式
+ expect(screen.getByTestId("device-card")).toHaveAttribute(
+ "data-tri-state",
+ "online-unregistered",
+ );
+ // 顯示「註冊」動作
+ expect(screen.getByTestId("device-register-btn")).toBeInTheDocument();
+ // 不顯示「取消註冊」
+ expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
+ });
+
+ it("已連接已註冊 → 無未註冊標記,顯示「取消註冊」動作", () => {
+ renderCard({
+ ...baseDevice,
+ remoteStatus: "online",
+ registeredAt: "2026-08-02T10:00:00Z",
+ });
+ expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
+ expect(screen.getByTestId("device-card")).toHaveAttribute(
+ "data-tri-state",
+ "online-registered",
+ );
+ expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
+ expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
+ });
+
+ it("離線未註冊 → 無未註冊標記、無註冊動作(需先連線)", () => {
+ renderCard({ ...baseDevice, remoteStatus: "offline", registeredAt: null });
+ expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
+ });
+
+ it("離線已註冊 → 顯示「取消註冊」(已註冊不論在線與否都可取消)", () => {
+ renderCard({
+ ...baseDevice,
+ remoteStatus: "offline",
+ registeredAt: "2026-08-02T10:00:00Z",
+ });
+ expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
+ });
+});
diff --git a/visionA-frontend/src/components/devices/device-card.tsx b/visionA-frontend/src/components/devices/device-card.tsx
index 69a4bb9..c290567 100644
--- a/visionA-frontend/src/components/devices/device-card.tsx
+++ b/visionA-frontend/src/components/devices/device-card.tsx
@@ -23,6 +23,8 @@
import Link from "next/link";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
+import { DeviceRegisterActions } from "@/components/devices/device-register-actions";
+import { UnregisteredBadge } from "@/components/devices/unregistered-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -30,6 +32,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { deriveTriState } from "@/lib/device-state";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DeviceSummary } from "@/stores/device-store";
@@ -44,15 +47,22 @@ export function DeviceCard({ device }: DeviceCardProps) {
const isOnline = device.remoteStatus === "online";
// WP-C(ADR-018):serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
const hasSerial = !!device.serialNumber;
+ // 三態(TDD §5.2):online-unregistered = 已連接未註冊(第三態,走 warning 色)。
+ const triState = deriveTriState(device);
+ const isOnlineUnregistered = triState === "online-unregistered";
+ const isRegistered = !!device.registeredAt;
return (
@@ -63,11 +73,15 @@ export function DeviceCard({ device }: DeviceCardProps) {
{device.name}
)}
-
+
+
+ {/* 第三態標記:連線與註冊是正交兩軸,未註冊用獨立 warning pill 疊加(不塞進連線 badge)。 */}
+ {isOnlineUnregistered && }
+
@@ -93,6 +107,11 @@ export function DeviceCard({ device }: DeviceCardProps) {
{t("common.manage")}
+ {/* 註冊 / 取消註冊:已連接未註冊 → 「註冊」;已註冊 → 「取消註冊」。
+ offline 未註冊不顯示(無從註冊,需先連線)。 */}
+ {(isOnlineUnregistered || isRegistered) && (
+
+ )}
{isOnline && device.flashedModel && hasSerial && (
diff --git a/visionA-frontend/src/components/devices/device-list-controls.tsx b/visionA-frontend/src/components/devices/device-list-controls.tsx
new file mode 100644
index 0000000..3bacc16
--- /dev/null
+++ b/visionA-frontend/src/components/devices/device-list-controls.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+/**
+ * DeviceListControls — 裝置列表的排序 + filter 控制列
+ *
+ * 規格來源:
+ * - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6(排序 + filter)
+ *
+ * 設計:
+ * - 排序:Select(狀態 / 名稱 / 註冊時間),預設「狀態」(保留既有在線優先行為)。
+ * - filter:三態 chips(全部 / 已連接 / 已連接未註冊 / 未連接),aria-pressed 表達選取。
+ * - 純受控元件:state 由呼叫端持有(DeviceList),本元件只負責呈現 + 觸發變更。
+ */
+
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { DeviceFilterKey, DeviceSortKey } from "@/lib/device-state";
+import { useT } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+interface DeviceListControlsProps {
+ sortKey: DeviceSortKey;
+ filter: DeviceFilterKey;
+ onSortChange: (key: DeviceSortKey) => void;
+ onFilterChange: (filter: DeviceFilterKey) => void;
+}
+
+const FILTERS: { key: DeviceFilterKey; labelKey: string }[] = [
+ { key: "all", labelKey: "devices.filter.all" },
+ { key: "online-registered", labelKey: "devices.filter.onlineRegistered" },
+ { key: "online-unregistered", labelKey: "devices.filter.onlineUnregistered" },
+ { key: "offline", labelKey: "devices.filter.offline" },
+];
+
+const SORTS: { key: DeviceSortKey; labelKey: string }[] = [
+ { key: "status", labelKey: "devices.sort.status" },
+ { key: "name", labelKey: "devices.sort.name" },
+ { key: "registeredAt", labelKey: "devices.sort.registeredAt" },
+];
+
+export function DeviceListControls({
+ sortKey,
+ filter,
+ onSortChange,
+ onFilterChange,
+}: DeviceListControlsProps) {
+ const t = useT();
+
+ return (
+
+ {/* Filter chips — role="group" + aria-pressed(不只靠色,選取態有邊框/底色雙變化)。 */}
+
+ {FILTERS.map(({ key, labelKey }) => {
+ const active = filter === key;
+ return (
+
+ );
+ })}
+
+
+ {/* Sort — Select */}
+
+ {t("devices.sort.label")}
+
+
+
+ );
+}
diff --git a/visionA-frontend/src/components/devices/device-list.tsx b/visionA-frontend/src/components/devices/device-list.tsx
index 5f4cb29..4139736 100644
--- a/visionA-frontend/src/components/devices/device-list.tsx
+++ b/visionA-frontend/src/components/devices/device-list.tsx
@@ -1,45 +1,56 @@
"use client";
/**
- * DeviceList — 裝置卡片網格 + 空狀態 + skeleton
+ * DeviceList — 裝置卡片網格 + 空狀態 + skeleton + 排序/filter 控制
*
* 來源:`local-tool/frontend/src/components/devices/device-list.tsx`(雲端版改造)
*
* 對齊:
* - `.autoflow/03-design/pages.md` §5.3(空狀態)
+ * - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6(排序 + filter)
*
* 改動:
* - 空狀態導向 `/devices/pair`(F7 的 Pairing 頁),不再是 scan
- * - 排序:在線優先(online → reconnecting → unknown → offline → error)
+ * - 排序:改為可自選(狀態 / 名稱 / 註冊時間),預設「狀態」保留既有在線優先行為
+ * - 新增三態 filter(全部 / 已連接 / 已連接未註冊 / 未連接)+ filter 後空結果狀態
*/
+import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { Link2 } from "lucide-react";
+import { Link2, SearchX } from "lucide-react";
import { DeviceCard } from "@/components/devices/device-card";
+import { DeviceListControls } from "@/components/devices/device-list-controls";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
+import {
+ applyDeviceListView,
+ type DeviceFilterKey,
+ type DeviceSortKey,
+} from "@/lib/device-state";
import { useT } from "@/lib/i18n/context";
-import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
+import type { DeviceSummary } from "@/stores/device-store";
interface DeviceListProps {
devices: DeviceSummary[];
loading?: boolean;
}
-const STATUS_ORDER: Record = {
- online: 0,
- reconnecting: 1,
- unknown: 2,
- offline: 3,
- error: 4,
-};
-
export function DeviceList({ devices, loading }: DeviceListProps) {
const t = useT();
const router = useRouter();
+ // 排序 / filter 狀態存 local state(P0 不持久化,TDD §6.2)。
+ const [sortKey, setSortKey] = useState("status");
+ const [filter, setFilter] = useState("all");
+
+ // 先 filter 再 sort(TDD §6.3);devices / 條件變動才重算。
+ const visible = useMemo(
+ () => applyDeviceListView(devices, filter, sortKey),
+ [devices, filter, sortKey],
+ );
+
if (loading) {
return (
STATUS_ORDER[a.remoteStatus] - STATUS_ORDER[b.remoteStatus],
- );
-
return (
-
- {sorted.map((device) => (
-
- ))}
- {/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
-
-
-
- {t("devices.addMore")}
-
-
+
+
+
+ {visible.length === 0 ? (
+ // filter 後 0 筆 → 與「完全沒裝置」區隔的空結果狀態(可清除 filter)。
+
+ setFilter("all"),
+ }}
+ />
+
+ ) : (
+
+ {visible.map((device) => (
+
+ ))}
+ {/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
+
+
+
+ {t("devices.addMore")}
+
+
+
+ )}
);
}
diff --git a/visionA-frontend/src/components/devices/device-register-actions.tsx b/visionA-frontend/src/components/devices/device-register-actions.tsx
new file mode 100644
index 0000000..aa12222
--- /dev/null
+++ b/visionA-frontend/src/components/devices/device-register-actions.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+/**
+ * DeviceRegisterActions — 註冊 / 取消註冊動作按鈕
+ *
+ * 規格來源:
+ * - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5(三態 + 註冊 UI)
+ * - `docs/autoflow/04-architecture/api/api-device-mgmt.md`(register / unregister 契約)
+ *
+ * 行為:
+ * - 「已連接未註冊」(online-unregistered)→ 顯示「註冊」按鈕(primary)。
+ * - 「已註冊」(registeredAt != null,不論在線與否)→ 顯示「取消註冊」按鈕(outline)。
+ * ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只退回未註冊態、保留裝置列,
+ * 文案明確用「取消註冊」而非「移除」,避免使用者誤以為會刪掉裝置。
+ * - 未連接且未註冊(offline + null)→ 無動作(不顯示按鈕)。
+ *
+ * 錯誤處理:
+ * - register 409 ALREADY_REGISTERED → 提示「已註冊」(後端與前端可能競態)。
+ * - representative REPRESENTATIVE_DEVICE / 403 FORBIDDEN → 對應 i18n,退化到 unknown 文案。
+ *
+ * 呼叫端(DeviceCard)已用 deriveTriState / registeredAt 決定是否 render 本元件。
+ */
+
+import { toast } from "sonner";
+import { UserCheck, UserX } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useT } from "@/lib/i18n/context";
+import type { DeviceSummary } from "@/stores/device-store";
+import { useDeviceStore } from "@/stores/device-store";
+
+interface DeviceRegisterActionsProps {
+ device: DeviceSummary;
+ /** 動作成功後的回呼(例如刷新詳情頁);卡片就地更新則可不傳。 */
+ onDone?: () => void | Promise
;
+ size?: "sm" | "default";
+}
+
+/** register 失敗時把 backend code 映射到 i18n key(找不到 → unknown 文案)。 */
+function registerErrorDesc(t: (k: string) => string, code: string): string {
+ const key = `devices.register.error.${code}`;
+ const resolved = t(key);
+ return resolved === key ? t("devices.register.error.unknown") : resolved;
+}
+
+export function DeviceRegisterActions({
+ device,
+ onDone,
+ size = "sm",
+}: DeviceRegisterActionsProps) {
+ const t = useT();
+ const registerDevice = useDeviceStore((s) => s.registerDevice);
+ const unregisterDevice = useDeviceStore((s) => s.unregisterDevice);
+ // registeringId 同時涵蓋 register / unregister 進行中;用當前 device.id 比對。
+ const isPending = useDeviceStore((s) => s.registeringId === device.id);
+
+ const isRegistered = !!device.registeredAt;
+
+ async function handleRegister() {
+ const result = await registerDevice(device.id);
+ if (result.ok) {
+ toast.success(t("devices.register.toast.success"));
+ await onDone?.();
+ } else {
+ toast.error(t("devices.register.error.title"), {
+ description: registerErrorDesc(t, result.code),
+ });
+ }
+ }
+
+ async function handleUnregister() {
+ const result = await unregisterDevice(device.id);
+ if (result.ok) {
+ toast.success(t("devices.unregister.toast.success"));
+ await onDone?.();
+ } else {
+ toast.error(t("devices.unregister.error.title"), {
+ description: registerErrorDesc(t, result.code),
+ });
+ }
+ }
+
+ if (isRegistered) {
+ return (
+
+ );
+ }
+
+ // 未註冊:只有「已連接未註冊」才可註冊(offline 未註冊不顯示 → 由呼叫端 gate)。
+ return (
+
+ );
+}
diff --git a/visionA-frontend/src/components/devices/unregistered-badge.tsx b/visionA-frontend/src/components/devices/unregistered-badge.tsx
new file mode 100644
index 0000000..cc62f51
--- /dev/null
+++ b/visionA-frontend/src/components/devices/unregistered-badge.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+/**
+ * UnregisteredBadge — 「已連接未註冊」第三態標記
+ *
+ * 規格來源:
+ * - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.3(配色落地)
+ *
+ * 設計要點:
+ * - 連線狀態(RemoteDeviceBadge)與註冊狀態是正交兩軸,硬塞進同一個 badge 會讓
+ * 「online 但未註冊」的顏色語意打架 → 用獨立的 warning 色 pill 疊加表達「未註冊」。
+ * - 配色只用既有 warning design token(--warning / --warning-foreground / --warning-subtle),
+ * 禁止裸色(bg-yellow-*)——對齊 pairing / login / flash-dialog 的既有慣例。
+ * - 無障礙(design-review M2):不只靠顏色,帶 icon(TriangleAlert)+ 文字「未註冊」。
+ */
+
+import { TriangleAlert } from "lucide-react";
+
+import { useT } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+export interface UnregisteredBadgeProps {
+ size?: "sm" | "md";
+ className?: string;
+}
+
+export function UnregisteredBadge({ size = "sm", className }: UnregisteredBadgeProps) {
+ const t = useT();
+ const label = t("devices.state.unregistered");
+
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/visionA-frontend/src/components/models/library-model-card.tsx b/visionA-frontend/src/components/models/library-model-card.tsx
new file mode 100644
index 0000000..da3fd4b
--- /dev/null
+++ b/visionA-frontend/src/components/models/library-model-card.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+/**
+ * LibraryModelCard — 共享模型庫卡片
+ *
+ * 對齊設計規格 §4.2。基於 `LibraryModel`(共享庫 DTO,含 visibility / owner / sharedWithMe /
+ * myAccess),沿用既有 ModelCard 的視覺語彙(Card + Badge 列 + metadata grid)。
+ *
+ * 與既有 ModelCard 差異:
+ * - 新增 visibility badge(三態 + sharedWithMe,見 ModelVisibilityBadge)
+ * - owner 卡片(owner.isMe):右上角 ⋮ 選單 → 公開設定(開 ModelVisibilityDialog)
+ * - receiver 卡片:次要資訊列「由 {ownerName} 共享」(契約不揭露 email,故用 owner.name)
+ * - 整張卡片是 到 /models/{id}(profile 頁)
+ *
+ * ⚠️ 契約 §4:response 不含 owner email,故 receiver 資訊列用 owner.name(非 email)。
+ */
+
+import { useState } from "react";
+import { MoreVertical, Settings2 } from "lucide-react";
+import Link from "next/link";
+
+import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
+import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import type { LibraryModel } from "@/lib/api/model-sharing";
+import { formatRelativeTime } from "@/lib/format/relative-time";
+import { useT } from "@/lib/i18n/context";
+
+interface LibraryModelCardProps {
+ model: LibraryModel;
+ /** deterministic 相對時間用(測試傳入固定值);預設 Date.now()。 */
+ nowMs?: number;
+}
+
+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 LibraryModelCard({ model, nowMs }: LibraryModelCardProps) {
+ const t = useT();
+ const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
+ // 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()。
+ const [mountedNow] = useState(() => Date.now());
+ const isOwner = model.owner.isMe;
+
+ return (
+ <>
+
+ {/* owner ⋮ 選單(絕對定位右上,避免與 導航衝突)。 */}
+ {isOwner && (
+
+
+
+
+
+
+ {
+ e.preventDefault();
+ setVisibilityDialogOpen(true);
+ }}
+ data-testid="library-card-visibility"
+ >
+
+ {t("models.visibility.title")}
+
+
+
+
+ )}
+
+
+
+
+ {model.name}
+
+
+
+ {model.targetChip.toUpperCase()}
+
+
+
+
+
+
+
+
{t("models.size")}
+
{formatFileSize(model.fileSize)}
+
+
+
{t("models.createdAt")}
+
+ {model.createdAt
+ ? new Date(model.createdAt).toLocaleDateString()
+ : "—"}
+
+
+
+ {/* receiver 視角:顯示分享者(契約不給 email,用 owner.name)。 */}
+ {!isOwner && (
+
+ {t("models.sharedByName").replace("{name}", model.owner.name)}
+ {model.updatedAt
+ ? ` · ${formatRelativeTime(model.updatedAt, nowMs ?? mountedNow, t)}`
+ : ""}
+
+ )}
+
+
+
+
+ {isOwner && (
+
+ )}
+ >
+ );
+}
diff --git a/visionA-frontend/src/components/models/library-toolbar.tsx b/visionA-frontend/src/components/models/library-toolbar.tsx
new file mode 100644
index 0000000..5628531
--- /dev/null
+++ b/visionA-frontend/src/components/models/library-toolbar.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+/**
+ * LibraryToolbar — 共享模型庫工具列(搜尋 + filter + 排序)
+ *
+ * 對齊設計規格 §4.3–§4.5。控制 model-sharing-store 的 filters。
+ *
+ * 元素(Mobile First,窄螢幕堆疊 wrap):
+ * - 搜尋框(Input + Search icon + 清除鈕),role="searchbox",debounce 由 parent 處理
+ * - 擁有關係 filter(全部 / 我的 / 共享給我)
+ * - 可見性 filter(全部 / 公開 / 同租戶)
+ * - 晶片 filter(沿用既有 targetChip 選項)
+ * - 排序(最新建立 / 名稱 / 檔案大小)
+ *
+ * 搜尋 debounce:本元件維持 local input state,透過 onSearchChange 通知 parent(parent 做 debounce)。
+ */
+
+import { Search, X } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useT } from "@/lib/i18n/context";
+import type { LibraryFilters } from "@/stores/model-sharing-store";
+
+interface LibraryToolbarProps {
+ filters: LibraryFilters;
+ /** 搜尋框當前輸入(受控;由 parent 管理以便 debounce)。 */
+ searchInput: string;
+ onSearchChange: (value: string) => void;
+ onFilterChange: (patch: Partial) => void;
+}
+
+export function LibraryToolbar({
+ filters,
+ searchInput,
+ onSearchChange,
+ onFilterChange,
+}: LibraryToolbarProps) {
+ const t = useT();
+
+ return (
+
+ {/* 搜尋框 */}
+
+
+ onSearchChange(e.target.value)}
+ placeholder={t("models.search.placeholder")}
+ aria-label={t("models.search.aria")}
+ className="pl-9 pr-9"
+ data-testid="library-search"
+ />
+ {searchInput && (
+
+ )}
+
+
+ {/* 擁有關係 filter */}
+
+
+ {/* 可見性 filter */}
+
+
+ {/* 晶片 filter */}
+
+
+ {/* 排序 */}
+
+
+ );
+}
diff --git a/visionA-frontend/src/components/models/model-owner-bar.tsx b/visionA-frontend/src/components/models/model-owner-bar.tsx
new file mode 100644
index 0000000..f19a6bd
--- /dev/null
+++ b/visionA-frontend/src/components/models/model-owner-bar.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+/**
+ * ModelOwnerBar — 模型擁有者資訊列(模型共享功能)
+ *
+ * 對齊設計規格 §6.4。僅在非 owner 檢視 profile 時渲染,顯示「由 {ownerName} 共享 · {time}」。
+ *
+ * ⚠️ 契約 §4:response 不揭露 owner email,故用 owner.name(非 email)。
+ * 頭像用 owner.name 首字母(沿用 UserMenu avatar 樣式)。
+ */
+
+import { useState } from "react";
+
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { formatRelativeTime } from "@/lib/format/relative-time";
+import { useT } from "@/lib/i18n/context";
+
+interface ModelOwnerBarProps {
+ ownerName: string;
+ /** 共享時間(ISO);用 updatedAt 近似(契約無獨立 sharedAt 欄)。 */
+ sharedAt?: string;
+ nowMs?: number;
+}
+
+export function ModelOwnerBar({ ownerName, sharedAt, nowMs }: ModelOwnerBarProps) {
+ const t = useT();
+ const initial = ownerName.trim().charAt(0).toUpperCase() || "?";
+ // 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()(相對時間顯示不需即時更新)。
+ const [mountedNow] = useState(() => Date.now());
+ const relative = sharedAt
+ ? formatRelativeTime(sharedAt, nowMs ?? mountedNow, t)
+ : "";
+
+ return (
+
+
+ {initial}
+
+
+ {t("models.sharedByName").replace("{name}", ownerName)}
+ {relative ? ` · ${relative}` : ""}
+
+
+ );
+}
diff --git a/visionA-frontend/src/components/models/model-visibility-badge.test.tsx b/visionA-frontend/src/components/models/model-visibility-badge.test.tsx
new file mode 100644
index 0000000..ed90f14
--- /dev/null
+++ b/visionA-frontend/src/components/models/model-visibility-badge.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * ModelVisibilityBadge 測試
+ *
+ * 覆蓋三態雙編碼(圖示 + 文字,不僅靠顏色)+ shared_with_me 優先顯示。
+ */
+
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { LocaleProvider } from "@/lib/i18n/context";
+import type { ModelVisibility } from "@/lib/api/model-sharing";
+
+import { ModelVisibilityBadge } from "./model-visibility-badge";
+
+function renderBadge(props: {
+ visibility: ModelVisibility;
+ sharedWithMe?: boolean;
+ sharedCount?: number;
+}) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ModelVisibilityBadge 三態", () => {
+ it("private → 顯示「私有」文字 + data-visibility=private", () => {
+ renderBadge({ visibility: "private" });
+ const badge = screen.getByTestId("model-visibility-badge");
+ expect(badge).toHaveAttribute("data-visibility", "private");
+ expect(badge).toHaveTextContent("私有");
+ });
+
+ it("public → 顯示「公開」文字 + data-visibility=public", () => {
+ renderBadge({ visibility: "public" });
+ const badge = screen.getByTestId("model-visibility-badge");
+ expect(badge).toHaveAttribute("data-visibility", "public");
+ expect(badge).toHaveTextContent("公開");
+ });
+
+ it("tenant → 顯示「同租戶」文字 + data-visibility=tenant", () => {
+ renderBadge({ visibility: "tenant" });
+ const badge = screen.getByTestId("model-visibility-badge");
+ expect(badge).toHaveAttribute("data-visibility", "tenant");
+ expect(badge).toHaveTextContent("同租戶");
+ });
+});
+
+describe("ModelVisibilityBadge sharedWithMe 優先", () => {
+ it("sharedWithMe=true → 顯示「共享給我」+ data-visibility=shared(覆蓋 visibility)", () => {
+ renderBadge({ visibility: "public", sharedWithMe: true });
+ const badge = screen.getByTestId("model-visibility-badge");
+ expect(badge).toHaveAttribute("data-visibility", "shared");
+ expect(badge).toHaveTextContent("共享給我");
+ });
+});
+
+describe("ModelVisibilityBadge sharedCount 尾綴", () => {
+ it("public + sharedCount=3 → 顯示「· 3 人」", () => {
+ renderBadge({ visibility: "public", sharedCount: 3 });
+ const badge = screen.getByTestId("model-visibility-badge");
+ expect(badge).toHaveTextContent("3 人");
+ });
+});
diff --git a/visionA-frontend/src/components/models/model-visibility-badge.tsx b/visionA-frontend/src/components/models/model-visibility-badge.tsx
new file mode 100644
index 0000000..b0b741a
--- /dev/null
+++ b/visionA-frontend/src/components/models/model-visibility-badge.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+/**
+ * ModelVisibilityBadge — 模型可見性徽章(模型共享功能)
+ *
+ * 三態雙編碼(圖示 + 文字,不僅靠顏色,滿足無障礙 De1):
+ * - private(Lock,中性):owner 檢視自己的私有模型
+ * - public(Globe,chart-2 tint):公開給所有 visionA 使用者
+ * - tenant(Building2,chart-1 tint):同租戶可見
+ *
+ * 另有「共享給我」的獨立標示(sharedWithMe=true → Users,chart-3 tint):
+ * 此態源自 model_shares 維度(與 visibility 正交,見 api-model-sharing.md §0),
+ * 優先於 visibility 顯示——receiver 最關心的是「這是別人分享給我的」。
+ *
+ * 配色沿用既有 chart-* token tint 風格(bg/10 + 純色文字 + /30 邊框),
+ * 與 model-card 的 source badge 同做法,Dark Mode 由 token 自動處理,零新 token。
+ */
+
+import { Building2, Globe, Lock, Users } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { useT } from "@/lib/i18n/context";
+import type { ModelVisibility } from "@/lib/api/model-sharing";
+import { cn } from "@/lib/utils";
+
+interface ModelVisibilityBadgeProps {
+ visibility: ModelVisibility;
+ /** model_shares 命中(別人分享給我)→ 優先顯示「共享給我」。 */
+ sharedWithMe?: boolean;
+ /** owner 視角:已分享給幾人(顯示在 badge 尾綴,如「公開 · 3 人」)。可選。 */
+ sharedCount?: number;
+ className?: string;
+}
+
+const VISIBILITY_META: Record<
+ ModelVisibility,
+ { icon: typeof Lock; labelKey: string; className: string }
+> = {
+ private: {
+ icon: Lock,
+ labelKey: "models.visibility.badge.private",
+ className: "text-muted-foreground border-border",
+ },
+ public: {
+ icon: Globe,
+ labelKey: "models.visibility.badge.public",
+ className: "border-chart-2/30 bg-chart-2/10 text-chart-2",
+ },
+ tenant: {
+ icon: Building2,
+ labelKey: "models.visibility.badge.tenant",
+ className: "border-chart-1/30 bg-chart-1/10 text-chart-1",
+ },
+};
+
+export function ModelVisibilityBadge({
+ visibility,
+ sharedWithMe = false,
+ sharedCount,
+ className,
+}: ModelVisibilityBadgeProps) {
+ const t = useT();
+
+ // 「共享給我」優先於 visibility 顯示(receiver 視角)。
+ if (sharedWithMe) {
+ return (
+
+
+ {t("models.visibility.badge.sharedWithMe")}
+
+ );
+ }
+
+ const meta = VISIBILITY_META[visibility];
+ const Icon = meta.icon;
+ const label = t(meta.labelKey);
+ // owner 視角:public/tenant 且有分享人數時,尾綴「· N 人」。
+ const suffix =
+ sharedCount && sharedCount > 0
+ ? ` · ${t("models.visibility.badge.sharedCount").replace("{n}", String(sharedCount))}`
+ : "";
+
+ return (
+
+
+ {label}
+ {suffix}
+
+ );
+}
diff --git a/visionA-frontend/src/components/models/model-visibility-dialog.test.tsx b/visionA-frontend/src/components/models/model-visibility-dialog.test.tsx
new file mode 100644
index 0000000..da59710
--- /dev/null
+++ b/visionA-frontend/src/components/models/model-visibility-dialog.test.tsx
@@ -0,0 +1,211 @@
+/**
+ * ModelVisibilityDialog 測試
+ *
+ * 覆蓋:
+ * - 三態選項渲染(私有 / 公開 / 同租戶)
+ * - public 選中 → amber 警告條顯示
+ * - email 加入:格式錯 → inline 錯誤;重複 → 提示;合法 → 呼叫 store.addShare
+ * - 授權清單渲染 + 移除
+ * - 儲存 → 呼叫 store.updateVisibility + toast.success + 關閉
+ *
+ * 走 store mock 模式(_setMockMode true)+ 直接注入 shares,避免打真實 API。
+ * Radix Dialog / RadioGroup 在 jsdom:dialog 受控 open=true,內容 render 到 portal 可查。
+ */
+
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
+
+import { LocaleProvider } from "@/lib/i18n/context";
+import { useModelSharingStore } from "@/stores/model-sharing-store";
+
+vi.mock("sonner", () => {
+ const success = vi.fn();
+ const error = vi.fn();
+ return { toast: Object.assign(vi.fn(), { success, error }) };
+});
+
+import { toast } from "sonner";
+
+import { ModelVisibilityDialog } from "./model-visibility-dialog";
+
+function resetStore() {
+ useModelSharingStore.setState({
+ shares: [],
+ isSharesLoading: false,
+ _mockMode: true,
+ // stub loadShares 為 noop:測試自行以 setState 注入 shares,
+ // 避免 body 掛載時 mock loadShares 覆蓋注入的清單。
+ loadShares: async () => {},
+ });
+}
+
+function renderDialog(currentVisibility: "private" | "public" | "tenant" = "private") {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ resetStore();
+ (toast.success as Mock).mockReset();
+ (toast.error as Mock).mockReset();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("三態選項渲染", () => {
+ it("顯示私有 / 公開 / 同租戶三選項", async () => {
+ renderDialog();
+ await waitFor(() =>
+ expect(screen.getByTestId("model-visibility-dialog")).toBeInTheDocument(),
+ );
+ expect(screen.getByRole("radio", { name: "私有" })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "公開" })).toBeInTheDocument();
+ expect(screen.getByRole("radio", { name: "同租戶" })).toBeInTheDocument();
+ });
+});
+
+describe("public 警告條", () => {
+ it("初始 currentVisibility=public → 顯示 amber 警告", async () => {
+ renderDialog("public");
+ await waitFor(() =>
+ expect(screen.getByTestId("visibility-public-warning")).toBeInTheDocument(),
+ );
+ });
+
+ it("初始 private → 不顯示警告", async () => {
+ renderDialog("private");
+ await waitFor(() =>
+ expect(screen.getByTestId("model-visibility-dialog")).toBeInTheDocument(),
+ );
+ expect(screen.queryByTestId("visibility-public-warning")).not.toBeInTheDocument();
+ });
+});
+
+describe("email 加入驗證", () => {
+ it("格式錯 → inline 錯誤,不呼叫 addShare", async () => {
+ const spy = vi.spyOn(useModelSharingStore.getState(), "addShare");
+ renderDialog();
+ await waitFor(() => screen.getByTestId("share-email-input"));
+
+ fireEvent.change(screen.getByTestId("share-email-input"), {
+ target: { value: "not-an-email" },
+ });
+ fireEvent.click(screen.getByTestId("share-email-add"));
+
+ await waitFor(() => expect(screen.getByText("Email 格式不正確")).toBeInTheDocument());
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it("重複 email → 提示已在清單中", async () => {
+ useModelSharingStore.setState({
+ shares: [
+ { userId: "u1", email: "dup@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
+ ],
+ });
+ renderDialog();
+ await waitFor(() => screen.getByTestId("share-email-input"));
+
+ fireEvent.change(screen.getByTestId("share-email-input"), {
+ target: { value: "dup@corp.com" },
+ });
+ fireEvent.click(screen.getByTestId("share-email-add"));
+
+ await waitFor(() => expect(screen.getByText("已在清單中")).toBeInTheDocument());
+ });
+
+ it("合法 email → 呼叫 addShare", async () => {
+ const spy = vi
+ .spyOn(useModelSharingStore.getState(), "addShare")
+ .mockResolvedValue({ ok: true });
+ renderDialog();
+ await waitFor(() => screen.getByTestId("share-email-input"));
+
+ fireEvent.change(screen.getByTestId("share-email-input"), {
+ target: { value: "new@corp.com" },
+ });
+ fireEvent.click(screen.getByTestId("share-email-add"));
+
+ await waitFor(() =>
+ expect(spy).toHaveBeenCalledWith("mock-model-01", "new@corp.com"),
+ );
+ });
+});
+
+describe("授權清單", () => {
+ it("渲染既有 shares + 移除鈕", async () => {
+ useModelSharingStore.setState({
+ shares: [
+ { userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
+ ],
+ });
+ renderDialog();
+ await waitFor(() => expect(screen.getByText("alice@corp.com")).toBeInTheDocument());
+ expect(screen.getByTestId("share-remove")).toBeInTheDocument();
+ });
+
+ it("點移除 → 呼叫 removeShare", async () => {
+ useModelSharingStore.setState({
+ shares: [
+ { userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
+ ],
+ });
+ const spy = vi
+ .spyOn(useModelSharingStore.getState(), "removeShare")
+ .mockResolvedValue({ ok: true });
+ renderDialog();
+ await waitFor(() => screen.getByTestId("share-remove"));
+
+ fireEvent.click(screen.getByTestId("share-remove"));
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("mock-model-01", "u1"));
+ });
+});
+
+describe("儲存", () => {
+ it("點儲存(private,無變更)→ updateVisibility + toast.success", async () => {
+ const spy = vi
+ .spyOn(useModelSharingStore.getState(), "updateVisibility")
+ .mockResolvedValue({ ok: true });
+ renderDialog("private");
+ await waitFor(() => screen.getByTestId("visibility-save"));
+
+ fireEvent.click(screen.getByTestId("visibility-save"));
+ await waitFor(() =>
+ expect(spy).toHaveBeenCalledWith("mock-model-01", "private"),
+ );
+ await waitFor(() => expect(toast.success).toHaveBeenCalled());
+ });
+
+ it("由 public 收回成 private 且有授權對象 → 先跳二次確認(不直接 save)", async () => {
+ useModelSharingStore.setState({
+ shares: [
+ { userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
+ ],
+ });
+ const spy = vi
+ .spyOn(useModelSharingStore.getState(), "updateVisibility")
+ .mockResolvedValue({ ok: true });
+ renderDialog("public");
+ await waitFor(() => screen.getByRole("radio", { name: "私有" }));
+
+ // 選「私有」
+ fireEvent.click(screen.getByRole("radio", { name: "私有" }));
+ fireEvent.click(screen.getByTestId("visibility-save"));
+
+ // 應出現二次確認,updateVisibility 尚未被呼叫
+ await waitFor(() =>
+ expect(screen.getByText(/改為私有後/)).toBeInTheDocument(),
+ );
+ expect(spy).not.toHaveBeenCalled();
+ });
+});
diff --git a/visionA-frontend/src/components/models/model-visibility-dialog.tsx b/visionA-frontend/src/components/models/model-visibility-dialog.tsx
new file mode 100644
index 0000000..8bee367
--- /dev/null
+++ b/visionA-frontend/src/components/models/model-visibility-dialog.tsx
@@ -0,0 +1,385 @@
+"use client";
+
+/**
+ * ModelVisibilityDialog — 模型公開設定(owner-only)
+ *
+ * 對齊設計規格 §5 + API 契約 §3(PATCH visibility)+ shares API(§4)。
+ *
+ * 內容:
+ * - RadioGroup 三態:private / public / tenant(同租戶)
+ * (API 契約 visibility 三態;PRD 的 restricted「指定對象」對應正交的 model_shares 維度,
+ * 下方獨立區塊管理,不佔 visibility 選項)
+ * - 指定對象(model_shares):email 加入 + 授權清單管理(永遠可用,與 visibility 正交)
+ * - public 選中 → amber 警告條
+ * - 由 public/tenant 改回 private 且曾分享給人 → AlertDialog 二次確認
+ *
+ * 受控元件:由 parent(卡片 ⋮ 選單 / profile 按鈕)以 open / onOpenChange 控制。
+ */
+
+import { useEffect, useState } from "react";
+import { Globe, Lock, X } from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Spinner } from "@/components/ui/spinner";
+import { isValidEmail, type ModelVisibility } from "@/lib/api/model-sharing";
+import { useT } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+import { useModelSharingStore } from "@/stores/model-sharing-store";
+
+interface ModelVisibilityDialogProps {
+ modelId: string;
+ modelName: string;
+ /** 目前的可見性(開啟時的初始值)。 */
+ currentVisibility: ModelVisibility;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+/** 三態選項(對齊 i18n key)。 */
+const VISIBILITY_OPTIONS: ReadonlyArray<{
+ value: ModelVisibility;
+ labelKey: string;
+ descKey: string;
+}> = [
+ { value: "private", labelKey: "models.visibility.private", descKey: "models.visibility.private.desc" },
+ { value: "public", labelKey: "models.visibility.public", descKey: "models.visibility.public.desc" },
+ { value: "tenant", labelKey: "models.visibility.tenant", descKey: "models.visibility.tenant.desc" },
+];
+
+/**
+ * 外層殼:受控 Dialog。內容以 key remount,讓每次開啟時 body 用最新 props 初始化 state
+ * (避免 setState-in-effect 的 cascading render)。
+ */
+export function ModelVisibilityDialog(props: ModelVisibilityDialogProps) {
+ const { open, onOpenChange } = props;
+ return (
+
+ );
+}
+
+/**
+ * Dialog body(僅在 open 時掛載)。state 直接以 props 初始化(無 setState-in-effect);
+ * 唯一副作用是掛載時載入 shares(external sync,合法 effect)。
+ */
+function VisibilityDialogBody({
+ modelId,
+ modelName,
+ currentVisibility,
+ onOpenChange,
+}: ModelVisibilityDialogProps) {
+ const t = useT();
+
+ const shares = useModelSharingStore((s) => s.shares);
+ const isSharesLoading = useModelSharingStore((s) => s.isSharesLoading);
+ const loadShares = useModelSharingStore((s) => s.loadShares);
+ const updateVisibility = useModelSharingStore((s) => s.updateVisibility);
+ const addShareAction = useModelSharingStore((s) => s.addShare);
+ const removeShareAction = useModelSharingStore((s) => s.removeShare);
+
+ const [visibility, setVisibility] = useState(currentVisibility);
+ const [emailInput, setEmailInput] = useState("");
+ const [emailError, setEmailError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+ const [addingEmail, setAddingEmail] = useState(false);
+ const [confirmRevokeOpen, setConfirmRevokeOpen] = useState(false);
+
+ // 掛載時載入授權清單(external sync);modelId 於本 body 生命週期固定(key remount)。
+ // loadShares 是 zustand action,身分穩定,可安全放入 deps。
+ useEffect(() => {
+ void loadShares(modelId);
+ }, [modelId, loadShares]);
+
+ async function handleAddEmail() {
+ const email = emailInput.trim();
+ if (!email) return;
+ if (!isValidEmail(email)) {
+ setEmailError(t("models.visibility.emailInvalid"));
+ return;
+ }
+ if (shares.some((s) => s.email.toLowerCase() === email.toLowerCase())) {
+ setEmailError(t("models.visibility.emailDuplicate"));
+ return;
+ }
+ setEmailError(null);
+ setAddingEmail(true);
+ const result = await addShareAction(modelId, email);
+ setAddingEmail(false);
+ if (result.ok) {
+ setEmailInput("");
+ } else if (result.code === "not_found") {
+ setEmailError(t("models.visibility.userNotFound").replace("{email}", email));
+ } else {
+ setEmailError(t("models.sharing.error.generic"));
+ }
+ }
+
+ async function handleRemoveShare(userId: string) {
+ const result = await removeShareAction(modelId, userId);
+ if (!result.ok) {
+ toast.error(t("models.sharing.error.generic"));
+ }
+ }
+
+ /** 執行儲存(可能先過二次確認)。 */
+ async function doSave() {
+ setSaving(true);
+ setSaveError(null);
+ const result = await updateVisibility(modelId, visibility);
+ setSaving(false);
+ if (result.ok) {
+ toast.success(t("models.visibility.saved"));
+ onOpenChange(false);
+ } else if (result.code === "conflict") {
+ setSaveError(t("models.visibility.notReady"));
+ } else {
+ setSaveError(t("models.visibility.saveFailed"));
+ }
+ }
+
+ function handleSaveClick() {
+ // 由 public/tenant 收回成 private 且曾有授權對象 → 二次確認。
+ const wasBroadcast = currentVisibility === "public" || currentVisibility === "tenant";
+ if (visibility === "private" && wasBroadcast && shares.length > 0) {
+ setConfirmRevokeOpen(true);
+ return;
+ }
+ void doSave();
+ }
+
+ return (
+ <>
+
+
+
+ {t("models.visibility.title")} — {modelName}
+
+
+
+
+
{t("models.visibility.question")}
+
+
setVisibility(v as ModelVisibility)}
+ aria-label={t("models.visibility.question")}
+ >
+ {VISIBILITY_OPTIONS.map((opt) => (
+
+ ))}
+
+
+ {/* public 警告條(沿用 §2.1 amber 半語義約定)。 */}
+ {visibility === "public" && (
+
+
+ {t("models.visibility.publicWarning")}
+
+ )}
+
+ {/* 指定對象(model_shares)管理,與 visibility 正交,永遠可用。 */}
+
+
+
+
+ {
+ setEmailInput(e.target.value);
+ if (emailError) setEmailError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ void handleAddEmail();
+ }
+ }}
+ placeholder={t("models.visibility.addEmail")}
+ aria-label={t("models.visibility.addEmail")}
+ aria-invalid={emailError ? true : undefined}
+ data-testid="share-email-input"
+ />
+
+
+
+ {emailError && (
+
+ {emailError}
+
+ )}
+
+ {/* 授權清單 */}
+
+ {isSharesLoading ? (
+
+ {t("common.loading")}
+
+ ) : shares.length === 0 ? (
+
+ {t("models.visibility.noShares")}
+
+ ) : (
+ shares.map((share) => (
+
+ {share.email}
+
+
+ {t("models.visibility.permissionViewDownload")}
+
+
+
+
+ ))
+ )}
+
+
+
+ {saveError && (
+
+ {saveError}
+
+ )}
+
+
+
+
+
+
+
+
+ {/* 收回權限二次確認 */}
+
+
+
+ {t("common.confirm")}
+
+ {t("models.visibility.revokeConfirm")}
+
+
+
+ {t("common.cancel")}
+ {
+ setConfirmRevokeOpen(false);
+ void doSave();
+ }}
+ >
+ {t("models.visibility.saveButton")}
+
+
+
+
+ >
+ );
+}
diff --git a/visionA-frontend/src/components/ui/radio-group.tsx b/visionA-frontend/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..2c91dd5
--- /dev/null
+++ b/visionA-frontend/src/components/ui/radio-group.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import * as React from "react";
+import { CircleIcon } from "lucide-react";
+import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+/**
+ * RadioGroup — Shadcn 風單選群組(Radix RadioGroup 封裝)
+ *
+ * 新增於「模型共享」功能:公開設定 Dialog 的三態選擇(私有 / 公開 / 指定對象)。
+ * shadcn 標準元件,radix-ui 已含 RadioGroup primitive(見 package.json radix-ui ^1.4.3)。
+ *
+ * 無障礙(Radix 內建):
+ * - role="radiogroup" / role="radio"、Arrow 鍵切換、aria-checked
+ * - 每個 Item 需搭配可見