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>
This commit is contained in:
jim800121chen 2026-07-01 00:17:37 +08:00
parent 0901ffafda
commit 6de7c1b4a3
4 changed files with 394 additions and 2 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

@ -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 }),
}));