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

140 lines
5.3 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";
/**
* ExpiredView — Expired state轉檔結果已過期、引導重做
*
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.6 + §8.2)
*
* 對齊:
* - flow-conversion.md §6.3Job 7 天後過期 — bootstrap / polling 自動切 expiredUI 引導重做)
* - feature-converter-integration.md §F4converter 7 天 GCUI 顯式提醒)
* - 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 反 patternalert 隱含 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>
);
}