Compare commits

...

3 Commits

Author SHA1 Message Date
7d8ad4857e fix(dashboard): 修首頁白屏(React #185 無限 re-render)
connected-devices-list 的 selector 內 .filter() 每次回新陣列,zustand v5
移除淺比較後 Object.is 永 false → 無限 re-render → #185 整頁白屏(配對後
有 device 資料才觸發)。

- selector 改回只取 s.devices(穩定 ref),filter 移到 component body
- activity-timeline 未知 type 加 fallback(?? Circle / muted)防 F7/F8 WS 事件 crash
- 測試 render not.toThrow + act flush 驗不再迴圈

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:17:38 +08:00
6de7c1b4a3 feat(devices): 裝置詳細頁加移除(unpair)功能
- device-store 加 unpairDevice action(接 POST /api/devices/:id/unpair)+ unpairingId
- 詳細頁加移除按鈕 + AlertDialog 二次確認(說明解除配對需重新配對、不可逆)
- 成功 toast + 導回 /devices;403/404/unknown 分流 toast、失敗不導頁
- i18n devices.remove.* 雙語

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:17:37 +08:00
0901ffafda feat(models): 模型庫頁按來源分三區(B2)+ 來源標籤 chart token 上色
- /models 改三區渲染:預設模型/我轉檔的/我上傳的(groupModelsBySource)
- 移除 source 篩選器(三區已分來源)、保留 targetChip 篩選跨三區
- 空區顯示標題+空狀態;新 model-section 分區元件
- 來源標籤三色:preset=chart-1/converted=chart-2/uploaded=chart-3
  (設計系統 token、badge + 區標題色點呼應、dark mode 自動跟隨)
- i18n models.section.* + .empty.* 雙語

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:17:19 +08:00
18 changed files with 1146 additions and 50 deletions

View File

@ -0,0 +1,172 @@
/**
* DeviceDetailClient unpair
*
* model-detail-client.test.tsx
* -
* -
* - store.unpairDevice(id)
* - toast.success + router.push("/devices")
* - FORBIDDEN toast.error i18n
* - code toast.error 退 unknown
* - loading unpairingId === id disabled +
*
* Mock
* - sonner toast success / error
* - next/navigation useRouter push
* - device-store fetchDevice / unpairDevice vi.spyOn selectedDevice setState
*/
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 { useDeviceStore, type Device } from "@/stores/device-store";
import { DeviceDetailClient } from "./device-detail-client";
vi.mock("sonner", () => {
const success = vi.fn();
const error = vi.fn();
return { toast: Object.assign(vi.fn(), { success, error }) };
});
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
}));
import { toast } from "sonner";
const onlineDevice: Device = {
id: "dev-1",
name: "KL520 Dev Kit",
type: "kl520",
status: "connected",
remoteStatus: "online",
lastSeenAt: "2026-04-21T00:00:00Z",
firmwareVersion: "2.3.1",
};
function renderDetail(id = "dev-1") {
return render(
<LocaleProvider>
<DeviceDetailClient id={id} />
</LocaleProvider>,
);
}
beforeEach(() => {
// fetchDevice 在 useEffect 會被呼叫 — stub 成 no-op避免打網路selectedDevice 自行注入。
vi.spyOn(useDeviceStore.getState(), "fetchDevice").mockResolvedValue(undefined);
useDeviceStore.setState({
devices: [],
selectedDevice: onlineDevice,
isLoading: false,
connectingId: null,
disconnectingId: null,
unpairingId: null,
error: null,
});
pushMock.mockReset();
(toast.success as Mock).mockReset();
(toast.error as Mock).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
useDeviceStore.setState({ selectedDevice: null, unpairingId: null });
});
describe("DeviceDetailClient 移除裝置按鈕", () => {
it("裝置載入後顯示移除按鈕", () => {
renderDetail();
expect(screen.getByTestId("device-remove-trigger")).toBeInTheDocument();
});
it("點移除按鈕 → 開二次確認對話框(不直接觸發 unpair", async () => {
const spy = vi.spyOn(useDeviceStore.getState(), "unpairDevice");
renderDetail();
fireEvent.click(screen.getByTestId("device-remove-trigger"));
// 確認框出現(含確認鈕),但尚未呼叫 unpair
await waitFor(() =>
expect(screen.getByTestId("device-remove-confirm")).toBeInTheDocument(),
);
expect(spy).not.toHaveBeenCalled();
});
it("確認後 → 觸發 unpairDevice(id)", async () => {
const spy = vi
.spyOn(useDeviceStore.getState(), "unpairDevice")
.mockResolvedValue({ ok: true });
renderDetail();
fireEvent.click(screen.getByTestId("device-remove-trigger"));
fireEvent.click(await screen.findByTestId("device-remove-confirm"));
await waitFor(() => expect(spy).toHaveBeenCalledWith("dev-1"));
});
it("移除成功 → toast.success + 導回 /devices", async () => {
vi.spyOn(useDeviceStore.getState(), "unpairDevice").mockResolvedValue({
ok: true,
});
renderDetail();
fireEvent.click(screen.getByTestId("device-remove-trigger"));
fireEvent.click(await screen.findByTestId("device-remove-confirm"));
await waitFor(() => expect(toast.success).toHaveBeenCalledOnce());
expect(pushMock).toHaveBeenCalledWith("/devices");
expect(toast.error).not.toHaveBeenCalled();
});
it("移除失敗FORBIDDEN→ toast.error描述用對應 i18n不導頁", async () => {
vi.spyOn(useDeviceStore.getState(), "unpairDevice").mockResolvedValue({
ok: false,
code: "FORBIDDEN",
message: "not owner",
});
renderDetail();
fireEvent.click(screen.getByTestId("device-remove-trigger"));
fireEvent.click(await screen.findByTestId("device-remove-confirm"));
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
const call = (toast.error as Mock).mock.calls[0];
expect(call[1].description).toBe("你沒有權限移除此裝置");
expect(pushMock).not.toHaveBeenCalled();
});
it("移除失敗(未對應 code→ toast.error 描述退化成 unknown 文案", async () => {
vi.spyOn(useDeviceStore.getState(), "unpairDevice").mockResolvedValue({
ok: false,
code: "SOME_UNMAPPED_CODE",
message: "x",
});
renderDetail();
fireEvent.click(screen.getByTestId("device-remove-trigger"));
fireEvent.click(await screen.findByTestId("device-remove-confirm"));
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
const call = (toast.error as Mock).mock.calls[0];
expect(call[1].description).toBe("發生錯誤,請稍後再試");
});
it("移除中unpairingId === id→ 觸發按鈕 disabled 且顯示「移除中」", () => {
useDeviceStore.setState({ unpairingId: "dev-1", selectedDevice: onlineDevice });
renderDetail();
const btn = screen.getByTestId("device-remove-trigger");
expect(btn).toBeDisabled();
expect(btn).toHaveTextContent("移除中");
});
});

View File

@ -19,13 +19,27 @@
import { useEffect } from "react";
import Link from "next/link";
import { AlertTriangle, ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { AlertTriangle, ArrowLeft, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
@ -40,14 +54,32 @@ interface DeviceDetailClientProps {
export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
const t = useT();
const router = useRouter();
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
const isLoading = useDeviceStore((s) => s.isLoading);
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
const unpairDevice = useDeviceStore((s) => s.unpairDevice);
const isUnpairing = useDeviceStore((s) => s.unpairingId === id);
useEffect(() => {
if (id) void fetchDevice(id);
}, [id, fetchDevice]);
async function handleRemove() {
const result = await unpairDevice(id);
if (result.ok) {
toast.success(t("devices.remove.toast.success"));
router.push("/devices");
} else {
// 用 backend code 對應 i18n找不到對應 key 時退化到 unknown對齊 model download toast
const key = `devices.remove.error.${result.code}`;
const desc = t(key);
toast.error(t("devices.remove.error.title"), {
description: desc === key ? t("devices.remove.error.unknown") : desc,
});
}
}
if (isLoading && !selectedDevice) {
return (
<div className="mx-auto max-w-5xl space-y-4 px-6 py-8">
@ -128,7 +160,7 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
errorMessage={selectedDevice.errorMessage ?? null}
/>
</div>
<div className="flex gap-2">
<div className="flex items-center gap-2">
{isOnline && selectedDevice.flashedModel && (
<Link href={`/workspace/${selectedDevice.id}`}>
<Button>{t("devices.openWorkspace")}</Button>
@ -146,6 +178,58 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
</TooltipContent>
</Tooltip>
)}
{/* destructive +
model UXAlertDialog / loading / / toast
*/}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isUnpairing}
data-testid="device-remove-trigger"
>
{isUnpairing ? (
<>
<Spinner size="sm" label={t("devices.remove.removing")} />
{t("devices.remove.removing")}
</>
) : (
<>
<Trash2 aria-hidden="true" className="mr-2 size-4" />
{t("devices.remove.action")}
</>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("devices.remove.confirm.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("devices.remove.confirm.description").replace(
"{name}",
displayName,
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isUnpairing}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemove}
disabled={isUnpairing}
data-testid="device-remove-confirm"
>
{t("devices.remove.confirm.action")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>

View File

@ -0,0 +1,214 @@
/**
* /models B2
*
* B2feedback
* - preset converted uploaded
* - model.source
* - source targetChip
* - model +
* - preset model
*
* Mock
* - model-store fetchModels no-opmodels _setModels
* - next/navigationModelCard Link / jsdom app router context
*/
import { render, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { useModelStore, type ModelSummary } from "@/stores/model-store";
import ModelsPage, { SECTION_ORDER, groupModelsBySource } from "./page";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
}));
function model(partial: Partial<ModelSummary> & Pick<ModelSummary, "id" | "source">): ModelSummary {
return {
name: `model-${partial.id}`,
targetChip: "kl520",
fileSize: 1024,
status: "ready",
createdAt: "2026-01-01T00:00:00Z",
...partial,
};
}
function renderPage() {
return render(
<LocaleProvider>
<ModelsPage />
</LocaleProvider>,
);
}
/** 取得某 source 區塊的 DOMdata-source。 */
function section(source: "preset" | "converted" | "uploaded"): HTMLElement {
const el = document.querySelector<HTMLElement>(
`[data-testid="model-section"][data-source="${source}"]`,
);
if (!el) throw new Error(`section ${source} not found`);
return el;
}
beforeEach(() => {
// mount 時 page 會呼叫 fetchModelsmock 成 no-op避免打網路、也不覆蓋我們灌的 models。
useModelStore.setState({
models: [],
isLoading: false,
fetchModels: vi.fn(async () => {}),
});
});
afterEach(() => {
vi.restoreAllMocks();
useModelStore.setState({ models: [], isLoading: false });
});
describe("<ModelsPage /> 三區分組", () => {
it("永遠渲染三個區塊,順序固定 preset → converted → uploaded", () => {
renderPage();
const sections = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="model-section"]'),
);
expect(sections).toHaveLength(3);
expect(sections.map((s) => s.getAttribute("data-source"))).toEqual([
"preset",
"converted",
"uploaded",
]);
});
it("依 source 把 model 分到對應區(三種 source 各分對區)", () => {
useModelStore.setState({
models: [
model({ id: "p1", source: "preset" }),
model({ id: "c1", source: "converted" }),
model({ id: "u1", source: "uploaded" }),
],
});
renderPage();
expect(within(section("preset")).getByText("model-p1")).toBeTruthy();
expect(within(section("preset")).queryByText("model-c1")).toBeNull();
expect(within(section("preset")).queryByText("model-u1")).toBeNull();
expect(within(section("converted")).getByText("model-c1")).toBeTruthy();
expect(within(section("converted")).queryByText("model-p1")).toBeNull();
expect(within(section("uploaded")).getByText("model-u1")).toBeTruthy();
expect(within(section("uploaded")).queryByText("model-c1")).toBeNull();
});
it("預設區顯示所有 preset model", () => {
useModelStore.setState({
models: [
model({ id: "p1", source: "preset" }),
model({ id: "p2", source: "preset" }),
model({ id: "u1", source: "uploaded" }),
],
});
renderPage();
const presetGrid = within(section("preset")).getByTestId("model-grid");
expect(within(presetGrid).getByText("model-p1")).toBeTruthy();
expect(within(presetGrid).getByText("model-p2")).toBeTruthy();
});
});
describe("<ModelsPage /> 空區空狀態", () => {
it("某區無 model → 顯示區標題 + 精簡空狀態(不整區隱藏)", () => {
useModelStore.setState({
models: [model({ id: "p1", source: "preset" })],
});
renderPage();
// 三區都還在
expect(section("uploaded")).toBeTruthy();
// 上傳區無 model → 顯示空狀態,非 grid
expect(within(section("uploaded")).getByTestId("model-grid-empty")).toBeTruthy();
expect(within(section("uploaded")).queryByTestId("model-grid")).toBeNull();
// 區標題仍在
expect(within(section("uploaded")).getByText("我上傳的")).toBeTruthy();
});
});
/**
* targetChip groupModelsBySource
*
* Radix Select UI jsdom pointer capture APISelect flaky
* + deterministic select
* page.tsx useMemo =
*/
describe("groupModelsBySource — targetChip 篩選跨三區作用", () => {
const data: ModelSummary[] = [
model({ id: "p520", source: "preset", targetChip: "kl520" }),
model({ id: "p720", source: "preset", targetChip: "kl720" }),
model({ id: "c720", source: "converted", targetChip: "kl720" }),
model({ id: "u520", source: "uploaded", targetChip: "kl520" }),
];
it("targetChip='all' → 不篩,三組各自拿到自己 source 的全部 model", () => {
const g = groupModelsBySource(data, "all");
expect(g.preset.map((m) => m.id).sort()).toEqual(["p520", "p720"]);
expect(g.converted.map((m) => m.id)).toEqual(["c720"]);
expect(g.uploaded.map((m) => m.id)).toEqual(["u520"]);
});
it("targetChip='kl720' → 三區都只留 KL720KL520-only 的 uploaded 區變空", () => {
const g = groupModelsBySource(data, "kl720");
expect(g.preset.map((m) => m.id)).toEqual(["p720"]); // p520 被濾掉
expect(g.converted.map((m) => m.id)).toEqual(["c720"]);
expect(g.uploaded).toEqual([]); // u520 是 KL520 → 套 KL720 後空
});
it("分組永遠回三個 key即使某 source 完全沒 model", () => {
const g = groupModelsBySource([], "all");
expect(Object.keys(g).sort()).toEqual(["converted", "preset", "uploaded"]);
expect(g.preset).toEqual([]);
expect(g.converted).toEqual([]);
expect(g.uploaded).toEqual([]);
});
it("SECTION_ORDER 固定為 preset → converted → uploaded", () => {
expect([...SECTION_ORDER]).toEqual(["preset", "converted", "uploaded"]);
});
});
/** 篩選後某區變空 → DOM 仍渲染該區 + 精簡空狀態(整合驗證,灌已篩好的資料模擬)。 */
describe("<ModelsPage /> 篩選後空區仍顯示空狀態", () => {
it("uploaded 區無 model模擬篩掉後→ 顯示區標題 + 空狀態,不整區隱藏", () => {
useModelStore.setState({
models: [
model({ id: "p720", source: "preset", targetChip: "kl720" }),
model({ id: "c720", source: "converted", targetChip: "kl720" }),
],
});
renderPage();
expect(section("uploaded")).toBeTruthy();
expect(within(section("uploaded")).getByTestId("model-grid-empty")).toBeTruthy();
expect(within(section("uploaded")).getByText("我上傳的")).toBeTruthy();
});
});
describe("<ModelsPage /> loading", () => {
it("isLoading=true → 各區顯示 grid skeleton", () => {
useModelStore.setState({ isLoading: true, models: [] });
renderPage();
const skeletons = document.querySelectorAll(
'[data-testid="model-grid-skeleton"]',
);
// 三區各一個 skeleton grid
expect(skeletons).toHaveLength(3);
});
});

View File

@ -4,6 +4,11 @@
* /models
*
* pages.md §8.1flow-model-upload.md §4.1
*
* B2feedback model
* (preset) (converted) (uploaded)
* source targetChip
* model +
*/
import { useEffect, useMemo, useState } from "react";
@ -12,10 +17,45 @@ import {
ModelFilters,
type ModelFilterValue,
} from "@/components/models/model-filters";
import { ModelGrid } from "@/components/models/model-grid";
import { ModelSection } from "@/components/models/model-section";
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
import { useT } from "@/lib/i18n/context";
import { useModelStore } from "@/stores/model-store";
import {
type ModelSource,
type ModelSummary,
type TargetChip,
useModelStore,
} from "@/stores/model-store";
/** 分區順序固定preset → converted → uploaded對齊 B2 拍板)。 */
export const SECTION_ORDER: readonly ModelSource[] = [
"preset",
"converted",
"uploaded",
] as const;
/**
* models targetChip source
* / deterministic Radix Select jsdom
*
* @param targetChip "all" = m.targetChip === targetChip model
*/
export function groupModelsBySource(
models: ModelSummary[],
targetChip: TargetChip | "all",
): Record<ModelSource, ModelSummary[]> {
const groups: Record<ModelSource, ModelSummary[]> = {
preset: [],
converted: [],
uploaded: [],
};
for (const m of models) {
if (targetChip !== "all" && m.targetChip !== targetChip) continue;
// m.source 已由 store normalize 收斂為三種之一(預設 "uploaded")。
groups[m.source].push(m);
}
return groups;
}
export default function ModelsPage() {
const t = useT();
@ -24,21 +64,17 @@ export default function ModelsPage() {
const fetchModels = useModelStore((s) => s.fetchModels);
const [filter, setFilter] = useState<ModelFilterValue>({
targetChip: "all",
source: "all",
});
useEffect(() => {
void fetchModels();
}, [fetchModels]);
const filtered = useMemo(() => {
return models.filter((m) => {
if (filter.targetChip !== "all" && m.targetChip !== filter.targetChip)
return false;
if (filter.source !== "all" && m.source !== filter.source) return false;
return true;
});
}, [models, filter]);
// 先套 targetChip 篩選(跨三區作用),再依 source 分三組。
const grouped = useMemo(
() => groupModelsBySource(models, filter.targetChip),
[models, filter],
);
return (
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
@ -50,7 +86,18 @@ export default function ModelsPage() {
<ModelUploadDialog />
</div>
<ModelFilters value={filter} onChange={setFilter} />
<ModelGrid models={filtered} loading={isLoading} />
<div className="space-y-8">
{SECTION_ORDER.map((source) => (
<ModelSection
key={source}
source={source}
title={t(`models.section.${source}`)}
emptyText={t(`models.section.empty.${source}`)}
models={grouped[source]}
loading={isLoading}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,75 @@
/**
* ActivityTimeline
*
*
* F7/F8 WS activityIcons/activityColors map
* type lookup undefined <Icon /> render undefined crash
* fallbackCircle / text-muted-foreground type activity
* render throw
*
* type
*/
import { render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import {
useActivityStore,
type ActivityEntry,
type ActivityType,
} from "@/stores/activity-store";
import { ActivityTimeline } from "./activity-timeline";
function renderTimeline() {
return render(
<LocaleProvider>
<ActivityTimeline />
</LocaleProvider>,
);
}
beforeEach(() => {
useActivityStore.setState({ activities: [] });
});
afterEach(() => {
useActivityStore.setState({ activities: [] });
});
describe("ActivityTimeline", () => {
it("空狀態:顯示空文案", () => {
expect(() => renderTimeline()).not.toThrow();
expect(screen.getByText(/還沒有任何活動/)).toBeInTheDocument();
});
it("已知 type 正常渲染", () => {
const entry: ActivityEntry = {
id: "a1",
type: "device_paired",
message: "配對成功",
timestamp: Date.now(),
};
useActivityStore.setState({ activities: [entry] });
expect(() => renderTimeline()).not.toThrow();
expect(screen.getByText("配對成功")).toBeInTheDocument();
});
it("未知 type後端推未涵蓋事件→ fallback icon/色,不 crash", () => {
// 模擬 F7/F8 接 WS 後推入 map 尚未涵蓋的 type。
const entry: ActivityEntry = {
id: "a-unknown",
type: "some_future_event" as ActivityType,
message: "未知事件",
timestamp: Date.now(),
};
useActivityStore.setState({ activities: [entry] });
expect(() => renderTimeline()).not.toThrow();
expect(screen.getByText("未知事件")).toBeInTheDocument();
// 列表有渲染fallback icon 沒讓整列消失)
expect(screen.getByTestId("activity-list")).toBeInTheDocument();
});
});

View File

@ -21,6 +21,7 @@ import { useEffect, useState } from "react";
import {
AlertTriangle,
CheckCircle,
Circle,
Link2,
RefreshCw,
Trash2,
@ -104,8 +105,11 @@ export function ActivityTimeline() {
) : (
<ul className="space-y-3" data-testid="activity-list">
{activities.map((activity) => {
const Icon = activityIcons[activity.type];
const color = activityColors[activity.type];
// F7/F8 接 WS 事件後,後端可能推入 activityIcons/activityColors 尚未
// 涵蓋的未知 type → map lookup 回 undefined。fallback 到中性 icon/色,
// 避免 <Icon /> render undefined 而 crash。
const Icon = activityIcons[activity.type] ?? Circle;
const color = activityColors[activity.type] ?? "text-muted-foreground";
return (
<li key={activity.id} className="flex items-start gap-3">
<Icon

View File

@ -0,0 +1,109 @@
/**
* ConnectedDevicesList
*
* React #185 re-render
* zustand v5 selector .filter() reference useSyncExternalStore
* Object.is false re-render filter selector
* render online/offline device render
* Maximum update depth exceededrender throw
*
* Mock
* - next/navigationjsdom app router context<Link>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { useDeviceStore, type DeviceSummary } from "@/stores/device-store";
import { ConnectedDevicesList } from "./connected-devices-list";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
}));
function makeDevice(over: Partial<DeviceSummary>): DeviceSummary {
return {
id: "d1",
name: "Device 1",
type: "kneron",
status: "connected",
remoteStatus: "online",
lastSeenAt: null,
...over,
};
}
function renderList() {
return render(
<LocaleProvider>
<ConnectedDevicesList />
</LocaleProvider>,
);
}
beforeEach(() => {
useDeviceStore.setState({ devices: [] });
});
afterEach(() => {
useDeviceStore.setState({ devices: [] });
});
describe("ConnectedDevicesList", () => {
it("無 online 裝置時顯示空狀態(不無限迴圈)", () => {
useDeviceStore.getState()._setDevices([
makeDevice({ id: "off1", remoteStatus: "offline" }),
]);
// 若 selector 迴圈仍在render 會 throw "Maximum update depth exceeded"。
expect(() => renderList()).not.toThrow();
// 沒有 online 裝置 → 不渲染列表,顯示空狀態文案
expect(screen.queryByTestId("connected-devices-list")).not.toBeInTheDocument();
expect(
screen.getByText(/目前沒有裝置線上/),
).toBeInTheDocument();
});
it("混合 online/offline 裝置:只列 online且不無限迴圈", () => {
useDeviceStore.getState()._setDevices([
makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }),
makeDevice({ id: "off1", name: "Offline B", remoteStatus: "offline" }),
makeDevice({ id: "on2", name: "Online C", remoteStatus: "online" }),
]);
expect(() => renderList()).not.toThrow();
const list = screen.getByTestId("connected-devices-list");
expect(list).toBeInTheDocument();
expect(screen.getByText("Online A")).toBeInTheDocument();
expect(screen.getByText("Online C")).toBeInTheDocument();
expect(screen.queryByText("Offline B")).not.toBeInTheDocument();
});
it("re-renderstate 更新)後 selector 穩定、不爆迴圈", () => {
useDeviceStore.getState()._setDevices([
makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }),
]);
renderList();
expect(screen.getByText("Online A")).toBeInTheDocument();
// 再次更新 store模擬裝置狀態變化→ 不應觸發無限迴圈。
// 外部 store 更新需用 act 包覆讓 React flush re-render。
expect(() => {
act(() => {
useDeviceStore.getState()._setDevices([
makeDevice({ id: "on1", name: "Online A", remoteStatus: "online" }),
makeDevice({ id: "on2", name: "Online D", remoteStatus: "online" }),
]);
});
}).not.toThrow();
expect(screen.getByText("Online D")).toBeInTheDocument();
});
});

View File

@ -26,10 +26,16 @@ import { useDeviceStore } from "@/stores/device-store";
export function ConnectedDevicesList() {
const t = useT();
// 雲端版語意列「線上」裝置remoteStatus=online不是 USB 連接
const devices = useDeviceStore((s) =>
s.devices.filter((d) => d.remoteStatus === "online"),
);
// 雲端版語意列「線上」裝置remoteStatus=online不是 USB 連接。
//
// selector 只取穩定 reference 的 s.devicesfilter 在 component body 做。
// zustand v5 底層用 useSyncExternalStore + Object.is 比較 snapshot
// 已移除 v4 內建的 selector 淺比較;若在 selector 內 .filter() 每次回新陣列
// reference → Object.is 永遠 false → 無限 re-renderReact #185
// 對齊專案其他頁慣例devices/models 頁、activity-timelineselector 回穩定
// reference衍生計算放 render body。
const allDevices = useDeviceStore((s) => s.devices);
const devices = allDevices.filter((d) => d.remoteStatus === "online");
return (
<Card>

View File

@ -161,3 +161,45 @@ describe("ModelCard 下載互動", () => {
expect(btn).toHaveTextContent("下載中");
});
});
describe("ModelCard 來源 Badge 配色B2 三區來源以顏色區分)", () => {
it("preset → chart-1 色", () => {
renderCard({ ...convertedReady, source: "preset" });
const badge = screen.getByTestId("model-source-badge");
expect(badge).toHaveAttribute("data-source", "preset");
expect(badge.className).toContain("text-chart-1");
expect(badge).toHaveTextContent("預設");
});
it("converted → chart-2 色", () => {
renderCard({ ...convertedReady, source: "converted" });
const badge = screen.getByTestId("model-source-badge");
expect(badge).toHaveAttribute("data-source", "converted");
expect(badge.className).toContain("text-chart-2");
expect(badge).toHaveTextContent("已轉檔");
});
it("uploaded → chart-3 色(先前無 badge現補上", () => {
renderCard({ ...convertedReady, source: "uploaded" });
const badge = screen.getByTestId("model-source-badge");
expect(badge).toHaveAttribute("data-source", "uploaded");
expect(badge.className).toContain("text-chart-3");
expect(badge).toHaveTextContent("自行上傳");
});
it("三種來源顏色彼此不同chart-1/2/3", () => {
const colorOf = (source: ModelSummary["source"]) => {
const { unmount } = renderCard({ ...convertedReady, source });
const cls = screen.getByTestId("model-source-badge").className;
const match = cls.match(/text-chart-\d/);
unmount();
return match?.[0];
};
const colors = new Set([
colorOf("preset"),
colorOf("converted"),
colorOf("uploaded"),
]);
expect(colors.size).toBe(3);
});
});

View File

@ -28,6 +28,7 @@ import { cn } from "@/lib/utils";
import {
isModelDownloadable,
useModelStore,
type ModelSource,
type ModelStatus,
type ModelSummary,
} from "@/stores/model-store";
@ -44,6 +45,29 @@ const STATUS_VARIANT: Record<ModelStatus, { variant: "default" | "secondary" | "
rejected: { variant: "destructive", key: "models.status.rejected" },
};
/**
* Badge B2
*
* chart-* tokendesign-review m1 Tailwind
* preset chart-1 / converted chart-2 / uploaded chart-3
* model-section dark mode token
* className tint bg/10 + + /30
*/
const SOURCE_BADGE: Record<ModelSource, { className: string; key: string }> = {
preset: {
className: "border-chart-1/30 bg-chart-1/10 text-chart-1",
key: "models.source.preset",
},
converted: {
className: "border-chart-2/30 bg-chart-2/10 text-chart-2",
key: "models.source.converted",
},
uploaded: {
className: "border-chart-3/30 bg-chart-3/10 text-chart-3",
key: "models.source.uploaded",
},
};
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@ -54,6 +78,7 @@ function formatFileSize(bytes: number): string {
export function ModelCard({ model }: ModelCardProps) {
const t = useT();
const statusMeta = STATUS_VARIANT[model.status];
const sourceMeta = SOURCE_BADGE[model.source];
const downloadModel = useModelStore((s) => s.downloadModel);
// per-card loading只在「下載中的是這張卡」時顯示 spinner。
@ -106,14 +131,14 @@ export function ModelCard({ model }: ModelCardProps) {
<Badge variant="outline" className="text-xs">
{model.targetChip.toUpperCase()}
</Badge>
{model.source === "preset" && (
<Badge variant="secondary" className="text-xs">
{t("models.source.preset")}
</Badge>
)}
{model.source === "converted" && (
<Badge variant="secondary" className="text-xs">
{t("models.source.converted")}
{sourceMeta && (
<Badge
variant="outline"
className={cn("text-xs", sourceMeta.className)}
data-testid="model-source-badge"
data-source={model.source}
>
{t(sourceMeta.key)}
</Badge>
)}
{model.category && (

View File

@ -3,7 +3,8 @@
/**
* ModelFilters
*
* api-spec §4 targetChip + source
* B2 /models source
* targetChip KL520 KL520
* Phase 1 design-review Search
*/
@ -17,11 +18,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useT } from "@/lib/i18n/context";
import type { ModelSource, TargetChip } from "@/stores/model-store";
import type { TargetChip } from "@/stores/model-store";
export interface ModelFilterValue {
targetChip: TargetChip | "all";
source: ModelSource | "all";
}
interface ModelFiltersProps {
@ -60,22 +60,6 @@ export function ModelFilters({ value, onChange }: ModelFiltersProps) {
<SelectItem value="kl730">KL730</SelectItem>
</SelectContent>
</Select>
<Select
value={value.source}
onValueChange={(v) =>
onChange({ ...value, source: v as ModelFilterValue["source"] })
}
>
<SelectTrigger className="h-9 w-40" aria-label={t("models.filters.source")}>
<SelectValue placeholder={t("models.filters.source")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
<SelectItem value="uploaded">{t("models.source.uploaded")}</SelectItem>
<SelectItem value="preset">{t("models.source.preset")}</SelectItem>
<SelectItem value="converted">{t("models.source.converted")}</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@ -23,9 +23,19 @@ interface ModelGridProps {
loading?: boolean;
/** 空狀態按下 CTA 觸發(通常跳到上傳 dialog */
onUploadClick?: () => void;
/**
* models
* EmptyState + CTA EmptyState
*/
emptyText?: string;
}
export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
export function ModelGrid({
models,
loading,
onUploadClick,
emptyText,
}: ModelGridProps) {
const t = useT();
if (loading) {
@ -42,6 +52,17 @@ export function ModelGrid({ models, loading, onUploadClick }: ModelGridProps) {
}
if (models.length === 0) {
// 分區模式:只顯示精簡 inline 空狀態(區標題由外層 ModelSection 負責)。
if (emptyText !== undefined) {
return (
<p
className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm"
data-testid="model-grid-empty"
>
{emptyText}
</p>
);
}
return (
<EmptyState
icon={Boxes}

View File

@ -0,0 +1,76 @@
/**
* ModelSection
*
* B2
* - presetchart-1 / convertedchart-2 / uploadedchart-3
* model-card Badge
* - source fallback crash
*
* Mock
* - next/navigationModelGrid ModelCard <Link>jsdom app router context
*/
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LocaleProvider } from "@/lib/i18n/context";
import { ModelSection } from "./model-section";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
}));
function renderSection(source: string) {
return render(
<LocaleProvider>
<ModelSection
title={`${source}`}
models={[]}
emptyText="(空)"
source={source}
/>
</LocaleProvider>,
);
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("ModelSection 區標題色點", () => {
it("preset → chart-1 色點", () => {
renderSection("preset");
expect(screen.getByTestId("model-section-dot").className).toContain(
"bg-chart-1",
);
});
it("converted → chart-2 色點", () => {
renderSection("converted");
expect(screen.getByTestId("model-section-dot").className).toContain(
"bg-chart-2",
);
});
it("uploaded → chart-3 色點", () => {
renderSection("uploaded");
expect(screen.getByTestId("model-section-dot").className).toContain(
"bg-chart-3",
);
});
it("未知 source → fallback 中性色,不 crash", () => {
expect(() => renderSection("mystery")).not.toThrow();
expect(screen.getByTestId("model-section-dot").className).toContain(
"bg-border",
);
});
});

View File

@ -0,0 +1,65 @@
"use client";
/**
* ModelSection
*
* B2feedback /models model
* (preset) (converted) (uploaded)
*
* = + source ModelGrid使 model使
* + emptyText ModelGrid
* loading ModelGrid skeleton
*/
import { ModelGrid } from "@/components/models/model-grid";
import type { ModelSummary } from "@/stores/model-store";
/**
* B2 model-card Badge
*
* chart-* tokendesign-review m1
* preset chart-1 / converted chart-2 / uploaded chart-3
* source fallback border crash
*/
const SOURCE_DOT_CLASS: Record<string, string> = {
preset: "bg-chart-1",
converted: "bg-chart-2",
uploaded: "bg-chart-3",
};
interface ModelSectionProps {
/** 區標題(已翻譯文字) */
title: string;
/** 此區的 model已依 source 分組 + 套用晶片篩選) */
models: ModelSummary[];
/** 區內無 model 時顯示的精簡空狀態文案(已翻譯) */
emptyText: string;
loading?: boolean;
/** 給測試 / DOM 定位用(如 "preset" / "converted" / "uploaded" */
source: string;
}
export function ModelSection({
title,
models,
emptyText,
loading,
source,
}: ModelSectionProps) {
return (
<section className="space-y-3" data-testid="model-section" data-source={source}>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
data-testid="model-section-dot"
className={`size-2.5 shrink-0 rounded-full ${
SOURCE_DOT_CLASS[source] ?? "bg-border"
}`}
/>
<h2 className="text-lg font-semibold">{title}</h2>
<span className="text-muted-foreground text-sm">({models.length})</span>
</div>
<ModelGrid models={models} loading={loading} emptyText={emptyText} />
</section>
);
}

View File

@ -159,6 +159,19 @@ export const en: Dictionary = {
"devices.status.error": "Error",
"devices.status.disconnected": "Disconnected",
// ── Devices: remove (unpair) ──
"devices.remove.action": "Remove device",
"devices.remove.removing": "Removing…",
"devices.remove.confirm.title": "Remove this device?",
"devices.remove.confirm.description":
"This unpairs “{name}” from your account and revokes its access. To use it again, you'll need to pair it from local agent. This cannot be undone.",
"devices.remove.confirm.action": "Remove",
"devices.remove.toast.success": "Device removed",
"devices.remove.error.title": "Couldn't remove device",
"devices.remove.error.FORBIDDEN": "You don't have permission to remove this device.",
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
"devices.remove.error.unknown": "Something went wrong. Please try again.",
// ── Remote Device Badge ──
"remote.status.online": "Online",
"remote.status.offline": "Offline",
@ -184,12 +197,17 @@ export const en: Dictionary = {
"models.source.converted": "Converted",
"models.filters.label": "Model filters",
"models.filters.hardware": "Hardware",
"models.filters.source": "Source",
"models.filters.all": "All",
"models.empty.title": "No models yet",
"models.empty.description":
"Upload your first .nef model to deploy it to any paired Kneron device.",
"models.empty.action": "Upload your first model",
"models.section.preset": "Preset models",
"models.section.converted": "Converted by you",
"models.section.uploaded": "Uploaded by you",
"models.section.empty.preset": "No preset models available.",
"models.section.empty.converted": "You haven't converted any models yet.",
"models.section.empty.uploaded": "You haven't uploaded any models yet.",
"models.detail.description": "Description",
"models.detail.version": "Version",
"models.detail.checksum": "Checksum",

View File

@ -160,6 +160,19 @@ export const zhHant: Dictionary = {
"devices.status.error": "錯誤",
"devices.status.disconnected": "未連接",
// ── Devices: 移除裝置unpair ──
"devices.remove.action": "移除裝置",
"devices.remove.removing": "移除中…",
"devices.remove.confirm.title": "確定要移除此裝置?",
"devices.remove.confirm.description":
"這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。",
"devices.remove.confirm.action": "移除",
"devices.remove.toast.success": "已移除裝置",
"devices.remove.error.title": "移除裝置失敗",
"devices.remove.error.FORBIDDEN": "你沒有權限移除此裝置",
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
"devices.remove.error.unknown": "發生錯誤,請稍後再試",
// ── Remote Device Badge雲端 tunnel 狀態) ──
"remote.status.online": "在線",
"remote.status.offline": "離線",
@ -185,12 +198,17 @@ export const zhHant: Dictionary = {
"models.source.converted": "已轉檔",
"models.filters.label": "模型篩選",
"models.filters.hardware": "硬體",
"models.filters.source": "來源",
"models.filters.all": "全部",
"models.empty.title": "還沒有任何模型",
"models.empty.description":
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
"models.empty.action": "上傳第一個模型",
"models.section.preset": "預設模型",
"models.section.converted": "我轉檔的",
"models.section.uploaded": "我上傳的",
"models.section.empty.preset": "目前沒有可用的預設模型",
"models.section.empty.converted": "你還沒有轉檔過任何模型",
"models.section.empty.uploaded": "你還沒有上傳過任何模型",
"models.detail.description": "說明",
"models.detail.version": "版本",
"models.detail.checksum": "校驗碼",

View File

@ -22,6 +22,7 @@ beforeEach(() => {
isLoading: false,
connectingId: null,
disconnectingId: null,
unpairingId: null,
error: null,
});
// OF2api.ts 不再需要 token gettercookie session 由瀏覽器自動帶)
@ -163,3 +164,97 @@ describe("useDeviceStore", () => {
expect(err.code).toBe("INTERNAL_ERROR");
});
});
describe("useDeviceStore.unpairDevice", () => {
const summary = {
id: "dev-1",
name: "KL520",
type: "kl520",
status: "connected" as const,
remoteStatus: "online" as const,
};
it("成功時打對 endpoint、回 { ok: true }、從 list 移除該裝置且清空 unpairingId", async () => {
useDeviceStore.setState({
devices: [summary, { ...summary, id: "dev-2" }],
selectedDevice: { ...summary },
});
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } }),
);
const result = await useDeviceStore.getState().unpairDevice("dev-1");
expect(result).toEqual({ ok: true });
// 打到 POST /api/devices/dev-1/unpair
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
expect(calledUrl).toContain("/api/devices/dev-1/unpair");
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
const state = useDeviceStore.getState();
expect(state.devices.map((d) => d.id)).toEqual(["dev-2"]);
// selectedDevice 是被移除的那台 → 清為 null
expect(state.selectedDevice).toBeNull();
expect(state.unpairingId).toBeNull();
});
it("成功移除非當前 selected 的裝置 → 不動 selectedDevice", async () => {
useDeviceStore.setState({
devices: [summary, { ...summary, id: "dev-2" }],
selectedDevice: { ...summary, id: "dev-2" },
});
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } }),
);
await useDeviceStore.getState().unpairDevice("dev-1");
expect(useDeviceStore.getState().selectedDevice?.id).toBe("dev-2");
});
it("呼叫期間 unpairingId 設為該 idloading 態)", async () => {
let observedDuringCall: string | null = "not-set";
vi.spyOn(globalThis, "fetch").mockImplementationOnce(async () => {
// fetch 進行中時 store 應已標記 unpairingId
observedDuringCall = useDeviceStore.getState().unpairingId;
return jsonResponse({ success: true, data: { id: "dev-1", unpaired: true } });
});
await useDeviceStore.getState().unpairDevice("dev-1");
expect(observedDuringCall).toBe("dev-1");
expect(useDeviceStore.getState().unpairingId).toBeNull();
});
it("403 FORBIDDEN非 owner→ 回 { ok:false, code:'FORBIDDEN' },不移除 list清空 unpairingId", async () => {
useDeviceStore.setState({ devices: [summary] });
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
403,
),
);
const result = await useDeviceStore.getState().unpairDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
// 失敗時 list 不變
expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]);
expect(useDeviceStore.getState().unpairingId).toBeNull();
expect(useDeviceStore.getState().error).toBe("not owner");
});
it("404 NOT_FOUND裝置已刪→ 回 { ok:false, code:'NOT_FOUND' }", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse(
{ success: false, error: { code: "NOT_FOUND", message: "device not found" } },
404,
),
);
const result = await useDeviceStore.getState().unpairDevice("dev-1");
expect(result).toMatchObject({ ok: false, code: "NOT_FOUND" });
expect(useDeviceStore.getState().unpairingId).toBeNull();
});
});

View File

@ -109,6 +109,18 @@ function normalizeDevice(raw: unknown): Device {
/* Store */
/* -------------------------------------------------------------------------- */
/**
* unpairaction / i18n code UI toast
*
* model-store deleteModel boolean unpair UI
* 403/ 404/ code boolean
* code ApiError.code backend api-spec §11 UI `devices.remove.error.*`
* i18n key 退 unknown model download toast
*/
export type UnpairResult =
| { ok: true }
| { ok: false; code: string; message: string };
interface DeviceState {
devices: DeviceSummary[];
selectedDevice: Device | null;
@ -116,6 +128,8 @@ interface DeviceState {
/** 連線中的裝置 idUI 顯示 button spinner不使用就是 null */
connectingId: string | null;
disconnectingId: string | null;
/** 移除unpair中的裝置 idUI 顯示 button spinner / disable 確認鈕);不使用就是 null */
unpairingId: string | null;
error: string | null;
/** 呼叫 `GET /api/devices` */
@ -126,6 +140,8 @@ interface DeviceState {
connectDevice: (id: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:id/disconnect` */
disconnectDevice: (id: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token */
unpairDevice: (id: string) => Promise<UnpairResult>;
/** 測試 / 雛形用:直接塞 list */
_setDevices: (devices: DeviceSummary[]) => void;
/** 測試 / 雛形用:直接塞 selected */
@ -138,6 +154,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
isLoading: false,
connectingId: null,
disconnectingId: null,
unpairingId: null,
error: null,
fetchDevices: async () => {
@ -199,6 +216,30 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
}
},
unpairDevice: async (id) => {
set({ unpairingId: id, error: null });
try {
// backend 回 envelope { success, data: { id, unpaired: true } }api.post 已 unwrap data。
// 這裡不需用回傳值(成功與否由有無 throw 判定),故不取 response。
await api.post(`/api/devices/${encodeURIComponent(id)}/unpair`);
// 成功後從本地 list 移除該裝置(避免 refetch 延遲);若 selected 是它也清掉。
set((state) => ({
devices: state.devices.filter((d) => d.id !== id),
selectedDevice:
state.selectedDevice?.id === id ? null : state.selectedDevice,
unpairingId: null,
}));
return { ok: true };
} catch (err) {
// ApiError 帶 backend error codeFORBIDDEN / NOT_FOUND / …)給 UI 分流 toast
// 其他例外(網路層)退化成 unknown。
const message = err instanceof Error ? err.message : String(err);
const code = err instanceof ApiError ? err.code : "unknown";
set({ unpairingId: null, error: message });
return { ok: false, code, message };
}
},
_setDevices: (devices) => set({ devices }),
_setSelected: (selectedDevice) => set({ selectedDevice }),
}));