@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 +1 -1
- package/src/form-hooks.svelte.ts +6 -4
- package/src/hooks.svelte.ts +224 -23
- package/src/i18n.svelte.ts +39 -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 +20 -1
- 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': '展开更多',
|
|
@@ -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 {
|
|
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
|
|
188
|
+
type,
|
|
136
189
|
});
|
|
137
190
|
}
|
|
138
191
|
}
|