jim800121chen 12f5bf6c47 feat(frontend): WP-C serial 路由落地(ADR-018 FE-A 混合模型)
- DeviceSummary 加 serialNumber(serial_number/serialNumber 雙 key 容錯、
  缺欄/空字串 → null)
- 五個 proxy 操作識別值換 serial:connect/disconnect、flash POST+進度 WS
  (單一派生點保 room key 一致)、inference WS、camera start/stop+WS、
  media 三 upload
- serial 為空全面 disable + 提示(workspace banner/media 占位/flash-dialog/
  device-card tooltip/device-detail 兩鈕+序號 InfoRow/選擇頁);i18n 兩語系
- UUID 組維持:fetchDevices/fetchDevice/unpair/詳情頁路由
- C5 偏差(路由段維持 UUID):與 FE-A 定案一致、reviewer 獨立驗證成立
  (API 層無 GetBySerial、帶 serial deep-link 必 404);文件回填另派
- evidence:tsc 0 / eslint 0 / next build 15 route / 觸及 11 測試檔 99 passed
- review:通過 0C/0M/3Mi/2Sug(.autoflow/05-implementation/review/
  wp-c-frontend-serial-routing-review.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:58:52 +08:00

426 lines
18 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 — /workspace/[deviceId]
*
* 對齊 pages.md §8.4、flow-offline-handling.md §6。
*
* 塊 1 範圍Camera MJPEG 串流顯示 + 開始/停止狀態機):
* - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 開始/停止推論按鈕
* - TabsCamera / Image / Video / BatchCamera 已接真串流,其餘塊 3 補)
* - Camera tab 內CameraFeed 顯示 MJPEG streamstart 成功後)
* - start/stop 契約修正:改打 `/api/camera/start` + `/api/camera/stop`(見評估 §3.1 R-C1
* - 裝置掉線remoteStatus != online顯示全頁遮罩 + 返回按鈕;掉線時自動清串流
*
* 塊 1 不做(後續塊補):
* - InferencePanel 的 classification result / performance metrics塊 2 接 WS
* - canvas overlay 邊界框繪製(塊 2
* - Image / Video / Batch tab 上傳與顯示(塊 3
* - Camera 來源選擇SourceSelector
*
* 重要:雲端版 Workspace 對「裝置離線」極敏感 —
* 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩並停止串流。
*
* ADR-018 serial 路由WP-C
* - 路由到 local agent 的操作camera start/stop、media upload、inference WS
* 識別值一律帶 `device.serialNumber`kn_number不是雲端 UUID。
* - 路由段 `[deviceId]` 仍是雲端 UUIDfetchDevice 是純 DB 操作、FE-A 混合模型),
* serial 從 fetch 回來的 selectedDevice 取。
* - serial 為空(舊資料未回報 / 假序號)→ 無法路由:顯示提示 banner、
* 「開始推論」disable、media tab 顯示提示不給上傳。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowLeft, WifiOff } from "lucide-react";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
import { CameraFeed } from "@/components/workspace/camera-feed";
import { CameraOverlay } from "@/components/workspace/camera-overlay";
import { InferencePanel } from "@/components/workspace/inference-panel";
import { MediaTab } from "@/components/workspace/media-tab";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useInferenceStream } from "@/hooks/use-inference-stream";
import { api, ApiError } from "@/lib/api";
import { buildStreamUrl } from "@/lib/camera";
import {
IMAGE_ACCEPT,
VIDEO_ACCEPT,
uploadBatchImages,
uploadImage,
uploadVideo,
validateBatchFiles,
validateImageFile,
validateVideoFile,
} from "@/lib/media";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
import { useInferenceStore } from "@/stores/inference-store";
import type { MediaUploadResponse } from "@/types/camera";
import { toast } from "sonner";
/** 影片上傳 timeout 拉長(大檔經 tunnel評估 R-M20 = 不限。 */
const VIDEO_UPLOAD_TIMEOUT_MS = 0;
interface WorkspaceClientProps {
deviceId: string;
}
export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
const t = useT();
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
const isLoading = useDeviceStore((s) => s.isLoading);
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
const [isRunning, setIsRunning] = useState(false);
const [busy, setBusy] = useState(false);
// 目前選中的 tabcamera / image / video / batch
// 用於:離開 camera tab 時停掉 camera 串流 + WS避免與 media tab 的 WS 訂閱同時寫入同一個 store。
const [activeTab, setActiveTab] = useState("camera");
// 塊 1Camera MJPEG 串流 URLstart 成功後由後端回傳的 streamUrl 組出)
const [streamUrl, setStreamUrl] = useState("");
// 塊 2CameraFeed `<img>` 實際 render 尺寸(供 overlay 換算 normalized bbox → px
// review M1初值為 null尚未從 ResizeObserver 拿到真實顯示尺寸。overlay 在拿到
// 真實尺寸前不繪製避免用寫死猜測640×480換算 → MJPEG 非 4:3 時 bbox 框錯位。
const [feedSize, setFeedSize] = useState<{ w: number; h: number } | null>(null);
const resetInference = useInferenceStore((s) => s.reset);
// handleStop 最新引用handleTabChange 用;見下方 handleStop 定義後賦值)
const handleStopRef = useRef<() => Promise<void>>(async () => {});
useEffect(() => {
if (deviceId) void fetchDevice(deviceId);
}, [deviceId, fetchDevice]);
/** 停止串流的共用清理stop / unmount 都用)。 */
const clearStream = useCallback(() => {
setStreamUrl("");
setIsRunning(false);
setFeedSize(null); // 重置尺寸,下次 start 重新等 ResizeObserver 回報真實值
resetInference();
}, [resetInference]);
const handleDimensionsChange = useCallback((w: number, h: number) => {
setFeedSize({ w, h });
}, []);
// 塊 2裝置線上性hooks 必須無條件呼叫,故在此處先算,早退前)
const isOnline = selectedDevice?.remoteStatus === "online";
// WP-CADR-018路由到 local agent 的操作一律用 serial 當識別值。
// serial 為空(舊資料 / 未回報序號)→ 操作全部 disable無法路由
const serialNumber = selectedDevice?.serialNumber ?? null;
const hasSerial = !!serialNumber;
// 塊 3切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store
const handleTabChange = useCallback(
(value: string) => {
setActiveTab(value);
if (value !== "camera" && isRunning) {
void handleStopRef.current();
}
},
[isRunning],
);
// 塊 2/3訂閱 camera WS `inference:<serial>`(只在 camera tab + 推論中 + 線上 + 有 serial 時連線)。
// media tab 有自己的 useInferenceStream在 MediaTab 內),兩者靠 activeTab 互斥。
// WP-CWS 識別值用 serial與 camera start 的 body deviceId 一致room key 才對得上)。
useInferenceStream(
serialNumber ?? "",
activeTab === "camera" && isRunning && isOnline && hasSerial,
);
// 塊 2最新結果的偵測框供 overlay 繪製)
const liveResult = useInferenceStore((s) => s.result);
const confidenceThreshold = useInferenceStore((s) => s.confidenceThreshold);
async function handleStart() {
// WP-Cserial 為空無法路由(按鈕已 disable此為防禦性 guard
if (!serialNumber) return;
setBusy(true);
try {
// 契約修正(評估 §3.1 R-C1camera pipeline 入口為 POST /api/camera/start
// body 帶 deviceId後端回傳 { streamUrl, sourceType }camera_handler.go:119-125
// WP-CADR-018deviceId 欄位帶 serial欄位名不動、值換 serialNumber
const data = await api.post<MediaUploadResponse>("/api/camera/start", {
deviceId: serialNumber,
});
const url = data?.streamUrl ?? "/api/camera/stream";
// cache-bust每次 start 換一個值,避免瀏覽器重用已結束的 MJPEG 連線
setStreamUrl(buildStreamUrl(url, `${serialNumber}-${Date.now()}`));
setIsRunning(true);
} catch (err) {
// 後端尚未接上 proxy 時可能 501不當致命錯誤僅提示
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
toast.info(t("workspace.camera.startNotWired"));
} else {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
setBusy(false);
}
}
async function handleStop() {
setBusy(true);
try {
// WP-CADR-018與 start 一致deviceId 欄位帶 serial。
// serial 為空時不可能已 starthandleStart 有 guard直接清本地串流即可。
if (serialNumber) {
await api.post("/api/camera/stop", { deviceId: serialNumber });
}
} catch (err) {
if (!(err instanceof ApiError && err.code === "NOT_IMPLEMENTED")) {
toast.error(err instanceof Error ? err.message : String(err));
}
} finally {
clearStream();
setBusy(false);
}
}
// handleTabChange 需要在切離 camera tab 時呼叫 handleStop用 ref 保持最新引用,
// 避免 handleTabChange 的 deps 依賴每次 render 重建的 handleStop。
// 於 effect 內同步 ref不在 render 期寫 ref符合 react-hooks/refs 規則)。
useEffect(() => {
handleStopRef.current = handleStop;
});
if (isLoading && !selectedDevice) {
return (
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-[60vh] rounded-lg" />
</div>
);
}
// 裝置尚未載入雛形selectedDevice 可能為 null— 簡易占位
const device = selectedDevice;
// 裝置掉線時不顯示串流(雲端版對離線極敏感;避免 <img> 卡在已死的 MJPEG 連線)。
// 用 render 期衍生而非 effect + setState避免 cascading render。
const effectiveStreamUrl = isOnline ? streamUrl : "";
// WP-Cmedia tab 在 serial 為空時的占位提示(無法上傳——上傳的識別值就是 serial
const noSerialNotice = (
<Card className="min-h-[60vh]">
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
<div className="max-w-md space-y-2 text-center" data-testid="media-no-serial">
<p className="text-sm font-medium">{t("workspace.noSerial.title")}</p>
<p className="text-muted-foreground text-sm">
{t("workspace.noSerial.description")}
</p>
</div>
</CardContent>
</Card>
);
return (
<div className="mx-auto max-w-7xl space-y-4 px-6 py-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Link href={device ? `/devices/${device.id}` : "/devices"}>
<Button variant="ghost" size="sm">
<ArrowLeft aria-hidden="true" className="mr-2 size-4" />
{t("workspace.header.backToDevices")}
</Button>
</Link>
<h1 className="text-xl font-bold">
{t("workspace.header.title")} · {device?.alias || device?.name || deviceId}
</h1>
{device && (
<RemoteDeviceBadge
status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null}
size="sm"
/>
)}
</div>
<div className="flex gap-2">
{isRunning ? (
<Button variant="destructive" onClick={handleStop} disabled={busy}>
{t("workspace.inference.stop")}
</Button>
) : (
// WP-Cserial 為空無法路由 → disable原因見下方 no-serial banner
<Button onClick={handleStart} disabled={busy || !isOnline || !hasSerial}>
{t("workspace.inference.start")}
</Button>
)}
</div>
</div>
{/* WP-CADR-018serial 為空 → 無法路由到 local agent說明原因與解法 */}
{device && !hasSerial && (
<div
role="alert"
className="bg-warning-subtle border-warning text-warning-foreground flex items-start gap-3 rounded-lg border p-4"
data-testid="workspace-no-serial-banner"
>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">{t("workspace.noSerial.title")}</p>
<p className="text-xs opacity-90">
{t("workspace.noSerial.description")}
</p>
</div>
</div>
)}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList>
<TabsTrigger value="camera">{t("workspace.tabs.camera")}</TabsTrigger>
<TabsTrigger value="image">{t("workspace.tabs.image")}</TabsTrigger>
<TabsTrigger value="video">{t("workspace.tabs.video")}</TabsTrigger>
<TabsTrigger value="batch">{t("workspace.tabs.batch")}</TabsTrigger>
</TabsList>
<TabsContent value="camera" className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
<Card className="min-h-[60vh]">
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
{effectiveStreamUrl ? (
<CameraFeed
streamUrl={effectiveStreamUrl}
sourceType="camera"
width={640}
onDimensionsChange={handleDimensionsChange}
overlay={
// M1feedSize 未就緒ResizeObserver 尚未回報真實顯示尺寸)前不繪製 overlay
// 避免用寫死猜測換算 bbox → 框錯位。
feedSize ? (
<CameraOverlay
detections={liveResult?.detections ?? []}
width={feedSize.w}
height={feedSize.h}
confidenceThreshold={confidenceThreshold}
/>
) : undefined
}
/>
) : (
<div className="text-center">
<p className="text-muted-foreground text-sm">
{t("workspace.camera.idleHint")}
</p>
</div>
)}
</CardContent>
</Card>
<Card className="min-h-[60vh]">
<CardHeader>
<CardTitle className="text-base">
{t("workspace.inference.panelTitle")}
</CardTitle>
</CardHeader>
<CardContent>
{/* 塊 2接 WS `inference:<deviceId>` → 效能指標 + 結果清單 */}
<InferencePanel isRunning={isRunning && isOnline} />
</CardContent>
</Card>
</div>
</TabsContent>
{/* 塊 3圖片 / 影片 / 批次 — 共用 MediaTab上傳 → CameraFeed + overlay + panel
WP-CADR-018上傳識別值帶 serialserial 為空 → 顯示提示、不給上傳。 */}
<TabsContent value="image" className="space-y-4">
{!hasSerial ? (
noSerialNotice
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="image"
accept={IMAGE_ACCEPT}
validate={(files) =>
files[0] ? validateImageFile(files[0]) : { code: "EMPTY" }
}
upload={async (files, ctx) => {
return uploadImage(serialNumber!, files[0]!, {
onProgress: ctx.onProgress,
signal: ctx.signal,
});
}}
/>
)}
</TabsContent>
<TabsContent value="video" className="space-y-4">
{!hasSerial ? (
noSerialNotice
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="video"
accept={VIDEO_ACCEPT}
validate={(files) =>
files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" }
}
upload={async (files, ctx) => {
return uploadVideo(serialNumber!, files[0]!, {
onProgress: ctx.onProgress,
signal: ctx.signal,
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
});
}}
/>
)}
</TabsContent>
<TabsContent value="batch" className="space-y-4">
{!hasSerial ? (
noSerialNotice
) : (
<MediaTab
deviceId={serialNumber!}
isOnline={!!isOnline}
sourceType="batch_image"
accept={IMAGE_ACCEPT}
multiple
validate={validateBatchFiles}
upload={async (files, ctx) => {
return uploadBatchImages(serialNumber!, files, {
onProgress: ctx.onProgress,
signal: ctx.signal,
});
}}
/>
)}
</TabsContent>
</Tabs>
{/* 裝置掉線遮罩flow-offline-handling §6.2 */}
{device && !isOnline && (
<div
role="alertdialog"
aria-labelledby="workspace-offline-title"
className="bg-background/80 fixed inset-0 z-50 grid place-items-center backdrop-blur-sm"
data-testid="workspace-offline-overlay"
>
<Card className="max-w-md">
<CardContent className="space-y-4 py-8 text-center">
<WifiOff
aria-hidden="true"
className="text-muted-foreground mx-auto size-12"
/>
<h2 id="workspace-offline-title" className="text-xl font-semibold">
{t("workspace.offline.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("workspace.offline.description").replace(
"{deviceName}",
device.alias || device.name,
)}
</p>
<Link href="/devices">
<Button>{t("workspace.offline.backToList")}</Button>
</Link>
</CardContent>
</Card>
</div>
)}
</div>
);
}