@svadmin/core 0.31.0 → 0.32.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.
- package/package.json +3 -3
- package/src/auth-hooks.svelte.ts +42 -25
- package/src/context.svelte.ts +185 -46
- package/src/data-transfer.svelte.ts +5 -3
- package/src/form-hooks.svelte.ts +322 -58
- package/src/helpers.ts +11 -7
- package/src/hook-utils.svelte.ts +13 -12
- package/src/hooks.svelte.ts +29 -23
- package/src/http.ts +114 -33
- package/src/i18n.svelte.ts +181 -35
- package/src/index.ts +7 -6
- package/src/mutation-hooks.svelte.ts +27 -16
- package/src/query-hooks.svelte.ts +16 -13
- package/src/router.ts +28 -8
- package/src/routing-hooks.svelte.ts +13 -18
- package/src/table-hooks.svelte.ts +5 -3
- package/src/table-state.svelte.ts +6 -3
- package/src/task-hooks.svelte.ts +27 -14
- package/src/theme.svelte.ts +248 -154
- package/src/toast.svelte.ts +1 -2
- package/src/types.ts +26 -4
- package/src/unsaved-changes.svelte.ts +4 -38
- package/src/url-sync.ts +47 -8
- package/src/useParsed.svelte.ts +16 -11
- package/src/utility-hooks.svelte.ts +8 -7
package/src/hook-utils.svelte.ts
CHANGED
|
@@ -3,33 +3,34 @@
|
|
|
3
3
|
|
|
4
4
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
5
5
|
import type { LiveProvider, LiveEvent, LiveMode } from './live.svelte';
|
|
6
|
-
import {
|
|
6
|
+
import { captureAdminContext } from './context.svelte';
|
|
7
|
+
import type { AdminContextAccessor } from './context.svelte';
|
|
7
8
|
import { notify } from './notification.svelte';
|
|
8
9
|
|
|
9
10
|
// ─── Auth Error Delegate ────────────────────────────────────────
|
|
10
|
-
// Delegate auth errors
|
|
11
|
+
// Delegate auth errors to authProvider.onError() — refine pattern
|
|
11
12
|
|
|
12
|
-
export async function checkError(
|
|
13
|
+
export async function checkError(
|
|
14
|
+
error: unknown,
|
|
15
|
+
adminContext: AdminContextAccessor = captureAdminContext(),
|
|
16
|
+
): Promise<void> {
|
|
13
17
|
try {
|
|
14
|
-
const authProvider =
|
|
18
|
+
const authProvider = adminContext.authProvider;
|
|
15
19
|
if (authProvider?.onError) {
|
|
16
20
|
const result = await authProvider.onError(error);
|
|
17
21
|
if (!result) return;
|
|
18
22
|
if (result.logout) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
navigate(result.redirectTo ?? '/login');
|
|
23
|
+
await authProvider.logout?.();
|
|
24
|
+
await adminContext.navigate(result.redirectTo ?? '/login');
|
|
22
25
|
} else if (result.redirectTo) {
|
|
23
|
-
|
|
24
|
-
navigate(result.redirectTo);
|
|
26
|
+
await adminContext.navigate(result.redirectTo);
|
|
25
27
|
}
|
|
26
28
|
return;
|
|
27
29
|
}
|
|
28
30
|
if (error && typeof error === 'object' && 'statusCode' in error) {
|
|
29
31
|
const status = (error as { statusCode: number }).statusCode;
|
|
30
|
-
if (status === 401
|
|
31
|
-
|
|
32
|
-
navigate('/login');
|
|
32
|
+
if (status === 401) {
|
|
33
|
+
await adminContext.navigate('/login');
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
} catch { /* auth check failed silently */ }
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* eslint-disable svelte/prefer-svelte-reactivity */
|
|
2
1
|
// @svadmin/core — Core Data and Hook APIs (Modularized for v0.2.29+)
|
|
3
2
|
|
|
4
3
|
// ─── Re-exports from modular hook files ─────────────────────────────
|
|
@@ -24,7 +23,7 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
|
|
|
24
23
|
|
|
25
24
|
import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
|
|
26
25
|
import { getAdminOptions } from './options.svelte';
|
|
27
|
-
import {
|
|
26
|
+
import { captureAdminContext } from './context.svelte';
|
|
28
27
|
import { useParsed } from './useParsed.svelte';
|
|
29
28
|
import { createOvertimeTracker, createLiveSubscription, fireSuccessNotification, fireErrorNotification, checkError } from './hook-utils.svelte';
|
|
30
29
|
import { invalidateByScopes, publishLiveEvent } from './mutation-hooks.svelte';
|
|
@@ -51,12 +50,13 @@ export interface UseInfiniteListOptions<_TData extends BaseRecord = BaseRecord,
|
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseInfiniteListOptions<TData, TError> = {}) {
|
|
53
|
+
const adminContext = captureAdminContext();
|
|
54
54
|
const parsed = useParsed();
|
|
55
55
|
const adminOptions = getAdminOptions();
|
|
56
56
|
|
|
57
57
|
const query = createInfiniteQuery<{ data: TData[]; total: number }, TError>(() => {
|
|
58
58
|
const resource = options.resource ?? parsed.resource ?? '';
|
|
59
|
-
const provider = getDataProviderForResource(resource, options.dataProviderName);
|
|
59
|
+
const provider = adminContext.getDataProviderForResource(resource, options.dataProviderName);
|
|
60
60
|
return {
|
|
61
61
|
queryKey: [options.dataProviderName, resource, 'infiniteList', options.pagination?.pageSize, options.sorters, options.filters, options.meta],
|
|
62
62
|
queryFn: async ({ pageParam = 1 }) => {
|
|
@@ -84,7 +84,7 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
|
|
|
84
84
|
|
|
85
85
|
createLiveSubscription((): LiveSubscriptionParams => ({
|
|
86
86
|
resource: options.resource ?? parsed.resource ?? '',
|
|
87
|
-
liveProvider:
|
|
87
|
+
liveProvider: adminContext.liveProvider,
|
|
88
88
|
liveMode: options.liveMode ?? adminOptions.liveMode,
|
|
89
89
|
onLiveEvent: (e: LiveEvent) => {
|
|
90
90
|
options.onLiveEvent?.(e);
|
|
@@ -102,7 +102,7 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
|
|
|
102
102
|
fireSuccessNotification(options.successNotification, '', query.data, undefined, options.resource ?? parsed.resource ?? '');
|
|
103
103
|
} else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
|
|
104
104
|
lastErrorAt = query.errorUpdatedAt;
|
|
105
|
-
checkError(query.error);
|
|
105
|
+
checkError(query.error, adminContext);
|
|
106
106
|
fireErrorNotification(options.errorNotification, 'Fetch failed', query.error, options.resource ?? parsed.resource ?? '');
|
|
107
107
|
}
|
|
108
108
|
});
|
|
@@ -133,6 +133,7 @@ export interface UseSelectOptions<TData extends BaseRecord = BaseRecord, _TOptio
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { label: string; value: string | number }>(options: UseSelectOptions<TData, TOption>) {
|
|
136
|
+
const adminContext = captureAdminContext();
|
|
136
137
|
const { resource, optionLabel = 'title', optionValue = 'id', sorters, filters, pagination, meta, dataProviderName, onSearch, debounce: debounceMs = 300 } = options;
|
|
137
138
|
const adminOptions = getAdminOptions();
|
|
138
139
|
|
|
@@ -149,7 +150,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
|
|
|
149
150
|
const effectivePageSize = options.fetchSize ?? pagination?.pageSize ?? 999;
|
|
150
151
|
|
|
151
152
|
const query = createQuery<{ data: TData[]; total: number }>(() => {
|
|
152
|
-
const provider = getDataProviderForResource(resource, dataProviderName);
|
|
153
|
+
const provider = adminContext.getDataProviderForResource(resource, dataProviderName);
|
|
153
154
|
return {
|
|
154
155
|
queryKey: [dataProviderName, resource, 'select', allFilters, sorters, pagination, meta],
|
|
155
156
|
queryFn: () => provider.getList<TData>({ resource, sorters, filters: allFilters, pagination: { current: 1, pageSize: effectivePageSize }, meta }),
|
|
@@ -162,7 +163,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
|
|
|
162
163
|
const defaultValueIds = options.defaultValue ?? [];
|
|
163
164
|
const defaultValueQuery = defaultValueIds.length > 0
|
|
164
165
|
? createQuery<{ data: TData[] }>(() => {
|
|
165
|
-
const provider = getDataProviderForResource(resource, dataProviderName);
|
|
166
|
+
const provider = adminContext.getDataProviderForResource(resource, dataProviderName);
|
|
166
167
|
return {
|
|
167
168
|
queryKey: [dataProviderName, resource, 'select-defaults', defaultValueIds],
|
|
168
169
|
queryFn: async () => {
|
|
@@ -205,7 +206,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
|
|
|
205
206
|
fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
|
|
206
207
|
} else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
|
|
207
208
|
lastErrorAt = query.errorUpdatedAt;
|
|
208
|
-
checkError(query.error);
|
|
209
|
+
checkError(query.error, adminContext);
|
|
209
210
|
fireErrorNotification(options.errorNotification, 'Fetch failed', query.error, resource);
|
|
210
211
|
}
|
|
211
212
|
});
|
|
@@ -243,10 +244,11 @@ export interface UseCustomOptions<_TData = unknown, _TError = HttpError> {
|
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
export function useCustom<TData = unknown, TError = HttpError>(options: UseCustomOptions<TData, TError>) {
|
|
247
|
+
const adminContext = captureAdminContext();
|
|
246
248
|
const adminOptions = getAdminOptions();
|
|
247
249
|
|
|
248
250
|
const query = createQuery<{ data: TData }, TError>(() => {
|
|
249
|
-
const provider = getDataProvider(options.dataProviderName);
|
|
251
|
+
const provider = adminContext.getDataProvider(options.dataProviderName);
|
|
250
252
|
return {
|
|
251
253
|
queryKey: [options.dataProviderName, 'custom', options.url, options.method, options.config, options.meta],
|
|
252
254
|
queryFn: async () => {
|
|
@@ -277,7 +279,7 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
|
|
|
277
279
|
fireSuccessNotification(options.successNotification, '', query.data);
|
|
278
280
|
} else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
|
|
279
281
|
lastErrorAt = query.errorUpdatedAt;
|
|
280
|
-
checkError(query.error);
|
|
282
|
+
checkError(query.error, adminContext);
|
|
281
283
|
fireErrorNotification(options.errorNotification, 'Custom request failed', query.error, '');
|
|
282
284
|
}
|
|
283
285
|
});
|
|
@@ -288,11 +290,12 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
|
|
|
288
290
|
// ─── useCustomMutation ──────────────────────────────────────────────
|
|
289
291
|
|
|
290
292
|
export function useCustomMutation<TData = unknown, TError = HttpError, TVariables = unknown>(dataProviderName?: string) {
|
|
293
|
+
const adminContext = captureAdminContext();
|
|
291
294
|
const queryClient = useQueryClient();
|
|
292
295
|
|
|
293
296
|
const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; query?: Record<string, unknown>; headers?: Record<string, string>; sorters?: Sort[]; filters?: Filter[]; meta?: Record<string, unknown>; invalidates?: string[] | false; resource?: string }>(() => ({
|
|
294
297
|
mutationFn: async (params) => {
|
|
295
|
-
const provider = getDataProvider(dataProviderName);
|
|
298
|
+
const provider = adminContext.getDataProvider(dataProviderName);
|
|
296
299
|
if (!provider.custom) throw new Error('DataProvider does not support custom method');
|
|
297
300
|
return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, query: params.query, headers: params.headers, sorters: params.sorters, filters: params.filters, meta: params.meta });
|
|
298
301
|
},
|
|
@@ -302,7 +305,7 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
|
|
|
302
305
|
}
|
|
303
306
|
},
|
|
304
307
|
onError: (error) => {
|
|
305
|
-
checkError(error);
|
|
308
|
+
checkError(error, adminContext);
|
|
306
309
|
},
|
|
307
310
|
}));
|
|
308
311
|
|
|
@@ -312,6 +315,7 @@ export function useCustomMutation<TData = unknown, TError = HttpError, TVariable
|
|
|
312
315
|
// ─── useCreateMany / useUpdateMany / useDeleteMany ──────────────────
|
|
313
316
|
|
|
314
317
|
export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
|
|
318
|
+
const adminContext = captureAdminContext();
|
|
315
319
|
const parsed = useParsed();
|
|
316
320
|
const resource = options.resource ?? parsed.resource ?? '';
|
|
317
321
|
const adminOptions = getAdminOptions();
|
|
@@ -320,7 +324,7 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
320
324
|
const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; variables: TVariables[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
|
|
321
325
|
mutationFn: async (params) => {
|
|
322
326
|
const resName = params.resource ?? resource;
|
|
323
|
-
const provider = getDataProviderForResource(resName, params.dataProviderName);
|
|
327
|
+
const provider = adminContext.getDataProviderForResource(resName, params.dataProviderName);
|
|
324
328
|
if (provider.createMany) return await provider.createMany<TData, TVariables>({ resource: resName, variables: params.variables, meta: params.meta });
|
|
325
329
|
const results = await Promise.all(params.variables.map(v => provider.create<TData, TVariables>({ resource: resName, variables: v, meta: params.meta })));
|
|
326
330
|
return { data: results.map(r => r.data) };
|
|
@@ -329,10 +333,10 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
329
333
|
const resName = params.resource ?? resource;
|
|
330
334
|
fireSuccessNotification(undefined, 'Created successfully', data.data, params.variables, resName);
|
|
331
335
|
audit({ action: 'create', resource: resName });
|
|
332
|
-
publishLiveEvent(resName, 'created');
|
|
336
|
+
publishLiveEvent(resName, 'created', undefined, adminContext);
|
|
333
337
|
},
|
|
334
338
|
onError: (error, params) => {
|
|
335
|
-
checkError(error);
|
|
339
|
+
checkError(error, adminContext);
|
|
336
340
|
fireErrorNotification(undefined, 'Create many failed', error, params.resource ?? resource);
|
|
337
341
|
},
|
|
338
342
|
onSettled: (_d, _e, params) => {
|
|
@@ -345,6 +349,7 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
345
349
|
}
|
|
346
350
|
|
|
347
351
|
export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
|
|
352
|
+
const adminContext = captureAdminContext();
|
|
348
353
|
const parsed = useParsed();
|
|
349
354
|
const resource = options.resource ?? parsed.resource ?? '';
|
|
350
355
|
const adminOptions = getAdminOptions();
|
|
@@ -353,7 +358,7 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
353
358
|
const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; variables: TVariables; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
|
|
354
359
|
mutationFn: async (params) => {
|
|
355
360
|
const resName = params.resource ?? resource;
|
|
356
|
-
const provider = getDataProviderForResource(resName, params.dataProviderName);
|
|
361
|
+
const provider = adminContext.getDataProviderForResource(resName, params.dataProviderName);
|
|
357
362
|
if (provider.updateMany) return await provider.updateMany<TData, TVariables>({ resource: resName, ids: params.ids, variables: params.variables, meta: params.meta });
|
|
358
363
|
const results = await Promise.all(params.ids.map(id => provider.update<TData, TVariables>({ resource: resName, id, variables: params.variables, meta: params.meta })));
|
|
359
364
|
return { data: results.map(r => r.data) };
|
|
@@ -362,10 +367,10 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
362
367
|
const resName = params.resource ?? resource;
|
|
363
368
|
fireSuccessNotification(undefined, 'Updated successfully', data.data, params.variables, resName);
|
|
364
369
|
audit({ action: 'update', resource: resName });
|
|
365
|
-
publishLiveEvent(resName, 'updated', params.ids);
|
|
370
|
+
publishLiveEvent(resName, 'updated', params.ids, adminContext);
|
|
366
371
|
},
|
|
367
372
|
onError: (error, params) => {
|
|
368
|
-
checkError(error);
|
|
373
|
+
checkError(error, adminContext);
|
|
369
374
|
fireErrorNotification(undefined, 'Update many failed', error, params.resource ?? resource);
|
|
370
375
|
},
|
|
371
376
|
onSettled: (_d, _e, params) => {
|
|
@@ -381,6 +386,7 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
381
386
|
}
|
|
382
387
|
|
|
383
388
|
export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
|
|
389
|
+
const adminContext = captureAdminContext();
|
|
384
390
|
const parsed = useParsed();
|
|
385
391
|
const resource = options.resource ?? parsed.resource ?? '';
|
|
386
392
|
const adminOptions = getAdminOptions();
|
|
@@ -389,7 +395,7 @@ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
389
395
|
const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
|
|
390
396
|
mutationFn: async (params) => {
|
|
391
397
|
const resName = params.resource ?? resource;
|
|
392
|
-
const provider = getDataProviderForResource(resName, params.dataProviderName);
|
|
398
|
+
const provider = adminContext.getDataProviderForResource(resName, params.dataProviderName);
|
|
393
399
|
if (provider.deleteMany) return await provider.deleteMany<TData>({ resource: resName, ids: params.ids, meta: params.meta });
|
|
394
400
|
const results = await Promise.all(params.ids.map(id => provider.deleteOne<TData>({ resource: resName, id, meta: params.meta })));
|
|
395
401
|
return { data: results.map(r => r.data) };
|
|
@@ -398,10 +404,10 @@ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
398
404
|
const resName = params.resource ?? resource;
|
|
399
405
|
fireSuccessNotification(undefined, 'Deleted successfully', data.data, undefined, resName);
|
|
400
406
|
audit({ action: 'delete', resource: resName });
|
|
401
|
-
publishLiveEvent(resName, 'deleted', params.ids);
|
|
407
|
+
publishLiveEvent(resName, 'deleted', params.ids, adminContext);
|
|
402
408
|
},
|
|
403
409
|
onError: (error, params) => {
|
|
404
|
-
checkError(error);
|
|
410
|
+
checkError(error, adminContext);
|
|
405
411
|
fireErrorNotification(undefined, 'Delete many failed', error, params.resource ?? resource);
|
|
406
412
|
},
|
|
407
413
|
onSettled: (_d, _e, params) => {
|
|
@@ -454,7 +460,8 @@ export function useOvertime(options?: OvertimeOptions) {
|
|
|
454
460
|
// ─── useDataProvider ────────────────────────────────────────────────
|
|
455
461
|
|
|
456
462
|
export function useDataProvider(): (dataProviderName?: string) => DataProvider {
|
|
457
|
-
|
|
463
|
+
const adminContext = captureAdminContext();
|
|
464
|
+
return (name?: string) => adminContext.getDataProvider(name);
|
|
458
465
|
}
|
|
459
466
|
|
|
460
467
|
// ─── useThemedLayoutContext ─────────────────────────────────────────
|
|
@@ -470,4 +477,3 @@ export function useThemedLayoutContext() {
|
|
|
470
477
|
toggleSidebar() { _sidebarCollapsed = !_sidebarCollapsed; },
|
|
471
478
|
};
|
|
472
479
|
}
|
|
473
|
-
|
package/src/http.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP fetch 工具 — 带 CSRF 防护、401
|
|
2
|
+
* HTTP fetch 工具 — 带 CSRF 防护、401 自动跳转和结构化错误语义。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { HttpError, type ValidationErrors } from './types';
|
|
6
|
+
|
|
5
7
|
export interface FetchWithInterceptorOptions {
|
|
6
8
|
/** 401 时跳转的登录页路径 */
|
|
7
9
|
loginPath?: string;
|
|
8
|
-
/** 403
|
|
10
|
+
/** 403 且服务端未返回消息时使用的错误消息 */
|
|
9
11
|
forbiddenMessage?: string;
|
|
10
12
|
/** 是否自动添加 X-Requested-With header (CSRF 防护) */
|
|
11
13
|
csrfProtection?: boolean;
|
|
@@ -21,32 +23,103 @@ const DEFAULT_OPTIONS: Required<Omit<FetchWithInterceptorOptions, 'fetchImpl' |
|
|
|
21
23
|
fetchImpl: FetchWithInterceptor;
|
|
22
24
|
onUnauthorized: (loginPath: string, returnTo: string) => void;
|
|
23
25
|
} = {
|
|
24
|
-
loginPath:
|
|
25
|
-
forbiddenMessage:
|
|
26
|
+
loginPath: '/auth/login',
|
|
27
|
+
forbiddenMessage: 'Forbidden: 该操作已被安全策略拒绝',
|
|
26
28
|
csrfProtection: true,
|
|
27
29
|
get fetchImpl() {
|
|
28
|
-
if (typeof globalThis !==
|
|
30
|
+
if (typeof globalThis !== 'undefined' && typeof globalThis.fetch === 'function') {
|
|
29
31
|
return (url: string, init?: RequestInit) => globalThis.fetch(url, init);
|
|
30
32
|
}
|
|
31
33
|
return async () => {
|
|
32
|
-
throw new Error(
|
|
34
|
+
throw new Error('No fetch implementation found. Pass fetchImpl in options.');
|
|
33
35
|
};
|
|
34
36
|
},
|
|
35
37
|
onUnauthorized: (loginPath, returnTo) => {
|
|
36
|
-
if (typeof window !==
|
|
38
|
+
if (typeof window !== 'undefined') {
|
|
37
39
|
window.location.href = `${loginPath}?returnTo=${returnTo}`;
|
|
38
40
|
}
|
|
39
41
|
},
|
|
40
42
|
};
|
|
41
43
|
|
|
44
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
45
|
+
return typeof value === 'object' && value !== null
|
|
46
|
+
? value as Record<string, unknown>
|
|
47
|
+
: null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asStructuredHttpError(error: unknown): HttpError | null {
|
|
51
|
+
const record = asRecord(error);
|
|
52
|
+
if (!record || typeof record.statusCode !== 'number') return null;
|
|
53
|
+
const message = typeof record.message === 'string' ? record.message : 'HTTP request failed';
|
|
54
|
+
const errors = asValidationErrors(record.errors);
|
|
55
|
+
return new HttpError(message, record.statusCode, errors, {
|
|
56
|
+
code: typeof record.code === 'string' ? record.code : undefined,
|
|
57
|
+
details: record.details,
|
|
58
|
+
body: record.body,
|
|
59
|
+
cause: error,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function firstString(...values: unknown[]): string | undefined {
|
|
64
|
+
return values.find((value): value is string => typeof value === 'string');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function asValidationErrors(value: unknown): ValidationErrors | undefined {
|
|
68
|
+
const record = asRecord(value);
|
|
69
|
+
if (!record) return undefined;
|
|
70
|
+
|
|
71
|
+
const valid = Object.values(record).every((entry) => (
|
|
72
|
+
typeof entry === 'string'
|
|
73
|
+
|| (Array.isArray(entry) && entry.every((item) => typeof item === 'string'))
|
|
74
|
+
));
|
|
75
|
+
return valid ? record as ValidationErrors : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readErrorBody(response: Response): Promise<unknown> {
|
|
79
|
+
const text = await response.text();
|
|
80
|
+
if (!text) return undefined;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(text) as unknown;
|
|
83
|
+
} catch {
|
|
84
|
+
return text;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function createResponseError(
|
|
89
|
+
response: Response,
|
|
90
|
+
forbiddenMessage: string,
|
|
91
|
+
): Promise<HttpError> {
|
|
92
|
+
const body = await readErrorBody(response);
|
|
93
|
+
const record = asRecord(body);
|
|
94
|
+
const nestedError = asRecord(record?.error);
|
|
95
|
+
const code = response.headers.get('X-Svadmin-Auth-Retry') === 'exhausted'
|
|
96
|
+
? 'auth_retry_exhausted'
|
|
97
|
+
: firstString(
|
|
98
|
+
record?.code,
|
|
99
|
+
record?.error_code,
|
|
100
|
+
nestedError?.code,
|
|
101
|
+
nestedError?.error_code,
|
|
102
|
+
record?.error,
|
|
103
|
+
);
|
|
104
|
+
const details = record?.details ?? nestedError?.details;
|
|
105
|
+
const message = firstString(
|
|
106
|
+
record?.message,
|
|
107
|
+
nestedError?.message,
|
|
108
|
+
record?.error_description,
|
|
109
|
+
nestedError?.error_description,
|
|
110
|
+
body,
|
|
111
|
+
) ?? (response.status === 401 ? 'Unauthorized' : forbiddenMessage);
|
|
112
|
+
|
|
113
|
+
return new HttpError(
|
|
114
|
+
message,
|
|
115
|
+
response.status,
|
|
116
|
+
asValidationErrors(record?.errors ?? nestedError?.errors),
|
|
117
|
+
{ code, details, body },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
42
121
|
/**
|
|
43
|
-
* 带拦截器的 fetch
|
|
44
|
-
*
|
|
45
|
-
* @example
|
|
46
|
-
* ```ts
|
|
47
|
-
* const fetcher = createFetchWithInterceptor({ loginPath: '/login' });
|
|
48
|
-
* const res = await fetcher('/api/users');
|
|
49
|
-
* ```
|
|
122
|
+
* 带拦截器的 fetch。Core 只保留错误语义;认证刷新和请求重放由认证提供方处理。
|
|
50
123
|
*/
|
|
51
124
|
export function createFetchWithInterceptor(
|
|
52
125
|
options: FetchWithInterceptorOptions = {},
|
|
@@ -55,34 +128,42 @@ export function createFetchWithInterceptor(
|
|
|
55
128
|
|
|
56
129
|
return async (url: string, init: RequestInit = {}): Promise<Response> => {
|
|
57
130
|
const headers = new Headers(init.headers || {});
|
|
58
|
-
|
|
59
131
|
if (
|
|
60
|
-
opts.csrfProtection
|
|
61
|
-
init.method
|
|
62
|
-
[
|
|
132
|
+
opts.csrfProtection
|
|
133
|
+
&& init.method
|
|
134
|
+
&& ['POST', 'PUT', 'DELETE', 'PATCH'].includes(init.method.toUpperCase())
|
|
63
135
|
) {
|
|
64
|
-
headers.set(
|
|
136
|
+
headers.set('X-Requested-With', 'XMLHttpRequest');
|
|
65
137
|
}
|
|
66
138
|
|
|
67
|
-
|
|
139
|
+
let response: Response;
|
|
140
|
+
try {
|
|
141
|
+
response = await opts.fetchImpl(url, { ...init, headers });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof HttpError) throw error;
|
|
144
|
+
const structuredError = asStructuredHttpError(error);
|
|
145
|
+
if (structuredError) throw structuredError;
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
68
148
|
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
149
|
+
if (response.ok || (response.status !== 401 && response.status !== 403)) {
|
|
150
|
+
return response;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const httpError = await createResponseError(response, opts.forbiddenMessage);
|
|
154
|
+
if (response.status === 401) {
|
|
155
|
+
const returnTo = typeof window !== 'undefined'
|
|
156
|
+
? encodeURIComponent(window.location.pathname)
|
|
157
|
+
: '';
|
|
158
|
+
try {
|
|
74
159
|
opts.onUnauthorized(opts.loginPath, returnTo);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (res.status === 403) {
|
|
78
|
-
throw new Error(opts.forbiddenMessage);
|
|
160
|
+
} catch {
|
|
161
|
+
// 重定向适配器失败不能覆盖服务端的结构化 401 错误。
|
|
79
162
|
}
|
|
80
163
|
}
|
|
81
|
-
|
|
164
|
+
throw httpError;
|
|
82
165
|
};
|
|
83
166
|
}
|
|
84
167
|
|
|
85
|
-
/**
|
|
86
|
-
* 便捷全局实例 — 使用默认配置。
|
|
87
|
-
*/
|
|
168
|
+
/** 便捷全局实例 — 使用默认配置。 */
|
|
88
169
|
export const fetchWithInterceptor = createFetchWithInterceptor();
|