@kine-design/crud 0.0.1-beta.2 → 0.0.1-beta.21
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.
- package/.vlaude/last-session-id +1 -0
- package/components/crudPage/KCrudPage.tsx +178 -0
- package/components/crudPage/crudPage.css +64 -0
- package/components/crudPage/index.ts +10 -0
- package/components/editableTable/KEditableTable.tsx +281 -0
- package/components/editableTable/editableTable.css +268 -0
- package/components/editableTable/index.ts +10 -0
- package/components/formPage/KApprovalDialog.tsx +142 -0
- package/components/formPage/KFormCard.tsx +65 -0
- package/components/formPage/KFormPage.tsx +128 -0
- package/components/formPage/KMasterDetailPage.tsx +205 -0
- package/components/formPage/KStickyActionBar.tsx +33 -0
- package/components/formPage/formPage.css +629 -0
- package/components/formPage/index.ts +14 -0
- package/components/layout/KContent.tsx +20 -0
- package/components/layout/KHeader.tsx +37 -0
- package/components/layout/KLayout.tsx +82 -0
- package/components/layout/KSider.tsx +80 -0
- package/components/layout/index.ts +18 -0
- package/components/layout/layout.css +262 -0
- package/components/login/KLoginPage.tsx +129 -0
- package/components/login/index.ts +10 -0
- package/components/login/login.css +118 -0
- package/components/navMenu/KNavMenu.tsx +175 -0
- package/components/navMenu/index.ts +2 -0
- package/components/navMenu/navMenu.css +197 -0
- package/components/pageHeader/KPageHeader.tsx +85 -0
- package/components/pageHeader/index.ts +9 -0
- package/components/pageHeader/pageHeader.css +93 -0
- package/components/searchTable/KSearchTable.tsx +138 -0
- package/components/searchTable/index.ts +10 -0
- package/components/searchTable/searchTable.css +121 -0
- package/components/upload/KFileList.tsx +95 -0
- package/components/upload/KImageUpload.tsx +286 -0
- package/components/upload/KUpload.tsx +206 -0
- package/components/upload/index.ts +13 -0
- package/components/upload/types.ts +26 -0
- package/components/upload/upload.css +345 -0
- package/composables/auth/authGuard.ts +128 -0
- package/composables/auth/index.ts +23 -0
- package/composables/auth/types.ts +109 -0
- package/composables/auth/useAuth.ts +278 -0
- package/composables/auth/vCan.ts +95 -0
- package/composables/defineRepository.ts +224 -0
- package/composables/error/createErrorHandler.ts +46 -0
- package/composables/error/defaultFeedbackHandler.ts +76 -0
- package/composables/error/dispatchError.ts +70 -0
- package/composables/error/index.ts +32 -0
- package/composables/error/types.ts +57 -0
- package/composables/error/useErrorHandler.ts +41 -0
- package/composables/form/index.ts +18 -0
- package/composables/form/renderFormField.tsx +119 -0
- package/composables/form/types.ts +129 -0
- package/composables/form/useFormPage.ts +183 -0
- package/composables/index.ts +62 -0
- package/composables/page/index.ts +11 -0
- package/composables/page/types.ts +62 -0
- package/composables/page/useCrudPage.ts +88 -0
- package/composables/request/composables.ts +206 -0
- package/composables/request/controlGate.ts +143 -0
- package/composables/request/createRequest.ts +173 -0
- package/composables/request/index.ts +71 -0
- package/composables/request/orchestrator.ts +145 -0
- package/composables/request/requestBuilder.ts +418 -0
- package/composables/request/transport/fetchTransport.ts +79 -0
- package/composables/request/transport/xhrTransport.ts +100 -0
- package/composables/request/types.ts +226 -0
- package/composables/request/upload.ts +146 -0
- package/composables/router/createRouterGuard.ts +134 -0
- package/composables/router/defineCrudRoutes.ts +116 -0
- package/composables/router/index.ts +22 -0
- package/composables/router/types.ts +128 -0
- package/composables/router/useMenuFromRoutes.ts +109 -0
- package/composables/router/useTabStore.ts +183 -0
- package/composables/search/index.ts +11 -0
- package/composables/search/useAutoCompleteSearch.ts +161 -0
- package/composables/setupCrud.ts +43 -0
- package/composables/storage/createStorageAdapter.ts +72 -0
- package/composables/storage/index.ts +13 -0
- package/composables/storage/types.ts +30 -0
- package/composables/storage/useStorage.ts +108 -0
- package/composables/store/defineUserStore.ts +122 -0
- package/composables/store/index.ts +11 -0
- package/composables/types.ts +118 -0
- package/dist/components/crudPage/KCrudPage.d.ts +14 -0
- package/dist/components/crudPage/index.d.ts +9 -0
- package/dist/components/editableTable/KEditableTable.d.ts +146 -0
- package/dist/components/editableTable/index.d.ts +10 -0
- package/dist/components/formPage/KApprovalDialog.d.ts +99 -0
- package/dist/components/formPage/KFormCard.d.ts +49 -0
- package/dist/components/formPage/KFormPage.d.ts +14 -0
- package/dist/components/formPage/KMasterDetailPage.d.ts +14 -0
- package/dist/components/formPage/KStickyActionBar.d.ts +16 -0
- package/dist/components/formPage/index.d.ts +14 -0
- package/dist/components/layout/KLayout.d.ts +7 -4
- package/dist/composables/auth/useAuth.d.ts +5 -5
- package/dist/composables/error/types.d.ts +2 -1
- package/dist/composables/form/index.d.ts +12 -0
- package/dist/composables/form/renderFormField.d.ts +11 -0
- package/dist/composables/form/types.d.ts +104 -0
- package/dist/composables/form/useFormPage.d.ts +38 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/composables/page/index.d.ts +10 -0
- package/dist/composables/page/types.d.ts +61 -0
- package/dist/composables/page/useCrudPage.d.ts +14 -0
- package/dist/composables/request/createRequest.d.ts +2 -0
- package/dist/composables/request/requestBuilder.d.ts +2 -0
- package/dist/composables/search/index.d.ts +10 -0
- package/dist/composables/search/useAutoCompleteSearch.d.ts +50 -0
- package/dist/crud.css +2499 -663
- package/dist/crud.js +11512 -2910
- package/dist/index.d.ts +11 -0
- package/dist/setup.d.ts +2 -2
- package/index.ts +144 -0
- package/package.json +20 -19
- package/setup.ts +288 -0
- package/tsconfig.json +12 -0
- package/vite.config.build.ts +52 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description 链式请求构建器
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/15
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
CachePolicy,
|
|
12
|
+
ControlPolicy,
|
|
13
|
+
Lifecycle,
|
|
14
|
+
NetworkError,
|
|
15
|
+
RequestConfig,
|
|
16
|
+
RequestMethod,
|
|
17
|
+
RetryPolicy,
|
|
18
|
+
Transport,
|
|
19
|
+
TransportResponse,
|
|
20
|
+
WrappedResponse,
|
|
21
|
+
} from './types';
|
|
22
|
+
import { BusinessError, NetworkRequestError } from './types';
|
|
23
|
+
import { buildCacheKey, ControlGate } from './controlGate';
|
|
24
|
+
|
|
25
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
// 内部:缓存存储
|
|
27
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
interface CacheEntry {
|
|
30
|
+
data: unknown;
|
|
31
|
+
timestamp: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const cacheStore = new Map<string, CacheEntry>();
|
|
35
|
+
|
|
36
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// 内部:自动压包
|
|
38
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** 判断是否包含 File/Blob */
|
|
41
|
+
function containsFile(obj: unknown): boolean {
|
|
42
|
+
if (obj instanceof File || obj instanceof Blob || obj instanceof FormData) return true;
|
|
43
|
+
if (typeof obj === 'object' && obj !== null) {
|
|
44
|
+
return Object.values(obj as Record<string, unknown>).some(containsFile);
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 将普通对象转为 FormData */
|
|
50
|
+
function toFormData(obj: Record<string, unknown>): FormData {
|
|
51
|
+
const fd = new FormData();
|
|
52
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
53
|
+
if (value instanceof File || value instanceof Blob) {
|
|
54
|
+
fd.append(key, value);
|
|
55
|
+
} else if (Array.isArray(value)) {
|
|
56
|
+
value.forEach(item => {
|
|
57
|
+
if (item instanceof File || item instanceof Blob) {
|
|
58
|
+
fd.append(key, item);
|
|
59
|
+
} else {
|
|
60
|
+
fd.append(key, String(item));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
} else if (value !== null && value !== undefined) {
|
|
64
|
+
fd.append(key, String(value));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return fd;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 将参数序列化为 query string */
|
|
71
|
+
function toQueryString(params: Record<string, unknown>): string {
|
|
72
|
+
const parts: string[] = [];
|
|
73
|
+
for (const [key, value] of Object.entries(params)) {
|
|
74
|
+
if (value === null || value === undefined) continue;
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
value.forEach(v => parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(v))}`));
|
|
77
|
+
} else {
|
|
78
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return parts.join('&');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
// 内部:自动解包
|
|
86
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/** 根据 Content-Type 解析响应,并检查 WrappedResponse */
|
|
89
|
+
async function unwrapResponse<T>(
|
|
90
|
+
response: { status: number; statusText: string; headers: Record<string, string>; text(): Promise<string>; json<R>(): Promise<R> },
|
|
91
|
+
): Promise<T> {
|
|
92
|
+
// HTTP 错误
|
|
93
|
+
if (response.status < 200 || response.status >= 300) {
|
|
94
|
+
// 4xx/5xx:尝试解析 body,提取业务错误信息
|
|
95
|
+
if (response.status >= 400) {
|
|
96
|
+
const ct = response.headers['content-type'] ?? '';
|
|
97
|
+
if (ct.includes('application/json')) {
|
|
98
|
+
try {
|
|
99
|
+
const json = await response.json<WrappedResponse<unknown>>();
|
|
100
|
+
if (isWrappedResponse(json) && !json.success) {
|
|
101
|
+
throw new BusinessError(json.message || `请求失败(${response.status})`);
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e instanceof BusinessError) throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw new NetworkRequestError({
|
|
109
|
+
type: 'httpError',
|
|
110
|
+
status: response.status,
|
|
111
|
+
statusText: response.statusText,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const contentType = response.headers['content-type'] ?? '';
|
|
116
|
+
|
|
117
|
+
// JSON 响应
|
|
118
|
+
if (contentType.includes('application/json')) {
|
|
119
|
+
const json = await response.json<WrappedResponse<T> | T>();
|
|
120
|
+
|
|
121
|
+
// 检查是否为 WrappedResponse 结构
|
|
122
|
+
if (isWrappedResponse<T>(json)) {
|
|
123
|
+
if (!json.success) {
|
|
124
|
+
throw new BusinessError(json.message || '请求失败');
|
|
125
|
+
}
|
|
126
|
+
return json.data;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return json as T;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 其他类型直接返回文本
|
|
133
|
+
const text = await response.text();
|
|
134
|
+
return text as unknown as T;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isWrappedResponse<T>(value: unknown): value is WrappedResponse<T> {
|
|
138
|
+
return (
|
|
139
|
+
typeof value === 'object' &&
|
|
140
|
+
value !== null &&
|
|
141
|
+
'success' in value &&
|
|
142
|
+
'data' in value &&
|
|
143
|
+
typeof (value as Record<string, unknown>).success === 'boolean'
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// 内部:重试执行
|
|
149
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async function withRetry<T>(fn: () => Promise<T>, policy: RetryPolicy, signal?: AbortSignal): Promise<T> {
|
|
152
|
+
if (policy.type === 'none') return fn();
|
|
153
|
+
|
|
154
|
+
let lastError: unknown;
|
|
155
|
+
const maxAttempts = policy.maxAttempts;
|
|
156
|
+
|
|
157
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
158
|
+
try {
|
|
159
|
+
return await fn();
|
|
160
|
+
} catch (e) {
|
|
161
|
+
lastError = e;
|
|
162
|
+
|
|
163
|
+
// 被取消则不重试
|
|
164
|
+
if (signal?.aborted) throw e;
|
|
165
|
+
// 业务错误不重试
|
|
166
|
+
if (e instanceof BusinessError) throw e;
|
|
167
|
+
|
|
168
|
+
if (attempt < maxAttempts) {
|
|
169
|
+
const delay = policy.type === 'fixed'
|
|
170
|
+
? policy.delay
|
|
171
|
+
: Math.min(policy.initialDelay * Math.pow(policy.multiplier, attempt - 1), policy.maxDelay);
|
|
172
|
+
|
|
173
|
+
await new Promise<void>((resolve, reject) => {
|
|
174
|
+
const timer = setTimeout(resolve, delay);
|
|
175
|
+
signal?.addEventListener('abort', () => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
reject(new NetworkRequestError({ type: 'aborted' }));
|
|
178
|
+
}, { once: true });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
throw lastError;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
// RequestBuilder
|
|
189
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export interface RequestBuilderContext {
|
|
192
|
+
transport: Transport;
|
|
193
|
+
controlGate: ControlGate;
|
|
194
|
+
baseURL: string;
|
|
195
|
+
defaultHeaders: Record<string, string>;
|
|
196
|
+
getToken?: () => string | null | undefined;
|
|
197
|
+
onUnauthorized?: () => void;
|
|
198
|
+
responseInterceptor?: <T>(data: T) => T;
|
|
199
|
+
/** 用户反馈处理器(写操作成功后自动 showSuccess) */
|
|
200
|
+
feedback?: import('./types').UserFeedbackHandler;
|
|
201
|
+
/** 注册 abort controller,供生命周期管理 */
|
|
202
|
+
registerAbort?: (controller: AbortController) => void;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export class RequestBuilder {
|
|
206
|
+
private method: RequestMethod;
|
|
207
|
+
private url: string;
|
|
208
|
+
private body: unknown;
|
|
209
|
+
private config: RequestConfig = {};
|
|
210
|
+
private context: RequestBuilderContext;
|
|
211
|
+
private customHeaders: Record<string, string> = {};
|
|
212
|
+
|
|
213
|
+
constructor(
|
|
214
|
+
context: RequestBuilderContext,
|
|
215
|
+
method: RequestMethod,
|
|
216
|
+
url: string,
|
|
217
|
+
body?: unknown,
|
|
218
|
+
) {
|
|
219
|
+
this.context = context;
|
|
220
|
+
this.method = method;
|
|
221
|
+
this.url = url;
|
|
222
|
+
this.body = body;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── 链式 API ────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
cache(policy: CachePolicy): this {
|
|
228
|
+
this.config.cache = policy;
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
retry(policy: RetryPolicy): this {
|
|
233
|
+
this.config.retry = policy;
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
debounce(delay: number): this {
|
|
238
|
+
this.config.control = { ...this.config.control, debounce: delay };
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
throttle(interval: number): this {
|
|
243
|
+
this.config.control = { ...this.config.control, throttle: interval };
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
deduplicate(enabled = true): this {
|
|
248
|
+
this.config.control = { ...this.config.control, deduplicate: enabled };
|
|
249
|
+
return this;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
timeout(ms: number): this {
|
|
253
|
+
this.config.timeout = ms;
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
totalTimeout(ms: number): this {
|
|
258
|
+
this.config.totalTimeout = ms;
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
lifecycle(lc: Lifecycle): this {
|
|
263
|
+
this.config.lifecycle = lc;
|
|
264
|
+
return this;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
priority(p: number): this {
|
|
268
|
+
this.config.control = { ...this.config.control, priority: p };
|
|
269
|
+
return this;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
headers(h: Record<string, string>): this {
|
|
273
|
+
Object.assign(this.customHeaders, h);
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── 执行 ────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
async execute<T>(): Promise<T> {
|
|
280
|
+
const { transport, controlGate, baseURL, defaultHeaders, getToken, onUnauthorized, responseInterceptor, registerAbort } = this.context;
|
|
281
|
+
|
|
282
|
+
// 构建完整 URL
|
|
283
|
+
let fullURL = this.url.startsWith('http') ? this.url : `${baseURL}${this.url}`;
|
|
284
|
+
|
|
285
|
+
// AbortController
|
|
286
|
+
const abortController = new AbortController();
|
|
287
|
+
registerAbort?.(abortController);
|
|
288
|
+
|
|
289
|
+
// 总超时
|
|
290
|
+
let totalTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
291
|
+
if (this.config.totalTimeout) {
|
|
292
|
+
totalTimeoutId = setTimeout(() => abortController.abort('totalTimeout'), this.config.totalTimeout);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
// 构建请求头
|
|
297
|
+
const reqHeaders: Record<string, string> = { ...defaultHeaders, ...this.customHeaders };
|
|
298
|
+
|
|
299
|
+
// 注入 token
|
|
300
|
+
const token = getToken?.();
|
|
301
|
+
if (token) {
|
|
302
|
+
reqHeaders['Authorization'] = `Bearer ${token}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 自动压包
|
|
306
|
+
let reqBody: BodyInit | null = null;
|
|
307
|
+
|
|
308
|
+
if (this.method === 'GET' && this.body && typeof this.body === 'object') {
|
|
309
|
+
// GET 参数拼接到 query string
|
|
310
|
+
const qs = toQueryString(this.body as Record<string, unknown>);
|
|
311
|
+
if (qs) {
|
|
312
|
+
fullURL += (fullURL.includes('?') ? '&' : '?') + qs;
|
|
313
|
+
}
|
|
314
|
+
} else if (this.body !== undefined && this.body !== null) {
|
|
315
|
+
if (this.body instanceof FormData) {
|
|
316
|
+
reqBody = this.body;
|
|
317
|
+
} else if (containsFile(this.body)) {
|
|
318
|
+
reqBody = toFormData(this.body as Record<string, unknown>);
|
|
319
|
+
} else {
|
|
320
|
+
reqBody = JSON.stringify(this.body);
|
|
321
|
+
reqHeaders['Content-Type'] = 'application/json';
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 缓存 key
|
|
326
|
+
const cacheKey = buildCacheKey(this.method, fullURL, this.body);
|
|
327
|
+
|
|
328
|
+
// 缓存策略检查
|
|
329
|
+
const cachePolicy = this.config.cache ?? { type: 'none' as const };
|
|
330
|
+
if (cachePolicy.type === 'cacheFirst' || cachePolicy.type === 'staleWhileRevalidate') {
|
|
331
|
+
const cached = cacheStore.get(cacheKey);
|
|
332
|
+
if (cached) {
|
|
333
|
+
const age = (Date.now() - cached.timestamp) / 1000;
|
|
334
|
+
if (age < cachePolicy.maxAge) {
|
|
335
|
+
if (cachePolicy.type === 'staleWhileRevalidate') {
|
|
336
|
+
// 返回旧数据,后台刷新
|
|
337
|
+
this.executeTransport<T>(transport, fullURL, reqHeaders, reqBody, abortController.signal)
|
|
338
|
+
.then(data => { cacheStore.set(cacheKey, { data, timestamp: Date.now() }); })
|
|
339
|
+
.catch(() => { /* 后台刷新失败静默忽略 */ });
|
|
340
|
+
}
|
|
341
|
+
return cached.data as T;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 执行函数(含重试)
|
|
347
|
+
const doRequest = () => this.executeTransport<T>(transport, fullURL, reqHeaders, reqBody, abortController.signal);
|
|
348
|
+
|
|
349
|
+
// 控制门
|
|
350
|
+
const controlPolicy: ControlPolicy = this.config.control ?? {};
|
|
351
|
+
const retryPolicy = this.config.retry ?? { type: 'none' as const };
|
|
352
|
+
|
|
353
|
+
const result = await controlGate.execute(cacheKey, async () => {
|
|
354
|
+
return withRetry(doRequest, retryPolicy, abortController.signal);
|
|
355
|
+
}, controlPolicy);
|
|
356
|
+
|
|
357
|
+
// 写入缓存
|
|
358
|
+
if (cachePolicy.type !== 'none') {
|
|
359
|
+
cacheStore.set(cacheKey, { data: result, timestamp: Date.now() });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 响应拦截器
|
|
363
|
+
const finalResult = responseInterceptor ? responseInterceptor(result) : result;
|
|
364
|
+
|
|
365
|
+
// 写操作成功后自动反馈
|
|
366
|
+
if (this.context.feedback && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method)) {
|
|
367
|
+
this.context.feedback.showSuccess('操作成功');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return finalResult as T;
|
|
371
|
+
} catch (error) {
|
|
372
|
+
// 401 检测
|
|
373
|
+
if (error instanceof NetworkRequestError && error.detail.type === 'httpError' && error.detail.status === 401) {
|
|
374
|
+
onUnauthorized?.();
|
|
375
|
+
}
|
|
376
|
+
// 写操作失败自动反馈错误信息
|
|
377
|
+
if (this.context.feedback) {
|
|
378
|
+
const msg = error instanceof BusinessError ? error.message
|
|
379
|
+
: error instanceof NetworkRequestError ? `请求失败: ${error.message}`
|
|
380
|
+
: '操作失败';
|
|
381
|
+
this.context.feedback.showError(msg);
|
|
382
|
+
}
|
|
383
|
+
throw error;
|
|
384
|
+
} finally {
|
|
385
|
+
if (totalTimeoutId !== undefined) clearTimeout(totalTimeoutId);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── 内部传输 ──────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
private async executeTransport<T>(
|
|
392
|
+
transport: Transport,
|
|
393
|
+
url: string,
|
|
394
|
+
headers: Record<string, string>,
|
|
395
|
+
body: BodyInit | null,
|
|
396
|
+
signal: AbortSignal,
|
|
397
|
+
): Promise<T> {
|
|
398
|
+
let response: TransportResponse;
|
|
399
|
+
try {
|
|
400
|
+
response = await transport.send({
|
|
401
|
+
url,
|
|
402
|
+
method: this.method,
|
|
403
|
+
headers,
|
|
404
|
+
body,
|
|
405
|
+
signal,
|
|
406
|
+
timeout: this.config.timeout,
|
|
407
|
+
});
|
|
408
|
+
} catch (error) {
|
|
409
|
+
// transport 层抛出的是 NetworkError 裸对象,统一包装为 NetworkRequestError
|
|
410
|
+
if (error && typeof error === 'object' && 'type' in error) {
|
|
411
|
+
throw new NetworkRequestError(error as NetworkError);
|
|
412
|
+
}
|
|
413
|
+
throw new NetworkRequestError({ type: 'unknown', error });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return unwrapResponse<T>(response);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description 基于 fetch API 的传输层实现
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/15
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Transport, TransportRequest, TransportResponse } from '../types';
|
|
11
|
+
|
|
12
|
+
/** 将 fetch Response 的 Headers 转为普通对象 */
|
|
13
|
+
function headersToRecord(headers: Headers): Record<string, string> {
|
|
14
|
+
const record: Record<string, string> = {};
|
|
15
|
+
headers.forEach((value, key) => {
|
|
16
|
+
record[key.toLowerCase()] = value;
|
|
17
|
+
});
|
|
18
|
+
return record;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 将 fetch Response 适配为 TransportResponse */
|
|
22
|
+
function wrapFetchResponse(response: Response): TransportResponse {
|
|
23
|
+
return {
|
|
24
|
+
status: response.status,
|
|
25
|
+
statusText: response.statusText,
|
|
26
|
+
headers: headersToRecord(response.headers),
|
|
27
|
+
body: response.body,
|
|
28
|
+
text: () => response.text(),
|
|
29
|
+
json: <T>() => response.json() as Promise<T>,
|
|
30
|
+
blob: () => response.blob(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class FetchTransport implements Transport {
|
|
35
|
+
async send(request: TransportRequest): Promise<TransportResponse> {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
|
|
38
|
+
// 将外部 signal 的 abort 传播到内部 controller
|
|
39
|
+
if (request.signal) {
|
|
40
|
+
if (request.signal.aborted) {
|
|
41
|
+
controller.abort(request.signal.reason);
|
|
42
|
+
} else {
|
|
43
|
+
request.signal.addEventListener('abort', () => {
|
|
44
|
+
controller.abort(request.signal!.reason);
|
|
45
|
+
}, { once: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 超时控制
|
|
50
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
51
|
+
if (request.timeout !== undefined && request.timeout > 0) {
|
|
52
|
+
timeoutId = setTimeout(() => controller.abort('timeout'), request.timeout);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(request.url, {
|
|
57
|
+
method: request.method,
|
|
58
|
+
headers: request.headers,
|
|
59
|
+
body: request.body,
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return wrapFetchResponse(response);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// 区分超时和手动取消
|
|
66
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
67
|
+
if (controller.signal.reason === 'timeout') {
|
|
68
|
+
throw { type: 'timeout' as const };
|
|
69
|
+
}
|
|
70
|
+
throw { type: 'aborted' as const };
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
} finally {
|
|
74
|
+
if (timeoutId !== undefined) {
|
|
75
|
+
clearTimeout(timeoutId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @description 基于 XMLHttpRequest 的传输层实现,支持上传进度
|
|
3
|
+
* @author 阿怪
|
|
4
|
+
* @date 2026/3/15
|
|
5
|
+
* @version v0.0.1
|
|
6
|
+
*
|
|
7
|
+
* 江湖的业务千篇一律,复杂的代码好几百行。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Transport, TransportRequest, TransportResponse } from '../types';
|
|
11
|
+
|
|
12
|
+
/** 解析 XHR 响应头字符串为 Record */
|
|
13
|
+
function parseResponseHeaders(rawHeaders: string): Record<string, string> {
|
|
14
|
+
const headers: Record<string, string> = {};
|
|
15
|
+
if (!rawHeaders) return headers;
|
|
16
|
+
|
|
17
|
+
rawHeaders.split('\r\n').forEach(line => {
|
|
18
|
+
const idx = line.indexOf(':');
|
|
19
|
+
if (idx > 0) {
|
|
20
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
21
|
+
const value = line.slice(idx + 1).trim();
|
|
22
|
+
headers[key] = value;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return headers;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class XhrTransport implements Transport {
|
|
29
|
+
send(request: TransportRequest): Promise<TransportResponse> {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const xhr = new XMLHttpRequest();
|
|
32
|
+
|
|
33
|
+
xhr.open(request.method, request.url, true);
|
|
34
|
+
|
|
35
|
+
// 设置请求头
|
|
36
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
37
|
+
xhr.setRequestHeader(key, value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 超时
|
|
41
|
+
if (request.timeout !== undefined && request.timeout > 0) {
|
|
42
|
+
xhr.timeout = request.timeout;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 上传进度
|
|
46
|
+
if (request.onUploadProgress) {
|
|
47
|
+
const onProgress = request.onUploadProgress;
|
|
48
|
+
xhr.upload.addEventListener('progress', (event) => {
|
|
49
|
+
if (event.lengthComputable) {
|
|
50
|
+
onProgress({ loaded: event.loaded, total: event.total });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 外部取消信号
|
|
56
|
+
if (request.signal) {
|
|
57
|
+
if (request.signal.aborted) {
|
|
58
|
+
xhr.abort();
|
|
59
|
+
reject({ type: 'aborted' as const });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
request.signal.addEventListener('abort', () => xhr.abort(), { once: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
xhr.onload = () => {
|
|
66
|
+
const headers = parseResponseHeaders(xhr.getAllResponseHeaders());
|
|
67
|
+
const responseText = xhr.responseText;
|
|
68
|
+
|
|
69
|
+
const response: TransportResponse = {
|
|
70
|
+
status: xhr.status,
|
|
71
|
+
statusText: xhr.statusText,
|
|
72
|
+
headers,
|
|
73
|
+
body: null,
|
|
74
|
+
text: () => Promise.resolve(responseText),
|
|
75
|
+
json: <T>() => {
|
|
76
|
+
try { return Promise.resolve(JSON.parse(responseText) as T); }
|
|
77
|
+
catch (e) { return Promise.reject(e); }
|
|
78
|
+
},
|
|
79
|
+
blob: () => Promise.resolve(new Blob([responseText])),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
resolve(response);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
xhr.onerror = () => {
|
|
86
|
+
reject({ type: 'unknown' as const, error: new Error('网络错误') });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
xhr.ontimeout = () => {
|
|
90
|
+
reject({ type: 'timeout' as const });
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
xhr.onabort = () => {
|
|
94
|
+
reject({ type: 'aborted' as const });
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
xhr.send(request.body as XMLHttpRequestBodyInit | null);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|