feat(frontend): flash 完成工作區入口醒目化 + 影片上限 200→500MB

驗收後兩個 UX 微調:
- flash 載入模型完成後「開啟工作區」入口不明顯、使用者要自己找 → Button
  改 size=lg + ArrowRight 前往圖示 + ring-primary/40 highlight(設計系統
  token、不裸色碼)。保留手動導航(點擊才去、無自動 router.push)、serial
  空 disable + 離線 disable gate 維持
- 影片上傳上限 MAX_VIDEO_BYTES 200→500MB + i18n 兩語系 hint 文案。影片
  upload timeout 本來就是 0(不限、後端決定)→ 500MB 不會撞 timeout

Reviewer 通過(0C/0M/0Mi/1Sug、timeout 獨立驗證、i18n 無殘留 200MB)。
tsc/eslint 0、vitest 39 passed(499/500/501MB 邊界 mock size 無 OOM +
醒目化樣式/行為)、next build 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-21 01:17:11 +08:00
parent 67737334c8
commit af44a9f9ba
6 changed files with 56 additions and 6 deletions

View File

@ -192,6 +192,20 @@ describe("DeviceDetailClient serial 路由 gatingWP-C / ADR-018", () => {
expect(link).toHaveAttribute("href", "/workspace/dev-1"); 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 按鈕、序號欄顯示未回報", () => { it("serial 為空flash 換成 disabled 按鈕、序號欄顯示未回報", () => {
useDeviceStore.setState({ useDeviceStore.setState({
selectedDevice: { ...onlineDevice, serialNumber: null }, selectedDevice: { ...onlineDevice, serialNumber: null },

View File

@ -23,7 +23,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; 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 { toast } from "sonner";
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge"; import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
@ -259,7 +259,8 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
disable 使 400 bug disable 使 400 bug
WP-CADR-018flash serial serial disable + tooltip WP-CADR-018flash serial serial disable + tooltip
flash dialog fetchDevice flashedModel flash dialog fetchDevice flashedModel
gate gate */} gate gate
size=lg + + ring highlight */}
{hasSerial ? ( {hasSerial ? (
<FlashDialog <FlashDialog
deviceId={selectedDevice.id} deviceId={selectedDevice.id}
@ -278,9 +279,21 @@ export function DeviceDetailClient({ id }: DeviceDetailClientProps) {
</Tooltip> </Tooltip>
)} )}
{/* flash 使
push
size=lg+ + primary ring
highlight --primary token使
serial disable gate */}
{isOnline && selectedDevice.flashedModel && hasSerial && ( {isOnline && selectedDevice.flashedModel && hasSerial && (
<Link href={`/workspace/${selectedDevice.id}`}> <Link href={`/workspace/${selectedDevice.id}`}>
<Button>{t("devices.openWorkspace")}</Button> <Button
size="lg"
data-testid="device-open-workspace-link"
className="ring-primary/40 ring-2 ring-offset-2"
>
{t("devices.openWorkspace")}
<ArrowRight aria-hidden="true" className="size-4" />
</Button>
</Link> </Link>
)} )}
{isOnline && selectedDevice.flashedModel && !hasSerial && ( {isOnline && selectedDevice.flashedModel && !hasSerial && (

View File

@ -362,7 +362,7 @@ export const en: Dictionary = {
"workspace.media.image.primary": "Drag an image here to run inference", "workspace.media.image.primary": "Drag an image here to run inference",
"workspace.media.image.hint": "JPG or PNG · up to 20 MB", "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.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.frame": "Frame",
"workspace.media.video.seek": "Seek video", "workspace.media.video.seek": "Seek video",
"workspace.media.batch.primary": "Drag images here (up to 50) for batch inference", "workspace.media.batch.primary": "Drag images here (up to 50) for batch inference",

View File

@ -350,7 +350,7 @@ export const zhHant: Dictionary = {
"workspace.media.image.primary": "將圖片拖曳到此處開始推論", "workspace.media.image.primary": "將圖片拖曳到此處開始推論",
"workspace.media.image.hint": "JPG 或 PNG · 上限 20 MB", "workspace.media.image.hint": "JPG 或 PNG · 上限 20 MB",
"workspace.media.video.primary": "將影片拖曳到此處開始推論", "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.frame": "影格",
"workspace.media.video.seek": "影片跳轉", "workspace.media.video.seek": "影片跳轉",
"workspace.media.batch.primary": "將圖片拖曳到此處(最多 50 張)進行批次推論", "workspace.media.batch.primary": "將圖片拖曳到此處(最多 50 張)進行批次推論",

View File

@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
MAX_BATCH_IMAGES, MAX_BATCH_IMAGES,
MAX_VIDEO_BYTES,
VIDEO_FALLBACK_FPS, VIDEO_FALLBACK_FPS,
buildBatchImageUrl, buildBatchImageUrl,
frameToSeekSeconds, frameToSeekSeconds,
@ -27,6 +28,16 @@ function makeFile(name: string, sizeBytes = 1024): File {
return new File([blob], name); 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", () => { describe("media validation", () => {
it("圖片JPG/PNG 通過,其他型別擋 TYPE", () => { it("圖片JPG/PNG 通過,其他型別擋 TYPE", () => {
expect(validateImageFile(makeFile("a.jpg"))).toBeNull(); expect(validateImageFile(makeFile("a.jpg"))).toBeNull();
@ -48,6 +59,18 @@ describe("media validation", () => {
expect(validateVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE"); 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", () => { it("批次:空陣列 → EMPTY超過 50 → COUNT含非圖 → TYPE", () => {
expect(validateBatchFiles([])?.code).toBe("EMPTY"); expect(validateBatchFiles([])?.code).toBe("EMPTY");

View File

@ -91,7 +91,7 @@ export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg";
/** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2。 */ /** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2。 */
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB 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大檔經 tunnelcaller 端 timeout 設 0 = 不限,見 workspace-client VIDEO_UPLOAD_TIMEOUT_MS
export interface UploadMediaOptions { export interface UploadMediaOptions {
/** 上傳進度 callback0~100 */ /** 上傳進度 callback0~100 */