jim800121chen e02059eff2 feat(visionA-frontend): Phase 0.8 conversion UI — sidebar tab + 6 view + 5 e2e flow tests
Phase 0.8 對應後端轉檔功能的 frontend UI。完整 state machine(idle / uploading /
queued / running / succeeded / failed / expired)+ XHR upload progress + polling +
half-auto result handling(加到模型庫 / 下載)。

新增 src/app/conversion/(11 元件 + 12 test files,~5,000 行 prod+test,310/310 tests 全綠):

- page.tsx:state router(mount → bootstrap → 依 store.uiState 切換 view)+ toast on
  store.error(aborted / active_job_exists 不 toast,避免雙重提示)
- IdleForm.tsx + FileDropzone.tsx + ChipSelector.tsx:上傳表單(拖放 + .onnx/.tflite
  format 驗證 + 500MB 大小限制 + KL520/630/720/730 chip 選擇 + ref images ≤100×10MB)
- UploadingView.tsx:XHR upload progress 0-100% + EWMA ETA(alpha=0.3 平滑,<5s 顯示
  「即將完成」避抖動)+ beforeunload warning + AlertDialog 取消
- ProcessingView.tsx:StageIndicator 三段式(解析模型 → 量化編譯 → 編譯 NEF)+
  Progress bar 三模式(queued / determinate / indeterminate)+ tab title `(轉檔中)` 防疊加 +
  409 active_job_exists banner(switchedFromActiveJob flag)
- SuccessView.tsx + PromoteDialog.tsx:兩按鈕(加到模型庫 / 下載)視覺平衡不暗示預設答案 +
  7 天 expires_at 倒數(mount 時 setTimeout 鎖過期那刻 + 60s tick)+ PromoteDialog 單欄位
  name 驗證(≤100 字元 / 無 /\\)+ spinner-during-close 阻擋 + 409 already_imported 特殊
  訊息 + toast.success router.push 跳模型清單
- FailedView.tsx:5 個 known error code 翻譯(UNSUPPORTED_FORMAT / INVALID_CHECKSUM /
  QUANTIZATION_FAILED / MODEL_TOO_LARGE / QUOTA_EXCEEDED)+ unknown fallback +
  3 個建議解決方法 + Job ID 前 8 字元(供回報)+ 「重新開始」
- ExpiredView.tsx:橘色 hero(過期不 alarming)+ source/chip 摘要 + 「重新轉檔」→
  store.reset()

新增 src/stores/conversion-store.ts(Zustand store + 29 tests):
- 7 個 UI state machine(不允許跳階段)
- recursive setTimeout polling(running 5s / queued 10s / 5xx 指數退避 cap 30s)+
  visibilitychange 暫停/恢復
- 不持久化 jobId(純靠 backend getActiveConversion() lazy rebuild ownership)
- AbortController 防 stale request + 取消上傳
- switchedFromActiveJob flag(409 自動切到既存 job + UI 顯示 banner)
- formDraft(chip / taskName 提到 store,failed→idle 後保留設定,file picker 重選;
  File 物件不能序列化只留 local)

新增 src/lib/api/conversion.ts + types/conversion.ts(5 client 函式 + 22 tests):
- initConversion:XHR multipart + onUploadProgress + AbortSignal
- getActiveConversion / getConversion / promoteConversionToModels:標準 fetch
- getConversionDownloadURL:純函式,回 `/api/conversion/{job_id}/download` 給 anchor
  download 觸發(server-side 302 → FAA,token 不過 frontend JS)
- ConversionAPIError(status, code, message, requestId?),code 統一全小寫對齊
  conversion.error.<code> i18n key 命名

新增 src/lib/utils/eta.ts(EWMA 演算法純函式 + 19 tests):抽出 smoothSpeed /
estimateRemainingSeconds / instantSpeedBytesPerSec / computeEtaUpdate(test 友善)。

新增 src/app/conversion/e2e-conversion-flow.test.tsx(5 e2e flow tests):
- happy path .onnx + KL720 + 0 ref images(idle → upload → polling → succeeded → 加到模型庫)
- variant 5 ref images
- upload fail → retry(form 設定保留)
- polling 5xx 指數退避 → 恢復繼續
- expired job → ExpiredView → 重新轉檔

修改 sidebar.tsx:左側 nav 新增「轉檔」tab(Wand2 icon,模型庫之後)。
修改 i18n 字典:新增 ~150 個 conversion.* keys(中英雙語對齊)。

PRD §9 14 條功能驗收條件全達成(4 條整合驗收等 stage 部署)。

驗證:pnpm lint / test (310/310) / build 全綠。

對齊文件:
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md
- .autoflow/04-architecture/api/api-conversion.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:56:54 +08:00

167 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* FileDropzone — 通用拖放 / 點擊選檔元件
*
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §4.1)
*
* 設計目標:
* - 同時被「來源模型」單檔accept=.onnx,.tflite與「Reference images」多檔accept=image/*)使用
* - 拖放與點擊兩種互動都觸發同一個 `onSelect`(呼叫端拿到 File[],再依 multiple 與否處理)
* - 已選檔的呈現由呼叫端負責(這裡只做「未選」的 dropzone 視覺;選了之後呼叫端會 hide 這個元件,改顯示 chips / list
*
* a11y
* - dropzone 本體是 `<label>` 包 `<input type="file" class="sr-only">`,鍵盤 Tab + Enter / Space 直接觸發 file picker瀏覽器原生
* - 整體區塊用 `aria-label` 描述功能dragover 時加 `aria-busy="true"`
* - 不用 role="button",避免覆蓋 label/input 的原生語義
*
* F-T4 不負責的:
* - 檔案驗證(副檔名 / size由呼叫端處理 — 這裡只是 UI 殼
* - 拖放動畫visualOnly hint dragover 高亮即可)
*/
import { Upload } from "lucide-react";
import {
type DragEvent,
type ReactNode,
useCallback,
useId,
useState,
} from "react";
import { cn } from "@/lib/utils";
interface FileDropzoneProps {
/** `<input accept>` — `.onnx,.tflite` 或 `image/*` 之類 */
accept: string;
/** 是否允許多檔ref images 用) */
multiple?: boolean;
/** 主要文字(拖放區大字) — 例:「拖曳 .onnx / .tflite 到此處」 */
primaryLabel: string;
/** 「或」之類連接文字,不傳則不顯示 */
orLabel?: string;
/** 點擊按鈕文字 — 例:「選擇檔案」 */
browseLabel: string;
/** 下方 hint — 例:「支援格式:.onnx · .tflite · 最大 500 MB」 */
hint?: string;
/** 拖放或選檔後 callback總是傳 File[](呼叫端再依 multiple 處理) */
onSelect: (files: File[]) => void;
/** disabled 時整塊變灰並擋互動 */
disabled?: boolean;
/** 整塊高度ref images dropzone 比 source dropzone 矮 */
size?: "default" | "compact";
/** 自訂 testid方便測試區分多個 dropzone */
"data-testid"?: string;
/** 給 form label 用的 aria-describedby指向錯誤訊息 id */
errorId?: string;
/** 外部 className override */
className?: string;
/** 額外 children理論上不用但保留 */
children?: ReactNode;
}
export function FileDropzone({
accept,
multiple = false,
primaryLabel,
orLabel,
browseLabel,
hint,
onSelect,
disabled = false,
size = "default",
"data-testid": dataTestId,
errorId,
className,
children,
}: FileDropzoneProps) {
const inputId = useId();
const [isDragOver, setIsDragOver] = useState(false);
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
onSelect(Array.from(fileList));
},
[onSelect],
);
const handleDragOver = useCallback(
(e: DragEvent<HTMLLabelElement>) => {
if (disabled) return;
e.preventDefault();
// 只有在 dataTransfer 帶檔案時才高亮(避免拖文字 / link 也觸發)
if (e.dataTransfer.types.includes("Files")) {
setIsDragOver(true);
}
},
[disabled],
);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(e: DragEvent<HTMLLabelElement>) => {
if (disabled) return;
e.preventDefault();
setIsDragOver(false);
handleFiles(e.dataTransfer.files);
},
[disabled, handleFiles],
);
const heightCls = size === "compact" ? "min-h-20 py-4" : "min-h-32 py-6";
return (
<label
htmlFor={inputId}
data-testid={dataTestId}
data-drag-over={isDragOver ? "true" : undefined}
aria-label={primaryLabel}
aria-busy={isDragOver || undefined}
aria-describedby={errorId}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"bg-muted/40 hover:bg-muted/60 border-input flex w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 text-center transition-colors",
heightCls,
isDragOver && "border-primary bg-primary/5",
disabled && "cursor-not-allowed opacity-60 hover:bg-muted/40",
className,
)}
>
<Upload aria-hidden="true" className="text-muted-foreground size-5" />
<div className="text-foreground text-sm font-medium">{primaryLabel}</div>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
{orLabel ? <span>{orLabel}</span> : null}
<span className="text-primary underline-offset-2 group-hover:underline">
{browseLabel}
</span>
</div>
{hint ? (
<p className="text-muted-foreground text-xs">{hint}</p>
) : null}
{children}
{/* 真正的 file input — 用 sr-only 讓鍵盤 / 螢幕閱讀器仍可使用 */}
<input
id={inputId}
type="file"
accept={accept}
multiple={multiple}
disabled={disabled}
className="sr-only"
onChange={(e) => {
handleFiles(e.target.files);
// reset value 讓同一檔案重選也會觸發 onChange
e.target.value = "";
}}
/>
</label>
);
}