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:
parent
67737334c8
commit
af44a9f9ba
@ -192,6 +192,20 @@ describe("DeviceDetailClient serial 路由 gating(WP-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 },
|
||||||
|
|||||||
@ -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-C(ADR-018):flash 走 serial 路由,serial 為空時同樣 disable + tooltip。
|
WP-C(ADR-018):flash 走 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 && (
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 張)進行批次推論",
|
||||||
|
|||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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(大檔經 tunnel,caller 端 timeout 設 0 = 不限,見 workspace-client VIDEO_UPLOAD_TIMEOUT_MS)
|
||||||
|
|
||||||
export interface UploadMediaOptions {
|
export interface UploadMediaOptions {
|
||||||
/** 上傳進度 callback(0~100) */
|
/** 上傳進度 callback(0~100) */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user