@svadmin/core 0.52.0 → 0.54.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.
@@ -1,5 +1,9 @@
1
1
  import { definedOptions } from './defined-options';
2
- import { createMutation,createQuery,useQueryClient } from '@tanstack/svelte-query';
2
+ import { onDestroy } from 'svelte';
3
+ import { createMutation,useQueryClient } from '@tanstack/svelte-query';
4
+ import { createSessionQuery } from './session-query.svelte';
5
+ import { supersededQuerySession } from './query-session.svelte';
6
+ import { captureAuthLiveScope } from './auth-hooks.svelte';
3
7
  import { getAdminOptions } from './options.svelte';
4
8
  import { captureAdminContext } from './context.svelte';
5
9
  import type { AdminContextAccessor } from './context.svelte';
@@ -9,7 +13,7 @@ import {
9
13
  import { queryKeyMatches, type QueryKey } from './query-keys';
10
14
  import { replaceReactiveMembers } from './reactive-projection';
11
15
  import { taskQueryParams, withValidatedTaskProvider } from './task-provider';
12
- import type { TaskError } from './task-contract';
16
+ import { TaskError, decodeTaskRecord, decodeTaskList } from './task-contract';
13
17
  import { checkError,createOvertimeTracker,fireErrorNotification,fireSuccessNotification } from './hook-utils.svelte';
14
18
  import type { NotificationConfig,OvertimeOptions } from './hook-utils.svelte';
15
19
  import { useTranslation } from './i18n.svelte';
@@ -48,6 +52,19 @@ function scopedTaskKey(key: QueryKey, provider: TaskProvider | undefined): Query
48
52
  return [{ ...key[0], params: definedOptions({ taskProvider: providerKey, query: key[0].params }) }];
49
53
  }
50
54
 
55
+ function taskReadError(error: unknown): Error {
56
+ try {
57
+ if (typeof error === 'object' && error !== null) {
58
+ const code: unknown = Object.getOwnPropertyDescriptor(error, 'code')?.value;
59
+ if (code === 'QUERY_SESSION_SUPERSEDED') return supersededQuerySession();
60
+ if (code === 'INVALID_TASK_INPUT' || code === 'INVALID_TASK_RESPONSE' || code === 'TASK_PROVIDER_FAILED') {
61
+ return new TaskError(code);
62
+ }
63
+ }
64
+ } catch { /* 不执行错误对象中的 getter,也不回显上游诊断。 */ }
65
+ return new TaskError('TASK_PROVIDER_FAILED');
66
+ }
67
+
51
68
  export interface UseSubmitTaskOptions {
52
69
  mutationOptions?: {
53
70
  onSuccess?: (data: unknown,variables: unknown,context: unknown) => void;
@@ -70,29 +87,74 @@ export function useSubmitTask(options: UseSubmitTaskOptions={}) {
70
87
  const adminOptions=getAdminOptions();
71
88
  const queryClient=useQueryClient();
72
89
  const overtime=createOvertimeTracker(() => mutation.isPending,options.overtimeOptions??adminOptions.overtime);
73
- const submissionScopes = new WeakMap<TaskHandle, ReturnType<AdminContextAccessor['queryKeyMatcher']>>();
90
+ let disposed = false;
91
+ onDestroy(() => { disposed = true; });
92
+ interface SubmissionScope {
93
+ queryScope: ReturnType<AdminContextAccessor['queryKeyMatcher']>;
94
+ auth: ReturnType<typeof captureAuthLiveScope>;
95
+ notification: AdminContextAccessor['notificationProvider'];
96
+ current: () => boolean;
97
+ }
98
+ const submissionScopes = new WeakMap<TaskHandle, SubmissionScope>();
99
+ const failureScopes = new WeakMap<Error, SubmissionScope>();
74
100
 
75
101
  const mutation=createMutation<TaskHandle,Error,UseSubmitTaskMutateParams>(() => ({
76
102
  mutationFn: async (params) => {
77
- const scope = adminContext.queryKeyMatcher();
78
- const provider=normalizeTaskProvider(params.taskProvider,adminContext);
79
- const providerMeta=adminContext.tenant
80
- ? {
81
- ...(params.options?.meta??{}),
82
- ...resolveTenantProviderMeta(adminContext.tenant,adminContext.tenantAdapter),
83
- }
84
- :params.options?.meta;
85
- const taskOptions=providerMeta===params.options?.meta
86
- ? params.options
87
- :definedOptions({
88
- ...params.options,
89
- meta: providerMeta,
90
- });
91
- const handle = await provider.submit(params.taskName,taskOptions);
92
- submissionScopes.set(handle, scope);
93
- return handle;
103
+ const authProvider = adminContext.authProvider;
104
+ const auth = captureAuthLiveScope(authProvider);
105
+ const source = params.taskProvider ?? adminContext.taskProvider;
106
+ const tenant = adminContext.tenantCacheKey?.__svadminTenant;
107
+ const router = adminContext.routerProvider;
108
+ const access = adminContext.accessControlProvider;
109
+ const notification = adminContext.notificationProvider;
110
+ const scope: SubmissionScope = {
111
+ queryScope: adminContext.queryKeyMatcher(),
112
+ auth,
113
+ notification,
114
+ current: () => !disposed && auth.isCurrent() && adminContext.authProvider === authProvider
115
+ && adminContext.tenantCacheKey?.__svadminTenant === tenant
116
+ && adminContext.routerProvider === router && adminContext.accessControlProvider === access
117
+ && adminContext.notificationProvider === notification
118
+ && (params.taskProvider ?? adminContext.taskProvider) === source,
119
+ };
120
+ if (!scope.current()) throw supersededQuerySession();
121
+ try {
122
+ const provider=normalizeTaskProvider(source,adminContext);
123
+ const providerMeta=adminContext.tenant
124
+ ? {
125
+ ...(params.options?.meta??{}),
126
+ ...resolveTenantProviderMeta(adminContext.tenant,adminContext.tenantAdapter),
127
+ }
128
+ :params.options?.meta;
129
+ const taskOptions=providerMeta===params.options?.meta
130
+ ? params.options
131
+ :definedOptions({
132
+ ...params.options,
133
+ meta: providerMeta,
134
+ });
135
+ const handle = await provider.submit(params.taskName,taskOptions);
136
+ submissionScopes.set(handle, scope);
137
+ return handle;
138
+ } catch (error) {
139
+ const failure = error instanceof TaskError ? error : new TaskError('TASK_PROVIDER_FAILED');
140
+ failureScopes.set(failure, scope);
141
+ throw failure;
142
+ }
94
143
  },
95
144
  onSuccess: (data,params,context) => {
145
+ const submission = submissionScopes.get(data);
146
+ if (submission) submissionScopes.delete(data);
147
+ if (!submission) return;
148
+ if (submission.auth.isCurrent()) {
149
+ void queryClient.invalidateQueries({
150
+ predicate: (query) => queryKeyMatches(query.queryKey,{
151
+ ...submission.queryScope,
152
+ kind: 'task',
153
+ action: 'list',
154
+ }),
155
+ });
156
+ }
157
+ if (!submission.current()) return;
96
158
  fireSuccessNotification({
97
159
  config: params.successNotification,
98
160
  defaultMessage: i18n.t('task.submitSuccess'),
@@ -100,34 +162,27 @@ export function useSubmitTask(options: UseSubmitTaskOptions={}) {
100
162
  values: params.options,
101
163
  resource: params.taskName,
102
164
  ...definedOptions({
103
- provider: adminContext.notificationProvider
165
+ provider: submission.notification
104
166
  })
105
167
  });
106
- const scope = submissionScopes.get(data);
107
- if (scope) {
108
- submissionScopes.delete(data);
109
- void queryClient.invalidateQueries({
110
- predicate: (query) => queryKeyMatches(query.queryKey,{
111
- ...scope,
112
- kind: 'task',
113
- action: 'list',
114
- }),
115
- });
116
- }
117
- options.mutationOptions?.onSuccess?.(data,params,context);
168
+ if (submission.current()) options.mutationOptions?.onSuccess?.(data,params,context);
118
169
  },
119
170
  onError: (error,params,context) => {
171
+ const submission = failureScopes.get(error);
172
+ failureScopes.delete(error);
173
+ if (!submission?.current()) return;
120
174
  checkError(error,adminContext);
175
+ if (!submission.current()) return;
121
176
  fireErrorNotification({
122
177
  config: params.errorNotification,
123
178
  defaultMessage: i18n.t('task.submitFailed'),
124
179
  error,
125
180
  resource: params.taskName,
126
181
  ...definedOptions({
127
- provider: adminContext.notificationProvider
182
+ provider: submission.notification
128
183
  })
129
184
  });
130
- options.mutationOptions?.onError?.(error,params,context);
185
+ if (submission.current()) options.mutationOptions?.onError?.(error,params,context);
131
186
  },
132
187
  }));
133
188
 
@@ -157,12 +212,17 @@ export function useTask(
157
212
  const i18n=useTranslation();
158
213
  const adminOptions=getAdminOptions();
159
214
 
160
- const query=createQuery<TaskRecord,Error>(() => {
215
+ const query=createSessionQuery<TaskRecord>(adminContext, () => {
161
216
  const activeProvider=options.taskProvider??adminContext.taskProvider;
162
217
  const provider=activeProvider? withValidatedTaskProvider(activeProvider):undefined;
163
218
  const taskId=options.taskId;
164
219
  const queryOptions=options.queryOptions;
165
220
  return {
221
+ resource: taskId ?? '',
222
+ successNotification: options.successNotification,
223
+ errorNotification: options.errorNotification ?? i18n.t('task.fetchFailed'),
224
+ normalizeError: taskReadError,
225
+ decode: value => decodeTaskRecord(value, taskId),
166
226
  queryKey: scopedTaskKey(adminContext.queryKeys().task.one(taskId??''),provider),
167
227
  queryFn: async () => {
168
228
  if(!provider) throw new Error('TaskProvider not found.');
@@ -184,37 +244,6 @@ export function useTask(
184
244
 
185
245
  const overtime=createOvertimeTracker(() => query.isLoading,options.overtimeOptions??adminOptions.overtime);
186
246
 
187
- let lastSuccessAt=0;
188
- let lastErrorAt=0;
189
- $effect(() => {
190
- if(query.isSuccess&&query.dataUpdatedAt>lastSuccessAt) {
191
- lastSuccessAt=query.dataUpdatedAt;
192
- if(options.successNotification) {
193
- fireSuccessNotification({
194
- config: options.successNotification,
195
- defaultMessage: '',
196
- data: query.data,
197
- ...definedOptions({ resource: options.taskId }),
198
- ...definedOptions({
199
- provider: adminContext.notificationProvider
200
- })
201
- });
202
- }
203
- } else if(query.isError&&query.errorUpdatedAt>lastErrorAt) {
204
- lastErrorAt=query.errorUpdatedAt;
205
- checkError(query.error,adminContext);
206
- fireErrorNotification({
207
- config: options.errorNotification,
208
- defaultMessage: i18n.t('task.fetchFailed'),
209
- error: query.error,
210
- ...definedOptions({ resource: options.taskId }),
211
- ...definedOptions({
212
- provider: adminContext.notificationProvider
213
- })
214
- });
215
- }
216
- });
217
-
218
247
  return replaceReactiveMembers(query,{ get overtime() { return overtime; } });
219
248
  }
220
249
 
@@ -242,25 +271,46 @@ export function useTaskList(
242
271
  const i18n=useTranslation();
243
272
  const adminOptions=getAdminOptions();
244
273
 
245
- const query=createQuery<TaskListResult,Error>(() => {
274
+ const query=createSessionQuery<TaskListResult>(adminContext, () => {
246
275
  const activeProvider=options.taskProvider??adminContext.taskProvider;
247
276
  const provider=activeProvider? withValidatedTaskProvider(activeProvider):undefined;
248
277
  const params=options.params===undefined? undefined:taskQueryParams(options.params);
278
+ const callerMeta = params?.['meta'];
279
+ if (callerMeta !== undefined
280
+ && (typeof callerMeta !== 'object' || callerMeta === null || Array.isArray(callerMeta))) {
281
+ throw new TaskError('INVALID_TASK_INPUT');
282
+ }
283
+ const tenantMeta = adminContext.tenant
284
+ ? resolveTenantProviderMeta(adminContext.tenant, adminContext.tenantAdapter)
285
+ : undefined;
286
+ const providerParams = params === undefined && tenantMeta === undefined ? undefined : {
287
+ ...(params ?? {}),
288
+ ...(tenantMeta ? { meta: { ...(callerMeta ?? {}), ...tenantMeta } } : {}),
289
+ };
249
290
  const dlq=options.dlq??false;
250
291
  const queryOptions=options.queryOptions;
292
+ const resource = dlq ? 'taskDlq' : 'tasks';
293
+ const successNotification = options.successNotification;
251
294
  return {
295
+ resource,
296
+ successNotification: typeof successNotification === 'function'
297
+ ? (data: unknown) => successNotification(data, params, resource)
298
+ : successNotification,
299
+ errorNotification: options.errorNotification ?? i18n.t('task.fetchListFailed'),
300
+ normalizeError: taskReadError,
301
+ decode: decodeTaskList,
252
302
  queryKey: scopedTaskKey(adminContext.queryKeys().task.list(definedOptions({
253
303
  list: dlq? 'dlq':'default',
254
- params,
304
+ params: providerParams,
255
305
  })),provider),
256
306
  queryFn: async () => {
257
307
  if(!provider) throw new Error('TaskProvider not found.');
258
308
  if(dlq) {
259
309
  if(!provider.listDlq) throw new Error('TaskProvider does not implement listDlq');
260
- return provider.listDlq(params);
310
+ return provider.listDlq(providerParams);
261
311
  }
262
312
  if(!provider.list) throw new Error('TaskProvider does not implement list');
263
- return provider.list(params);
313
+ return provider.list(providerParams);
264
314
  },
265
315
  enabled: queryOptions?.enabled??true,
266
316
  ...definedOptions({
@@ -277,38 +327,6 @@ export function useTaskList(
277
327
 
278
328
  const overtime=createOvertimeTracker(() => query.isLoading,options.overtimeOptions??adminOptions.overtime);
279
329
 
280
- let lastSuccessAt=0;
281
- let lastErrorAt=0;
282
- $effect(() => {
283
- if(query.isSuccess&&query.dataUpdatedAt>lastSuccessAt) {
284
- lastSuccessAt=query.dataUpdatedAt;
285
- if(options.successNotification) {
286
- fireSuccessNotification({
287
- config: options.successNotification,
288
- defaultMessage: '',
289
- data: query.data,
290
- values: options.params,
291
- resource: options.dlq? 'taskDlq':'tasks',
292
- ...definedOptions({
293
- provider: adminContext.notificationProvider
294
- })
295
- });
296
- }
297
- } else if(query.isError&&query.errorUpdatedAt>lastErrorAt) {
298
- lastErrorAt=query.errorUpdatedAt;
299
- checkError(query.error,adminContext);
300
- fireErrorNotification({
301
- config: options.errorNotification,
302
- defaultMessage: i18n.t('task.fetchListFailed'),
303
- error: query.error,
304
- resource: options.dlq? 'taskDlq':'tasks',
305
- ...definedOptions({
306
- provider: adminContext.notificationProvider
307
- })
308
- });
309
- }
310
- });
311
-
312
330
  return replaceReactiveMembers(query,{ get overtime() { return overtime; } });
313
331
  }
314
332
 
@@ -328,19 +346,36 @@ export function useTaskSubscription(
328
346
  const enabled=options.enabled??true;
329
347
  if(!enabled) return;
330
348
 
331
- const provider=normalizeTaskProvider(options.taskProvider,adminContext);
349
+ const authProvider = adminContext.authProvider;
350
+ const auth = captureAuthLiveScope(authProvider);
351
+ if (!auth.available) return;
352
+ const source = options.taskProvider ?? adminContext.taskProvider;
353
+ const tenant = adminContext.tenantCacheKey?.__svadminTenant;
354
+ const router = adminContext.routerProvider;
355
+ const access = adminContext.accessControlProvider;
356
+ const taskId = options.taskId;
357
+ const onTask = options.onTask;
358
+ const onError = options.onError;
359
+ let active = true;
360
+ const current = () => active && (options.enabled ?? true) && options.taskId === taskId
361
+ && (options.taskProvider ?? adminContext.taskProvider) === source
362
+ && adminContext.authProvider === authProvider && auth.isCurrent()
363
+ && adminContext.tenantCacheKey?.__svadminTenant === tenant
364
+ && adminContext.routerProvider === router && adminContext.accessControlProvider === access;
365
+ const provider=normalizeTaskProvider(source,adminContext);
332
366
  if(!provider.subscribe) {
333
367
  throw new Error('TaskProvider does not implement subscribe');
334
368
  }
335
369
 
336
- const subscription=provider.subscribe(options.taskId,options.onTask,options.onError);
337
-
338
- if(typeof subscription==='function') {
339
- return subscription;
340
- }
341
-
342
- if(typeof subscription.unsubscribe==='function') {
343
- return () => subscription.unsubscribe();
344
- }
370
+ const subscription=provider.subscribe(taskId,
371
+ task => { if (current()) onTask(task); },
372
+ error => { if (current()) onError?.(error); },
373
+ );
374
+ return () => {
375
+ // 先失效,再退订;上游退订时同步发出的最后一条事件也不能进入新会话。
376
+ active = false;
377
+ if (typeof subscription === 'function') subscription();
378
+ else subscription.unsubscribe();
379
+ };
345
380
  });
346
381
  }
package/src/types.ts CHANGED
@@ -405,12 +405,28 @@ export interface MenuItem {
405
405
  children?: MenuItem[];
406
406
  }
407
407
 
408
+ export interface DateInputOptions {
409
+ min?: string;
410
+ max?: string;
411
+ step?: number;
412
+ /** 仅用于 daterange;其他日期字段由 field.type 决定输入模式。 */
413
+ rangeMode?: 'date' | 'time' | 'datetime';
414
+ disabledDate?: (date: string) => boolean;
415
+ presets?: { label: string; value: { start: string; end: string } }[];
416
+ /** datetime 字段的输出语义;默认保留本地 datetime-local 字符串。 */
417
+ valueMode?: 'civil' | 'instant';
418
+ /** valueMode=instant 时必填的 IANA 时区。 */
419
+ timeZone?: string;
420
+ /** 仅用于 datetime,重复本地时刻默认拒绝,允许明确选择较早或较晚的一次。 */
421
+ disambiguation?: 'reject' | 'earlier' | 'later';
422
+ }
423
+
408
424
  export interface FieldDefinition {
409
425
  key: string;
410
426
  label: string;
411
- type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'multiselect' | 'tags'
427
+ type: 'text' | 'number' | 'boolean' | 'date' | 'time' | 'datetime' | 'daterange' | 'select' | 'multiselect' | 'tags'
412
428
  | 'textarea' | 'richtext' | 'image' | 'images' | 'json' | 'relation' | 'color'
413
- | 'url' | 'email' | 'phone' | 'currency' | 'file' | 'markdown' | 'password' | 'array'
429
+ | 'url' | 'email' | 'phone' | 'currency' | 'percent' | 'file' | 'markdown' | 'password' | 'array'
414
430
  | 'tree-select' | 'treeselect' | 'cascader' | 'transfer' | 'rate' | 'rating' | 'avatar' | 'copy' | 'code';
415
431
  required?: boolean;
416
432
  searchable?: boolean;
@@ -430,6 +446,19 @@ export interface FieldDefinition {
430
446
  changeOnSelect?: boolean;
431
447
  separator?: string;
432
448
  defaultValue?: unknown;
449
+ // Numeric input contract
450
+ min?: number;
451
+ max?: number;
452
+ step?: number;
453
+ precision?: number;
454
+ currency?: string;
455
+ locale?: string;
456
+ scale?: '100' | '1';
457
+ // 日期输入策略与数值上下限分离,不隐式转换时区。
458
+ dateInput?: DateInputOptions;
459
+ // File upload contract
460
+ accept?: string;
461
+ maxSize?: number;
433
462
  // Relation support
434
463
  resource?: string; // related resource name
435
464
  optionLabel?: string; // field to use as label