@svadmin/core 0.41.0 → 0.42.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/core",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
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': '展开更多',
@@ -203,6 +215,13 @@ const baseLocales: Record<string, Record<string, string>> = {
203
215
  'integrations.statusDisconnected': '未连接',
204
216
  'integrations.enabled': '已启用',
205
217
  'integrations.disabled': '已禁用',
218
+ 'integrations.statusProvidedByHost': '状态由已接入的集成服务提供',
219
+ 'integrations.sourceControl': '代码仓库',
220
+ 'integrations.sourceControlDescription': '同步仓库元数据、部署记录和提交活动。',
221
+ 'integrations.chatOpsDescription': '向工作区频道发送运行告警和审计摘要。',
222
+ 'integrations.identityCloud': '身份云',
223
+ 'integrations.identityCloudDescription': '配置单点登录和工作区身份交接。',
224
+ 'integrations.webhooksDescription': '为内部工具和监控注册出站 Webhook。',
206
225
  'integrations.addMoreHint': '需要自定义集成?请联系管理员注册新的应用程序。',
207
226
  'notifications.emailTitle': '邮件通知',
208
227
  'notifications.emailDescription': '将系统自动发送的通知推送到您的电子邮箱。',
@@ -663,6 +682,7 @@ const baseLocales: Record<string, Record<string, string>> = {
663
682
  'common.delete': 'Delete',
664
683
  'common.batchDeletePartialFail': 'Batch delete partially failed: {failed}/{total} unsuccessful',
665
684
  'common.batchDeleteSuccess': 'Successfully deleted {count} records',
685
+ 'common.batchDeleteFailed': 'Batch delete failed; {count} records were not deleted',
666
686
  'common.selectedItems': '{count} selected',
667
687
  'common.clearSelection': 'Clear selection',
668
688
  'common.density': 'Table density',
@@ -686,7 +706,11 @@ const baseLocales: Record<string, Record<string, string>> = {
686
706
  'common.autoSaving': 'Auto-saving...',
687
707
  'common.autoSaved': 'Saved',
688
708
  'common.selectAll': 'Select All',
709
+ 'common.selectRow': 'Select record {id}',
689
710
  'common.batchDelete': 'Batch Delete ({count})',
711
+ 'common.selectedCount': '{count} records selected',
712
+ 'common.processing': 'Processing...',
713
+ 'common.openFullDetail': 'Full details',
690
714
  'common.unsavedChanges': 'You have unsaved changes. Leave anyway?',
691
715
  'common.unsaved': 'Unsaved',
692
716
  'common.deleteConfirm': 'Are you sure you want to delete this record? This cannot be undone.',
@@ -694,9 +718,11 @@ const baseLocales: Record<string, Record<string, string>> = {
694
718
  'common.confirmAction': 'Confirm Action',
695
719
  'common.operationSuccess': 'Operation successful',
696
720
  'common.operationFailed': 'Operation failed',
721
+ 'common.accessDenied': 'You do not have access to this record',
697
722
  'common.createSuccess': 'Created successfully',
698
723
  'common.updateSuccess': 'Updated successfully',
699
724
  'common.deleteSuccess': 'Deleted successfully',
725
+ 'common.actionCanBeUndone': 'Action submitted. You can undo it before the countdown ends.',
700
726
  'common.loginFailed': 'Login failed',
701
727
  'common.home': 'Home',
702
728
  'common.logout': 'Log out',
@@ -731,6 +757,11 @@ const baseLocales: Record<string, Record<string, string>> = {
731
757
  'common.back': 'Back',
732
758
  'common.step': 'Step',
733
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',
734
765
  'common.perPage': 'per page',
735
766
  'common.copyId': 'Copy ID',
736
767
  'common.showMore': 'Show more',
@@ -844,6 +875,13 @@ const baseLocales: Record<string, Record<string, string>> = {
844
875
  'integrations.statusDisconnected': 'Disconnected',
845
876
  'integrations.enabled': 'Enabled',
846
877
  'integrations.disabled': 'Disabled',
878
+ 'integrations.statusProvidedByHost': 'Status is provided by the connected integration service',
879
+ 'integrations.sourceControl': 'Source Control',
880
+ 'integrations.sourceControlDescription': 'Sync repository metadata, deployments, and commit activity.',
881
+ 'integrations.chatOpsDescription': 'Send operational alerts and audit summaries to workspace channels.',
882
+ 'integrations.identityCloud': 'Identity Cloud',
883
+ 'integrations.identityCloudDescription': 'Configure SSO and workspace identity handoff.',
884
+ 'integrations.webhooksDescription': 'Register outbound webhooks for internal tools and monitors.',
847
885
  'integrations.addMoreHint': 'Need custom integrations? Contact your administrator to register new application manifests.',
848
886
  'notifications.emailTitle': 'Email Notifications',
849
887
  'notifications.emailDescription': 'Get automated system messages delivered directly to your mailbox.',
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>;
@@ -458,7 +477,7 @@ export interface FieldDefinition {
458
477
  label: string;
459
478
  type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'multiselect' | 'tags'
460
479
  | 'textarea' | 'richtext' | 'image' | 'images' | 'json' | 'relation' | 'color'
461
- | 'url' | 'email' | 'phone' | 'file' | 'markdown' | 'password' | 'array';
480
+ | 'url' | 'email' | 'phone' | 'currency' | 'file' | 'markdown' | 'password' | 'array';
462
481
  required?: boolean;
463
482
  searchable?: boolean;
464
483
  sortable?: boolean;
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
  }