From eb4f1f816447d513978da9c4e7316fb57afac5c8 Mon Sep 17 00:00:00 2001
From: zhongming4762
Date: Thu, 11 Sep 2025 10:50:19 +0800
Subject: [PATCH 1/3] feat: add SSE support to request-client
---
.../src/request-client/modules/sse.test.ts | 131 ++++++++++++++++++
.../request/src/request-client/modules/sse.ts | 96 +++++++++++++
.../src/request-client/request-client.ts | 16 ++-
.../request/src/request-client/types.ts | 9 ++
4 files changed, 251 insertions(+), 1 deletion(-)
create mode 100644 packages/effects/request/src/request-client/modules/sse.test.ts
create mode 100644 packages/effects/request/src/request-client/modules/sse.ts
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..5d630a87
--- /dev/null
+++ b/packages/effects/request/src/request-client/modules/sse.test.ts
@@ -0,0 +1,131 @@
+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');
+ expect(onEnd).toHaveBeenCalledWith('hello world');
+ });
+
+ 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,
+ });
+
+ vi.stubGlobal('fetch', createFetchMock(['data']));
+
+ // 创建 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.objectContaining({ 'x-test': 'intercepted' }),
+ }),
+ );
+ });
+
+ 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..0edf2d4a
--- /dev/null
+++ b/packages/effects/request/src/request-client/modules/sse.ts
@@ -0,0 +1,96 @@
+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() || '';
+ const hasUrlSplit = baseUrl.endsWith('/') && url.startsWith('/');
+
+ const axiosConfig: InternalAxiosRequestConfig = {
+ 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 (handler.fulfilled) {
+ await handler.fulfilled(axiosConfig);
+ }
+ }
+ }
+
+ const requestInit: RequestInit = {
+ ...requestOptions,
+ body: data,
+ headers: {
+ ...(axiosConfig.headers as Record),
+ ...requestOptions?.headers,
+ },
+ };
+
+ const response = await fetch(
+ `${baseUrl}${hasUrlSplit ? '' : '/'}${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;
+ let allMessage = '';
+ while (!isEnd) {
+ const { done, value } = await reader.read();
+ if (done) {
+ isEnd = true;
+ requestOptions?.onEnd?.(allMessage);
+ break;
+ }
+ const content = decoder.decode(value, { stream: true });
+ requestOptions?.onMessage?.(content);
+ allMessage += content;
+ }
+ }
+}
+
+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..aa1e7811 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?: (message: string) => void;
+}
+
interface RequestInterceptorConfig {
fulfilled?: (
config: ExtendOptions & InternalAxiosRequestConfig,
@@ -78,4 +86,5 @@ export type {
RequestInterceptorConfig,
RequestResponse,
ResponseInterceptorConfig,
+ SseRequestOptions,
};
From 66822a5f951ed97ac13046a68e11d36c895a36c2 Mon Sep 17 00:00:00 2001
From: zhongming4762
Date: Thu, 11 Sep 2025 11:22:47 +0800
Subject: [PATCH 2/3] feat: add SSE support to request-client
---
.../src/request-client/modules/sse.test.ts | 21 ++++--
.../request/src/request-client/modules/sse.ts | 72 ++++++++++++++-----
.../request/src/request-client/types.ts | 2 +-
3 files changed, 73 insertions(+), 22 deletions(-)
diff --git a/packages/effects/request/src/request-client/modules/sse.test.ts b/packages/effects/request/src/request-client/modules/sse.test.ts
index 5d630a87..4e8c6a9d 100644
--- a/packages/effects/request/src/request-client/modules/sse.test.ts
+++ b/packages/effects/request/src/request-client/modules/sse.test.ts
@@ -89,7 +89,8 @@ describe('sSE', () => {
expect(onMessage).toHaveBeenCalledTimes(2);
expect(messages.join('')).toBe('hello world');
- expect(onEnd).toHaveBeenCalledWith('hello world');
+ // onEnd 不再带参数
+ expect(onEnd).toHaveBeenCalled();
});
it('should apply request interceptors', async () => {
@@ -101,20 +102,30 @@ describe('sSE', () => {
fulfilled: interceptor,
});
- vi.stubGlobal('fetch', createFetchMock(['data']));
-
// 创建 fetch mock,并挂到全局
const fetchMock = createFetchMock(['data']);
vi.stubGlobal('fetch', fetchMock);
+
await sse.requestSSE('/sse', undefined, {});
expect(interceptor).toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledWith(
- 'http://localhost//sse',
+ 'http://localhost/sse',
expect.objectContaining({
- headers: expect.objectContaining({ 'x-test': 'intercepted' }),
+ 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 () => {
diff --git a/packages/effects/request/src/request-client/modules/sse.ts b/packages/effects/request/src/request-client/modules/sse.ts
index 0edf2d4a..09d13017 100644
--- a/packages/effects/request/src/request-client/modules/sse.ts
+++ b/packages/effects/request/src/request-client/modules/sse.ts
@@ -36,9 +36,10 @@ class SSE {
requestOptions?: SseRequestOptions,
) {
const baseUrl = this.client.getBaseUrl() || '';
- const hasUrlSplit = baseUrl.endsWith('/') && url.startsWith('/');
- const axiosConfig: InternalAxiosRequestConfig = {
+ let axiosConfig: InternalAxiosRequestConfig = {
+ url,
+ method: (requestOptions?.method as any) ?? 'GET',
headers: {} as AxiosRequestHeaders,
};
const requestInterceptors = this.client.instance.interceptors
@@ -48,25 +49,45 @@ class SSE {
requestInterceptors.handlers.length > 0
) {
for (const handler of requestInterceptors.handlers) {
- if (handler.fulfilled) {
- await handler.fulfilled(axiosConfig);
+ 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,
- body: data,
- headers: {
- ...(axiosConfig.headers as Record),
- ...requestOptions?.headers,
- },
+ method: axiosConfig.method,
+ headers: merged,
+ body: bodyInit,
};
- const response = await fetch(
- `${baseUrl}${hasUrlSplit ? '' : '/'}${url}`,
- requestInit,
- );
+ const response = await fetch(safeJoinUrl(baseUrl, url), requestInit);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -78,19 +99,38 @@ class SSE {
throw new Error('No reader');
}
let isEnd = false;
- let allMessage = '';
while (!isEnd) {
const { done, value } = await reader.read();
if (done) {
isEnd = true;
- requestOptions?.onEnd?.(allMessage);
+ decoder.decode(new Uint8Array(0), { stream: false });
+ requestOptions?.onEnd?.();
+ reader.releaseLock?.();
break;
}
const content = decoder.decode(value, { stream: true });
requestOptions?.onMessage?.(content);
- allMessage += 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/types.ts b/packages/effects/request/src/request-client/types.ts
index aa1e7811..d40ee8a5 100644
--- a/packages/effects/request/src/request-client/types.ts
+++ b/packages/effects/request/src/request-client/types.ts
@@ -46,7 +46,7 @@ type RequestClientOptions = CreateAxiosDefaults & ExtendOptions;
*/
interface SseRequestOptions extends RequestInit {
onMessage?: (message: string) => void;
- onEnd?: (message: string) => void;
+ onEnd?: () => void;
}
interface RequestInterceptorConfig {
From 11d273cbb6156dacdb84daedba52ead6ab28d323 Mon Sep 17 00:00:00 2001
From: oc
Date: Mon, 15 Sep 2025 07:43:18 +0800
Subject: [PATCH 3/3] =?UTF-8?q?feat(authentication):=20=E4=BA=8C=E7=BB=B4?=
=?UTF-8?q?=E7=A0=81=E7=99=BB=E5=BD=95=E5=92=8C=E9=AA=8C=E8=AF=81=E7=A0=81?=
=?UTF-8?q?=E7=99=BB=E5=BD=95=E7=BB=84=E4=BB=B6=E5=A2=9E=E5=8A=A0=E8=BF=94?=
=?UTF-8?q?=E5=9B=9E=E6=8C=89=E9=92=AE=E6=98=BE=E9=9A=90=E9=85=8D=E7=BD=AE?=
=?UTF-8?q?=20(#6713)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在 CodeLogin 和 QrcodeLogin 组件中添加 showBack 属性
- 根据 showBack 属性决定是否显示返回按钮
- 默认值为 true,即默认显示返回按钮
---
.../effects/common-ui/src/ui/authentication/code-login.vue | 7 ++++++-
.../common-ui/src/ui/authentication/qrcode-login.vue | 7 ++++++-
2 files changed, 12 insertions(+), 2 deletions(-)
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') }}