This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
+82 -33
View File
@@ -1,6 +1,11 @@
import axios, {AxiosError, AxiosResponse, CancelTokenSource, InternalAxiosRequestConfig} from 'axios';
import {toast} from 'sonner';
import {apiConfig} from './config';
import axios, {
AxiosError,
AxiosResponse,
CancelTokenSource,
InternalAxiosRequestConfig,
} from 'axios';
import { toast } from 'sonner';
import { apiConfig } from './config';
import {
ApiErrorBase,
ForbiddenError,
@@ -11,7 +16,7 @@ import {
UnauthorizedError,
ValidationError,
} from './errors';
import {ApiError, ApiResponse} from './types';
import { ApiError, ApiResponse } from './types';
/**
* API 客户端实例
@@ -37,20 +42,22 @@ const cancelTokens = new Map<string, CancelTokenSource>();
*/
const pendingRequests = new Map<string, Promise<AxiosResponse<ApiResponse>>>();
/**
* 生成请求的唯一键
* 包含方法、URL 和请求数据的哈希,确保不同参数的请求不会被误取消
*/
function getRequestKey(config: { method?: string; url?: string; data?: unknown }): string {
const baseKey = `${ config.method?.toUpperCase() }_${ config.url }`;
function getRequestKey(config: {
method?: string;
url?: string;
data?: unknown;
}): string {
const baseKey = `${config.method?.toUpperCase()}_${config.url}`;
/* 序列化加入键中 */
if (config.data) {
try {
const dataHash = JSON.stringify(config.data);
return `${ baseKey }_${ dataHash }`;
return `${baseKey}_${dataHash}`;
} catch {
// 失败使用基础键
return baseKey;
@@ -92,7 +99,7 @@ function initiateLogin(currentPath: string): Promise<never> {
window.location.href = loginUrl.toString();
}
return new Promise<never>(() => { });
return new Promise<never>(() => {});
}
/**
@@ -121,7 +128,9 @@ apiClient.interceptors.response.use(
/* 请求被取消时静默处理 */
if (axios.isCancel(error)) {
const cancelError = new Error(error.message || '请求已被取消') as Error & { __CANCEL__?: boolean };
const cancelError = new Error(
error.message || '请求已被取消',
) as Error & { __CANCEL__?: boolean };
cancelError.__CANCEL__ = true;
return Promise.reject(cancelError);
}
@@ -134,7 +143,11 @@ apiClient.interceptors.response.use(
/* 403 权限不足错误 */
if (error.response?.status === 403) {
return Promise.reject(
new ForbiddenError(error.response.data?.error_msg || '权限不足,请过盾后重试', error.response.data?.error_code, error.response.data?.details),
new ForbiddenError(
error.response.data?.error_msg || '权限不足,请过盾后重试',
error.response.data?.error_code,
error.response.data?.details,
),
);
}
@@ -158,17 +171,16 @@ apiClient.interceptors.response.use(
/* 429 速率限制错误 */
if (error.response?.status === 429) {
const retryAfter = error.response.headers?.['retry-after'];
const message = error.response.data?.error_msg ||
`请求过于频繁,请 ${ retryAfter || '稍后' } 秒后重试`;
const message =
error.response.data?.error_msg ||
`请求过于频繁,请 ${retryAfter || '稍后'} 秒后重试`;
toast.error('请求频率限制', {
description: message,
id: 'rate-limit-error',
});
return Promise.reject(
new ApiErrorBase(message, 'RATE_LIMITED', 429),
);
return Promise.reject(new ApiErrorBase(message, 'RATE_LIMITED', 429));
}
/* 5xx 服务器错误 */
@@ -212,9 +224,7 @@ apiClient.interceptors.response.use(
}
/* 兜底错误 */
return Promise.reject(
new ApiErrorBase(error.message || '网络请求失败'),
);
return Promise.reject(new ApiErrorBase(error.message || '网络请求失败'));
},
);
@@ -224,7 +234,7 @@ apiClient.interceptors.response.use(
* @param url - 请求 URL
*/
export function cancelRequest(method: string, url: string): void {
const requestKey = `${ method.toUpperCase() }_${ url }`;
const requestKey = `${method.toUpperCase()}_${url}`;
const source = cancelTokens.get(requestKey);
if (source) {
source.cancel('请求已被手动取消');
@@ -249,18 +259,29 @@ export function cancelAllRequests(): void {
*/
function createRequestMethod(
method: 'get' | 'post' | 'put' | 'patch' | 'delete',
hasBody: boolean
hasBody: boolean,
) {
if (hasBody) {
return <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => {
const requestKey = getRequestKey({ method: method.toUpperCase(), url, data });
return <T = ApiResponse>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
) => {
const requestKey = getRequestKey({
method: method.toUpperCase(),
url,
data,
});
if (pendingRequests.has(requestKey)) {
return pendingRequests.get(requestKey) as Promise<AxiosResponse<T>>;
}
const promise = apiClient[method]<T>(url, data, config);
pendingRequests.set(requestKey, promise as Promise<AxiosResponse<ApiResponse>>);
pendingRequests.set(
requestKey,
promise as Promise<AxiosResponse<ApiResponse>>,
);
promise.then(
() => pendingRequests.delete(requestKey),
@@ -271,15 +292,25 @@ function createRequestMethod(
};
}
return <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => {
const requestKey = getRequestKey({ method: method.toUpperCase(), url, data: config?.params });
return <T = ApiResponse>(
url: string,
config?: InternalAxiosRequestConfig,
) => {
const requestKey = getRequestKey({
method: method.toUpperCase(),
url,
data: config?.params,
});
if (pendingRequests.has(requestKey)) {
return pendingRequests.get(requestKey) as Promise<AxiosResponse<T>>;
}
const promise = apiClient[method]<T>(url, config);
pendingRequests.set(requestKey, promise as Promise<AxiosResponse<ApiResponse>>);
pendingRequests.set(
requestKey,
promise as Promise<AxiosResponse<ApiResponse>>,
);
promise.then(
() => pendingRequests.delete(requestKey),
@@ -295,11 +326,29 @@ function createRequestMethod(
* 在原有 axios 实例基础上添加请求缓存功能
*/
const wrappedApiClient = {
get: createRequestMethod('get', false) as <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
post: createRequestMethod('post', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
put: createRequestMethod('put', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
patch: createRequestMethod('patch', true) as <T = ApiResponse>(url: string, data?: unknown, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
delete: createRequestMethod('delete', false) as <T = ApiResponse>(url: string, config?: InternalAxiosRequestConfig) => Promise<AxiosResponse<T>>,
get: createRequestMethod('get', false) as <T = ApiResponse>(
url: string,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
post: createRequestMethod('post', true) as <T = ApiResponse>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
put: createRequestMethod('put', true) as <T = ApiResponse>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
patch: createRequestMethod('patch', true) as <T = ApiResponse>(
url: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
delete: createRequestMethod('delete', false) as <T = ApiResponse>(
url: string,
config?: InternalAxiosRequestConfig,
) => Promise<AxiosResponse<T>>,
};
export default wrappedApiClient;
+9 -14
View File
@@ -5,12 +5,12 @@ import { InternalAxiosRequestConfig } from 'axios';
/**
* 服务基类
* 提供通用的 HTTP 方法封装
*
*
* @example
* ```typescript
* class UserService extends BaseService {
* protected static readonly basePath = '/api/v1/users';
*
*
* static async getAll() {
* return this.get<User[]>('/');
* }
@@ -30,7 +30,7 @@ export class BaseService {
* @returns 完整路径
*/
protected static getFullPath(path: string): string {
return `${ this.basePath }${ path }`;
return `${this.basePath}${path}`;
}
/**
@@ -150,18 +150,14 @@ export class BaseService {
* @param url - 完整 URL
* @param params - 查询参数
* @returns 响应数据(不经过 response.data.data 解包)
*
*
* @remarks
* 仅用于不遵循标准响应格式的特殊端点(如 /api.php)
*/
protected static async rawGet<T>(
url: string,
params?: unknown,
): Promise<T> {
const response = await apiClient.get<T>(
url,
{ params } as InternalAxiosRequestConfig,
);
protected static async rawGet<T>(url: string, params?: unknown): Promise<T> {
const response = await apiClient.get<T>(url, {
params,
} as InternalAxiosRequestConfig);
return response.data;
}
@@ -172,7 +168,7 @@ export class BaseService {
* @param data - 请求数据
* @param config - 额外的请求配置
* @returns 响应数据(不经过 response.data.data 解包)
*
*
* @remarks
* 仅用于不遵循标准响应格式的特殊端点(如 /api.php)
*/
@@ -185,4 +181,3 @@ export class BaseService {
return response.data;
}
}
+5 -2
View File
@@ -8,7 +8,11 @@
*/
export function getApiBaseUrl(): string {
if (typeof window === 'undefined') {
return process.env.WAVELET_BACKEND_URL || process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || 'http://localhost:8000';
return (
process.env.WAVELET_BACKEND_URL ||
process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL ||
'http://localhost:8000'
);
}
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
}
@@ -24,4 +28,3 @@ export const apiConfig = {
/** 携带凭证 */
withCredentials: true,
} as const;
+8 -1
View File
@@ -101,5 +101,12 @@ export class ValidationError extends ApiErrorBase {
* @returns 是否为取消错误
*/
export function isCancelError(error: unknown): boolean {
return error !== null && typeof error === 'object' && ('__CANCEL__' in error && (error as { __CANCEL__?: boolean }).__CANCEL__ === true || ('message' in error && (error as { message?: string }).message === '请求已被取消'));
return (
error !== null &&
typeof error === 'object' &&
(('__CANCEL__' in error &&
(error as { __CANCEL__?: boolean }).__CANCEL__ === true) ||
('message' in error &&
(error as { message?: string }).message === '请求已被取消'))
);
}
+5 -2
View File
@@ -3,7 +3,11 @@
* 提供 API 请求的基础设施
*/
export { default as apiClient, cancelRequest, cancelAllRequests } from './api-client';
export {
default as apiClient,
cancelRequest,
cancelAllRequests,
} from './api-client';
export { BaseService } from './base.service';
export { apiConfig } from './config';
export {
@@ -24,4 +28,3 @@ export type {
PaginationResponse,
RequestConfig,
} from './types';
-1
View File
@@ -61,4 +61,3 @@ export interface RequestConfig {
/** 是否携带凭证 */
withCredentials?: boolean;
}