Compare commits
2 Commits
0010cc35c3
...
051994ed54
| Author | SHA1 | Date | |
|---|---|---|---|
| 051994ed54 | |||
| 81a32a5bf8 |
@ -292,6 +292,45 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# /ws/* → api-server :3721 (推論 inference WS + 既有 pairing/events/system WS)
|
||||
#
|
||||
# ⚠️ 為什麼必須有這條 location:
|
||||
# 下方 catch-all `location /` 反代到 Next.js frontend (:3000)。若沒有這條,
|
||||
# /ws/devices/:id/inference 等所有 /ws/* 會落到 frontend → Next 沒有此 route
|
||||
# → 404,前端推論頁的 WS 握手斷掉。必須在 catch-all 之前用前綴 location 攔下,
|
||||
# 導到 api-server (:3721),該處掛 wsAuthGroup(/ws/devices/:id/inference 需 auth)
|
||||
# 與 registerWebSocketStubs(其餘 /ws/* 目前 501 stub)。
|
||||
#
|
||||
# 與 /tunnel/connect 的關係:兩者是不同 path 前綴(/ws/ vs /tunnel/connect),
|
||||
# nginx 前綴 location 各自最長匹配、互不 shadow。/ws/ 不會吃到 /tunnel/connect。
|
||||
#
|
||||
# WS upgrade 三要素 + 長 timeout + buffering off,比照 /tunnel/connect。
|
||||
# $connection_upgrade 繼承自 http-context map(檔頭 38-41 行)。
|
||||
# cookie 由 proxy_pass 預設透傳(inference WS 走 cookie/session auth)。
|
||||
# ============================================================
|
||||
location /ws/ {
|
||||
proxy_pass http://visiona_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket upgrade headers
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# 推論 WS 為 long-lived 串流;拉長 timeout(比照 /tunnel/connect)
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
|
||||
# 不 buffer,避免延遲 WS 訊框
|
||||
proxy_buffering off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Next.js hashed static assets — 永久 cache
|
||||
# /_next/static/{hash}.js 等
|
||||
@ -480,6 +519,29 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
}
|
||||
|
||||
# ── /ws/* → api-server :3721(推論 inference WS + 其餘 /ws/* stub)──
|
||||
# 完整比照 stage-9527 block 的 /ws/ 設定:http/1.1 + Upgrade + Connection
|
||||
# + 86400s timeout + buffering off。$connection_upgrade 繼承自 http-context map。
|
||||
# 不會 shadow 上面的 /tunnel/connect(不同 path 前綴)。
|
||||
location /ws/ {
|
||||
proxy_pass http://visiona_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
|
||||
proxy_buffering off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto http;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
|
||||
# ── Next.js hashed static(內網瀏覽器用)──
|
||||
location /_next/static/ {
|
||||
proxy_pass http://visiona_frontend;
|
||||
|
||||
@ -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) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* flash(載入模型):裝置在線才可 flash;離線時 disable 觸發鈕。
|
||||
flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方
|
||||
「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */}
|
||||
<FlashDialog deviceId={selectedDevice.id} disabled={!isOnline} />
|
||||
|
||||
{isOnline && selectedDevice.flashedModel && (
|
||||
<Link href={`/workspace/${selectedDevice.id}`}>
|
||||
<Button>{t("devices.openWorkspace")}</Button>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 `<img>` 實際 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<void>>(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:<deviceId>`(只在推論中且線上時連線)
|
||||
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:<deviceId>`(只在 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) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="camera" className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="camera">{t("workspace.tabs.camera")}</TabsTrigger>
|
||||
<TabsTrigger value="image" disabled>
|
||||
{t("workspace.tabs.image")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="video" disabled>
|
||||
{t("workspace.tabs.video")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="batch" disabled>
|
||||
{t("workspace.tabs.batch")}
|
||||
</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]">
|
||||
@ -234,6 +265,61 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 塊 3:圖片 / 影片 / 批次 — 共用 MediaTab(上傳 → CameraFeed + overlay + panel) */}
|
||||
<TabsContent value="image" className="space-y-4">
|
||||
<MediaTab
|
||||
deviceId={deviceId}
|
||||
isOnline={!!isOnline}
|
||||
sourceType="image"
|
||||
accept={IMAGE_ACCEPT}
|
||||
validate={(files) =>
|
||||
files[0] ? validateImageFile(files[0]) : { code: "EMPTY" }
|
||||
}
|
||||
upload={async (files, ctx) => {
|
||||
return uploadImage(deviceId, files[0]!, {
|
||||
onProgress: ctx.onProgress,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="video" className="space-y-4">
|
||||
<MediaTab
|
||||
deviceId={deviceId}
|
||||
isOnline={!!isOnline}
|
||||
sourceType="video"
|
||||
accept={VIDEO_ACCEPT}
|
||||
validate={(files) =>
|
||||
files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" }
|
||||
}
|
||||
upload={async (files, ctx) => {
|
||||
return uploadVideo(deviceId, files[0]!, {
|
||||
onProgress: ctx.onProgress,
|
||||
signal: ctx.signal,
|
||||
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="batch" className="space-y-4">
|
||||
<MediaTab
|
||||
deviceId={deviceId}
|
||||
isOnline={!!isOnline}
|
||||
sourceType="batch_image"
|
||||
accept={IMAGE_ACCEPT}
|
||||
multiple
|
||||
validate={validateBatchFiles}
|
||||
upload={async (files, ctx) => {
|
||||
return uploadBatchImages(deviceId, files, {
|
||||
onProgress: ctx.onProgress,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 裝置掉線遮罩(flow-offline-handling §6.2) */}
|
||||
|
||||
125
visionA-frontend/src/components/devices/flash-dialog.test.tsx
Normal file
125
visionA-frontend/src/components/devices/flash-dialog.test.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* FlashDialog 互動測試(flash UI)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 觸發鈕:disabled prop 生效(離線)
|
||||
* - 開 dialog → fetchModels 被呼叫、reset flash 態
|
||||
* - flash 進行中(isFlashing)→ 顯示進度區、不允許關閉
|
||||
* - flash 完成(percent>=100)→ 顯示「完成」按鈕;點完成 → fetchDevice(gate 解鎖)+ reset
|
||||
* - flash 失敗(error)→ 顯示錯誤 + 「關閉」按鈕;點關閉 → 不 fetchDevice
|
||||
*
|
||||
* Mock:
|
||||
* - useFlashProgress → 攔截 beginFlash / stop(不真的連 WS)
|
||||
* - model-store fetchModels / device-store fetchDevice → vi.fn 觀察呼叫
|
||||
* - Radix Select 的 model 選取在 jsdom 難穩定驅動 → 相容性 / 選取邏輯由 hardware-compat.test
|
||||
* 與 flash-store.test 覆蓋;本檔聚焦 dialog 狀態機與 gate 接線。
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useDeviceStore, type Device } from "@/stores/device-store";
|
||||
import { useFlashStore } from "@/stores/flash-store";
|
||||
import { useModelStore } from "@/stores/model-store";
|
||||
|
||||
const mockBeginFlash = vi.fn();
|
||||
const mockStop = vi.fn();
|
||||
vi.mock("@/hooks/use-flash-progress", () => ({
|
||||
useFlashProgress: () => ({
|
||||
beginFlash: mockBeginFlash,
|
||||
stop: mockStop,
|
||||
isActive: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FlashDialog } from "./flash-dialog";
|
||||
|
||||
const device: Device = {
|
||||
id: "dev-1",
|
||||
name: "Cam A",
|
||||
type: "kneron_kl520",
|
||||
status: "connected",
|
||||
remoteStatus: "online",
|
||||
flashedModel: null,
|
||||
};
|
||||
|
||||
function renderDialog(props?: { disabled?: boolean }) {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<FlashDialog deviceId="dev-1" disabled={props?.disabled} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function resetStores() {
|
||||
useFlashStore.setState({ isFlashing: false, progress: null, error: null, lastFlashParams: null });
|
||||
useModelStore.setState({ models: [], selectedModel: null, isLoading: false, downloadingId: null });
|
||||
useDeviceStore.setState({ selectedDevice: device });
|
||||
}
|
||||
|
||||
describe("FlashDialog", () => {
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
mockBeginFlash.mockReset();
|
||||
mockStop.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("觸發鈕 disabled prop 生效(離線)", () => {
|
||||
renderDialog({ disabled: true });
|
||||
expect(screen.getByTestId("flash-model-trigger")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("開 dialog → fetchModels 被呼叫", () => {
|
||||
const fetchModels = vi.fn();
|
||||
useModelStore.setState({ fetchModels });
|
||||
renderDialog();
|
||||
fireEvent.click(screen.getByTestId("flash-model-trigger"));
|
||||
expect(fetchModels).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flash 進行中 → 顯示進度區", () => {
|
||||
renderDialog();
|
||||
fireEvent.click(screen.getByTestId("flash-model-trigger"));
|
||||
// 直接把 store 推到 flashing + 有進度(act 包裹讓 React flush)
|
||||
act(() => {
|
||||
useFlashStore.setState({ isFlashing: true, progress: { percent: 42, stage: "loading" } });
|
||||
});
|
||||
expect(screen.getByTestId("flash-progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flash 完成 → 顯示完成按鈕,點完成 fetchDevice(gate 解鎖)+ stop", () => {
|
||||
const fetchDevice = vi.fn();
|
||||
useDeviceStore.setState({ fetchDevice });
|
||||
renderDialog();
|
||||
fireEvent.click(screen.getByTestId("flash-model-trigger"));
|
||||
act(() => {
|
||||
useFlashStore.setState({ isFlashing: false, progress: { percent: 100, stage: "done" } });
|
||||
});
|
||||
|
||||
const doneBtn = screen.getByTestId("flash-done-btn");
|
||||
expect(doneBtn).toBeInTheDocument();
|
||||
fireEvent.click(doneBtn);
|
||||
expect(fetchDevice).toHaveBeenCalledWith("dev-1");
|
||||
expect(mockStop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flash 失敗 → 顯示錯誤 + 關閉按鈕;點關閉不 fetchDevice", () => {
|
||||
const fetchDevice = vi.fn();
|
||||
useDeviceStore.setState({ fetchDevice });
|
||||
renderDialog();
|
||||
fireEvent.click(screen.getByTestId("flash-model-trigger"));
|
||||
act(() => {
|
||||
useFlashStore.setState({ isFlashing: false, error: "device busy" });
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("flash-error")).toBeInTheDocument();
|
||||
expect(screen.getByText("device busy")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("flash-done-btn"));
|
||||
// error 態關閉不刷 device(避免覆蓋錯誤狀態)
|
||||
expect(fetchDevice).not.toHaveBeenCalled();
|
||||
expect(mockStop).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
215
visionA-frontend/src/components/devices/flash-dialog.tsx
Normal file
215
visionA-frontend/src/components/devices/flash-dialog.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FlashDialog — 選模型 + 相容性檢查 + 觸發 flash + 進度顯示 + gate 解鎖
|
||||
*
|
||||
* 移植自 POC edge-ai-platform/frontend/src/components/devices/flash-dialog.tsx
|
||||
* (唯讀參考、非搬 code,見 flash-model-load-mapping.md §2.1 F-5)。
|
||||
*
|
||||
* 三個必須對齊的契約差異(§2.3):
|
||||
* A. api client throw-based:由 flash-store startFlash try/catch 處理(本檔不直接碰 api)。
|
||||
* B. WS 順序保證(最關鍵):改用 useFlashProgress 的 `beginFlash(modelId)`——
|
||||
* 它設 enabled=true,並在 WS `onOpen` 才 POST flash(先開 WS 再 POST,無 race)。
|
||||
* POC 的 `await connectAndWait(); startFlash()` 在此拆成單一 `beginFlash()`。
|
||||
* C. 相容性:visionA model 是 `targetChip` 單值,用 hardware-compat 新簽章
|
||||
* `isModelCompatible(model.targetChip, device.type)`(寧鬆勿嚴,權威在 agent)。
|
||||
*
|
||||
* gate 解鎖(§2.4):flash 完成後「完成」按鈕呼叫 `fetchDevice(deviceId)` 刷新 selectedDevice
|
||||
* → local agent 已設 FlashedModel → device.flashedModel 有值 → device-detail line 164
|
||||
* 的「開啟工作區」按鈕自動出現。**無需改 gate 邏輯本身**。
|
||||
*
|
||||
* model 來源(§2.1):`useModelStore.models`(`GET /api/models`);開 dialog 時 fetchModels。
|
||||
* 模型庫的 model 欄位是 ModelSummary(含 targetChip / name / id)。
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
import { FlashProgress } from "@/components/devices/flash-progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useFlashProgress } from "@/hooks/use-flash-progress";
|
||||
import { getHardwareLabel, isModelCompatible } from "@/lib/hardware-compat";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useDeviceStore } from "@/stores/device-store";
|
||||
import { useFlashStore } from "@/stores/flash-store";
|
||||
import { useModelStore } from "@/stores/model-store";
|
||||
|
||||
interface FlashDialogProps {
|
||||
deviceId: string;
|
||||
/** 裝置離線時不可 flash,disable 觸發鈕(呼叫端傳入)。 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FlashDialog({ deviceId, disabled }: FlashDialogProps) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedModelId, setSelectedModelId] = useState("");
|
||||
|
||||
const models = useModelStore((s) => s.models);
|
||||
const fetchModels = useModelStore((s) => s.fetchModels);
|
||||
|
||||
const isFlashing = useFlashStore((s) => s.isFlashing);
|
||||
const progress = useFlashStore((s) => s.progress);
|
||||
const error = useFlashStore((s) => s.error);
|
||||
const retryFlash = useFlashStore((s) => s.retryFlash);
|
||||
const reset = useFlashStore((s) => s.reset);
|
||||
|
||||
const selectedDevice = useDeviceStore((s) => s.selectedDevice);
|
||||
const fetchDevice = useDeviceStore((s) => s.fetchDevice);
|
||||
|
||||
const { beginFlash, stop } = useFlashProgress(deviceId);
|
||||
|
||||
// device 從 selectedDevice 取(detail 頁已 fetch);防呆用 id 比對。
|
||||
const device = selectedDevice?.id === deviceId ? selectedDevice : null;
|
||||
const selectedModel = models.find((m) => m.id === selectedModelId);
|
||||
|
||||
const compatible = useMemo(() => {
|
||||
if (!selectedModel || !device) return true;
|
||||
return isModelCompatible(selectedModel.targetChip, device.type);
|
||||
}, [selectedModel, device]);
|
||||
|
||||
// 開 / 關 dialog 的副作用集中在 onOpenChange 事件處理(非 effect),避免 effect 內
|
||||
// 同步 setState 觸發 cascading render(react-hooks/set-state-in-effect)。
|
||||
const handleOpenChange = (v: boolean) => {
|
||||
// flash 進行中(未完成、未錯誤)不允許關閉,避免中斷 WS 漏進度。
|
||||
if (!v && isFlashing && !error) return;
|
||||
if (v) {
|
||||
void fetchModels();
|
||||
reset();
|
||||
setSelectedModelId("");
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
setOpen(v);
|
||||
};
|
||||
|
||||
const started = isFlashing || progress !== null || error !== null;
|
||||
const done = (progress && progress.percent >= 100) || error !== null;
|
||||
|
||||
const handleFlash = () => {
|
||||
if (!selectedModelId || !compatible) return;
|
||||
// 契約差異 B:只呼叫 beginFlash(設 enabled + onOpen 觸發 POST),不在此直接 POST。
|
||||
beginFlash(selectedModelId);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// flash 成功(非 error)→ 刷新 device 讓 gate(開啟工作區)出現(§2.4)。
|
||||
if (!error) void fetchDevice(deviceId);
|
||||
stop();
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={disabled} data-testid="flash-model-trigger">
|
||||
{t("devices.flash.flashModel")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("devices.flash.flashToDevice")}</DialogTitle>
|
||||
<DialogDescription>{t("devices.flash.dialogDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!started ? (
|
||||
<>
|
||||
<Select value={selectedModelId} onValueChange={setSelectedModelId}>
|
||||
<SelectTrigger data-testid="flash-model-select">
|
||||
<SelectValue placeholder={t("devices.flash.selectModel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||
{t("devices.flash.noModels")}
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
{m.targetChip !== "unknown" && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
{m.targetChip.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedModelId && !compatible && (
|
||||
<div
|
||||
role="alert"
|
||||
className="border-warning bg-warning-subtle rounded-md border p-3"
|
||||
data-testid="flash-incompatible"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className="text-warning mt-0.5 size-5 shrink-0"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<p className="text-warning-foreground font-medium">
|
||||
{t("devices.flash.hardwareIncompatible")}
|
||||
</p>
|
||||
<p className="text-warning-foreground/90">
|
||||
{t("devices.flash.incompatibleDesc").replace(
|
||||
"{device}",
|
||||
device ? getHardwareLabel(device.type) : t("devices.flash.thisDevice"),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleFlash}
|
||||
disabled={!selectedModelId || !compatible}
|
||||
className="w-full"
|
||||
data-testid="flash-start-btn"
|
||||
>
|
||||
{!selectedModelId
|
||||
? t("devices.flash.selectModel")
|
||||
: !compatible
|
||||
? t("devices.flash.incompatibleCannotFlash")
|
||||
: t("devices.flash.startFlash")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<FlashProgress progress={progress} error={error} onRetry={retryFlash} />
|
||||
)}
|
||||
|
||||
{done && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleClose}
|
||||
data-testid="flash-done-btn"
|
||||
>
|
||||
{error ? t("common.close") : t("common.done")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
93
visionA-frontend/src/components/devices/flash-progress.tsx
Normal file
93
visionA-frontend/src/components/devices/flash-progress.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FlashProgress — flash 進度顯示(進行中 / 完成 / 錯誤)
|
||||
*
|
||||
* 移植自 POC edge-ai-platform/frontend/src/components/devices/flash-progress.tsx
|
||||
* (近乎純移植,見 flash-model-load-mapping.md §2.2)。
|
||||
*
|
||||
* 與 POC 的差異(visionA 慣例):
|
||||
* - i18n:`useT()`(單一 key、無插值),非 POC 的 `useTranslation()`。
|
||||
* - 設計 token:不用裸色(bg-red-50 / text-yellow-600 …),改用 visionA design tokens
|
||||
* (--destructive / text-primary / text-muted-foreground),對齊 device-detail-client 慣例。
|
||||
* - 型別:`FlashProgress` 從 flash-store import(非 POC 的 types/device)。
|
||||
*
|
||||
* 無障礙:進度區 `role="status"` + `aria-live="polite"`,讓螢幕閱讀器播報進度變化;
|
||||
* 錯誤區 `role="alert"` 立即播報。Progress 元件本身(Radix)已帶 progressbar role。
|
||||
*/
|
||||
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { FlashProgress as FlashProgressType } from "@/stores/flash-store";
|
||||
|
||||
interface FlashProgressProps {
|
||||
progress: FlashProgressType | null;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function FlashProgress({ progress, error, onRetry }: FlashProgressProps) {
|
||||
const t = useT();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
role="alert"
|
||||
className="border-destructive/40 bg-destructive/10 rounded-md border p-4"
|
||||
data-testid="flash-error"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<XCircle aria-hidden="true" className="text-destructive mt-0.5 size-5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-destructive font-medium">
|
||||
{t("devices.flash.flashFailed")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} className="w-full" variant="outline">
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="space-y-2 text-center" role="status" aria-live="polite">
|
||||
<div className="text-muted-foreground animate-pulse text-sm">
|
||||
{t("devices.flash.preparingFlash")}
|
||||
</div>
|
||||
<Progress value={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isComplete = progress.percent >= 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" role="status" aria-live="polite" data-testid="flash-progress">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">{progress.stage}</span>
|
||||
<span className="text-muted-foreground">{progress.percent}%</span>
|
||||
</div>
|
||||
<Progress value={progress.percent} />
|
||||
{progress.message && (
|
||||
<p className="text-muted-foreground text-sm">{progress.message}</p>
|
||||
)}
|
||||
{isComplete && (
|
||||
<p className="text-primary flex items-center gap-1.5 text-sm font-medium">
|
||||
<CheckCircle2 aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t("devices.flash.flashComplete")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
visionA-frontend/src/components/workspace/media-tab.test.tsx
Normal file
208
visionA-frontend/src/components/workspace/media-tab.test.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* MediaTab 單元測試(塊 3)
|
||||
*
|
||||
* 驗證:
|
||||
* - 初始顯示 uploader(未上傳)
|
||||
* - 上傳成功 → 切到 CameraFeed(img)+ 面板
|
||||
* - 離線(isOnline=false)→ 不顯示串流,顯示 uploader
|
||||
* - video:store 有 frameIndex/totalFrames → 顯示進度 + seek slider
|
||||
* - batch:store 有 imageIndex/totalImages → 顯示批次進度
|
||||
* - feedSize 未就緒不繪 overlay(沿用塊 2 M1 教訓;overlay canvas 需 ResizeObserver 回報後才掛)
|
||||
*
|
||||
* 策略:mock useInferenceStream(避免真連 WS)、直接操作 inference-store 模擬 WS 結果。
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useInferenceStore } from "@/stores/inference-store";
|
||||
import type { MediaUploadResponse } from "@/types/camera";
|
||||
|
||||
vi.mock("@/hooks/use-inference-stream", () => ({
|
||||
useInferenceStream: vi.fn(),
|
||||
}));
|
||||
const seekVideoMock = vi.fn().mockResolvedValue({ seekTo: 0, frameOffset: 0 });
|
||||
vi.mock("@/lib/media", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/media")>();
|
||||
return { ...actual, seekVideo: (...args: unknown[]) => seekVideoMock(...args) };
|
||||
});
|
||||
|
||||
import { MediaTab } from "./media-tab";
|
||||
|
||||
beforeAll(() => {
|
||||
if (!("ResizeObserver" in globalThis)) {
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
});
|
||||
|
||||
function resetStore() {
|
||||
useInferenceStore.setState({
|
||||
result: null,
|
||||
results: [],
|
||||
fps: 0,
|
||||
avgLatency: 0,
|
||||
batchResults: {},
|
||||
confidenceThreshold: 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
function renderTab(
|
||||
props: Partial<React.ComponentProps<typeof MediaTab>> = {},
|
||||
) {
|
||||
const upload = (props.upload ??
|
||||
vi.fn().mockResolvedValue({
|
||||
streamUrl: "/api/camera/stream",
|
||||
sourceType: "image",
|
||||
} as MediaUploadResponse)) as React.ComponentProps<typeof MediaTab>["upload"];
|
||||
render(
|
||||
<LocaleProvider>
|
||||
<MediaTab
|
||||
deviceId="dev-1"
|
||||
isOnline
|
||||
sourceType="image"
|
||||
accept=".jpg,.png"
|
||||
validate={() => null}
|
||||
upload={upload}
|
||||
{...props}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
return { upload };
|
||||
}
|
||||
|
||||
function selectFiles(files: File[]) {
|
||||
const input = document.querySelector("input[type=file]") as HTMLInputElement;
|
||||
Object.defineProperty(input, "files", { value: files, configurable: true });
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
describe("<MediaTab />", () => {
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
seekVideoMock.mockClear();
|
||||
});
|
||||
|
||||
it("初始顯示 uploader", () => {
|
||||
renderTab();
|
||||
expect(screen.getByTestId("media-uploader-image")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("camera-feed-img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("上傳成功 → 顯示 CameraFeed", async () => {
|
||||
renderTab();
|
||||
selectFiles([new File([new Blob(["x"])], "a.jpg")]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(),
|
||||
);
|
||||
// feedSize 未就緒(ResizeObserver mock 不回報尺寸)→ 不繪 overlay canvas
|
||||
expect(document.querySelector("canvas")).toBeNull();
|
||||
});
|
||||
|
||||
it("離線時不顯示串流(顯示 uploader)", async () => {
|
||||
const { rerender } = render(
|
||||
<LocaleProvider>
|
||||
<MediaTab
|
||||
deviceId="dev-1"
|
||||
isOnline
|
||||
sourceType="image"
|
||||
accept=".jpg,.png"
|
||||
validate={() => null}
|
||||
upload={vi.fn().mockResolvedValue({
|
||||
streamUrl: "/api/camera/stream",
|
||||
sourceType: "image",
|
||||
})}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
selectFiles([new File([new Blob(["x"])], "a.jpg")]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(),
|
||||
);
|
||||
// 切離線 → effectiveStreamUrl="" → 回到 uploader
|
||||
rerender(
|
||||
<LocaleProvider>
|
||||
<MediaTab
|
||||
deviceId="dev-1"
|
||||
isOnline={false}
|
||||
sourceType="image"
|
||||
accept=".jpg,.png"
|
||||
validate={() => null}
|
||||
upload={vi.fn()}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
expect(screen.queryByTestId("camera-feed-img")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("media-uploader-image")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("video:有 frameIndex/totalFrames → 顯示進度 + seek slider", async () => {
|
||||
renderTab({
|
||||
sourceType: "video",
|
||||
accept: ".mp4",
|
||||
upload: vi.fn().mockResolvedValue({
|
||||
streamUrl: "/api/camera/stream",
|
||||
sourceType: "video",
|
||||
totalFrames: 100,
|
||||
}) as never,
|
||||
});
|
||||
selectFiles([new File([new Blob(["x"])], "v.mp4")]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// 模擬 WS 推來一筆 video 結果(含 frame 進度)
|
||||
useInferenceStore.setState({
|
||||
result: {
|
||||
taskType: "detection",
|
||||
timestamp: Date.now(),
|
||||
latencyMs: 10,
|
||||
frameIndex: 30,
|
||||
totalFrames: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("media-video-progress")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText("31 / 100")).toBeInTheDocument();
|
||||
expect(screen.getByRole("slider")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("batch:有 imageIndex/totalImages → 顯示批次進度", async () => {
|
||||
renderTab({
|
||||
sourceType: "batch_image",
|
||||
accept: ".jpg,.png",
|
||||
multiple: true,
|
||||
upload: vi.fn().mockResolvedValue({
|
||||
streamUrl: "/api/camera/stream",
|
||||
sourceType: "batch_image",
|
||||
totalImages: 5,
|
||||
}) as never,
|
||||
});
|
||||
selectFiles([new File([new Blob(["x"])], "a.jpg")]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("camera-feed-img")).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
useInferenceStore.setState({
|
||||
result: {
|
||||
taskType: "classification",
|
||||
timestamp: Date.now(),
|
||||
latencyMs: 10,
|
||||
imageIndex: 2,
|
||||
totalImages: 5,
|
||||
filename: "third.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("media-batch-progress")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText("第 3 / 5 張")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
263
visionA-frontend/src/components/workspace/media-tab.tsx
Normal file
263
visionA-frontend/src/components/workspace/media-tab.tsx
Normal file
@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MediaTab — 圖片 / 影片 / 批次 推論 tab 的共用骨架(塊 3)
|
||||
*
|
||||
* 三種 media 來源共用同一套顯示管線(見評估 §0.1):
|
||||
* 上傳 → 後端回 streamUrl → CameraFeed(MJPEG `<img>`)+ CameraOverlay(canvas bbox)
|
||||
* + InferencePanel(WS 結果)。與 camera(塊 1、2)完全複用,不重造。
|
||||
*
|
||||
* 差異由 props / sourceType 控制:
|
||||
* - image:上傳單張 → 顯示結果,最單純
|
||||
* - video:上傳影片 → 逐 frame 播放;額外顯示進度(frameIndex/totalFrames)+ seek bar
|
||||
* - batch:多檔上傳 → 逐張結果瀏覽;額外顯示縮圖導覽(imageIndex/totalImages)
|
||||
*
|
||||
* WS 訂閱 / overlay / feedSize 管理沿用塊 2 教訓:
|
||||
* - feedSize 初值 null,未就緒不繪 overlay(避免 bbox 框錯位,塊 2 review M1)
|
||||
* - overlay 只在拿到真實顯示尺寸後才掛載
|
||||
* - 切離 tab / 重新上傳 / unmount → reset store + feedSize
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { CameraFeed } from "@/components/workspace/camera-feed";
|
||||
import { CameraOverlay } from "@/components/workspace/camera-overlay";
|
||||
import { InferencePanel } from "@/components/workspace/inference-panel";
|
||||
import { MediaUploader } from "@/components/workspace/media-uploader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useInferenceStream } from "@/hooks/use-inference-stream";
|
||||
import { buildStreamUrl } from "@/lib/camera";
|
||||
import { frameToSeekSeconds, seekVideo } from "@/lib/media";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useInferenceStore } from "@/stores/inference-store";
|
||||
import type {
|
||||
FileValidationError,
|
||||
} from "@/lib/media";
|
||||
import type { MediaUploadResponse, SourceType } from "@/types/camera";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface MediaTabProps {
|
||||
deviceId: string;
|
||||
/** 裝置是否線上(離線時停止串流 + 禁止上傳) */
|
||||
isOnline: boolean;
|
||||
/** 本 tab 的來源類型 */
|
||||
sourceType: Extract<SourceType, "image" | "video" | "batch_image">;
|
||||
/** 上傳 accept 字串 */
|
||||
accept: string;
|
||||
/** 多檔(batch) */
|
||||
multiple?: boolean;
|
||||
/** 前端驗證 */
|
||||
validate: (files: File[]) => FileValidationError | null;
|
||||
/** 上傳動作(回 MediaUploadResponse,含 streamUrl 等) */
|
||||
upload: (
|
||||
files: File[],
|
||||
ctx: { onProgress: (p: number) => void; signal: AbortSignal },
|
||||
) => Promise<MediaUploadResponse>;
|
||||
}
|
||||
|
||||
export function MediaTab({
|
||||
deviceId,
|
||||
isOnline,
|
||||
sourceType,
|
||||
accept,
|
||||
multiple = false,
|
||||
validate,
|
||||
upload,
|
||||
}: MediaTabProps) {
|
||||
const t = useT();
|
||||
const [streamUrl, setStreamUrl] = useState("");
|
||||
const [uploaded, setUploaded] = useState(false);
|
||||
const [feedSize, setFeedSize] = useState<{ w: number; h: number } | null>(null);
|
||||
// video seek bar 拖曳中的暫存 frame(放開才送 seek)
|
||||
const [seeking, setSeeking] = useState<number | null>(null);
|
||||
// 影片總長秒數(upload response 回;用於 frame→秒換算,避免依賴寫死 fps)
|
||||
const [durationSeconds, setDurationSeconds] = useState<number | undefined>(undefined);
|
||||
|
||||
const resetInference = useInferenceStore((s) => s.reset);
|
||||
const liveResult = useInferenceStore((s) => s.result);
|
||||
const confidenceThreshold = useInferenceStore((s) => s.confidenceThreshold);
|
||||
|
||||
// 上傳成功後訂閱 WS(uploaded && online)
|
||||
useInferenceStream(deviceId, uploaded && isOnline);
|
||||
|
||||
/** 清理串流狀態(切離 / 重新上傳 / unmount)。 */
|
||||
const clearStream = useCallback(() => {
|
||||
setStreamUrl("");
|
||||
setUploaded(false);
|
||||
setFeedSize(null);
|
||||
setSeeking(null);
|
||||
setDurationSeconds(undefined);
|
||||
resetInference();
|
||||
}, [resetInference]);
|
||||
|
||||
// unmount(切離 tab)時清理,避免殘留 WS + 死掉的 MJPEG 連線。
|
||||
// 用 ref 保存最新的 clearStream,effect deps 保持空陣列(只在 unmount 跑一次)。
|
||||
const clearRef = useRef(clearStream);
|
||||
useEffect(() => {
|
||||
clearRef.current = clearStream;
|
||||
}, [clearStream]);
|
||||
useEffect(() => {
|
||||
return () => clearRef.current();
|
||||
}, []);
|
||||
|
||||
const handleDimensionsChange = useCallback((w: number, h: number) => {
|
||||
setFeedSize({ w, h });
|
||||
}, []);
|
||||
|
||||
// 裝置掉線時不顯示串流(雲端版對離線敏感;用 render 期衍生而非 effect+setState,避免 cascading render)。
|
||||
// WS 訂閱本身由 `uploaded && isOnline` gate → 掉線時 useInferenceStream 收 enabled=false 自動關閉並 reset store。
|
||||
const effectiveStreamUrl = isOnline ? streamUrl : "";
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (
|
||||
files: File[],
|
||||
ctx: { onProgress: (p: number) => void; signal: AbortSignal },
|
||||
) => {
|
||||
const data = await upload(files, ctx);
|
||||
const url = data?.streamUrl ?? "/api/camera/stream";
|
||||
setStreamUrl(buildStreamUrl(url, `${deviceId}-${sourceType}-${Date.now()}`));
|
||||
// 影片:記住總長秒數(seek frame→秒換算用;缺值時 frameToSeekSeconds 退用 fps 常數)
|
||||
setDurationSeconds(data?.durationSeconds);
|
||||
setUploaded(true);
|
||||
},
|
||||
[upload, deviceId, sourceType],
|
||||
);
|
||||
|
||||
// ── Video seek ──────────────────────────────────────────────────────────
|
||||
const frameIndex = liveResult?.frameIndex;
|
||||
const totalFrames = liveResult?.totalFrames;
|
||||
const hasVideoProgress =
|
||||
sourceType === "video" &&
|
||||
typeof frameIndex === "number" &&
|
||||
typeof totalFrames === "number" &&
|
||||
totalFrames > 0;
|
||||
|
||||
const handleSeekCommit = useCallback(
|
||||
async (frame: number) => {
|
||||
setSeeking(null);
|
||||
if (!totalFrames || totalFrames <= 0) return;
|
||||
// 後端 seek 以秒數為單位(SeekVideo)。frame→秒優先用後端回的 durationSeconds 換算,
|
||||
// 無 duration 時才退用具名 fps 常數(見 media.ts frameToSeekSeconds / VIDEO_FALLBACK_FPS)。
|
||||
const timeSeconds = frameToSeekSeconds(frame, totalFrames, durationSeconds);
|
||||
try {
|
||||
await seekVideo(timeSeconds);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[totalFrames, durationSeconds],
|
||||
);
|
||||
|
||||
// ── Batch navigation ────────────────────────────────────────────────────
|
||||
const imageIndex = liveResult?.imageIndex;
|
||||
const totalImages = liveResult?.totalImages;
|
||||
const hasBatchProgress =
|
||||
sourceType === "batch_image" &&
|
||||
typeof imageIndex === "number" &&
|
||||
typeof totalImages === "number" &&
|
||||
totalImages > 0;
|
||||
|
||||
return (
|
||||
<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 ? (
|
||||
<MediaUploader
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
primaryLabel={t(`workspace.media.${uploaderKey(sourceType)}.primary`)}
|
||||
browseLabel={t("workspace.media.browse")}
|
||||
hint={t(`workspace.media.${uploaderKey(sourceType)}.hint`)}
|
||||
validate={validate}
|
||||
onUpload={handleUpload}
|
||||
data-testid={`media-uploader-${sourceType}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full space-y-3">
|
||||
<CameraFeed
|
||||
streamUrl={effectiveStreamUrl}
|
||||
sourceType={sourceType}
|
||||
width={640}
|
||||
onDimensionsChange={handleDimensionsChange}
|
||||
overlay={
|
||||
feedSize ? (
|
||||
<CameraOverlay
|
||||
detections={liveResult?.detections ?? []}
|
||||
width={feedSize.w}
|
||||
height={feedSize.h}
|
||||
confidenceThreshold={confidenceThreshold}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Video:進度 + seek bar */}
|
||||
{hasVideoProgress && (
|
||||
<div className="space-y-1" data-testid="media-video-progress">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>{t("workspace.media.video.frame")}</span>
|
||||
<span className="font-mono">
|
||||
{(seeking ?? frameIndex) + 1} / {totalFrames}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
aria-label={t("workspace.media.video.seek")}
|
||||
min={0}
|
||||
max={Math.max(0, totalFrames - 1)}
|
||||
step={1}
|
||||
value={[seeking ?? frameIndex ?? 0]}
|
||||
onValueChange={(v) => setSeeking(v[0] ?? 0)}
|
||||
onValueCommit={(v) => void handleSeekCommit(v[0] ?? 0)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch:逐張導覽 */}
|
||||
{hasBatchProgress && (
|
||||
<div
|
||||
className="text-muted-foreground flex items-center justify-between text-xs"
|
||||
data-testid="media-batch-progress"
|
||||
>
|
||||
<span>
|
||||
{liveResult?.filename ?? ""}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{t("workspace.media.batch.progress")
|
||||
.replace("{current}", String((imageIndex ?? 0) + 1))
|
||||
.replace("{total}", String(totalImages))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={clearStream}>
|
||||
{t("workspace.media.uploadAnother")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="min-h-[60vh]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("workspace.inference.panelTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InferencePanel isRunning={uploaded && isOnline} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** sourceType → i18n key 前綴(batch_image 對應 batch)。 */
|
||||
function uploaderKey(
|
||||
sourceType: MediaTabProps["sourceType"],
|
||||
): "image" | "video" | "batch" {
|
||||
return sourceType === "batch_image" ? "batch" : sourceType;
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
/**
|
||||
* MediaUploader 單元測試(塊 3)
|
||||
*
|
||||
* 驗證:
|
||||
* - 初始顯示 dropzone
|
||||
* - 前端驗證失敗(型別)→ 顯示 role=alert 錯誤,不呼叫 onUpload
|
||||
* - 驗證通過 → 呼叫 onUpload;上傳中顯示進度 + 取消
|
||||
* - 取消按鈕 → abort signal 觸發
|
||||
* - 上傳失敗(onUpload reject)→ 顯示錯誤訊息
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { validateImageFile } from "@/lib/media";
|
||||
import type { FileValidationError } from "@/lib/media";
|
||||
|
||||
import { MediaUploader } from "./media-uploader";
|
||||
|
||||
function makeFile(name: string): File {
|
||||
return new File([new Blob(["x"])], name);
|
||||
}
|
||||
|
||||
/** 陣列版驗證(取第一張圖驗)—— 對齊 MediaUploader.validate 的 File[] 簽章。 */
|
||||
function validateFirstImage(files: File[]): FileValidationError | null {
|
||||
return files[0] ? validateImageFile(files[0]) : { code: "EMPTY" };
|
||||
}
|
||||
|
||||
function renderUploader(
|
||||
props: Partial<React.ComponentProps<typeof MediaUploader>> = {},
|
||||
) {
|
||||
const onUpload = props.onUpload ?? vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<LocaleProvider>
|
||||
<MediaUploader
|
||||
accept=".jpg,.png"
|
||||
primaryLabel="drop"
|
||||
browseLabel="browse"
|
||||
hint="hint"
|
||||
validate={validateFirstImage}
|
||||
onUpload={onUpload}
|
||||
{...props}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
return { onUpload };
|
||||
}
|
||||
|
||||
/** 直接觸發 dropzone 內的 file input change(模擬選檔)。 */
|
||||
function selectFiles(files: File[]) {
|
||||
const input = document.querySelector("input[type=file]") as HTMLInputElement;
|
||||
Object.defineProperty(input, "files", {
|
||||
value: files,
|
||||
configurable: true,
|
||||
});
|
||||
fireEvent.change(input);
|
||||
}
|
||||
|
||||
describe("<MediaUploader />", () => {
|
||||
it("初始顯示 dropzone", () => {
|
||||
renderUploader();
|
||||
expect(screen.getByTestId("media-uploader")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("型別驗證失敗 → 顯示錯誤 alert,不呼叫 onUpload", () => {
|
||||
const onUpload = vi.fn().mockResolvedValue(undefined);
|
||||
renderUploader({ onUpload });
|
||||
selectFiles([makeFile("bad.gif")]);
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(onUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("驗證通過 → 呼叫 onUpload 並顯示上傳中 UI", async () => {
|
||||
let resolveUpload: () => void = () => {};
|
||||
const onUpload = vi.fn(
|
||||
() => new Promise<void>((r) => (resolveUpload = r)),
|
||||
);
|
||||
renderUploader({ onUpload });
|
||||
|
||||
selectFiles([makeFile("ok.jpg")]);
|
||||
expect(onUpload).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("media-uploader-uploading")).toBeInTheDocument(),
|
||||
);
|
||||
resolveUpload();
|
||||
});
|
||||
|
||||
it("上傳失敗(reject)→ 顯示錯誤訊息", async () => {
|
||||
const onUpload = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
renderUploader({ onUpload });
|
||||
selectFiles([makeFile("ok.jpg")]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("media-uploader-error")).toHaveTextContent("boom"),
|
||||
);
|
||||
});
|
||||
|
||||
it("取消按鈕 → 觸發 abort", async () => {
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const onUpload = vi.fn(
|
||||
(_files: File[], ctx: { signal: AbortSignal }) => {
|
||||
capturedSignal = ctx.signal;
|
||||
return new Promise<void>(() => {}); // 永不 resolve,停在上傳中
|
||||
},
|
||||
);
|
||||
renderUploader({ onUpload });
|
||||
selectFiles([makeFile("ok.jpg")]);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("media-uploader-uploading")).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByText("取消"));
|
||||
expect(capturedSignal!.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
185
visionA-frontend/src/components/workspace/media-uploader.tsx
Normal file
185
visionA-frontend/src/components/workspace/media-uploader.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MediaUploader — 通用媒體上傳面板(塊 3:圖片 / 影片 / 批次共用)
|
||||
*
|
||||
* 職責:
|
||||
* - 用 FileDropzone 選檔(單檔 / 多檔)
|
||||
* - 前端驗證(型別 / 大小 / 數量)→ 錯誤時顯示可讀訊息(不信任副檔名,也擋大小)
|
||||
* - 呼叫 caller 傳入的 `onUpload(files)`(各 tab 依 sourceType 決定打哪支 API)
|
||||
* - 上傳中顯示進度條 + 取消按鈕;上傳失敗顯示錯誤 + 重試
|
||||
*
|
||||
* 顯示管線由 caller(MediaTab)負責:上傳成功拿到 streamUrl 後接 CameraFeed。
|
||||
* 本元件只負責「把檔案交出去 + 呈現上傳狀態」,不碰串流。
|
||||
*
|
||||
* a11y:dropzone 本身鍵盤可用(見 FileDropzone);進度用 <progress> 語意;
|
||||
* 錯誤訊息 role="alert"、與 dropzone 以 aria-describedby 關聯。
|
||||
*/
|
||||
|
||||
import { useCallback, useId, useRef, useState } from "react";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
|
||||
import { FileDropzone } from "@/app/conversion/components/FileDropzone";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { type FileValidationError } from "@/lib/media";
|
||||
|
||||
export interface MediaUploaderProps {
|
||||
/** `<input accept>`(IMAGE_ACCEPT / VIDEO_ACCEPT) */
|
||||
accept: string;
|
||||
/** 是否多檔(batch=true) */
|
||||
multiple?: boolean;
|
||||
/** 主要提示文字 */
|
||||
primaryLabel: string;
|
||||
/** 「選擇檔案」按鈕文字 */
|
||||
browseLabel: string;
|
||||
/** 大小 / 格式限制 hint */
|
||||
hint: string;
|
||||
/** 前端驗證:回傳 null 表通過,否則回錯誤(型別 / 大小 / 數量) */
|
||||
validate: (files: File[]) => FileValidationError | null;
|
||||
/** 上傳動作(caller 依 sourceType 決定 API);回報進度 + 支援取消 */
|
||||
onUpload: (
|
||||
files: File[],
|
||||
ctx: { onProgress: (p: number) => void; signal: AbortSignal },
|
||||
) => Promise<void>;
|
||||
/** 測試用 testid */
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
type Phase = "idle" | "uploading" | "error";
|
||||
|
||||
export function MediaUploader({
|
||||
accept,
|
||||
multiple = false,
|
||||
primaryLabel,
|
||||
browseLabel,
|
||||
hint,
|
||||
validate,
|
||||
onUpload,
|
||||
"data-testid": dataTestId = "media-uploader",
|
||||
}: MediaUploaderProps) {
|
||||
const t = useT();
|
||||
const errorId = useId();
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [percent, setPercent] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
/** 把 FileValidationError 轉成可讀訊息(i18n)。 */
|
||||
const validationMessage = useCallback(
|
||||
(err: FileValidationError): string => {
|
||||
switch (err.code) {
|
||||
case "TYPE":
|
||||
return t("workspace.media.errorType").replace(
|
||||
"{filename}",
|
||||
err.filename ?? "",
|
||||
);
|
||||
case "SIZE":
|
||||
return t("workspace.media.errorSize").replace(
|
||||
"{filename}",
|
||||
err.filename ?? "",
|
||||
);
|
||||
case "COUNT":
|
||||
return t("workspace.media.errorCount");
|
||||
case "EMPTY":
|
||||
return t("workspace.media.errorEmpty");
|
||||
default:
|
||||
return t("workspace.media.errorGeneric");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(files: File[]) => {
|
||||
const err = validate(files);
|
||||
if (err) {
|
||||
setErrorMsg(validationMessage(err));
|
||||
setPhase("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMsg(null);
|
||||
setPercent(0);
|
||||
setPhase("uploading");
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
void onUpload(files, {
|
||||
onProgress: (p) => setPercent(p),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(() => {
|
||||
// 成功後 caller 會切到顯示畫面(unmount 本元件);保底回 idle
|
||||
setPhase("idle");
|
||||
setPercent(0);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// 使用者主動取消 → 回 idle 不顯示錯誤
|
||||
if (controller.signal.aborted) {
|
||||
setPhase("idle");
|
||||
setPercent(0);
|
||||
return;
|
||||
}
|
||||
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||
setPhase("error");
|
||||
})
|
||||
.finally(() => {
|
||||
abortRef.current = null;
|
||||
});
|
||||
},
|
||||
[onUpload, validate, validationMessage],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
if (phase === "uploading") {
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-md space-y-3"
|
||||
data-testid={`${dataTestId}-uploading`}
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
|
||||
<span>{t("workspace.media.uploading")}</span>
|
||||
</div>
|
||||
<Progress value={percent} aria-label={t("workspace.media.uploading")} />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground font-mono text-xs">{percent}%</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
||||
<X aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("workspace.media.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-3" data-testid={dataTestId}>
|
||||
<FileDropzone
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
primaryLabel={primaryLabel}
|
||||
browseLabel={browseLabel}
|
||||
hint={hint}
|
||||
onSelect={handleSelect}
|
||||
errorId={errorMsg ? errorId : undefined}
|
||||
data-testid={`${dataTestId}-dropzone`}
|
||||
/>
|
||||
{errorMsg && (
|
||||
<p
|
||||
id={errorId}
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-testid={`${dataTestId}-error`}
|
||||
>
|
||||
{errorMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
visionA-frontend/src/hooks/use-flash-progress.test.tsx
Normal file
132
visionA-frontend/src/hooks/use-flash-progress.test.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* useFlashProgress 單元測試(flash UI 契約層,最關鍵的 WS 順序保證)
|
||||
*
|
||||
* 策略:mock `@/hooks/use-websocket`,攔截 (path, options);
|
||||
* - 手動觸發 options.onOpen 模擬 WS open → 斷言此時才 POST(順序保證)
|
||||
* - 手動觸發 options.onMessage 模擬進度推送 → 斷言進 flash-store
|
||||
* - 重連(onOpen 再次觸發)→ 斷言不重複 POST(hasStartedRef)
|
||||
*
|
||||
* 驗證重點(契約差異 B / 選項 2):
|
||||
* - beginFlash 只設 enabled=true,POST 延到 onOpen 才送(先開 WS 再 POST)
|
||||
* - 重連時的 onOpen 不重複 POST
|
||||
* - path 帶 encode 過的 deviceId
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// 攔截 useWebSocket:存下最後一次的 (path, options),測試手動觸發 onOpen/onMessage。
|
||||
let lastCall: {
|
||||
path: string;
|
||||
options: {
|
||||
enabled?: boolean;
|
||||
onOpen?: () => void;
|
||||
onMessage: (d: unknown) => void;
|
||||
};
|
||||
} | null = null;
|
||||
|
||||
vi.mock("@/hooks/use-websocket", () => ({
|
||||
useWebSocket: (
|
||||
path: string,
|
||||
options: {
|
||||
enabled?: boolean;
|
||||
onOpen?: () => void;
|
||||
onMessage: (d: unknown) => void;
|
||||
},
|
||||
) => {
|
||||
lastCall = { path, options };
|
||||
return { send: vi.fn(), close: vi.fn() };
|
||||
},
|
||||
}));
|
||||
|
||||
// mock flash-store 的 action,斷言呼叫;updateProgress / startFlash 都攔截。
|
||||
const mockStartFlash = vi.fn();
|
||||
const mockUpdateProgress = vi.fn();
|
||||
vi.mock("@/stores/flash-store", () => ({
|
||||
useFlashStore: (selector: (s: unknown) => unknown) =>
|
||||
selector({
|
||||
startFlash: mockStartFlash,
|
||||
updateProgress: mockUpdateProgress,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { useFlashProgress } from "./use-flash-progress";
|
||||
|
||||
describe("useFlashProgress", () => {
|
||||
beforeEach(() => {
|
||||
lastCall = null;
|
||||
mockStartFlash.mockReset();
|
||||
mockUpdateProgress.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("path 帶 encode 過的 deviceId;初始 enabled=false", () => {
|
||||
renderHook(() => useFlashProgress("a/b"));
|
||||
expect(lastCall?.path).toBe("/ws/devices/a%2Fb/flash-progress");
|
||||
expect(lastCall?.options.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("契約差異 B:beginFlash 只 enable WS,POST 延到 onOpen 才送(順序保證)", () => {
|
||||
const { result, rerender } = renderHook(() => useFlashProgress("dev-1"));
|
||||
|
||||
// beginFlash 當下不應 POST(WS 還沒 open)
|
||||
act(() => result.current.beginFlash("model-7"));
|
||||
expect(mockStartFlash).not.toHaveBeenCalled();
|
||||
|
||||
rerender();
|
||||
expect(lastCall?.options.enabled).toBe(true);
|
||||
|
||||
// 模擬 WS open → 此時才 POST
|
||||
act(() => lastCall!.options.onOpen?.());
|
||||
expect(mockStartFlash).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartFlash).toHaveBeenCalledWith("dev-1", "model-7");
|
||||
});
|
||||
|
||||
it("重連防重複 POST:onOpen 再次觸發不重複 POST(hasStartedRef)", () => {
|
||||
const { result } = renderHook(() => useFlashProgress("dev-1"));
|
||||
act(() => result.current.beginFlash("model-7"));
|
||||
act(() => lastCall!.options.onOpen?.());
|
||||
// 模擬斷線重連 → onOpen 再次觸發
|
||||
act(() => lastCall!.options.onOpen?.());
|
||||
expect(mockStartFlash).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("onMessage 進度推送 → 正規化後進 store", () => {
|
||||
renderHook(() => useFlashProgress("dev-1"));
|
||||
act(() =>
|
||||
lastCall!.options.onMessage({ percent: 60, stage: "loading", message: "半途" }),
|
||||
);
|
||||
expect(mockUpdateProgress).toHaveBeenCalledWith({
|
||||
percent: 60,
|
||||
stage: "loading",
|
||||
message: "半途",
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("onMessage 只帶 error(無 percent)→ 仍進 store", () => {
|
||||
renderHook(() => useFlashProgress("dev-1"));
|
||||
act(() => lastCall!.options.onMessage({ error: "boom" }));
|
||||
expect(mockUpdateProgress).toHaveBeenCalledWith({
|
||||
percent: 0,
|
||||
stage: "",
|
||||
message: undefined,
|
||||
error: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
it("onMessage 非法形狀(無 percent 無 error)→ 忽略", () => {
|
||||
renderHook(() => useFlashProgress("dev-1"));
|
||||
act(() => lastCall!.options.onMessage({ foo: "bar" }));
|
||||
expect(mockUpdateProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stop 後 enabled=false", () => {
|
||||
const { result, rerender } = renderHook(() => useFlashProgress("dev-1"));
|
||||
act(() => result.current.beginFlash("m"));
|
||||
rerender();
|
||||
expect(lastCall?.options.enabled).toBe(true);
|
||||
act(() => result.current.stop());
|
||||
rerender();
|
||||
expect(lastCall?.options.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
102
visionA-frontend/src/hooks/use-flash-progress.ts
Normal file
102
visionA-frontend/src/hooks/use-flash-progress.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* useFlashProgress — flash 進度 WS 訂閱 + 順序保證的 POST 觸發(塊:flash UI)
|
||||
*
|
||||
* 移植自 POC edge-ai-platform/frontend/src/hooks/use-flash-progress.ts(唯讀參考、非搬 code)。
|
||||
*
|
||||
* ⚠️ 最關鍵的契約差異(見 flash-model-load-mapping.md §2.3-B,採「選項 2」):
|
||||
* - POC `useFlashProgress` 提供 imperative `connectAndWait(): Promise<void>`,
|
||||
* dialog 先 `await connectAndWait()`(開 WS 並等 open)**再** `startFlash()`(POST)。
|
||||
* - visionA `useWebSocket(path, { enabled, onMessage, onOpen })` 是 **declarative**
|
||||
* (靠 `enabled` false→true 觸發連線,無法回傳「已 open」的 promise)。
|
||||
* 若 `setEnabled(true)` 後立刻 POST → race(POST 已送、WS 還沒 open,漏早期 progress)。
|
||||
*
|
||||
* **本 hook 的解法(選項 2,複用最多、無 race)**:
|
||||
* 用 `useWebSocket` 的 `onOpen` callback 觸發 POST。呼叫端 `beginFlash(modelId)` 只設
|
||||
* `enabled=true` + 記下 modelId;WS 真正 open 後才在 `onOpen` 內 `startFlash(deviceId, modelId)`,
|
||||
* 天然保證「先 open WS 再 POST」的順序(flash 很快就送 progress,順序錯會漏早期 percent)。
|
||||
*
|
||||
* **重連防重複 POST(`hasStartedRef`)**:
|
||||
* `onOpen` 在**每次連線成功**(含斷線重連)都會觸發。若不防護,重連會重複 POST flash。
|
||||
* 故用 `hasStartedRef` 記錄「本次 flash 已 POST 過」,重連時的 onOpen 不再 POST,只續收 progress。
|
||||
*
|
||||
* 認證:same-origin cookie(visiona_session HttpOnly)自動帶,**不放 token 到 URL**
|
||||
* (對齊 use-websocket.ts 安全決策 + security 對 token-in-URL 的 Critical 否決)。
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { useWebSocket } from "@/hooks/use-websocket";
|
||||
import { type FlashProgress, useFlashStore } from "@/stores/flash-store";
|
||||
|
||||
interface UseFlashProgress {
|
||||
/**
|
||||
* 開始 flash:啟用 WS(`enabled=true`),WS open 後才 POST flash(順序保證)。
|
||||
* @param modelId 要 flash 的模型 id
|
||||
*/
|
||||
beginFlash: (modelId: string) => void;
|
||||
/** 停止:關閉 WS、重置狀態(不再重連、不再 POST)。 */
|
||||
stop: () => void;
|
||||
/** 目前是否已啟用 WS(供除錯 / 測試觀察)。 */
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function useFlashProgress(deviceId: string): UseFlashProgress {
|
||||
const updateProgress = useFlashStore((s) => s.updateProgress);
|
||||
const startFlash = useFlashStore((s) => s.startFlash);
|
||||
|
||||
// enabled 用 state 讓 useWebSocket 收到 false→true 變化 → 觸發連線。
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
// 本次 flash 要送的 modelId(beginFlash 設定、onOpen 讀取)。
|
||||
const modelIdRef = useRef<string | null>(null);
|
||||
// 防重連重複 POST:本次 flash 已 POST 過就不再送。
|
||||
const hasStartedRef = useRef(false);
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
// 只在「本次 flash 尚未 POST 過」時觸發 POST(重連時的 onOpen 會跳過)。
|
||||
if (hasStartedRef.current) return;
|
||||
const modelId = modelIdRef.current;
|
||||
if (!modelId) return;
|
||||
hasStartedRef.current = true;
|
||||
void startFlash(deviceId, modelId);
|
||||
}, [deviceId, startFlash]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(data: unknown) => {
|
||||
const p = data as Partial<FlashProgress>;
|
||||
// 防呆:非預期形狀(percent 非數字且無 error)直接忽略,不污染 store。
|
||||
if (typeof p?.percent !== "number" && typeof p?.error !== "string") {
|
||||
return;
|
||||
}
|
||||
updateProgress({
|
||||
percent: typeof p.percent === "number" ? p.percent : 0,
|
||||
stage: typeof p.stage === "string" ? p.stage : "",
|
||||
message: typeof p.message === "string" ? p.message : undefined,
|
||||
error: typeof p.error === "string" ? p.error : undefined,
|
||||
});
|
||||
},
|
||||
[updateProgress],
|
||||
);
|
||||
|
||||
useWebSocket(`/ws/devices/${encodeURIComponent(deviceId)}/flash-progress`, {
|
||||
enabled,
|
||||
onOpen: handleOpen,
|
||||
onMessage: handleMessage,
|
||||
});
|
||||
|
||||
const beginFlash = useCallback((modelId: string) => {
|
||||
modelIdRef.current = modelId;
|
||||
hasStartedRef.current = false; // 新一輪 flash,重置「已 POST」旗標
|
||||
setEnabled(true);
|
||||
}, [setEnabled]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
modelIdRef.current = null;
|
||||
hasStartedRef.current = false;
|
||||
setEnabled(false);
|
||||
}, [setEnabled]);
|
||||
|
||||
return { beginFlash, stop, isActive: enabled };
|
||||
}
|
||||
69
visionA-frontend/src/lib/hardware-compat.test.ts
Normal file
69
visionA-frontend/src/lib/hardware-compat.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* hardware-compat 單元測試(flash UI 契約層)
|
||||
*
|
||||
* 驗證重點(契約差異 C:targetChip 單值 + 寧鬆勿嚴):
|
||||
* - device.type 各種變體正規化成小寫晶片代號
|
||||
* - 相容比對:相同晶片 → true、不同晶片 → false
|
||||
* - 寧鬆勿嚴:targetChip=unknown / device 無法辨識 → true(不擋,交 agent 把關)
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getChipFromDeviceType,
|
||||
getHardwareLabel,
|
||||
isModelCompatible,
|
||||
} from "./hardware-compat";
|
||||
|
||||
describe("getChipFromDeviceType", () => {
|
||||
it.each([
|
||||
["kneron_kl520", "kl520"],
|
||||
["kneron_kl720", "kl720"],
|
||||
["KL520", "kl520"],
|
||||
["kl630", "kl630"],
|
||||
["KL-730", "kl730"],
|
||||
["kl_520", "kl520"],
|
||||
])("正規化 %s → %s", (input, expected) => {
|
||||
expect(getChipFromDeviceType(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("空字串回空字串", () => {
|
||||
expect(getChipFromDeviceType("")).toBe("");
|
||||
});
|
||||
|
||||
it("無法辨識時回小寫原字串", () => {
|
||||
expect(getChipFromDeviceType("SomeUnknownDevice")).toBe("someunknowndevice");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelCompatible", () => {
|
||||
it("相同晶片 → 相容", () => {
|
||||
expect(isModelCompatible("kl520", "kneron_kl520")).toBe(true);
|
||||
expect(isModelCompatible("kl720", "KL720")).toBe(true);
|
||||
});
|
||||
|
||||
it("不同晶片 → 不相容", () => {
|
||||
expect(isModelCompatible("kl520", "kneron_kl720")).toBe(false);
|
||||
expect(isModelCompatible("kl730", "KL520")).toBe(false);
|
||||
});
|
||||
|
||||
it("寧鬆勿嚴:targetChip=unknown → 相容(不擋)", () => {
|
||||
expect(isModelCompatible("unknown", "kneron_kl520")).toBe(true);
|
||||
});
|
||||
|
||||
it("寧鬆勿嚴:device 無法辨識晶片 → 相容(不擋,交 agent 把關)", () => {
|
||||
expect(isModelCompatible("kl520", "some-weird-device")).toBe(true);
|
||||
expect(isModelCompatible("kl520", "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHardwareLabel", () => {
|
||||
it("辨識到晶片 → 回大寫代號", () => {
|
||||
expect(getHardwareLabel("kneron_kl520")).toBe("KL520");
|
||||
expect(getHardwareLabel("kl720")).toBe("KL720");
|
||||
});
|
||||
|
||||
it("無法辨識 → 回原字串", () => {
|
||||
expect(getHardwareLabel("weird")).toBe("weird");
|
||||
expect(getHardwareLabel("")).toBe("");
|
||||
});
|
||||
});
|
||||
61
visionA-frontend/src/lib/hardware-compat.ts
Normal file
61
visionA-frontend/src/lib/hardware-compat.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 硬體相容性檢查 — visionA Cloud(flash 前的 UX 早警示)
|
||||
*
|
||||
* 移植自 POC edge-ai-platform/frontend/src/lib/hardware-compat.ts(唯讀參考、非搬 code)。
|
||||
*
|
||||
* ⚠️ 與 POC 的關鍵契約差異(見 flash-model-load-mapping.md §2.3-C):
|
||||
* - POC model 用 `supportedHardware: string[]`(多值陣列),比對「device 晶片 ∈ 陣列」。
|
||||
* - visionA model 只有 `targetChip: TargetChip`(單值,見 model-store.ts:33),故改為單值比對。
|
||||
*
|
||||
* 定位(重要):**這只是前端 UX 早警示**,避免使用者選了不相容 model 才 POST 被拒。
|
||||
* 真正的權威把關在 local agent(`flash/service.go isCompatible`)——即使前端誤放行,
|
||||
* agent 仍會擋。因此前端邏輯**寧鬆勿嚴**(無法判定時預設相容),避免誤擋合法組合。
|
||||
*
|
||||
* device.type 來源多樣(local agent 回報值 / 後端正規化值),可能是:
|
||||
* - `kneron_kl520` / `kneron_kl720` …(driver type 前綴)
|
||||
* - `KL520` / `kl520`(大小寫混用)
|
||||
* 這裡統一正規化到小寫晶片代號(`kl520`…),與 `TargetChip` 對齊後比對。
|
||||
*/
|
||||
|
||||
import type { TargetChip } from "@/stores/model-store";
|
||||
|
||||
/**
|
||||
* 把 device.type 正規化成小寫晶片代號(如 `kl520`)。
|
||||
* 無法辨識時回傳原字串的小寫,交由呼叫端寬鬆處理(寧鬆勿嚴)。
|
||||
*/
|
||||
export function getChipFromDeviceType(deviceType: string): string {
|
||||
if (!deviceType) return "";
|
||||
const lower = deviceType.toLowerCase();
|
||||
// 抓出 kl 後接 3 碼數字的晶片代號(涵蓋 `kneron_kl520` / `KL520` / `kl-520` 等變體)。
|
||||
const match = lower.match(/kl\s*[-_]?\s*(\d{3})/);
|
||||
if (match) return `kl${match[1]}`;
|
||||
return lower;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判斷 model(單一 targetChip)是否與 device 相容。
|
||||
*
|
||||
* 寧鬆勿嚴規則(前端只做早警示、權威在 agent):
|
||||
* - model.targetChip 為 `unknown` → 視為相容(無法判定,不擋)。
|
||||
* - device.type 無法辨識出晶片代號 → 視為相容(無法判定,不擋)。
|
||||
* - 兩者都可辨識 → 嚴格比對晶片代號是否相同。
|
||||
*/
|
||||
export function isModelCompatible(
|
||||
targetChip: TargetChip,
|
||||
deviceType: string,
|
||||
): boolean {
|
||||
if (targetChip === "unknown") return true;
|
||||
const deviceChip = getChipFromDeviceType(deviceType);
|
||||
// device 端無法辨識晶片 → 不擋(交給 agent 權威把關)。
|
||||
if (!deviceChip.startsWith("kl")) return true;
|
||||
return deviceChip === targetChip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 給 UI 顯示用的裝置晶片代號(大寫,如 `KL520`);無法辨識時回傳原字串。
|
||||
*/
|
||||
export function getHardwareLabel(deviceType: string): string {
|
||||
const chip = getChipFromDeviceType(deviceType);
|
||||
if (chip.startsWith("kl")) return chip.toUpperCase();
|
||||
return deviceType || "";
|
||||
}
|
||||
@ -18,6 +18,7 @@ export const en: Dictionary = {
|
||||
"common.confirm": "Confirm",
|
||||
"common.save": "Save",
|
||||
"common.close": "Close",
|
||||
"common.done": "Done",
|
||||
"common.retry": "Retry",
|
||||
"common.view": "View",
|
||||
"common.manage": "Manage",
|
||||
@ -172,6 +173,23 @@ export const en: Dictionary = {
|
||||
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
|
||||
"devices.remove.error.unknown": "Something went wrong. Please try again.",
|
||||
|
||||
// ── Devices: flash (load model to device) ──
|
||||
"devices.flash.flashModel": "Load model",
|
||||
"devices.flash.flashToDevice": "Load a model to this device",
|
||||
"devices.flash.dialogDesc":
|
||||
"Pick a model from your library and load it onto the device. The device must have a model loaded before it can run inference.",
|
||||
"devices.flash.selectModel": "Select a model",
|
||||
"devices.flash.noModels": "No models available. Add one to your library first.",
|
||||
"devices.flash.startFlash": "Load to device",
|
||||
"devices.flash.hardwareIncompatible": "Model may not be compatible",
|
||||
"devices.flash.incompatibleDesc":
|
||||
"This model targets a different chip than {device}. You can still try, but the device may reject it.",
|
||||
"devices.flash.incompatibleCannotFlash": "Incompatible model",
|
||||
"devices.flash.thisDevice": "this device",
|
||||
"devices.flash.preparingFlash": "Preparing to load…",
|
||||
"devices.flash.flashComplete": "Model loaded successfully",
|
||||
"devices.flash.flashFailed": "Failed to load model",
|
||||
|
||||
// ── Remote Device Badge ──
|
||||
"remote.status.online": "Online",
|
||||
"remote.status.offline": "Offline",
|
||||
@ -300,9 +318,29 @@ export const en: Dictionary = {
|
||||
"The connection to {deviceName} was lost; inference has been stopped.",
|
||||
"workspace.offline.backToList": "Back to devices",
|
||||
"workspace.tabs.camera": "Camera",
|
||||
"workspace.tabs.image": "Image (Phase 1)",
|
||||
"workspace.tabs.video": "Video (Phase 1)",
|
||||
"workspace.tabs.batch": "Batch (Phase 1)",
|
||||
"workspace.tabs.image": "Image",
|
||||
"workspace.tabs.video": "Video",
|
||||
"workspace.tabs.batch": "Batch",
|
||||
|
||||
// ── Workspace media upload (Block 3) ──
|
||||
"workspace.media.browse": "Choose file",
|
||||
"workspace.media.uploading": "Uploading…",
|
||||
"workspace.media.cancel": "Cancel",
|
||||
"workspace.media.uploadAnother": "Upload another",
|
||||
"workspace.media.errorType": "Unsupported file: {filename}",
|
||||
"workspace.media.errorSize": "File too large: {filename}",
|
||||
"workspace.media.errorCount": "You can upload at most 50 images at once.",
|
||||
"workspace.media.errorEmpty": "Please choose at least one file.",
|
||||
"workspace.media.errorGeneric": "Upload failed. Please try again.",
|
||||
"workspace.media.image.primary": "Drag an image here to run inference",
|
||||
"workspace.media.image.hint": "JPG or PNG · up to 20 MB",
|
||||
"workspace.media.video.primary": "Drag a video here to run inference",
|
||||
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · up to 200 MB",
|
||||
"workspace.media.video.frame": "Frame",
|
||||
"workspace.media.video.seek": "Seek video",
|
||||
"workspace.media.batch.primary": "Drag images here (up to 50) for batch inference",
|
||||
"workspace.media.batch.hint": "JPG or PNG · up to 50 files · 20 MB each",
|
||||
"workspace.media.batch.progress": "Image {current} / {total}",
|
||||
|
||||
// ── Settings ──
|
||||
"settings.title": "Settings",
|
||||
|
||||
@ -21,6 +21,7 @@ export const zhHant: Dictionary = {
|
||||
"common.confirm": "確認",
|
||||
"common.save": "儲存",
|
||||
"common.close": "關閉",
|
||||
"common.done": "完成",
|
||||
"common.retry": "重試",
|
||||
"common.view": "檢視",
|
||||
"common.manage": "管理",
|
||||
@ -173,6 +174,23 @@ export const zhHant: Dictionary = {
|
||||
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
|
||||
"devices.remove.error.unknown": "發生錯誤,請稍後再試",
|
||||
|
||||
// ── Devices: flash(載入模型到裝置) ──
|
||||
"devices.flash.flashModel": "載入模型",
|
||||
"devices.flash.flashToDevice": "載入模型到此裝置",
|
||||
"devices.flash.dialogDesc":
|
||||
"從模型庫挑一個模型載入到裝置。裝置必須先載入模型,才能開始推論。",
|
||||
"devices.flash.selectModel": "選擇模型",
|
||||
"devices.flash.noModels": "沒有可用的模型,請先在模型庫新增。",
|
||||
"devices.flash.startFlash": "載入到裝置",
|
||||
"devices.flash.hardwareIncompatible": "模型可能不相容",
|
||||
"devices.flash.incompatibleDesc":
|
||||
"此模型的目標晶片與 {device} 不同。仍可嘗試,但裝置可能拒絕載入。",
|
||||
"devices.flash.incompatibleCannotFlash": "模型不相容",
|
||||
"devices.flash.thisDevice": "此裝置",
|
||||
"devices.flash.preparingFlash": "準備載入中…",
|
||||
"devices.flash.flashComplete": "模型載入成功",
|
||||
"devices.flash.flashFailed": "模型載入失敗",
|
||||
|
||||
// ── Remote Device Badge(雲端 tunnel 狀態) ──
|
||||
"remote.status.online": "在線",
|
||||
"remote.status.offline": "離線",
|
||||
@ -289,9 +307,29 @@ export const zhHant: Dictionary = {
|
||||
"workspace.offline.description": "與 {deviceName} 的連線中斷,推論已自動停止",
|
||||
"workspace.offline.backToList": "返回裝置列表",
|
||||
"workspace.tabs.camera": "Camera",
|
||||
"workspace.tabs.image": "圖片(Phase 1)",
|
||||
"workspace.tabs.video": "影片(Phase 1)",
|
||||
"workspace.tabs.batch": "批次(Phase 1)",
|
||||
"workspace.tabs.image": "圖片",
|
||||
"workspace.tabs.video": "影片",
|
||||
"workspace.tabs.batch": "批次",
|
||||
|
||||
// ── 推論工作區媒體上傳(塊 3) ──
|
||||
"workspace.media.browse": "選擇檔案",
|
||||
"workspace.media.uploading": "上傳中…",
|
||||
"workspace.media.cancel": "取消",
|
||||
"workspace.media.uploadAnother": "重新上傳",
|
||||
"workspace.media.errorType": "不支援的檔案:{filename}",
|
||||
"workspace.media.errorSize": "檔案過大:{filename}",
|
||||
"workspace.media.errorCount": "一次最多上傳 50 張圖片。",
|
||||
"workspace.media.errorEmpty": "請至少選擇一個檔案。",
|
||||
"workspace.media.errorGeneric": "上傳失敗,請再試一次。",
|
||||
"workspace.media.image.primary": "將圖片拖曳到此處開始推論",
|
||||
"workspace.media.image.hint": "JPG 或 PNG · 上限 20 MB",
|
||||
"workspace.media.video.primary": "將影片拖曳到此處開始推論",
|
||||
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · 上限 200 MB",
|
||||
"workspace.media.video.frame": "影格",
|
||||
"workspace.media.video.seek": "影片跳轉",
|
||||
"workspace.media.batch.primary": "將圖片拖曳到此處(最多 50 張)進行批次推論",
|
||||
"workspace.media.batch.hint": "JPG 或 PNG · 最多 50 張 · 每張上限 20 MB",
|
||||
"workspace.media.batch.progress": "第 {current} / {total} 張",
|
||||
|
||||
// ── Settings ──
|
||||
"settings.title": "設定",
|
||||
|
||||
246
visionA-frontend/src/lib/media.test.ts
Normal file
246
visionA-frontend/src/lib/media.test.ts
Normal file
@ -0,0 +1,246 @@
|
||||
/**
|
||||
* media.ts 單元測試(塊 3)
|
||||
*
|
||||
* 驗證:
|
||||
* - validateImageFile / validateVideoFile / validateBatchFiles 各種通過 / 失敗路徑
|
||||
* - buildBatchImageUrl 組路徑(含 / 不含 cacheBust)
|
||||
* - uploadImage / uploadBatchImages / seekVideo 打對的路徑與 FormData 欄位
|
||||
* (mock XMLHttpRequest + api.post)
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_BATCH_IMAGES,
|
||||
VIDEO_FALLBACK_FPS,
|
||||
buildBatchImageUrl,
|
||||
frameToSeekSeconds,
|
||||
seekVideo,
|
||||
uploadBatchImages,
|
||||
uploadImage,
|
||||
validateBatchFiles,
|
||||
validateImageFile,
|
||||
validateVideoFile,
|
||||
} from "./media";
|
||||
|
||||
function makeFile(name: string, sizeBytes = 1024): File {
|
||||
const blob = new Blob([new Uint8Array(sizeBytes)], { type: "application/octet-stream" });
|
||||
return new File([blob], name);
|
||||
}
|
||||
|
||||
describe("media validation", () => {
|
||||
it("圖片:JPG/PNG 通過,其他型別擋 TYPE", () => {
|
||||
expect(validateImageFile(makeFile("a.jpg"))).toBeNull();
|
||||
expect(validateImageFile(makeFile("a.jpeg"))).toBeNull();
|
||||
expect(validateImageFile(makeFile("a.PNG"))).toBeNull();
|
||||
expect(validateImageFile(makeFile("a.gif"))?.code).toBe("TYPE");
|
||||
expect(validateImageFile(makeFile("a.mp4"))?.code).toBe("TYPE");
|
||||
});
|
||||
|
||||
it("圖片:超過 20 MB 擋 SIZE", () => {
|
||||
const big = makeFile("a.jpg", 21 * 1024 * 1024);
|
||||
expect(validateImageFile(big)?.code).toBe("SIZE");
|
||||
});
|
||||
|
||||
it("影片:MP4/AVI/MOV/MPEG 通過,其他擋 TYPE", () => {
|
||||
expect(validateVideoFile(makeFile("v.mp4"))).toBeNull();
|
||||
expect(validateVideoFile(makeFile("v.mov"))).toBeNull();
|
||||
expect(validateVideoFile(makeFile("v.mpeg"))).toBeNull();
|
||||
expect(validateVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE");
|
||||
});
|
||||
|
||||
it("批次:空陣列 → EMPTY,超過 50 → COUNT,含非圖 → TYPE", () => {
|
||||
expect(validateBatchFiles([])?.code).toBe("EMPTY");
|
||||
|
||||
const tooMany = Array.from({ length: MAX_BATCH_IMAGES + 1 }, (_, i) =>
|
||||
makeFile(`img${i}.jpg`),
|
||||
);
|
||||
expect(validateBatchFiles(tooMany)?.code).toBe("COUNT");
|
||||
|
||||
expect(
|
||||
validateBatchFiles([makeFile("ok.jpg"), makeFile("bad.txt")])?.code,
|
||||
).toBe("TYPE");
|
||||
|
||||
expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("frameToSeekSeconds", () => {
|
||||
it("有 durationSeconds + totalFrames → 用 duration 換算(不依賴 fps)", () => {
|
||||
// 100 frames / 10 秒 → frame 50 = 5 秒
|
||||
expect(frameToSeekSeconds(50, 100, 10)).toBe(5);
|
||||
// frame 0 = 0 秒
|
||||
expect(frameToSeekSeconds(0, 100, 10)).toBe(0);
|
||||
// 換算與 fps 常數無關:即使 fps 常數是 15,這裡仍用 duration
|
||||
expect(frameToSeekSeconds(30, 60, 12)).toBe(6);
|
||||
});
|
||||
|
||||
it("缺 durationSeconds → 退用具名 fps 常數(frame / VIDEO_FALLBACK_FPS)", () => {
|
||||
expect(frameToSeekSeconds(30, 100, undefined)).toBe(30 / VIDEO_FALLBACK_FPS);
|
||||
// duration <= 0 也視為缺
|
||||
expect(frameToSeekSeconds(30, 100, 0)).toBe(30 / VIDEO_FALLBACK_FPS);
|
||||
});
|
||||
|
||||
it("缺 totalFrames → 退用 fps 常數", () => {
|
||||
expect(frameToSeekSeconds(45, undefined, 10)).toBe(45 / VIDEO_FALLBACK_FPS);
|
||||
});
|
||||
|
||||
it("負 frame → clamp 為 0", () => {
|
||||
expect(frameToSeekSeconds(-5, 100, 10)).toBe(0);
|
||||
expect(frameToSeekSeconds(-5, undefined, undefined)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBatchImageUrl", () => {
|
||||
// jsdom 有 window 且未設 NEXT_PUBLIC_API_BASE → getApiBaseUrl 回 ""(同 origin 相對路徑)
|
||||
it("組出 /api/media/batch-images/:index(無 cacheBust)", () => {
|
||||
expect(buildBatchImageUrl(3)).toBe("/api/media/batch-images/3");
|
||||
});
|
||||
|
||||
it("帶 cacheBust 加上 _t query", () => {
|
||||
expect(buildBatchImageUrl(0, "abc")).toBe("/api/media/batch-images/0?_t=abc");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Upload(mock XMLHttpRequest) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface FakeXHR {
|
||||
method?: string;
|
||||
url?: string;
|
||||
withCredentials?: boolean;
|
||||
timeout?: number;
|
||||
sent?: FormData;
|
||||
status: number;
|
||||
responseText: string;
|
||||
upload: { onprogress: ((ev: ProgressEvent) => void) | null };
|
||||
onload: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
ontimeout: (() => void) | null;
|
||||
open(method: string, url: string): void;
|
||||
send(body: FormData): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
let lastXhr: FakeXHR | null = null;
|
||||
|
||||
function installXhrMock(responder: (xhr: FakeXHR) => void) {
|
||||
// 以工廠回傳 plain object(closure over `xhr`),避免 class + `this` 別名(no-this-alias)。
|
||||
function XHRMock(this: unknown) {
|
||||
const xhr: FakeXHR = {
|
||||
withCredentials: false,
|
||||
timeout: 0,
|
||||
status: 200,
|
||||
responseText: "",
|
||||
upload: { onprogress: null },
|
||||
onload: null,
|
||||
onerror: null,
|
||||
ontimeout: null,
|
||||
open(method: string, url: string) {
|
||||
xhr.method = method;
|
||||
xhr.url = url;
|
||||
},
|
||||
send(body: FormData) {
|
||||
xhr.sent = body;
|
||||
queueMicrotask(() => responder(xhr));
|
||||
},
|
||||
abort() {},
|
||||
};
|
||||
lastXhr = xhr;
|
||||
return xhr;
|
||||
}
|
||||
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest =
|
||||
XHRMock as unknown as typeof XMLHttpRequest;
|
||||
}
|
||||
|
||||
describe("uploadImage / uploadBatchImages(XHR mock)", () => {
|
||||
const origXHR = globalThis.XMLHttpRequest;
|
||||
beforeEach(() => {
|
||||
lastXhr = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR;
|
||||
});
|
||||
|
||||
it("uploadImage:POST /api/media/upload/image、帶 deviceId+file、回傳 data", async () => {
|
||||
installXhrMock((xhr) => {
|
||||
xhr.status = 200;
|
||||
xhr.responseText = JSON.stringify({
|
||||
success: true,
|
||||
data: { streamUrl: "/api/camera/stream", sourceType: "image", filename: "a.jpg" },
|
||||
});
|
||||
xhr.onload?.();
|
||||
});
|
||||
|
||||
const res = await uploadImage("dev-1", makeFile("a.jpg"));
|
||||
expect(res.sourceType).toBe("image");
|
||||
expect(res.streamUrl).toBe("/api/camera/stream");
|
||||
expect(lastXhr?.method).toBe("POST");
|
||||
expect(lastXhr?.url).toContain("/api/media/upload/image");
|
||||
expect(lastXhr?.withCredentials).toBe(true);
|
||||
expect(lastXhr?.sent?.get("deviceId")).toBe("dev-1");
|
||||
expect(lastXhr?.sent?.get("file")).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it("uploadBatchImages:多檔以 files 欄位附加", async () => {
|
||||
installXhrMock((xhr) => {
|
||||
xhr.status = 200;
|
||||
xhr.responseText = JSON.stringify({
|
||||
success: true,
|
||||
data: { streamUrl: "/api/camera/stream", sourceType: "batch_image", totalImages: 2 },
|
||||
});
|
||||
xhr.onload?.();
|
||||
});
|
||||
|
||||
const res = await uploadBatchImages("dev-1", [makeFile("a.jpg"), makeFile("b.png")]);
|
||||
expect(res.totalImages).toBe(2);
|
||||
expect(lastXhr?.url).toContain("/api/media/upload/batch-images");
|
||||
expect(lastXhr?.sent?.getAll("files")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("非 2xx 且有 error envelope → 拋 ApiError(保留 code)", async () => {
|
||||
installXhrMock((xhr) => {
|
||||
xhr.status = 400;
|
||||
xhr.responseText = JSON.stringify({
|
||||
success: false,
|
||||
error: { code: "BAD_REQUEST", message: "only JPG/PNG files are supported" },
|
||||
});
|
||||
xhr.onload?.();
|
||||
});
|
||||
|
||||
await expect(uploadImage("dev-1", makeFile("a.jpg"))).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("回報上傳進度", async () => {
|
||||
installXhrMock((xhr) => {
|
||||
xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent);
|
||||
xhr.status = 200;
|
||||
xhr.responseText = JSON.stringify({
|
||||
success: true,
|
||||
data: { streamUrl: "/s", sourceType: "image" },
|
||||
});
|
||||
xhr.onload?.();
|
||||
});
|
||||
|
||||
const onProgress = vi.fn();
|
||||
await uploadImage("dev-1", makeFile("a.jpg"), { onProgress });
|
||||
expect(onProgress).toHaveBeenCalledWith(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seekVideo", () => {
|
||||
it("POST /api/media/seek,body 帶 timeSeconds", async () => {
|
||||
const apiModule = await import("@/lib/api");
|
||||
const spy = vi
|
||||
.spyOn(apiModule.api, "post")
|
||||
.mockResolvedValue({ seekTo: 3, frameOffset: 45 } as never);
|
||||
|
||||
const res = await seekVideo(3);
|
||||
expect(spy).toHaveBeenCalledWith("/api/media/seek", { timeSeconds: 3 });
|
||||
expect(res).toEqual({ seekTo: 3, frameOffset: 45 });
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
301
visionA-frontend/src/lib/media.ts
Normal file
301
visionA-frontend/src/lib/media.ts
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Media 上傳 / 推論工具 — visionA Cloud 前端(塊 3:圖片 / 影片 / 批次)
|
||||
*
|
||||
* 職責:
|
||||
* - `uploadMedia()`:以 multipart/form-data 上傳單檔(圖片 / 影片)到
|
||||
* `/api/media/upload/image` | `/api/media/upload/video`,回傳解開 envelope 的
|
||||
* `MediaUploadResponse`;支援上傳進度 + AbortSignal(大檔影片可取消)。
|
||||
* - `uploadBatchImages()`:多檔(≤ MAX_BATCH_IMAGES)上傳到
|
||||
* `/api/media/upload/batch-images`(欄位名 `files`,對齊 camera_handler.go:354)。
|
||||
* - `seekVideo()`:`POST /api/media/seek`,body `{ timeSeconds }`(對齊 SeekVideo:541-547)。
|
||||
* - `buildBatchImageUrl()`:組 `GET /api/media/batch-images/:index` 的完整 URL(回單張 jpeg)。
|
||||
*
|
||||
* 為什麼不直接用 `api.upload`:
|
||||
* `api.upload` 的 body 是單一 Blob(用於 presigned PUT 直送 storage),
|
||||
* media 端需要 multipart form 帶額外欄位(`deviceId` + `file` / `files`),
|
||||
* 故這裡自寫 XHR + FormData。認證與 `api.upload` 一致:same-origin cookie
|
||||
* (`withCredentials = true`),不放 token 到 URL(對齊 lib/camera.ts / use-websocket §10)。
|
||||
*
|
||||
* 顯示管線:上傳成功後拿 `streamUrl` → 走 CameraFeed(MJPEG `<img>`)+ overlay + WS 面板,
|
||||
* 與 camera / 塊 1、2 完全共用(見 .autoflow/04-architecture/camera-e2e-effort-estimate.md §0.1)。
|
||||
*/
|
||||
|
||||
import {
|
||||
AbortError,
|
||||
ApiError,
|
||||
NetworkError,
|
||||
TimeoutError,
|
||||
api,
|
||||
getApiBaseUrl,
|
||||
} from "@/lib/api";
|
||||
import type { ApiErrorShape } from "@/types/api";
|
||||
import type { MediaUploadResponse, SeekResponse } from "@/types/camera";
|
||||
|
||||
/** 單檔上傳(圖片 / 影片)的後端路徑。 */
|
||||
export const MEDIA_UPLOAD_IMAGE_PATH = "/api/media/upload/image";
|
||||
export const MEDIA_UPLOAD_VIDEO_PATH = "/api/media/upload/video";
|
||||
export const MEDIA_UPLOAD_BATCH_PATH = "/api/media/upload/batch-images";
|
||||
export const MEDIA_SEEK_PATH = "/api/media/seek";
|
||||
export const MEDIA_BATCH_FRAME_PATH = "/api/media/batch-images";
|
||||
|
||||
/** 批次最多張數(對齊 camera_handler.go:359 的 50 上限)。 */
|
||||
export const MAX_BATCH_IMAGES = 50;
|
||||
|
||||
/**
|
||||
* 影片播放取樣 fps 的 fallback 值。
|
||||
*
|
||||
* ⚠️ 契約備註:seek API(POST /api/media/seek)body 是 `{ timeSeconds }`(秒數),
|
||||
* 但 WS 進度只給 frameIndex/totalFrames(無秒數)。把 frame → 秒需要 fps 或 duration。
|
||||
* 後端 upload video response 有回 `durationSeconds`(camera_handler.go:333),
|
||||
* **優先用 duration 換算**(frame/totalFrames × durationSeconds),完全不依賴 fps。
|
||||
*
|
||||
* 只有當後端沒回 durationSeconds(舊版 / 探測失敗)時,才退用此 fps 常數。
|
||||
* 此值必須與後端 `camera_handler.go` 的 `h.videoFPS = 15`(line 303)同步;
|
||||
* 後端改 fps 時這裡也要跟著改(後端 response 目前不帶 fps,故無法自動同步)。
|
||||
*/
|
||||
export const VIDEO_FALLBACK_FPS = 15;
|
||||
|
||||
/**
|
||||
* 把 seek bar 的 frame 位置換算成後端 seek 需要的秒數。
|
||||
*
|
||||
* @param frame 目標 frame index(0-based)
|
||||
* @param totalFrames 影片總 frame 數(WS 進度回報)
|
||||
* @param durationSeconds 影片總長秒數(upload response 回報;可能為 undefined)
|
||||
* @returns seek 目標秒數(≥ 0)
|
||||
*
|
||||
* 換算優先序:
|
||||
* 1. 有 durationSeconds + totalFrames → `frame / totalFrames × durationSeconds`
|
||||
* (只用後端回的欄位,無 fps 隱性耦合)
|
||||
* 2. 否則退用 `frame / VIDEO_FALLBACK_FPS`(具名常數,需與後端 fps 同步)
|
||||
*/
|
||||
export function frameToSeekSeconds(
|
||||
frame: number,
|
||||
totalFrames?: number,
|
||||
durationSeconds?: number,
|
||||
): number {
|
||||
if (
|
||||
typeof durationSeconds === "number" &&
|
||||
durationSeconds > 0 &&
|
||||
typeof totalFrames === "number" &&
|
||||
totalFrames > 0
|
||||
) {
|
||||
return Math.max(0, (frame / totalFrames) * durationSeconds);
|
||||
}
|
||||
return Math.max(0, frame / VIDEO_FALLBACK_FPS);
|
||||
}
|
||||
|
||||
/** 圖片副檔名白名單(對齊 UploadImage:155 / UploadBatchImages:369)。 */
|
||||
export const IMAGE_ACCEPT = ".jpg,.jpeg,.png";
|
||||
/** 影片副檔名白名單(對齊 UploadVideo:249)。 */
|
||||
export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg";
|
||||
|
||||
/** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2)。 */
|
||||
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
|
||||
export const MAX_VIDEO_BYTES = 200 * 1024 * 1024; // 200 MB
|
||||
|
||||
export interface UploadMediaOptions {
|
||||
/** 上傳進度 callback(0~100) */
|
||||
onProgress?: (percent: number) => void;
|
||||
/** 取消訊號(使用者按取消 / 切 tab / unmount) */
|
||||
signal?: AbortSignal;
|
||||
/** 覆寫 timeout(毫秒);預設影片較長,見 caller。傳 0 關閉 timeout。 */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 XHR + FormData 上傳 multipart,解析後端統一 envelope。
|
||||
*
|
||||
* @param path 後端相對路徑
|
||||
* @param form 已組好的 FormData(含 deviceId + file/files)
|
||||
* @param options 進度 / 取消 / timeout
|
||||
* @returns 解開 envelope 的 `data`(MediaUploadResponse)
|
||||
* @throws ApiError | NetworkError | TimeoutError | AbortError(與 api.ts 一致的錯誤體系)
|
||||
*/
|
||||
function postMultipart(
|
||||
path: string,
|
||||
form: FormData,
|
||||
options: UploadMediaOptions = {},
|
||||
): Promise<MediaUploadResponse> {
|
||||
const url = `${getApiBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
|
||||
return new Promise<MediaUploadResponse>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", url, true);
|
||||
// same-origin cookie(BFF session);不設 Content-Type,讓瀏覽器帶 multipart boundary
|
||||
xhr.withCredentials = true;
|
||||
|
||||
if (options.timeoutMs && options.timeoutMs > 0) {
|
||||
xhr.timeout = options.timeoutMs;
|
||||
}
|
||||
|
||||
if (options.onProgress) {
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (ev.lengthComputable) {
|
||||
options.onProgress!(Math.min(100, Math.round((ev.loaded / ev.total) * 100)));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null;
|
||||
} catch {
|
||||
// 非 JSON body
|
||||
}
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"success" in parsed &&
|
||||
(parsed as { success: boolean }).success === true &&
|
||||
"data" in parsed
|
||||
) {
|
||||
resolve((parsed as { data: MediaUploadResponse }).data);
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new ApiError(xhr.status, {
|
||||
code: "PARSE_ERROR",
|
||||
message: "Unexpected upload response shape",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// non-2xx:盡量取 envelope 的 error
|
||||
let errShape: ApiErrorShape = {
|
||||
code: xhr.status === 401 ? "UNAUTHORIZED" : "INTERNAL_ERROR",
|
||||
message: `Upload failed: HTTP ${xhr.status}`,
|
||||
};
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"error" in parsed &&
|
||||
(parsed as { error?: unknown }).error
|
||||
) {
|
||||
errShape = (parsed as { error: ApiErrorShape }).error;
|
||||
}
|
||||
reject(new ApiError(xhr.status, errShape));
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new NetworkError(`Upload to ${url} failed`));
|
||||
xhr.ontimeout = () => reject(new TimeoutError(`Upload to ${url} timed out`));
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
reject(new AbortError());
|
||||
return;
|
||||
}
|
||||
options.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
xhr.abort();
|
||||
reject(new AbortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
/** 上傳單張圖片 → 開始推論;回傳含 streamUrl 的 response。 */
|
||||
export function uploadImage(
|
||||
deviceId: string,
|
||||
file: File,
|
||||
options?: UploadMediaOptions,
|
||||
): Promise<MediaUploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append("deviceId", deviceId);
|
||||
form.append("file", file);
|
||||
return postMultipart(MEDIA_UPLOAD_IMAGE_PATH, form, options);
|
||||
}
|
||||
|
||||
/** 上傳單支影片 → 開始逐 frame 推論;回傳含 streamUrl / totalFrames / durationSeconds。 */
|
||||
export function uploadVideo(
|
||||
deviceId: string,
|
||||
file: File,
|
||||
options?: UploadMediaOptions,
|
||||
): Promise<MediaUploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append("deviceId", deviceId);
|
||||
form.append("file", file);
|
||||
return postMultipart(MEDIA_UPLOAD_VIDEO_PATH, form, options);
|
||||
}
|
||||
|
||||
/** 上傳多張圖(batch)→ 逐張推論;回傳含 batchId / totalImages / images[]。 */
|
||||
export function uploadBatchImages(
|
||||
deviceId: string,
|
||||
files: File[],
|
||||
options?: UploadMediaOptions,
|
||||
): Promise<MediaUploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append("deviceId", deviceId);
|
||||
for (const f of files) {
|
||||
form.append("files", f);
|
||||
}
|
||||
return postMultipart(MEDIA_UPLOAD_BATCH_PATH, form, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 影片 seek — 跳到指定秒數並從該處重啟推論。
|
||||
* body `{ timeSeconds }`(對齊 SeekVideo:541-547;後端以秒數 clamp,非 frameIndex)。
|
||||
*/
|
||||
export function seekVideo(timeSeconds: number): Promise<SeekResponse> {
|
||||
return api.post<SeekResponse>(MEDIA_SEEK_PATH, { timeSeconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* 組批次單張結果圖 URL(`GET /api/media/batch-images/:index` 回單張 jpeg)。
|
||||
*
|
||||
* @param index 批次內索引(0-based)
|
||||
* @param cacheBust 可選 cache-busting(切換到同 index 但結果已更新時避免瀏覽器用舊圖)
|
||||
*/
|
||||
export function buildBatchImageUrl(index: number, cacheBust?: string): string {
|
||||
const base = getApiBaseUrl();
|
||||
const full = `${base}${MEDIA_BATCH_FRAME_PATH}/${index}`;
|
||||
if (!cacheBust) return full;
|
||||
return `${full}?_t=${encodeURIComponent(cacheBust)}`;
|
||||
}
|
||||
|
||||
/** 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 */
|
||||
export interface FileValidationError {
|
||||
code: "TYPE" | "SIZE" | "COUNT" | "EMPTY";
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
/** 依副檔名 + 大小驗證單張圖片。回傳 null 表通過。 */
|
||||
export function validateImageFile(file: File): FileValidationError | null {
|
||||
if (!/\.(jpe?g|png)$/i.test(file.name)) {
|
||||
return { code: "TYPE", filename: file.name };
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
return { code: "SIZE", filename: file.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 依副檔名 + 大小驗證影片。回傳 null 表通過。 */
|
||||
export function validateVideoFile(file: File): FileValidationError | null {
|
||||
if (!/\.(mp4|avi|mov|mpe?g)$/i.test(file.name)) {
|
||||
return { code: "TYPE", filename: file.name };
|
||||
}
|
||||
if (file.size > MAX_VIDEO_BYTES) {
|
||||
return { code: "SIZE", filename: file.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 驗證整批圖片(數量 + 每張型別 / 大小)。回傳 null 表通過。 */
|
||||
export function validateBatchFiles(files: File[]): FileValidationError | null {
|
||||
if (files.length === 0) return { code: "EMPTY" };
|
||||
if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" };
|
||||
for (const f of files) {
|
||||
const err = validateImageFile(f);
|
||||
if (err) return err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
137
visionA-frontend/src/stores/flash-store.test.ts
Normal file
137
visionA-frontend/src/stores/flash-store.test.ts
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* flash-store 單元測試(flash UI 契約層)
|
||||
*
|
||||
* 驗證重點:
|
||||
* - 契約差異 A(throw-based):startFlash 成功 / api throw ApiError 時 setError
|
||||
* - updateProgress:error → 失敗態、percent>=100 → 完成、其餘 → 更新
|
||||
* - retryFlash 用 lastFlashParams
|
||||
* - reset
|
||||
* - activity-store 副作用(flash_start / flash_complete / flash_error)
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useActivityStore } from "@/stores/activity-store";
|
||||
|
||||
// mock HTTP client(startFlash 依賴 api.post);保留 ApiError 讓 catch 分支可用。
|
||||
vi.mock("@/lib/api", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
||||
return {
|
||||
...actual,
|
||||
api: { ...actual.api, post: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useFlashStore } from "./flash-store";
|
||||
|
||||
const mockPost = vi.mocked(api.post);
|
||||
|
||||
function reset() {
|
||||
useFlashStore.setState({
|
||||
isFlashing: false,
|
||||
progress: null,
|
||||
error: null,
|
||||
lastFlashParams: null,
|
||||
});
|
||||
useActivityStore.setState({ activities: [] });
|
||||
}
|
||||
|
||||
describe("flash-store", () => {
|
||||
beforeEach(reset);
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("初始態", () => {
|
||||
const s = useFlashStore.getState();
|
||||
expect(s.isFlashing).toBe(false);
|
||||
expect(s.progress).toBeNull();
|
||||
expect(s.error).toBeNull();
|
||||
});
|
||||
|
||||
it("startFlash 成功:POST 帶 modelId、記 lastFlashParams、加 flash_start activity", async () => {
|
||||
mockPost.mockResolvedValueOnce({ taskId: "t-1" });
|
||||
await useFlashStore.getState().startFlash("dev-1", "model-9");
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith("/api/devices/dev-1/flash", {
|
||||
modelId: "model-9",
|
||||
});
|
||||
const s = useFlashStore.getState();
|
||||
expect(s.isFlashing).toBe(true); // 觸發成功,等 WS 推完成
|
||||
expect(s.error).toBeNull();
|
||||
expect(s.lastFlashParams).toEqual({ deviceId: "dev-1", modelId: "model-9" });
|
||||
expect(useActivityStore.getState().activities[0]?.type).toBe("flash_start");
|
||||
});
|
||||
|
||||
it("契約差異 A:api throw ApiError → setError + flash_error activity", async () => {
|
||||
mockPost.mockRejectedValueOnce(
|
||||
new ApiError(400, { code: "FLASH_FAILED", message: "device busy" }),
|
||||
);
|
||||
await useFlashStore.getState().startFlash("dev-1", "model-9");
|
||||
|
||||
const s = useFlashStore.getState();
|
||||
expect(s.isFlashing).toBe(false);
|
||||
expect(s.error).toBe("device busy");
|
||||
expect(useActivityStore.getState().activities[0]?.type).toBe("flash_error");
|
||||
});
|
||||
|
||||
it("startFlash: deviceId 特殊字元 encode", async () => {
|
||||
mockPost.mockResolvedValueOnce({});
|
||||
await useFlashStore.getState().startFlash("a/b", "m1");
|
||||
expect(mockPost).toHaveBeenCalledWith("/api/devices/a%2Fb/flash", {
|
||||
modelId: "m1",
|
||||
});
|
||||
});
|
||||
|
||||
it("updateProgress: error 有值 → 失敗態", () => {
|
||||
useFlashStore.setState({ isFlashing: true, lastFlashParams: { deviceId: "d", modelId: "m" } });
|
||||
useFlashStore.getState().updateProgress({ percent: 40, stage: "loading", error: "boom" });
|
||||
const s = useFlashStore.getState();
|
||||
expect(s.isFlashing).toBe(false);
|
||||
expect(s.error).toBe("boom");
|
||||
expect(useActivityStore.getState().activities[0]?.type).toBe("flash_error");
|
||||
});
|
||||
|
||||
it("updateProgress: percent<100 → 更新 progress、仍 flashing", () => {
|
||||
useFlashStore.setState({ isFlashing: true });
|
||||
useFlashStore.getState().updateProgress({ percent: 55, stage: "loading", message: "半途" });
|
||||
const s = useFlashStore.getState();
|
||||
expect(s.progress?.percent).toBe(55);
|
||||
expect(s.isFlashing).toBe(true);
|
||||
});
|
||||
|
||||
it("updateProgress: percent>=100 → 完成、加 flash_complete activity", () => {
|
||||
useFlashStore.setState({ isFlashing: true, lastFlashParams: { deviceId: "d", modelId: "m" } });
|
||||
useFlashStore.getState().updateProgress({ percent: 100, stage: "done" });
|
||||
const s = useFlashStore.getState();
|
||||
expect(s.isFlashing).toBe(false);
|
||||
expect(useActivityStore.getState().activities[0]?.type).toBe("flash_complete");
|
||||
});
|
||||
|
||||
it("retryFlash 用 lastFlashParams 再 POST 一次", async () => {
|
||||
mockPost.mockRejectedValueOnce(
|
||||
new ApiError(400, { code: "FLASH_FAILED", message: "x" }),
|
||||
);
|
||||
await useFlashStore.getState().startFlash("dev-2", "model-2");
|
||||
mockPost.mockResolvedValueOnce({});
|
||||
await useFlashStore.getState().retryFlash();
|
||||
expect(mockPost).toHaveBeenLastCalledWith("/api/devices/dev-2/flash", {
|
||||
modelId: "model-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("retryFlash: 無 lastFlashParams → no-op", async () => {
|
||||
await useFlashStore.getState().retryFlash();
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reset 回初始態", () => {
|
||||
useFlashStore.setState({
|
||||
isFlashing: true,
|
||||
progress: { percent: 50, stage: "x" },
|
||||
error: "e",
|
||||
lastFlashParams: { deviceId: "d", modelId: "m" },
|
||||
});
|
||||
useFlashStore.getState().reset();
|
||||
const s = useFlashStore.getState();
|
||||
expect(s).toMatchObject({ isFlashing: false, progress: null, error: null, lastFlashParams: null });
|
||||
});
|
||||
});
|
||||
150
visionA-frontend/src/stores/flash-store.ts
Normal file
150
visionA-frontend/src/stores/flash-store.ts
Normal file
@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Flash Store — visionA Cloud(載入模型到裝置 / flash)
|
||||
*
|
||||
* 移植自 POC edge-ai-platform/frontend/src/stores/flash-store.ts(唯讀參考、非搬 code)。
|
||||
*
|
||||
* 對齊:
|
||||
* - 契約:flash-model-load-mapping.md §3.2(REST + WS 契約,由 local agent 既有實作定死)
|
||||
* - api client:api.ts(throw-based,見下方契約差異 A)
|
||||
*
|
||||
* ⚠️ 與 POC 的契約差異(見 flash-model-load-mapping.md §2.3-A):
|
||||
* POC 用 `const res = await api.post(...); if (!res.success) {...}`(result-based)。
|
||||
* visionA `api.post<T>()` 成功回 data、失敗 throw `ApiError`(api.ts:477-481)。
|
||||
* → `startFlash` 改用 try/catch:catch `ApiError` 時 `setError(e.message)`。
|
||||
*
|
||||
* flash 完成 / 失敗判定(消費 WS progress,見 §3.2 前端消費契約):
|
||||
* - `error` 有值 → 失敗態、`isFlashing=false`。
|
||||
* - `percent >= 100` 且無 error → 完成、`isFlashing=false`(UI 端負責 fetchDevice 刷 gate)。
|
||||
* - 其餘 → 更新 progress。
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useActivityStore } from "@/stores/activity-store";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types — 對齊 §3.2 WS payload(raw JSON,api-server 透明轉發、不含 envelope) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* flash 進度(driver.FlashProgress 的前端鏡像,見 interface.go:42-47)。
|
||||
* - percent:0-100
|
||||
* - stage:當前階段(如 "connecting" / "loading" / "verifying")
|
||||
* - message:選填,人類可讀描述
|
||||
* - error:選填,有值即失敗
|
||||
*/
|
||||
export interface FlashProgress {
|
||||
percent: number;
|
||||
stage: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** `POST /api/devices/:id/flash` 回傳(envelope 已由 api.post 解開,剩 data)。 */
|
||||
interface FlashResponse {
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
interface FlashState {
|
||||
isFlashing: boolean;
|
||||
progress: FlashProgress | null;
|
||||
error: string | null;
|
||||
lastFlashParams: { deviceId: string; modelId: string } | null;
|
||||
|
||||
/** 觸發 flash:`POST /api/devices/:id/flash { modelId }`(throw-based)。 */
|
||||
startFlash: (deviceId: string, modelId: string) => Promise<void>;
|
||||
/** 消費 WS 推來的進度(error / 完成 / 更新)。 */
|
||||
updateProgress: (progress: FlashProgress) => void;
|
||||
/** 直接設定錯誤(例如 WS 斷線 / 前端層錯誤)。 */
|
||||
setError: (error: string) => void;
|
||||
/** 用上一次參數重試。 */
|
||||
retryFlash: () => Promise<void>;
|
||||
/** 重置到初始態(開 dialog / 關 dialog 時呼叫)。 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useFlashStore = create<FlashState>()((set, get) => ({
|
||||
isFlashing: false,
|
||||
progress: null,
|
||||
error: null,
|
||||
lastFlashParams: null,
|
||||
|
||||
startFlash: async (deviceId, modelId) => {
|
||||
set({
|
||||
isFlashing: true,
|
||||
progress: null,
|
||||
error: null,
|
||||
lastFlashParams: { deviceId, modelId },
|
||||
});
|
||||
try {
|
||||
await api.post<FlashResponse>(
|
||||
`/api/devices/${encodeURIComponent(deviceId)}/flash`,
|
||||
{ modelId },
|
||||
);
|
||||
// POST 成功只代表「觸發成功」,實際進度 / 完成由 WS 推送。
|
||||
useActivityStore.getState().addActivity({
|
||||
type: "flash_start",
|
||||
message: "Flash started",
|
||||
deviceId,
|
||||
modelId,
|
||||
});
|
||||
} catch (err) {
|
||||
// api client 為 throw-based:ApiError 帶 backend code/message;其他退化成 Error。
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
set({ isFlashing: false, error: message });
|
||||
useActivityStore.getState().addActivity({
|
||||
type: "flash_error",
|
||||
message: `Flash failed: ${message}`,
|
||||
deviceId,
|
||||
modelId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateProgress: (progress) => {
|
||||
if (progress.error) {
|
||||
set({ isFlashing: false, error: progress.error, progress });
|
||||
const { lastFlashParams } = get();
|
||||
useActivityStore.getState().addActivity({
|
||||
type: "flash_error",
|
||||
message: `Flash failed: ${progress.error}`,
|
||||
deviceId: lastFlashParams?.deviceId,
|
||||
modelId: lastFlashParams?.modelId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
set({ progress });
|
||||
if (progress.percent >= 100) {
|
||||
set({ isFlashing: false });
|
||||
const { lastFlashParams } = get();
|
||||
useActivityStore.getState().addActivity({
|
||||
type: "flash_complete",
|
||||
message: "Flash completed",
|
||||
deviceId: lastFlashParams?.deviceId,
|
||||
modelId: lastFlashParams?.modelId,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setError: (error) => {
|
||||
set({ error, isFlashing: false });
|
||||
},
|
||||
|
||||
retryFlash: async () => {
|
||||
const { lastFlashParams } = get();
|
||||
if (!lastFlashParams) return;
|
||||
await get().startFlash(lastFlashParams.deviceId, lastFlashParams.modelId);
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({ isFlashing: false, progress: null, error: null, lastFlashParams: null });
|
||||
},
|
||||
}));
|
||||
@ -40,7 +40,10 @@ export interface StreamState {
|
||||
/**
|
||||
* Media 上傳 / Camera 啟動的共用 response data。
|
||||
*
|
||||
* 對齊 camera_handler.go 各 handler 的 `data` 欄位(streamUrl 必有,其餘依來源)。
|
||||
* 對齊 camera_handler.go 各 handler 的 `data` 欄位(streamUrl 必有,其餘依來源):
|
||||
* - camera / image:streamUrl + sourceType (+ width/height/filename)
|
||||
* - video(UploadVideo:326-335):另有 totalFrames / durationSeconds
|
||||
* - batch(UploadBatchImages:469-478):另有 batchId / totalImages / images[]
|
||||
*/
|
||||
export interface MediaUploadResponse {
|
||||
streamUrl: string;
|
||||
@ -48,4 +51,27 @@ export interface MediaUploadResponse {
|
||||
width?: number;
|
||||
height?: number;
|
||||
filename?: string;
|
||||
|
||||
// Video 專屬(UploadVideo)
|
||||
totalFrames?: number;
|
||||
durationSeconds?: number;
|
||||
|
||||
// Batch 專屬(UploadBatchImages)
|
||||
batchId?: string;
|
||||
totalImages?: number;
|
||||
images?: BatchImageEntry[];
|
||||
}
|
||||
|
||||
/** 批次上傳後回傳的每張圖 metadata(camera_handler.go:459-465)。 */
|
||||
export interface BatchImageEntry {
|
||||
index: number;
|
||||
filename: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** SeekVideo(POST /api/media/seek)回傳的 data(camera_handler.go:615-621)。 */
|
||||
export interface SeekResponse {
|
||||
seekTo: number;
|
||||
frameOffset: number;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user