Compare commits

..

2 Commits

Author SHA1 Message Date
051994ed54 feat(workspace): 圖片/影片/批次推論 tab + 載入模型到裝置 FlashDialog(前端)
推論工作區前端塔3 + flash 前端(i18n/device-detail 交纏、一批 commit)。

塔3(圖片/影片/批次 tab、複用塊1/2 顯示管線 0 重造):
- media.ts 上傳 helper(XHR image/video/batch)+ media-uploader + media-tab
- workspace-client 3 tab 打開 + activeTab 互斥 camera/media WS
- video seek 用後端 durationSeconds 換算(脫離 fps 耦合、Reviewer M-1 修正)
- 補 tab 切換 WS reset 測試(S-1)
- Reviewer 2 輪通過

flash 前端(載入模型到裝置、補「進工作區前置」缺口,照 POC 移植):
- flash-dialog + flash-progress(選 model + 相容性檢查 + WS 進度)
- flash-store(throw-based ApiError)
- use-flash-progress(onOpen 才 POST 防 race + hasStartedRef 防重連重複)
- hardware-compat(targetChip 單值、寧鬆勿嚴、UX 警示非安全閘)
- device-detail-client 掛 FlashDialog(gate 未改、flash 完 fetchDevice 解鎖)
- Reviewer 0C/0M

契約:same-origin cookie、WS/stream/upload URL 相對路徑無 token、i18n 雙語對稱、
設計 token 不裸色值。全前端 tsc/eslint clean、塔3 30 test + flash 45 test 綠。
(全套 11 failed 全屬 conversion-store 既有 flaky、未觸碰)

follow-up:flash WS 逾時提示(Mi)、--success token(design)、conversion-store flaky 釐清。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 06:27:14 +08:00
81a32a5bf8 feat(stage): nginx 加 /ws/ location(推論 inference WS + 其餘 /ws/* stub)
兩個 server block(公網 stage-9527 + 內網 IP 直連 192.168.0.130)各加
一條 location /ws/ → proxy_pass api-server(:3721)。缺這條時 /ws/* 會落到
catch-all → Next.js → 404,擋住前端推論頁 WS 握手。

- WS upgrade 三要素(http/1.1 + Upgrade + Connection $connection_upgrade)
- 長 timeout 86400s + proxy_buffering off(比照 /tunnel/connect)
- 不影響既有 /tunnel/connect(不同 path 前綴、互不 shadow)
- 兩 block header 對齊(含 X-Forwarded-Host)

驗證:/ws/devices/test-id/inference 不帶 cookie → 401(過 backend auth)
不再 404;/tunnel/connect 回歸 401;preset 下載 200/206;demo 設定保留。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 06:00:59 +08:00
22 changed files with 2755 additions and 22 deletions

View File

@ -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;

View File

@ -11,8 +11,11 @@
* - + Card
* - / disabled
*
* F6 stub
* - FlashDialog flash tunnel forwardF8
* flashFlashDialog flash-model-load-mapping.md
* model flashPOST /api/devices/:id/flash WS
* fetchDevice gate disable
*
* stub
* - DeviceHealthCard / DeviceConnectionLogPhase 1
* - DeviceSettingsCardalias / 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>

View File

@ -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 TabRadix 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=truecamera tab + 推論中 + 線上)
expect(lastCameraWsEnabled()).toBe(true);
// 尚未呼叫 stop
expect(post).not.toHaveBeenCalledWith("/api/camera/stop", expect.anything());
// 切到 image tab
await switchTab("圖片");
// handleTabChange 應自動停掉 cameraPOST /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 tabcamera WS 不應又被打開
expect(lastCameraWsEnabled()).toBe(false);
});
});

View File

@ -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-M20 = 不限。 */
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);
// 目前選中的 tabcamera / image / video / batch
// 用於:離開 camera tab 時停掉 camera 串流 + WS避免與 media tab 的 WS 訂閱同時寫入同一個 store。
const [activeTab, setActiveTab] = useState("camera");
// 塊 1Camera MJPEG 串流 URLstart 成功後由後端回傳的 streamUrl 組出)
const [streamUrl, setStreamUrl] = useState("");
// 塊 2CameraFeed `<img>` 實際 render 尺寸(供 overlay 換算 normalized bbox → px
@ -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 */}

View File

@ -0,0 +1,125 @@
/**
* FlashDialog flash UI
*
*
* - disabled prop
* - dialog fetchModels reset flash
* - flash isFlashing
* - flash percent>=100 fetchDevicegate + 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 完成 → 顯示完成按鈕,點完成 fetchDevicegate 解鎖)+ 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();
});
});

View 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.4flash `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;
/** 裝置離線時不可 flashdisable 觸發鈕(呼叫端傳入)。 */
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 renderreact-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>
);
}

View 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()`
* - tokenbg-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>
);
}

View File

@ -0,0 +1,208 @@
/**
* MediaTab 3
*
*
* - uploader
* - CameraFeedimg+
* - isOnline=false uploader
* - videostore frameIndex/totalFrames + seek slider
* - batchstore 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();
});
});

View File

@ -0,0 +1,263 @@
"use client";
/**
* MediaTab / / tab 3
*
* media §0.1
* streamUrl CameraFeedMJPEG `<img>`+ CameraOverlaycanvas bbox
* + InferencePanelWS camera 12
*
* 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);
// 上傳成功後訂閱 WSuploaded && 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 保存最新的 clearStreameffect 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;
}

View File

@ -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);
});
});

View File

@ -0,0 +1,185 @@
"use client";
/**
* MediaUploader 3 / /
*
*
* - FileDropzone /
* - / /
* - caller `onUpload(files)` tab sourceType API
* - + +
*
* callerMediaTab streamUrl CameraFeed
* +
*
* a11ydropzone 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>
);
}

View 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 POSThasStartedRef
*
* B / 2
* - beginFlash enabled=truePOST 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("契約差異 BbeginFlash 只 enable WSPOST 延到 onOpen 才送(順序保證)", () => {
const { result, rerender } = renderHook(() => useFlashProgress("dev-1"));
// beginFlash 當下不應 POSTWS 還沒 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("重連防重複 POSTonOpen 再次觸發不重複 POSThasStartedRef", () => {
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);
});
});

View 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` falsetrue open promise
* `setEnabled(true)` POST racePOST WS open progress
*
* ** hook 2 race**
* `useWebSocket` `onOpen` callback POST `beginFlash(modelId)`
* `enabled=true` + modelIdWS open `onOpen` `startFlash(deviceId, modelId)`
* open WS POSTflash progress percent
*
* ** POST`hasStartedRef`**
* `onOpen` **** POST flash
* `hasStartedRef` flash POST onOpen POST progress
*
* same-origin cookievisiona_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 要送的 modelIdbeginFlash 設定、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 };
}

View File

@ -0,0 +1,69 @@
/**
* hardware-compat flash UI
*
* CtargetChip +
* - 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("");
});
});

View File

@ -0,0 +1,61 @@
/**
* visionA Cloudflash 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 || "";
}

View File

@ -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",

View File

@ -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": "設定",

View 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");
});
});
/* -------------------------------------------------------------------------- */
/* Uploadmock 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 objectclosure 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 / uploadBatchImagesXHR mock", () => {
const origXHR = globalThis.XMLHttpRequest;
beforeEach(() => {
lastXhr = null;
});
afterEach(() => {
(globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR;
});
it("uploadImagePOST /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/seekbody 帶 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();
});
});

View 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` CameraFeedMJPEG `<img>`+ overlay + WS
* camera / 12 .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 APIPOST /api/media/seekbody `{ 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 index0-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 {
/** 上傳進度 callback0~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 cookieBFF 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;
}

View File

@ -0,0 +1,137 @@
/**
* flash-store flash UI
*
*
* - Athrow-basedstartFlash / api throw ApiError setError
* - updateProgresserror 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 clientstartFlash 依賴 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("契約差異 Aapi 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 });
});
});

View 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.2REST + WS local agent
* - api clientapi.tsthrow-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/catchcatch `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 payloadraw JSONapi-server 透明轉發、不含 envelope */
/* -------------------------------------------------------------------------- */
/**
* flash driver.FlashProgress interface.go:42-47
* - percent0-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-basedApiError 帶 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 });
},
}));

View File

@ -40,7 +40,10 @@ export interface StreamState {
/**
* Media / Camera response data
*
* camera_handler.go handler `data` streamUrl
* camera_handler.go handler `data` streamUrl
* - camera / imagestreamUrl + sourceType (+ width/height/filename)
* - videoUploadVideo:326-335 totalFrames / durationSeconds
* - batchUploadBatchImages: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[];
}
/** 批次上傳後回傳的每張圖 metadatacamera_handler.go:459-465。 */
export interface BatchImageEntry {
index: number;
filename: string;
width: number;
height: number;
}
/** SeekVideoPOST /api/media/seek回傳的 datacamera_handler.go:615-621。 */
export interface SeekResponse {
seekTo: number;
frameOffset: number;
}