This commit is contained in:
ZhuJW
2026-04-22 13:40:01 +08:00
commit d6bf4684d2
1146 changed files with 96233 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
{
"name": "@vben/request",
"version": "5.5.2",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/effects/request"
},
"license": "MIT",
"type": "module",
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"dependencies": {
"@vben/locales": "workspace:*",
"@vben/utils": "workspace:*",
"axios": "catalog:"
},
"devDependencies": {
"axios-mock-adapter": "catalog:"
}
}

View File

@@ -0,0 +1,2 @@
export * from './request-client';
export * from 'axios';

View File

@@ -0,0 +1,3 @@
export * from './preset-interceptors';
export * from './request-client';
export type * from './types';

View File

@@ -0,0 +1,84 @@
import type { AxiosRequestConfig } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileDownloader } from './downloader';
describe('fileDownloader', () => {
let fileDownloader: FileDownloader;
const mockAxiosInstance = {
get: vi.fn(),
} as any;
beforeEach(() => {
fileDownloader = new FileDownloader(mockAxiosInstance);
});
it('should create an instance of FileDownloader', () => {
expect(fileDownloader).toBeInstanceOf(FileDownloader);
});
it('should download a file and return a Blob', async () => {
const url = 'https://example.com/file';
const mockBlob = new Blob(['file content'], { type: 'text/plain' });
const mockResponse: Blob = mockBlob;
mockAxiosInstance.get.mockResolvedValueOnce(mockResponse);
const result = await fileDownloader.download(url);
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
responseType: 'blob',
});
});
it('should merge provided config with default config', async () => {
const url = 'https://example.com/file';
const mockBlob = new Blob(['file content'], { type: 'text/plain' });
const mockResponse: Blob = mockBlob;
mockAxiosInstance.get.mockResolvedValueOnce(mockResponse);
const customConfig: AxiosRequestConfig = {
headers: { 'Custom-Header': 'value' },
};
const result = await fileDownloader.download(url, customConfig);
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
...customConfig,
responseType: 'blob',
});
});
it('should handle errors gracefully', async () => {
const url = 'https://example.com/file';
mockAxiosInstance.get.mockRejectedValueOnce(new Error('Network Error'));
await expect(fileDownloader.download(url)).rejects.toThrow('Network Error');
});
it('should handle empty URL gracefully', async () => {
const url = '';
mockAxiosInstance.get.mockRejectedValueOnce(
new Error('Request failed with status code 404'),
);
await expect(fileDownloader.download(url)).rejects.toThrow(
'Request failed with status code 404',
);
});
it('should handle null URL gracefully', async () => {
const url = null as unknown as string;
mockAxiosInstance.get.mockRejectedValueOnce(
new Error('Request failed with status code 404'),
);
await expect(fileDownloader.download(url)).rejects.toThrow(
'Request failed with status code 404',
);
});
});

View File

@@ -0,0 +1,31 @@
import type { AxiosRequestConfig } from 'axios';
import type { RequestClient } from '../request-client';
import type { RequestResponse } from '../types';
class FileDownloader {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
public async download(
url: string,
config?: AxiosRequestConfig,
): Promise<RequestResponse<Blob>> {
const finalConfig: AxiosRequestConfig = {
...config,
responseType: 'blob',
};
const response = await this.client.get<RequestResponse<Blob>>(
url,
finalConfig,
);
return response;
}
}
export { FileDownloader };

View File

@@ -0,0 +1,40 @@
import type { AxiosInstance, AxiosResponse } from 'axios';
import type {
RequestInterceptorConfig,
ResponseInterceptorConfig,
} from '../types';
const defaultRequestInterceptorConfig: RequestInterceptorConfig = {
fulfilled: (response) => response,
rejected: (error) => Promise.reject(error),
};
const defaultResponseInterceptorConfig: ResponseInterceptorConfig = {
fulfilled: (response: AxiosResponse) => response,
rejected: (error) => Promise.reject(error),
};
class InterceptorManager {
private axiosInstance: AxiosInstance;
constructor(instance: AxiosInstance) {
this.axiosInstance = instance;
}
addRequestInterceptor({
fulfilled,
rejected,
}: RequestInterceptorConfig = defaultRequestInterceptorConfig) {
this.axiosInstance.interceptors.request.use(fulfilled, rejected);
}
addResponseInterceptor<T = any>({
fulfilled,
rejected,
}: ResponseInterceptorConfig<T> = defaultResponseInterceptorConfig) {
this.axiosInstance.interceptors.response.use(fulfilled, rejected);
}
}
export { InterceptorManager };

View File

@@ -0,0 +1,118 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileUploader } from './uploader';
describe('fileUploader', () => {
let fileUploader: FileUploader;
// Mock the AxiosInstance
const mockAxiosInstance = {
post: vi.fn(),
} as any;
beforeEach(() => {
fileUploader = new FileUploader(mockAxiosInstance);
});
it('should create an instance of FileUploader', () => {
expect(fileUploader).toBeInstanceOf(FileUploader);
});
it('should upload a file and return the response', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
const mockResponse: AxiosResponse = {
config: {} as any,
data: { success: true },
headers: {},
status: 200,
statusText: 'OK',
};
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(mockResponse);
const result = await fileUploader.upload(url, { file });
expect(result).toEqual(mockResponse);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
url,
expect.any(FormData),
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
});
it('should merge provided config with default config', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
const mockResponse: AxiosResponse = {
config: {} as any,
data: { success: true },
headers: {},
status: 200,
statusText: 'OK',
};
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(mockResponse);
const customConfig: AxiosRequestConfig = {
headers: { 'Custom-Header': 'value' },
};
const result = await fileUploader.upload(url, { file }, customConfig);
expect(result).toEqual(mockResponse);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
url,
expect.any(FormData),
{
headers: {
'Content-Type': 'multipart/form-data',
'Custom-Header': 'value',
},
},
);
});
it('should handle errors gracefully', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Network Error'));
await expect(fileUploader.upload(url, { file })).rejects.toThrow(
'Network Error',
);
});
it('should handle empty URL gracefully', async () => {
const url = '';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Request failed with status code 404'));
await expect(fileUploader.upload(url, { file })).rejects.toThrow(
'Request failed with status code 404',
);
});
it('should handle null URL gracefully', async () => {
const url = null as unknown as string;
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Request failed with status code 404'));
await expect(fileUploader.upload(url, { file })).rejects.toThrow(
'Request failed with status code 404',
);
});
});

View File

@@ -0,0 +1,35 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import type { RequestClient } from '../request-client';
class FileUploader {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
public async upload(
url: string,
data: { file: Blob | File } & Record<string, any>,
config?: AxiosRequestConfig,
): Promise<AxiosResponse> {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value);
});
const finalConfig: AxiosRequestConfig = {
...config,
headers: {
'Content-Type': 'multipart/form-data',
...config?.headers,
},
};
return this.client.post(url, formData, finalConfig);
}
}
export { FileUploader };

View File

@@ -0,0 +1,126 @@
import type { RequestClient } from './request-client';
import type { MakeErrorMessageFn, ResponseInterceptorConfig } from './types';
import { $t } from '@vben/locales';
import axios from 'axios';
export const authenticateResponseInterceptor = ({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken,
formatToken,
}: {
client: RequestClient;
doReAuthenticate: () => Promise<void>;
doRefreshToken: () => Promise<string>;
enableRefreshToken: boolean;
formatToken: (token: string) => null | string;
}): ResponseInterceptorConfig => {
return {
rejected: async (error) => {
const { config, response } = error;
// 如果不是 401 错误,直接抛出异常
if (response?.status !== 401) {
throw error;
}
// 判断是否启用了 refreshToken 功能
// 如果没有启用或者已经是重试请求了,直接跳转到重新登录
if (!enableRefreshToken || config.__isRetryRequest) {
await doReAuthenticate();
throw error;
}
// 如果正在刷新 token则将请求加入队列等待刷新完成
if (client.isRefreshing) {
return new Promise((resolve) => {
client.refreshTokenQueue.push((newToken: string) => {
config.headers.Authorization = formatToken(newToken);
resolve(client.request(config.url, { ...config }));
});
});
}
// 标记开始刷新 token
client.isRefreshing = true;
// 标记当前请求为重试请求,避免无限循环
config.__isRetryRequest = true;
try {
const newToken = await doRefreshToken();
// 处理队列中的请求
client.refreshTokenQueue.forEach((callback) => callback(newToken));
// 清空队列
client.refreshTokenQueue = [];
return client.request(error.config.url, { ...error.config });
} catch (refreshError) {
// 如果刷新 token 失败,处理错误(如强制登出或跳转登录页面)
client.refreshTokenQueue.forEach((callback) => callback(''));
client.refreshTokenQueue = [];
console.error('Refresh token failed, please login again.');
await doReAuthenticate();
throw refreshError;
} finally {
client.isRefreshing = false;
}
},
};
};
export const errorMessageResponseInterceptor = (
makeErrorMessage?: MakeErrorMessageFn,
): ResponseInterceptorConfig => {
return {
rejected: (error: any) => {
if (axios.isCancel(error)) {
return Promise.reject(error);
}
const err: string = error?.toString?.() ?? '';
let errMsg = '';
if (err?.includes('Network Error')) {
errMsg = $t('ui.fallback.http.networkError');
} else if (error?.message?.includes?.('timeout')) {
errMsg = $t('ui.fallback.http.requestTimeout');
}
if (errMsg) {
makeErrorMessage?.(errMsg, error);
return Promise.reject(error);
}
let errorMessage = '';
const status = error?.response?.status;
console.log("error",status)
switch (status) {
case 400: {
errorMessage = $t('ui.fallback.http.badRequest');
break;
}
case 401: {
errorMessage = $t('ui.fallback.http.unauthorized');
break;
}
case 403: {
errorMessage = $t('ui.fallback.http.forbidden');
break;
}
case 404: {
errorMessage = $t('ui.fallback.http.notFound');
break;
}
case 408: {
errorMessage = $t('ui.fallback.http.requestTimeout');
break;
}
default: {
errorMessage = $t('ui.fallback.http.internalServerError');
}
}
makeErrorMessage?.(errorMessage, error);
return Promise.reject(error);
},
};
};

View File

@@ -0,0 +1,99 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RequestClient } from './request-client';
describe('requestClient', () => {
let mock: MockAdapter;
let requestClient: RequestClient;
beforeEach(() => {
mock = new MockAdapter(axios);
requestClient = new RequestClient();
});
afterEach(() => {
mock.reset();
});
it('should successfully make a GET request', async () => {
mock.onGet('test/url').reply(200, { data: 'response' });
const response = await requestClient.get('test/url');
expect(response.data).toEqual({ data: 'response' });
});
it('should successfully make a POST request', async () => {
const postData = { key: 'value' };
const mockData = { data: 'response' };
mock.onPost('/test/post', postData).reply(200, mockData);
const response = await requestClient.post('/test/post', postData);
expect(response.data).toEqual(mockData);
});
it('should successfully make a PUT request', async () => {
const putData = { key: 'updatedValue' };
const mockData = { data: 'updated response' };
mock.onPut('/test/put', putData).reply(200, mockData);
const response = await requestClient.put('/test/put', putData);
expect(response.data).toEqual(mockData);
});
it('should successfully make a DELETE request', async () => {
const mockData = { data: 'delete response' };
mock.onDelete('/test/delete').reply(200, mockData);
const response = await requestClient.delete('/test/delete');
expect(response.data).toEqual(mockData);
});
it('should handle network errors', async () => {
mock.onGet('/test/error').networkError();
try {
await requestClient.get('/test/error');
expect(true).toBe(false);
} catch (error: any) {
expect(error.isAxiosError).toBe(true);
expect(error.message).toBe('Network Error');
}
});
it('should handle timeout', async () => {
mock.onGet('/test/timeout').timeout();
try {
await requestClient.get('/test/timeout');
expect(true).toBe(false);
} catch (error: any) {
expect(error.isAxiosError).toBe(true);
expect(error.code).toBe('ECONNABORTED');
}
});
it('should successfully upload a file', async () => {
const fileData = new Blob(['file contents'], { type: 'text/plain' });
mock.onPost('/test/upload').reply((config) => {
return config.data instanceof FormData && config.data.has('file')
? [200, { data: 'file uploaded' }]
: [400, { error: 'Bad Request' }];
});
const response = await requestClient.upload('/test/upload', {
file: fileData,
});
expect(response.data).toEqual({ data: 'file uploaded' });
});
it('should successfully download a file as a blob', async () => {
const mockFileContent = new Blob(['mock file content'], {
type: 'text/plain',
});
mock.onGet('/test/download').reply(200, mockFileContent);
const res = await requestClient.download('/test/download');
expect(res.data).toBeInstanceOf(Blob);
});
});

View File

@@ -0,0 +1,116 @@
import type {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
CreateAxiosDefaults,
} from 'axios';
import { bindMethods, merge } from '@vben/utils';
import axios from 'axios';
import { FileDownloader } from './modules/downloader';
import { InterceptorManager } from './modules/interceptor';
import { FileUploader } from './modules/uploader';
import { type RequestClientOptions } from './types';
class RequestClient {
private readonly instance: AxiosInstance;
public addRequestInterceptor: InterceptorManager['addRequestInterceptor'];
public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
public download: FileDownloader['download'];
// 是否正在刷新token
public isRefreshing = false;
// 刷新token队列
public refreshTokenQueue: ((token: string) => void)[] = [];
public upload: FileUploader['upload'];
/**
* 构造函数用于创建Axios实例
* @param options - Axios请求配置可选
*/
constructor(options: RequestClientOptions = {}) {
// 合并默认配置和传入的配置
const defaultConfig: CreateAxiosDefaults = {
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
// 默认超时时间
timeout: 10_000,
};
const { ...axiosConfig } = options;
const requestConfig = merge(axiosConfig, defaultConfig);
this.instance = axios.create(requestConfig);
bindMethods(this);
// 实例化拦截器管理器
const interceptorManager = new InterceptorManager(this.instance);
this.addRequestInterceptor =
interceptorManager.addRequestInterceptor.bind(interceptorManager);
this.addResponseInterceptor =
interceptorManager.addResponseInterceptor.bind(interceptorManager);
// 实例化文件上传器
const fileUploader = new FileUploader(this);
this.upload = fileUploader.upload.bind(fileUploader);
// 实例化文件下载器
const fileDownloader = new FileDownloader(this);
this.download = fileDownloader.download.bind(fileDownloader);
}
/**
* DELETE请求方法
*/
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'DELETE' });
}
/**
* GET请求方法
*/
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'GET' });
}
/**
* POST请求方法
*/
public post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'POST' });
}
/**
* PUT请求方法
*/
public put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'PUT' });
}
/**
* 通用的请求方法
*/
public async request<T>(url: string, config: AxiosRequestConfig): Promise<T> {
try {
const response: AxiosResponse<T> = await this.instance({
url,
...config,
});
return response as T;
} catch (error: any) {
throw error.response ? error.response.data : error;
}
}
}
export { RequestClient };

View File

@@ -0,0 +1,54 @@
import type {
AxiosResponse,
CreateAxiosDefaults,
InternalAxiosRequestConfig,
} from 'axios';
type RequestResponse<T = any> = AxiosResponse<T>;
type RequestContentType =
| 'application/json;charset=utf-8'
| 'application/octet-stream;charset=utf-8'
| 'application/x-www-form-urlencoded;charset=utf-8'
| 'multipart/form-data;charset=utf-8';
type RequestClientOptions = CreateAxiosDefaults;
interface RequestInterceptorConfig {
fulfilled?: (
config: InternalAxiosRequestConfig,
) =>
| InternalAxiosRequestConfig<any>
| Promise<InternalAxiosRequestConfig<any>>;
rejected?: (error: any) => any;
}
interface ResponseInterceptorConfig<T = any> {
fulfilled?: (
response: AxiosResponse<T>,
) => AxiosResponse | Promise<AxiosResponse>;
rejected?: (error: any) => any;
}
type MakeErrorMessageFn = (message: string, error: any) => void;
interface HttpResponse<T = any> {
/**
* 0 表示成功 其他表示失败
* 0 means success, others means fail
*/
code?: number;
data?: T;
message?: string;
error?:string;
}
export type {
HttpResponse,
MakeErrorMessageFn,
RequestClientOptions,
RequestContentType,
RequestInterceptorConfig,
RequestResponse,
ResponseInterceptorConfig,
};

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}