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 讓使用者能配對更多裝置,避免空間死角 */} - -