物业代码生成

This commit is contained in:
2025-06-18 11:03:42 +08:00
commit 1262d4c745
1881 changed files with 249599 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,86 @@
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',
responseReturn: 'body',
});
});
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',
responseReturn: 'body',
});
});
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,41 @@
import type { RequestClient } from '../request-client';
import type { RequestClientConfig } from '../types';
type DownloadRequestConfig = {
/**
* 定义期望获得的数据类型。
* raw: 原始的AxiosResponse包括headers、status等。
* body: 只返回响应数据的BODY部分(Blob)
*/
responseReturn?: 'body' | 'raw';
} & Omit<RequestClientConfig, 'responseReturn'>;
class FileDownloader {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
/**
* 下载文件
* @param url 文件的完整链接
* @param config 配置信息,可选。
* @returns 如果config.responseReturn为'body'则返回Blob(默认)否则返回RequestResponse<Blob>
*/
public async download<T = Blob>(
url: string,
config?: DownloadRequestConfig,
): Promise<T> {
const finalConfig: DownloadRequestConfig = {
responseReturn: 'body',
...config,
responseType: 'blob',
};
const response = await this.client.get<T>(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,40 @@
import type { RequestClient } from '../request-client';
import type { RequestClientConfig } from '../types';
class FileUploader {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
public async upload<T = any>(
url: string,
data: Record<string, any> & { file: Blob | File },
config?: RequestClientConfig,
): Promise<T> {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item, index) => {
formData.append(`${key}[${index}]`, item);
});
} else {
formData.append(key, value);
}
});
const finalConfig: RequestClientConfig = {
...config,
headers: {
'Content-Type': 'multipart/form-data',
...config?.headers,
},
};
return this.client.post(url, formData, finalConfig);
}
}
export { FileUploader };

View File

@@ -0,0 +1,165 @@
import type { RequestClient } from './request-client';
import type { MakeErrorMessageFn, ResponseInterceptorConfig } from './types';
import { $t } from '@vben/locales';
import { isFunction } from '@vben/utils';
import axios from 'axios';
export const defaultResponseInterceptor = ({
codeField = 'code',
dataField = 'data',
successCode = 0,
}: {
/** 响应数据中代表访问结果的字段名 */
codeField: string;
/** 响应数据中装载实际数据的字段名,或者提供一个函数从响应数据中解析需要返回的数据 */
dataField: ((response: any) => any) | string;
/** 当codeField所指定的字段值与successCode相同时代表接口访问成功。如果提供一个函数则返回true代表接口访问成功 */
successCode: ((code: any) => boolean) | number | string;
}): ResponseInterceptorConfig => {
return {
fulfilled: (response) => {
const { config, data: responseData, status } = response;
if (config.responseReturn === 'raw') {
return response;
}
if (status >= 200 && status < 400) {
if (config.responseReturn === 'body') {
return responseData;
} else if (
isFunction(successCode)
? successCode(responseData[codeField])
: responseData[codeField] === successCode
) {
return isFunction(dataField)
? dataField(responseData)
: responseData[dataField];
}
}
throw Object.assign({}, response, { response });
},
};
};
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;
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,196 @@
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { RequestClientConfig, RequestClientOptions } from './types';
import { bindMethods, isString, merge } from '@vben/utils';
import axios from 'axios';
import qs from 'qs';
import { FileDownloader } from './modules/downloader';
import { InterceptorManager } from './modules/interceptor';
import { FileUploader } from './modules/uploader';
function getParamsSerializer(
paramsSerializer: RequestClientOptions['paramsSerializer'],
) {
if (isString(paramsSerializer)) {
switch (paramsSerializer) {
case 'brackets': {
return (params: any) =>
qs.stringify(params, { arrayFormat: 'brackets' });
}
case 'comma': {
return (params: any) => qs.stringify(params, { arrayFormat: 'comma' });
}
case 'indices': {
return (params: any) =>
qs.stringify(params, { arrayFormat: 'indices' });
}
case 'repeat': {
return (params: any) => qs.stringify(params, { arrayFormat: 'repeat' });
}
}
}
return paramsSerializer;
}
class RequestClient {
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'];
private readonly instance: AxiosInstance;
/**
* 构造函数用于创建Axios实例
* @param options - Axios请求配置可选
*/
constructor(options: RequestClientOptions = {}) {
// 合并默认配置和传入的配置
const defaultConfig: RequestClientOptions = {
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
responseReturn: 'raw',
// 默认超时时间
timeout: 10_000,
};
const { ...axiosConfig } = options;
const requestConfig = merge(axiosConfig, defaultConfig);
requestConfig.paramsSerializer = getParamsSerializer(
requestConfig.paramsSerializer,
);
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?: RequestClientConfig,
): Promise<T> {
return this.request<T>(url, { ...config, method: 'DELETE' });
}
/**
* DELETE请求方法 成功会弹出msg
*/
public deleteWithMsg<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, {
...config,
method: 'DELETE',
successMessageMode: 'message',
});
}
/**
* GET请求方法
*/
public get<T = any>(url: string, config?: RequestClientConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'GET' });
}
/**
* POST请求方法
*/
public post<T = any>(
url: string,
data?: any,
config?: RequestClientConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'POST' });
}
/**
* POST请求方法 成功会弹出msg
*/
public postWithMsg<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, {
...config,
data,
method: 'POST',
successMessageMode: 'message',
});
}
/**
* PUT请求方法
*/
public put<T = any>(
url: string,
data?: any,
config?: RequestClientConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'PUT' });
}
/**
* PUT请求方法 成功会弹出msg
*/
public putWithMsg<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, {
...config,
data,
method: 'PUT',
successMessageMode: 'message',
});
}
/**
* 通用的请求方法
*/
public async request<T>(
url: string,
config: RequestClientConfig,
): Promise<T> {
try {
const response: AxiosResponse<T> = await this.instance({
url,
...config,
...(config.paramsSerializer
? { paramsSerializer: getParamsSerializer(config.paramsSerializer) }
: {}),
});
return response as T;
} catch (error: any) {
throw error.response ? error.response.data : error;
}
}
}
export { RequestClient };

View File

@@ -0,0 +1,106 @@
import type {
AxiosRequestConfig,
AxiosResponse,
CreateAxiosDefaults,
InternalAxiosRequestConfig,
} from 'axios';
type ExtendOptions<T = any> = {
/**
* 参数序列化方式。预置的有
* - brackets: ids[]=1&ids[]=2&ids[]=3
* - comma: ids=1,2,3
* - indices: ids[0]=1&ids[1]=2&ids[2]=3
* - repeat: ids=1&ids=2&ids=3
*/
paramsSerializer?:
| 'brackets'
| 'comma'
| 'indices'
| 'repeat'
| AxiosRequestConfig<T>['paramsSerializer'];
/**
* 响应数据的返回方式。
* - raw: 原始的AxiosResponse包括headers、status等不做是否成功请求的检查。
* - body: 返回响应数据的BODY部分只会根据status检查请求是否成功忽略对code的判断这种情况下应由调用方检查请求是否成功
* - data: 解构响应的BODY数据只返回其中的data节点数据会检查status和code是否为成功状态
*/
responseReturn?: 'body' | 'data' | 'raw';
};
type RequestClientConfig<T = any> = AxiosRequestConfig<T> & ExtendOptions<T>;
type RequestResponse<T = any> = AxiosResponse<T> & {
config: RequestClientConfig<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 & ExtendOptions;
interface RequestInterceptorConfig {
fulfilled?: (
config: ExtendOptions & InternalAxiosRequestConfig,
) =>
| (ExtendOptions & InternalAxiosRequestConfig<any>)
| Promise<ExtendOptions & InternalAxiosRequestConfig<any>>;
rejected?: (error: any) => any;
}
interface ResponseInterceptorConfig<T = any> {
fulfilled?: (
response: RequestResponse<T>,
) => Promise<RequestResponse> | RequestResponse;
rejected?: (error: any) => any;
}
type MakeErrorMessageFn = (message: string, error: any) => void;
interface HttpResponse<T = any> {
code: number;
data: T;
msg: string;
}
export type {
HttpResponse,
MakeErrorMessageFn,
RequestClientConfig,
RequestClientOptions,
RequestContentType,
RequestInterceptorConfig,
RequestResponse,
ResponseInterceptorConfig,
};
export type ErrorMessageMode = 'message' | 'modal' | 'none' | undefined;
export type SuccessMessageMode = ErrorMessageMode;
/**
* 拓展axios的请求配置
*/
declare module 'axios' {
interface AxiosRequestConfig {
/** 是否加密请求参数 */
encrypt?: boolean;
/**
* 错误弹窗类型
*/
errorMessageMode?: ErrorMessageMode;
/**
* 是否返回原生axios响应
*/
isReturnNativeResponse?: boolean;
/**
* 是否需要转换响应 即只获取{code, msg, data}中的data
*/
isTransformResponse?: boolean;
/**
* 成功弹窗类型
*/
successMessageMode?: SuccessMessageMode;
}
}