192 lines
5.9 KiB
TypeScript
192 lines
5.9 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,
|
|||
|
|
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.SERVER]: "api.err.500000",
|
|||
|
|
[API_CODES.NOT_IMPLEMENTED]: "api.err.501000",
|
|||
|
|
[API_CODES.NETWORK]: "api.err.network",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 依業務碼 + 目前語系取文案(給非 React 層,如 http client)。
|
|||
|
|
* 已知 code 只用語言包;未知才回 fallback 或 api.err.unknown。
|
|||
|
|
*/
|
|||
|
|
export function messageForApiCode(
|
|||
|
|
code: number,
|
|||
|
|
_fallback?: string,
|
|||
|
|
locale?: AppLocale,
|
|||
|
|
): string {
|
|||
|
|
const loc = locale ?? currentLocale();
|
|||
|
|
const key = i18nKeyForApiCode(code);
|
|||
|
|
if (key) return translate(loc, key);
|
|||
|
|
return translate(loc, "api.err.unknown");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ErrLike = {
|
|||
|
|
code?: number;
|
|||
|
|
message?: string;
|
|||
|
|
name?: 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.code != null && !Number.isNaN(Number(e.code))) {
|
|||
|
|
const code = Number(e.code);
|
|||
|
|
const key = i18nKeyForApiCode(code);
|
|||
|
|
if (key) return translate(loc, key);
|
|||
|
|
// 未知業務碼:不顯示後端英文,改通用錯誤
|
|||
|
|
return translate(loc, "api.err.unknown");
|
|||
|
|
}
|
|||
|
|
// mock 前端 throw 的中文 Error,或無 code 的例外
|
|||
|
|
if (err instanceof Error && err.message && err.name !== "ApiError") {
|
|||
|
|
return err.message;
|
|||
|
|
}
|
|||
|
|
if (err instanceof Error && err.name === "ApiError") {
|
|||
|
|
// 有 ApiError 但沒 code 時
|
|||
|
|
return translate(loc, "api.err.unknown");
|
|||
|
|
}
|
|||
|
|
return translate(loc, fallbackKey);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* React hook:用目前 I18n 語言包(t / locale)格式化錯誤。
|
|||
|
|
* 頁面 catch 請用這個,確保跟 UI 語系完全一致。
|
|||
|
|
*/
|
|||
|
|
export function useFormatApiError() {
|
|||
|
|
const { t, locale } = useI18n();
|
|||
|
|
return useCallback(
|
|||
|
|
(err: unknown, fallbackKey = "common.error"): string => {
|
|||
|
|
const e = err as ErrLike | null;
|
|||
|
|
if (e && typeof e === "object" && e.code != null && !Number.isNaN(Number(e.code))) {
|
|||
|
|
const key = i18nKeyForApiCode(Number(e.code));
|
|||
|
|
if (key) return t(key);
|
|||
|
|
return t("api.err.unknown");
|
|||
|
|
}
|
|||
|
|
if (err instanceof Error && err.message && err.name !== "ApiError") {
|
|||
|
|
return err.message;
|
|||
|
|
}
|
|||
|
|
return t(fallbackKey);
|
|||
|
|
},
|
|||
|
|
[t, 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],
|
|||
|
|
);
|
|||
|
|
}
|