@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.41.1",
3
+ "version": "0.42.1",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 path = adminContext.currentPath().split('?')[0];
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 ─────────────────────────────────────────────────────
@@ -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 type { BaseRecord, HttpError, Pagination, Sort, Filter, DataProvider, KnownResources } from './types';
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 function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: { resource?: KnownResources; overtimeOptions?: OvertimeOptions } = {}) {
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
- const mutation = createMutation<{ data: TData[] }, TError, { resource?: KnownResources; ids: (string | number)[]; meta?: Record<string, unknown>; dataProviderName?: string }>(() => ({
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 results = await Promise.all(params.ids.map(id => provider.deleteOne<TData>({ resource: resName, id, meta: params.meta })));
509
- return { data: results.map(r => r.data) };
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
- onSuccess: (data, params) => {
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: undefined,
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
- checkError(error, adminContext);
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: undefined,
731
+ config: params.errorNotification ?? options.errorNotification,
530
732
  defaultMessage: 'Delete many failed',
531
733
  error,
532
- resource: params.resource ?? 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: (_d, _e, params) => {
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
- for (const id of params.ids) {
545
- queryClient.removeQueries({
546
- predicate: (q) => queryKeyMatches(q.queryKey, {
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
  }));
@@ -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 { readURLState, writeURLState } from './url-sync';
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: 'replace'
188
+ type,
136
189
  });
137
190
  }
138
191
  }