visionA/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx
jim800121chen 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

307 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* 裝置詳情 Client — /devices/[id]
*
* 對齊 pages.md §7 + flow-offline-handling.md §5。
*
* F6 範圍:
* - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 操作按鈕
* - 離線 bannerremoteStatus=offline 時顯示)
* - 裝置資訊 + 模型狀態 兩欄 Card
* - 離線降級:燒錄 / 工作區按鈕 disabled
*
* F6 不做(保留 stub 或隱藏):
* - FlashDialog雲端版 flash 走 tunnel forwardF8 補完整流程)
* - DeviceHealthCard / DeviceConnectionLogPhase 1 完整化)
* - DeviceSettingsCardalias / notes — Phase 1 接後端)
*/
import { useEffect } from "react";
import Link from "next/link";
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,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
interface DeviceDetailClientProps {
id: string;
}
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">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-48 rounded-lg" />
<Skeleton className="h-48 rounded-lg" />
</div>
</div>
);
}
// 404 / 未載入雛形selectedDevice 為 null 時顯示占位)
if (!selectedDevice) {
return (
<div className="mx-auto max-w-5xl space-y-4 px-6 py-8">
<Link href="/devices">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground text-sm">
{t("common.loading")}
</p>
</CardContent>
</Card>
</div>
);
}
const displayName = selectedDevice.alias || selectedDevice.name;
const isOnline = selectedDevice.remoteStatus === "online";
const isOffline = selectedDevice.remoteStatus === "offline";
return (
<div className="mx-auto max-w-5xl space-y-6 px-6 py-8">
<Link href="/devices">
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("common.back")}
</Button>
</Link>
{/* 離線 bannerflow-offline-handling §5.1
F6 Review Minor #1F7 修):改走 --warning tokenglobals.css §1.3 新增),
與 PrototypeBanner / 其他警示 UI 使用相同色系,避免散落 amber-* 硬編碼。 */}
{isOffline && (
<div
role="alert"
className="bg-warning-subtle border-warning text-warning-foreground flex items-start gap-3 rounded-lg border p-4"
data-testid="device-offline-banner"
>
<AlertTriangle aria-hidden="true" className="text-warning mt-0.5 size-5 shrink-0" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">
{t("devices.detail.offlineBanner.title")}
</p>
<p className="text-xs opacity-90">
{t("devices.detail.offlineBanner.description")}
</p>
</div>
</div>
)}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold">{displayName}</h1>
{selectedDevice.alias && (
<p className="text-muted-foreground text-sm">{selectedDevice.name}</p>
)}
<RemoteDeviceBadge
status={selectedDevice.remoteStatus}
lastSeenAt={selectedDevice.lastSeenAt ?? null}
errorMessage={selectedDevice.errorMessage ?? null}
/>
</div>
<div className="flex items-center gap-2">
{isOnline && selectedDevice.flashedModel && (
<Link href={`/workspace/${selectedDevice.id}`}>
<Button>{t("devices.openWorkspace")}</Button>
</Link>
)}
{!isOnline && (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button disabled>{t("devices.openWorkspace")}</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{t("devices.detail.offlineBanner.title")}
</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>
<Separator />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("devices.detail.deviceInfo")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow label={t("devices.detail.id")} value={<span className="font-mono text-sm">{selectedDevice.id}</span>} />
<InfoRow label={t("devices.detail.type")} value={selectedDevice.type || "—"} />
<InfoRow
label={t("devices.detail.firmware")}
value={selectedDevice.firmwareVersion || t("common.na")}
/>
{selectedDevice.hostName && (
<InfoRow label={t("devices.detail.hostName")} value={selectedDevice.hostName} />
)}
{selectedDevice.pairedAt && (
<InfoRow
label={t("devices.detail.pairedAt")}
value={new Date(selectedDevice.pairedAt).toLocaleString()}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("devices.detail.modelStatus")}
</CardTitle>
</CardHeader>
<CardContent>
{selectedDevice.flashedModel ? (
<div className="space-y-3">
<InfoRow
label={t("devices.flashedModel")}
value={<span className="font-medium">{selectedDevice.flashedModel}</span>}
/>
<p className="text-muted-foreground text-sm">
{t("devices.detail.readyForInference")}
</p>
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("devices.detail.noModelFlashed")}
</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="text-right">{value}</span>
</div>
);
}