@svadmin/core 0.41.1 → 0.42.1
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 +1 -1
- package/src/form-hooks.svelte.ts +6 -4
- package/src/hooks.svelte.ts +224 -23
- package/src/i18n.svelte.ts +25 -1
- package/src/index.ts +10 -3
- package/src/mutation-hooks.svelte.ts +1 -3
- package/src/routing-hooks.svelte.ts +12 -5
- package/src/types.ts +19 -0
- package/src/url-sync.ts +54 -1
package/package.json
CHANGED
package/src/form-hooks.svelte.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { BaseRecord, MutationMode, KnownResources } from './types';
|
|
|
14
14
|
import { checkError } from './hook-utils.svelte';
|
|
15
15
|
import { toast } from './toast.svelte';
|
|
16
16
|
import { deepMerge, invalidateByScopes } from './mutation-hooks.svelte';
|
|
17
|
+
import { appendListQueryFromPath } from './url-sync';
|
|
17
18
|
|
|
18
19
|
// ─── Types ───────────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -652,14 +653,15 @@ export function useForm<
|
|
|
652
653
|
}));
|
|
653
654
|
|
|
654
655
|
function doRedirect(to: 'list' | 'edit' | 'show' | false) {
|
|
655
|
-
const
|
|
656
|
+
const currentPath = adminContext.currentPath();
|
|
657
|
+
const path = currentPath.split('?')[0];
|
|
656
658
|
const parts = path.split('/');
|
|
657
659
|
const resIdx = parts.lastIndexOf(String(resource));
|
|
658
660
|
const base = resIdx >= 0 ? parts.slice(0, resIdx + 1).join('/') : `/${resource}`;
|
|
659
661
|
|
|
660
|
-
if (to === 'list') void adminContext.navigate(base);
|
|
661
|
-
else if (to === 'edit' && currentId) void adminContext.navigate(`${base}/edit/${currentId}
|
|
662
|
-
else if (to === 'show' && currentId) void adminContext.navigate(`${base}/show/${currentId}
|
|
662
|
+
if (to === 'list') void adminContext.navigate(appendListQueryFromPath(base, currentPath));
|
|
663
|
+
else if (to === 'edit' && currentId) void adminContext.navigate(appendListQueryFromPath(`${base}/edit/${currentId}`, currentPath));
|
|
664
|
+
else if (to === 'show' && currentId) void adminContext.navigate(appendListQueryFromPath(`${base}/show/${currentId}`, currentPath));
|
|
663
665
|
}
|
|
664
666
|
|
|
665
667
|
// ─── Submit ─────────────────────────────────────────────────────
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -22,14 +22,19 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
|
|
|
22
22
|
import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
|
|
23
23
|
import { getAdminOptions } from './options.svelte';
|
|
24
24
|
import { captureAdminContext } from './context.svelte';
|
|
25
|
-
import { queryKeyMatches } from './query-keys';
|
|
25
|
+
import { dataQueryMatches, queryKeyMatches } from './query-keys';
|
|
26
|
+
import type { QueryMatcher } from './query-keys';
|
|
26
27
|
import { useParsed } from './useParsed.svelte';
|
|
27
28
|
import { createOvertimeTracker, createLiveSubscription, fireSuccessNotification, fireErrorNotification, checkError } from './hook-utils.svelte';
|
|
28
29
|
import { invalidateByScopes, publishLiveEvent } from './mutation-hooks.svelte';
|
|
29
30
|
import type { NotificationConfig, OvertimeOptions, LiveSubscriptionParams } from './hook-utils.svelte';
|
|
30
|
-
import
|
|
31
|
+
import { DeleteManyPartialError, UndoError } from './types';
|
|
32
|
+
import type { BaseRecord, HttpError, Pagination, Sort, Filter, DataProvider, KnownResources, MutationMode } from './types';
|
|
31
33
|
import type { LiveMode, LiveEvent } from './live.svelte';
|
|
32
34
|
import { auditWithProvider } from './audit';
|
|
35
|
+
import { useTranslation } from './i18n.svelte';
|
|
36
|
+
import { toast } from './toast.svelte';
|
|
37
|
+
import type { MutationCallbacks } from './mutation-hooks.svelte';
|
|
33
38
|
|
|
34
39
|
// ─── useInfiniteList ────────────────────────────────────────────────
|
|
35
40
|
|
|
@@ -493,25 +498,175 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
493
498
|
return { mutation, get overtime() { return createOvertimeTracker(() => mutation.isPending, options.overtimeOptions ?? adminOptions.overtime); } };
|
|
494
499
|
}
|
|
495
500
|
|
|
496
|
-
export
|
|
501
|
+
export interface UseDeleteManyOptions {
|
|
502
|
+
resource?: KnownResources;
|
|
503
|
+
mutationMode?: MutationMode;
|
|
504
|
+
undoableTimeout?: number;
|
|
505
|
+
mutationOptions?: MutationCallbacks;
|
|
506
|
+
overtimeOptions?: OvertimeOptions;
|
|
507
|
+
successNotification?: NotificationConfig;
|
|
508
|
+
errorNotification?: NotificationConfig;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export interface UseDeleteManyMutateParams {
|
|
512
|
+
resource?: KnownResources;
|
|
513
|
+
ids: (string | number)[];
|
|
514
|
+
mutationMode?: MutationMode;
|
|
515
|
+
undoableTimeout?: number;
|
|
516
|
+
meta?: Record<string, unknown>;
|
|
517
|
+
dataProviderName?: string;
|
|
518
|
+
invalidates?: string[] | false;
|
|
519
|
+
onCancel?: () => void;
|
|
520
|
+
successNotification?: NotificationConfig;
|
|
521
|
+
errorNotification?: NotificationConfig;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
interface DeleteManyMutationContext {
|
|
525
|
+
_svadmin_ctx?: boolean;
|
|
526
|
+
userContext?: unknown;
|
|
527
|
+
previousQueries?: [readonly unknown[], unknown][];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseDeleteManyOptions = {}) {
|
|
497
531
|
const adminContext = captureAdminContext();
|
|
498
532
|
const parsed = useParsed();
|
|
499
533
|
const resource = options.resource ?? parsed.resource ?? '';
|
|
500
534
|
const adminOptions = getAdminOptions();
|
|
501
535
|
const queryClient = useQueryClient();
|
|
536
|
+
const i18n = useTranslation();
|
|
537
|
+
const mutationMode = options.mutationMode ?? adminOptions.mutationMode ?? 'pessimistic';
|
|
538
|
+
const undoableTimeout = options.undoableTimeout ?? adminOptions.undoableTimeout ?? 5000;
|
|
539
|
+
|
|
540
|
+
const removeIdsFromCache = (
|
|
541
|
+
resName: string,
|
|
542
|
+
ids: (string | number)[],
|
|
543
|
+
dataProviderName?: string,
|
|
544
|
+
) => {
|
|
545
|
+
const matcher = adminContext.queryKeyMatcher(resName, dataProviderName);
|
|
546
|
+
const dataMatch = (queryKey: readonly unknown[], fields: QueryMatcher = {}) => dataQueryMatches(queryKey, {
|
|
547
|
+
...matcher,
|
|
548
|
+
resource: resName,
|
|
549
|
+
...fields,
|
|
550
|
+
});
|
|
551
|
+
const primaryKey = adminContext.getResource(resName).primaryKey ?? 'id';
|
|
552
|
+
const deletedIds = new Set(ids.map(String));
|
|
553
|
+
const removeFromCollection = (old: unknown): unknown => {
|
|
554
|
+
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
555
|
+
const current = old as { data: Record<string, unknown>[]; total?: number };
|
|
556
|
+
if (!Array.isArray(current.data)) return old;
|
|
557
|
+
const filtered = current.data.filter((item) => !deletedIds.has(String(item[primaryKey])));
|
|
558
|
+
const removedCount = current.data.length - filtered.length;
|
|
559
|
+
return {
|
|
560
|
+
...current,
|
|
561
|
+
data: filtered,
|
|
562
|
+
total: current.total == null ? current.total : Math.max(0, current.total - removedCount),
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
const removeFromInfiniteCollection = (old: unknown): unknown => {
|
|
566
|
+
if (!old || typeof old !== 'object' || !('pages' in old)) return old;
|
|
567
|
+
const current = old as {
|
|
568
|
+
pages: { data: Record<string, unknown>[]; total?: number }[];
|
|
569
|
+
pageParams?: unknown[];
|
|
570
|
+
};
|
|
571
|
+
if (!Array.isArray(current.pages)) return old;
|
|
572
|
+
|
|
573
|
+
const removedIds = new Set<string>();
|
|
574
|
+
for (const page of current.pages) {
|
|
575
|
+
if (!Array.isArray(page.data)) continue;
|
|
576
|
+
for (const item of page.data) {
|
|
577
|
+
const itemId = String(item[primaryKey]);
|
|
578
|
+
if (deletedIds.has(itemId)) removedIds.add(itemId);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
502
581
|
|
|
503
|
-
|
|
582
|
+
return {
|
|
583
|
+
...current,
|
|
584
|
+
pages: current.pages.map((page) => ({
|
|
585
|
+
...page,
|
|
586
|
+
data: Array.isArray(page.data)
|
|
587
|
+
? page.data.filter((item) => !deletedIds.has(String(item[primaryKey])))
|
|
588
|
+
: page.data,
|
|
589
|
+
total: page.total == null ? page.total : Math.max(0, page.total - removedIds.size),
|
|
590
|
+
})),
|
|
591
|
+
};
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
queryClient.setQueriesData({
|
|
595
|
+
predicate: (query) => ['list', 'select', 'selectDefaults'].some((action) => dataMatch(query.queryKey, { action })),
|
|
596
|
+
}, removeFromCollection);
|
|
597
|
+
queryClient.setQueriesData({
|
|
598
|
+
predicate: (query) => dataMatch(query.queryKey, { action: 'infiniteList' }),
|
|
599
|
+
}, removeFromInfiniteCollection);
|
|
600
|
+
queryClient.setQueriesData({
|
|
601
|
+
predicate: (query) => dataMatch(query.queryKey, { action: 'many' }),
|
|
602
|
+
}, removeFromCollection);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const mutation = createMutation<{ data: TData[] }, TError | DeleteManyPartialError, UseDeleteManyMutateParams, DeleteManyMutationContext>(() => ({
|
|
504
606
|
mutationFn: async (params) => {
|
|
505
607
|
const resName = params.resource ?? resource;
|
|
608
|
+
const effectiveMutationMode = params.mutationMode ?? mutationMode;
|
|
609
|
+
const effectiveUndoableTimeout = params.undoableTimeout ?? undoableTimeout;
|
|
610
|
+
if (effectiveMutationMode === 'undoable') {
|
|
611
|
+
await new Promise<void>((resolve, reject) => {
|
|
612
|
+
toast.undoable(i18n.t('common.actionCanBeUndone'), effectiveUndoableTimeout, () => {
|
|
613
|
+
params.onCancel?.();
|
|
614
|
+
reject(new UndoError());
|
|
615
|
+
}, resolve);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
506
618
|
const provider = adminContext.getDataProviderForResource(resName, params.dataProviderName);
|
|
507
619
|
if (provider.deleteMany) return await provider.deleteMany<TData>({ resource: resName, ids: params.ids, meta: params.meta });
|
|
508
|
-
const
|
|
509
|
-
|
|
620
|
+
const settled = await Promise.allSettled(
|
|
621
|
+
params.ids.map(id => provider.deleteOne<TData>({ resource: resName, id, meta: params.meta })),
|
|
622
|
+
);
|
|
623
|
+
const succeededIds: (string | number)[] = [];
|
|
624
|
+
const failedIds: (string | number)[] = [];
|
|
625
|
+
const causes: unknown[] = [];
|
|
626
|
+
const data: TData[] = [];
|
|
627
|
+
settled.forEach((result, index) => {
|
|
628
|
+
const id = params.ids[index];
|
|
629
|
+
if (result.status === 'fulfilled') {
|
|
630
|
+
succeededIds.push(id);
|
|
631
|
+
data.push(result.value.data);
|
|
632
|
+
} else {
|
|
633
|
+
failedIds.push(id);
|
|
634
|
+
causes.push(result.reason);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
if (failedIds.length > 0) {
|
|
638
|
+
if (succeededIds.length > 0) {
|
|
639
|
+
throw new DeleteManyPartialError(succeededIds, failedIds, causes);
|
|
640
|
+
}
|
|
641
|
+
throw causes[0] ?? new Error('Delete many failed');
|
|
642
|
+
}
|
|
643
|
+
return { data };
|
|
510
644
|
},
|
|
511
|
-
|
|
645
|
+
onMutate: async (params) => {
|
|
646
|
+
const userContext = typeof options.mutationOptions?.onMutate === 'function'
|
|
647
|
+
? await options.mutationOptions.onMutate(params)
|
|
648
|
+
: undefined;
|
|
649
|
+
const effectiveMutationMode = params.mutationMode ?? mutationMode;
|
|
650
|
+
if (effectiveMutationMode === 'pessimistic') return { _svadmin_ctx: true, userContext };
|
|
651
|
+
const resName = params.resource ?? resource;
|
|
652
|
+
const matcher = adminContext.queryKeyMatcher(resName, params.dataProviderName);
|
|
653
|
+
const dataMatch = (queryKey: readonly unknown[], fields: QueryMatcher = {}) => dataQueryMatches(queryKey, {
|
|
654
|
+
...matcher,
|
|
655
|
+
resource: resName,
|
|
656
|
+
...fields,
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
await queryClient.cancelQueries({ predicate: (query) => dataMatch(query.queryKey) });
|
|
660
|
+
const previousQueries = queryClient.getQueriesData({ predicate: (query) => dataMatch(query.queryKey) });
|
|
661
|
+
removeIdsFromCache(resName, params.ids, params.dataProviderName);
|
|
662
|
+
|
|
663
|
+
return { _svadmin_ctx: true, userContext, previousQueries };
|
|
664
|
+
},
|
|
665
|
+
onSuccess: (data, params, context) => {
|
|
512
666
|
const resName = params.resource ?? resource;
|
|
667
|
+
const extractedContext = context?._svadmin_ctx ? context.userContext : context;
|
|
513
668
|
fireSuccessNotification({
|
|
514
|
-
config:
|
|
669
|
+
config: params.successNotification ?? options.successNotification,
|
|
515
670
|
defaultMessage: 'Deleted successfully',
|
|
516
671
|
data: data.data,
|
|
517
672
|
resource: resName,
|
|
@@ -522,35 +677,81 @@ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = Ht
|
|
|
522
677
|
adminContext.auditLogProvider,
|
|
523
678
|
);
|
|
524
679
|
publishLiveEvent(resName, 'deleted', params.ids, adminContext);
|
|
680
|
+
for (const id of params.ids) {
|
|
681
|
+
queryClient.removeQueries({
|
|
682
|
+
predicate: (query) => queryKeyMatches(query.queryKey, {
|
|
683
|
+
...adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
684
|
+
kind: 'data',
|
|
685
|
+
resource: resName,
|
|
686
|
+
action: 'one',
|
|
687
|
+
id,
|
|
688
|
+
}),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
if (typeof options.mutationOptions?.onSuccess === 'function') {
|
|
692
|
+
options.mutationOptions.onSuccess(data, params, extractedContext);
|
|
693
|
+
}
|
|
525
694
|
},
|
|
526
|
-
onError: (error, params) => {
|
|
527
|
-
|
|
695
|
+
onError: (error, params, context) => {
|
|
696
|
+
if (context?.previousQueries) {
|
|
697
|
+
for (const [queryKey, data] of context.previousQueries) queryClient.setQueryData(queryKey, data);
|
|
698
|
+
}
|
|
699
|
+
if (error instanceof UndoError) return;
|
|
700
|
+
const resName = params.resource ?? resource;
|
|
701
|
+
if (error instanceof DeleteManyPartialError) {
|
|
702
|
+
removeIdsFromCache(resName, error.succeededIds, params.dataProviderName);
|
|
703
|
+
for (const id of error.succeededIds) {
|
|
704
|
+
queryClient.removeQueries({
|
|
705
|
+
predicate: (query) => queryKeyMatches(query.queryKey, {
|
|
706
|
+
...adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
707
|
+
kind: 'data',
|
|
708
|
+
resource: resName,
|
|
709
|
+
action: 'one',
|
|
710
|
+
id,
|
|
711
|
+
}),
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
auditWithProvider(
|
|
715
|
+
{
|
|
716
|
+
action: 'delete',
|
|
717
|
+
resource: resName,
|
|
718
|
+
outcome: 'failure',
|
|
719
|
+
details: { succeededIds: error.succeededIds, failedIds: error.failedIds },
|
|
720
|
+
meta: adminContext.getProviderMeta(resName),
|
|
721
|
+
},
|
|
722
|
+
adminContext.auditLogProvider,
|
|
723
|
+
);
|
|
724
|
+
publishLiveEvent(resName, 'deleted', error.succeededIds, adminContext);
|
|
725
|
+
}
|
|
726
|
+
const authError = error instanceof DeleteManyPartialError
|
|
727
|
+
? (error.causes[0] ?? error)
|
|
728
|
+
: error;
|
|
729
|
+
checkError(authError, adminContext);
|
|
528
730
|
fireErrorNotification({
|
|
529
|
-
config:
|
|
731
|
+
config: params.errorNotification ?? options.errorNotification,
|
|
530
732
|
defaultMessage: 'Delete many failed',
|
|
531
733
|
error,
|
|
532
|
-
resource:
|
|
734
|
+
resource: resName,
|
|
533
735
|
provider: adminContext.notificationProvider,
|
|
534
736
|
});
|
|
737
|
+
const extractedContext = context?._svadmin_ctx ? context.userContext : context;
|
|
738
|
+
if (typeof options.mutationOptions?.onError === 'function') {
|
|
739
|
+
options.mutationOptions.onError(error, params, extractedContext);
|
|
740
|
+
}
|
|
535
741
|
},
|
|
536
|
-
onSettled: (
|
|
742
|
+
onSettled: (_data, error, params, context) => {
|
|
743
|
+
if (error instanceof UndoError) return;
|
|
537
744
|
const resName = params.resource ?? resource;
|
|
538
745
|
invalidateByScopes({
|
|
539
746
|
queryClient,
|
|
540
747
|
resource: resName,
|
|
748
|
+
scopes: params.invalidates,
|
|
541
749
|
defaults: ['list', 'many'],
|
|
542
750
|
matcher: adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
543
751
|
});
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
...adminContext.queryKeyMatcher(resName, params.dataProviderName),
|
|
548
|
-
kind: 'data',
|
|
549
|
-
resource: resName,
|
|
550
|
-
action: 'one',
|
|
551
|
-
id,
|
|
552
|
-
}),
|
|
553
|
-
});
|
|
752
|
+
const extractedContext = context?._svadmin_ctx ? context.userContext : context;
|
|
753
|
+
if (typeof options.mutationOptions?.onSettled === 'function') {
|
|
754
|
+
options.mutationOptions.onSettled(_data, error, params, extractedContext);
|
|
554
755
|
}
|
|
555
756
|
},
|
|
556
757
|
}));
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -22,8 +22,9 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
22
22
|
'common.delete': '删除',
|
|
23
23
|
'common.batchDeletePartialFail': '批量删除部分失败:{failed}/{total} 条未成功',
|
|
24
24
|
'common.batchDeleteSuccess': '成功删除 {count} 条记录',
|
|
25
|
+
'common.batchDeleteFailed': '批量删除失败,未删除 {count} 条记录',
|
|
25
26
|
'common.selectedItems': '已选择 {count} 项',
|
|
26
|
-
'common.clearSelection': '
|
|
27
|
+
'common.clearSelection': '清除选择',
|
|
27
28
|
'common.density': '表格密度',
|
|
28
29
|
'common.densityCompact': '紧凑',
|
|
29
30
|
'common.densityComfortable': '宽松',
|
|
@@ -45,7 +46,11 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
45
46
|
'common.autoSaving': '自动保存中...',
|
|
46
47
|
'common.autoSaved': '已自动保存',
|
|
47
48
|
'common.selectAll': '全选',
|
|
49
|
+
'common.selectRow': '选择记录 {id}',
|
|
48
50
|
'common.batchDelete': '批量删除 ({count})',
|
|
51
|
+
'common.selectedCount': '已选择 {count} 条记录',
|
|
52
|
+
'common.processing': '处理中...',
|
|
53
|
+
'common.openFullDetail': '完整详情',
|
|
49
54
|
'common.unsavedChanges': '有未保存的修改,确定离开吗?',
|
|
50
55
|
'common.unsaved': '未保存',
|
|
51
56
|
'common.deleteConfirm': '确定要删除这条记录吗?此操作不可撤销。',
|
|
@@ -53,9 +58,11 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
53
58
|
'common.confirmAction': '确认操作',
|
|
54
59
|
'common.operationSuccess': '操作成功',
|
|
55
60
|
'common.operationFailed': '操作失败',
|
|
61
|
+
'common.accessDenied': '无权查看此记录',
|
|
56
62
|
'common.createSuccess': '创建成功',
|
|
57
63
|
'common.updateSuccess': '更新成功',
|
|
58
64
|
'common.deleteSuccess': '删除成功',
|
|
65
|
+
'common.actionCanBeUndone': '操作已提交,可在倒计时结束前撤销',
|
|
59
66
|
'common.loginFailed': '登录失败',
|
|
60
67
|
'common.home': '首页',
|
|
61
68
|
'common.logout': '退出登录',
|
|
@@ -90,6 +97,11 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
90
97
|
'common.back': '返回',
|
|
91
98
|
'common.step': '步骤',
|
|
92
99
|
'common.columns': '列',
|
|
100
|
+
'common.savedViews': '视图',
|
|
101
|
+
'common.savedViewsHint': '将当前筛选、搜索、排序、分页和列布局保存在此浏览器。',
|
|
102
|
+
'common.currentView': '当前未保存视图',
|
|
103
|
+
'common.viewName': '视图名称',
|
|
104
|
+
'common.saveView': '保存',
|
|
93
105
|
'common.perPage': '每页',
|
|
94
106
|
'common.copyId': '复制 ID',
|
|
95
107
|
'common.showMore': '展开更多',
|
|
@@ -670,6 +682,7 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
670
682
|
'common.delete': 'Delete',
|
|
671
683
|
'common.batchDeletePartialFail': 'Batch delete partially failed: {failed}/{total} unsuccessful',
|
|
672
684
|
'common.batchDeleteSuccess': 'Successfully deleted {count} records',
|
|
685
|
+
'common.batchDeleteFailed': 'Batch delete failed; {count} records were not deleted',
|
|
673
686
|
'common.selectedItems': '{count} selected',
|
|
674
687
|
'common.clearSelection': 'Clear selection',
|
|
675
688
|
'common.density': 'Table density',
|
|
@@ -693,7 +706,11 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
693
706
|
'common.autoSaving': 'Auto-saving...',
|
|
694
707
|
'common.autoSaved': 'Saved',
|
|
695
708
|
'common.selectAll': 'Select All',
|
|
709
|
+
'common.selectRow': 'Select record {id}',
|
|
696
710
|
'common.batchDelete': 'Batch Delete ({count})',
|
|
711
|
+
'common.selectedCount': '{count} records selected',
|
|
712
|
+
'common.processing': 'Processing...',
|
|
713
|
+
'common.openFullDetail': 'Full details',
|
|
697
714
|
'common.unsavedChanges': 'You have unsaved changes. Leave anyway?',
|
|
698
715
|
'common.unsaved': 'Unsaved',
|
|
699
716
|
'common.deleteConfirm': 'Are you sure you want to delete this record? This cannot be undone.',
|
|
@@ -701,9 +718,11 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
701
718
|
'common.confirmAction': 'Confirm Action',
|
|
702
719
|
'common.operationSuccess': 'Operation successful',
|
|
703
720
|
'common.operationFailed': 'Operation failed',
|
|
721
|
+
'common.accessDenied': 'You do not have access to this record',
|
|
704
722
|
'common.createSuccess': 'Created successfully',
|
|
705
723
|
'common.updateSuccess': 'Updated successfully',
|
|
706
724
|
'common.deleteSuccess': 'Deleted successfully',
|
|
725
|
+
'common.actionCanBeUndone': 'Action submitted. You can undo it before the countdown ends.',
|
|
707
726
|
'common.loginFailed': 'Login failed',
|
|
708
727
|
'common.home': 'Home',
|
|
709
728
|
'common.logout': 'Log out',
|
|
@@ -738,6 +757,11 @@ const baseLocales: Record<string, Record<string, string>> = {
|
|
|
738
757
|
'common.back': 'Back',
|
|
739
758
|
'common.step': 'Step',
|
|
740
759
|
'common.columns': 'Columns',
|
|
760
|
+
'common.savedViews': 'Views',
|
|
761
|
+
'common.savedViewsHint': 'Save filters, search, sorting, pagination, and column layout in this browser.',
|
|
762
|
+
'common.currentView': 'Current unsaved view',
|
|
763
|
+
'common.viewName': 'View name',
|
|
764
|
+
'common.saveView': 'Save',
|
|
741
765
|
'common.perPage': 'per page',
|
|
742
766
|
'common.copyId': 'Copy ID',
|
|
743
767
|
'common.showMore': 'Show more',
|
package/src/index.ts
CHANGED
|
@@ -70,10 +70,16 @@ export {
|
|
|
70
70
|
} from './hooks.svelte';
|
|
71
71
|
export { matchRoute, navigate, currentPath, setActiveRouterProvider, beforeEach, afterEach, resetRouter } from './router';
|
|
72
72
|
export type { RouteGuard } from './router';
|
|
73
|
-
export {
|
|
73
|
+
export {
|
|
74
|
+
appendListQuery,
|
|
75
|
+
appendListQueryFromPath,
|
|
76
|
+
readURLState,
|
|
77
|
+
sanitizeListQueryParams,
|
|
78
|
+
writeURLState,
|
|
79
|
+
} from './url-sync';
|
|
74
80
|
export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync, createFeatureGate } from './permissions.svelte';
|
|
75
81
|
export { useLive, useSubscription, usePublish } from './live.svelte';
|
|
76
|
-
export { toast, getToastQueue, consumeToastQueue, getPromiseQueue, consumePromiseQueue, resetToast } from './toast.svelte';
|
|
82
|
+
export { toast, getToastQueue, consumeToastQueue, getPromiseQueue, consumePromiseQueue, getToasts, removeToast, resetToast } from './toast.svelte';
|
|
77
83
|
export {
|
|
78
84
|
notify,
|
|
79
85
|
notifyWithProvider,
|
|
@@ -120,7 +126,7 @@ export type { AdminOptions, TextTransformers, OvertimeConfig } from './options.s
|
|
|
120
126
|
export { checkError } from './hook-utils.svelte';
|
|
121
127
|
export type { NotificationConfig, OvertimeOptions, OvertimeResult, LiveSubscriptionParams } from './hook-utils.svelte';
|
|
122
128
|
|
|
123
|
-
export { HttpError, UndoError } from './types';
|
|
129
|
+
export { DeleteManyPartialError, HttpError, UndoError } from './types';
|
|
124
130
|
export type {
|
|
125
131
|
DataProvider, AuthProvider, NotificationProvider, MutationMode,
|
|
126
132
|
ValidationErrors, HttpErrorOptions, CrudOperator, LogicalFilter, FieldFilter,
|
|
@@ -189,6 +195,7 @@ export { TableState } from './table-state.svelte';
|
|
|
189
195
|
export type { TableStateOptions } from './table-state.svelte';
|
|
190
196
|
export type { UseInfiniteListOptions } from './hooks.svelte';
|
|
191
197
|
export type { UseSelectOptions } from './hooks.svelte';
|
|
198
|
+
export type { UseDeleteManyOptions, UseDeleteManyMutateParams } from './hooks.svelte';
|
|
192
199
|
export type { UseFormReturn } from './form-hooks.svelte';
|
|
193
200
|
export type {
|
|
194
201
|
TaskProvider,
|
|
@@ -91,7 +91,7 @@ interface MutationContext {
|
|
|
91
91
|
previousQueries?: [readonly unknown[], unknown][];
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
interface MutationCallbacks {
|
|
94
|
+
export interface MutationCallbacks {
|
|
95
95
|
onMutate?: (variables: unknown) => unknown | Promise<unknown>;
|
|
96
96
|
onSuccess?: (data: unknown, variables: unknown, context: unknown) => void;
|
|
97
97
|
onError?: (error: unknown, variables: unknown, context: unknown) => void;
|
|
@@ -464,8 +464,6 @@ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpEr
|
|
|
464
464
|
|
|
465
465
|
const pk = adminContext.getResource(resName).primaryKey ?? 'id';
|
|
466
466
|
|
|
467
|
-
if (targetId != null) queryClient.removeQueries({ predicate: (q) => dataMatch(q.queryKey, { action: 'one', id: targetId }) });
|
|
468
|
-
|
|
469
467
|
queryClient.setQueriesData({ predicate: (q) => dataMatch(q.queryKey, { action: 'list' }) }, (old: unknown) => {
|
|
470
468
|
if (!old || typeof old !== 'object' || !('data' in old)) return old;
|
|
471
469
|
const o = old as { data: Record<string, unknown>[]; total?: number };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { captureAdminContext } from './context.svelte';
|
|
2
2
|
import { useParsed } from './useParsed.svelte';
|
|
3
|
+
import { sanitizeListQueryParams } from './url-sync';
|
|
3
4
|
|
|
4
5
|
// ─── routing-hooks.svelte.ts ───────────────────────────────────
|
|
5
6
|
|
|
@@ -118,13 +119,19 @@ export function useResource(resourceName?: string) {
|
|
|
118
119
|
export function useNavigation() {
|
|
119
120
|
const go = useGo();
|
|
120
121
|
const parsed = useParsed();
|
|
122
|
+
const listQuery = (resource?: string) => {
|
|
123
|
+
const target = resource ?? parsed.resourcePath ?? parsed.resource;
|
|
124
|
+
const targetResource = target?.split('/').filter(Boolean).at(-1);
|
|
125
|
+
if (targetResource && parsed.resource && targetResource !== parsed.resource) return {};
|
|
126
|
+
return sanitizeListQueryParams(parsed.params ?? {});
|
|
127
|
+
};
|
|
121
128
|
|
|
122
129
|
return {
|
|
123
|
-
create: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'create' }),
|
|
124
|
-
edit: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'edit', id: id ?? parsed.id }),
|
|
125
|
-
clone: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'clone', id: id ?? parsed.id }),
|
|
126
|
-
show: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'show', id: id ?? parsed.id }),
|
|
127
|
-
list: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'list' }),
|
|
130
|
+
create: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'create', query: listQuery(resource) }),
|
|
131
|
+
edit: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'edit', id: id ?? parsed.id, query: listQuery(resource) }),
|
|
132
|
+
clone: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'clone', id: id ?? parsed.id, query: listQuery(resource) }),
|
|
133
|
+
show: (resource?: string, id?: string | number) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'show', id: id ?? parsed.id, query: listQuery(resource) }),
|
|
134
|
+
list: (resource?: string) => go({ type: 'push', resource: resource ?? parsed.resourcePath ?? parsed.resource, action: 'list', query: listQuery(resource) }),
|
|
128
135
|
push: (url: string) => go({ to: url, type: 'push' }),
|
|
129
136
|
replace: (url: string) => go({ to: url, type: 'replace' }),
|
|
130
137
|
goBack: useBack(),
|
package/src/types.ts
CHANGED
|
@@ -45,6 +45,25 @@ export class UndoError extends Error {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** Raised when a delete-many fallback removed some records but not all. */
|
|
49
|
+
export class DeleteManyPartialError extends Error {
|
|
50
|
+
readonly succeededIds: (string | number)[];
|
|
51
|
+
readonly failedIds: (string | number)[];
|
|
52
|
+
readonly causes: unknown[];
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
succeededIds: (string | number)[],
|
|
56
|
+
failedIds: (string | number)[],
|
|
57
|
+
causes: unknown[] = [],
|
|
58
|
+
) {
|
|
59
|
+
super(`Delete many partially failed: ${failedIds.length}/${succeededIds.length + failedIds.length} failed`);
|
|
60
|
+
this.name = 'DeleteManyPartialError';
|
|
61
|
+
this.succeededIds = [...succeededIds];
|
|
62
|
+
this.failedIds = [...failedIds];
|
|
63
|
+
this.causes = [...causes];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
48
67
|
// ─── Base Types ───────────────────────────────────────────────
|
|
49
68
|
|
|
50
69
|
export type BaseRecord = Record<string, unknown>;
|
package/src/url-sync.ts
CHANGED
|
@@ -39,6 +39,49 @@ function parseFilters(value: string): Filter[] | undefined {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function positiveIntegerParam(value: string | undefined, max: number): string | undefined {
|
|
43
|
+
if (!value || !/^\d+$/.test(value)) return undefined;
|
|
44
|
+
const parsed = Number(value);
|
|
45
|
+
return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= max ? String(parsed) : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 保留从列表进入 CRUD 页面时可安全恢复的查询状态。
|
|
50
|
+
* 未知参数不会跨页面传播,避免把 detail、tenantId 或敏感参数带入记录路由。
|
|
51
|
+
*/
|
|
52
|
+
export function sanitizeListQueryParams(params: Record<string, string>): Record<string, string> {
|
|
53
|
+
const result: Record<string, string> = {};
|
|
54
|
+
const page = positiveIntegerParam(params['page'], 1_000_000);
|
|
55
|
+
const pageSize = positiveIntegerParam(params['pageSize'], 1_000);
|
|
56
|
+
const sort = params['sort']?.trim();
|
|
57
|
+
const search = params['q'];
|
|
58
|
+
const filters = params['filters'] ? parseFilters(params['filters']) : undefined;
|
|
59
|
+
|
|
60
|
+
if (page) result['page'] = page;
|
|
61
|
+
if (pageSize) result['pageSize'] = pageSize;
|
|
62
|
+
if (sort && sort.length <= 256) {
|
|
63
|
+
result['sort'] = sort;
|
|
64
|
+
if (params['order'] === 'asc' || params['order'] === 'desc') result['order'] = params['order'];
|
|
65
|
+
}
|
|
66
|
+
if (search && search.length <= 1_000) result['q'] = search;
|
|
67
|
+
if (filters) result['filters'] = JSON.stringify(filters);
|
|
68
|
+
if (params['records'] === '1') result['records'] = '1';
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function appendListQuery(path: string, params: Record<string, string>): string {
|
|
74
|
+
const query = new URLSearchParams(sanitizeListQueryParams(params)).toString();
|
|
75
|
+
if (!query) return path;
|
|
76
|
+
return `${path}${path.includes('?') ? '&' : '?'}${query}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function appendListQueryFromPath(path: string, currentPath: string): string {
|
|
80
|
+
const queryString = currentPath.split('?')[1];
|
|
81
|
+
if (!queryString) return path;
|
|
82
|
+
return appendListQuery(path, Object.fromEntries(new URLSearchParams(queryString)));
|
|
83
|
+
}
|
|
84
|
+
|
|
42
85
|
export interface URLState {
|
|
43
86
|
page?: number;
|
|
44
87
|
pageSize?: number;
|
|
@@ -46,6 +89,7 @@ export interface URLState {
|
|
|
46
89
|
sortOrder?: 'asc' | 'desc';
|
|
47
90
|
search?: string;
|
|
48
91
|
filters?: Filter[];
|
|
92
|
+
detailId?: string;
|
|
49
93
|
}
|
|
50
94
|
|
|
51
95
|
export function readURLState(adminContext: AdminContextAccessor = captureAdminContext()): URLState {
|
|
@@ -77,12 +121,16 @@ export function readURLState(adminContext: AdminContextAccessor = captureAdminCo
|
|
|
77
121
|
if (filters) state.filters = filters;
|
|
78
122
|
}
|
|
79
123
|
|
|
124
|
+
const detailId = params['detail'];
|
|
125
|
+
if (detailId) state.detailId = detailId;
|
|
126
|
+
|
|
80
127
|
return state;
|
|
81
128
|
}
|
|
82
129
|
|
|
83
130
|
export function writeURLState(
|
|
84
131
|
state: URLState,
|
|
85
132
|
adminContext: AdminContextAccessor = captureAdminContext(),
|
|
133
|
+
type: 'push' | 'replace' = 'replace',
|
|
86
134
|
): void {
|
|
87
135
|
if (typeof window === 'undefined') return;
|
|
88
136
|
const rp = adminContext.routerProvider;
|
|
@@ -124,6 +172,11 @@ export function writeURLState(
|
|
|
124
172
|
}
|
|
125
173
|
}
|
|
126
174
|
|
|
175
|
+
if ('detailId' in state) {
|
|
176
|
+
if (state.detailId) params['detail'] = state.detailId;
|
|
177
|
+
else delete params['detail'];
|
|
178
|
+
}
|
|
179
|
+
|
|
127
180
|
// Prevent redundant navigation if nothing actually changed
|
|
128
181
|
const qsOld = new URLSearchParams(current.params).toString();
|
|
129
182
|
const qsNew = new URLSearchParams(params).toString();
|
|
@@ -132,7 +185,7 @@ export function writeURLState(
|
|
|
132
185
|
rp.go({
|
|
133
186
|
to: current.pathname,
|
|
134
187
|
query: params,
|
|
135
|
-
type
|
|
188
|
+
type,
|
|
136
189
|
});
|
|
137
190
|
}
|
|
138
191
|
}
|