Merge remote-tracking branch 'vben/main' into vben-dev

# Conflicts:
#	.gitignore
#	.husky/pre-commit
#	.vscode/extensions.json
#	.vscode/settings.json
#	README.ja-JP.md
#	README.md
#	README.zh-CN.md
#	apps/backend-mock/api/auth/codes.ts
#	apps/backend-mock/api/auth/login.post.ts
#	apps/backend-mock/api/auth/logout.post.ts
#	apps/backend-mock/api/auth/refresh.post.ts
#	apps/backend-mock/api/menu/all.ts
#	apps/backend-mock/api/status.ts
#	apps/backend-mock/api/table/list.ts
#	apps/backend-mock/api/test.get.ts
#	apps/backend-mock/api/test.post.ts
#	apps/backend-mock/api/user/info.ts
#	apps/backend-mock/middleware/1.api.ts
#	apps/backend-mock/nitro.config.ts
#	apps/backend-mock/package.json
#	apps/backend-mock/routes/[...].ts
#	apps/backend-mock/tsconfig.json
#	apps/backend-mock/utils/cookie-utils.ts
#	apps/backend-mock/utils/jwt-utils.ts
#	apps/backend-mock/utils/mock-data.ts
#	apps/backend-mock/utils/response.ts
#	apps/web-antd/.env
#	apps/web-antd/src/adapter/component/index.ts
#	apps/web-antd/src/adapter/form.ts
#	apps/web-antd/src/api/request.ts
#	apps/web-antd/src/layouts/auth.vue
#	apps/web-antd/src/layouts/basic.vue
#	apps/web-antd/src/preferences.ts
#	apps/web-antd/src/router/guard.ts
#	apps/web-antd/src/router/routes/modules/demos.ts
#	apps/web-antd/src/store/auth.ts
#	apps/web-antd/src/views/demos/antd/index.vue
#	apps/web-antdv-next/vite.config.ts
#	apps/web-ele/.env
#	apps/web-ele/index.html
#	apps/web-ele/package.json
#	apps/web-ele/src/adapter/component/index.ts
#	apps/web-ele/src/adapter/form.ts
#	apps/web-ele/src/adapter/vxe-table.ts
#	apps/web-ele/src/api/request.ts
#	apps/web-ele/src/bootstrap.ts
#	apps/web-ele/src/layouts/auth.vue
#	apps/web-ele/src/layouts/basic.vue
#	apps/web-ele/src/locales/index.ts
#	apps/web-ele/src/locales/langs/en-US/demos.json
#	apps/web-ele/src/locales/langs/en-US/page.json
#	apps/web-ele/src/locales/langs/zh-CN/demos.json
#	apps/web-ele/src/locales/langs/zh-CN/page.json
#	apps/web-ele/src/preferences.ts
#	apps/web-ele/src/router/guard.ts
#	apps/web-ele/src/router/routes/core.ts
#	apps/web-ele/src/router/routes/modules/dashboard.ts
#	apps/web-ele/src/router/routes/modules/demos.ts
#	apps/web-ele/src/router/routes/modules/vben.ts
#	apps/web-ele/src/store/auth.ts
#	apps/web-ele/src/views/_core/authentication/code-login.vue
#	apps/web-ele/src/views/_core/authentication/forget-password.vue
#	apps/web-ele/src/views/_core/authentication/register.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-trends.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-data.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-sales.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits-source.vue
#	apps/web-ele/src/views/dashboard/analytics/analytics-visits.vue
#	apps/web-ele/src/views/dashboard/analytics/index.vue
#	apps/web-ele/src/views/demos/element/index.vue
#	apps/web-ele/src/views/demos/form/basic.vue
#	apps/web-ele/tsconfig.json
#	apps/web-ele/tsconfig.node.json
#	apps/web-naive/.env
#	apps/web-naive/index.html
#	apps/web-naive/package.json
#	apps/web-naive/src/adapter/component/index.ts
#	apps/web-naive/src/adapter/form.ts
#	apps/web-naive/src/adapter/vxe-table.ts
#	apps/web-naive/src/api/request.ts
#	apps/web-naive/src/bootstrap.ts
#	apps/web-naive/src/layouts/auth.vue
#	apps/web-naive/src/layouts/basic.vue
#	apps/web-naive/src/locales/index.ts
#	apps/web-naive/src/locales/langs/en-US/demos.json
#	apps/web-naive/src/locales/langs/en-US/page.json
#	apps/web-naive/src/locales/langs/zh-CN/demos.json
#	apps/web-naive/src/locales/langs/zh-CN/page.json
#	apps/web-naive/src/preferences.ts
#	apps/web-naive/src/router/guard.ts
#	apps/web-naive/src/router/routes/core.ts
#	apps/web-naive/src/router/routes/modules/dashboard.ts
#	apps/web-naive/src/router/routes/modules/demos.ts
#	apps/web-naive/src/router/routes/modules/vben.ts
#	apps/web-naive/src/store/auth.ts
#	apps/web-naive/src/views/_core/authentication/code-login.vue
#	apps/web-naive/src/views/_core/authentication/forget-password.vue
#	apps/web-naive/src/views/_core/authentication/register.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-trends.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-data.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-sales.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits-source.vue
#	apps/web-naive/src/views/dashboard/analytics/analytics-visits.vue
#	apps/web-naive/src/views/dashboard/analytics/index.vue
#	apps/web-naive/src/views/demos/form/basic.vue
#	apps/web-naive/src/views/demos/naive/index.vue
#	apps/web-naive/tsconfig.json
#	apps/web-naive/tsconfig.node.json
#	apps/web-naive/vite.config.ts
#	docs/.vitepress/components/demo-preview.vue
#	docs/.vitepress/components/preview-group.vue
#	docs/.vitepress/config/en.mts
#	docs/.vitepress/config/index.mts
#	docs/.vitepress/config/plugins/demo-preview.ts
#	docs/.vitepress/config/shared.mts
#	docs/.vitepress/config/zh.mts
#	docs/.vitepress/theme/components/site-layout.vue
#	docs/.vitepress/theme/components/vben-contributors.vue
#	docs/.vitepress/theme/styles/variables.css
#	docs/package.json
#	docs/src/_env/adapter/component.ts
#	docs/src/_env/adapter/form.ts
#	docs/src/_env/adapter/vxe-table.ts
#	docs/src/commercial/community.md
#	docs/src/components/common-ui/vben-count-to-animator.md
#	docs/src/components/common-ui/vben-drawer.md
#	docs/src/components/common-ui/vben-form.md
#	docs/src/components/common-ui/vben-modal.md
#	docs/src/components/common-ui/vben-vxe-table.md
#	docs/src/components/layout-ui/page.md
#	docs/src/demos/vben-drawer/auto-height/drawer.vue
#	docs/src/demos/vben-drawer/basic/index.vue
#	docs/src/demos/vben-drawer/dynamic/index.vue
#	docs/src/demos/vben-drawer/shared-data/drawer.vue
#	docs/src/demos/vben-drawer/shared-data/index.vue
#	docs/src/demos/vben-form/api/index.vue
#	docs/src/demos/vben-form/basic/index.vue
#	docs/src/demos/vben-form/custom/index.vue
#	docs/src/demos/vben-form/dynamic/index.vue
#	docs/src/demos/vben-form/query/index.vue
#	docs/src/demos/vben-form/rules/index.vue
#	docs/src/demos/vben-modal/auto-height/modal.vue
#	docs/src/demos/vben-modal/basic/index.vue
#	docs/src/demos/vben-modal/dynamic/index.vue
#	docs/src/demos/vben-modal/shared-data/index.vue
#	docs/src/demos/vben-modal/shared-data/modal.vue
#	docs/src/demos/vben-vxe-table/basic/index.vue
#	docs/src/demos/vben-vxe-table/custom-cell/index.vue
#	docs/src/demos/vben-vxe-table/edit-row/index.vue
#	docs/src/demos/vben-vxe-table/fixed/index.vue
#	docs/src/demos/vben-vxe-table/form/index.vue
#	docs/src/demos/vben-vxe-table/remote/index.vue
#	docs/src/demos/vben-vxe-table/tree/index.vue
#	docs/src/demos/vben-vxe-table/virtual/index.vue
#	docs/src/en/guide/essentials/development.md
#	docs/src/en/guide/essentials/external-module.md
#	docs/src/en/guide/essentials/route.md
#	docs/src/en/guide/essentials/server.md
#	docs/src/en/guide/essentials/settings.md
#	docs/src/en/guide/in-depth/access.md
#	docs/src/en/guide/in-depth/loading.md
#	docs/src/en/guide/in-depth/locale.md
#	docs/src/en/guide/in-depth/theme.md
#	docs/src/en/guide/introduction/quick-start.md
#	docs/src/en/guide/introduction/thin.md
#	docs/src/en/guide/introduction/vben.md
#	docs/src/en/guide/other/faq.md
#	docs/src/en/guide/project/dir.md
#	docs/src/en/guide/project/standard.md
#	docs/src/en/guide/project/tailwindcss.md
#	docs/src/en/index.md
#	docs/src/friend-links/index.md
#	docs/src/guide/essentials/build.md
#	docs/src/guide/essentials/development.md
#	docs/src/guide/essentials/external-module.md
#	docs/src/guide/essentials/route.md
#	docs/src/guide/essentials/server.md
#	docs/src/guide/essentials/settings.md
#	docs/src/guide/in-depth/access.md
#	docs/src/guide/in-depth/check-updates.md
#	docs/src/guide/in-depth/loading.md
#	docs/src/guide/in-depth/locale.md
#	docs/src/guide/in-depth/theme.md
#	docs/src/guide/introduction/quick-start.md
#	docs/src/guide/introduction/thin.md
#	docs/src/guide/introduction/vben.md
#	docs/src/guide/introduction/why.md
#	docs/src/guide/other/faq.md
#	docs/src/guide/project/dir.md
#	docs/src/guide/project/standard.md
#	docs/src/guide/project/tailwindcss.md
#	docs/src/index.md
#	docs/tsconfig.json
#	package.json
#	packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue
#	packages/constants/src/core.ts
#	packages/effects/common-ui/src/components/index.ts
#	packages/effects/common-ui/src/ui/dashboard/analysis/analysis-overview.vue
#	packages/effects/common-ui/src/ui/dashboard/workbench/workbench-header.vue
#	packages/effects/common-ui/src/ui/dashboard/workbench/workbench-project.vue
#	packages/effects/layouts/src/authentication/authentication.vue
#	packages/effects/layouts/src/basic/layout.vue
#	packages/effects/layouts/src/widgets/notification/notification.vue
#	packages/effects/layouts/src/widgets/notification/types.ts
#	packages/icons/src/iconify/index.ts
#	packages/icons/src/svg/index.ts
#	packages/stores/src/modules/user.ts
#	playground/src/adapter/component/index.ts
#	playground/src/store/auth.ts
#	pnpm-lock.yaml
#	vben-admin.code-workspace
This commit is contained in:
李琦
2026-08-10 16:55:17 +08:00
1278 changed files with 71754 additions and 19298 deletions

View File

@@ -30,7 +30,9 @@ describe('fileDownloader', () => {
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
method: 'GET',
responseType: 'blob',
responseReturn: 'body',
});
});
@@ -50,7 +52,9 @@ describe('fileDownloader', () => {
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
...customConfig,
method: 'GET',
responseType: 'blob',
responseReturn: 'body',
});
});
@@ -82,3 +86,72 @@ describe('fileDownloader', () => {
);
});
});
describe('fileDownloader use other method', () => {
let fileDownloader: FileDownloader;
it('should call request using get', async () => {
const url = 'https://example.com/file';
const mockBlob = new Blob(['file content'], { type: 'text/plain' });
const mockResponse: Blob = mockBlob;
const mockAxiosInstance = {
request: vi.fn(),
} as any;
fileDownloader = new FileDownloader(mockAxiosInstance);
mockAxiosInstance.request.mockResolvedValueOnce(mockResponse);
const result = await fileDownloader.download(url);
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.request).toHaveBeenCalledWith(url, {
method: 'GET',
responseType: 'blob',
responseReturn: 'body',
});
});
it('should call post', async () => {
const url = 'https://example.com/file';
const mockAxiosInstance = {
post: vi.fn(),
} as any;
fileDownloader = new FileDownloader(mockAxiosInstance);
const customConfig: AxiosRequestConfig = {
method: 'POST',
data: { name: 'aa' },
};
await fileDownloader.download(url, customConfig);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
url,
{ name: 'aa' },
{
method: 'POST',
responseType: 'blob',
responseReturn: 'body',
},
);
});
it('should handle errors gracefully', async () => {
const url = 'https://example.com/file';
const mockAxiosInstance = {
post: vi.fn(),
} as any;
fileDownloader = new FileDownloader(mockAxiosInstance);
await expect(() =>
fileDownloader.download(url, { method: 'postt' }),
).rejects.toThrow(
'RequestClient does not support method "POSTT". Please ensure the method is properly implemented in your RequestClient instance.',
);
});
});

View File

@@ -1,7 +1,14 @@
import type { AxiosRequestConfig } from 'axios';
import type { RequestClient } from '../request-client';
import type { RequestResponse } from '../types';
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;
@@ -9,22 +16,44 @@ class FileDownloader {
constructor(client: RequestClient) {
this.client = client;
}
public async download(
/**
* 下载文件
* @param url 文件的完整链接
* @param config 配置信息,可选。
* @returns 如果config.responseReturn为'body',则返回Blob(默认),否则返回RequestResponse<Blob>
*/
public async download<T = Blob>(
url: string,
config?: AxiosRequestConfig,
): Promise<RequestResponse<Blob>> {
const finalConfig: AxiosRequestConfig = {
config?: DownloadRequestConfig,
): Promise<T> {
const finalConfig: DownloadRequestConfig = {
responseReturn: 'body',
method: 'GET',
...config,
responseType: 'blob',
};
const response = await this.client.get<RequestResponse<Blob>>(
url,
finalConfig,
);
// Prefer a generic request if available; otherwise, dispatch to method-specific calls.
const method = (finalConfig.method || 'GET').toUpperCase();
const clientAny = this.client as any;
return response;
if (typeof clientAny.request === 'function') {
return await clientAny.request(url, finalConfig);
}
const lower = method.toLowerCase();
if (typeof clientAny[lower] === 'function') {
if (['POST', 'PUT'].includes(method)) {
const { data, ...rest } = finalConfig as Record<string, any>;
return await clientAny[lower](url, data, rest);
}
return await clientAny[lower](url, finalConfig);
}
throw new Error(
`RequestClient does not support method "${method}". Please ensure the method is properly implemented in your RequestClient instance.`,
);
}
}

View File

@@ -0,0 +1,142 @@
import type { RequestClient } from '../request-client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SSE } from './sse';
// 模拟 TextDecoder
const OriginalTextDecoder = globalThis.TextDecoder;
beforeEach(() => {
vi.stubGlobal(
'TextDecoder',
class {
private decoder = new OriginalTextDecoder();
decode(value: Uint8Array, opts?: any) {
return this.decoder.decode(value, opts);
}
},
);
});
// 创建 fetch mock
const createFetchMock = (chunks: string[], ok = true) => {
const encoder = new TextEncoder();
let index = 0;
return vi.fn().mockResolvedValue({
ok,
status: ok ? 200 : 500,
body: {
getReader: () => ({
read: async () => {
if (index < chunks.length) {
return { done: false, value: encoder.encode(chunks[index++]) };
}
return { done: true, value: undefined };
},
}),
},
});
};
describe('sSE', () => {
let client: RequestClient;
let sse: SSE;
beforeEach(() => {
vi.restoreAllMocks();
client = {
getBaseUrl: () => 'http://localhost',
instance: {
interceptors: {
request: {
handlers: [],
},
},
},
} as unknown as RequestClient;
sse = new SSE(client);
});
it('should call requestSSE when postSSE is used', async () => {
const spy = vi.spyOn(sse, 'requestSSE').mockResolvedValue(undefined);
await sse.postSSE('/test', { foo: 'bar' }, { headers: { a: '1' } });
expect(spy).toHaveBeenCalledWith(
'/test',
{ foo: 'bar' },
{
headers: { a: '1' },
method: 'POST',
},
);
});
it('should throw error if fetch response not ok', async () => {
vi.stubGlobal('fetch', createFetchMock([], false));
await expect(sse.requestSSE('/bad')).rejects.toThrow(
'HTTP error! status: 500',
);
});
it('should trigger onMessage and onEnd callbacks', async () => {
const messages: string[] = [];
const onMessage = vi.fn((msg: string) => messages.push(msg));
const onEnd = vi.fn();
vi.stubGlobal('fetch', createFetchMock(['hello', ' world']));
await sse.requestSSE('/sse', undefined, { onMessage, onEnd });
expect(onMessage).toHaveBeenCalledTimes(2);
expect(messages.join('')).toBe('hello world');
// onEnd 不再带参数
expect(onEnd).toHaveBeenCalled();
});
it('should apply request interceptors', async () => {
const interceptor = vi.fn(async (config) => {
config.headers['x-test'] = 'intercepted';
return config;
});
(client.instance.interceptors.request as any).handlers.push({
fulfilled: interceptor,
});
// 创建 fetch mock,并挂到全局
const fetchMock = createFetchMock(['data']);
vi.stubGlobal('fetch', fetchMock);
await sse.requestSSE('/sse', undefined, {});
expect(interceptor).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost/sse',
expect.objectContaining({
headers: expect.any(Headers),
}),
);
const calls = fetchMock.mock?.calls;
expect(calls).toBeDefined();
expect(calls?.length).toBeGreaterThan(0);
const init = calls?.[0]?.[1] as RequestInit;
expect(init).toBeDefined();
const headers = init?.headers as Headers;
expect(headers?.get('x-test')).toBe('intercepted');
expect(headers?.get('accept')).toBe('text/event-stream');
});
it('should throw error when no reader', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
body: null,
}),
);
await expect(sse.requestSSE('/sse')).rejects.toThrow('No reader');
});
});

View File

@@ -0,0 +1,134 @@
import type { AxiosRequestHeaders, InternalAxiosRequestConfig } from 'axios';
import type { RequestClient } from '../request-client';
import type { SseRequestOptions } from '../types';
/**
* SSE模块
*/
class SSE {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
public async postSSE(
url: string,
data?: any,
requestOptions?: SseRequestOptions,
) {
return this.requestSSE(url, data, {
...requestOptions,
method: 'POST',
});
}
/**
* SSE请求方法
* @param url - 请求URL
* @param data - 请求数据
* @param requestOptions - SSE请求选项
*/
public async requestSSE(
url: string,
data?: any,
requestOptions?: SseRequestOptions,
) {
const baseUrl = this.client.getBaseUrl() || '';
let axiosConfig: InternalAxiosRequestConfig<any> = {
url,
method: (requestOptions?.method as any) ?? 'GET',
headers: {} as AxiosRequestHeaders,
};
const requestInterceptors = this.client.instance.interceptors
.request as any;
if (
requestInterceptors.handlers &&
requestInterceptors.handlers.length > 0
) {
for (const handler of requestInterceptors.handlers) {
if (typeof handler?.fulfilled === 'function') {
const next = await handler.fulfilled(axiosConfig as any);
if (next) axiosConfig = next as InternalAxiosRequestConfig<any>;
}
}
}
const merged = new Headers();
Object.entries(
(axiosConfig.headers ?? {}) as Record<string, string>,
).forEach(([k, v]) => merged.set(k, String(v)));
if (requestOptions?.headers) {
new Headers(requestOptions.headers).forEach((v, k) => merged.set(k, v));
}
if (!merged.has('accept')) {
merged.set('accept', 'text/event-stream');
}
let bodyInit = requestOptions?.body ?? data;
const ct = (merged.get('content-type') || '').toLowerCase();
if (
bodyInit &&
typeof bodyInit === 'object' &&
!ArrayBuffer.isView(bodyInit as any) &&
!(bodyInit instanceof ArrayBuffer) &&
!(bodyInit instanceof Blob) &&
!(bodyInit instanceof FormData) &&
ct.includes('application/json')
) {
bodyInit = JSON.stringify(bodyInit);
}
const requestInit: RequestInit = {
...requestOptions,
method: axiosConfig.method,
headers: merged,
body: bodyInit,
};
const response = await fetch(safeJoinUrl(baseUrl, url), requestInit);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('No reader');
}
while (true) {
const { done, value } = await reader.read();
if (done) {
decoder.decode(new Uint8Array(0), { stream: false });
requestOptions?.onEnd?.();
reader.releaseLock?.();
break;
}
const content = decoder.decode(value, { stream: true });
requestOptions?.onMessage?.(content);
}
}
}
function safeJoinUrl(baseUrl: string | undefined, url: string): string {
if (!baseUrl) {
return url; // 没有 baseUrl,直接返回 url
}
// 如果 url 本身就是绝对地址,直接返回
if (/^https?:\/\//i.test(url)) {
return url;
}
// 如果 baseUrl 是完整 URL,就用 new URL
if (/^https?:\/\//i.test(baseUrl)) {
return new URL(url, baseUrl).toString();
}
// 否则,当作路径拼接
return `${baseUrl.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`;
}
export { SSE };

View File

@@ -1,6 +1,7 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import type { RequestClient } from '../request-client';
import type { RequestClientConfig } from '../types';
import { isUndefined } from '@vben/utils';
class FileUploader {
private client: RequestClient;
@@ -9,18 +10,24 @@ class FileUploader {
this.client = client;
}
public async upload(
public async upload<T = any>(
url: string,
data: { file: Blob | File } & Record<string, any>,
config?: AxiosRequestConfig,
): Promise<AxiosResponse> {
data: Record<string, any> & { file: Blob | File },
config?: RequestClientConfig,
): Promise<T> {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
formData.append(key, value);
if (Array.isArray(value)) {
value.forEach((item, index) => {
!isUndefined(item) && formData.append(`${key}[${index}]`, item);
});
} else {
!isUndefined(value) && formData.append(key, value);
}
});
const finalConfig: AxiosRequestConfig = {
const finalConfig: RequestClientConfig = {
...config,
headers: {
'Content-Type': 'multipart/form-data',

View File

@@ -2,9 +2,48 @@ 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,
@@ -91,7 +130,7 @@ export const errorMessageResponseInterceptor = (
return Promise.reject(error);
}
let errorMessage = '';
let errorMessage: string;
const status = error?.response?.status;
switch (status) {

View File

@@ -50,24 +50,18 @@ describe('requestClient', () => {
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');
}
await expect(requestClient.get('/test/error')).rejects.toMatchObject({
isAxiosError: true,
message: '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');
}
await expect(requestClient.get('/test/timeout')).rejects.toMatchObject({
isAxiosError: true,
code: 'ECONNABORTED',
});
});
it('should successfully upload a file', async () => {
@@ -92,7 +86,7 @@ describe('requestClient', () => {
mock.onGet('/test/download').reply(200, mockFileContent);
const res = await requestClient.download('/test/download');
const res = await requestClient.download<any>('/test/download');
expect(res.data).toBeInstanceOf(Blob);
});

View File

@@ -1,30 +1,54 @@
import type {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
CreateAxiosDefaults,
} from 'axios';
import type { AxiosInstance, AxiosResponse } from 'axios';
import { bindMethods, merge } from '@vben/utils';
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 { SSE } from './modules/sse';
import { FileUploader } from './modules/uploader';
import { type RequestClientOptions } from './types';
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 {
private readonly instance: AxiosInstance;
public addRequestInterceptor: InterceptorManager['addRequestInterceptor'];
public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
public download: FileDownloader['download'];
public readonly instance: AxiosInstance;
// 是否正在刷新token
public isRefreshing = false;
public postSSE: SSE['postSSE'];
// 刷新token队列
public refreshTokenQueue: ((token: string) => void)[] = [];
public requestSSE: SSE['requestSSE'];
public upload: FileUploader['upload'];
/**
@@ -33,15 +57,19 @@ class RequestClient {
*/
constructor(options: RequestClientOptions = {}) {
// 合并默认配置和传入的配置
const defaultConfig: CreateAxiosDefaults = {
const defaultConfig: RequestClientOptions = {
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
responseReturn: 'raw',
// 默认超时时间
timeout: 100_000,
};
const { ...axiosConfig } = options;
const requestConfig = merge(axiosConfig, defaultConfig);
requestConfig.paramsSerializer = getParamsSerializer(
requestConfig.paramsSerializer,
);
this.instance = axios.create(requestConfig);
bindMethods(this);
@@ -59,29 +87,43 @@ class RequestClient {
// 实例化文件下载器
const fileDownloader = new FileDownloader(this);
this.download = fileDownloader.download.bind(fileDownloader);
// 实例化SSE模块
const sse = new SSE(this);
this.postSSE = sse.postSSE.bind(sse);
this.requestSSE = sse.requestSSE.bind(sse);
}
/**
* DELETE请求方法
*/
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
public delete<T = any>(
url: string,
config?: RequestClientConfig,
): Promise<T> {
return this.request<T>(url, { ...config, method: 'DELETE' });
}
/**
* GET请求方法
*/
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
public get<T = any>(url: string, config?: RequestClientConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'GET' });
}
/**
* 获取基础URL
*/
public getBaseUrl() {
return this.instance.defaults.baseURL;
}
/**
* POST请求方法
*/
public post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
config?: RequestClientConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'POST' });
}
@@ -92,7 +134,7 @@ class RequestClient {
public put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
config?: RequestClientConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'PUT' });
}
@@ -100,11 +142,17 @@ class RequestClient {
/**
* 通用的请求方法
*/
public async request<T>(url: string, config: AxiosRequestConfig): Promise<T> {
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) {

View File

@@ -1,10 +1,37 @@
import type {
AxiosRequestConfig,
AxiosResponse,
CreateAxiosDefaults,
InternalAxiosRequestConfig,
} from 'axios';
type RequestResponse<T = any> = AxiosResponse<T>;
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'
@@ -12,21 +39,29 @@ type RequestContentType =
| 'application/x-www-form-urlencoded;charset=utf-8'
| 'multipart/form-data;charset=utf-8';
type RequestClientOptions = CreateAxiosDefaults;
type RequestClientOptions = CreateAxiosDefaults & ExtendOptions;
/**
* SSE 请求选项
*/
interface SseRequestOptions extends RequestInit {
onMessage?: (message: string) => void;
onEnd?: () => void;
}
interface RequestInterceptorConfig {
fulfilled?: (
config: InternalAxiosRequestConfig,
config: ExtendOptions & InternalAxiosRequestConfig,
) =>
| InternalAxiosRequestConfig<any>
| Promise<InternalAxiosRequestConfig<any>>;
| (ExtendOptions & InternalAxiosRequestConfig<any>)
| Promise<ExtendOptions & InternalAxiosRequestConfig<any>>;
rejected?: (error: any) => any;
}
interface ResponseInterceptorConfig<T = any> {
fulfilled?: (
response: AxiosResponse<T>,
) => AxiosResponse | Promise<AxiosResponse>;
response: RequestResponse<T>,
) => Promise<RequestResponse> | RequestResponse;
rejected?: (error: any) => any;
}
@@ -45,9 +80,11 @@ interface HttpResponse<T = any> {
export type {
HttpResponse,
MakeErrorMessageFn,
RequestClientConfig,
RequestClientOptions,
RequestContentType,
RequestInterceptorConfig,
RequestResponse,
ResponseInterceptorConfig,
SseRequestOptions,
};