import axios from 'axios';
|
import type {
|
AxiosError,
|
AxiosRequestConfig,
|
AxiosResponse,
|
InternalAxiosRequestConfig,
|
} from 'axios';
|
import { showErrorMessage } from '@/utils/feedback';
|
|
/** 一个请求的配置。TReq 为请求参数 / 请求体的类型。 */
|
export type ApiRequestConfig<TReq = unknown> = Omit<
|
AxiosRequestConfig<TReq>,
|
'signal'
|
> & {
|
url: string;
|
};
|
|
/** 每个调用方可以单独设置的请求选项。 */
|
export interface RequestOptions<TParse extends boolean = boolean> {
|
/** 请求失败时是否自动提示错误信息,默认 true。 */
|
showError?: boolean;
|
/** 是否合并相同的并发请求,默认 true;为 false 时每次都单独发送。 */
|
merge?: boolean;
|
/** 是否解析响应:true 返回 response.data,false 返回完整 AxiosResponse。默认 true。 */
|
parse?: TParse;
|
/** 用于取消当前调用方发起的请求。 */
|
signal?: AbortSignal;
|
}
|
|
/** 请求失败时抛出的统一错误,保留 Axios 返回的完整信息。 */
|
export class ApiError extends Error {
|
readonly code?: string;
|
readonly status?: number;
|
readonly data?: unknown;
|
readonly response?: AxiosResponse;
|
readonly config?: InternalAxiosRequestConfig;
|
readonly isCanceled: boolean;
|
readonly originalError?: unknown;
|
/** 相同请求被多个调用方共享时,保证错误信息只提示一次。 */
|
notified = false;
|
|
constructor(
|
message: string,
|
options: {
|
code?: string;
|
status?: number;
|
data?: unknown;
|
response?: AxiosResponse;
|
config?: InternalAxiosRequestConfig;
|
isCanceled?: boolean;
|
originalError?: unknown;
|
} = {},
|
) {
|
super(message);
|
this.name = 'ApiError';
|
this.code = options.code;
|
this.status = options.status;
|
this.data = options.data;
|
this.response = options.response;
|
this.config = options.config;
|
this.isCanceled = options.isCanceled ?? false;
|
this.originalError = options.originalError;
|
}
|
}
|
|
const http = axios.create({
|
// 开发环境走 Vite 代理(同源),生产环境使用真实接口地址。
|
baseURL: import.meta.env.DEV
|
? '/proxy-api'
|
: import.meta.env.VITE_API_BASE_URL ?? '',
|
timeout: 15000,
|
});
|
|
/** 递归排序对象字段,保证相同参数得到相同的请求 key。 */
|
const sortValue = (value: unknown): unknown => {
|
if (Array.isArray(value)) return value.map(sortValue);
|
if (value && typeof value === 'object') {
|
return Object.keys(value as Record<string, unknown>)
|
.sort()
|
.reduce<Record<string, unknown>>((result, key) => {
|
result[key] = sortValue((value as Record<string, unknown>)[key]);
|
return result;
|
}, {});
|
}
|
return value;
|
};
|
|
const stringify = (value: unknown): string => {
|
if (value === undefined) return '';
|
try {
|
return JSON.stringify(sortValue(value));
|
} catch {
|
return String(value);
|
}
|
};
|
|
const getRequestKey = <TReq>(config: ApiRequestConfig<TReq>): string =>
|
[
|
(config.method ?? 'GET').toUpperCase(),
|
config.url,
|
stringify(config.params),
|
stringify(config.data),
|
].join('|');
|
|
/** 正在进行中的相同请求,值为完整的 AxiosResponse。 */
|
const pendingRequests = new Map<string, Promise<AxiosResponse>>();
|
|
const createCanceledError = () =>
|
new ApiError('请求已取消', { isCanceled: true, code: 'ERR_CANCELED' });
|
|
const toApiError = (error: unknown): ApiError => {
|
if (error instanceof ApiError) return error;
|
|
const axiosError = error as AxiosError<{ message?: string; msg?: string }>;
|
const response = axiosError?.response;
|
const data = response?.data;
|
const serverMessage =
|
typeof data?.message === 'string'
|
? data.message
|
: typeof data?.msg === 'string'
|
? data.msg
|
: undefined;
|
const isCanceled = axios.isCancel(error) || axiosError?.code === 'ERR_CANCELED';
|
|
return new ApiError(
|
isCanceled ? '请求已取消' : serverMessage ?? axiosError?.message ?? '网络请求失败',
|
{
|
code: axiosError?.code,
|
status: response?.status,
|
data,
|
response,
|
config: axiosError?.config,
|
isCanceled,
|
originalError: error,
|
},
|
);
|
};
|
|
/** 发送底层请求并始终保留完整响应;解析与提示交给每个调用方处理。 */
|
const execute = async <TReq, TRes>(
|
config: ApiRequestConfig<TReq>,
|
options: RequestOptions,
|
): Promise<AxiosResponse<TRes>> => {
|
try {
|
return await http.request<TRes, AxiosResponse<TRes>, TReq>({
|
...config,
|
// 合并请求时不能把某个调用方的 signal 交给底层请求,否则会取消其他调用方。
|
signal: options.merge === false ? options.signal : undefined,
|
});
|
} catch (error) {
|
throw toApiError(error);
|
}
|
};
|
|
/** 后端统一响应包装:{ code, message, data, success }。 */
|
interface ResultEnvelope {
|
code: number | string;
|
message?: string;
|
data?: unknown;
|
success?: boolean;
|
}
|
|
const isResultEnvelope = (body: unknown): body is ResultEnvelope => {
|
if (!body || typeof body !== 'object') return false;
|
const record = body as Record<string, unknown>;
|
return (
|
'code' in record &&
|
('data' in record || 'success' in record || 'message' in record)
|
);
|
};
|
|
const isSuccessCode = (code: number | string) => code === 200;
|
|
const formatResponse = <TRes, TParse extends boolean>(
|
response: AxiosResponse<TRes>,
|
options: RequestOptions<TParse>,
|
): TParse extends true ? TRes : AxiosResponse<TRes> => {
|
if (options.parse === false) {
|
return response as TParse extends true ? TRes : AxiosResponse<TRes>;
|
}
|
|
const body = response.data as unknown;
|
if (isResultEnvelope(body)) {
|
const succeeded = isSuccessCode(body.code) || body.success === true ;
|
if (!succeeded) {
|
throw new ApiError(
|
body.message || '请求失败',
|
{
|
code: String(body.code),
|
status: response.status,
|
data: body,
|
response,
|
config: response.config,
|
},
|
);
|
}
|
return body.data as TParse extends true ? TRes : AxiosResponse<TRes>;
|
}
|
|
return body as TParse extends true ? TRes : AxiosResponse<TRes>;
|
};
|
|
const notifyError = (error: ApiError, options: RequestOptions) => {
|
if (error.isCanceled || options.showError === false || error.notified) return;
|
error.notified = true;
|
showErrorMessage(error.message);
|
};
|
|
/** 让共享请求对每个调用方单独支持取消。 */
|
const raceWithSignal = <T>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
|
if (signal.aborted) return Promise.reject(createCanceledError());
|
|
return new Promise<T>((resolve, reject) => {
|
const onAbort = () => reject(createCanceledError());
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
promise.then(
|
(value) => {
|
signal.removeEventListener('abort', onAbort);
|
resolve(value);
|
},
|
(error) => {
|
signal.removeEventListener('abort', onAbort);
|
reject(error);
|
},
|
);
|
});
|
};
|
|
/**
|
* 发送网络请求。
|
*
|
* 默认合并相同的并发请求、自动提示错误,并且只返回服务端的 data。
|
*
|
* @example
|
* const result = await request<{ keyword: string }, PageResult<User>>({
|
* url: '/api/users',
|
* method: 'GET',
|
* params: { keyword },
|
* });
|
*
|
* // 不解析,拿到完整的 AxiosResponse
|
* const response = await request<void, User[]>(
|
* { url: '/api/users' },
|
* { parse: false },
|
* );
|
*
|
* // 不合并、不自动提示,并通过 signal 取消
|
* const controller = new AbortController();
|
* await request({ url: '/api/users' }, {
|
* merge: false,
|
* showError: false,
|
* signal: controller.signal,
|
* });
|
*/
|
export const request = async <
|
TReq = unknown,
|
TRes = unknown,
|
TParse extends boolean = true,
|
>(
|
config: ApiRequestConfig<TReq>,
|
options: RequestOptions<TParse> = {},
|
): Promise<TParse extends true ? TRes : AxiosResponse<TRes>> => {
|
const { signal, merge = true } = options;
|
|
if (signal?.aborted) {
|
throw createCanceledError();
|
}
|
|
let task: Promise<AxiosResponse<TRes>>;
|
|
if (merge) {
|
const key = getRequestKey(config);
|
const existing = pendingRequests.get(key) as Promise<AxiosResponse<TRes>> | undefined;
|
|
if (existing) {
|
task = existing;
|
} else {
|
task = execute<TReq, TRes>(config, options);
|
pendingRequests.set(key, task);
|
|
const cleanup = () => {
|
if (pendingRequests.get(key) === task) {
|
pendingRequests.delete(key);
|
}
|
};
|
task.then(cleanup, cleanup);
|
}
|
} else {
|
task = execute<TReq, TRes>(config, options);
|
}
|
|
try {
|
const response = merge && signal ? await raceWithSignal(task, signal) : await task;
|
return formatResponse(response, options);
|
} catch (error) {
|
const apiError = toApiError(error);
|
notifyError(apiError, options);
|
throw apiError;
|
}
|
};
|