Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
This commit is contained in:
@@ -35,6 +35,10 @@ interface Props {
|
||||
* @zh_CN 按钮文本
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
/**
|
||||
* @zh_CN 是否显示返回按钮
|
||||
*/
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -43,6 +47,7 @@ defineOptions({
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
showBack: true,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
@@ -110,7 +115,7 @@ defineExpose({
|
||||
{{ submitButtonText || $t('common.login') }}
|
||||
</slot>
|
||||
</VbenButton>
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
<VbenButton v-if="showBack" class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,10 @@ interface Props {
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @zh_CN 是否显示返回按钮
|
||||
*/
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -44,6 +48,7 @@ defineOptions({
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
description: '',
|
||||
loading: false,
|
||||
showBack: true,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
@@ -89,7 +94,7 @@ function goToLogin() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
<VbenButton v-if="showBack" class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
|
||||
142
packages/effects/request/src/request-client/modules/sse.test.ts
Normal file
142
packages/effects/request/src/request-client/modules/sse.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
136
packages/effects/request/src/request-client/modules/sse.ts
Normal file
136
packages/effects/request/src/request-client/modules/sse.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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');
|
||||
}
|
||||
let isEnd = false;
|
||||
while (!isEnd) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
isEnd = true;
|
||||
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 };
|
||||
@@ -9,6 +9,7 @@ import qs from 'qs';
|
||||
|
||||
import { FileDownloader } from './modules/downloader';
|
||||
import { InterceptorManager } from './modules/interceptor';
|
||||
import { SSE } from './modules/sse';
|
||||
import { FileUploader } from './modules/uploader';
|
||||
|
||||
function getParamsSerializer(
|
||||
@@ -41,12 +42,14 @@ class RequestClient {
|
||||
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'];
|
||||
private readonly instance: AxiosInstance;
|
||||
|
||||
/**
|
||||
* 构造函数,用于创建Axios实例
|
||||
@@ -85,6 +88,10 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,6 +111,13 @@ class RequestClient {
|
||||
return this.request<T>(url, { ...config, method: 'GET' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础URL
|
||||
*/
|
||||
public getBaseUrl() {
|
||||
return this.instance.defaults.baseURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求方法
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,14 @@ type RequestContentType =
|
||||
|
||||
type RequestClientOptions = CreateAxiosDefaults & ExtendOptions;
|
||||
|
||||
/**
|
||||
* SSE 请求选项
|
||||
*/
|
||||
interface SseRequestOptions extends RequestInit {
|
||||
onMessage?: (message: string) => void;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
interface RequestInterceptorConfig {
|
||||
fulfilled?: (
|
||||
config: ExtendOptions & InternalAxiosRequestConfig,
|
||||
@@ -91,4 +99,5 @@ export type {
|
||||
RequestInterceptorConfig,
|
||||
RequestResponse,
|
||||
ResponseInterceptorConfig,
|
||||
SseRequestOptions,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user