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>
140 lines
5.3 KiB
TypeScript
140 lines
5.3 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* ExpiredView — Expired state(轉檔結果已過期、引導重做)
|
||
*
|
||
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.6 + §8.2)
|
||
*
|
||
* 對齊:
|
||
* - flow-conversion.md §6.3(Job 7 天後過期 — bootstrap / polling 自動切 expired;UI 引導重做)
|
||
* - feature-converter-integration.md §F4(converter 7 天 GC,UI 顯式提醒)
|
||
* - flow §11 (Q4) — 「重新轉檔」放在 hero 卡內,動線單一
|
||
*
|
||
* 範圍(F-T9 sub-1):
|
||
* - hero 區塊:⚠️ 過期提示(紅色但不 alarming — 過期是預期行為,不是錯誤)
|
||
* - 任務摘要:source filename → target chip(與 success / failed view 對齊)
|
||
* - 解釋訊息(為什麼過期 + 怎麼處理)
|
||
* - 「重新轉檔」主按鈕 → store.reset() 切 idle
|
||
*
|
||
* a11y:
|
||
* - hero 用 `role="status"` + `aria-live="polite"`(過期不是緊急錯誤,用 polite 而非 assertive)
|
||
* —— 過去用 `role="alert"` + `aria-live="polite"` 是 ARIA 反 pattern:alert 隱含 assertive,
|
||
* 兩者並存某些 SR 會視為矛盾(F-T9 M1 修補時順手調整)
|
||
* - 「重新轉檔」按鈕有明確 aria-label
|
||
* - 顏色:橘紅而非 destructive,避免暗示「失敗」
|
||
*
|
||
* 不做:
|
||
* - 「複製 Job ID」(過期 job 的 ID 對 ops 沒意義 — converter 已 GC)
|
||
* - 倒數計時(已過期,不需要)
|
||
*/
|
||
|
||
import { Clock4Icon, RefreshCwIcon } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import { useT } from "@/lib/i18n/context";
|
||
import { useConversionStore } from "@/stores/conversion-store";
|
||
import type { ConversionJob } from "@/types/conversion";
|
||
|
||
export function ExpiredView() {
|
||
const t = useT();
|
||
const job = useConversionStore((s) => s.job);
|
||
const reset = useConversionStore((s) => s.reset);
|
||
|
||
// bootstrap 命中 expired 時 store 一定會塞 job;保險起見 job=null 也能渲(極端情境:
|
||
// GET /active 回 404 但前端誤切 expired — 不該發生)
|
||
return <ExpiredViewInner job={job} reset={reset} t={t} />;
|
||
}
|
||
|
||
interface ExpiredViewInnerProps {
|
||
job: ConversionJob | null;
|
||
reset: () => void;
|
||
t: (key: string) => string;
|
||
}
|
||
|
||
function ExpiredViewInner({ job, reset, t }: ExpiredViewInnerProps) {
|
||
const sourceFilename = job?.source_filename ?? "";
|
||
const targetChip = job?.target_chip ?? "";
|
||
|
||
const handleStartNew = () => {
|
||
// store.reset() 會清掉 polling / upload controller;切回 idle 後 page.tsx 自動換 IdleForm
|
||
reset();
|
||
};
|
||
|
||
return (
|
||
<div data-testid="conversion-expired" className="space-y-6">
|
||
{/* 過期 hero — role="status" + aria-live="polite"(預期行為,不需 assertive 打斷)
|
||
注意:原本用 role="alert" 會隱含 assertive,再寫 aria-live="polite" 會與 SR 行為矛盾;
|
||
已改為 role="status"(implicit polite),與 ProcessingView banner 的寫法一致 */}
|
||
<div
|
||
role="status"
|
||
aria-live="polite"
|
||
aria-label={t("conversion.expired.aria.alert")}
|
||
data-testid="expired-hero"
|
||
className="border-amber-300 bg-amber-50/40 dark:border-amber-800 dark:bg-amber-950/20 flex items-start gap-3 rounded-xl border p-6"
|
||
>
|
||
<Clock4Icon
|
||
aria-hidden="true"
|
||
className="size-6 shrink-0 text-amber-600 dark:text-amber-400"
|
||
/>
|
||
<div className="space-y-1">
|
||
<h2 className="text-lg font-semibold">
|
||
{t("conversion.expired.heading")}
|
||
</h2>
|
||
<p className="text-muted-foreground text-sm" data-testid="expired-description">
|
||
{t("conversion.expired.description")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 任務摘要(若 job 還有資訊則顯示,與 success / failed 視覺對齊) */}
|
||
{(sourceFilename || targetChip) && (
|
||
<div
|
||
data-testid="expired-summary"
|
||
className="bg-card space-y-3 rounded-xl border p-6"
|
||
>
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||
{sourceFilename ? (
|
||
<span className="font-mono" data-testid="expired-source-filename">
|
||
{sourceFilename}
|
||
</span>
|
||
) : null}
|
||
{sourceFilename && targetChip ? (
|
||
<span aria-hidden="true" className="text-muted-foreground">
|
||
→
|
||
</span>
|
||
) : null}
|
||
{targetChip ? (
|
||
<span
|
||
className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium"
|
||
data-testid="expired-target-chip"
|
||
>
|
||
{targetChip}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<p className="text-muted-foreground text-sm">
|
||
{t("conversion.expired.subDescription")}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主動作:重新轉檔 — 單一 CTA,動線清楚 */}
|
||
<div className="flex justify-end border-t pt-4">
|
||
<Button
|
||
type="button"
|
||
variant="default"
|
||
size="lg"
|
||
onClick={handleStartNew}
|
||
aria-label={t("conversion.expired.aria.startNew")}
|
||
data-testid="expired-start-new"
|
||
className="gap-2"
|
||
>
|
||
<RefreshCwIcon aria-hidden="true" className="size-4" />
|
||
{t("conversion.expired.startNew")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|