feat: 优化登录与入口页面加载体验 - 为登录页面添加 Suspense 边界,统一 fallback 样式 - 重构入口守卫逻辑,在布局阶段处理未登录重定向,避免闪烁 - 登录页面支持会话过期提示,并自动清理 URL 参数 feat: 信用盘与钱包盘文案差异化展示 - 底部导航「钱包」标签在信用盘模式下显示为「信用」 - 大厅余额条在信用盘模式下使用「可
179 lines
6.1 KiB
TypeScript
179 lines
6.1 KiB
TypeScript
import axios, {
|
||
isAxiosError,
|
||
type AxiosError,
|
||
type AxiosRequestConfig,
|
||
type AxiosResponse,
|
||
} from "axios";
|
||
|
||
import { isInIframe } from "@/components/iframe-bridge";
|
||
import { withPlayerAuthHeader } from "@/lib/lottery-auth";
|
||
import { usePlayerSessionStore } from "@/stores/player-session-store";
|
||
import { withLotteryLocaleHeaders } from "@/lib/lottery-locale";
|
||
import {
|
||
LotteryApiBizError,
|
||
LotteryApiEnvelopeError,
|
||
} from "@/types/api/errors";
|
||
import { isApiEnvelope } from "@/types/api/envelope";
|
||
import { useErrorStore } from "@/stores/error-store";
|
||
import { resolveLotteryApiV1Base } from "@/lib/lottery-api-base";
|
||
import i18n from "@/i18n";
|
||
|
||
/**
|
||
* **第一层**:`baseURL` 对齐 Laravel `api/v1`;各 `api/*.ts` 只写业务 path(如 `/currencies`)。
|
||
*/
|
||
export const lotteryHttp = axios.create({
|
||
baseURL: resolveLotteryApiV1Base(),
|
||
timeout: 30_000,
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
|
||
/** 凭据类 401(非 token 失效),不应触发「会话过期」跳转 */
|
||
const PLAYER_CREDENTIAL_BIZ_CODES = new Set([8006]);
|
||
|
||
function isPlayerAuthLoginRequest(config: AxiosRequestConfig | undefined): boolean {
|
||
if (!config || (config.method ?? "get").toLowerCase() !== "post") {
|
||
return false;
|
||
}
|
||
return `${config.url ?? ""}`.includes("/player/auth/login");
|
||
}
|
||
|
||
/** 是否应将 401 视为已登录态失效并跳转入口(见 {@link EntryGate} `session=expired`) */
|
||
function shouldRedirectPlayerSessionExpired(error: AxiosError): boolean {
|
||
if (isPlayerAuthLoginRequest(error.config)) {
|
||
return false;
|
||
}
|
||
|
||
if (typeof window !== "undefined" && window.location.pathname === "/login") {
|
||
return false;
|
||
}
|
||
|
||
const body = error.response?.data;
|
||
if (isApiEnvelope(body) && PLAYER_CREDENTIAL_BIZ_CODES.has(body.code)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/** 站内接口 401:清本地会话并回入口,与 {@link EntryGate} `session=expired` 衔接 */
|
||
/** 500 错误:更新全局服务器错误状态 */
|
||
lotteryHttp.interceptors.response.use(
|
||
(response) => response,
|
||
(error: unknown) => {
|
||
if (isAxiosError(error) && typeof window !== "undefined") {
|
||
const status = error.response?.status;
|
||
|
||
// 401: 已登录态 token 失效 → 清会话并回入口;登录凭据错误等由页面自行提示
|
||
if (status === 401) {
|
||
const redirectSessionExpired = shouldRedirectPlayerSessionExpired(error);
|
||
if (redirectSessionExpired || window.location.pathname === "/login") {
|
||
usePlayerSessionStore.getState().clearBearerToken();
|
||
}
|
||
if (redirectSessionExpired) {
|
||
const pathname = window.location.pathname;
|
||
const alreadyExpired = window.location.search.includes("session=expired");
|
||
const expiredTarget = isInIframe() ? "/?session=expired" : "/login?session=expired";
|
||
const onExpiredPage =
|
||
(pathname === "/" || pathname === "/login") && alreadyExpired;
|
||
if (!onExpiredPage) {
|
||
window.location.replace(expiredTarget);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 500/502/503: 服务器错误,更新全局错误状态
|
||
if (status === 500 || status === 502 || status === 503) {
|
||
const setServerError = useErrorStore.getState().setServerError;
|
||
let message = i18n.t("serverError.serverMessage", { ns: "player" });
|
||
|
||
// 尝试从响应中获取更详细的错误信息
|
||
const responseData = error.response?.data;
|
||
if (
|
||
typeof responseData === "object" &&
|
||
responseData !== null &&
|
||
"msg" in responseData &&
|
||
typeof responseData.msg === "string"
|
||
) {
|
||
message = responseData.msg;
|
||
}
|
||
|
||
setServerError(true, message);
|
||
}
|
||
|
||
// 网络错误 (无响应): 检查网络状态
|
||
if (!error.response && error.message?.includes("Network Error")) {
|
||
const setIsOffline = useErrorStore.getState().setIsOffline;
|
||
setIsOffline(true);
|
||
}
|
||
}
|
||
return Promise.reject(error);
|
||
},
|
||
);
|
||
|
||
/**
|
||
* 对 **payload**(通常是 `response.data`)校验信封并成功时返回 `data`;
|
||
* `code !== 0` 抛 {@link LotteryApiBizError}。
|
||
*/
|
||
export function unwrapData<T>(payload: unknown): T {
|
||
if (!isApiEnvelope(payload)) {
|
||
throw new LotteryApiEnvelopeError();
|
||
}
|
||
if (payload.code !== 0) {
|
||
throw new LotteryApiBizError(payload.msg, payload.code, payload.data);
|
||
}
|
||
return payload.data as T;
|
||
}
|
||
|
||
/** 对已拿到的 `AxiosResponse` 解一层 `unwrapData(response.data)` */
|
||
export function unwrapResponse<T>(res: AxiosResponse<unknown>): T {
|
||
return unwrapData<T>(res.data);
|
||
}
|
||
|
||
/**
|
||
* **第二层**:补齐玩家鉴权与语言头,用 `lotteryHttp` 发请求,再 `unwrapResponse`。
|
||
* 是否提示用户由各页面/特性自己 `catch` 决定。
|
||
*/
|
||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||
const merged = withPlayerAuthHeader(withLotteryLocaleHeaders(config));
|
||
try {
|
||
const res = await lotteryHttp.request<unknown>(merged);
|
||
return unwrapResponse<T>(res);
|
||
} catch (err: unknown) {
|
||
if (isAxiosError(err) && err.response?.data !== undefined) {
|
||
const body = err.response.data;
|
||
if (isApiEnvelope(body) && body.code !== 0) {
|
||
throw new LotteryApiBizError(body.msg, body.code, body.data);
|
||
}
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** 第二层常用动词(内部都是 `request`) */
|
||
export const lotteryRequest = {
|
||
request,
|
||
|
||
get: <T>(url: string, config?: Omit<AxiosRequestConfig, "url" | "method">) =>
|
||
request<T>({ ...config, url, method: "GET" }),
|
||
|
||
delete: <T>(url: string, config?: Omit<AxiosRequestConfig, "url" | "method">) =>
|
||
request<T>({ ...config, url, method: "DELETE" }),
|
||
|
||
post: <T>(
|
||
url: string,
|
||
data?: unknown,
|
||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||
) => request<T>({ ...config, url, method: "POST", data }),
|
||
|
||
put: <T>(
|
||
url: string,
|
||
data?: unknown,
|
||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||
) => request<T>({ ...config, url, method: "PUT", data }),
|
||
|
||
patch: <T>(
|
||
url: string,
|
||
data?: unknown,
|
||
config?: Omit<AxiosRequestConfig, "url" | "method" | "data">,
|
||
) => request<T>({ ...config, url, method: "PATCH", data }),
|
||
}; |