"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 用 `` 標記(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..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 ; } 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 (
{/* 失敗 hero — role="alert" + aria-live="assertive"(重要錯誤訊息應主動播報) */}
{/* 任務摘要 + 錯誤碼 + Job ID */}
{/* 檔名 → chip(含「失敗」字尾) */}
{sourceFilename ? ( {sourceFilename} ) : null} {targetChip} {t("conversion.failed.summary.failedSuffix")}
{/* 錯誤代碼 + Job ID — dl 結構,給 SR 標籤明確 */}
{t("conversion.failed.errorCode")}
{errorCode}
{t("conversion.failed.jobIdLabel")}
{shortJobId}
{/* 建議解決方法 */} {suggestions.length > 0 ? (

{t("conversion.failed.suggestionsTitle")}

    {suggestions.map((s, idx) => (
  • {s}
  • ))}
) : null} {/* 主動作:重新開始(wireframe §3.5:在卡片外面下方對齊右側) */}
); }