diff --git a/packages/effects/common-ui/src/ui/authentication/code-login.vue b/packages/effects/common-ui/src/ui/authentication/code-login.vue index 5dce7402..8045ea89 100644 --- a/packages/effects/common-ui/src/ui/authentication/code-login.vue +++ b/packages/effects/common-ui/src/ui/authentication/code-login.vue @@ -35,6 +35,10 @@ interface Props { * @zh_CN 按钮文本 */ submitButtonText?: string; + /** + * @zh_CN 是否显示返回按钮 + */ + showBack?: boolean; } defineOptions({ @@ -43,6 +47,7 @@ defineOptions({ const props = withDefaults(defineProps(), { loading: false, + showBack: true, loginPath: '/auth/login', submitButtonText: '', subTitle: '', @@ -110,7 +115,7 @@ defineExpose({ {{ submitButtonText || $t('common.login') }} - + {{ $t('common.back') }} diff --git a/packages/effects/common-ui/src/ui/authentication/qrcode-login.vue b/packages/effects/common-ui/src/ui/authentication/qrcode-login.vue index aee41a8d..493f98a7 100644 --- a/packages/effects/common-ui/src/ui/authentication/qrcode-login.vue +++ b/packages/effects/common-ui/src/ui/authentication/qrcode-login.vue @@ -35,6 +35,10 @@ interface Props { * @zh_CN 描述 */ description?: string; + /** + * @zh_CN 是否显示返回按钮 + */ + showBack?: boolean; } defineOptions({ @@ -44,6 +48,7 @@ defineOptions({ const props = withDefaults(defineProps(), { description: '', loading: false, + showBack: true, loginPath: '/auth/login', submitButtonText: '', subTitle: '', @@ -88,7 +93,7 @@ function goToLogin() {

- + {{ $t('common.back') }} diff --git a/packages/effects/request/src/request-client/modules/sse.test.ts b/packages/effects/request/src/request-client/modules/sse.test.ts new file mode 100644 index 00000000..4e8c6a9d --- /dev/null +++ b/packages/effects/request/src/request-client/modules/sse.test.ts @@ -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'); + }); +}); diff --git a/packages/effects/request/src/request-client/modules/sse.ts b/packages/effects/request/src/request-client/modules/sse.ts new file mode 100644 index 00000000..09d13017 --- /dev/null +++ b/packages/effects/request/src/request-client/modules/sse.ts @@ -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 = { + 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; + } + } + } + + const merged = new Headers(); + Object.entries( + (axiosConfig.headers ?? {}) as Record, + ).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 }; diff --git a/packages/effects/request/src/request-client/request-client.ts b/packages/effects/request/src/request-client/request-client.ts index e5811673..453913b2 100644 --- a/packages/effects/request/src/request-client/request-client.ts +++ b/packages/effects/request/src/request-client/request-client.ts @@ -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实例 @@ -84,6 +87,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); } /** @@ -103,6 +110,13 @@ class RequestClient { return this.request(url, { ...config, method: 'GET' }); } + /** + * 获取基础URL + */ + public getBaseUrl() { + return this.instance.defaults.baseURL; + } + /** * POST请求方法 */ diff --git a/packages/effects/request/src/request-client/types.ts b/packages/effects/request/src/request-client/types.ts index 494741dc..d40ee8a5 100644 --- a/packages/effects/request/src/request-client/types.ts +++ b/packages/effects/request/src/request-client/types.ts @@ -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, @@ -78,4 +86,5 @@ export type { RequestInterceptorConfig, RequestResponse, ResponseInterceptorConfig, + SseRequestOptions, };