@svadmin/core 0.34.1 → 0.35.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.
@@ -2,6 +2,11 @@ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-qu
2
2
  import { getAdminOptions } from './options.svelte';
3
3
  import { captureAdminContext } from './context.svelte';
4
4
  import type { AdminContextAccessor } from './context.svelte';
5
+ import {
6
+ appendTenantCacheKey,
7
+ queryKeyMatchesTenant,
8
+ resolveTenantProviderMeta,
9
+ } from './provider-bundle';
5
10
  import { checkError, createOvertimeTracker, fireErrorNotification, fireSuccessNotification } from './hook-utils.svelte';
6
11
  import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
7
12
  import { useTranslation } from './i18n.svelte';
@@ -54,27 +59,41 @@ export function useSubmitTask<
54
59
  const mutation = createMutation<TaskHandle<TTask>, TError, UseSubmitTaskMutateParams<TTask>>(() => ({
55
60
  mutationFn: async (params) => {
56
61
  const provider = normalizeTaskProvider<TTask>(params.taskProvider, adminContext);
57
- return provider.submit(params.taskName, params.options);
62
+ const providerMeta = adminContext.tenant
63
+ ? {
64
+ ...(params.options?.meta ?? {}),
65
+ ...resolveTenantProviderMeta(adminContext.tenant, adminContext.tenantAdapter),
66
+ }
67
+ : params.options?.meta;
68
+ const taskOptions = providerMeta === params.options?.meta
69
+ ? params.options
70
+ : { ...params.options, meta: providerMeta };
71
+ return provider.submit(params.taskName, taskOptions);
58
72
  },
59
73
  onSuccess: (data, params, context) => {
60
- fireSuccessNotification(
61
- params.successNotification,
62
- i18n.t('task.submitSuccess'),
74
+ fireSuccessNotification({
75
+ config: params.successNotification,
76
+ defaultMessage: i18n.t('task.submitSuccess'),
63
77
  data,
64
- params.options,
65
- params.taskName,
66
- );
67
- void queryClient.invalidateQueries({ queryKey: ['taskList'] });
78
+ values: params.options,
79
+ resource: params.taskName,
80
+ provider: adminContext.notificationProvider,
81
+ });
82
+ void queryClient.invalidateQueries({
83
+ predicate: (query) => query.queryKey[0] === 'taskList'
84
+ && queryKeyMatchesTenant(query.queryKey, adminContext.tenantCacheKey),
85
+ });
68
86
  options.mutationOptions?.onSuccess?.(data, params, context);
69
87
  },
70
88
  onError: (error, params, context) => {
71
89
  checkError(error, adminContext);
72
- fireErrorNotification(
73
- params.errorNotification,
74
- i18n.t('task.submitFailed'),
90
+ fireErrorNotification({
91
+ config: params.errorNotification,
92
+ defaultMessage: i18n.t('task.submitFailed'),
75
93
  error,
76
- params.taskName,
77
- );
94
+ resource: params.taskName,
95
+ provider: adminContext.notificationProvider,
96
+ });
78
97
  options.mutationOptions?.onError?.(error, params, context);
79
98
  },
80
99
  }));
@@ -109,7 +128,7 @@ export function useTask<TTask extends TaskRecord = TaskRecord, TError = HttpErro
109
128
  const provider = normalizeTaskProvider<TTask>(options.taskProvider, adminContext);
110
129
  const queryOptions = options.queryOptions;
111
130
  return {
112
- queryKey: ['task', options.taskId],
131
+ queryKey: appendTenantCacheKey(['task', options.taskId], adminContext.tenantCacheKey),
113
132
  queryFn: async () => {
114
133
  if (!options.taskId) throw new Error('useTask requires a taskId');
115
134
  return provider.get(options.taskId);
@@ -131,12 +150,24 @@ export function useTask<TTask extends TaskRecord = TaskRecord, TError = HttpErro
131
150
  if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
132
151
  lastSuccessAt = query.dataUpdatedAt;
133
152
  if (options.successNotification) {
134
- fireSuccessNotification(options.successNotification, '', query.data, undefined, options.taskId);
153
+ fireSuccessNotification({
154
+ config: options.successNotification,
155
+ defaultMessage: '',
156
+ data: query.data,
157
+ resource: options.taskId,
158
+ provider: adminContext.notificationProvider,
159
+ });
135
160
  }
136
161
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
137
162
  lastErrorAt = query.errorUpdatedAt;
138
163
  checkError(query.error, adminContext);
139
- fireErrorNotification(options.errorNotification, i18n.t('task.fetchFailed'), query.error, options.taskId);
164
+ fireErrorNotification({
165
+ config: options.errorNotification,
166
+ defaultMessage: i18n.t('task.fetchFailed'),
167
+ error: query.error,
168
+ resource: options.taskId,
169
+ provider: adminContext.notificationProvider,
170
+ });
140
171
  }
141
172
  });
142
173
 
@@ -176,7 +207,10 @@ export function useTaskList<TTask extends TaskRecord = TaskRecord, TError = Http
176
207
  const provider = normalizeTaskProvider<TTask>(options.taskProvider, adminContext);
177
208
  const queryOptions = options.queryOptions;
178
209
  return {
179
- queryKey: ['taskList', options.dlq ? 'dlq' : 'default', options.params],
210
+ queryKey: appendTenantCacheKey(
211
+ ['taskList', options.dlq ? 'dlq' : 'default', options.params],
212
+ adminContext.tenantCacheKey,
213
+ ),
180
214
  queryFn: async () => {
181
215
  if (options.dlq) {
182
216
  if (!provider.listDlq) throw new Error('TaskProvider does not implement listDlq');
@@ -202,12 +236,25 @@ export function useTaskList<TTask extends TaskRecord = TaskRecord, TError = Http
202
236
  if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt) {
203
237
  lastSuccessAt = query.dataUpdatedAt;
204
238
  if (options.successNotification) {
205
- fireSuccessNotification(options.successNotification, '', query.data, options.params, options.dlq ? 'taskDlq' : 'tasks');
239
+ fireSuccessNotification({
240
+ config: options.successNotification,
241
+ defaultMessage: '',
242
+ data: query.data,
243
+ values: options.params,
244
+ resource: options.dlq ? 'taskDlq' : 'tasks',
245
+ provider: adminContext.notificationProvider,
246
+ });
206
247
  }
207
248
  } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
208
249
  lastErrorAt = query.errorUpdatedAt;
209
250
  checkError(query.error, adminContext);
210
- fireErrorNotification(options.errorNotification, i18n.t('task.fetchListFailed'), query.error, options.dlq ? 'taskDlq' : 'tasks');
251
+ fireErrorNotification({
252
+ config: options.errorNotification,
253
+ defaultMessage: i18n.t('task.fetchListFailed'),
254
+ error: query.error,
255
+ resource: options.dlq ? 'taskDlq' : 'tasks',
256
+ provider: adminContext.notificationProvider,
257
+ });
211
258
  }
212
259
  });
213
260
 
package/src/types.ts CHANGED
@@ -382,6 +382,28 @@ export type MutationMode = 'pessimistic' | 'optimistic' | 'undoable';
382
382
 
383
383
  // ─── ResourceDefinition ───────────────────────────────────────
384
384
 
385
+ /** 声明资源使用的传输协议;具体 URL/序列化行为由对应 provider 实现。 */
386
+ export interface ResourceTransportConfig {
387
+ readonly type: string;
388
+ readonly endpoint?: string;
389
+ readonly options?: Readonly<Record<string, unknown>>;
390
+ }
391
+
392
+ /** 声明资源使用的后端 adapter,不在 Core 内绑定具体运行时实现。 */
393
+ export interface ResourceAdapterConfig {
394
+ readonly name: string;
395
+ readonly version?: string;
396
+ readonly options?: Readonly<Record<string, unknown>>;
397
+ }
398
+
399
+ /** 每资源的 provider 选择与声明性 transport/adapter 元数据。 */
400
+ export interface ResourceProviderConfig {
401
+ readonly dataProviderName?: string;
402
+ readonly transport?: string | ResourceTransportConfig;
403
+ readonly adapter?: string | ResourceAdapterConfig;
404
+ readonly meta?: Readonly<Record<string, unknown>>;
405
+ }
406
+
385
407
  export interface ResourceDefinition {
386
408
  name: string;
387
409
  /** Unique identifier — use when multiple resources share the same `name` but target different DataProviders */
@@ -401,6 +423,9 @@ export interface ResourceDefinition {
401
423
  menuOrder?: number;
402
424
  /** Navigation group name — resources with the same group are displayed in a collapsible section */
403
425
  group?: string;
426
+ /** DataProvider 选择及声明性 transport/adapter 元数据。 */
427
+ provider?: ResourceProviderConfig;
428
+ /** @deprecated 新代码优先使用 `provider.dataProviderName` 选择 DataProvider。 */
404
429
  meta?: Record<string, unknown> & { dataProviderName?: string; parent?: string };
405
430
  }
406
431
 
package/src/useCan.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // useCan — reactive permission check hook with TanStack Query integration
2
2
 
3
3
  import { createQuery } from '@tanstack/svelte-query';
4
- import { canAccessAsync, getAccessControlProvider } from './permissions.svelte';
4
+ import { captureAdminContext } from './context.svelte';
5
+ import { appendTenantCacheKey } from './provider-bundle';
5
6
  import type { Action, CanResult } from './permissions.svelte';
6
7
 
7
8
  export interface UseCanOptions {
@@ -32,11 +33,24 @@ export interface UseCanResult {
32
33
  * ```
33
34
  */
34
35
  export function useCan(options: () => UseCanOptions): UseCanResult {
36
+ const adminContext = captureAdminContext();
35
37
  const query = createQuery<CanResult>(() => {
36
38
  const opts = options();
39
+ const provider = adminContext.accessControlProvider;
37
40
  return {
38
- queryKey: ['useCan', opts.resource, opts.action, opts.params, opts.meta] as const,
39
- queryFn: () => canAccessAsync(opts.resource, opts.action, opts.params, opts.meta),
41
+ queryKey: appendTenantCacheKey([
42
+ 'useCan', opts.resource, opts.action, opts.params, opts.meta,
43
+ ], adminContext.tenantCacheKey),
44
+ queryFn: async () => {
45
+ if (!provider) return { can: true };
46
+ const result = await provider.can({
47
+ resource: opts.resource,
48
+ action: opts.action,
49
+ params: opts.params,
50
+ meta: adminContext.getProviderMeta(opts.resource, opts.meta),
51
+ });
52
+ return Array.isArray(result) ? (result[0] ?? { can: false }) : result;
53
+ },
40
54
  enabled: opts.queryOptions?.enabled ?? true,
41
55
  staleTime: opts.queryOptions?.staleTime ?? 5 * 60 * 1000,
42
56
  };
@@ -44,7 +58,7 @@ export function useCan(options: () => UseCanOptions): UseCanResult {
44
58
 
45
59
  return {
46
60
  get allowed() {
47
- const p = getAccessControlProvider();
61
+ const p = adminContext.accessControlProvider;
48
62
  if (!p) return true;
49
63
  return (query.data as CanResult | undefined)?.can ?? false;
50
64
  },
@@ -2,6 +2,7 @@ import { useForm, type UseFormOptions } from './form-hooks.svelte';
2
2
  import { useQueryClient } from '@tanstack/svelte-query';
3
3
  import { useParsed } from './useParsed.svelte';
4
4
  import { captureAdminContext } from './context.svelte';
5
+ import { queryKeyMatchesTenant } from './provider-bundle';
5
6
  import { getAdminOptions } from './options.svelte';
6
7
  import { t } from './i18n.svelte';
7
8
  import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
@@ -39,6 +40,7 @@ export function useModalForm<
39
40
  TError = HttpError,
40
41
  >(options: UseModalFormOptions<TVariables, TData, TError> = {} as UseModalFormOptions<TVariables, TData, TError>) {
41
42
  const queryClient = useQueryClient();
43
+ const adminContext = captureAdminContext();
42
44
  const formState = useForm<TVariables, TData, TError>(options);
43
45
  let visible = $state(options.defaultVisible ?? false);
44
46
 
@@ -58,7 +60,8 @@ export function useModalForm<
58
60
  formState.reset();
59
61
  if (options.autoSave?.invalidateOnClose) {
60
62
  const dpN = options.dataProviderName;
61
- const dp = (q: { queryKey: readonly unknown[] }) => q.queryKey[0] === dpN;
63
+ const dp = (q: { queryKey: readonly unknown[] }) =>
64
+ queryKeyMatchesTenant(q.queryKey, adminContext.tenantCacheKey) && q.queryKey[0] === dpN;
62
65
  queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
63
66
  queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && q.queryKey[2] === 'many' });
64
67
  queryClient.invalidateQueries({ predicate: (q) => dp(q) && q.queryKey[1] === formState.resource && q.queryKey[2] === 'one' });
@@ -182,7 +185,33 @@ export function useRelation<TData extends BaseRecord = BaseRecord, TError = Http
182
185
 
183
186
  // ─── useNotification ────────────────────────────────────────
184
187
  export function useNotification() {
185
- return { open: toast.info, success: toast.success, error: toast.error, warning: toast.warning };
188
+ const adminContext = captureAdminContext();
189
+
190
+ function sendToast(
191
+ type: 'success' | 'error' | 'warning' | 'info',
192
+ message: string,
193
+ duration?: number,
194
+ options?: { key?: string },
195
+ ): void {
196
+ const provider = adminContext.notificationProvider;
197
+ if (provider) {
198
+ provider.open({ type, message, key: options?.key });
199
+ return;
200
+ }
201
+ toast[type](message, duration, options);
202
+ }
203
+
204
+ return {
205
+ open: (message: string, duration?: number, options?: { key?: string }) =>
206
+ sendToast('info', message, duration, options),
207
+ success: (message: string, duration?: number, options?: { key?: string }) =>
208
+ sendToast('success', message, duration, options),
209
+ error: (message: string, duration?: number, options?: { key?: string }) =>
210
+ sendToast('error', message, duration, options),
211
+ warning: (message: string, duration?: number, options?: { key?: string }) =>
212
+ sendToast('warning', message, duration, options),
213
+ close: (key: string) => adminContext.notificationProvider?.close(key),
214
+ };
186
215
  }
187
216
 
188
217
  // ─── Field-specific hooks (refine-compatible) ─────────────────────