- 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>
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 裝置詳情 Client — /devices/[id]
|
||
*
|
||
* 對齊 pages.md §7 + flow-offline-handling.md §5。
|
||
*
|
||
* F6 範圍:
|
||
* - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 操作按鈕
|
||
* - 離線 banner(remoteStatus=offline 時顯示)
|
||
* - 裝置資訊 + 模型狀態 兩欄 Card
|
||
* - 離線降級:燒錄 / 工作區按鈕 disabled
|
||
*
|
||
* F6 不做(保留 stub 或隱藏):
|
||
* - FlashDialog(雲端版 flash 走 tunnel forward;F8 補完整流程)
|
||
* - DeviceHealthCard / DeviceConnectionLog(Phase 1 完整化)
|
||
* - DeviceSettingsCard(alias / 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>
|
||
|
||
{/* 離線 banner(flow-offline-handling §5.1)
|
||
F6 Review Minor #1(F7 修):改走 --warning token(globals.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 詳細頁刪除 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>
|
||
|
||
<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>
|
||
);
|
||
}
|