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>
275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
"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 §6(error_code 在 backend 直接傳入,非 i18n key 形式)
|
||
*
|
||
* 範圍(F-T8):
|
||
* - 失敗 hero(紅色 ✗ + 「轉檔失敗」title),role="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-have;F-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 §78–92);我們用「翻譯結果 === 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;
|
||
|
||
// fallback:unknown 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 必有 job(store 切 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>
|
||
);
|
||
}
|