diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx
new file mode 100644
index 0000000..7c261f6
--- /dev/null
+++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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("移除中");
+ });
+});
diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx
index d042b2e..7e2ba15 100644
--- a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx
+++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx
@@ -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 (
@@ -128,7 +160,7 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
errorMessage={selectedDevice.errorMessage ?? null}
/>
-
+
{isOnline && selectedDevice.flashedModel && (
@@ -146,6 +178,58 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
)}
+
+ {/* 移除裝置(破壞性操作):destructive 樣式 + 二次確認對話框。
+ 對齊 model 詳細頁刪除 UX(AlertDialog / loading 態 / 成功導頁 / 失敗 toast)。
+ 不論裝置在線與否都可移除——移除是帳號層級的解除配對、與即時連線無關。 */}
+
+
+
+
+
+
+
+ {t("devices.remove.confirm.title")}
+
+
+ {t("devices.remove.confirm.description").replace(
+ "{name}",
+ displayName,
+ )}
+
+
+
+
+ {t("common.cancel")}
+
+
+ {t("devices.remove.confirm.action")}
+
+
+
+
diff --git a/visionA-frontend/src/stores/device-store.test.ts b/visionA-frontend/src/stores/device-store.test.ts
index 4af72fd..4496b7d 100644
--- a/visionA-frontend/src/stores/device-store.test.ts
+++ b/visionA-frontend/src/stores/device-store.test.ts
@@ -22,6 +22,7 @@ beforeEach(() => {
isLoading: false,
connectingId: null,
disconnectingId: null,
+ unpairingId: null,
error: null,
});
// OF2:api.ts 不再需要 token getter(cookie 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 設為該 id(loading 態)", 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();
+ });
+});
diff --git a/visionA-frontend/src/stores/device-store.ts b/visionA-frontend/src/stores/device-store.ts
index 841fa54..5d2c380 100644
--- a/visionA-frontend/src/stores/device-store.ts
+++ b/visionA-frontend/src/stores/device-store.ts
@@ -109,6 +109,18 @@ function normalizeDevice(raw: unknown): Device {
/* Store */
/* -------------------------------------------------------------------------- */
+/**
+ * unpair(移除裝置)action 的回傳:成功 / 失敗(帶 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 {
/** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */
connectingId: string | null;
disconnectingId: string | null;
+ /** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
+ unpairingId: string | null;
error: string | null;
/** 呼叫 `GET /api/devices` */
@@ -126,6 +140,8 @@ interface DeviceState {
connectDevice: (id: string) => Promise;
/** 呼叫 `POST /api/devices/:id/disconnect` */
disconnectDevice: (id: string) => Promise;
+ /** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
+ unpairDevice: (id: string) => Promise;
/** 測試 / 雛形用:直接塞 list */
_setDevices: (devices: DeviceSummary[]) => void;
/** 測試 / 雛形用:直接塞 selected */
@@ -138,6 +154,7 @@ export const useDeviceStore = create()((set) => ({
isLoading: false,
connectingId: null,
disconnectingId: null,
+ unpairingId: null,
error: null,
fetchDevices: async () => {
@@ -199,6 +216,30 @@ export const useDeviceStore = create()((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 code(FORBIDDEN / 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 }),
}));