/** * 後端 envelope code / data.message → 語言包。 * 顯示時一律用目前 UI 語系,不直接秀後端英文。 */ import { useCallback } from "react"; import { useI18n } from "../i18n/I18nContext"; import { translate } from "./i18n/messages"; import { loadUiPrefs } from "./i18n/prefs"; import type { AppLocale } from "./i18n/types"; /** 與 backend internal/response + middleware 對齊 */ export const API_CODES = { SUCCESS: 102000, BAD_REQUEST: 400001, WEAK_PASSWORD: 400003, INVALID_CODE: 400004, SELF_SUSPEND: 400020, LAST_ADMIN: 400021, MISSING_AUTH: 401001, INVALID_TOKEN: 401002, MEMBER_NOT_FOUND_AUTH: 401003, BAD_PASSWORD: 401010, SUSPENDED: 403001, ADMIN_REQUIRED: 403002, NOT_FOUND: 404001, EMAIL_NOT_REGISTERED: 404002, EMAIL_TAKEN: 409001, SERVER: 500000, NOT_IMPLEMENTED: 501000, NETWORK: 0, } as const; /** code → messages.ts 的 key */ export const API_ERROR_I18N_KEY: Record = { [API_CODES.BAD_REQUEST]: "api.err.400001", [API_CODES.WEAK_PASSWORD]: "api.err.400003", [API_CODES.INVALID_CODE]: "api.err.400004", [API_CODES.SELF_SUSPEND]: "api.err.400020", [API_CODES.LAST_ADMIN]: "api.err.400021", [API_CODES.MISSING_AUTH]: "api.err.401001", [API_CODES.INVALID_TOKEN]: "api.err.401002", [API_CODES.MEMBER_NOT_FOUND_AUTH]: "api.err.401003", [API_CODES.BAD_PASSWORD]: "api.err.401010", [API_CODES.SUSPENDED]: "api.err.403001", [API_CODES.ADMIN_REQUIRED]: "api.err.403002", [API_CODES.NOT_FOUND]: "api.err.404001", [API_CODES.EMAIL_NOT_REGISTERED]: "api.err.404002", [API_CODES.EMAIL_TAKEN]: "api.err.409001", [API_CODES.SERVER]: "api.err.500000", [API_CODES.NOT_IMPLEMENTED]: "api.err.501000", [API_CODES.NETWORK]: "api.err.network", // 400050/400060 不進表:後端 message 常是具體中文原因(人設分析等),由 rawMessage 顯示 400061: "api.err.crawlerSession", 503001: "api.err.timeoutAI", }; export function i18nKeyForApiCode(code: number): string | undefined { return API_ERROR_I18N_KEY[code]; } /** * 後端 data.message(英文固定字)→ 語言包 key。 * 對齊 apps/backend internal/logic/** 回傳的 Message 字串。 */ const API_OK_MESSAGE_KEY: Record = { "verification code sent": "api.ok.verifySent", "verification code issued": "api.ok.verifyIssued", "reset code sent": "api.ok.resetSent", "password updated": "api.ok.passwordUpdated", "logged out": "api.ok.loggedOut", "unbound": "api.ok.unbound", // 舊文案相容 "if the account exists, a reset code was issued": "api.ok.resetSent", }; export function i18nKeyForApiOkMessage(raw: string): string | undefined { const k = raw.trim().toLowerCase(); if (!k) return undefined; // exact if (API_OK_MESSAGE_KEY[raw.trim()]) return API_OK_MESSAGE_KEY[raw.trim()]; if (API_OK_MESSAGE_KEY[k]) return API_OK_MESSAGE_KEY[k]; // case-insensitive scan for (const [en, key] of Object.entries(API_OK_MESSAGE_KEY)) { if (en.toLowerCase() === k) return key; } return undefined; } /** 成功 data.message → 目前語系文案;未知則回原文或 fallbackKey */ export function messageForApiOk( raw: string | undefined | null, fallbackKey = "common.success", locale?: AppLocale, ): string { const loc = locale ?? currentLocale(); const text = (raw || "").trim(); if (!text) return translate(loc, fallbackKey); const key = i18nKeyForApiOkMessage(text); if (key) return translate(loc, key); return text; } function currentLocale(): AppLocale { try { return loadUiPrefs().locale; } catch { return "zh-TW"; } } /** 後端業務提示(多為中文)可直接顯示,避免被「操作失敗」蓋掉 */ function cleanBizMessage(raw: string): string { let s = raw.trim(); for (const p of ["studio validation: ", "inspire validation: ", "scout validation: ", "validation: "]) { if (s.toLowerCase().startsWith(p)) { s = s.slice(p.length).trim(); break; } } // errors.Is wrap: "studio validation: detail" const m = s.match(/^(?:studio|inspire|scout)\s+validation:\s*(.+)$/i); if (m?.[1]) s = m[1].trim(); return s; } function isUserFacingBizMessage(raw: string | undefined | null): boolean { const s = (raw || "").trim(); if (!s) return false; // 含中日韓或明確業務提示 if (/[\u4e00-\u9fff]/.test(s)) return true; if (s.length >= 24 && !/^error\b/i.test(s)) return true; return false; } /** * 依業務碼 + 目前語系取文案(給非 React 層,如 http client)。 * 已知 code 用語言包;否則若 fallback 是可讀業務提示則直接顯示。 */ export function messageForApiCode( code: number, fallback?: string, locale?: AppLocale, ): string { const loc = locale ?? currentLocale(); const key = i18nKeyForApiCode(code); if (key) return translate(loc, key); if (isUserFacingBizMessage(fallback)) return cleanBizMessage(fallback!); return translate(loc, "api.err.unknown"); } type ErrLike = { code?: number; message?: string; name?: string; rawMessage?: string; }; /** 從 thrown value 取可顯示字串(依目前語言包) */ export function formatThrownError(err: unknown, fallbackKey = "common.error"): string { const loc = currentLocale(); const e = err as ErrLike | null; if (e && typeof e === "object" && e.name === "ApiError") { const code = e.code != null ? Number(e.code) : NaN; const raw = e.rawMessage || e.message; if (!Number.isNaN(code)) { const key = i18nKeyForApiCode(code); // 驗證類業務碼:優先後端中文原因 if ( isUserFacingBizMessage(raw) && (code === 400050 || code === 400060 || code === 400061 || !key) ) { return cleanBizMessage(String(raw)); } if (key) return translate(loc, key); } if (isUserFacingBizMessage(raw)) return cleanBizMessage(String(raw)); return translate(loc, "api.err.unknown"); } if (e && typeof e === "object" && e.code != null && !Number.isNaN(Number(e.code))) { const code = Number(e.code); const key = i18nKeyForApiCode(code); if (key) return translate(loc, key); if (isUserFacingBizMessage(e.message)) return cleanBizMessage(String(e.message)); return translate(loc, "api.err.unknown"); } // mock 前端 throw 的中文 Error,或無 code 的例外 if (err instanceof Error && err.message && err.name !== "ApiError") { return err.message; } return translate(loc, fallbackKey); } /** * React hook:用目前 I18n 語言包(t / locale)格式化錯誤。 * 頁面 catch 請用這個,確保跟 UI 語系完全一致。 */ export function useFormatApiError() { const { locale } = useI18n(); return useCallback( (err: unknown, fallbackKey = "common.error"): string => { void locale; return formatThrownError(err, fallbackKey); }, [locale], ); } /** * React hook:後端成功 message → 目前語言包。 * 例:verification code sent → 「驗證碼已寄出」 */ export function useFormatApiOk() { const { t, locale } = useI18n(); return useCallback( (raw: string | undefined | null, fallbackKey = "common.success"): string => { const text = (raw || "").trim(); if (!text) return t(fallbackKey); const key = i18nKeyForApiOkMessage(text); if (key) return t(key); return text; }, [t, locale], ); }