239 lines
7.8 KiB
TypeScript
239 lines
7.8 KiB
TypeScript
/**
|
||
* 後端 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,
|
||
QUOTA: 402001,
|
||
METER_CAP: 402002,
|
||
PLATFORM_CAPACITY: 503002,
|
||
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",
|
||
[API_CODES.QUOTA]: "api.err.402001",
|
||
[API_CODES.METER_CAP]: "usage.err.meterCap",
|
||
[API_CODES.PLATFORM_CAPACITY]: "usage.err.platformCapacity",
|
||
[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",
|
||
// 503002 = PLATFORM_CAPACITY,已在上方 API_CODES 映射
|
||
};
|
||
|
||
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";
|
||
}
|
||
}
|
||
|
||
/** 後端業務提示(多為中文)可直接顯示,避免被「操作失敗」蓋掉 */
|
||
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();
|
||
// 後端業務錯誤直接回 i18n key(invite.err.* / usage.err.*)
|
||
const fb = (fallback || "").trim();
|
||
if (fb.startsWith("invite.err.") || fb.startsWith("usage.err.")) return translate(loc, fb);
|
||
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 (typeof raw === "string" && (raw.startsWith("invite.err.") || raw.startsWith("usage.err."))) {
|
||
return translate(loc, raw);
|
||
}
|
||
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 } = 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],
|
||
);
|
||
}
|