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:
parent
47a1d4d0ef
commit
6a797d5eb5
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* ModelProfileClient 雙態測試
|
||||
*
|
||||
* 覆蓋:
|
||||
* - owner 版(myAccess=owner):顯示公開設定 + 刪除按鈕
|
||||
* - 公開 / 共享版(myAccess=viewer):隱藏公開設定 / 刪除;顯示 owner 資訊列
|
||||
* - canDownload → 顯示下載鈕
|
||||
* - 無權限 / 404(profileError)→ 全頁 EmptyState「找不到 / 無權限」
|
||||
*
|
||||
* 走 store mock 模式,直接以 _setProfile / setState 注入 profile,不打真實 API。
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import type { ModelProfile } from "@/lib/api/model-sharing";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ModelProfileClient } from "./model-profile-client";
|
||||
|
||||
const ownerProfile: ModelProfile = {
|
||||
id: "p1",
|
||||
name: "我的模型",
|
||||
targetChip: "kl520",
|
||||
fileSize: 1024 * 1024,
|
||||
source: "converted",
|
||||
status: "ready",
|
||||
visibility: "private",
|
||||
owner: { id: "me", name: "我", isMe: true },
|
||||
myAccess: "owner",
|
||||
canDownload: true,
|
||||
createdAt: "2026-07-01T00:00:00Z",
|
||||
updatedAt: "2026-07-02T00:00:00Z",
|
||||
};
|
||||
|
||||
const viewerProfile: ModelProfile = {
|
||||
...ownerProfile,
|
||||
id: "p2",
|
||||
name: "共享模型",
|
||||
visibility: "public",
|
||||
owner: { id: "alice", name: "Alice", isMe: false },
|
||||
myAccess: "viewer",
|
||||
};
|
||||
|
||||
/**
|
||||
* 讓 loadProfile 直接把注入的 profile 放進 store(不打 API),避免 useEffect 覆蓋。
|
||||
*/
|
||||
function stubLoadProfile(profile: ModelProfile | null, error: string | null = null) {
|
||||
useModelSharingStore.setState({
|
||||
_mockMode: true,
|
||||
loadProfile: async () => {
|
||||
useModelSharingStore.setState({
|
||||
profile,
|
||||
profileError: error,
|
||||
isProfileLoading: false,
|
||||
});
|
||||
},
|
||||
clearProfile: () => {
|
||||
/* 測試中保留注入的 profile,不清空 */
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfile(id: string) {
|
||||
return render(
|
||||
<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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
337
visionA-frontend/src/app/models/[id]/model-profile-client.tsx
Normal file
337
visionA-frontend/src/app/models/[id]/model-profile-client.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelProfileClient — 模型 profile 頁(owner / 公開雙態)
|
||||
*
|
||||
* 對齊設計規格 §6 + API 契約 §2(GET /:id/profile)。取代舊的 owner-only detail:
|
||||
* 用 `GET /api/models/:id/profile` 取詳情(後端依身份裁剪 + 權限檢查),依 `myAccess`
|
||||
* 決定渲染 owner 版或公開 / 共享版。
|
||||
*
|
||||
* 雙態差異(設計規格 §6.2):
|
||||
* - owner 版(myAccess==='owner'):下載(若可)+ 刪除 + 【新增】公開設定 Dialog
|
||||
* - 公開 / 共享版:僅下載(canDownload 為 true 時);隱藏刪除 / 公開設定;顯示 ModelOwnerBar
|
||||
*
|
||||
* 錯誤(契約 §2):無可見性 → 404(防 enumeration)→ 全頁「找不到 / 無權限」EmptyState。
|
||||
*
|
||||
* 下載沿用既有 FAA delegated download(lib/api/model-download),走同一 endpoint
|
||||
* (契約 §3:download 已加共享權限檢查)。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, DownloadIcon, Globe, SearchX, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ModelOwnerBar } from "@/components/models/model-owner-bar";
|
||||
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
|
||||
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
getModelDownload,
|
||||
ModelDownloadError,
|
||||
triggerNavDownload,
|
||||
} from "@/lib/api/model-download";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
import { useModelStore } from "@/stores/model-store";
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!bytes) return "—";
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatInputShape(shape: number[]): string {
|
||||
return shape.join(" × ");
|
||||
}
|
||||
|
||||
const CLASSES_PREVIEW_LIMIT = 8;
|
||||
|
||||
interface ModelProfileClientProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ModelProfileClient({ id }: ModelProfileClientProps) {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const profile = useModelSharingStore((s) => s.profile);
|
||||
const isLoading = useModelSharingStore((s) => s.isProfileLoading);
|
||||
const profileError = useModelSharingStore((s) => s.profileError);
|
||||
const loadProfile = useModelSharingStore((s) => s.loadProfile);
|
||||
const clearProfile = useModelSharingStore((s) => s.clearProfile);
|
||||
|
||||
// 刪除沿用既有 model-store(owner-only DELETE /api/models/:id)。
|
||||
const deleteModel = useModelStore((s) => s.deleteModel);
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloadBusy, setDownloadBusy] = useState(false);
|
||||
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) void loadProfile(id);
|
||||
return () => clearProfile();
|
||||
}, [id, loadProfile, clearProfile]);
|
||||
|
||||
const isOwner = profile?.myAccess === "owner";
|
||||
|
||||
async function handleDownload() {
|
||||
if (!profile || downloadBusy) return;
|
||||
setDownloadBusy(true);
|
||||
try {
|
||||
const grant = await getModelDownload(profile.id);
|
||||
triggerNavDownload(grant.downloadUrl);
|
||||
toast.success(t("models.download.toast.start"), {
|
||||
description: t("models.download.toast.hint"),
|
||||
});
|
||||
} catch (err) {
|
||||
const code = err instanceof ModelDownloadError ? err.code : "unknown";
|
||||
const key = `models.download.error.${code}`;
|
||||
const desc = t(key);
|
||||
toast.error(t("models.download.error.title"), {
|
||||
description: desc === key ? t("models.download.error.unknown") : desc,
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
const ok = await deleteModel(id);
|
||||
setDeleting(false);
|
||||
if (ok) {
|
||||
toast.success(t("common.save"));
|
||||
router.push("/models");
|
||||
} else {
|
||||
toast.error(t("common.error"));
|
||||
}
|
||||
}
|
||||
|
||||
const backButton = (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,16 @@
|
||||
import { ModelDetailClient } from "./model-detail-client";
|
||||
import { ModelProfileClient } from "./model-profile-client";
|
||||
|
||||
/**
|
||||
* 模型 profile 頁 — /models/[id]
|
||||
*
|
||||
* 模型共享功能後改用 ModelProfileClient(owner / 公開雙態,走 GET /:id/profile,
|
||||
* 支援非 owner 依權限檢視)。舊的 owner-only ModelDetailClient 保留於同目錄但不再掛路由。
|
||||
*/
|
||||
export default async function ModelDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <ModelDetailClient id={id} />;
|
||||
return <ModelProfileClient id={id} />;
|
||||
}
|
||||
|
||||
129
visionA-frontend/src/app/models/library/library-client.test.tsx
Normal file
129
visionA-frontend/src/app/models/library/library-client.test.tsx
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
213
visionA-frontend/src/app/models/library/library-client.tsx
Normal file
213
visionA-frontend/src/app/models/library/library-client.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LibraryClient — 共享模型庫列表頁(cursor 無限捲動)
|
||||
*
|
||||
* 對齊設計規格 §4 + API 契約 §1。使用者拍板「cursor 無限捲動」(往下捲自動載入更多)。
|
||||
*
|
||||
* 狀態機(設計規格 §7):
|
||||
* - 首屏載入 → skeleton 網格
|
||||
* - 有資料 → 卡片網格 + 底部哨兵(IntersectionObserver 觸發 loadMore)
|
||||
* - 續載中 → 底部補 skeleton 卡片
|
||||
* - 空(無共享模型) → EmptyState
|
||||
* - 搜尋無結果 → EmptyState + 清除搜尋 CTA
|
||||
* - 列表錯誤 → 錯誤提示 + 重試
|
||||
*
|
||||
* 搜尋 debounce 300ms(設計規格 §4.4):local searchInput → debounce → store.setFilters({ q })。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Boxes, SearchX, Users } from "lucide-react";
|
||||
|
||||
import { LibraryModelCard } from "@/components/models/library-model-card";
|
||||
import { LibraryToolbar } from "@/components/models/library-toolbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
function SkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
11
visionA-frontend/src/app/models/library/page.tsx
Normal file
11
visionA-frontend/src/app/models/library/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { LibraryClient } from "./library-client";
|
||||
|
||||
/**
|
||||
* 共享模型庫 — /models/library
|
||||
*
|
||||
* 依身份權限可見的模型列表(我的 ∪ 公開 ∪ 同租戶 ∪ 分享給我 ∪ preset),
|
||||
* cursor 無限捲動分頁。對齊 api-model-sharing.md §1、feature-model-sharing-design.md §4。
|
||||
*/
|
||||
export default function ModelLibraryPage() {
|
||||
return <LibraryClient />;
|
||||
}
|
||||
@ -12,6 +12,8 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
import {
|
||||
ModelFilters,
|
||||
@ -19,6 +21,7 @@ import {
|
||||
} from "@/components/models/model-filters";
|
||||
import { ModelSection } from "@/components/models/model-section";
|
||||
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import {
|
||||
type ModelSource,
|
||||
@ -83,8 +86,16 @@ export default function ModelsPage() {
|
||||
<h1 className="text-2xl font-bold">{t("models.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("models.subtitle")}</p>
|
||||
</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 />
|
||||
</div>
|
||||
</div>
|
||||
<ModelFilters value={filter} onChange={setFilter} />
|
||||
<div className="space-y-8">
|
||||
{SECTION_ORDER.map((source) => (
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
|
||||
|
||||
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RemoteStatus } from "@/stores/device-store";
|
||||
@ -36,30 +37,6 @@ export interface RemoteDeviceBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相對時間(components.md §10.3 規格)。
|
||||
* - < 60 秒 → 「剛剛」
|
||||
* - < 60 分 → 「X 分鐘前」
|
||||
* - < 24 時 → 「X 小時前」
|
||||
* - ≥ 24 時 → 絕對時間「MM/DD HH:mm」
|
||||
*/
|
||||
function formatRelativeTime(isoString: string, nowMs: number, t: (k: string) => string): string {
|
||||
const ts = Date.parse(isoString);
|
||||
if (Number.isNaN(ts)) return "";
|
||||
const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
|
||||
if (diffSec < 60) return t("remote.lastSeen.justNow");
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
|
||||
const d = new Date(ts);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
export function RemoteDeviceBadge({
|
||||
status,
|
||||
lastSeenAt,
|
||||
|
||||
@ -63,3 +63,52 @@ describe("DeviceCard — serial 路由 gating(WP-C / ADR-018)", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceCard — 三態分色 + 註冊 UI(TDD §5)", () => {
|
||||
it("已連接未註冊(online + registeredAt null)→ warning 標記(文字「未註冊」+ icon,不只靠色)", () => {
|
||||
renderCard({ ...baseDevice, remoteStatus: "online", registeredAt: null });
|
||||
const badge = screen.getByTestId("unregistered-badge");
|
||||
// 不只靠顏色:badge 有文字「未註冊」
|
||||
expect(badge).toHaveTextContent("未註冊");
|
||||
// 卡片 data-tri-state 標記便於測試/樣式
|
||||
expect(screen.getByTestId("device-card")).toHaveAttribute(
|
||||
"data-tri-state",
|
||||
"online-unregistered",
|
||||
);
|
||||
// 顯示「註冊」動作
|
||||
expect(screen.getByTestId("device-register-btn")).toBeInTheDocument();
|
||||
// 不顯示「取消註冊」
|
||||
expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("已連接已註冊 → 無未註冊標記,顯示「取消註冊」動作", () => {
|
||||
renderCard({
|
||||
...baseDevice,
|
||||
remoteStatus: "online",
|
||||
registeredAt: "2026-08-02T10:00:00Z",
|
||||
});
|
||||
expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("device-card")).toHaveAttribute(
|
||||
"data-tri-state",
|
||||
"online-registered",
|
||||
);
|
||||
expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("離線未註冊 → 無未註冊標記、無註冊動作(需先連線)", () => {
|
||||
renderCard({ ...baseDevice, remoteStatus: "offline", registeredAt: null });
|
||||
expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("離線已註冊 → 顯示「取消註冊」(已註冊不論在線與否都可取消)", () => {
|
||||
renderCard({
|
||||
...baseDevice,
|
||||
remoteStatus: "offline",
|
||||
registeredAt: "2026-08-02T10:00:00Z",
|
||||
});
|
||||
expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,6 +23,8 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
|
||||
import { DeviceRegisterActions } from "@/components/devices/device-register-actions";
|
||||
import { UnregisteredBadge } from "@/components/devices/unregistered-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@ -30,6 +32,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { deriveTriState } from "@/lib/device-state";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DeviceSummary } from "@/stores/device-store";
|
||||
@ -44,15 +47,22 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
||||
const isOnline = device.remoteStatus === "online";
|
||||
// WP-C(ADR-018):serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
|
||||
const hasSerial = !!device.serialNumber;
|
||||
// 三態(TDD §5.2):online-unregistered = 已連接未註冊(第三態,走 warning 色)。
|
||||
const triState = deriveTriState(device);
|
||||
const isOnlineUnregistered = triState === "online-unregistered";
|
||||
const isRegistered = !!device.registeredAt;
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-testid="device-card"
|
||||
data-remote-status={device.remoteStatus}
|
||||
data-tri-state={triState}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
// 離線裝置 opacity-75(flow-offline-handling §4.1)
|
||||
!isOnline && device.remoteStatus !== "reconnecting" && "opacity-75",
|
||||
// 第三態「已連接未註冊」:warning 色邊框(配合角落 UnregisteredBadge 的文字+icon,不只靠色)。
|
||||
isOnlineUnregistered && "border-warning",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
@ -63,11 +73,15 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
||||
<p className="text-muted-foreground truncate text-xs">{device.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<RemoteDeviceBadge
|
||||
status={device.remoteStatus}
|
||||
lastSeenAt={device.lastSeenAt ?? null}
|
||||
size="sm"
|
||||
/>
|
||||
{/* 第三態標記:連線與註冊是正交兩軸,未註冊用獨立 warning pill 疊加(不塞進連線 badge)。 */}
|
||||
{isOnlineUnregistered && <UnregisteredBadge size="sm" />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@ -93,6 +107,11 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
||||
{t("common.manage")}
|
||||
</Button>
|
||||
</Link>
|
||||
{/* 註冊 / 取消註冊:已連接未註冊 → 「註冊」;已註冊 → 「取消註冊」。
|
||||
offline 未註冊不顯示(無從註冊,需先連線)。 */}
|
||||
{(isOnlineUnregistered || isRegistered) && (
|
||||
<DeviceRegisterActions device={device} size="sm" />
|
||||
)}
|
||||
{isOnline && device.flashedModel && hasSerial && (
|
||||
<Link href={`/workspace/${device.id}`}>
|
||||
<Button size="sm">{t("devices.openWorkspace")}</Button>
|
||||
|
||||
111
visionA-frontend/src/components/devices/device-list-controls.tsx
Normal file
111
visionA-frontend/src/components/devices/device-list-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,45 +1,56 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DeviceList — 裝置卡片網格 + 空狀態 + skeleton
|
||||
* DeviceList — 裝置卡片網格 + 空狀態 + skeleton + 排序/filter 控制
|
||||
*
|
||||
* 來源:`local-tool/frontend/src/components/devices/device-list.tsx`(雲端版改造)
|
||||
*
|
||||
* 對齊:
|
||||
* - `.autoflow/03-design/pages.md` §5.3(空狀態)
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6(排序 + filter)
|
||||
*
|
||||
* 改動:
|
||||
* - 空狀態導向 `/devices/pair`(F7 的 Pairing 頁),不再是 scan
|
||||
* - 排序:在線優先(online → reconnecting → unknown → offline → error)
|
||||
* - 排序:改為可自選(狀態 / 名稱 / 註冊時間),預設「狀態」保留既有在線優先行為
|
||||
* - 新增三態 filter(全部 / 已連接 / 已連接未註冊 / 未連接)+ filter 後空結果狀態
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { Link2, SearchX } from "lucide-react";
|
||||
|
||||
import { DeviceCard } from "@/components/devices/device-card";
|
||||
import { DeviceListControls } from "@/components/devices/device-list-controls";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
applyDeviceListView,
|
||||
type DeviceFilterKey,
|
||||
type DeviceSortKey,
|
||||
} from "@/lib/device-state";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
|
||||
import type { DeviceSummary } from "@/stores/device-store";
|
||||
|
||||
interface DeviceListProps {
|
||||
devices: DeviceSummary[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_ORDER: Record<RemoteStatus, number> = {
|
||||
online: 0,
|
||||
reconnecting: 1,
|
||||
unknown: 2,
|
||||
offline: 3,
|
||||
error: 4,
|
||||
};
|
||||
|
||||
export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
// 排序 / filter 狀態存 local state(P0 不持久化,TDD §6.2)。
|
||||
const [sortKey, setSortKey] = useState<DeviceSortKey>("status");
|
||||
const [filter, setFilter] = useState<DeviceFilterKey>("all");
|
||||
|
||||
// 先 filter 再 sort(TDD §6.3);devices / 條件變動才重算。
|
||||
const visible = useMemo(
|
||||
() => applyDeviceListView(devices, filter, sortKey),
|
||||
[devices, filter, sortKey],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
@ -53,6 +64,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 完全沒有裝置(非 filter 造成)→ 導向配對的既有空狀態。
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<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 (
|
||||
<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
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
data-testid="device-list"
|
||||
>
|
||||
{sorted.map((device) => (
|
||||
{visible.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} />
|
||||
))}
|
||||
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
|
||||
@ -98,5 +128,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DeviceRegisterActions — 註冊 / 取消註冊動作按鈕
|
||||
*
|
||||
* 規格來源:
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5(三態 + 註冊 UI)
|
||||
* - `docs/autoflow/04-architecture/api/api-device-mgmt.md`(register / unregister 契約)
|
||||
*
|
||||
* 行為:
|
||||
* - 「已連接未註冊」(online-unregistered)→ 顯示「註冊」按鈕(primary)。
|
||||
* - 「已註冊」(registeredAt != null,不論在線與否)→ 顯示「取消註冊」按鈕(outline)。
|
||||
* ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只退回未註冊態、保留裝置列,
|
||||
* 文案明確用「取消註冊」而非「移除」,避免使用者誤以為會刪掉裝置。
|
||||
* - 未連接且未註冊(offline + null)→ 無動作(不顯示按鈕)。
|
||||
*
|
||||
* 錯誤處理:
|
||||
* - register 409 ALREADY_REGISTERED → 提示「已註冊」(後端與前端可能競態)。
|
||||
* - representative REPRESENTATIVE_DEVICE / 403 FORBIDDEN → 對應 i18n,退化到 unknown 文案。
|
||||
*
|
||||
* 呼叫端(DeviceCard)已用 deriveTriState / registeredAt 決定是否 render 本元件。
|
||||
*/
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { UserCheck, UserX } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { DeviceSummary } from "@/stores/device-store";
|
||||
import { useDeviceStore } from "@/stores/device-store";
|
||||
|
||||
interface DeviceRegisterActionsProps {
|
||||
device: DeviceSummary;
|
||||
/** 動作成功後的回呼(例如刷新詳情頁);卡片就地更新則可不傳。 */
|
||||
onDone?: () => void | Promise<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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UnregisteredBadge — 「已連接未註冊」第三態標記
|
||||
*
|
||||
* 規格來源:
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.3(配色落地)
|
||||
*
|
||||
* 設計要點:
|
||||
* - 連線狀態(RemoteDeviceBadge)與註冊狀態是正交兩軸,硬塞進同一個 badge 會讓
|
||||
* 「online 但未註冊」的顏色語意打架 → 用獨立的 warning 色 pill 疊加表達「未註冊」。
|
||||
* - 配色只用既有 warning design token(--warning / --warning-foreground / --warning-subtle),
|
||||
* 禁止裸色(bg-yellow-*)——對齊 pairing / login / flash-dialog 的既有慣例。
|
||||
* - 無障礙(design-review M2):不只靠顏色,帶 icon(TriangleAlert)+ 文字「未註冊」。
|
||||
*/
|
||||
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface UnregisteredBadgeProps {
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UnregisteredBadge({ size = "sm", className }: UnregisteredBadgeProps) {
|
||||
const t = useT();
|
||||
const label = t("devices.state.unregistered");
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
154
visionA-frontend/src/components/models/library-model-card.tsx
Normal file
154
visionA-frontend/src/components/models/library-model-card.tsx
Normal 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 頁)
|
||||
*
|
||||
* ⚠️ 契約 §4:response 不含 owner email,故 receiver 資訊列用 owner.name(非 email)。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { MoreVertical, Settings2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
|
||||
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { LibraryModel } from "@/lib/api/model-sharing";
|
||||
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
|
||||
interface LibraryModelCardProps {
|
||||
model: LibraryModel;
|
||||
/** deterministic 相對時間用(測試傳入固定值);預設 Date.now()。 */
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function LibraryModelCard({ model, nowMs }: LibraryModelCardProps) {
|
||||
const t = useT();
|
||||
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
|
||||
// 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()。
|
||||
const [mountedNow] = useState(() => Date.now());
|
||||
const isOwner = model.owner.isMe;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
visionA-frontend/src/components/models/library-toolbar.tsx
Normal file
163
visionA-frontend/src/components/models/library-toolbar.tsx
Normal 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 通知 parent(parent 做 debounce)。
|
||||
*/
|
||||
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { LibraryFilters } from "@/stores/model-sharing-store";
|
||||
|
||||
interface LibraryToolbarProps {
|
||||
filters: LibraryFilters;
|
||||
/** 搜尋框當前輸入(受控;由 parent 管理以便 debounce)。 */
|
||||
searchInput: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFilterChange: (patch: Partial<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>
|
||||
);
|
||||
}
|
||||
49
visionA-frontend/src/components/models/model-owner-bar.tsx
Normal file
49
visionA-frontend/src/components/models/model-owner-bar.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelOwnerBar — 模型擁有者資訊列(模型共享功能)
|
||||
*
|
||||
* 對齊設計規格 §6.4。僅在非 owner 檢視 profile 時渲染,顯示「由 {ownerName} 共享 · {time}」。
|
||||
*
|
||||
* ⚠️ 契約 §4:response 不揭露 owner email,故用 owner.name(非 email)。
|
||||
* 頭像用 owner.name 首字母(沿用 UserMenu avatar 樣式)。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
|
||||
interface ModelOwnerBarProps {
|
||||
ownerName: string;
|
||||
/** 共享時間(ISO);用 updatedAt 近似(契約無獨立 sharedAt 欄)。 */
|
||||
sharedAt?: string;
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export function ModelOwnerBar({ ownerName, sharedAt, nowMs }: ModelOwnerBarProps) {
|
||||
const t = useT();
|
||||
const initial = ownerName.trim().charAt(0).toUpperCase() || "?";
|
||||
// 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()(相對時間顯示不需即時更新)。
|
||||
const [mountedNow] = useState(() => Date.now());
|
||||
const relative = sharedAt
|
||||
? formatRelativeTime(sharedAt, nowMs ?? mountedNow, t)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -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 人");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelVisibilityBadge — 模型可見性徽章(模型共享功能)
|
||||
*
|
||||
* 三態雙編碼(圖示 + 文字,不僅靠顏色,滿足無障礙 De1):
|
||||
* - private(Lock,中性):owner 檢視自己的私有模型
|
||||
* - public(Globe,chart-2 tint):公開給所有 visionA 使用者
|
||||
* - tenant(Building2,chart-1 tint):同租戶可見
|
||||
*
|
||||
* 另有「共享給我」的獨立標示(sharedWithMe=true → Users,chart-3 tint):
|
||||
* 此態源自 model_shares 維度(與 visibility 正交,見 api-model-sharing.md §0),
|
||||
* 優先於 visibility 顯示——receiver 最關心的是「這是別人分享給我的」。
|
||||
*
|
||||
* 配色沿用既有 chart-* token tint 風格(bg/10 + 純色文字 + /30 邊框),
|
||||
* 與 model-card 的 source badge 同做法,Dark Mode 由 token 自動處理,零新 token。
|
||||
*/
|
||||
|
||||
import { Building2, Globe, Lock, Users } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { ModelVisibility } from "@/lib/api/model-sharing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelVisibilityBadgeProps {
|
||||
visibility: ModelVisibility;
|
||||
/** model_shares 命中(別人分享給我)→ 優先顯示「共享給我」。 */
|
||||
sharedWithMe?: boolean;
|
||||
/** owner 視角:已分享給幾人(顯示在 badge 尾綴,如「公開 · 3 人」)。可選。 */
|
||||
sharedCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VISIBILITY_META: Record<
|
||||
ModelVisibility,
|
||||
{ icon: typeof Lock; labelKey: string; className: string }
|
||||
> = {
|
||||
private: {
|
||||
icon: Lock,
|
||||
labelKey: "models.visibility.badge.private",
|
||||
className: "text-muted-foreground border-border",
|
||||
},
|
||||
public: {
|
||||
icon: Globe,
|
||||
labelKey: "models.visibility.badge.public",
|
||||
className: "border-chart-2/30 bg-chart-2/10 text-chart-2",
|
||||
},
|
||||
tenant: {
|
||||
icon: Building2,
|
||||
labelKey: "models.visibility.badge.tenant",
|
||||
className: "border-chart-1/30 bg-chart-1/10 text-chart-1",
|
||||
},
|
||||
};
|
||||
|
||||
export function ModelVisibilityBadge({
|
||||
visibility,
|
||||
sharedWithMe = false,
|
||||
sharedCount,
|
||||
className,
|
||||
}: ModelVisibilityBadgeProps) {
|
||||
const t = useT();
|
||||
|
||||
// 「共享給我」優先於 visibility 顯示(receiver 視角)。
|
||||
if (sharedWithMe) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
/**
|
||||
* ModelVisibilityDialog 測試
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 三態選項渲染(私有 / 公開 / 同租戶)
|
||||
* - public 選中 → amber 警告條顯示
|
||||
* - email 加入:格式錯 → inline 錯誤;重複 → 提示;合法 → 呼叫 store.addShare
|
||||
* - 授權清單渲染 + 移除
|
||||
* - 儲存 → 呼叫 store.updateVisibility + toast.success + 關閉
|
||||
*
|
||||
* 走 store mock 模式(_setMockMode true)+ 直接注入 shares,避免打真實 API。
|
||||
* Radix Dialog / RadioGroup 在 jsdom:dialog 受控 open=true,內容 render 到 portal 可查。
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
|
||||
vi.mock("sonner", () => {
|
||||
const success = vi.fn();
|
||||
const error = vi.fn();
|
||||
return { toast: Object.assign(vi.fn(), { success, error }) };
|
||||
});
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ModelVisibilityDialog } from "./model-visibility-dialog";
|
||||
|
||||
function resetStore() {
|
||||
useModelSharingStore.setState({
|
||||
shares: [],
|
||||
isSharesLoading: false,
|
||||
_mockMode: true,
|
||||
// stub loadShares 為 noop:測試自行以 setState 注入 shares,
|
||||
// 避免 body 掛載時 mock loadShares 覆蓋注入的清單。
|
||||
loadShares: async () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDialog(currentVisibility: "private" | "public" | "tenant" = "private") {
|
||||
return render(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelVisibilityDialog — 模型公開設定(owner-only)
|
||||
*
|
||||
* 對齊設計規格 §5 + API 契約 §3(PATCH visibility)+ shares API(§4)。
|
||||
*
|
||||
* 內容:
|
||||
* - RadioGroup 三態:private / public / tenant(同租戶)
|
||||
* (API 契約 visibility 三態;PRD 的 restricted「指定對象」對應正交的 model_shares 維度,
|
||||
* 下方獨立區塊管理,不佔 visibility 選項)
|
||||
* - 指定對象(model_shares):email 加入 + 授權清單管理(永遠可用,與 visibility 正交)
|
||||
* - public 選中 → amber 警告條
|
||||
* - 由 public/tenant 改回 private 且曾分享給人 → AlertDialog 二次確認
|
||||
*
|
||||
* 受控元件:由 parent(卡片 ⋮ 選單 / profile 按鈕)以 open / onOpenChange 控制。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Globe, Lock, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { isValidEmail, type ModelVisibility } from "@/lib/api/model-sharing";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
|
||||
interface ModelVisibilityDialogProps {
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
/** 目前的可見性(開啟時的初始值)。 */
|
||||
currentVisibility: ModelVisibility;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** 三態選項(對齊 i18n key)。 */
|
||||
const VISIBILITY_OPTIONS: ReadonlyArray<{
|
||||
value: ModelVisibility;
|
||||
labelKey: string;
|
||||
descKey: string;
|
||||
}> = [
|
||||
{ value: "private", labelKey: "models.visibility.private", descKey: "models.visibility.private.desc" },
|
||||
{ value: "public", labelKey: "models.visibility.public", descKey: "models.visibility.public.desc" },
|
||||
{ value: "tenant", labelKey: "models.visibility.tenant", descKey: "models.visibility.tenant.desc" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 外層殼:受控 Dialog。內容以 key remount,讓每次開啟時 body 用最新 props 初始化 state
|
||||
* (避免 setState-in-effect 的 cascading render)。
|
||||
*/
|
||||
export function ModelVisibilityDialog(props: ModelVisibilityDialogProps) {
|
||||
const { open, onOpenChange } = props;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* key 綁 modelId:開啟不同模型時 body remount,state 重新以 props 初始化。 */}
|
||||
{open && <VisibilityDialogBody key={props.modelId} {...props} />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog body(僅在 open 時掛載)。state 直接以 props 初始化(無 setState-in-effect);
|
||||
* 唯一副作用是掛載時載入 shares(external sync,合法 effect)。
|
||||
*/
|
||||
function VisibilityDialogBody({
|
||||
modelId,
|
||||
modelName,
|
||||
currentVisibility,
|
||||
onOpenChange,
|
||||
}: ModelVisibilityDialogProps) {
|
||||
const t = useT();
|
||||
|
||||
const shares = useModelSharingStore((s) => s.shares);
|
||||
const isSharesLoading = useModelSharingStore((s) => s.isSharesLoading);
|
||||
const loadShares = useModelSharingStore((s) => s.loadShares);
|
||||
const updateVisibility = useModelSharingStore((s) => s.updateVisibility);
|
||||
const addShareAction = useModelSharingStore((s) => s.addShare);
|
||||
const removeShareAction = useModelSharingStore((s) => s.removeShare);
|
||||
|
||||
const [visibility, setVisibility] = useState<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 sync);modelId 於本 body 生命週期固定(key remount)。
|
||||
// loadShares 是 zustand action,身分穩定,可安全放入 deps。
|
||||
useEffect(() => {
|
||||
void loadShares(modelId);
|
||||
}, [modelId, loadShares]);
|
||||
|
||||
async function handleAddEmail() {
|
||||
const email = emailInput.trim();
|
||||
if (!email) return;
|
||||
if (!isValidEmail(email)) {
|
||||
setEmailError(t("models.visibility.emailInvalid"));
|
||||
return;
|
||||
}
|
||||
if (shares.some((s) => s.email.toLowerCase() === email.toLowerCase())) {
|
||||
setEmailError(t("models.visibility.emailDuplicate"));
|
||||
return;
|
||||
}
|
||||
setEmailError(null);
|
||||
setAddingEmail(true);
|
||||
const result = await addShareAction(modelId, email);
|
||||
setAddingEmail(false);
|
||||
if (result.ok) {
|
||||
setEmailInput("");
|
||||
} else if (result.code === "not_found") {
|
||||
setEmailError(t("models.visibility.userNotFound").replace("{email}", email));
|
||||
} else {
|
||||
setEmailError(t("models.sharing.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveShare(userId: string) {
|
||||
const result = await removeShareAction(modelId, userId);
|
||||
if (!result.ok) {
|
||||
toast.error(t("models.sharing.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 執行儲存(可能先過二次確認)。 */
|
||||
async function doSave() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const result = await updateVisibility(modelId, visibility);
|
||||
setSaving(false);
|
||||
if (result.ok) {
|
||||
toast.success(t("models.visibility.saved"));
|
||||
onOpenChange(false);
|
||||
} else if (result.code === "conflict") {
|
||||
setSaveError(t("models.visibility.notReady"));
|
||||
} else {
|
||||
setSaveError(t("models.visibility.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick() {
|
||||
// 由 public/tenant 收回成 private 且曾有授權對象 → 二次確認。
|
||||
const wasBroadcast = currentVisibility === "public" || currentVisibility === "tenant";
|
||||
if (visibility === "private" && wasBroadcast && shares.length > 0) {
|
||||
setConfirmRevokeOpen(true);
|
||||
return;
|
||||
}
|
||||
void doSave();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
visionA-frontend/src/components/ui/radio-group.tsx
Normal file
55
visionA-frontend/src/components/ui/radio-group.tsx
Normal 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 };
|
||||
62
visionA-frontend/src/hooks/use-infinite-scroll.ts
Normal file
62
visionA-frontend/src/hooks/use-infinite-scroll.ts
Normal 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 };
|
||||
}
|
||||
155
visionA-frontend/src/lib/api/model-sharing.mock.ts
Normal file
155
visionA-frontend/src/lib/api/model-sharing.mock.ts
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Model Sharing — Mock Fixtures(平行開發用)
|
||||
*
|
||||
* 對 API 契約 mock(api-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/非 owner、shared_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 = 已消費筆數的 base64(不透明;mock 只需能往下切)。
|
||||
*/
|
||||
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 },
|
||||
],
|
||||
};
|
||||
180
visionA-frontend/src/lib/api/model-sharing.test.ts
Normal file
180
visionA-frontend/src/lib/api/model-sharing.test.ts
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Model Sharing API Client 測試
|
||||
*
|
||||
* 覆蓋:
|
||||
* - normalize:snake_case → camelCase、visibility / access 收斂、防呆預設
|
||||
* - mock 分頁:cursor 切頁、filter、排序(deterministic,不打真實 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 陣列 → undefined(UI 有值才顯示)", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
487
visionA-frontend/src/lib/api/model-sharing.ts
Normal file
487
visionA-frontend/src/lib/api/model-sharing.ts
Normal 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 / tenant);private 不在共享庫語意內。 */
|
||||
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 的顯示 email(owner 自己輸入的授權目標,可顯示)。 */
|
||||
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`(400,visibility 非法 / 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 包成 ModelSharingError(code 統一小寫)。 */
|
||||
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 wrapper(cookie session、envelope、ApiError 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_failed(visibility 非法 / 無 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 email)。P0 固定 viewer 權限。
|
||||
*
|
||||
* @throws {ModelSharingError} 400 validation_failed(email 格式)/ 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());
|
||||
}
|
||||
196
visionA-frontend/src/lib/device-state.test.ts
Normal file
196
visionA-frontend/src/lib/device-state.test.ts
Normal 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.2:online×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("name:displayName(alias 優先)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("registeredAt:desc(新在前),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=name(A→Z:b、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([]);
|
||||
});
|
||||
});
|
||||
135
visionA-frontend/src/lib/device-state.ts
Normal file
135
visionA-frontend/src/lib/device-state.ts
Normal 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";
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 排序 + filter(TDD §6,client-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 輸入)。
|
||||
*
|
||||
* - status:REMOTE_STATUS_ORDER(在線優先);同狀態內次比名稱(localeCompare)。
|
||||
* - name:displayName localeCompare,A→Z。
|
||||
* - registeredAt:desc(新註冊在前);null(未註冊)一律排最後。
|
||||
*/
|
||||
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 再 sort(TDD §6.3:組合順序)。 */
|
||||
export function applyDeviceListView(
|
||||
devices: DeviceSummary[],
|
||||
filter: DeviceFilterKey,
|
||||
sortKey: DeviceSortKey,
|
||||
): DeviceSummary[] {
|
||||
return sortDevices(filterDevices(devices, filter), sortKey);
|
||||
}
|
||||
38
visionA-frontend/src/lib/format/relative-time.ts
Normal file
38
visionA-frontend/src/lib/format/relative-time.ts
Normal 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}`;
|
||||
}
|
||||
@ -180,6 +180,43 @@ export const en: Dictionary = {
|
||||
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
|
||||
"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.flashModel": "Load model",
|
||||
"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.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.title": "Workspace",
|
||||
"workspace.subtitle": "Select an online device to start inference",
|
||||
|
||||
@ -181,6 +181,41 @@ export const zhHant: Dictionary = {
|
||||
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
|
||||
"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.flashModel": "載入模型",
|
||||
"devices.flash.flashToDevice": "載入模型到此裝置",
|
||||
@ -301,6 +336,78 @@ export const zhHant: Dictionary = {
|
||||
"models.download.error.busy": "已有下載進行中,請稍候",
|
||||
"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.title": "推論工作區",
|
||||
"workspace.subtitle": "選擇已線上的裝置開始推論",
|
||||
|
||||
@ -23,6 +23,7 @@ beforeEach(() => {
|
||||
connectingId: null,
|
||||
disconnectingId: null,
|
||||
unpairingId: null,
|
||||
registeringId: null,
|
||||
error: null,
|
||||
});
|
||||
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
||||
@ -412,3 +413,253 @@ describe("useDeviceStore.unpairDevice", () => {
|
||||
expect(useDeviceStore.getState().unpairingId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 註冊軸:normalizeDevice registeredAt + register / unregister actions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("useDeviceStore — registeredAt 正規化(TDD §5.1)", () => {
|
||||
it("registered_at(snake)/ registeredAt(camel)有值 → 正確帶入;缺欄 → 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 endpoint(UUID)、就地更新 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_version(omitempty)
|
||||
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 設為該 id(loading 態)", 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_DEVICE(representative)→ 回 { 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:true,list 保留", 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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -89,6 +89,13 @@ export interface DeviceSummary {
|
||||
status: DeviceHardwareStatus;
|
||||
/** 遠端 tunnel 狀態(flow-offline-handling.md §2 新增) */
|
||||
remoteStatus: RemoteStatus;
|
||||
/**
|
||||
* 註冊時間(ISO 8601)— 註冊軸的真實來源(feature-device-mgmt-tdd.md §5)。
|
||||
* 後端 JSON key 為 `registered_at`(omitempty):null / 缺欄位 = 未註冊。
|
||||
* 三態運算(lib/device-state.ts deriveTriState)以「online 且 registeredAt != null」
|
||||
* 判為「已連接(已註冊在線)」;online 且 null → 第三態「已連接未註冊」。
|
||||
*/
|
||||
registeredAt?: string | null;
|
||||
/** ISO 8601,最後心跳時間 */
|
||||
lastSeenAt?: string | null;
|
||||
firmwareVersion?: string | null;
|
||||
@ -152,6 +159,9 @@ function normalizeDevice(raw: unknown): Device {
|
||||
status: coerceHardwareStatus(pick<string>("status")),
|
||||
remoteStatus:
|
||||
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
|
||||
// 註冊軸(TDD §5.1):後端回 registered_at(omitempty)— 缺欄 / null 皆視為未註冊。
|
||||
// 沿用既有 pick snake/camel 相容範式;不做時間格式驗證(後端保證 ISO 8601)。
|
||||
registeredAt: pick<string>("registered_at", "registeredAt") ?? null,
|
||||
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
||||
firmwareVersion:
|
||||
pick<string>("firmware_version", "firmwareVersion") ?? null,
|
||||
@ -179,6 +189,20 @@ export type UnpairResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
/**
|
||||
* register / unregister action 的回傳。
|
||||
*
|
||||
* 與 unpair 同樣採「回 code 而非 boolean」的範式,讓 UI 對不同錯誤分流顯示 toast:
|
||||
* - register:409 `ALREADY_REGISTERED`(已註冊)、409 `REPRESENTATIVE_DEVICE`(representative)、
|
||||
* 403 `FORBIDDEN`(非 owner)、404 `NOT_FOUND` 等(api-device-mgmt.md §3)。
|
||||
* - unregister:契約上冪等(已未註冊回 200),主要錯誤為 403 / 404 / representative(REPRESENTATIVE_DEVICE)。
|
||||
*
|
||||
* 成功時 store 已就地更新該筆 device 的 registeredAt(避免 refetch 延遲,比照 unpair 就地移除範式)。
|
||||
*/
|
||||
export type RegisterResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
interface DeviceState {
|
||||
devices: DeviceSummary[];
|
||||
selectedDevice: Device | null;
|
||||
@ -188,6 +212,8 @@ interface DeviceState {
|
||||
disconnectingId: string | null;
|
||||
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
||||
unpairingId: string | null;
|
||||
/** 註冊 / 取消註冊進行中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
||||
registeringId: string | null;
|
||||
error: string | null;
|
||||
|
||||
/** 呼叫 `GET /api/devices` */
|
||||
@ -207,6 +233,19 @@ interface DeviceState {
|
||||
disconnectDevice: (serialNumber: string) => Promise<boolean>;
|
||||
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
||||
unpairDevice: (id: string) => Promise<UnpairResult>;
|
||||
/**
|
||||
* 呼叫 `POST /api/devices/:id/register`(UUID 識別,純雲端 DB 操作)。
|
||||
* 把裝置由「未註冊」翻成「已註冊」(registered_at NULL → now())。
|
||||
* 成功後就地更新該筆 registeredAt(避免 refetch 延遲)。
|
||||
* ⚠️ 與 connect 不同(connect 用 serial 路由);register 用 UUID(ADR-018 FE-A:DB 操作用 UUID)。
|
||||
*/
|
||||
registerDevice: (id: string) => Promise<RegisterResult>;
|
||||
/**
|
||||
* 呼叫 `POST /api/devices/:id/unregister`(UUID 識別)。
|
||||
* 把裝置退回「未註冊」(registered_at → NULL),**保留裝置列**(不軟刪、不撤 token)。
|
||||
* ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只清 registeredAt、device 仍在清單顯示為未註冊。
|
||||
*/
|
||||
unregisterDevice: (id: string) => Promise<RegisterResult>;
|
||||
/** 測試 / 雛形用:直接塞 list */
|
||||
_setDevices: (devices: DeviceSummary[]) => void;
|
||||
/** 測試 / 雛形用:直接塞 selected */
|
||||
@ -220,6 +259,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
||||
connectingId: null,
|
||||
disconnectingId: null,
|
||||
unpairingId: null,
|
||||
registeringId: null,
|
||||
error: null,
|
||||
|
||||
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 → 回更新後的 DeviceListItem(registered_at 非 null)。
|
||||
// api.post 已 unwrap envelope 的 data;normalizeDevice 讀出 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。回更新後 DeviceListItem(registered_at=null)。
|
||||
await api.post<unknown>(
|
||||
`/api/devices/${encodeURIComponent(id)}/unregister`,
|
||||
);
|
||||
// 就地把該筆 registeredAt 清成 null(device 仍留在 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 }),
|
||||
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
||||
}));
|
||||
|
||||
183
visionA-frontend/src/stores/model-sharing-store.test.ts
Normal file
183
visionA-frontend/src/stores/model-sharing-store.test.ts
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Model Sharing Store 測試(mock 模式,deterministic)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - loadFirstPage:載入首頁、設 cursor / hasMore
|
||||
* - loadMore:append 下一頁、不重複、到底 hasMore=false
|
||||
* - loadMore 重入防護:無 cursor / 載入中不觸發
|
||||
* - setFilters:重置分頁並重新載入
|
||||
* - loadProfile:mock 命中 / 404
|
||||
* - updateVisibility / addShare / removeShare(樂觀更新)
|
||||
* - filtersToQuery:UI 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 內部呼叫 loadFirstPage(async);等 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 → 加入 shares;removeShare → 移除", 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");
|
||||
});
|
||||
});
|
||||
366
visionA-frontend/src/stores/model-sharing-store.ts
Normal file
366
visionA-frontend/src/stores/model-sharing-store.ts
Normal file
@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Model Sharing Store — visionA Cloud(模型共享 L 級新功能)
|
||||
*
|
||||
* 管理三塊狀態:
|
||||
* 1. 共享模型庫列表(cursor 無限捲動分頁 + 搜尋 / filter / 排序)
|
||||
* 2. 模型 profile(公開版詳情,依身份雙態)
|
||||
* 3. 公開設定 Dialog(visibility + 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"
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 列表篩選 / 排序狀態 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 共享庫的可見性 filter(UI 用;all = 不過濾)。 */
|
||||
export type LibraryVisibilityFilter = "all" | "public" | "tenant";
|
||||
|
||||
/** 擁有關係 filter(UI 用;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.6:desktop 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 code);null = 無錯誤。 */
|
||||
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 {
|
||||
// 載入授權清單失敗時清空 + 停止 loading;Dialog 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 }),
|
||||
}));
|
||||
@ -36,6 +36,8 @@ export type KnownErrorCode =
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "VALIDATION_FAILED"
|
||||
| "REPRESENTATIVE_DEVICE" // 409:representative device 不可 register/unregister(api-device-mgmt.md §3,backend 實際回碼)
|
||||
| "ALREADY_REGISTERED" // 409:register 時裝置已註冊(api-device-mgmt.md §3,本功能新增)
|
||||
| "TUNNEL_DISCONNECTED"
|
||||
| "TUNNEL_ERROR"
|
||||
| "NOT_IMPLEMENTED"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user