"use client"; /** * 推論工作區 Client — /workspace/[deviceId] * * 對齊 pages.md §8.4、flow-offline-handling.md §6。 * * 塊 1 範圍(Camera MJPEG 串流顯示 + 開始/停止狀態機): * - 頂部:返回 + 裝置名稱 + RemoteDeviceBadge + 開始/停止推論按鈕 * - Tabs:Camera / Image / Video / Batch(Camera 已接真串流,其餘塊 3 補) * - Camera tab 內:CameraFeed 顯示 MJPEG stream(start 成功後) * - 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]` 仍是雲端 UUID(fetchDevice 是純 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-M2);0 = 不限。 */ 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); // 目前選中的 tab(camera / image / video / batch)。 // 用於:離開 camera tab 時停掉 camera 串流 + WS,避免與 media tab 的 WS 訂閱同時寫入同一個 store。 const [activeTab, setActiveTab] = useState("camera"); // 塊 1:Camera MJPEG 串流 URL(start 成功後由後端回傳的 streamUrl 組出) const [streamUrl, setStreamUrl] = useState(""); // 塊 2:CameraFeed `` 實際 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>(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-C(ADR-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:`(只在 camera tab + 推論中 + 線上 + 有 serial 時連線)。 // media tab 有自己的 useInferenceStream(在 MediaTab 內),兩者靠 activeTab 互斥。 // WP-C:WS 識別值用 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-C:serial 為空無法路由(按鈕已 disable;此為防禦性 guard)。 if (!serialNumber) return; setBusy(true); try { // 契約修正(評估 §3.1 R-C1):camera pipeline 入口為 POST /api/camera/start // body 帶 deviceId;後端回傳 { streamUrl, sourceType }(camera_handler.go:119-125) // WP-C(ADR-018):deviceId 欄位帶 serial(欄位名不動、值換 serialNumber)。 const data = await api.post("/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-C(ADR-018):與 start 一致,deviceId 欄位帶 serial。 // serial 為空時不可能已 start(handleStart 有 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 (
); } // 裝置尚未載入(雛形:selectedDevice 可能為 null)— 簡易占位 const device = selectedDevice; // 裝置掉線時不顯示串流(雲端版對離線極敏感;避免 卡在已死的 MJPEG 連線)。 // 用 render 期衍生而非 effect + setState,避免 cascading render。 const effectiveStreamUrl = isOnline ? streamUrl : ""; // WP-C:media tab 在 serial 為空時的占位提示(無法上傳——上傳的識別值就是 serial)。 const noSerialNotice = (

{t("workspace.noSerial.title")}

{t("workspace.noSerial.description")}

); return (

{t("workspace.header.title")} · {device?.alias || device?.name || deviceId}

{device && ( )}
{isRunning ? ( ) : ( // WP-C:serial 為空無法路由 → disable(原因見下方 no-serial banner) )}
{/* WP-C(ADR-018):serial 為空 → 無法路由到 local agent,說明原因與解法 */} {device && !hasSerial && (

{t("workspace.noSerial.title")}

{t("workspace.noSerial.description")}

)} {t("workspace.tabs.camera")} {t("workspace.tabs.image")} {t("workspace.tabs.video")} {t("workspace.tabs.batch")}
{effectiveStreamUrl ? ( ) : undefined } /> ) : (

{t("workspace.camera.idleHint")}

)}
{t("workspace.inference.panelTitle")} {/* 塊 2:接 WS `inference:` → 效能指標 + 結果清單 */}
{/* 塊 3:圖片 / 影片 / 批次 — 共用 MediaTab(上傳 → CameraFeed + overlay + panel) WP-C(ADR-018):上傳識別值帶 serial;serial 為空 → 顯示提示、不給上傳。 */} {!hasSerial ? ( noSerialNotice ) : ( files[0] ? validateImageFile(files[0]) : { code: "EMPTY" } } upload={async (files, ctx) => { return uploadImage(serialNumber!, files[0]!, { onProgress: ctx.onProgress, signal: ctx.signal, }); }} /> )} {!hasSerial ? ( noSerialNotice ) : ( 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, }); }} /> )} {!hasSerial ? ( noSerialNotice ) : ( { return uploadBatchImages(serialNumber!, files, { onProgress: ctx.onProgress, signal: ctx.signal, }); }} /> )}
{/* 裝置掉線遮罩(flow-offline-handling §6.2) */} {device && !isOnline && (
)}
); }