@base-web-kits/base-tools-web 1.0.2 → 1.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,491 @@
1
+ import {
2
+ appendUrlParam,
3
+ cloneDeep,
4
+ getObjectValue,
5
+ isPlainObject,
6
+ pickBy,
7
+ toDayjs,
8
+ } from '../../ts';
9
+ import { getBaseToolsConfig } from '../config';
10
+ import type { AppLogInfo } from '../config';
11
+
12
+ /** 请求方法类型 */
13
+ export type RequestMethod =
14
+ | 'GET'
15
+ | 'POST'
16
+ | 'PUT'
17
+ | 'DELETE'
18
+ | 'CONNECT'
19
+ | 'HEAD'
20
+ | 'OPTIONS'
21
+ | 'TRACE'
22
+ | 'PATCH';
23
+
24
+ /**
25
+ * 请求参数类型
26
+ * 包含 fetch 原生支持的 BodyInit 类型,以及支持自动 JSON 序列化的对象和数组
27
+ */
28
+ export type RequestData =
29
+ | string
30
+ | ArrayBuffer
31
+ | ArrayBufferView
32
+ | Blob
33
+ | FormData
34
+ | URLSearchParams
35
+ | ReadableStream<Uint8Array>
36
+ | Record<string, unknown>
37
+ | unknown[]
38
+ | null;
39
+
40
+ /**
41
+ * 响应数据类型
42
+ */
43
+ export type ResponseData = string | ArrayBuffer | Blob | Record<string, unknown> | unknown[] | null;
44
+
45
+ /**
46
+ * 发起请求的配置 (对外,参数可选)
47
+ */
48
+ export type RequestConfig<D extends RequestData = RequestData> = Partial<RequestConfigBase<D>>;
49
+
50
+ /**
51
+ * 自定义请求的配置 (接口字段参数必填)
52
+ */
53
+ export type RequestConfigBase<D extends RequestData = RequestData> = {
54
+ /** 接口地址 */
55
+ url: string;
56
+ /** 请求方法 */
57
+ method?: RequestMethod;
58
+ /** 请求头 */
59
+ header?: Record<string, string>;
60
+ /** 请求参数 */
61
+ data?: D;
62
+ /** 超时时间 (毫秒), 默认 60000 */
63
+ timeout?: number;
64
+
65
+ /** 接口返回响应数据的字段, 支持"a[0].b.c"的格式, 当配置false时返回完整的响应数据 */
66
+ resKey: string | false;
67
+
68
+ /** 接口返回响应消息的字段, 支持"a[0].b.c"的格式 */
69
+ msgKey: string;
70
+
71
+ /** 接口返回响应状态码的字段, 支持"a[0].b.c"的格式 */
72
+ codeKey: string;
73
+
74
+ /** 接口返回成功状态码的字段, 支持"a[0].b.c"的格式 (默认取 codeKey) */
75
+ successKey?: string;
76
+
77
+ /** 成功状态码 */
78
+ successCode: (number | string)[];
79
+
80
+ /** 登录过期状态码 */
81
+ reloginCode: (number | string)[];
82
+
83
+ /** 是否显示进度条 (默认true) */
84
+ showLoading?: boolean;
85
+
86
+ /** 是否提示接口异常 (默认true) */
87
+ toastError?: boolean;
88
+
89
+ /** 是否输出日志 (默认true) */
90
+ isLog?: boolean;
91
+
92
+ /** 额外输出的日志数据 */
93
+ extraLog?: Record<string, unknown>;
94
+
95
+ /** 响应数据的缓存时间, 单位毫秒。仅在成功时缓存;仅缓存在内存,应用退出,缓存消失。(默认0,不开启缓存) */
96
+ cacheTime?: number;
97
+
98
+ /** 是否开启流式传输 (如 SSE) */
99
+ enableChunked?: boolean;
100
+
101
+ /** 响应类型 (默认 json, enableChunked为true时忽略) */
102
+ responseType?: 'text' | 'arraybuffer' | 'json';
103
+
104
+ /** 响应拦截 */
105
+ responseInterceptor?: (data: ResponseData) => ResponseData;
106
+ };
107
+
108
+ /**
109
+ * 请求任务对象 (用于取消请求或监听流式数据)
110
+ */
111
+ export interface RequestTask {
112
+ /** 取消请求 */
113
+ abort: () => void;
114
+
115
+ /** 监听流式数据块接收事件 */
116
+ onChunkReceived: (callback: ChunkCallback) => void;
117
+
118
+ /** 取消监听流式数据块接收事件 */
119
+ offChunkReceived: () => void;
120
+ }
121
+
122
+ /**
123
+ * 流式数据块接收事件回调
124
+ */
125
+ export type ChunkCallback = (response: { data: ArrayBuffer }) => void;
126
+
127
+ /** 请求缓存 */
128
+ const requestCache = new Map<string, { res: unknown; expire: number }>();
129
+
130
+ /**
131
+ * 基础请求 (返回 Promise 和 Task 对象)
132
+ * 基于 fetch API 封装,支持流式请求
133
+ * @param config 请求配置
134
+ * @returns Promise<T> & { task?: RequestTask }
135
+ * @example
136
+ * // 在入口文件完成配置 (确保请求失败有toast提示,登录过期能够触发重新登录,log有日志输出)
137
+ * setBaseToolsConfig({
138
+ * toast: ({ msg, status }) => (status === 'fail' ? message.error(msg) : message.success(msg)),
139
+ * showLoading: () => message.loading('加载中...'),
140
+ * hideLoading: () => message.destroy(),
141
+ * toLogin: () => reLogin(),
142
+ * log(level, data) {
143
+ * if (data.name === 'request') {
144
+ * sendLog('request', data); // 请求日志
145
+ * } else if (level === 'error') {
146
+ * sendLog('error', data); // 错误日志
147
+ * } else {
148
+ * sendLog('action', data); // 操作日志
149
+ * }
150
+ * },
151
+ * });
152
+ *
153
+ * // 封装项目的基础请求
154
+ * export function requestApi<T>(config: RequestConfig) {
155
+ * return request<T>({
156
+ * header: { token: 'xx', version: 'xx', tid: 'xx' }, // 会自动过滤空值
157
+ * // responseInterceptor: (res) => res, // 响应拦截,可预处理响应数据,如解密 (可选)
158
+ * resKey: 'data',
159
+ * msgKey: 'message',
160
+ * codeKey: 'status',
161
+ * successCode: [1],
162
+ * reloginCode: [-10],
163
+ * ...config,
164
+ * });
165
+ * }
166
+ *
167
+ * // 1. 基于上面 requestApi 的普通接口
168
+ * export function apiGoodList(data: { page: number, size: number }) {
169
+ * return requestApi<GoodItem[]>({ url: '/goods/list', data, resKey: 'data.list' });
170
+ * }
171
+ *
172
+ * const goodList = await apiGoodList({ page:1, size:10 });
173
+ *
174
+ * // 2. 参数泛型的写法
175
+ * export function apiGoodList(config: RequestConfig<{ page: number, size: number }>) {
176
+ * return requestApi<GoodItem[]>({ url: '/goods/list', resKey: 'data.list', ...config });
177
+ * }
178
+ *
179
+ * const goodList = await apiGoodList({ data: { page:1, size:10 }, showLoading: false });
180
+ *
181
+ * // 3. 基于上面 requestApi 的流式接口
182
+ * export function apiChatStream(data: { question: string }) {
183
+ * return requestApi<T>({
184
+ * url: '/sse/chatStream',
185
+ * data,
186
+ * resKey: false,
187
+ * showLoading: false,
188
+ * responseType: 'arraybuffer',
189
+ * enableChunked: true,
190
+ * });
191
+ * }
192
+ *
193
+ * const { task } = apiChatStream({question: '你好'}); // 发起流式请求
194
+ *
195
+ * task.onChunkReceived((res) => {
196
+ * console.log('ArrayBuffer', res.data); // 接收流式数据
197
+ * });
198
+ *
199
+ * task.offChunkReceived(); // 取消监听,中断流式接收 (调用时机:流式结束,组件销毁,页面关闭)
200
+ * task.abort(); // 取消请求 (若流式传输中,会中断流并抛出异常)
201
+ */
202
+ export function request<T, D extends RequestData = RequestData>(config: RequestConfigBase<D>) {
203
+ // 1. 初始化控制对象
204
+ const controller = new AbortController();
205
+ const signal = controller.signal;
206
+ let chunkCallback: ChunkCallback | null = null;
207
+
208
+ // 构造 Task 对象
209
+ const task: RequestTask = {
210
+ abort: () => controller.abort(),
211
+ onChunkReceived: (cb) => {
212
+ chunkCallback = cb;
213
+ },
214
+ offChunkReceived: () => {
215
+ chunkCallback = null;
216
+ },
217
+ };
218
+
219
+ // 2. 创建 Promise
220
+ const promise = new Promise<T>((resolve, reject) => {
221
+ const execute = async () => {
222
+ const {
223
+ url,
224
+ data,
225
+ header,
226
+ method = 'GET',
227
+ resKey,
228
+ msgKey,
229
+ codeKey,
230
+ successKey,
231
+ successCode,
232
+ reloginCode,
233
+ showLoading = true,
234
+ toastError = true,
235
+ enableChunked = false,
236
+ cacheTime,
237
+ responseInterceptor,
238
+ responseType = 'json',
239
+ timeout = 60000,
240
+ } = config;
241
+
242
+ const isGet = method === 'GET';
243
+ const isObjectData = isPlainObject(data);
244
+ const isArrayData = !isObjectData && Array.isArray(data);
245
+
246
+ // 2.1 参数处理
247
+ // 参数: 过滤undefined, 避免接口处理异常 (不可过滤 null 、 "" 、 false 、 0 这些有效值)
248
+ const fillData = isObjectData ? pickBy(data, (val) => val !== undefined) : data;
249
+
250
+ // 请求头: 过滤空值 (undefined 、null 、"" 、false 、0), 因为服务器端接收到的都是字符串
251
+ const fillHeader = (header ? pickBy(header, (val) => !!val) : {}) as Record<string, string>;
252
+
253
+ if (!isGet && fillData && (isObjectData || isArrayData) && !fillHeader['Content-Type']) {
254
+ fillHeader['Content-Type'] = 'application/json';
255
+ }
256
+
257
+ // 2.2 处理 URL 和 Body
258
+ const fillUrl =
259
+ isGet && isObjectData ? appendUrlParam(url, fillData as Record<string, unknown>) : url;
260
+
261
+ const fillBody =
262
+ !isGet && fillData
263
+ ? isObjectData || isArrayData
264
+ ? JSON.stringify(fillData)
265
+ : (fillData as BodyInit)
266
+ : undefined;
267
+
268
+ // 2.3 日志与缓存配置
269
+ const logConfig = { ...config, data: fillData, header: fillHeader, url: fillUrl };
270
+ const startTime = Date.now();
271
+
272
+ // 2.4 检查缓存
273
+ const isCache = cacheTime && cacheTime > 0;
274
+ const cacheKey = isCache ? JSON.stringify({ url: fillUrl, data: fillData }) : '';
275
+
276
+ if (isCache) {
277
+ const res = checkCache(cacheKey);
278
+ if (res) {
279
+ logRequestInfo({
280
+ status: 'success',
281
+ config: logConfig,
282
+ fromCache: true,
283
+ startTime,
284
+ res,
285
+ });
286
+ resolve(getResult(res, resKey) as T);
287
+ return;
288
+ }
289
+ }
290
+
291
+ // 2.5 UI 反馈
292
+ const appConfig = getBaseToolsConfig();
293
+ if (showLoading) appConfig.showLoading?.();
294
+
295
+ // 2.6 设置超时
296
+ let isTimeout = false;
297
+ const timeoutId = setTimeout(() => {
298
+ isTimeout = true;
299
+ controller.abort();
300
+ }, timeout);
301
+
302
+ try {
303
+ // 2.7 发起请求
304
+ const response = await fetch(fillUrl, {
305
+ method,
306
+ headers: fillHeader,
307
+ body: fillBody,
308
+ signal,
309
+ });
310
+
311
+ if (!response.ok) {
312
+ if (showLoading) appConfig.hideLoading?.();
313
+ throw new Error(`HTTP Error ${response.status}: ${response.statusText}`);
314
+ }
315
+
316
+ // 2.8 处理流式响应
317
+ if (enableChunked) {
318
+ if (showLoading) appConfig.hideLoading?.();
319
+
320
+ const res = await handleStreamResponse(response, chunkCallback);
321
+
322
+ logRequestInfo({ status: 'success', config: logConfig, startTime, res });
323
+
324
+ resolve(res as T);
325
+ return;
326
+ }
327
+
328
+ // 2.9 处理普通响应
329
+ const resData = await parseResponse(response, responseType);
330
+
331
+ // 隐藏 Loading
332
+ if (showLoading) appConfig.hideLoading?.();
333
+
334
+ // 响应拦截
335
+ const res = responseInterceptor ? responseInterceptor(resData) : resData;
336
+
337
+ // 2.10 业务状态码解析
338
+ const code = getObjectValue(res, codeKey);
339
+ const scode = successKey ? getObjectValue(res, successKey) : code;
340
+ const msg = getObjectValue(res, msgKey);
341
+ const isSuccess = successCode.includes(scode);
342
+ const isRelogin = reloginCode.includes(code);
343
+
344
+ logRequestInfo({ status: 'success', config: logConfig, startTime, res });
345
+
346
+ // 2.11 结果处理
347
+ if (isSuccess) {
348
+ // 业务正常
349
+ if (isCache) requestCache.set(cacheKey, { res, expire: Date.now() + cacheTime });
350
+ resolve(getResult(res, resKey) as T);
351
+ } else if (isRelogin) {
352
+ // 登录失效
353
+ reject(res);
354
+ appConfig.toLogin?.(); // 放在后面,确保reject执行后再跳转登录
355
+ } else {
356
+ // 业务错误
357
+ if (toastError && msg) appConfig.toast?.({ status: 'fail', msg });
358
+ reject(res);
359
+ }
360
+ } catch (e) {
361
+ const status = 'fail';
362
+ const isAbortError = e instanceof DOMException && e.name === 'AbortError'; // 取消请求不视为错误
363
+
364
+ if (isAbortError && isTimeout) {
365
+ if (toastError) appConfig.toast?.({ status, msg: '请求超时' });
366
+ const timeoutError = new Error('Request Timeout');
367
+ logRequestInfo({ status, config: logConfig, startTime, e: timeoutError });
368
+ reject(timeoutError);
369
+ return;
370
+ }
371
+
372
+ if (!isAbortError && toastError) appConfig.toast?.({ status, msg: '网络请求失败' });
373
+ logRequestInfo({ status, config: logConfig, startTime, e });
374
+ reject(e);
375
+ } finally {
376
+ if (timeoutId) clearTimeout(timeoutId);
377
+ }
378
+ };
379
+
380
+ execute();
381
+ }) as Promise<T> & { task?: RequestTask };
382
+
383
+ // 3. 挂载 Task
384
+ promise.task = task;
385
+
386
+ return promise;
387
+ }
388
+
389
+ /**
390
+ * 日志输出
391
+ */
392
+ function logRequestInfo(options: {
393
+ config: RequestConfigBase<RequestData> & { url?: string };
394
+ fromCache?: boolean;
395
+ startTime: number;
396
+ status: 'success' | 'fail';
397
+ res?: unknown;
398
+ e?: unknown;
399
+ }) {
400
+ const { log } = getBaseToolsConfig();
401
+ const { isLog = true } = options.config;
402
+
403
+ if (!log || !isLog) return;
404
+
405
+ const { config, res, fromCache = false, startTime, status, e } = options;
406
+ const { url, data, header, method, extraLog } = config;
407
+ const endTime = Date.now();
408
+ const fmt = 'YYYY-MM-DD HH:mm:ss.SSS';
409
+
410
+ const info: AppLogInfo = {
411
+ name: 'request',
412
+ status,
413
+ url,
414
+ data,
415
+ method,
416
+ header,
417
+ fromCache,
418
+ startTime: toDayjs(startTime).format(fmt),
419
+ endTime: toDayjs(endTime).format(fmt),
420
+ duration: endTime - startTime,
421
+ ...extraLog,
422
+ };
423
+
424
+ if (status === 'success') {
425
+ info.res = cloneDeep(res); // 深拷贝,避免外部修改对象,造成输出不一致
426
+ log('info', info);
427
+ } else {
428
+ info.e = e;
429
+ log('error', info);
430
+ }
431
+ }
432
+
433
+ /**
434
+ * 获取 resKey 对应的数据
435
+ */
436
+ function getResult(res: unknown, resKey?: RequestConfigBase['resKey']) {
437
+ if (!res || !resKey || typeof res !== 'object') return res;
438
+ return getObjectValue(res, resKey);
439
+ }
440
+
441
+ /**
442
+ * 检查缓存
443
+ */
444
+ function checkCache(cacheKey: string) {
445
+ const cached = requestCache.get(cacheKey);
446
+ if (!cached) return null;
447
+ if (cached.expire <= Date.now()) {
448
+ requestCache.delete(cacheKey);
449
+ return null;
450
+ }
451
+ return cached.res;
452
+ }
453
+
454
+ /**
455
+ * 处理流式响应
456
+ */
457
+ async function handleStreamResponse(response: Response, chunkCallback: ChunkCallback | null) {
458
+ if (!response.body) throw new Error('Response body is null');
459
+
460
+ const reader = response.body.getReader();
461
+
462
+ while (true) {
463
+ const { done, value } = await reader.read();
464
+ if (done) break;
465
+ if (chunkCallback && value) {
466
+ chunkCallback({ data: value.buffer });
467
+ }
468
+ }
469
+
470
+ return 'Stream Finished';
471
+ }
472
+
473
+ /**
474
+ * 解析响应数据
475
+ */
476
+ async function parseResponse(response: Response, responseType: string) {
477
+ let resData: ResponseData;
478
+ if (responseType === 'arraybuffer') {
479
+ resData = await response.arrayBuffer();
480
+ } else if (responseType === 'text') {
481
+ resData = await response.text();
482
+ } else {
483
+ const text = await response.text();
484
+ try {
485
+ resData = JSON.parse(text);
486
+ } catch {
487
+ resData = text;
488
+ }
489
+ }
490
+ return resData;
491
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/web/load/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAE3C;;;;;;;;GAQG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,SAAK,iBAyC/D;AAED;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,aAAa,CAAC,IAAI,CAAC;;;GAc5D;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,WAAW,CAAC,EAAE,MAAM,UAkB1D;AAED;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAC1B,GAAG,EAAE,MAAM,EACX,KAAK,CAAC,EAAE,IAAI,CAAC,iBAAiB,EAAE,OAAO,GAAG,OAAO,GAAG,aAAa,CAAC,iBAuBnE;AAED;;;;;;;;GAQG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,MAAM,WAOhC;AAED;;;;;;;GAOG;AACH,wBAAsB,OAAO,CAC3B,IAAI,EAAE,MAAM,EACZ,KAAK,CAAC,EAAE,IAAI,CAAC,eAAe,EAAE,aAAa,GAAG,OAAO,CAAC,iBAuBvD;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,WAOlC;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,6BAOvC"}
File without changes