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:
parent
0901ffafda
commit
6de7c1b4a3
@ -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("移除中");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -19,13 +19,27 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import Link from "next/link";
|
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 { 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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@ -40,14 +54,32 @@ interface DeviceDetailClientProps {
|
|||||||
|
|
||||||
export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const router = useRouter();
|
||||||
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
|
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
|
||||||
const isLoading = useDeviceStore((s) => s.isLoading);
|
const isLoading = useDeviceStore((s) => s.isLoading);
|
||||||
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
|
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
|
||||||
|
const unpairDevice = useDeviceStore((s) => s.unpairDevice);
|
||||||
|
const isUnpairing = useDeviceStore((s) => s.unpairingId === id);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) void fetchDevice(id);
|
if (id) void fetchDevice(id);
|
||||||
}, [id, fetchDevice]);
|
}, [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) {
|
if (isLoading && !selectedDevice) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-4 px-6 py-8">
|
<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}
|
errorMessage={selectedDevice.errorMessage ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isOnline && selectedDevice.flashedModel && (
|
{isOnline && selectedDevice.flashedModel && (
|
||||||
<Link href={`/workspace/${selectedDevice.id}`}>
|
<Link href={`/workspace/${selectedDevice.id}`}>
|
||||||
<Button>{t("devices.openWorkspace")}</Button>
|
<Button>{t("devices.openWorkspace")}</Button>
|
||||||
@ -146,6 +178,58 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 移除裝置(破壞性操作):destructive 樣式 + 二次確認對話框。
|
||||||
|
對齊 model 詳細頁刪除 UX(AlertDialog / 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ beforeEach(() => {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
connectingId: null,
|
connectingId: null,
|
||||||
disconnectingId: null,
|
disconnectingId: null,
|
||||||
|
unpairingId: null,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
||||||
@ -163,3 +164,97 @@ describe("useDeviceStore", () => {
|
|||||||
expect(err.code).toBe("INTERNAL_ERROR");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -109,6 +109,18 @@ function normalizeDevice(raw: unknown): Device {
|
|||||||
/* Store */
|
/* 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 {
|
interface DeviceState {
|
||||||
devices: DeviceSummary[];
|
devices: DeviceSummary[];
|
||||||
selectedDevice: Device | null;
|
selectedDevice: Device | null;
|
||||||
@ -116,6 +128,8 @@ interface DeviceState {
|
|||||||
/** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
/** 連線中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
||||||
connectingId: string | null;
|
connectingId: string | null;
|
||||||
disconnectingId: string | null;
|
disconnectingId: string | null;
|
||||||
|
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
||||||
|
unpairingId: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
/** 呼叫 `GET /api/devices` */
|
/** 呼叫 `GET /api/devices` */
|
||||||
@ -126,6 +140,8 @@ interface DeviceState {
|
|||||||
connectDevice: (id: string) => Promise<boolean>;
|
connectDevice: (id: string) => Promise<boolean>;
|
||||||
/** 呼叫 `POST /api/devices/:id/disconnect` */
|
/** 呼叫 `POST /api/devices/:id/disconnect` */
|
||||||
disconnectDevice: (id: string) => Promise<boolean>;
|
disconnectDevice: (id: string) => Promise<boolean>;
|
||||||
|
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
||||||
|
unpairDevice: (id: string) => Promise<UnpairResult>;
|
||||||
/** 測試 / 雛形用:直接塞 list */
|
/** 測試 / 雛形用:直接塞 list */
|
||||||
_setDevices: (devices: DeviceSummary[]) => void;
|
_setDevices: (devices: DeviceSummary[]) => void;
|
||||||
/** 測試 / 雛形用:直接塞 selected */
|
/** 測試 / 雛形用:直接塞 selected */
|
||||||
@ -138,6 +154,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
connectingId: null,
|
connectingId: null,
|
||||||
disconnectingId: null,
|
disconnectingId: null,
|
||||||
|
unpairingId: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchDevices: async () => {
|
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 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 }),
|
_setDevices: (devices) => set({ devices }),
|
||||||
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user