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

275 lines
10 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";
/**
* FailedView — Failed state轉檔失敗、錯誤訊息翻譯 + 建議解決方法)
*
* Phase 0.8 conversion (見 .autoflow/03-design/wireframes/wireframe-conversion.md §3.5 + §8)
*
* 對齊:
* - flow-conversion.md §5.6 failed → 顯示翻譯訊息 + 建議 + 重新開始
* - feature-converter-integration.md §F5錯誤碼對照表 + unknown fallback
* - api-conversion.md §6error_code 在 backend 直接傳入,非 i18n key 形式)
*
* 範圍F-T8
* - 失敗 hero紅色 ✗ + 「轉檔失敗」titlerole="alert" + aria-live="assertive"
* - 任務摘要source filename → target chip含「失敗」字尾
* - 錯誤訊息卡片:翻譯後的訊息 + error code給 ops+ Job ID 前 8 字元
* - 建議解決方法清單(最多 3 項;找不到對應 key 走 unknown fallback
* - 「重新開始」主按鈕 → store.reset() 切回 idle
* - i18n key fallback未知 code 一律走 conversion.error.unknown.*
*
* 不做:
* - 「回模型庫」按鈕wireframe §8 有列出但任務未要求;可由 sidebar 切換)
* - 「複製任務 ID」按鈕wireframe §8 有 nice-to-haveF-T9 視情況補)
*
* a11y
* - 失敗 hero `role="alert"` + `aria-live="assertive"`(重要訊息、即時播報)
* - error code 用 `<code>` 標記pre-formatted、SR 不會逐字朗讀)
* - 「重新開始」按鈕有明確 aria-label
* - Dark mode 對齊既有 destructive token
*/
import { AlertCircleIcon, RefreshCwIcon } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import type { TranslateFn } from "@/lib/i18n/context";
import { useT } from "@/lib/i18n/context";
import { useConversionStore } from "@/stores/conversion-store";
import type { ConversionJob } from "@/types/conversion";
/** Job ID 前 N 字元 — wireframe §3.5 顯示「abcd1234前 8 字元)」 */
const JOB_ID_DISPLAY_PREFIX = 8;
/** Suggestions 上限 — i18n key 設計支援 suggestion1/2/3 */
const MAX_SUGGESTIONS = 3;
/** Unknown fallback 用的 i18n code key 段 */
const UNKNOWN_CODE_KEY = "unknown";
/* -------------------------------------------------------------------------- */
/* Helpers — 安全的 i18n lookup找不到 key 不要 throw / 不要顯示 raw key */
/* -------------------------------------------------------------------------- */
/**
* 安全取得錯誤訊息 — 找不到對應 i18n key 時,自動 fallback 到 `conversion.error.unknown.message`。
*
* useT 的 t(key) 在找不到時回傳 key 本身context.tsx §7892我們用「翻譯結果 === key」
* 偵測 miss然後 fallback。對齊 flow-conversion.md §5.6 的虛擬碼。
*
* @param t - useT 取得的翻譯函式
* @param code - converter 回傳的 error code如 `QUANTIZATION_FAILED` / 任意未知字串)
* @returns 翻譯後的文字fallback 失敗(連 unknown key 都不存在)時回空字串
*/
export function getErrorMessage(t: TranslateFn, code: string): string {
const key = `conversion.error.${code}.message`;
const translated = t(key);
if (translated !== key) return translated;
// fallbackunknown code
const fallbackKey = `conversion.error.${UNKNOWN_CODE_KEY}.message`;
const fallback = t(fallbackKey);
if (fallback !== fallbackKey) return fallback;
// fallback 也找不到不應發生i18n 字典必含 unknown.message
return "";
}
/**
* 安全取得建議解決方法清單(最多 3 條)。
*
* 規則:
* 1. 先嘗試 `conversion.error.<code>.suggestion1/2/3`
* 2. 任一條找不到就停不繼續往下找3 條共用同一個 namespace
* 3. 全部找不到 → fallback 到 `conversion.error.unknown.suggestion1/2/3`
*
* 為什麼不混合(找到 1 條後 fallback 補 2、3
* - 各 code 的 suggestion 彼此語意不同QUANTIZATION_FAILED 的「移除 Custom Op」對 unknown 沒意義)
* - 若某 code 只有 suggestion1那它就只該顯示 1 條,補 unknown 的反而誤導
*/
export function getErrorSuggestions(t: TranslateFn, code: string): string[] {
const suggestions = collectSuggestions(t, code);
if (suggestions.length > 0) return suggestions;
return collectSuggestions(t, UNKNOWN_CODE_KEY);
}
/** 從特定 code 連續抓 suggestion1..N遇到第一個 miss 就停 */
function collectSuggestions(t: TranslateFn, code: string): string[] {
const out: string[] = [];
for (let i = 1; i <= MAX_SUGGESTIONS; i++) {
const key = `conversion.error.${code}.suggestion${i}`;
const translated = t(key);
if (translated === key) break;
out.push(translated);
}
return out;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export function FailedView() {
const t = useT();
const job = useConversionStore((s) => s.job);
const reset = useConversionStore((s) => s.reset);
// 防呆page.tsx 已 routing理論上 failed state 必有 jobstore 切 failed 同時設 job
if (!job) return null;
return <FailedViewInner job={job} reset={reset} t={t} />;
}
interface FailedViewInnerProps {
job: ConversionJob;
reset: () => void;
t: TranslateFn;
}
function FailedViewInner({ job, reset, t }: FailedViewInnerProps) {
/* ---- 衍生資料 ---- */
// error code優先用 job.error.code極端情況 backend 漏給時 fallback unknown
const errorCode = job.error?.code ?? UNKNOWN_CODE_KEY;
const errorMessage = useMemo(
() => getErrorMessage(t, errorCode),
[t, errorCode],
);
const suggestions = useMemo(
() => getErrorSuggestions(t, errorCode),
[t, errorCode],
);
const sourceFilename = job.source_filename || "";
const targetChip = job.target_chip;
const shortJobId = job.job_id.slice(0, JOB_ID_DISPLAY_PREFIX);
/* ---- handler ---- */
const handleRestart = () => {
// store.reset() 會清掉 polling / upload controller切回 idle 後 page.tsx 自動換 IdleForm
reset();
};
return (
<div data-testid="conversion-failed" className="space-y-6">
{/* 失敗 hero — role="alert" + aria-live="assertive"(重要錯誤訊息應主動播報) */}
<div
role="alert"
aria-live="assertive"
aria-label={t("conversion.failed.aria.alert")}
data-testid="failed-hero"
className="border-destructive/40 bg-destructive/5 dark:bg-destructive/10 flex items-start gap-3 rounded-xl border p-6"
>
<AlertCircleIcon
aria-hidden="true"
className="text-destructive size-6 shrink-0"
/>
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("conversion.failed.heading")}
</h2>
{/* 翻譯後的錯誤訊息unknown fallback 已內建於 helper */}
<p
data-testid="failed-message"
className="text-foreground text-sm"
>
{errorMessage}
</p>
</div>
</div>
{/* 任務摘要 + 錯誤碼 + Job ID */}
<div
data-testid="failed-summary"
className="bg-card space-y-3 rounded-xl border p-6"
>
{/* 檔名 → chip含「失敗」字尾 */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
{sourceFilename ? (
<span className="font-mono" data-testid="failed-source-filename">
{sourceFilename}
</span>
) : null}
<span aria-hidden="true" className="text-muted-foreground">
</span>
<span
className="bg-muted text-muted-foreground rounded px-2 py-0.5 text-xs font-medium"
data-testid="failed-target-chip"
>
{targetChip}
</span>
<span className="text-muted-foreground text-xs">
{t("conversion.failed.summary.failedSuffix")}
</span>
</div>
{/* 錯誤代碼 + Job ID — dl 結構,給 SR 標籤明確 */}
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs">
{t("conversion.failed.errorCode")}
</dt>
<dd>
<code
data-testid="failed-error-code"
className="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono text-xs"
>
{errorCode}
</code>
</dd>
</div>
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs">
{t("conversion.failed.jobIdLabel")}
</dt>
<dd
data-testid="failed-job-id"
className="font-mono text-xs"
// 完整 ID 給 ops 複製,但顯示只到前 8 字wireframe §3.5
title={job.job_id}
>
{shortJobId}
</dd>
</div>
</dl>
</div>
{/* 建議解決方法 */}
{suggestions.length > 0 ? (
<div
data-testid="failed-suggestions"
className="space-y-2"
>
<p className="text-sm font-medium">
{t("conversion.failed.suggestionsTitle")}
</p>
<ul
className="text-muted-foreground list-disc space-y-1 pl-5 text-sm"
data-testid="failed-suggestions-list"
>
{suggestions.map((s, idx) => (
<li key={idx} data-testid={`failed-suggestion-${idx}`}>
{s}
</li>
))}
</ul>
</div>
) : null}
{/* 主動作重新開始wireframe §3.5:在卡片外面下方對齊右側) */}
<div className="flex justify-end border-t pt-4">
<Button
type="button"
variant="default"
size="lg"
onClick={handleRestart}
aria-label={t("conversion.failed.aria.retry")}
data-testid="failed-restart"
className="gap-2"
>
<RefreshCwIcon aria-hidden="true" className="size-4" />
{t("conversion.failed.retry")}
</Button>
</div>
</div>
);
}