diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx index 52110ea..7ef6a82 100644 --- a/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.test.tsx @@ -192,6 +192,20 @@ describe("DeviceDetailClient serial 路由 gating(WP-C / ADR-018)", () => { expect(link).toHaveAttribute("href", "/workspace/dev-1"); }); + it("flash 完成後開啟工作區入口做醒目樣式(size=lg + 具名 testid,供使用者一眼發現)", () => { + useDeviceStore.setState({ + selectedDevice: { ...onlineDevice, flashedModel: "yolo-v5" }, + }); + renderDetail(); + const btn = screen.getByTestId("device-open-workspace-link"); + // size=lg → h-10(比其他 default h-9 操作鈕大) + expect(btn.className).toContain("h-10"); + // ring highlight(用設計系統 --primary token,非裸色碼) + expect(btn.className).toContain("ring-primary/40"); + // 仍是可導航的連結入口(維持手動點擊、不自動跳) + expect(btn.closest("a")).toHaveAttribute("href", "/workspace/dev-1"); + }); + it("serial 為空:flash 換成 disabled 按鈕、序號欄顯示未回報", () => { useDeviceStore.setState({ selectedDevice: { ...onlineDevice, serialNumber: null }, diff --git a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx index 3dbecdb..bd85217 100644 --- a/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx +++ b/visionA-frontend/src/app/devices/[id]/device-detail-client.tsx @@ -23,7 +23,7 @@ import { useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { AlertTriangle, ArrowLeft, Trash2 } from "lucide-react"; +import { AlertTriangle, ArrowLeft, ArrowRight, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge"; @@ -259,7 +259,8 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) { 提早 disable 避免使用者按了才收 400(診斷原始 bug)。 WP-C(ADR-018):flash 走 serial 路由,serial 為空時同樣 disable + tooltip。 flash 完成後 dialog 內部呼叫 fetchDevice → flashedModel 有值 → 下方 - 「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯)。 */} + 「開啟工作區」按鈕自動出現(gate 解鎖,無需改 gate 邏輯);該按鈕 + 做醒目樣式(size=lg + 箭頭 + ring highlight)讓完成後易於發現。 */} {hasSerial ? ( )} + {/* flash 完成後的主要動作入口 — 使用者反映原本樣式不明顯要自己找。 + 做法:維持手動導航(點擊才去、不自動 push,避免誤導),但把入口 + 做醒目:size=lg(比其他操作鈕大)+ 前往箭頭圖示 + primary ring + highlight(用設計系統 --primary token,不裸寫色碼),讓使用者一眼 + 看到燒錄完成後該點哪。serial 為空時仍走下方 disable 分支(gate 不變)。 */} {isOnline && selectedDevice.flashedModel && hasSerial && ( - + )} {isOnline && selectedDevice.flashedModel && !hasSerial && ( diff --git a/visionA-frontend/src/lib/i18n/dictionaries/en.ts b/visionA-frontend/src/lib/i18n/dictionaries/en.ts index adcf1f2..694f525 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/en.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/en.ts @@ -362,7 +362,7 @@ export const en: Dictionary = { "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.hint": "MP4 / AVI / MOV / MPEG · up to 500 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", diff --git a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts index 82b66d1..f7a13c9 100644 --- a/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts +++ b/visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts @@ -350,7 +350,7 @@ export const zhHant: Dictionary = { "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.hint": "MP4 / AVI / MOV / MPEG · 上限 500 MB", "workspace.media.video.frame": "影格", "workspace.media.video.seek": "影片跳轉", "workspace.media.batch.primary": "將圖片拖曳到此處(最多 50 張)進行批次推論", diff --git a/visionA-frontend/src/lib/media.test.ts b/visionA-frontend/src/lib/media.test.ts index 840c495..92639e7 100644 --- a/visionA-frontend/src/lib/media.test.ts +++ b/visionA-frontend/src/lib/media.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_BATCH_IMAGES, + MAX_VIDEO_BYTES, VIDEO_FALLBACK_FPS, buildBatchImageUrl, frameToSeekSeconds, @@ -27,6 +28,16 @@ function makeFile(name: string, sizeBytes = 1024): File { return new File([blob], name); } +/** + * 建立「宣稱」指定 size 的 File,但不實際配置該大小的記憶體。 + * 用於大檔邊界測試(500 MB 若真配置 Uint8Array 會 OOM)——只覆寫 File.size getter。 + */ +function makeSizedFile(name: string, sizeBytes: number): File { + const file = new File([new Blob([new Uint8Array(8)])], name); + Object.defineProperty(file, "size", { value: sizeBytes, configurable: true }); + return file; +} + describe("media validation", () => { it("圖片:JPG/PNG 通過,其他型別擋 TYPE", () => { expect(validateImageFile(makeFile("a.jpg"))).toBeNull(); @@ -48,6 +59,18 @@ describe("media validation", () => { expect(validateVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE"); }); + it("影片:上限為 500 MB(常數校驗)", () => { + expect(MAX_VIDEO_BYTES).toBe(500 * 1024 * 1024); + }); + + it("影片:499 MB 通過,剛好 500 MB 通過,501 MB 擋 SIZE(邊界)", () => { + const mb = 1024 * 1024; + expect(validateVideoFile(makeSizedFile("v.mp4", 499 * mb))).toBeNull(); + // 剛好等於上限:validateVideoFile 用 > 判斷,等於上限應通過 + expect(validateVideoFile(makeSizedFile("v.mp4", 500 * mb))).toBeNull(); + expect(validateVideoFile(makeSizedFile("v.mp4", 501 * mb))?.code).toBe("SIZE"); + }); + it("批次:空陣列 → EMPTY,超過 50 → COUNT,含非圖 → TYPE", () => { expect(validateBatchFiles([])?.code).toBe("EMPTY"); diff --git a/visionA-frontend/src/lib/media.ts b/visionA-frontend/src/lib/media.ts index a9dc981..9a0906b 100644 --- a/visionA-frontend/src/lib/media.ts +++ b/visionA-frontend/src/lib/media.ts @@ -91,7 +91,7 @@ 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 const MAX_VIDEO_BYTES = 500 * 1024 * 1024; // 500 MB(大檔經 tunnel,caller 端 timeout 設 0 = 不限,見 workspace-client VIDEO_UPLOAD_TIMEOUT_MS) export interface UploadMediaOptions { /** 上傳進度 callback(0~100) */