diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx
index 7e2ba15..2820380 100644
--- a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx
+++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx
@@ -11,8 +11,11 @@
* - 裝置資訊 + 模型狀態 兩欄 Card
* - 離線降級:燒錄 / 工作區按鈕 disabled
*
- * F6 不做(保留 stub 或隱藏):
- * - FlashDialog(雲端版 flash 走 tunnel forward;F8 補完整流程)
+ * flash(載入模型到裝置):FlashDialog 已接上(見 flash-model-load-mapping.md)。
+ * 選 model → 相容性檢查 → 觸發 flash(POST /api/devices/:id/flash)→ WS 進度
+ * → 完成後 fetchDevice 刷新 → gate(開啟工作區)自動出現。裝置離線時 disable。
+ *
+ * 尚未做(保留 stub 或隱藏):
* - DeviceHealthCard / DeviceConnectionLog(Phase 1 完整化)
* - DeviceSettingsCard(alias / notes — Phase 1 接後端)
*/
@@ -24,6 +27,7 @@ import { AlertTriangle, ArrowLeft, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
+import { FlashDialog } from "@/components/devices/flash-dialog";
import {
AlertDialog,
AlertDialogAction,
@@ -161,6 +165,11 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
/>
+ {/* flash(載入模型):裝置在線才可 flash;離線時 disable 觸發鈕。
+ flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方
+ 「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */}
+
+
{isOnline && selectedDevice.flashedModel && (
diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx
index cfe167f..6cec548 100644
--- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx
+++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx
@@ -42,8 +42,17 @@ class ControllableRO {
disconnect() {}
}
+import { useInferenceStream } from "@/hooks/use-inference-stream";
+
import { WorkspaceClient } from "./workspace-client";
+/** 取 useInferenceStream 最後一次呼叫的 enabled 參數(第 2 個引數)。 */
+function lastCameraWsEnabled(): boolean | undefined {
+ const mock = vi.mocked(useInferenceStream);
+ const call = mock.mock.calls.at(-1);
+ return call?.[1] as boolean | undefined;
+}
+
function seedOnlineDevice() {
useDeviceStore.setState({
selectedDevice: {
@@ -140,3 +149,68 @@ describe("WorkspaceClient — overlay 尺寸就緒閘門(M1)", () => {
expect(canvas.getAttribute("height")).toBe("360");
});
});
+
+/**
+ * 切換 Radix Tab。Radix Tabs 預設 activationMode="automatic"(focus 即切換),
+ * jsdom 下單純 click 不一定觸發 onValueChange,故同時 fire focus + click 確保切換。
+ */
+async function switchTab(name: string) {
+ const trigger = screen.getByRole("tab", { name });
+ await act(async () => {
+ fireEvent.focus(trigger);
+ fireEvent.click(trigger);
+ });
+}
+
+describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉(S-1,塊3 最高風險路徑)", () => {
+ it("推論中切離 camera tab → 呼叫 /api/camera/stop + camera WS enabled 變 false", async () => {
+ post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" });
+ renderClient();
+
+ // 開始 camera 推論
+ fireEvent.click(screen.getByText("開始推論"));
+ await waitFor(() => {
+ expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument();
+ });
+ // camera WS 此時應為 enabled=true(camera tab + 推論中 + 線上)
+ expect(lastCameraWsEnabled()).toBe(true);
+ // 尚未呼叫 stop
+ expect(post).not.toHaveBeenCalledWith("/api/camera/stop", expect.anything());
+
+ // 切到 image tab
+ await switchTab("圖片");
+
+ // handleTabChange 應自動停掉 camera(POST /api/camera/stop)
+ await waitFor(() => {
+ expect(post).toHaveBeenCalledWith("/api/camera/stop", { deviceId: "dev-1" });
+ });
+ // camera 串流已清(isRunning=false)→ camera 的 useInferenceStream enabled 變 false(不殘留連線)
+ await waitFor(() => {
+ expect(lastCameraWsEnabled()).toBe(false);
+ });
+ // camera feed 已不在畫面(切到 image tab,且串流已清)
+ expect(screen.queryByTestId("camera-feed-img")).not.toBeInTheDocument();
+ });
+
+ it("未推論時切 tab → 不呼叫 stop(無殘留可清)", async () => {
+ renderClient();
+ // 沒按開始,直接切到 video tab
+ await switchTab("影片");
+ expect(post).not.toHaveBeenCalledWith("/api/camera/stop", expect.anything());
+ // camera WS 一直是 disabled
+ expect(lastCameraWsEnabled()).toBe(false);
+ });
+
+ it("切到 image tab 後 camera WS 保持 false(媒體 tab 有自己的 WS,兩者互斥)", async () => {
+ post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" });
+ renderClient();
+ fireEvent.click(screen.getByText("開始推論"));
+ await waitFor(() => expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument());
+
+ await switchTab("圖片");
+ await waitFor(() => expect(lastCameraWsEnabled()).toBe(false));
+
+ // 停留在 image tab,camera WS 不應又被打開
+ expect(lastCameraWsEnabled()).toBe(false);
+ });
+});
diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx
index d14eb62..c71a53f 100644
--- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx
+++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx
@@ -22,7 +22,7 @@
* 任何時刻若收到 remoteStatus != online 都要立刻顯示 offline 遮罩並停止串流。
*/
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { ArrowLeft, WifiOff } from "lucide-react";
@@ -30,6 +30,7 @@ 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";
@@ -37,12 +38,25 @@ 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;
}
@@ -54,6 +68,9 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
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)。
@@ -62,6 +79,8 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
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);
@@ -82,8 +101,20 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
// 塊 2:裝置線上性(hooks 必須無條件呼叫,故在此處先算,早退前)
const isOnline = selectedDevice?.remoteStatus === "online";
- // 塊 2:訂閱 WS `inference:`(只在推論中且線上時連線)
- useInferenceStream(deviceId, isRunning && isOnline);
+ // 塊 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 + 推論中 + 線上時連線)。
+ // media tab 有自己的 useInferenceStream(在 MediaTab 內),兩者靠 activeTab 互斥。
+ useInferenceStream(deviceId, activeTab === "camera" && isRunning && isOnline);
// 塊 2:最新結果的偵測框(供 overlay 繪製)
const liveResult = useInferenceStore((s) => s.result);
@@ -126,6 +157,12 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
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 (
@@ -176,18 +213,12 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
-