2026-07-10 12:54:45 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 後端 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,
|
2026-07-13 03:18:08 +00:00
|
|
|
|
QUOTA: 402001,
|
|
|
|
|
|
METER_CAP: 402002,
|
|
|
|
|
|
PLATFORM_CAPACITY: 503002,
|
2026-07-10 12:54:45 +00:00
|
|
|
|
SERVER: 500000,
|
|
|
|
|
|
NOT_IMPLEMENTED: 501000,
|
|
|
|
|
|
NETWORK: 0,
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
|
|
/** code → messages.ts 的 key */
|
|
|
|
|
|
export const API_ERROR_I18N_KEY: Record<number, string> = {
|
|
|
|
|
|
[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",
|
2026-07-13 03:18:08 +00:00
|
|
|
|
[API_CODES.QUOTA]: "api.err.402001",
|
|
|
|
|
|
[API_CODES.METER_CAP]: "usage.err.meterCap",
|
|
|
|
|
|
[API_CODES.PLATFORM_CAPACITY]: "usage.err.platformCapacity",
|
2026-07-10 12:54:45 +00:00
|
|
|
|
[API_CODES.SERVER]: "api.err.500000",
|
|
|
|
|
|
[API_CODES.NOT_IMPLEMENTED]: "api.err.501000",
|
|
|
|
|
|
[API_CODES.NETWORK]: "api.err.network",
|
2026-07-13 01:15:30 +00:00
|
|
|
|
// 400050/400060 不進表:後端 message 常是具體中文原因(人設分析等),由 rawMessage 顯示
|
|
|
|
|
|
400061: "api.err.crawlerSession",
|
|
|
|
|
|
503001: "api.err.timeoutAI",
|
2026-07-13 03:18:08 +00:00
|
|
|
|
// 503002 = PLATFORM_CAPACITY,已在上方 API_CODES 映射
|
2026-07-10 12:54:45 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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<string, string> = {
|
|
|
|
|
|
"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";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 01:15:30 +00:00
|
|
|
|
/** 後端業務提示(多為中文)可直接顯示,避免被「操作失敗」蓋掉 */
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 12:54:45 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 依業務碼 + 目前語系取文案(給非 React 層,如 http client)。
|
2026-07-13 01:15:30 +00:00
|
|
|
|
* 已知 code 用語言包;否則若 fallback 是可讀業務提示則直接顯示。
|
2026-07-10 12:54:45 +00:00
|
|
|
|
*/
|
|
|
|
|
|
export function messageForApiCode(
|
|
|
|
|
|
code: number,
|
2026-07-13 01:15:30 +00:00
|
|
|
|
fallback?: string,
|
2026-07-10 12:54:45 +00:00
|
|
|
|
locale?: AppLocale,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
const loc = locale ?? currentLocale();
|
2026-07-13 03:18:08 +00:00
|
|
|
|
// 後端業務錯誤直接回 i18n key(invite.err.* / usage.err.*)
|
|
|
|
|
|
const fb = (fallback || "").trim();
|
|
|
|
|
|
if (fb.startsWith("invite.err.") || fb.startsWith("usage.err.")) return translate(loc, fb);
|
2026-07-10 12:54:45 +00:00
|
|
|
|
const key = i18nKeyForApiCode(code);
|
|
|
|
|
|
if (key) return translate(loc, key);
|
2026-07-13 01:15:30 +00:00
|
|
|
|
if (isUserFacingBizMessage(fallback)) return cleanBizMessage(fallback!);
|
2026-07-10 12:54:45 +00:00
|
|
|
|
return translate(loc, "api.err.unknown");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ErrLike = {
|
|
|
|
|
|
code?: number;
|
|
|
|
|
|
message?: string;
|
|
|
|
|
|
name?: string;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
rawMessage?: string;
|
2026-07-10 12:54:45 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 從 thrown value 取可顯示字串(依目前語言包) */
|
|
|
|
|
|
export function formatThrownError(err: unknown, fallbackKey = "common.error"): string {
|
|
|
|
|
|
const loc = currentLocale();
|
|
|
|
|
|
const e = err as ErrLike | null;
|
2026-07-13 01:15:30 +00:00
|
|
|
|
if (e && typeof e === "object" && e.name === "ApiError") {
|
|
|
|
|
|
const code = e.code != null ? Number(e.code) : NaN;
|
|
|
|
|
|
const raw = e.rawMessage || e.message;
|
2026-07-13 03:18:08 +00:00
|
|
|
|
if (typeof raw === "string" && (raw.startsWith("invite.err.") || raw.startsWith("usage.err."))) {
|
|
|
|
|
|
return translate(loc, raw);
|
|
|
|
|
|
}
|
2026-07-13 01:15:30 +00:00
|
|
|
|
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");
|
|
|
|
|
|
}
|
2026-07-10 12:54:45 +00:00
|
|
|
|
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);
|
2026-07-13 01:15:30 +00:00
|
|
|
|
if (isUserFacingBizMessage(e.message)) return cleanBizMessage(String(e.message));
|
2026-07-10 12:54:45 +00:00
|
|
|
|
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() {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const { locale } = useI18n();
|
2026-07-10 12:54:45 +00:00
|
|
|
|
return useCallback(
|
|
|
|
|
|
(err: unknown, fallbackKey = "common.error"): string => {
|
2026-07-13 01:15:30 +00:00
|
|
|
|
void locale;
|
|
|
|
|
|
return formatThrownError(err, fallbackKey);
|
2026-07-10 12:54:45 +00:00
|
|
|
|
},
|
2026-07-13 01:15:30 +00:00
|
|
|
|
[locale],
|
2026-07-10 12:54:45 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|