2026-06-22 14:43:46 +08:00
|
|
|
|
import axios, {
|
|
|
|
|
|
AxiosError,
|
|
|
|
|
|
AxiosInstance,
|
|
|
|
|
|
InternalAxiosRequestConfig,
|
|
|
|
|
|
} from 'axios';
|
|
|
|
|
|
|
|
|
|
|
|
/** 后端统一响应体 */
|
|
|
|
|
|
export interface ApiResult<T> {
|
|
|
|
|
|
code: number;
|
|
|
|
|
|
msg: string;
|
|
|
|
|
|
data: T;
|
|
|
|
|
|
path?: string;
|
|
|
|
|
|
timestamp?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 分页结果 */
|
|
|
|
|
|
export interface Paginated<T> {
|
|
|
|
|
|
list: T[];
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
page: number;
|
|
|
|
|
|
pageSize: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api';
|
|
|
|
|
|
const TOKEN_KEY = process.env.NEXT_PUBLIC_TOKEN_KEY ?? 'admin_token';
|
|
|
|
|
|
|
|
|
|
|
|
/** ---------- Token 存取(仅浏览器端) ---------- */
|
|
|
|
|
|
export const tokenStorage = {
|
|
|
|
|
|
get(): string | null {
|
|
|
|
|
|
if (typeof window === 'undefined') return null;
|
|
|
|
|
|
return window.localStorage.getItem(TOKEN_KEY);
|
|
|
|
|
|
},
|
|
|
|
|
|
set(token: string): void {
|
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
|
window.localStorage.setItem(TOKEN_KEY, token);
|
|
|
|
|
|
},
|
|
|
|
|
|
clear(): void {
|
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
|
window.localStorage.removeItem(TOKEN_KEY);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** ---------- axios 实例 ---------- */
|
|
|
|
|
|
export const http: AxiosInstance = axios.create({
|
|
|
|
|
|
baseURL: API_URL,
|
|
|
|
|
|
timeout: 15000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 请求拦截:自动注入 JWT
|
|
|
|
|
|
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|
|
|
|
|
const token = tokenStorage.get();
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
config.headers.set('Authorization', `Bearer ${token}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
return config;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 响应拦截:拆包 + 401 跳登录
|
2026-06-22 15:49:58 +08:00
|
|
|
|
// 注:onFulfilled 在拦截器中返回业务数据(拆包后的 data)而非 AxiosResponse,
|
|
|
|
|
|
// 这是项目约定的解包模式;axios 1.x 的类型签名不灵活,必须 cast 为 any
|
2026-06-22 14:43:46 +08:00
|
|
|
|
http.interceptors.response.use(
|
2026-06-22 15:49:58 +08:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
|
(response): any => {
|
2026-06-22 14:43:46 +08:00
|
|
|
|
const body = response.data as ApiResult<unknown>;
|
|
|
|
|
|
if (body && typeof body.code === 'number') {
|
|
|
|
|
|
if (body.code === 200) {
|
|
|
|
|
|
return body.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 业务错误:抛出,UI 层 try/catch 处理
|
|
|
|
|
|
return Promise.reject(new Error(body.msg || '请求失败'));
|
|
|
|
|
|
}
|
|
|
|
|
|
return body;
|
|
|
|
|
|
},
|
|
|
|
|
|
(error: AxiosError<ApiResult<unknown>>) => {
|
|
|
|
|
|
const status = error.response?.status;
|
|
|
|
|
|
const msg =
|
|
|
|
|
|
error.response?.data?.msg ?? error.message ?? '网络异常,请稍后重试';
|
|
|
|
|
|
|
|
|
|
|
|
if (status === 401) {
|
|
|
|
|
|
tokenStorage.clear();
|
|
|
|
|
|
// 避免循环跳转:仅在 /admin 下跳登录
|
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
|
const path = window.location.pathname;
|
|
|
|
|
|
if (path.startsWith('/admin') && path !== '/admin/login') {
|
|
|
|
|
|
window.location.href = '/admin/login?expired=1';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return Promise.reject(new Error(msg));
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/** ---------- SWR 通用 fetcher ---------- */
|
|
|
|
|
|
export const fetcher = async <T>(url: string): Promise<T> => {
|
|
|
|
|
|
return (await http.get<unknown, T>(url)) as T;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** ---------- 工具:表单提交包装 ---------- */
|
|
|
|
|
|
export async function submitForm<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
|
|
|
return fn();
|
|
|
|
|
|
}
|