feat(frontend): 設備管理三態/註冊 + 模型共享庫/公開設定(B + C)

B 設備管理:device-store 補 registeredAt + register/unregister actions;
deriveTriState 三態(已連接/未連接/已連接未註冊);三態 badge(warning token
+ icon + 文字不只色);排序/filter chips;註冊 UI(明確區分取消註冊≠移除)。

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-08-02 16:30:22 +08:00
parent 47a1d4d0ef
commit 6a797d5eb5
36 changed files with 4820 additions and 66 deletions

View File

@ -0,0 +1,147 @@
/**
* ModelProfileClient
*
*
* - owner myAccess=owner +
* - / myAccess=viewer / owner
* - canDownload
* - / 404profileError 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(
<LocaleProvider>
<ModelProfileClient id={id} />
</LocaleProvider>,
);
}
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(),
);
});
});

View File

@ -0,0 +1,337 @@
"use client";
/**
* ModelProfileClient profile owner /
*
* §6 + API §2GET /:id/profile owner-only detail
* `GET /api/models/:id/profile` + `myAccess`
* owner /
*
* §6.2
* - owner myAccess==='owner'+ + Dialog
* - / canDownload true / ModelOwnerBar
*
* §2 404 enumeration / EmptyState
*
* 沿 FAA delegated downloadlib/api/model-download endpoint
* §3download
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, DownloadIcon, Globe, SearchX, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { ModelOwnerBar } from "@/components/models/model-owner-bar";
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
getModelDownload,
ModelDownloadError,
triggerNavDownload,
} from "@/lib/api/model-download";
import { useT } from "@/lib/i18n/context";
import { useModelSharingStore } from "@/stores/model-sharing-store";
import { useModelStore } from "@/stores/model-store";
function formatFileSize(bytes: number): string {
if (!bytes) return "—";
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatInputShape(shape: number[]): string {
return shape.join(" × ");
}
const CLASSES_PREVIEW_LIMIT = 8;
interface ModelProfileClientProps {
id: string;
}
export function ModelProfileClient({ id }: ModelProfileClientProps) {
const t = useT();
const router = useRouter();
const profile = useModelSharingStore((s) => s.profile);
const isLoading = useModelSharingStore((s) => s.isProfileLoading);
const profileError = useModelSharingStore((s) => s.profileError);
const loadProfile = useModelSharingStore((s) => s.loadProfile);
const clearProfile = useModelSharingStore((s) => s.clearProfile);
// 刪除沿用既有 model-storeowner-only DELETE /api/models/:id
const deleteModel = useModelStore((s) => s.deleteModel);
const [deleting, setDeleting] = useState(false);
const [downloadBusy, setDownloadBusy] = useState(false);
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
useEffect(() => {
if (id) void loadProfile(id);
return () => clearProfile();
}, [id, loadProfile, clearProfile]);
const isOwner = profile?.myAccess === "owner";
async function handleDownload() {
if (!profile || downloadBusy) return;
setDownloadBusy(true);
try {
const grant = await getModelDownload(profile.id);
triggerNavDownload(grant.downloadUrl);
toast.success(t("models.download.toast.start"), {
description: t("models.download.toast.hint"),
});
} catch (err) {
const code = err instanceof ModelDownloadError ? err.code : "unknown";
const key = `models.download.error.${code}`;
const desc = t(key);
toast.error(t("models.download.error.title"), {
description: desc === key ? t("models.download.error.unknown") : desc,
});
} finally {
setDownloadBusy(false);
}
}
async function handleDelete() {
setDeleting(true);
const ok = await deleteModel(id);
setDeleting(false);
if (ok) {
toast.success(t("common.save"));
router.push("/models");
} else {
toast.error(t("common.error"));
}
}
const backButton = (
<Link href="/models/library">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
);
// 載入中
if (isLoading && !profile) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-48 rounded-lg" />
</div>
);
}
// 無權限 / 找不到(契約:無可見性回 404合併「找不到 / 無權限」)。
if (profileError || !profile) {
return (
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
{backButton}
<EmptyState
icon={SearchX}
title={t("models.profile.notFound.title")}
description={t("models.profile.notFound.description")}
action={{
label: t("models.profile.backToLibrary"),
onClick: () => router.push("/models/library"),
}}
/>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 px-6 py-8">
{backButton}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<h1 className="text-2xl font-bold">{profile.name}</h1>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{profile.targetChip.toUpperCase()}</Badge>
<Badge variant={profile.status === "ready" ? "default" : "secondary"}>
{t(`models.status.${profile.status === "ready" ? "ready" : "scanning"}`)}
</Badge>
{profile.source !== "uploaded" && (
<Badge variant="secondary">{t(`models.source.${profile.source}`)}</Badge>
)}
{/* owner 看到自己的 visibility非 owner 看到共享標示。 */}
<ModelVisibilityBadge
visibility={profile.visibility}
sharedWithMe={!isOwner}
/>
</div>
</div>
<div className="flex items-center gap-2">
{profile.canDownload && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloadBusy}
aria-label={t("models.action.download.aria")}
data-testid="profile-download"
>
{downloadBusy ? (
<>
<Spinner size="sm" label={t("models.action.downloading")} />
{t("models.action.downloading")}
</>
) : (
<>
<DownloadIcon aria-hidden className="mr-2 size-4" />
{t("models.action.download")}
</>
)}
</Button>
)}
{/* owner-only 操作:公開設定 + 刪除 */}
{isOwner && (
<>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setVisibilityDialogOpen(true)}
data-testid="profile-visibility"
>
<Globe aria-hidden className="mr-2 size-4" />
{t("models.visibility.title")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deleting}>
<Trash2 aria-hidden className="mr-2 size-4" />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
<AlertDialogDescription>{profile.name}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
</div>
{/* 非 owner擁有者資訊列。 */}
{!isOwner && (
<ModelOwnerBar ownerName={profile.owner.name} sharedAt={profile.updatedAt} />
)}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("models.detail.description")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{profile.description ? (
<p className="text-sm">{profile.description}</p>
) : (
<p className="text-muted-foreground text-sm"></p>
)}
<div className="grid grid-cols-2 gap-3 pt-3 text-sm">
<InfoRow label={t("models.size")} value={formatFileSize(profile.fileSize)} />
<InfoRow
label={t("models.createdAt")}
value={profile.createdAt ? new Date(profile.createdAt).toLocaleString() : "—"}
/>
{profile.framework && (
<InfoRow
label={t("models.detail.framework")}
value={<span className="font-mono text-xs">{profile.framework}</span>}
/>
)}
{profile.inputShape && profile.inputShape.length > 0 && (
<InfoRow
label={t("models.detail.inputShape")}
value={
<span className="font-mono text-xs">
{formatInputShape(profile.inputShape)}
</span>
}
/>
)}
</div>
{profile.classes && profile.classes.length > 0 && (
<div className="space-y-2 border-t pt-3 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">{t("models.detail.classes")}</span>
<span className="text-muted-foreground text-xs">
{profile.classes.length} {t("models.detail.classesCountSuffix")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{profile.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
<Badge key={`${i}-${c}`} variant="secondary" className="font-normal">
{c}
</Badge>
))}
{profile.classes.length > CLASSES_PREVIEW_LIMIT && (
<Badge variant="outline" className="font-normal">
+{profile.classes.length - CLASSES_PREVIEW_LIMIT}
</Badge>
)}
</div>
</div>
)}
</CardContent>
</Card>
{isOwner && (
<ModelVisibilityDialog
modelId={profile.id}
modelName={profile.name}
currentVisibility={profile.visibility}
open={visibilityDialogOpen}
onOpenChange={setVisibilityDialogOpen}
/>
)}
</div>
);
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground">{label}</span>
<span className="text-right">{value}</span>
</div>
);
}

View File

@ -1,10 +1,16 @@
import { ModelDetailClient } from "./model-detail-client"; import { ModelProfileClient } from "./model-profile-client";
/**
* profile /models/[id]
*
* ModelProfileClientowner / GET /:id/profile
* owner owner-only ModelDetailClient
*/
export default async function ModelDetailPage({ export default async function ModelDetailPage({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
return <ModelDetailClient id={id} />; return <ModelProfileClient id={id} />;
} }

View File

@ -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(
<LocaleProvider>
<LibraryClient />
</LocaleProvider>,
);
}
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(),
);
});
});

View File

@ -0,0 +1,213 @@
"use client";
/**
* LibraryClient cursor
*
* §4 + API §1使cursor
*
* §7
* - skeleton
* - + IntersectionObserver loadMore
* - skeleton
* - EmptyState
* - EmptyState + CTA
* - +
*
* debounce 300ms §4.4local 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 (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
data-testid="library-skeleton"
>
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} className="h-52 rounded-lg" />
))}
</div>
);
}
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<ReturnType<typeof setTimeout> | 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 (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
<div>
<h1 className="text-2xl font-bold">{t("models.library.title")}</h1>
<p className="text-muted-foreground">{t("models.library.subtitle")}</p>
</div>
<LibraryToolbar
filters={filters}
searchInput={searchInput}
onSearchChange={setSearchInput}
onFilterChange={setFilters}
/>
{/* 搜尋結果數(無障礙播報)。 */}
<p className="sr-only" role="status" aria-live="polite">
{t("models.library.resultCount").replace("{n}", String(items.length))}
</p>
{/* 首屏載入 */}
{isLoading && <SkeletonGrid />}
{/* 列表錯誤(首屏) */}
{!isLoading && listError && items.length === 0 && (
<EmptyState
icon={SearchX}
title={t("models.library.error.title")}
description={t("models.library.error.description")}
action={{
label: t("common.retry"),
onClick: () => void loadFirstPage(),
}}
/>
)}
{/* 空狀態 */}
{isEmpty && !listError && (
isSearchActive ? (
<EmptyState
icon={SearchX}
title={t("models.library.empty.search.title")}
description={t("models.library.empty.search.description")}
action={{
label: t("models.search.clearAll"),
onClick: () => {
setSearchInput("");
setFilters({
q: "",
targetChip: "all",
visibility: "all",
owned: "all",
});
},
}}
/>
) : (
<EmptyState
icon={Users}
title={t("models.library.empty.title")}
description={t("models.library.empty.description")}
/>
)
)}
{/* 卡片網格 */}
{!isLoading && items.length > 0 && (
<>
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
data-testid="library-grid"
>
{items.map((model) => (
<LibraryModelCard key={model.id} model={model} />
))}
</div>
{/* 續載中 skeleton */}
{isLoadingMore && (
<div className="mt-4">
<SkeletonGrid count={4} />
</div>
)}
{/* 續載錯誤(已有資料時)→ 重試按鈕 */}
{listError && !isLoadingMore && (
<div className="mt-4 flex justify-center">
<Button
type="button"
variant="outline"
onClick={() => void loadMore()}
data-testid="library-load-more-retry"
>
{t("models.library.loadMore.retry")}
</Button>
</div>
)}
{/* 無限捲動哨兵(有下一頁且無錯誤時掛載) */}
{hasMore && !listError && (
<div
ref={sentinelRef}
className="h-4"
aria-hidden
data-testid="library-sentinel"
/>
)}
{/* 到底提示 */}
{!hasMore && (
<p
className="text-muted-foreground flex items-center justify-center gap-2 py-4 text-sm"
data-testid="library-end"
>
<Boxes aria-hidden className="size-4" />
{t("models.library.end")}
</p>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,11 @@
import { LibraryClient } from "./library-client";
/**
* /models/library
*
* preset
* cursor api-model-sharing.md §1feature-model-sharing-design.md §4
*/
export default function ModelLibraryPage() {
return <LibraryClient />;
}

View File

@ -12,6 +12,8 @@
*/ */
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Users } from "lucide-react";
import { import {
ModelFilters, ModelFilters,
@ -19,6 +21,7 @@ import {
} from "@/components/models/model-filters"; } from "@/components/models/model-filters";
import { ModelSection } from "@/components/models/model-section"; import { ModelSection } from "@/components/models/model-section";
import { ModelUploadDialog } from "@/components/models/model-upload-dialog"; import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context"; import { useT } from "@/lib/i18n/context";
import { import {
type ModelSource, type ModelSource,
@ -83,8 +86,16 @@ export default function ModelsPage() {
<h1 className="text-2xl font-bold">{t("models.title")}</h1> <h1 className="text-2xl font-bold">{t("models.title")}</h1>
<p className="text-muted-foreground">{t("models.subtitle")}</p> <p className="text-muted-foreground">{t("models.subtitle")}</p>
</div> </div>
<div className="flex items-center gap-2">
<Link href="/models/library">
<Button variant="outline" data-testid="models-library-link">
<Users aria-hidden className="mr-2 size-4" />
{t("models.library.link")}
</Button>
</Link>
<ModelUploadDialog /> <ModelUploadDialog />
</div> </div>
</div>
<ModelFilters value={filter} onChange={setFilter} /> <ModelFilters value={filter} onChange={setFilter} />
<div className="space-y-8"> <div className="space-y-8">
{SECTION_ORDER.map((source) => ( {SECTION_ORDER.map((source) => (

View File

@ -20,6 +20,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react"; import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
import { formatRelativeTime } from "@/lib/format/relative-time";
import { useT } from "@/lib/i18n/context"; import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { RemoteStatus } from "@/stores/device-store"; import type { RemoteStatus } from "@/stores/device-store";
@ -36,30 +37,6 @@ export interface RemoteDeviceBadgeProps {
className?: string; 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({ export function RemoteDeviceBadge({
status, status,
lastSeenAt, lastSeenAt,

View File

@ -63,3 +63,52 @@ describe("DeviceCard — serial 路由 gatingWP-C / ADR-018", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
}); });
describe("DeviceCard — 三態分色 + 註冊 UITDD §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();
});
});

View File

@ -23,6 +23,8 @@
import Link from "next/link"; import Link from "next/link";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge"; 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 { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
@ -30,6 +32,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { deriveTriState } from "@/lib/device-state";
import { useT } from "@/lib/i18n/context"; import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { DeviceSummary } from "@/stores/device-store"; import type { DeviceSummary } from "@/stores/device-store";
@ -44,15 +47,22 @@ export function DeviceCard({ device }: DeviceCardProps) {
const isOnline = device.remoteStatus === "online"; const isOnline = device.remoteStatus === "online";
// WP-CADR-018serial 為空 → 工作區(推論類操作)無法路由,入口 disable。 // WP-CADR-018serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
const hasSerial = !!device.serialNumber; const hasSerial = !!device.serialNumber;
// 三態TDD §5.2online-unregistered = 已連接未註冊(第三態,走 warning 色)。
const triState = deriveTriState(device);
const isOnlineUnregistered = triState === "online-unregistered";
const isRegistered = !!device.registeredAt;
return ( return (
<Card <Card
data-testid="device-card" data-testid="device-card"
data-remote-status={device.remoteStatus} data-remote-status={device.remoteStatus}
data-tri-state={triState}
className={cn( className={cn(
"transition-colors", "transition-colors",
// 離線裝置 opacity-75flow-offline-handling §4.1 // 離線裝置 opacity-75flow-offline-handling §4.1
!isOnline && device.remoteStatus !== "reconnecting" && "opacity-75", !isOnline && device.remoteStatus !== "reconnecting" && "opacity-75",
// 第三態「已連接未註冊」warning 色邊框(配合角落 UnregisteredBadge 的文字+icon不只靠色
isOnlineUnregistered && "border-warning",
)} )}
> >
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -63,11 +73,15 @@ export function DeviceCard({ device }: DeviceCardProps) {
<p className="text-muted-foreground truncate text-xs">{device.name}</p> <p className="text-muted-foreground truncate text-xs">{device.name}</p>
)} )}
</div> </div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<RemoteDeviceBadge <RemoteDeviceBadge
status={device.remoteStatus} status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null} lastSeenAt={device.lastSeenAt ?? null}
size="sm" size="sm"
/> />
{/* 第三態標記:連線與註冊是正交兩軸,未註冊用獨立 warning pill 疊加(不塞進連線 badge。 */}
{isOnlineUnregistered && <UnregisteredBadge size="sm" />}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
@ -93,6 +107,11 @@ export function DeviceCard({ device }: DeviceCardProps) {
{t("common.manage")} {t("common.manage")}
</Button> </Button>
</Link> </Link>
{/* 註冊 /
offline */}
{(isOnlineUnregistered || isRegistered) && (
<DeviceRegisterActions device={device} size="sm" />
)}
{isOnline && device.flashedModel && hasSerial && ( {isOnline && device.flashedModel && hasSerial && (
<Link href={`/workspace/${device.id}`}> <Link href={`/workspace/${device.id}`}>
<Button size="sm">{t("devices.openWorkspace")}</Button> <Button size="sm">{t("devices.openWorkspace")}</Button>

View File

@ -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 (
<div
className="flex flex-wrap items-center justify-between gap-3"
data-testid="device-list-controls"
>
{/* Filter chips — role="group" + aria-pressed不只靠色選取態有邊框/底色雙變化)。 */}
<div
role="group"
aria-label={t("devices.filter.label")}
className="flex flex-wrap gap-2"
>
{FILTERS.map(({ key, labelKey }) => {
const active = filter === key;
return (
<Button
key={key}
type="button"
size="sm"
variant={active ? "default" : "outline"}
aria-pressed={active}
onClick={() => onFilterChange(key)}
data-testid={`device-filter-${key}`}
className={cn(active && "font-semibold")}
>
{t(labelKey)}
</Button>
);
})}
</div>
{/* Sort — Select */}
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">{t("devices.sort.label")}</span>
<Select
value={sortKey}
onValueChange={(v) => onSortChange(v as DeviceSortKey)}
>
<SelectTrigger
size="sm"
className="w-[10rem]"
data-testid="device-sort-select"
aria-label={t("devices.sort.label")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORTS.map(({ key, labelKey }) => (
<SelectItem key={key} value={key}>
{t(labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@ -1,45 +1,56 @@
"use client"; "use client";
/** /**
* DeviceList + + skeleton * DeviceList + + skeleton + /filter
* *
* `local-tool/frontend/src/components/devices/device-list.tsx` * `local-tool/frontend/src/components/devices/device-list.tsx`
* *
* *
* - `.autoflow/03-design/pages.md` §5.3 * - `.autoflow/03-design/pages.md` §5.3
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6 + filter
* *
* *
* - `/devices/pair`F7 Pairing scan * - `/devices/pair`F7 Pairing scan
* - online reconnecting unknown offline error * - / /
* - filter / / / + filter
*/ */
import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Link2 } from "lucide-react"; import { Link2, SearchX } from "lucide-react";
import { DeviceCard } from "@/components/devices/device-card"; import { DeviceCard } from "@/components/devices/device-card";
import { DeviceListControls } from "@/components/devices/device-list-controls";
import { EmptyState } from "@/components/ui/empty-state"; import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import {
applyDeviceListView,
type DeviceFilterKey,
type DeviceSortKey,
} from "@/lib/device-state";
import { useT } from "@/lib/i18n/context"; import { useT } from "@/lib/i18n/context";
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store"; import type { DeviceSummary } from "@/stores/device-store";
interface DeviceListProps { interface DeviceListProps {
devices: DeviceSummary[]; devices: DeviceSummary[];
loading?: boolean; loading?: boolean;
} }
const STATUS_ORDER: Record<RemoteStatus, number> = {
online: 0,
reconnecting: 1,
unknown: 2,
offline: 3,
error: 4,
};
export function DeviceList({ devices, loading }: DeviceListProps) { export function DeviceList({ devices, loading }: DeviceListProps) {
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
// 排序 / filter 狀態存 local stateP0 不持久化TDD §6.2)。
const [sortKey, setSortKey] = useState<DeviceSortKey>("status");
const [filter, setFilter] = useState<DeviceFilterKey>("all");
// 先 filter 再 sortTDD §6.3devices / 條件變動才重算。
const visible = useMemo(
() => applyDeviceListView(devices, filter, sortKey),
[devices, filter, sortKey],
);
if (loading) { if (loading) {
return ( return (
<div <div
@ -53,6 +64,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
); );
} }
// 完全沒有裝置(非 filter 造成)→ 導向配對的既有空狀態。
if (devices.length === 0) { if (devices.length === 0) {
return ( return (
<EmptyState <EmptyState
@ -74,16 +86,34 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
); );
} }
const sorted = [...devices].sort(
(a, b) => STATUS_ORDER[a.remoteStatus] - STATUS_ORDER[b.remoteStatus],
);
return ( return (
<div className="space-y-4" data-testid="device-list-container">
<DeviceListControls
sortKey={sortKey}
filter={filter}
onSortChange={setSortKey}
onFilterChange={setFilter}
/>
{visible.length === 0 ? (
// filter 後 0 筆 → 與「完全沒裝置」區隔的空結果狀態(可清除 filter
<div data-testid="device-filter-empty">
<EmptyState
icon={SearchX}
title={t("devices.filter.empty.title")}
description={t("devices.filter.empty.description")}
action={{
label: t("devices.filter.empty.action"),
onClick: () => setFilter("all"),
}}
/>
</div>
) : (
<div <div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
data-testid="device-list" data-testid="device-list"
> >
{sorted.map((device) => ( {visible.map((device) => (
<DeviceCard key={device.id} device={device} /> <DeviceCard key={device.id} device={device} />
))} ))}
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */} {/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
@ -98,5 +128,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
</span> </span>
</Link> </Link>
</div> </div>
)}
</div>
); );
} }

View File

@ -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
* unpairunregister 退
* 使
* - 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<void>;
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 (
<Button
size={size}
variant="outline"
onClick={handleUnregister}
disabled={isPending}
data-testid="device-unregister-btn"
>
<UserX aria-hidden="true" className="mr-1.5 size-4" />
{isPending ? t("devices.unregister.pending") : t("devices.unregister.action")}
</Button>
);
}
// 未註冊只有「已連接未註冊」才可註冊offline 未註冊不顯示 → 由呼叫端 gate
return (
<Button
size={size}
onClick={handleRegister}
disabled={isPending}
data-testid="device-register-btn"
>
<UserCheck aria-hidden="true" className="mr-1.5 size-4" />
{isPending ? t("devices.register.pending") : t("devices.register.action")}
</Button>
);
}

View File

@ -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 iconTriangleAlert+
*/
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 (
<span
data-testid="unregistered-badge"
// role/aria-label讓 SR 讀出「未註冊」而非只感知一個色塊。
role="status"
aria-label={label}
className={cn(
"bg-warning-subtle text-warning-foreground border-warning inline-flex items-center gap-1 rounded-full border font-medium",
size === "sm" ? "px-2 py-0.5 text-xs" : "px-2.5 py-1 text-sm",
className,
)}
>
<TriangleAlert
aria-hidden="true"
className={cn("text-warning shrink-0", size === "sm" ? "size-3" : "size-3.5")}
/>
{label}
</span>
);
}

View File

@ -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
* - <Link> /models/{id}profile
*
* §4response 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 (
<>
<Card className="hover:bg-accent/40 relative h-full transition-shadow hover:shadow-md">
{/* owner ⋮ 選單(絕對定位右上,避免與 <Link> 導航衝突)。 */}
{isOwner && (
<div className="absolute right-2 top-2 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9"
aria-label={t("models.card.menu.aria")}
data-testid="library-card-menu"
onClick={(e) => {
// 阻止冒泡到外層 <Link>。
e.preventDefault();
e.stopPropagation();
}}
>
<MoreVertical aria-hidden className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setVisibilityDialogOpen(true);
}}
data-testid="library-card-visibility"
>
<Settings2 aria-hidden className="size-4" />
{t("models.visibility.title")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<Link href={`/models/${model.id}`} data-testid="library-model-card">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2 pr-8">
<CardTitle className="text-base leading-tight">{model.name}</CardTitle>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="text-xs">
{model.targetChip.toUpperCase()}
</Badge>
<ModelVisibilityBadge
visibility={model.visibility}
sharedWithMe={model.sharedWithMe}
/>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-muted-foreground">{t("models.size")}</p>
<p className="font-medium">{formatFileSize(model.fileSize)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("models.createdAt")}</p>
<p className="font-medium">
{model.createdAt
? new Date(model.createdAt).toLocaleDateString()
: "—"}
</p>
</div>
</div>
{/* receiver 視角:顯示分享者(契約不給 email用 owner.name。 */}
{!isOwner && (
<p
className="text-muted-foreground mt-3 text-xs"
data-testid="library-card-owner-info"
>
{t("models.sharedByName").replace("{name}", model.owner.name)}
{model.updatedAt
? ` · ${formatRelativeTime(model.updatedAt, nowMs ?? mountedNow, t)}`
: ""}
</p>
)}
</CardContent>
</Link>
</Card>
{isOwner && (
<ModelVisibilityDialog
modelId={model.id}
modelName={model.name}
currentVisibility={model.visibility}
open={visibilityDialogOpen}
onOpenChange={setVisibilityDialogOpen}
/>
)}
</>
);
}

View File

@ -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 parentparent 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<LibraryFilters>) => void;
}
export function LibraryToolbar({
filters,
searchInput,
onSearchChange,
onFilterChange,
}: LibraryToolbarProps) {
const t = useT();
return (
<div
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
data-testid="library-toolbar"
role="group"
aria-label={t("models.filters.label")}
>
{/* 搜尋框 */}
<div className="relative w-full sm:max-w-xs">
<Search
aria-hidden
className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
/>
<Input
type="search"
role="searchbox"
value={searchInput}
onChange={(e) => 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 && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
onClick={() => onSearchChange("")}
aria-label={t("models.search.clear")}
data-testid="library-search-clear"
>
<X aria-hidden className="size-4" />
</Button>
)}
</div>
{/* 擁有關係 filter */}
<Select
value={filters.owned}
onValueChange={(v) =>
onFilterChange({ owned: v as LibraryFilters["owned"] })
}
>
<SelectTrigger className="h-9 w-full sm:w-40" aria-label={t("models.filters.owned")}>
<SelectValue placeholder={t("models.filters.owned")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("models.filters.owned.all")}</SelectItem>
<SelectItem value="mine">{t("models.filters.owned.mine")}</SelectItem>
<SelectItem value="shared">{t("models.filters.owned.shared")}</SelectItem>
</SelectContent>
</Select>
{/* 可見性 filter */}
<Select
value={filters.visibility}
onValueChange={(v) =>
onFilterChange({ visibility: v as LibraryFilters["visibility"] })
}
>
<SelectTrigger
className="h-9 w-full sm:w-40"
aria-label={t("models.filters.visibility")}
>
<SelectValue placeholder={t("models.filters.visibility")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
<SelectItem value="public">{t("models.visibility.public")}</SelectItem>
<SelectItem value="tenant">{t("models.visibility.tenant")}</SelectItem>
</SelectContent>
</Select>
{/* 晶片 filter */}
<Select
value={filters.targetChip}
onValueChange={(v) =>
onFilterChange({ targetChip: v as LibraryFilters["targetChip"] })
}
>
<SelectTrigger
className="h-9 w-full sm:w-36"
aria-label={t("models.filters.hardware")}
>
<SelectValue placeholder={t("models.filters.hardware")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
<SelectItem value="kl520">KL520</SelectItem>
<SelectItem value="kl720">KL720</SelectItem>
<SelectItem value="kl630">KL630</SelectItem>
<SelectItem value="kl730">KL730</SelectItem>
</SelectContent>
</Select>
{/* 排序 */}
<Select
value={filters.sort}
onValueChange={(v) =>
onFilterChange({ sort: v as LibraryFilters["sort"] })
}
>
<SelectTrigger className="h-9 w-full sm:w-40" aria-label={t("models.sort.label")}>
<SelectValue placeholder={t("models.sort.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at">{t("models.sort.createdAt")}</SelectItem>
<SelectItem value="name">{t("models.sort.name")}</SelectItem>
<SelectItem value="file_size">{t("models.sort.fileSize")}</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@ -0,0 +1,49 @@
"use client";
/**
* ModelOwnerBar
*
* §6.4 owner profile {ownerName} · {time}
*
* §4response 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 (
<div
className="bg-muted/50 flex items-center gap-2 rounded-md px-3 py-2 text-sm"
aria-label={t("models.ownerBar.aria")}
data-testid="model-owner-bar"
>
<Avatar className="size-6">
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="truncate">
{t("models.sharedByName").replace("{name}", ownerName)}
{relative ? ` · ${relative}` : ""}
</span>
</div>
);
}

View File

@ -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(
<LocaleProvider>
<ModelVisibilityBadge {...props} />
</LocaleProvider>,
);
}
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 人");
});
});

View File

@ -0,0 +1,100 @@
"use client";
/**
* ModelVisibilityBadge
*
* + 滿 De1
* - privateLockowner
* - publicGlobechart-2 tint visionA 使
* - tenantBuilding2chart-1 tint
*
* sharedWithMe=true Userschart-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 (
<Badge
variant="outline"
className={cn("border-chart-3/30 bg-chart-3/10 text-chart-3 gap-1 text-xs", className)}
data-testid="model-visibility-badge"
data-visibility="shared"
>
<Users aria-hidden className="size-3" />
{t("models.visibility.badge.sharedWithMe")}
</Badge>
);
}
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 (
<Badge
variant="outline"
className={cn("gap-1 text-xs", meta.className, className)}
data-testid="model-visibility-badge"
data-visibility={visibility}
>
<Icon aria-hidden className="size-3" />
{label}
{suffix}
</Badge>
);
}

View File

@ -0,0 +1,211 @@
/**
* ModelVisibilityDialog
*
*
* - / /
* - public amber
* - email inline store.addShare
* - +
* - store.updateVisibility + toast.success +
*
* store mock _setMockMode true+ shares API
* Radix Dialog / RadioGroup jsdomdialog 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(
<LocaleProvider>
<ModelVisibilityDialog
modelId="mock-model-01"
modelName="測試模型"
currentVisibility={currentVisibility}
open
onOpenChange={vi.fn()}
/>
</LocaleProvider>,
);
}
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();
});
});

View File

@ -0,0 +1,385 @@
"use client";
/**
* ModelVisibilityDialog owner-only
*
* §5 + API §3PATCH visibility+ shares API§4
*
*
* - RadioGroup private / public / tenant
* API visibility PRD restricted model_shares
* visibility
* - model_sharesemail + 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 open={open} onOpenChange={onOpenChange}>
{/* key 綁 modelId開啟不同模型時 body remountstate 重新以 props 初始化。 */}
{open && <VisibilityDialogBody key={props.modelId} {...props} />}
</Dialog>
);
}
/**
* Dialog body open state props setState-in-effect
* sharesexternal 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<ModelVisibility>(currentVisibility);
const [emailInput, setEmailInput] = useState("");
const [emailError, setEmailError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [addingEmail, setAddingEmail] = useState(false);
const [confirmRevokeOpen, setConfirmRevokeOpen] = useState(false);
// 掛載時載入授權清單external syncmodelId 於本 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 (
<>
<DialogContent className="max-w-lg" data-testid="model-visibility-dialog">
<DialogHeader>
<DialogTitle>
{t("models.visibility.title")} {modelName}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm font-medium">{t("models.visibility.question")}</p>
<RadioGroup
value={visibility}
onValueChange={(v) => setVisibility(v as ModelVisibility)}
aria-label={t("models.visibility.question")}
>
{VISIBILITY_OPTIONS.map((opt) => (
<label
key={opt.value}
htmlFor={`visibility-${opt.value}`}
className="hover:bg-accent/40 flex cursor-pointer items-start gap-3 rounded-md border p-3"
data-testid={`visibility-option-${opt.value}`}
>
<RadioGroupItem
value={opt.value}
id={`visibility-${opt.value}`}
aria-label={t(opt.labelKey)}
className="mt-0.5"
/>
<span className="space-y-0.5">
<span className="block text-sm font-medium">{t(opt.labelKey)}</span>
<span className="text-muted-foreground block text-xs">
{t(opt.descKey)}
</span>
</span>
</label>
))}
</RadioGroup>
{/* public 警告條(沿用 §2.1 amber 半語義約定)。 */}
{visibility === "public" && (
<div
className="flex items-start gap-2 rounded-md bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
role="alert"
data-testid="visibility-public-warning"
>
<Globe aria-hidden className="mt-0.5 size-4 shrink-0" />
<span>{t("models.visibility.publicWarning")}</span>
</div>
)}
{/* 指定對象model_shares管理與 visibility 正交,永遠可用。 */}
<div className="space-y-2 border-t pt-4">
<Label htmlFor="share-email-input" className="text-sm font-medium">
{t("models.visibility.sharedPeopleTitle")}
</Label>
<div className="flex gap-2">
<div className="flex-1">
<Input
id="share-email-input"
type="email"
value={emailInput}
onChange={(e) => {
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"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => void handleAddEmail()}
disabled={addingEmail || !emailInput.trim()}
data-testid="share-email-add"
>
{addingEmail ? (
<Spinner size="sm" label={t("common.loading")} />
) : (
t("models.visibility.addButton")
)}
</Button>
</div>
{emailError && (
<p className="text-destructive text-xs" role="alert">
{emailError}
</p>
)}
{/* 授權清單 */}
<div
className="max-h-40 space-y-1 overflow-y-auto"
data-testid="share-list"
>
{isSharesLoading ? (
<p className="text-muted-foreground py-2 text-center text-xs">
{t("common.loading")}
</p>
) : shares.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">
{t("models.visibility.noShares")}
</p>
) : (
shares.map((share) => (
<div
key={share.userId}
className="bg-muted/50 flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm"
>
<span className="truncate">{share.email}</span>
<span className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
{t("models.visibility.permissionViewDownload")}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={() => void handleRemoveShare(share.userId)}
aria-label={t("models.visibility.removeShare").replace(
"{email}",
share.email,
)}
data-testid="share-remove"
>
<X aria-hidden className="size-4" />
</Button>
</span>
</div>
))
)}
</div>
</div>
{saveError && (
<div
className={cn(
"border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-3 text-xs",
)}
role="alert"
data-testid="visibility-save-error"
>
{saveError}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSaveClick}
disabled={saving}
data-testid="visibility-save"
>
{saving ? (
<>
<Spinner size="sm" label={t("common.loading")} />
{t("common.loading")}
</>
) : (
<>
<Lock aria-hidden className="size-4" />
{t("models.visibility.saveButton")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
{/* 收回權限二次確認 */}
<AlertDialog open={confirmRevokeOpen} onOpenChange={setConfirmRevokeOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("models.visibility.revokeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmRevokeOpen(false);
void doSave();
}}
>
{t("models.visibility.saveButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -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 <Label htmlFor> label
*/
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-2", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,62 @@
"use client";
/**
* useInfiniteScroll hook
*
* IntersectionObserver + rootMargin
* `onLoadMore` cursor §4.6使
*
*
* - `enabled=false` hasMore=false / loading
* - `onLoadMore` ref observer
* - rootMargin `200px` 200px
* - store loadMore isLoadingMore / hasMore
* 使 observer
*
* `sentinelRef` div
*/
import { useCallback, useEffect, useRef } from "react";
interface UseInfiniteScrollOptions {
/** 是否啟用觀察(通常 = hasMore && !isLoading。 */
enabled: boolean;
/** 觸達哨兵時呼叫。 */
onLoadMore: () => void;
/** 提前量(哨兵距視窗多遠就觸發)。預設 "200px"。 */
rootMargin?: string;
}
export function useInfiniteScroll({
enabled,
onLoadMore,
rootMargin = "200px",
}: UseInfiniteScrollOptions) {
const sentinelRef = useRef<HTMLDivElement | null>(null);
const onLoadMoreRef = useRef(onLoadMore);
// 保持最新 callback避免 observer 因 callback 身分變動而重建。
useEffect(() => {
onLoadMoreRef.current = onLoadMore;
}, [onLoadMore]);
const handleIntersect = useCallback<IntersectionObserverCallback>((entries) => {
const entry = entries[0];
if (entry?.isIntersecting) {
onLoadMoreRef.current();
}
}, []);
useEffect(() => {
const sentinel = sentinelRef.current;
// 環境不支援 IntersectionObserver如部分測試環境時安全退出。
if (!enabled || !sentinel || typeof IntersectionObserver === "undefined") {
return;
}
const observer = new IntersectionObserver(handleIntersect, { rootMargin });
observer.observe(sentinel);
return () => observer.disconnect();
}, [enabled, handleIntersect, rootMargin]);
return { sentinelRef };
}

View File

@ -0,0 +1,155 @@
/**
* Model Sharing Mock Fixtures
*
* API mockapi-model-sharing.md response
* snake_case model-sharing.ts normalize
*
*
* 1. store `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1` mock model-sharing-store
* 2. fixture
*
* mock owner email §4 email
* shares email owner email
*/
/** 契約 §1 library item 的原始snake_case形狀。 */
export interface RawLibraryItem {
id: string;
name: string;
description?: string;
target_chip: string;
file_size: number;
source: string;
status: string;
visibility: string;
owner: { id: string; name: string; is_me: boolean };
shared_with_me: boolean;
my_access: string;
created_at: string;
updated_at: string;
}
/** 契約 §1 分頁 response 原始形狀。 */
export interface RawLibraryPage {
items: RawLibraryItem[];
next_cursor: string | null;
has_more: boolean;
}
const NOW = "2026-08-01T00:00:00Z";
/**
* mock 30 cursor
* owner/ ownershared_with_me UI
*/
export const MOCK_LIBRARY_ITEMS: RawLibraryItem[] = Array.from({ length: 30 }).map(
(_, i) => {
const isMine = i % 3 === 0;
const visibility = isMine ? "private" : i % 3 === 1 ? "public" : "tenant";
const sharedWithMe = !isMine && i % 4 === 0;
const chips = ["kl520", "kl720", "kl630", "kl730"];
const sources = ["converted", "uploaded", "preset"];
return {
id: `mock-model-${String(i + 1).padStart(2, "0")}`,
name: `mock-model-${i + 1} ${["yolov5s", "resnet50", "mobilenet", "ssd"][i % 4]}`,
description: i % 2 === 0 ? `Mock 模型 ${i + 1} 的描述` : undefined,
target_chip: chips[i % chips.length],
file_size: (i + 1) * 1024 * 1024,
source: isMine ? "converted" : sources[i % sources.length],
status: "ready",
visibility,
owner: isMine
? { id: "me", name: "我", is_me: true }
: { id: `owner-${i}`, name: `Owner ${(i % 5) + 1}`, is_me: false },
shared_with_me: sharedWithMe,
my_access: isMine ? "owner" : sharedWithMe ? "viewer" : "viewer",
created_at: `2026-07-${String((i % 28) + 1).padStart(2, "0")}T00:00:00Z`,
updated_at: NOW,
};
},
);
/**
* MOCK_LIBRARY_ITEMS query / filter / / cursor
* cursor = base64mock
*/
export function mockLibraryPage(query: {
cursor?: string;
limit?: number;
q?: string;
targetChip?: string;
source?: string;
visibility?: string;
owned?: boolean;
sort?: string;
order?: string;
}): RawLibraryPage {
let items = [...MOCK_LIBRARY_ITEMS];
// filter
if (query.q) {
const q = query.q.toLowerCase();
items = items.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
(m.description ?? "").toLowerCase().includes(q),
);
}
if (query.targetChip) {
items = items.filter((m) => m.target_chip === query.targetChip);
}
if (query.source) {
items = items.filter((m) => m.source === query.source);
}
if (query.visibility) {
items = items.filter((m) => m.visibility === query.visibility);
}
if (query.owned === true) {
items = items.filter((m) => m.owner.is_me);
} else if (query.owned === false) {
items = items.filter((m) => !m.owner.is_me);
}
// sort
const order = query.order === "asc" ? 1 : -1;
const sortKey = query.sort ?? "created_at";
items.sort((a, b) => {
if (sortKey === "name") return a.name.localeCompare(b.name) * order;
if (sortKey === "file_size") return (a.file_size - b.file_size) * order;
return a.created_at.localeCompare(b.created_at) * order;
});
// cursor 分頁
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const start = query.cursor ? Number(atob(query.cursor)) : 0;
const slice = items.slice(start, start + limit);
const nextStart = start + slice.length;
const hasMore = nextStart < items.length;
return {
items: slice,
next_cursor: hasMore ? btoa(String(nextStart)) : null,
has_more: hasMore,
};
}
/** 依 id 產生一筆 profile 原始形狀(找不到回 null模擬 404。 */
export function mockProfile(id: string): Record<string, unknown> | null {
const item = MOCK_LIBRARY_ITEMS.find((m) => m.id === id);
if (!item) return null;
return {
...item,
input_shape: [1, 3, 224, 224],
classes: ["person", "car", "dog", "cat"],
framework: "onnx",
can_download: item.my_access !== "none",
uploaded_at: item.created_at,
};
}
/** mock shares 清單owner 檢視自己模型的授權對象)。 */
export const MOCK_SHARES: Record<string, Array<{ user_id: string; email: string; role: string; created_at: string }>> = {
"mock-model-01": [
{ user_id: "u-alice", email: "alice@corp.com", role: "viewer", created_at: NOW },
{ user_id: "u-bob", email: "bob@corp.com", role: "viewer", created_at: NOW },
],
};

View File

@ -0,0 +1,180 @@
/**
* Model Sharing API Client
*
*
* - normalizesnake_case camelCasevisibility / access
* - mock cursor filterdeterministic API
* - isValidEmail
*/
import { describe, expect, it } from "vitest";
import {
isValidEmail,
normalizeLibraryModel,
normalizeLibraryPage,
normalizeProfile,
} from "./model-sharing";
import { mockLibraryPage, mockProfile, MOCK_LIBRARY_ITEMS } from "./model-sharing.mock";
describe("normalizeLibraryModel", () => {
it("snake_case → camelCase + 收斂 target_chip 大小寫", () => {
const m = normalizeLibraryModel({
id: "m1",
name: "YOLO",
target_chip: "KL520",
file_size: 2048,
source: "converted",
status: "ready",
visibility: "public",
owner: { id: "o1", name: "Alice", is_me: false },
shared_with_me: true,
my_access: "viewer",
created_at: "2026-07-01T00:00:00Z",
updated_at: "2026-07-02T00:00:00Z",
});
expect(m.targetChip).toBe("kl520");
expect(m.fileSize).toBe(2048);
expect(m.visibility).toBe("public");
expect(m.owner.isMe).toBe(false);
expect(m.sharedWithMe).toBe(true);
expect(m.myAccess).toBe("viewer");
});
it("非法 visibility → 收斂為 private非法 access → none", () => {
const m = normalizeLibraryModel({
id: "m2",
visibility: "weird",
my_access: "hacker",
owner: {},
});
expect(m.visibility).toBe("private");
expect(m.myAccess).toBe("none");
});
it("缺欄位 → 安全預設(不 throw", () => {
const m = normalizeLibraryModel({});
expect(m.id).toBe("");
expect(m.fileSize).toBe(0);
expect(m.source).toBe("uploaded");
expect(m.visibility).toBe("private");
});
});
describe("normalizeLibraryPage", () => {
it("解析 items + next_cursor + has_more", () => {
const page = normalizeLibraryPage({
items: [{ id: "a" }, { id: "b" }],
next_cursor: "abc",
has_more: true,
});
expect(page.items).toHaveLength(2);
expect(page.nextCursor).toBe("abc");
expect(page.hasMore).toBe(true);
});
it("無 next_cursor → null", () => {
const page = normalizeLibraryPage({ items: [], next_cursor: null, has_more: false });
expect(page.nextCursor).toBeNull();
expect(page.hasMore).toBe(false);
});
});
describe("normalizeProfile", () => {
it("解析 input_shape / classes / can_download", () => {
const p = normalizeProfile({
id: "p1",
name: "N",
target_chip: "kl720",
input_shape: [1, 3, 224, 224],
classes: ["cat", "dog"],
can_download: true,
my_access: "owner",
owner: { id: "o", name: "Me", is_me: true },
});
expect(p.inputShape).toEqual([1, 3, 224, 224]);
expect(p.classes).toEqual(["cat", "dog"]);
expect(p.canDownload).toBe(true);
expect(p.myAccess).toBe("owner");
});
it("空 classes 陣列 → undefinedUI 有值才顯示)", () => {
const p = normalizeProfile({ id: "p2", classes: [], owner: {} });
expect(p.classes).toBeUndefined();
});
});
describe("mockLibraryPage — cursor 分頁", () => {
it("首頁 limit=10 → 回 10 筆 + has_more + next_cursor", () => {
const page = mockLibraryPage({ limit: 10 });
expect(page.items).toHaveLength(10);
expect(page.has_more).toBe(true);
expect(page.next_cursor).not.toBeNull();
});
it("用 next_cursor 續載 → 不重複、能一路切到底", () => {
const seen = new Set<string>();
let cursor: string | undefined;
let guard = 0;
for (;;) {
const page: ReturnType<typeof mockLibraryPage> = mockLibraryPage({ limit: 7, cursor });
for (const item of page.items) {
expect(seen.has(item.id)).toBe(false); // 不重複
seen.add(item.id);
}
if (!page.has_more || !page.next_cursor) break;
cursor = page.next_cursor;
if (++guard > 20) throw new Error("cursor 未收斂");
}
expect(seen.size).toBe(MOCK_LIBRARY_ITEMS.length);
});
it("filter owned=true → 只回我的owner.is_me", () => {
const page = mockLibraryPage({ limit: 100, owned: true });
expect(page.items.every((m) => m.owner.is_me)).toBe(true);
expect(page.items.length).toBeGreaterThan(0);
});
it("filter visibility=public → 只回 public", () => {
const page = mockLibraryPage({ limit: 100, visibility: "public" });
expect(page.items.every((m) => m.visibility === "public")).toBe(true);
});
it("搜尋 q 無 match → 空頁 + has_more=false", () => {
const page = mockLibraryPage({ limit: 100, q: "zzz-no-such-model" });
expect(page.items).toHaveLength(0);
expect(page.has_more).toBe(false);
});
it("sort=name asc → 名稱遞增", () => {
const page = mockLibraryPage({ limit: 100, sort: "name", order: "asc" });
const names = page.items.map((m) => m.name);
const sorted = [...names].sort((a, b) => a.localeCompare(b));
expect(names).toEqual(sorted);
});
});
describe("mockProfile", () => {
it("存在 id → 回 profile 原始形狀(含 can_download", () => {
const raw = mockProfile("mock-model-01");
expect(raw).not.toBeNull();
expect(raw!.can_download).toBeDefined();
});
it("不存在 id → null模擬 404", () => {
expect(mockProfile("no-such")).toBeNull();
});
});
describe("isValidEmail", () => {
it.each([
["alice@corp.com", true],
["a@b.co", true],
["no-at-sign", false],
["missing@domain", false],
["@no-local.com", false],
["", false],
])("%s → %s", (email, expected) => {
expect(isValidEmail(email)).toBe(expected);
});
});

View File

@ -0,0 +1,487 @@
/**
* Model Sharing API Client visionA Cloud L
*
*
* - `docs/autoflow/04-architecture/api/api-model-sharing.md`
* - `docs/autoflow/02-prd/features/feature-model-sharing.md`
* - `docs/autoflow/03-design/feature-model-sharing-design.md`UI
*
* API shared
* - visibility `private` / `tenant` / `public`
* - model_shares visibility response `shared_with_me`
* - my_access`owner` / `editor` / `viewer` / `none`
*
* Endpoint
* 1. GET /api/models/library cursor
* 2. GET /api/models/:id/profile
* 3. PATCH /api/models/:id/visibility owner-only
* 4. GET/POST/DELETE /api/models/:id/shares owner-only
*
* response owner email §4 email
* shares email owner
*
* client API mock mock
*/
import { ApiError, api } from "@/lib/api";
/* -------------------------------------------------------------------------- */
/* Types — 對齊 api-model-sharing.md */
/* -------------------------------------------------------------------------- */
/** 模型可見性(廣播維度)。 */
export type ModelVisibility = "private" | "tenant" | "public";
/** 當前 user 對模型的有效權限(取最高)。 */
export type ModelAccess = "owner" | "editor" | "viewer" | "none";
/** 排序欄位(契約 §1。 */
export type LibrarySort = "created_at" | "name" | "file_size";
export type SortOrder = "asc" | "desc";
/** 共享庫列表項的 owner 資訊(契約:只揭露 id / name / is_me不揭露 email。 */
export interface LibraryOwner {
id: string;
name: string;
isMe: boolean;
}
/** 共享庫單一模型項(契約 §1 response items。 */
export interface LibraryModel {
id: string;
name: string;
description?: string;
targetChip: string;
fileSize: number;
source: "uploaded" | "converted" | "preset";
status: "pending" | "ready";
visibility: ModelVisibility;
owner: LibraryOwner;
/** 是否因 model_shares 命中(供 UI 標「共享給我」)。 */
sharedWithMe: boolean;
/** 當前 user 的有效權限。 */
myAccess: ModelAccess;
createdAt: string;
updatedAt: string;
}
/** 共享庫分頁結果cursor 分頁契約 §1.3)。 */
export interface LibraryPage {
items: LibraryModel[];
/** 下一頁游標(不透明);無下一頁時為 null。 */
nextCursor: string | null;
hasMore: boolean;
}
/** 共享庫查詢參數(契約 §1 query。 */
export interface LibraryQuery {
cursor?: string;
limit?: number;
sort?: LibrarySort;
order?: SortOrder;
/** 搜尋關鍵字(比對 name + description。 */
q?: string;
targetChip?: "kl520" | "kl720" | "kl630" | "kl730";
source?: "uploaded" | "converted" | "preset";
/** 僅過濾廣播類public / tenantprivate 不在共享庫語意內。 */
visibility?: "public" | "tenant";
/** true=只看我的、false=只看別人分享/公開給我的、不帶=全部。 */
owned?: boolean;
}
/** 模型 profile 頁(公開版詳情,契約 §2。 */
export interface ModelProfile {
id: string;
name: string;
description?: string;
targetChip: string;
fileSize: number;
source: "uploaded" | "converted" | "preset";
status: "pending" | "ready";
visibility: ModelVisibility;
inputShape?: number[];
classes?: string[];
framework?: string;
owner: LibraryOwner;
myAccess: ModelAccess;
/** my_access != none 時 true前端據此決定是否顯示下載鈕。 */
canDownload: boolean;
createdAt: string;
updatedAt: string;
uploadedAt?: string;
}
/** 點對點分享單一授權對象owner 檢視自己模型的授權清單)。 */
export interface ModelShare {
/** 被授權 user id。 */
userId: string;
/** 被授權 user 的顯示 emailowner 自己輸入的授權目標,可顯示)。 */
email: string;
/** 授權角色P0 固定 viewer = 可檢視 + 下載)。 */
role: "viewer" | "editor";
createdAt: string;
}
/* -------------------------------------------------------------------------- */
/* Error class */
/* -------------------------------------------------------------------------- */
/**
* UI `error.code` i18n key`models.sharing.error.<code>`
*
* code
* - `not_found`404 enumeration
* - `forbidden`403 owner visibility / shares
* - `conflict`409 ready
* - `validation_failed`400visibility / email / org tenant
* - `network_error` / `unknown`
*/
export class ModelSharingError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "ModelSharingError";
this.status = status;
this.code = code;
if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, ModelSharingError);
}
}
}
/** 把底層 ApiError / 一般 Error 包成 ModelSharingErrorcode 統一小寫)。 */
function wrapError(err: unknown): ModelSharingError {
if (err instanceof ModelSharingError) return err;
if (err instanceof ApiError) {
return new ModelSharingError(err.status, err.code.toLowerCase(), err.message);
}
if (err instanceof Error) {
const maybeCode = (err as unknown as { code?: unknown }).code;
const code =
typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error";
return new ModelSharingError(0, code, err.message);
}
return new ModelSharingError(0, "unknown", String(err));
}
/* -------------------------------------------------------------------------- */
/* snake_case ⇄ camelCase 正規化(後端契約用 snake_case */
/* -------------------------------------------------------------------------- */
type Raw = Record<string, unknown>;
function asRaw(v: unknown): Raw {
return (v ?? {}) as Raw;
}
function pickStr(r: Raw, ...keys: string[]): string {
for (const k of keys) {
if (r[k] !== undefined && r[k] !== null) return String(r[k]);
}
return "";
}
function pickNum(r: Raw, ...keys: string[]): number {
for (const k of keys) {
if (r[k] !== undefined && r[k] !== null) return Number(r[k]);
}
return 0;
}
function pickBool(r: Raw, ...keys: string[]): boolean {
for (const k of keys) {
if (r[k] !== undefined && r[k] !== null) return Boolean(r[k]);
}
return false;
}
function normalizeVisibility(v: unknown): ModelVisibility {
return v === "public" || v === "tenant" ? v : "private";
}
function normalizeAccess(v: unknown): ModelAccess {
return v === "owner" || v === "editor" || v === "viewer" ? v : "none";
}
function normalizeOwner(raw: unknown): LibraryOwner {
const r = asRaw(raw);
return {
id: pickStr(r, "id"),
name: pickStr(r, "name"),
isMe: pickBool(r, "is_me", "isMe"),
};
}
function normalizeNumberArray(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const arr = value.map((v) => Number(v)).filter((n) => Number.isFinite(n));
return arr.length > 0 ? arr : undefined;
}
function normalizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
const arr = value
.filter((v) => v !== null && v !== undefined)
.map((v) => String(v));
return arr.length > 0 ? arr : undefined;
}
export function normalizeLibraryModel(raw: unknown): LibraryModel {
const r = asRaw(raw);
const rawChip = pickStr(r, "target_chip", "targetChip");
const source = pickStr(r, "source") || "uploaded";
const status = pickStr(r, "status") || "ready";
return {
id: pickStr(r, "id"),
name: pickStr(r, "name"),
description: r.description ? String(r.description) : undefined,
targetChip: rawChip.toLowerCase(),
fileSize: pickNum(r, "file_size", "fileSize"),
source: source as LibraryModel["source"],
status: status as LibraryModel["status"],
visibility: normalizeVisibility(r.visibility),
owner: normalizeOwner(r.owner),
sharedWithMe: pickBool(r, "shared_with_me", "sharedWithMe"),
myAccess: normalizeAccess(r.my_access ?? r.myAccess),
createdAt: pickStr(r, "created_at", "createdAt"),
updatedAt: pickStr(r, "updated_at", "updatedAt"),
};
}
export function normalizeLibraryPage(raw: unknown): LibraryPage {
const r = asRaw(raw);
const items = Array.isArray(r.items) ? r.items.map(normalizeLibraryModel) : [];
const nextCursorRaw = r.next_cursor ?? r.nextCursor;
return {
items,
nextCursor: nextCursorRaw ? String(nextCursorRaw) : null,
hasMore: pickBool(r, "has_more", "hasMore"),
};
}
export function normalizeProfile(raw: unknown): ModelProfile {
const r = asRaw(raw);
const rawChip = pickStr(r, "target_chip", "targetChip");
const source = pickStr(r, "source") || "uploaded";
const status = pickStr(r, "status") || "ready";
return {
id: pickStr(r, "id"),
name: pickStr(r, "name"),
description: r.description ? String(r.description) : undefined,
targetChip: rawChip.toLowerCase(),
fileSize: pickNum(r, "file_size", "fileSize"),
source: source as ModelProfile["source"],
status: status as ModelProfile["status"],
visibility: normalizeVisibility(r.visibility),
inputShape: normalizeNumberArray(r.input_shape ?? r.inputShape),
classes: normalizeStringArray(r.classes),
framework: r.framework ? String(r.framework) : undefined,
owner: normalizeOwner(r.owner),
myAccess: normalizeAccess(r.my_access ?? r.myAccess),
canDownload: pickBool(r, "can_download", "canDownload"),
createdAt: pickStr(r, "created_at", "createdAt"),
updatedAt: pickStr(r, "updated_at", "updatedAt"),
uploadedAt: pickStr(r, "uploaded_at", "uploadedAt") || undefined,
};
}
function normalizeShare(raw: unknown): ModelShare {
const r = asRaw(raw);
const role = r.role === "editor" ? "editor" : "viewer";
return {
userId: pickStr(r, "user_id", "userId", "grantee_user_id"),
email: pickStr(r, "email", "grantee_email"),
role,
createdAt: pickStr(r, "created_at", "createdAt"),
};
}
/* -------------------------------------------------------------------------- */
/* 1. GET /api/models/library — 共享模型庫cursor 分頁) */
/* -------------------------------------------------------------------------- */
function buildLibraryQueryString(query: LibraryQuery): string {
const params = new URLSearchParams();
if (query.cursor) params.set("cursor", query.cursor);
if (query.limit !== undefined) params.set("limit", String(query.limit));
if (query.sort) params.set("sort", query.sort);
if (query.order) params.set("order", query.order);
if (query.q) params.set("q", query.q);
if (query.targetChip) params.set("target_chip", query.targetChip);
if (query.source) params.set("source", query.source);
if (query.visibility) params.set("visibility", query.visibility);
if (query.owned !== undefined) params.set("owned", String(query.owned));
const qs = params.toString();
return qs ? `?${qs}` : "";
}
/**
* api.get wrappercookie sessionenvelopeApiError mapping
*
* 501 UI fetchModels 501
*
* @throws {ModelSharingError} 400 validation_failed /
*/
export async function fetchLibrary(query: LibraryQuery = {}): Promise<LibraryPage> {
try {
const raw = await api.get<unknown>(
`/api/models/library${buildLibraryQueryString(query)}`,
);
return normalizeLibraryPage(raw);
} catch (err) {
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
return { items: [], nextCursor: null, hasMore: false };
}
throw wrapError(err);
}
}
/* -------------------------------------------------------------------------- */
/* 2. GET /api/models/:id/profile — 公開版詳情 */
/* -------------------------------------------------------------------------- */
/**
* profile 404 enumeration
*
* @throws {ModelSharingError} 404 not_found or /
*/
export async function fetchProfile(modelId: string): Promise<ModelProfile> {
if (!modelId) {
throw new ModelSharingError(0, "validation_failed", "modelId is required");
}
try {
const raw = await api.get<unknown>(
`/api/models/${encodeURIComponent(modelId)}/profile`,
);
return normalizeProfile(raw);
} catch (err) {
throw wrapError(err);
}
}
/* -------------------------------------------------------------------------- */
/* 3. PATCH /api/models/:id/visibility — 設定公開對象owner-only */
/* -------------------------------------------------------------------------- */
export interface UpdateVisibilityResult {
id: string;
visibility: ModelVisibility;
updatedAt: string;
}
/**
* owner
*
* @throws {ModelSharingError} 403 forbidden / 404 not_found /
* 409 conflict ready/ 400 validation_failedvisibility / org tenant
*/
export async function updateVisibility(
modelId: string,
visibility: ModelVisibility,
): Promise<UpdateVisibilityResult> {
if (!modelId) {
throw new ModelSharingError(0, "validation_failed", "modelId is required");
}
try {
const raw = await api.patch<unknown>(
`/api/models/${encodeURIComponent(modelId)}/visibility`,
{ visibility },
);
const r = asRaw(raw);
return {
id: pickStr(r, "id") || modelId,
visibility: normalizeVisibility(r.visibility),
updatedAt: pickStr(r, "updated_at", "updatedAt"),
};
} catch (err) {
throw wrapError(err);
}
}
/* -------------------------------------------------------------------------- */
/* 4. 點對點分享管理owner-only— shares CRUD */
/* -------------------------------------------------------------------------- */
/**
* owner
*
* @throws {ModelSharingError} 403 forbidden / 404 not_found
*/
export async function fetchShares(modelId: string): Promise<ModelShare[]> {
if (!modelId) {
throw new ModelSharingError(0, "validation_failed", "modelId is required");
}
try {
const raw = await api.get<unknown>(
`/api/models/${encodeURIComponent(modelId)}/shares`,
);
const r = asRaw(raw);
const list = Array.isArray(r.items)
? r.items
: Array.isArray(raw)
? (raw as unknown[])
: [];
return list.map(normalizeShare);
} catch (err) {
throw wrapError(err);
}
}
/**
* by emailP0 viewer
*
* @throws {ModelSharingError} 400 validation_failedemail / 404 user_not_found /
* 403 forbidden / 409 conflict
*/
export async function addShare(
modelId: string,
email: string,
): Promise<ModelShare> {
if (!modelId) {
throw new ModelSharingError(0, "validation_failed", "modelId is required");
}
if (!email) {
throw new ModelSharingError(0, "validation_failed", "email is required");
}
try {
const raw = await api.post<unknown>(
`/api/models/${encodeURIComponent(modelId)}/shares`,
{ email, role: "viewer" },
);
return normalizeShare(raw);
} catch (err) {
throw wrapError(err);
}
}
/**
*
*
* @throws {ModelSharingError} 403 forbidden / 404 not_found
*/
export async function removeShare(
modelId: string,
userId: string,
): Promise<void> {
if (!modelId || !userId) {
throw new ModelSharingError(0, "validation_failed", "modelId and userId are required");
}
try {
await api.del(
`/api/models/${encodeURIComponent(modelId)}/shares/${encodeURIComponent(userId)}`,
);
} catch (err) {
throw wrapError(err);
}
}
/* -------------------------------------------------------------------------- */
/* Email 驗證(前端即時 UX後端仍會驗 */
/* -------------------------------------------------------------------------- */
/** 寬鬆但實用的 email 格式驗證(前端即時回饋用;權威驗證在後端)。 */
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
}

View File

@ -0,0 +1,196 @@
/**
* device-state + + filter
*
*
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.2 6 §6/filter
*/
import { describe, expect, it } from "vitest";
import type { DeviceSummary } from "@/stores/device-store";
import {
applyDeviceListView,
deriveTriState,
filterDevices,
isOnlineUnregistered,
sortDevices,
} from "./device-state";
/** 建一筆最小 DeviceSummary只需 deriveTriState 用到的欄位可覆寫)。 */
function makeDevice(overrides: Partial<DeviceSummary> = {}): DeviceSummary {
return {
id: "dev",
name: "KL520",
type: "kl520",
status: "connected",
remoteStatus: "online",
registeredAt: null,
...overrides,
};
}
const REGISTERED_AT = "2026-08-02T10:00:00Z";
describe("deriveTriState — 真值表(連線軸 × 註冊軸)", () => {
// TDD §5.2online×registered / online×null / offline×registered / offline×null
// + reconnecting / unknown≥6 格)
it("online + registeredAt 有值 → online-registered", () => {
expect(
deriveTriState({ remoteStatus: "online", registeredAt: REGISTERED_AT }),
).toBe("online-registered");
});
it("online + registeredAt null → online-unregistered第三態", () => {
expect(deriveTriState({ remoteStatus: "online", registeredAt: null })).toBe(
"online-unregistered",
);
});
it("online + registeredAt undefined → online-unregistered缺欄等同未註冊", () => {
expect(deriveTriState({ remoteStatus: "online", registeredAt: undefined })).toBe(
"online-unregistered",
);
});
it("offline + registeredAt 有值 → offline離線不論註冊與否", () => {
expect(
deriveTriState({ remoteStatus: "offline", registeredAt: REGISTERED_AT }),
).toBe("offline");
});
it("offline + registeredAt null → offline", () => {
expect(deriveTriState({ remoteStatus: "offline", registeredAt: null })).toBe(
"offline",
);
});
it("reconnecting → offline非 online 一律歸 offline 態)", () => {
expect(
deriveTriState({ remoteStatus: "reconnecting", registeredAt: REGISTERED_AT }),
).toBe("offline");
});
it("unknown → offline", () => {
expect(deriveTriState({ remoteStatus: "unknown", registeredAt: null })).toBe(
"offline",
);
});
it("error → offline", () => {
expect(
deriveTriState({ remoteStatus: "error", registeredAt: REGISTERED_AT }),
).toBe("offline");
});
it("isOnlineUnregistered 僅在第三態為 true", () => {
expect(
isOnlineUnregistered({ remoteStatus: "online", registeredAt: null }),
).toBe(true);
expect(
isOnlineUnregistered({ remoteStatus: "online", registeredAt: REGISTERED_AT }),
).toBe(false);
expect(
isOnlineUnregistered({ remoteStatus: "offline", registeredAt: null }),
).toBe(false);
});
});
describe("filterDevices — 依三態過濾", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "a", remoteStatus: "online", registeredAt: REGISTERED_AT }), // online-registered
makeDevice({ id: "b", remoteStatus: "online", registeredAt: null }), // online-unregistered
makeDevice({ id: "c", remoteStatus: "offline", registeredAt: null }), // offline
makeDevice({ id: "d", remoteStatus: "reconnecting", registeredAt: REGISTERED_AT }), // offline
];
it("all → 不過濾(回原陣列)", () => {
expect(filterDevices(devices, "all")).toBe(devices);
});
it("online-registered → 只留已連接已註冊", () => {
expect(filterDevices(devices, "online-registered").map((d) => d.id)).toEqual([
"a",
]);
});
it("online-unregistered → 只留第三態", () => {
expect(filterDevices(devices, "online-unregistered").map((d) => d.id)).toEqual([
"b",
]);
});
it("offline → 留所有非 online含 reconnecting", () => {
expect(filterDevices(devices, "offline").map((d) => d.id)).toEqual(["c", "d"]);
});
});
describe("sortDevices — 三種排序鍵", () => {
it("status在線優先online→reconnecting→unknown→offline→error同狀態內比名稱", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "off", name: "Z", remoteStatus: "offline" }),
makeDevice({ id: "on-b", name: "B", remoteStatus: "online" }),
makeDevice({ id: "err", name: "A", remoteStatus: "error" }),
makeDevice({ id: "on-a", name: "A", remoteStatus: "online" }),
makeDevice({ id: "rec", name: "C", remoteStatus: "reconnecting" }),
];
expect(sortDevices(devices, "status").map((d) => d.id)).toEqual([
"on-a", // online A
"on-b", // online B
"rec", // reconnecting
"off", // offline
"err", // error
]);
});
it("namedisplayNamealias 優先localeCompare A→Z", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "1", name: "Charlie" }),
makeDevice({ id: "2", name: "Zoo", alias: "Apple" }), // alias 優先 → 排最前
makeDevice({ id: "3", name: "Bravo" }),
];
expect(sortDevices(devices, "name").map((d) => d.id)).toEqual(["2", "3", "1"]);
});
it("registeredAtdesc新在前null未註冊排最後", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "old", registeredAt: "2026-01-01T00:00:00Z" }),
makeDevice({ id: "none", registeredAt: null }),
makeDevice({ id: "new", registeredAt: "2026-08-01T00:00:00Z" }),
];
expect(sortDevices(devices, "registeredAt").map((d) => d.id)).toEqual([
"new",
"old",
"none",
]);
});
it("不 mutate 輸入陣列", () => {
const devices = [
makeDevice({ id: "1", name: "B" }),
makeDevice({ id: "2", name: "A" }),
];
const before = devices.map((d) => d.id);
sortDevices(devices, "name");
expect(devices.map((d) => d.id)).toEqual(before);
});
});
describe("applyDeviceListView — 先 filter 再 sort", () => {
it("filter 後再排序,順序正確", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "a", name: "Z", remoteStatus: "online", registeredAt: "2026-01-01T00:00:00Z" }),
makeDevice({ id: "b", name: "A", remoteStatus: "online", registeredAt: "2026-08-01T00:00:00Z" }),
makeDevice({ id: "c", name: "C", remoteStatus: "offline", registeredAt: null }),
];
// filter=online-registered留 a、b→ sort=nameA→Zb、a
const result = applyDeviceListView(devices, "online-registered", "name");
expect(result.map((d) => d.id)).toEqual(["b", "a"]);
});
it("filter 後 0 筆 → 回空陣列", () => {
const devices: DeviceSummary[] = [
makeDevice({ id: "a", remoteStatus: "offline", registeredAt: null }),
];
expect(applyDeviceListView(devices, "online-unregistered", "status")).toEqual([]);
});
});

View File

@ -0,0 +1,135 @@
/**
* device-state ×
*
*
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.2
*
* = remoteStatus× registeredAt
* | | | |
* | ----------------- | --------------------------------------- | ----------------------- |
* | online-registered | remoteStatus === "online" | |
* | online-unregistered | remoteStatus === "online" | |
* | offline | remoteStatus !== "online" | |
*
* 便 6 UI
*/
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
/** 三態列舉:連線軸 × 註冊軸推導出的裝置狀態。 */
export type DeviceTriState =
| "online-registered"
| "online-unregistered"
| "offline";
/**
*
*
* - online registeredAt != null "online-registered"
* - online registeredAt == null "online-unregistered"
* - offline / reconnecting / error / unknown "offline"
*
* TDD §5.2 online offline 沿
* RemoteDeviceBadge online
*/
export function deriveTriState(
d: Pick<DeviceSummary, "remoteStatus" | "registeredAt">,
): DeviceTriState {
if (d.remoteStatus !== "online") return "offline";
return d.registeredAt != null ? "online-registered" : "online-unregistered";
}
/** 便利判定:此裝置是否為「已連接未註冊」第三態(可被註冊)。 */
export function isOnlineUnregistered(
d: Pick<DeviceSummary, "remoteStatus" | "registeredAt">,
): boolean {
return deriveTriState(d) === "online-unregistered";
}
/* -------------------------------------------------------------------------- */
/* 排序 + filterTDD §6client-side、不分頁 */
/* -------------------------------------------------------------------------- */
/** 排序鍵TDD §6.2)。`status`=依連線狀態(預設,保留既有行為)。 */
export type DeviceSortKey = "status" | "name" | "registeredAt";
/** Filter 選項TDD §6.3,依三態)。`all`=不過濾(預設)。 */
export type DeviceFilterKey =
| "all"
| "online-registered"
| "offline"
| "online-unregistered";
/**
* 沿 device-list STATUS_ORDER
* online reconnecting unknown offline error
*/
const REMOTE_STATUS_ORDER: Record<RemoteStatus, number> = {
online: 0,
reconnecting: 1,
unknown: 2,
offline: 3,
error: 4,
};
/** 顯示名稱alias 優先,對齊 DeviceCard 的 displayName 規則)。 */
function displayName(d: DeviceSummary): string {
return d.alias || d.name;
}
/**
* filter deriveTriState
* `all` sort
*/
export function filterDevices(
devices: DeviceSummary[],
filter: DeviceFilterKey,
): DeviceSummary[] {
if (filter === "all") return devices;
return devices.filter((d) => deriveTriState(d) === filter);
}
/**
* mutate
*
* - statusREMOTE_STATUS_ORDERlocaleCompare
* - namedisplayName localeCompareAZ
* - registeredAtdescnull
*/
export function sortDevices(
devices: DeviceSummary[],
sortKey: DeviceSortKey,
): DeviceSummary[] {
const copy = [...devices];
switch (sortKey) {
case "name":
return copy.sort((a, b) => displayName(a).localeCompare(displayName(b)));
case "registeredAt":
return copy.sort((a, b) => {
const ra = a.registeredAt ?? null;
const rb = b.registeredAt ?? null;
// null未註冊排最後兩者皆有值時比時間 desc新在前
if (ra == null && rb == null) return 0;
if (ra == null) return 1;
if (rb == null) return -1;
return rb.localeCompare(ra);
});
case "status":
default:
return copy.sort((a, b) => {
const diff =
REMOTE_STATUS_ORDER[a.remoteStatus] - REMOTE_STATUS_ORDER[b.remoteStatus];
// 同狀態內次比名稱,讓排序穩定可預期。
return diff !== 0 ? diff : displayName(a).localeCompare(displayName(b));
});
}
}
/** 先 filter 再 sortTDD §6.3:組合順序)。 */
export function applyDeviceListView(
devices: DeviceSummary[],
filter: DeviceFilterKey,
sortKey: DeviceSortKey,
): DeviceSummary[] {
return sortDevices(filterDevices(devices, filter), sortKey);
}

View File

@ -0,0 +1,38 @@
/**
* util
*
* `components/cloud/remote-device-badge.tsx` `formatRelativeTime`
* owner / 沿 i18n key
*
* components.md §10.3
* - < 60 remote.lastSeen.justNow
* - < 60 X remote.lastSeen.minutesAgo
* - < 24 X remote.lastSeen.hoursAgo
* - 24 MM/DD HH:mm
*/
/**
* @param isoString ISO 8601
* @param nowMs ms caller 便 deterministic
* @param t i18n remote.lastSeen.* key
*/
export 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}`;
}

View File

@ -180,6 +180,43 @@ export const en: Dictionary = {
"devices.remove.error.NOT_FOUND": "This device no longer exists.", "devices.remove.error.NOT_FOUND": "This device no longer exists.",
"devices.remove.error.unknown": "Something went wrong. Please try again.", "devices.remove.error.unknown": "Something went wrong. Please try again.",
// ── Devices: tri-state (connection × registration) ──
"devices.state.unregistered": "Unregistered",
// ── Devices: register / unregister ──
"devices.register.action": "Register",
"devices.register.pending": "Registering…",
"devices.register.toast.success": "Device registered",
"devices.register.error.title": "Couldn't register device",
"devices.register.error.ALREADY_REGISTERED": "This device is already registered.",
"devices.register.error.REPRESENTATIVE_DEVICE":
"This kind of device can't be registered or unregistered.",
"devices.register.error.FORBIDDEN": "You don't have permission to register this device.",
"devices.register.error.NOT_FOUND": "This device no longer exists.",
"devices.register.error.unknown": "Something went wrong. Please try again.",
// Unregister (return to unregistered state) — NOT the same as removing/unpairing the device.
"devices.unregister.action": "Unregister",
"devices.unregister.pending": "Unregistering…",
"devices.unregister.hint":
"This returns the device to an unregistered state. It stays in your list and isn't removed.",
"devices.unregister.toast.success": "Device unregistered",
"devices.unregister.error.title": "Couldn't unregister device",
// ── Devices: sort + filter ──
"devices.sort.label": "Sort by",
"devices.sort.status": "Status",
"devices.sort.name": "Name",
"devices.sort.registeredAt": "Registered",
"devices.filter.label": "Filter devices",
"devices.filter.all": "All",
"devices.filter.onlineRegistered": "Connected",
"devices.filter.onlineUnregistered": "Connected, unregistered",
"devices.filter.offline": "Disconnected",
"devices.filter.empty.title": "No devices match this filter",
"devices.filter.empty.description":
"Try a different filter, or clear it to see all your devices.",
"devices.filter.empty.action": "Clear filter",
// ── Devices: flash (load model to device) ── // ── Devices: flash (load model to device) ──
"devices.flash.flashModel": "Load model", "devices.flash.flashModel": "Load model",
"devices.flash.flashToDevice": "Load a model to this device", "devices.flash.flashToDevice": "Load a model to this device",
@ -309,6 +346,78 @@ export const en: Dictionary = {
"models.download.error.busy": "A download is already in progress, please wait.", "models.download.error.busy": "A download is already in progress, please wait.",
"models.download.error.unknown": "Download failed, please try again later.", "models.download.error.unknown": "Download failed, please try again later.",
// ── Model Sharing ──
// Shared model library list
"models.library.title": "Shared Model Library",
"models.library.subtitle": "Browse models you can access: yours, public ones, and those shared with you",
"models.library.empty.title": "No shared models yet",
"models.library.empty.description": "Models shared with you or made public will appear here",
"models.library.empty.search.title": "No models match your criteria",
"models.library.empty.search.description": "Try other keywords or clear the filters",
"models.library.error.title": "Failed to load the library",
"models.library.error.description": "Please try again later",
"models.library.loadMore.retry": "Failed to load. Click to retry",
"models.library.resultCount": "Found {n} models",
"models.library.end": "All models shown",
"models.library.link": "Shared library",
// Search
"models.search.placeholder": "Search model name…",
"models.search.aria": "Search models",
"models.search.clear": "Clear search",
"models.search.clearAll": "Clear all filters",
// Filters
"models.filters.owned": "Ownership",
"models.filters.owned.all": "All",
"models.filters.owned.mine": "My models",
"models.filters.owned.shared": "Shared with me",
"models.filters.visibility": "Visibility",
// Sort
"models.sort.label": "Sort",
"models.sort.createdAt": "Newest",
"models.sort.name": "Name",
"models.sort.fileSize": "File size",
// Visibility badge (three states + shared)
"models.visibility.badge.private": "Private",
"models.visibility.badge.public": "Public",
"models.visibility.badge.tenant": "Same tenant",
"models.visibility.badge.sharedWithMe": "Shared with me",
"models.visibility.badge.sharedCount": "{n} people",
// Card owner menu
"models.card.menu.aria": "Model actions menu",
// Receiver info row (contract does not expose email, use name)
"models.sharedByName": "Shared by {name}",
"models.ownerBar.aria": "Model owner info",
// Visibility dialog
"models.visibility.title": "Visibility",
"models.visibility.question": "Who can access this model?",
"models.visibility.private": "Private",
"models.visibility.private.desc": "Only you",
"models.visibility.public": "Public",
"models.visibility.public.desc": "All visionA users",
"models.visibility.tenant": "Same tenant",
"models.visibility.tenant.desc": "Members of your organization",
"models.visibility.sharedPeopleTitle": "Specific people (additionally shared)",
"models.visibility.addEmail": "Add by email",
"models.visibility.addButton": "Add",
"models.visibility.noShares": "Not shared with anyone yet",
"models.visibility.permissionViewDownload": "View + download",
"models.visibility.removeShare": "Remove {email}",
"models.visibility.publicWarning": "Once public, all visionA users can view and download this model",
"models.visibility.saveButton": "Save changes",
"models.visibility.saved": "Visibility updated",
"models.visibility.saveFailed": "Failed to save, please retry",
"models.visibility.notReady": "Model is not ready and cannot be made public",
"models.visibility.emailInvalid": "Invalid email format",
"models.visibility.emailDuplicate": "Already in the list",
"models.visibility.userNotFound": "User {email} not found",
"models.visibility.revokeConfirm": "Setting to private revokes access for shared users. Continue?",
// Profile page
"models.profile.notFound.title": "Model not found or no access",
"models.profile.notFound.description": "This model does not exist, is not public, or was not shared with you",
"models.profile.backToLibrary": "Back to shared library",
// Generic sharing error
"models.sharing.error.generic": "Operation failed, please try again later",
// ── Workspace ── // ── Workspace ──
"workspace.title": "Workspace", "workspace.title": "Workspace",
"workspace.subtitle": "Select an online device to start inference", "workspace.subtitle": "Select an online device to start inference",

View File

@ -181,6 +181,41 @@ export const zhHant: Dictionary = {
"devices.remove.error.NOT_FOUND": "此裝置已不存在", "devices.remove.error.NOT_FOUND": "此裝置已不存在",
"devices.remove.error.unknown": "發生錯誤,請稍後再試", "devices.remove.error.unknown": "發生錯誤,請稍後再試",
// ── Devices: 三態(連線 × 註冊) ──
"devices.state.unregistered": "未註冊",
// ── Devices: 註冊 / 取消註冊 ──
"devices.register.action": "註冊",
"devices.register.pending": "註冊中…",
"devices.register.toast.success": "已註冊裝置",
"devices.register.error.title": "註冊裝置失敗",
"devices.register.error.ALREADY_REGISTERED": "此裝置已註冊",
"devices.register.error.REPRESENTATIVE_DEVICE": "這類裝置無法註冊或取消註冊",
"devices.register.error.FORBIDDEN": "你沒有權限註冊此裝置",
"devices.register.error.NOT_FOUND": "此裝置已不存在",
"devices.register.error.unknown": "發生錯誤,請稍後再試",
// 取消註冊(退回未註冊態)— 與「移除裝置(解除配對)」不同,不會刪掉裝置。
"devices.unregister.action": "取消註冊",
"devices.unregister.pending": "取消註冊中…",
"devices.unregister.hint":
"這會把裝置退回未註冊狀態,裝置仍保留在清單中,不會被移除。",
"devices.unregister.toast.success": "已取消註冊",
"devices.unregister.error.title": "取消註冊失敗",
// ── Devices: 排序 + 篩選 ──
"devices.sort.label": "排序方式",
"devices.sort.status": "狀態",
"devices.sort.name": "名稱",
"devices.sort.registeredAt": "註冊時間",
"devices.filter.label": "篩選裝置",
"devices.filter.all": "全部",
"devices.filter.onlineRegistered": "已連接",
"devices.filter.onlineUnregistered": "已連接未註冊",
"devices.filter.offline": "未連接",
"devices.filter.empty.title": "沒有符合此篩選條件的裝置",
"devices.filter.empty.description": "試試其他篩選條件,或清除篩選以顯示所有裝置。",
"devices.filter.empty.action": "清除篩選",
// ── Devices: flash載入模型到裝置 ── // ── Devices: flash載入模型到裝置 ──
"devices.flash.flashModel": "載入模型", "devices.flash.flashModel": "載入模型",
"devices.flash.flashToDevice": "載入模型到此裝置", "devices.flash.flashToDevice": "載入模型到此裝置",
@ -301,6 +336,78 @@ export const zhHant: Dictionary = {
"models.download.error.busy": "已有下載進行中,請稍候", "models.download.error.busy": "已有下載進行中,請稍候",
"models.download.error.unknown": "下載失敗,請稍後再試", "models.download.error.unknown": "下載失敗,請稍後再試",
// ── 模型共享Model Sharing──
// 共享模型庫列表
"models.library.title": "共享模型庫",
"models.library.subtitle": "瀏覽你可存取的模型:你的、公開的、以及別人分享給你的",
"models.library.empty.title": "還沒有可存取的共享模型",
"models.library.empty.description": "當同事把模型分享給你、或有公開模型時,會出現在這裡",
"models.library.empty.search.title": "找不到符合條件的模型",
"models.library.empty.search.description": "試試其他關鍵字或清除篩選條件",
"models.library.error.title": "載入模型庫失敗",
"models.library.error.description": "請稍後再試",
"models.library.loadMore.retry": "載入失敗,點擊重試",
"models.library.resultCount": "找到 {n} 個模型",
"models.library.end": "已顯示全部模型",
"models.library.link": "共享模型庫",
// 搜尋
"models.search.placeholder": "搜尋模型名稱…",
"models.search.aria": "搜尋模型",
"models.search.clear": "清除搜尋",
"models.search.clearAll": "清除所有篩選",
// filter
"models.filters.owned": "擁有關係",
"models.filters.owned.all": "全部",
"models.filters.owned.mine": "我的模型",
"models.filters.owned.shared": "共享給我",
"models.filters.visibility": "可見性",
// 排序
"models.sort.label": "排序",
"models.sort.createdAt": "最新建立",
"models.sort.name": "名稱",
"models.sort.fileSize": "檔案大小",
// visibility badge三態 + 共享)
"models.visibility.badge.private": "私有",
"models.visibility.badge.public": "公開",
"models.visibility.badge.tenant": "同租戶",
"models.visibility.badge.sharedWithMe": "共享給我",
"models.visibility.badge.sharedCount": "{n} 人",
// 卡片 owner 選單
"models.card.menu.aria": "模型操作選單",
// receiver 資訊列(契約不揭露 email用名稱
"models.sharedByName": "由 {name} 共享",
"models.ownerBar.aria": "模型擁有者資訊",
// 公開設定 Dialog
"models.visibility.title": "公開設定",
"models.visibility.question": "誰可以看到並使用這個模型?",
"models.visibility.private": "私有",
"models.visibility.private.desc": "只有你自己",
"models.visibility.public": "公開",
"models.visibility.public.desc": "所有 visionA 使用者",
"models.visibility.tenant": "同租戶",
"models.visibility.tenant.desc": "與你同組織的成員",
"models.visibility.sharedPeopleTitle": "指定對象(額外分享給特定人)",
"models.visibility.addEmail": "輸入 email 加入",
"models.visibility.addButton": "加入",
"models.visibility.noShares": "尚未分享給任何人",
"models.visibility.permissionViewDownload": "可檢視 + 下載",
"models.visibility.removeShare": "移除 {email}",
"models.visibility.publicWarning": "公開後,所有 visionA 使用者都能檢視並下載此模型",
"models.visibility.saveButton": "儲存變更",
"models.visibility.saved": "已更新公開設定",
"models.visibility.saveFailed": "儲存失敗,請重試",
"models.visibility.notReady": "模型尚未就緒,無法公開",
"models.visibility.emailInvalid": "Email 格式不正確",
"models.visibility.emailDuplicate": "已在清單中",
"models.visibility.userNotFound": "找不到使用者 {email}",
"models.visibility.revokeConfirm": "改為私有後,已分享的對象將無法再存取,確定要繼續嗎?",
// profile 頁
"models.profile.notFound.title": "找不到模型或沒有存取權",
"models.profile.notFound.description": "這個模型不存在、未公開,或未分享給你",
"models.profile.backToLibrary": "返回共享模型庫",
// 通用共享錯誤
"models.sharing.error.generic": "操作失敗,請稍後再試",
// ── Workspace ── // ── Workspace ──
"workspace.title": "推論工作區", "workspace.title": "推論工作區",
"workspace.subtitle": "選擇已線上的裝置開始推論", "workspace.subtitle": "選擇已線上的裝置開始推論",

View File

@ -23,6 +23,7 @@ beforeEach(() => {
connectingId: null, connectingId: null,
disconnectingId: null, disconnectingId: null,
unpairingId: null, unpairingId: null,
registeringId: null,
error: null, error: null,
}); });
// OF2api.ts 不再需要 token gettercookie session 由瀏覽器自動帶) // OF2api.ts 不再需要 token gettercookie session 由瀏覽器自動帶)
@ -412,3 +413,253 @@ describe("useDeviceStore.unpairDevice", () => {
expect(useDeviceStore.getState().unpairingId).toBeNull(); expect(useDeviceStore.getState().unpairingId).toBeNull();
}); });
}); });
/* -------------------------------------------------------------------------- */
/* 註冊軸normalizeDevice registeredAt + register / unregister actions */
/* -------------------------------------------------------------------------- */
describe("useDeviceStore — registeredAt 正規化TDD §5.1", () => {
it("registered_atsnake/ registeredAtcamel有值 → 正確帶入;缺欄 → null", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
success: true,
data: [
// snake_case後端實際形狀
{
id: "dev-1",
name: "A",
type: "kl520",
status: "connected",
registered_at: "2026-08-02T10:00:00Z",
},
// camelCase 容錯
{
id: "dev-2",
name: "B",
type: "kl520",
status: "connected",
registeredAt: "2026-08-01T00:00:00Z",
},
// 缺欄位(未註冊 / 舊資料)→ null
{ id: "dev-3", name: "C", type: "kl520", status: "connected" },
// 明確 null → null
{
id: "dev-4",
name: "D",
type: "kl520",
status: "connected",
registered_at: null,
},
],
}),
);
await useDeviceStore.getState().fetchDevices();
const { devices } = useDeviceStore.getState();
expect(devices[0]?.registeredAt).toBe("2026-08-02T10:00:00Z");
expect(devices[1]?.registeredAt).toBe("2026-08-01T00:00:00Z");
expect(devices[2]?.registeredAt).toBeNull();
expect(devices[3]?.registeredAt).toBeNull();
});
});
describe("useDeviceStore.registerDevice", () => {
const unregistered = {
id: "dev-1",
name: "KL520",
type: "kl520",
status: "connected" as const,
remoteStatus: "online" as const,
registeredAt: null,
};
it("成功時打對 register endpointUUID、就地更新 registeredAt、回 { ok:true }", async () => {
useDeviceStore.setState({
devices: [unregistered, { ...unregistered, id: "dev-2" }],
selectedDevice: { ...unregistered },
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
success: true,
data: {
id: "dev-1",
name: "KL520",
device_type: "kl520",
status: "connected",
remote_status: "online",
registered_at: "2026-08-02T10:00:00Z",
},
}),
);
const result = await useDeviceStore.getState().registerDevice("dev-1");
expect(result).toEqual({ ok: true });
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
expect(calledUrl).toContain("/api/devices/dev-1/register");
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
const state = useDeviceStore.getState();
// 就地更新該筆 registeredAt不移除 list
expect(state.devices.find((d) => d.id === "dev-1")?.registeredAt).toBe(
"2026-08-02T10:00:00Z",
);
// 其他裝置不受影響
expect(state.devices.find((d) => d.id === "dev-2")?.registeredAt).toBeNull();
// selectedDevice 同步更新
expect(state.selectedDevice?.registeredAt).toBe("2026-08-02T10:00:00Z");
expect(state.registeringId).toBeNull();
});
it("就地 merge 只覆寫 registeredAt不把本地既有欄位清成 null後端 omitempty 防禦)", async () => {
useDeviceStore.setState({
devices: [{ ...unregistered, firmwareVersion: "2.3.1" }],
});
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
// 後端回應缺 firmware_versionomitempty
jsonResponse({
success: true,
data: {
id: "dev-1",
name: "KL520",
status: "connected",
registered_at: "2026-08-02T10:00:00Z",
},
}),
);
await useDeviceStore.getState().registerDevice("dev-1");
const d = useDeviceStore.getState().devices[0];
expect(d?.registeredAt).toBe("2026-08-02T10:00:00Z");
// 本地既有 firmwareVersion 不被覆寫成 null
expect(d?.firmwareVersion).toBe("2.3.1");
});
it("呼叫期間 registeringId 設為該 idloading 態)", async () => {
let observed: string | null = "not-set";
vi.spyOn(globalThis, "fetch").mockImplementationOnce(async () => {
observed = useDeviceStore.getState().registeringId;
return jsonResponse({
success: true,
data: { id: "dev-1", registered_at: "2026-08-02T10:00:00Z" },
});
});
await useDeviceStore.getState().registerDevice("dev-1");
expect(observed).toBe("dev-1");
expect(useDeviceStore.getState().registeringId).toBeNull();
});
it("409 ALREADY_REGISTERED → 回 { ok:false, code:'ALREADY_REGISTERED' },不改 list", async () => {
useDeviceStore.setState({ devices: [unregistered] });
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{
success: false,
error: { code: "ALREADY_REGISTERED", message: "device already registered" },
},
409,
),
);
const result = await useDeviceStore.getState().registerDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "ALREADY_REGISTERED" });
// list 不變registeredAt 仍 null
expect(useDeviceStore.getState().devices[0]?.registeredAt).toBeNull();
expect(useDeviceStore.getState().registeringId).toBeNull();
});
it("409 REPRESENTATIVE_DEVICErepresentative→ 回 { ok:false, code:'REPRESENTATIVE_DEVICE' }", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{
success: false,
error: { code: "REPRESENTATIVE_DEVICE", message: "representative" },
},
409,
),
);
const result = await useDeviceStore.getState().registerDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "REPRESENTATIVE_DEVICE" });
});
it("403 FORBIDDEN非 owner→ 回 { ok:false, code:'FORBIDDEN' }", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
403,
),
);
const result = await useDeviceStore.getState().registerDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
});
});
describe("useDeviceStore.unregisterDevice", () => {
const registered = {
id: "dev-1",
name: "KL520",
type: "kl520",
status: "connected" as const,
remoteStatus: "online" as const,
registeredAt: "2026-08-02T10:00:00Z",
};
it("成功時打對 unregister endpoint、清 registeredAt、**保留 list**(不移除)", async () => {
useDeviceStore.setState({
devices: [registered, { ...registered, id: "dev-2" }],
selectedDevice: { ...registered },
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({
success: true,
data: { id: "dev-1", name: "KL520", status: "connected", registered_at: null },
}),
);
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
expect(result).toEqual({ ok: true });
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
expect(calledUrl).toContain("/api/devices/dev-1/unregister");
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
const state = useDeviceStore.getState();
// 關鍵device 仍在 list與 unpair 的差異),只是 registeredAt 清 null
expect(state.devices.map((d) => d.id)).toEqual(["dev-1", "dev-2"]);
expect(state.devices.find((d) => d.id === "dev-1")?.registeredAt).toBeNull();
expect(state.selectedDevice?.registeredAt).toBeNull();
expect(state.registeringId).toBeNull();
});
it("冪等:已未註冊再 unregister後端回 200 null→ ok:truelist 保留", async () => {
useDeviceStore.setState({
devices: [{ ...registered, registeredAt: null }],
});
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ success: true, data: { id: "dev-1", registered_at: null } }),
);
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
expect(result).toEqual({ ok: true });
expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]);
});
it("403 FORBIDDEN → 回 { ok:false, code:'FORBIDDEN' }list 不變", async () => {
useDeviceStore.setState({ devices: [registered] });
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
403,
),
);
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
// 失敗時 registeredAt 不變
expect(useDeviceStore.getState().devices[0]?.registeredAt).toBe(
"2026-08-02T10:00:00Z",
);
expect(useDeviceStore.getState().registeringId).toBeNull();
});
});

View File

@ -89,6 +89,13 @@ export interface DeviceSummary {
status: DeviceHardwareStatus; status: DeviceHardwareStatus;
/** 遠端 tunnel 狀態flow-offline-handling.md §2 新增) */ /** 遠端 tunnel 狀態flow-offline-handling.md §2 新增) */
remoteStatus: RemoteStatus; remoteStatus: RemoteStatus;
/**
* ISO 8601 feature-device-mgmt-tdd.md §5
* JSON key `registered_at`omitemptynull / =
* lib/device-state.ts deriveTriStateonline registeredAt != null
* online null
*/
registeredAt?: string | null;
/** ISO 8601最後心跳時間 */ /** ISO 8601最後心跳時間 */
lastSeenAt?: string | null; lastSeenAt?: string | null;
firmwareVersion?: string | null; firmwareVersion?: string | null;
@ -152,6 +159,9 @@ function normalizeDevice(raw: unknown): Device {
status: coerceHardwareStatus(pick<string>("status")), status: coerceHardwareStatus(pick<string>("status")),
remoteStatus: remoteStatus:
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"), tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
// 註冊軸TDD §5.1):後端回 registered_atomitempty— 缺欄 / null 皆視為未註冊。
// 沿用既有 pick snake/camel 相容範式;不做時間格式驗證(後端保證 ISO 8601
registeredAt: pick<string>("registered_at", "registeredAt") ?? null,
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null, lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
firmwareVersion: firmwareVersion:
pick<string>("firmware_version", "firmwareVersion") ?? null, pick<string>("firmware_version", "firmwareVersion") ?? null,
@ -179,6 +189,20 @@ export type UnpairResult =
| { ok: true } | { ok: true }
| { ok: false; code: string; message: string }; | { ok: false; code: string; message: string };
/**
* register / unregister action
*
* unpair code boolean UI toast
* - register409 `ALREADY_REGISTERED`409 `REPRESENTATIVE_DEVICE`representative
* 403 `FORBIDDEN` owner404 `NOT_FOUND` api-device-mgmt.md §3
* - unregister 200 403 / 404 / representativeREPRESENTATIVE_DEVICE
*
* store device registeredAt refetch unpair
*/
export type RegisterResult =
| { ok: true }
| { ok: false; code: string; message: string };
interface DeviceState { interface DeviceState {
devices: DeviceSummary[]; devices: DeviceSummary[];
selectedDevice: Device | null; selectedDevice: Device | null;
@ -188,6 +212,8 @@ interface DeviceState {
disconnectingId: string | null; disconnectingId: string | null;
/** 移除unpair中的裝置 idUI 顯示 button spinner / disable 確認鈕);不使用就是 null */ /** 移除unpair中的裝置 idUI 顯示 button spinner / disable 確認鈕);不使用就是 null */
unpairingId: string | null; unpairingId: string | null;
/** 註冊 / 取消註冊進行中的裝置 idUI 顯示 button spinner不使用就是 null */
registeringId: string | null;
error: string | null; error: string | null;
/** 呼叫 `GET /api/devices` */ /** 呼叫 `GET /api/devices` */
@ -207,6 +233,19 @@ interface DeviceState {
disconnectDevice: (serialNumber: string) => Promise<boolean>; disconnectDevice: (serialNumber: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token */ /** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token */
unpairDevice: (id: string) => Promise<UnpairResult>; unpairDevice: (id: string) => Promise<UnpairResult>;
/**
* `POST /api/devices/:id/register`UUID DB
* registered_at NULL now()
* registeredAt refetch
* connect connect serial register UUIDADR-018 FE-ADB UUID
*/
registerDevice: (id: string) => Promise<RegisterResult>;
/**
* `POST /api/devices/:id/unregister`UUID
* 退registered_at NULL**** token
* unpairunregister registeredAtdevice
*/
unregisterDevice: (id: string) => Promise<RegisterResult>;
/** 測試 / 雛形用:直接塞 list */ /** 測試 / 雛形用:直接塞 list */
_setDevices: (devices: DeviceSummary[]) => void; _setDevices: (devices: DeviceSummary[]) => void;
/** 測試 / 雛形用:直接塞 selected */ /** 測試 / 雛形用:直接塞 selected */
@ -220,6 +259,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
connectingId: null, connectingId: null,
disconnectingId: null, disconnectingId: null,
unpairingId: null, unpairingId: null,
registeringId: null,
error: null, error: null,
fetchDevices: async () => { fetchDevices: async () => {
@ -317,6 +357,65 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
} }
}, },
registerDevice: async (id) => {
set({ registeringId: id, error: null });
try {
// 契約POST /api/devices/:id/register → 回更新後的 DeviceListItemregistered_at 非 null
// api.post 已 unwrap envelope 的 datanormalizeDevice 讀出 registeredAt。
const raw = await api.post<unknown>(
`/api/devices/${encodeURIComponent(id)}/register`,
);
const updated = normalizeDevice(raw);
// 就地更新該筆 registeredAt避免 refetch 延遲,比照 unpair 就地移除範式)。
// 後端回應可能缺部分欄位omitempty→ 只 merge registeredAt其餘沿用本地既有值
// 避免把本地已知欄位(如 firmwareVersion覆寫成 null。
set((state) => ({
devices: state.devices.map((d) =>
d.id === id ? { ...d, registeredAt: updated.registeredAt } : d,
),
selectedDevice:
state.selectedDevice?.id === id
? { ...state.selectedDevice, registeredAt: updated.registeredAt }
: state.selectedDevice,
registeringId: null,
}));
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const code = err instanceof ApiError ? err.code : "unknown";
set({ registeringId: null, error: message });
return { ok: false, code, message };
}
},
unregisterDevice: async (id) => {
set({ registeringId: id, error: null });
try {
// 契約POST /api/devices/:id/unregister → registered_at → null**保留裝置列**(不軟刪)。
// 冪等:已未註冊也回 200。回更新後 DeviceListItemregistered_at=null
await api.post<unknown>(
`/api/devices/${encodeURIComponent(id)}/unregister`,
);
// 就地把該筆 registeredAt 清成 nulldevice 仍留在 list不移除——與 unpair 的關鍵差異)。
set((state) => ({
devices: state.devices.map((d) =>
d.id === id ? { ...d, registeredAt: null } : d,
),
selectedDevice:
state.selectedDevice?.id === id
? { ...state.selectedDevice, registeredAt: null }
: state.selectedDevice,
registeringId: null,
}));
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const code = err instanceof ApiError ? err.code : "unknown";
set({ registeringId: null, error: message });
return { ok: false, code, message };
}
},
_setDevices: (devices) => set({ devices }), _setDevices: (devices) => set({ devices }),
_setSelected: (selectedDevice) => set({ selectedDevice }), _setSelected: (selectedDevice) => set({ selectedDevice }),
})); }));

View File

@ -0,0 +1,183 @@
/**
* Model Sharing Store mock deterministic
*
*
* - loadFirstPage cursor / hasMore
* - loadMoreappend hasMore=false
* - loadMore cursor /
* - setFilters
* - loadProfilemock / 404
* - updateVisibility / addShare / removeShare
* - filtersToQueryUI filters API query
*/
import { beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_LIBRARY_FILTERS,
filtersToQuery,
LIBRARY_PAGE_SIZE,
useModelSharingStore,
} from "./model-sharing-store";
function resetStore() {
useModelSharingStore.setState({
items: [],
filters: { ...DEFAULT_LIBRARY_FILTERS },
cursor: null,
hasMore: false,
isLoading: false,
isLoadingMore: false,
listError: null,
profile: null,
isProfileLoading: false,
profileError: null,
shares: [],
isSharesLoading: false,
_mockMode: true, // 測試一律走 mock不打真實 API
});
}
beforeEach(() => {
resetStore();
});
describe("loadFirstPage", () => {
it("載入首頁 → items = PAGE_SIZE、hasMore=true、cursor 非空", async () => {
await useModelSharingStore.getState().loadFirstPage();
const s = useModelSharingStore.getState();
expect(s.items).toHaveLength(LIBRARY_PAGE_SIZE);
expect(s.hasMore).toBe(true);
expect(s.cursor).not.toBeNull();
expect(s.isLoading).toBe(false);
});
});
describe("loadMore — cursor 無限捲動", () => {
it("續載 → append 下一頁且不與首頁重複", async () => {
const store = useModelSharingStore.getState();
await store.loadFirstPage();
const firstIds = useModelSharingStore.getState().items.map((m) => m.id);
await useModelSharingStore.getState().loadMore();
const all = useModelSharingStore.getState().items;
// 續載後總數 > 首頁
expect(all.length).toBeGreaterThan(firstIds.length);
// 無重複 id
expect(new Set(all.map((m) => m.id)).size).toBe(all.length);
});
it("一路 loadMore 到底 → hasMore=false、涵蓋全部 30 筆", async () => {
await useModelSharingStore.getState().loadFirstPage();
let guard = 0;
while (useModelSharingStore.getState().hasMore) {
await useModelSharingStore.getState().loadMore();
if (++guard > 10) throw new Error("loadMore 未收斂");
}
const s = useModelSharingStore.getState();
expect(s.hasMore).toBe(false);
expect(s.items).toHaveLength(30); // mock fixtures 共 30 筆
});
it("重入防護:無 cursor未載入首頁→ loadMore 不改變 items", async () => {
await useModelSharingStore.getState().loadMore();
expect(useModelSharingStore.getState().items).toHaveLength(0);
});
});
describe("setFilters", () => {
it("設 owned=mine → 重置分頁並只留我的模型", async () => {
await useModelSharingStore.getState().loadFirstPage();
useModelSharingStore.getState().setFilters({ owned: "mine" });
// setFilters 內部呼叫 loadFirstPageasync等 microtask
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
const s = useModelSharingStore.getState();
expect(s.filters.owned).toBe("mine");
expect(s.items.every((m) => m.owner.isMe)).toBe(true);
});
});
describe("loadProfile", () => {
it("命中 mock id → 設 profile", async () => {
await useModelSharingStore.getState().loadProfile("mock-model-01");
const s = useModelSharingStore.getState();
expect(s.profile?.id).toBe("mock-model-01");
expect(s.profileError).toBeNull();
});
it("不存在 id → profileError=not_found模擬 404 防 enumeration", async () => {
await useModelSharingStore.getState().loadProfile("no-such-model");
const s = useModelSharingStore.getState();
expect(s.profile).toBeNull();
expect(s.profileError).toBe("not_found");
});
});
describe("公開設定樂觀更新", () => {
it("updateVisibility → 更新 items 中對應項與 profile", async () => {
await useModelSharingStore.getState().loadProfile("mock-model-01");
const result = await useModelSharingStore
.getState()
.updateVisibility("mock-model-01", "public");
expect(result.ok).toBe(true);
expect(useModelSharingStore.getState().profile?.visibility).toBe("public");
});
it("addShare → 加入 sharesremoveShare → 移除", async () => {
await useModelSharingStore.getState().loadShares("mock-model-01");
const before = useModelSharingStore.getState().shares.length;
const add = await useModelSharingStore
.getState()
.addShare("mock-model-01", "new@corp.com");
expect(add.ok).toBe(true);
expect(useModelSharingStore.getState().shares).toHaveLength(before + 1);
const added = useModelSharingStore
.getState()
.shares.find((s) => s.email === "new@corp.com");
const remove = await useModelSharingStore
.getState()
.removeShare("mock-model-01", added!.userId);
expect(remove.ok).toBe(true);
expect(useModelSharingStore.getState().shares).toHaveLength(before);
});
});
describe("filtersToQuery", () => {
it("all / 空值省略mine → owned=true", () => {
const q = filtersToQuery({
...DEFAULT_LIBRARY_FILTERS,
owned: "mine",
q: " hello ",
});
expect(q.owned).toBe(true);
expect(q.q).toBe("hello"); // trim
expect(q.targetChip).toBeUndefined(); // all → 省略
expect(q.visibility).toBeUndefined();
expect(q.limit).toBe(LIBRARY_PAGE_SIZE);
});
it("shared → owned=false具體 filter 帶上", () => {
const q = filtersToQuery({
q: "",
targetChip: "kl720",
visibility: "public",
owned: "shared",
sort: "name",
order: "asc",
});
expect(q.owned).toBe(false);
expect(q.targetChip).toBe("kl720");
expect(q.visibility).toBe("public");
expect(q.sort).toBe("name");
expect(q.order).toBe("asc");
});
it("帶 cursor → query 含 cursor", () => {
const q = filtersToQuery(DEFAULT_LIBRARY_FILTERS, "CURSOR123");
expect(q.cursor).toBe("CURSOR123");
});
});

View File

@ -0,0 +1,366 @@
/**
* Model Sharing Store visionA Cloud L
*
*
* 1. cursor + / filter /
* 2. profile
* 3. Dialogvisibility + shares
*
* `api-model-sharing.md`API `lib/api/model-sharing.ts`
*
* ## mock
* `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1` `_setMockMode(true)`
* `model-sharing.mock.ts` fixtures API response
* flag UI / normalize
*/
"use client";
import { create } from "zustand";
import {
addShare as apiAddShare,
fetchLibrary as apiFetchLibrary,
fetchProfile as apiFetchProfile,
fetchShares as apiFetchShares,
removeShare as apiRemoveShare,
updateVisibility as apiUpdateVisibility,
normalizeLibraryPage,
normalizeProfile,
ModelSharingError,
type LibraryModel,
type LibraryQuery,
type LibrarySort,
type ModelProfile,
type ModelShare,
type ModelVisibility,
type SortOrder,
} from "@/lib/api/model-sharing";
import {
mockLibraryPage,
mockProfile,
MOCK_SHARES,
} from "@/lib/api/model-sharing.mock";
/* -------------------------------------------------------------------------- */
/* Mock 模式判定 */
/* -------------------------------------------------------------------------- */
function envMockMode(): boolean {
return (
typeof process !== "undefined" &&
process.env?.NEXT_PUBLIC_USE_MODEL_SHARING_MOCK === "1"
);
}
/* -------------------------------------------------------------------------- */
/* 列表篩選 / 排序狀態 */
/* -------------------------------------------------------------------------- */
/** 共享庫的可見性 filterUI 用all = 不過濾)。 */
export type LibraryVisibilityFilter = "all" | "public" | "tenant";
/** 擁有關係 filterUI 用all = 全部可見)。 */
export type LibraryOwnedFilter = "all" | "mine" | "shared";
export interface LibraryFilters {
q: string;
targetChip: "all" | "kl520" | "kl720" | "kl630" | "kl730";
visibility: LibraryVisibilityFilter;
owned: LibraryOwnedFilter;
sort: LibrarySort;
order: SortOrder;
}
export const DEFAULT_LIBRARY_FILTERS: LibraryFilters = {
q: "",
targetChip: "all",
visibility: "all",
owned: "all",
sort: "created_at",
order: "desc",
};
/** 每頁筆數(對齊設計規格 §4.6desktop 3 欄 × 8 列)。 */
export const LIBRARY_PAGE_SIZE = 24;
/** 把 UI filters 轉成 API query省略 all / 空值)。 */
export function filtersToQuery(
filters: LibraryFilters,
cursor?: string,
): LibraryQuery {
const query: LibraryQuery = {
limit: LIBRARY_PAGE_SIZE,
sort: filters.sort,
order: filters.order,
};
if (cursor) query.cursor = cursor;
if (filters.q.trim()) query.q = filters.q.trim();
if (filters.targetChip !== "all") query.targetChip = filters.targetChip;
if (filters.visibility !== "all") query.visibility = filters.visibility;
if (filters.owned === "mine") query.owned = true;
else if (filters.owned === "shared") query.owned = false;
return query;
}
/* -------------------------------------------------------------------------- */
/* Store 型別 */
/* -------------------------------------------------------------------------- */
/** 分享對象操作的結果(帶 i18n code 給 UI 顯示)。 */
export type ShareOpResult =
| { ok: true }
| { ok: false; code: string; message: string };
interface ModelSharingState {
/* ── 共享庫列表 ── */
items: LibraryModel[];
filters: LibraryFilters;
cursor: string | null;
hasMore: boolean;
/** 首屏 / filter 變更後的整體載入。 */
isLoading: boolean;
/** 「載入更多」cursor 續載)中。 */
isLoadingMore: boolean;
/** 列表載入錯誤i18n codenull = 無錯誤。 */
listError: string | null;
/* ── profile ── */
profile: ModelProfile | null;
isProfileLoading: boolean;
/** profile 錯誤 code如 not_found → 無權限 / 找不到)。 */
profileError: string | null;
/* ── 公開設定shares ── */
shares: ModelShare[];
isSharesLoading: boolean;
/* ── actions ── */
/** 設定 filters會重置分頁並重新載入首頁。 */
setFilters: (patch: Partial<LibraryFilters>) => void;
/** 載入首頁reset 已載入項 + cursor。 */
loadFirstPage: () => Promise<void>;
/** cursor 續載下一頁append。 */
loadMore: () => Promise<void>;
/** 載入 profile。 */
loadProfile: (id: string) => Promise<void>;
clearProfile: () => void;
/** 載入授權清單。 */
loadShares: (id: string) => Promise<void>;
/** 更新可見性。 */
updateVisibility: (
id: string,
visibility: ModelVisibility,
) => Promise<ShareOpResult>;
/** 新增授權對象。 */
addShare: (id: string, email: string) => Promise<ShareOpResult>;
/** 移除授權對象。 */
removeShare: (id: string, userId: string) => Promise<ShareOpResult>;
/* ── 測試 / mock ── */
_mockMode: boolean;
_setMockMode: (on: boolean) => void;
_setItems: (items: LibraryModel[]) => void;
_setProfile: (p: ModelProfile | null) => void;
_setShares: (s: ModelShare[]) => void;
}
/* -------------------------------------------------------------------------- */
/* Mock 分頁 / profile / shares走 fixtures */
/* -------------------------------------------------------------------------- */
function mockFetchLibrary(query: LibraryQuery) {
const raw = mockLibraryPage({
cursor: query.cursor,
limit: query.limit,
q: query.q,
targetChip: query.targetChip,
source: query.source,
visibility: query.visibility,
owned: query.owned,
sort: query.sort,
order: query.order,
});
return normalizeLibraryPage(raw);
}
function mockFetchProfile(id: string): ModelProfile {
const raw = mockProfile(id);
if (!raw) {
throw new ModelSharingError(404, "not_found", "model not found");
}
return normalizeProfile(raw);
}
function mockFetchShares(id: string): ModelShare[] {
const list = MOCK_SHARES[id] ?? [];
return list.map((s) => ({
userId: s.user_id,
email: s.email,
role: s.role === "editor" ? "editor" : "viewer",
createdAt: s.created_at,
}));
}
/* -------------------------------------------------------------------------- */
/* Store */
/* -------------------------------------------------------------------------- */
export const useModelSharingStore = create<ModelSharingState>()((set, get) => ({
items: [],
filters: { ...DEFAULT_LIBRARY_FILTERS },
cursor: null,
hasMore: false,
isLoading: false,
isLoadingMore: false,
listError: null,
profile: null,
isProfileLoading: false,
profileError: null,
shares: [],
isSharesLoading: false,
_mockMode: envMockMode(),
setFilters: (patch) => {
set((state) => ({ filters: { ...state.filters, ...patch } }));
void get().loadFirstPage();
},
loadFirstPage: async () => {
const { filters, _mockMode } = get();
set({ isLoading: true, listError: null, items: [], cursor: null, hasMore: false });
try {
const query = filtersToQuery(filters);
const page = _mockMode ? mockFetchLibrary(query) : await apiFetchLibrary(query);
set({
items: page.items,
cursor: page.nextCursor,
hasMore: page.hasMore,
isLoading: false,
});
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
set({ isLoading: false, listError: code });
}
},
loadMore: async () => {
const { filters, cursor, hasMore, isLoadingMore, isLoading, _mockMode } = get();
// 防呆:無下一頁 / 正在載入時不重複觸發(無限捲動 observer 可能連續觸發)。
if (!hasMore || !cursor || isLoadingMore || isLoading) return;
set({ isLoadingMore: true, listError: null });
try {
const query = filtersToQuery(filters, cursor);
const page = _mockMode ? mockFetchLibrary(query) : await apiFetchLibrary(query);
set((state) => ({
items: [...state.items, ...page.items],
cursor: page.nextCursor,
hasMore: page.hasMore,
isLoadingMore: false,
}));
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
set({ isLoadingMore: false, listError: code });
}
},
loadProfile: async (id) => {
const { _mockMode } = get();
set({ isProfileLoading: true, profileError: null, profile: null });
try {
const profile = _mockMode ? mockFetchProfile(id) : await apiFetchProfile(id);
set({ profile, isProfileLoading: false });
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
set({ isProfileLoading: false, profileError: code });
}
},
clearProfile: () => set({ profile: null, profileError: null }),
loadShares: async (id) => {
const { _mockMode } = get();
set({ isSharesLoading: true });
try {
const shares = _mockMode ? mockFetchShares(id) : await apiFetchShares(id);
set({ shares, isSharesLoading: false });
} catch {
// 載入授權清單失敗時清空 + 停止 loadingDialog UI 顯示空清單,操作仍可重試。
set({ shares: [], isSharesLoading: false });
}
},
updateVisibility: async (id, visibility) => {
const { _mockMode } = get();
try {
if (!_mockMode) {
await apiUpdateVisibility(id, visibility);
}
// 樂觀更新 profile 與列表中對應項的 visibility。
set((state) => ({
profile:
state.profile?.id === id
? { ...state.profile, visibility }
: state.profile,
items: state.items.map((m) =>
m.id === id ? { ...m, visibility } : m,
),
}));
return { ok: true };
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
const message = err instanceof Error ? err.message : String(err);
return { ok: false, code, message };
}
},
addShare: async (id, email) => {
const { _mockMode } = get();
try {
let newShare: ModelShare;
if (_mockMode) {
newShare = {
userId: `u-${email}`,
email,
role: "viewer",
createdAt: new Date().toISOString(),
};
} else {
newShare = await apiAddShare(id, email);
}
set((state) => ({ shares: [...state.shares, newShare] }));
return { ok: true };
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
const message = err instanceof Error ? err.message : String(err);
return { ok: false, code, message };
}
},
removeShare: async (id, userId) => {
const { _mockMode } = get();
try {
if (!_mockMode) {
await apiRemoveShare(id, userId);
}
set((state) => ({
shares: state.shares.filter((s) => s.userId !== userId),
}));
return { ok: true };
} catch (err) {
const code = err instanceof ModelSharingError ? err.code : "unknown";
const message = err instanceof Error ? err.message : String(err);
return { ok: false, code, message };
}
},
_setMockMode: (on) => set({ _mockMode: on }),
_setItems: (items) => set({ items }),
_setProfile: (profile) => set({ profile }),
_setShares: (shares) => set({ shares }),
}));

View File

@ -36,6 +36,8 @@ export type KnownErrorCode =
| "FORBIDDEN" | "FORBIDDEN"
| "NOT_FOUND" | "NOT_FOUND"
| "VALIDATION_FAILED" | "VALIDATION_FAILED"
| "REPRESENTATIVE_DEVICE" // 409representative device 不可 register/unregisterapi-device-mgmt.md §3backend 實際回碼)
| "ALREADY_REGISTERED" // 409register 時裝置已註冊api-device-mgmt.md §3本功能新增
| "TUNNEL_DISCONNECTED" | "TUNNEL_DISCONNECTED"
| "TUNNEL_ERROR" | "TUNNEL_ERROR"
| "NOT_IMPLEMENTED" | "NOT_IMPLEMENTED"