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>
167 lines
5.4 KiB
TypeScript
167 lines
5.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|