@svadmin/core 0.52.0 → 0.53.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.52.0",
3
+ "version": "0.53.0",
4
4
  "author": "zuohuadong",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,9 +18,13 @@ const baseLocales: Record<string, Record<string, string>> = {
18
18
  'common.moveUp': '上移',
19
19
  'common.moveDown': '下移',
20
20
  'common.treeSelect': '树形选择',
21
+ 'tree.toggleChildren': '展开或折叠子节点',
22
+ 'tree.loadFailed': '子节点加载失败',
21
23
  'common.cascader': '级联选择',
22
24
  'common.transfer': '穿梭框',
23
25
  'common.filterBuilder': '高级筛选',
26
+ 'filter.incomplete': '请补全筛选条件后再应用。',
27
+ 'filter.readonly': '只读筛选条件',
24
28
  'common.addRule': '添加筛选条件',
25
29
  'common.sourceList': '源列表',
26
30
  'common.targetList': '目标列表',
@@ -716,9 +720,13 @@ const baseLocales: Record<string, Record<string, string>> = {
716
720
  'common.moveUp': 'Move Up',
717
721
  'common.moveDown': 'Move Down',
718
722
  'common.treeSelect': 'Tree Select',
723
+ 'tree.toggleChildren': 'Expand or collapse children',
724
+ 'tree.loadFailed': 'Failed to load child nodes',
719
725
  'common.cascader': 'Cascader',
720
726
  'common.transfer': 'Transfer',
721
727
  'common.filterBuilder': 'Filter Builder',
728
+ 'filter.incomplete': 'Complete the filter conditions before applying.',
729
+ 'filter.readonly': 'Read-only filter condition',
722
730
  'common.addRule': 'Add Filter Rule',
723
731
  'common.sourceList': 'Source',
724
732
  'common.targetList': 'Target',
package/src/inferencer.ts CHANGED
@@ -351,6 +351,15 @@ export function generateTypeBoxSchemaCode(resource: ResourceDefinition): string
351
351
  case 'date':
352
352
  typeDef = "Type.String({ format: 'date-time' })";
353
353
  break;
354
+ case 'datetime':
355
+ case 'time':
356
+ // 原生输入没有强制时区,不把本地时间误标为 RFC3339 date-time。
357
+ typeDef = 'Type.String()';
358
+ break;
359
+ case 'daterange':
360
+ // 与 DateRangeInput 一致:两个端点明确存在,未选择时为 null。
361
+ typeDef = 'Type.Union([Type.Object({ start: Type.Union([Type.String(), Type.Null()]), end: Type.Union([Type.String(), Type.Null()]) }, { additionalProperties: false }), Type.Null()])';
362
+ break;
354
363
  case 'email':
355
364
  typeDef = "Type.String({ format: 'email' })";
356
365
  break;
@@ -26,6 +26,8 @@ export interface QuerySessionOptions {
26
26
  isTargetCurrent?: () => boolean;
27
27
  /** Allow a shared query function to delegate work to another live observer. */
28
28
  allowRetiredQueryFn?: boolean;
29
+ /** Internal domain adapter; must return a sanitized error without provider diagnostics. */
30
+ normalizeError?: (error: unknown) => Error;
29
31
  }
30
32
 
31
33
  export function supersededQuerySession(): HttpError {
@@ -88,6 +90,8 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
88
90
  origin.provider === context.authProvider && origin.tenant === context.tenantCacheKey?.__svadminTenant &&
89
91
  origin.router === context.routerProvider && (origin.isTargetCurrent === undefined || origin.isTargetCurrent() === true);
90
92
  const current = (origin: Origin) => targetCurrent(origin) && sessionCurrent(origin);
93
+ const errorFor = (origin: Origin, error: unknown): Error =>
94
+ origin.normalizeError ? origin.normalizeError(error) : querySessionError(error);
91
95
 
92
96
  function notify(origin: Origin, type: 'success' | 'error', value: unknown): void {
93
97
  try {
@@ -125,7 +129,7 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
125
129
  });
126
130
  return;
127
131
  }
128
- const error = querySessionError(result.error);
132
+ const error = errorFor(origin, result.error);
129
133
  query.setState({
130
134
  error,
131
135
  errorUpdatedAt: now,
@@ -154,7 +158,7 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
154
158
  throw supersededQuerySession();
155
159
  }
156
160
  if (!current(origin)) settleDetached(origin, { success: false, error });
157
- throw querySessionError(error);
161
+ throw errorFor(origin, error);
158
162
  }
159
163
  }
160
164
 
@@ -174,7 +178,7 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
174
178
  untrack(() => {
175
179
  void handleAuthError(querySessionError(result.error), context, () => targetCurrent(origin),
176
180
  () => clearAuthQueries(client, origin.provider), origin.auth).catch(() => {});
177
- notify(origin, 'error', querySessionError(result.error));
181
+ notify(origin, 'error', errorFor(origin, result.error));
178
182
  });
179
183
  }
180
184
  });
@@ -186,12 +190,12 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
186
190
  if (query?.getObserversCount() === 0) void query.cancel();
187
191
  });
188
192
  });
189
- return { get options() { return options; }, client, current, sessionCurrent, run, observe };
193
+ return { get options() { return options; }, client, current, sessionCurrent, run, observe, errorFor };
190
194
  }
191
195
 
192
196
  export function pendingQueryResult<T, F extends (settings?: RefetchOptions) => Promise<unknown> = QueryObserverResult<T, unknown>['refetch']>(
193
197
  refetch: F,
194
- ): Omit<QueryObserverPendingResult<T, unknown>, 'refetch'> & { refetch: F } {
198
+ ): Omit<QueryObserverPendingResult<T, Error>, 'refetch'> & { refetch: F } {
195
199
  return {
196
200
  data: undefined, dataUpdatedAt: 0, error: null, errorUpdatedAt: 0,
197
201
  failureCount: 0, failureReason: null, errorUpdateCount: 0,
@@ -3,7 +3,7 @@ import { tick, untrack } from 'svelte';
3
3
  import type { AdminContextAccessor } from './context.svelte';
4
4
  import { definedOptions } from './defined-options';
5
5
  import {
6
- createQuerySession, createQueryResultView, pendingQueryResult, querySessionError, supersededQuerySession,
6
+ createQuerySession, createQueryResultView, pendingQueryResult, supersededQuerySession,
7
7
  type QuerySessionOptions,
8
8
  } from './query-session.svelte';
9
9
 
@@ -14,17 +14,28 @@ interface SessionQueryOptions<TQuery, T> extends QuerySessionOptions {
14
14
  staleTime?: number;
15
15
  gcTime?: number;
16
16
  refetchOnWindowFocus?: boolean;
17
+ refetchInterval?: number | false;
18
+ refetchIntervalInBackground?: boolean;
17
19
  }
18
20
 
21
+ // 只有显式提供错误归一化器的内部领域查询才承诺 Error;普通查询保留 unknown 契约。
22
+ export function createSessionQuery<TQuery, T = TQuery>(
23
+ context: AdminContextAccessor,
24
+ getOptions: () => SessionQueryOptions<TQuery, T> & { normalizeError: (error: unknown) => Error },
25
+ ): QueryObserverResult<T, Error>;
26
+ export function createSessionQuery<TQuery, T = TQuery>(
27
+ context: AdminContextAccessor,
28
+ getOptions: () => SessionQueryOptions<TQuery, T>,
29
+ ): QueryObserverResult<T, unknown>;
19
30
  /** Ordinary reads keep their public result shape while cache and effects own an auth revision. */
20
31
  export function createSessionQuery<TQuery, T = TQuery>(
21
32
  context: AdminContextAccessor,
22
33
  getOptions: () => SessionQueryOptions<TQuery, T>,
23
- ): QueryObserverResult<T, unknown> {
34
+ ): QueryObserverResult<T, Error> {
24
35
  const session = createQuerySession(context, getOptions);
25
36
  const inFlight = new Map<string, { origin: typeof session.options; request: Promise<TQuery> }>();
26
37
  type Origin = typeof session.options;
27
- const query = createQuery<TQuery, unknown>(() => {
38
+ const query = createQuery<TQuery, Error>(() => {
28
39
  const origin = session.options;
29
40
  return {
30
41
  queryKey: origin.queryKey,
@@ -45,39 +56,43 @@ export function createSessionQuery<TQuery, T = TQuery>(
45
56
  enabled: origin.enabled && origin.auth.available,
46
57
  ...definedOptions({
47
58
  staleTime: origin.staleTime, gcTime: origin.gcTime, refetchOnWindowFocus: origin.refetchOnWindowFocus,
59
+ refetchInterval: origin.refetchInterval, refetchIntervalInBackground: origin.refetchIntervalInBackground,
48
60
  }),
49
61
  };
50
62
  });
51
63
 
52
- function project(origin: Origin, result: QueryObserverResult<TQuery, unknown>): QueryObserverResult<T, unknown> {
53
- const refetch = async (settings?: RefetchOptions): Promise<QueryObserverResult<T, unknown>> => {
64
+ function project(origin: Origin, result: QueryObserverResult<TQuery, Error>): QueryObserverResult<T, Error> {
65
+ const refetch = async (settings?: RefetchOptions): Promise<QueryObserverResult<T, Error>> => {
54
66
  if (!session.current(origin)) return pendingQueryResult(refetch);
55
67
  await tick();
56
68
  if (!session.current(origin)) return pendingQueryResult(refetch);
57
69
  try {
58
70
  return project(origin, await query.refetch(settings));
59
71
  } catch (error) {
60
- throw session.current(origin) ? querySessionError(error) : supersededQuerySession();
72
+ throw session.current(origin) ? session.errorFor(origin, error) : supersededQuerySession();
61
73
  }
62
74
  };
63
75
  if (!session.current(origin)) return pendingQueryResult(refetch);
76
+ // 新查询键尚未入缓存时也要订阅结果,否则请求完成后视图不会重新计算。
77
+ const observedData = result.data;
78
+ const observedError = result.error;
64
79
  const state = session.client.getQueryState(origin.queryKey);
65
80
  // The observer may still expose its previous key until Svelte flushes its effects.
66
- if (!state || state.data !== result.data || state.error !== result.error) return pendingQueryResult(refetch);
67
- const failureReason = result.failureReason === null ? null : querySessionError(result.failureReason);
81
+ if (!state || state.data !== observedData || state.error !== observedError) return pendingQueryResult(refetch);
82
+ const failureReason = result.failureReason === null ? null : session.errorFor(origin, result.failureReason);
68
83
  try {
69
84
  if (result.isSuccess || result.isRefetchError) {
70
85
  const data = untrack(() => origin.decode(result.data, state.dataUpdateCount));
71
86
  if (!session.current(origin)) return pendingQueryResult(refetch);
72
87
  if (result.isSuccess) return { ...result, data, failureReason, refetch };
73
- return { ...result, data, error: querySessionError(result.error), failureReason, refetch };
88
+ return { ...result, data, error: session.errorFor(origin, result.error), failureReason, refetch };
74
89
  }
75
- if (result.isError) return { ...result, error: querySessionError(result.error), failureReason, refetch };
90
+ if (result.isError) return { ...result, error: session.errorFor(origin, result.error), failureReason, refetch };
76
91
  return { ...result, failureReason, refetch };
77
92
  } catch (error) {
78
93
  if (!session.current(origin)) return pendingQueryResult(refetch);
79
94
  return {
80
- ...pendingQueryResult(refetch), data: undefined, error: querySessionError(error), status: 'error',
95
+ ...pendingQueryResult(refetch), data: undefined, error: session.errorFor(origin, error), status: 'error',
81
96
  isError: true, isPending: false, isLoading: false, isLoadingError: true,
82
97
  isRefetchError: false, isSuccess: false, isPlaceholderData: false,
83
98
  };
@@ -1,5 +1,7 @@
1
1
  import { definedOptions } from './defined-options';
2
- import { createMutation,createQuery,useQueryClient } from '@tanstack/svelte-query';
2
+ import { createMutation,useQueryClient } from '@tanstack/svelte-query';
3
+ import { createSessionQuery } from './session-query.svelte';
4
+ import { supersededQuerySession } from './query-session.svelte';
3
5
  import { getAdminOptions } from './options.svelte';
4
6
  import { captureAdminContext } from './context.svelte';
5
7
  import type { AdminContextAccessor } from './context.svelte';
@@ -9,7 +11,7 @@ import {
9
11
  import { queryKeyMatches, type QueryKey } from './query-keys';
10
12
  import { replaceReactiveMembers } from './reactive-projection';
11
13
  import { taskQueryParams, withValidatedTaskProvider } from './task-provider';
12
- import type { TaskError } from './task-contract';
14
+ import { TaskError, decodeTaskRecord, decodeTaskList } from './task-contract';
13
15
  import { checkError,createOvertimeTracker,fireErrorNotification,fireSuccessNotification } from './hook-utils.svelte';
14
16
  import type { NotificationConfig,OvertimeOptions } from './hook-utils.svelte';
15
17
  import { useTranslation } from './i18n.svelte';
@@ -48,6 +50,19 @@ function scopedTaskKey(key: QueryKey, provider: TaskProvider | undefined): Query
48
50
  return [{ ...key[0], params: definedOptions({ taskProvider: providerKey, query: key[0].params }) }];
49
51
  }
50
52
 
53
+ function taskReadError(error: unknown): Error {
54
+ try {
55
+ if (typeof error === 'object' && error !== null) {
56
+ const code: unknown = Object.getOwnPropertyDescriptor(error, 'code')?.value;
57
+ if (code === 'QUERY_SESSION_SUPERSEDED') return supersededQuerySession();
58
+ if (code === 'INVALID_TASK_INPUT' || code === 'INVALID_TASK_RESPONSE' || code === 'TASK_PROVIDER_FAILED') {
59
+ return new TaskError(code);
60
+ }
61
+ }
62
+ } catch { /* 不执行错误对象中的 getter,也不回显上游诊断。 */ }
63
+ return new TaskError('TASK_PROVIDER_FAILED');
64
+ }
65
+
51
66
  export interface UseSubmitTaskOptions {
52
67
  mutationOptions?: {
53
68
  onSuccess?: (data: unknown,variables: unknown,context: unknown) => void;
@@ -157,12 +172,17 @@ export function useTask(
157
172
  const i18n=useTranslation();
158
173
  const adminOptions=getAdminOptions();
159
174
 
160
- const query=createQuery<TaskRecord,Error>(() => {
175
+ const query=createSessionQuery<TaskRecord>(adminContext, () => {
161
176
  const activeProvider=options.taskProvider??adminContext.taskProvider;
162
177
  const provider=activeProvider? withValidatedTaskProvider(activeProvider):undefined;
163
178
  const taskId=options.taskId;
164
179
  const queryOptions=options.queryOptions;
165
180
  return {
181
+ resource: taskId ?? '',
182
+ successNotification: options.successNotification,
183
+ errorNotification: options.errorNotification ?? i18n.t('task.fetchFailed'),
184
+ normalizeError: taskReadError,
185
+ decode: value => decodeTaskRecord(value, taskId),
166
186
  queryKey: scopedTaskKey(adminContext.queryKeys().task.one(taskId??''),provider),
167
187
  queryFn: async () => {
168
188
  if(!provider) throw new Error('TaskProvider not found.');
@@ -184,37 +204,6 @@ export function useTask(
184
204
 
185
205
  const overtime=createOvertimeTracker(() => query.isLoading,options.overtimeOptions??adminOptions.overtime);
186
206
 
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
207
  return replaceReactiveMembers(query,{ get overtime() { return overtime; } });
219
208
  }
220
209
 
@@ -242,13 +231,22 @@ export function useTaskList(
242
231
  const i18n=useTranslation();
243
232
  const adminOptions=getAdminOptions();
244
233
 
245
- const query=createQuery<TaskListResult,Error>(() => {
234
+ const query=createSessionQuery<TaskListResult>(adminContext, () => {
246
235
  const activeProvider=options.taskProvider??adminContext.taskProvider;
247
236
  const provider=activeProvider? withValidatedTaskProvider(activeProvider):undefined;
248
237
  const params=options.params===undefined? undefined:taskQueryParams(options.params);
249
238
  const dlq=options.dlq??false;
250
239
  const queryOptions=options.queryOptions;
240
+ const resource = dlq ? 'taskDlq' : 'tasks';
241
+ const successNotification = options.successNotification;
251
242
  return {
243
+ resource,
244
+ successNotification: typeof successNotification === 'function'
245
+ ? (data: unknown) => successNotification(data, params, resource)
246
+ : successNotification,
247
+ errorNotification: options.errorNotification ?? i18n.t('task.fetchListFailed'),
248
+ normalizeError: taskReadError,
249
+ decode: decodeTaskList,
252
250
  queryKey: scopedTaskKey(adminContext.queryKeys().task.list(definedOptions({
253
251
  list: dlq? 'dlq':'default',
254
252
  params,
@@ -277,38 +275,6 @@ export function useTaskList(
277
275
 
278
276
  const overtime=createOvertimeTracker(() => query.isLoading,options.overtimeOptions??adminOptions.overtime);
279
277
 
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
278
  return replaceReactiveMembers(query,{ get overtime() { return overtime; } });
313
279
  }
314
280
 
package/src/types.ts CHANGED
@@ -408,9 +408,9 @@ export interface MenuItem {
408
408
  export interface FieldDefinition {
409
409
  key: string;
410
410
  label: string;
411
- type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'multiselect' | 'tags'
411
+ type: 'text' | 'number' | 'boolean' | 'date' | 'time' | 'datetime' | 'daterange' | 'select' | 'multiselect' | 'tags'
412
412
  | 'textarea' | 'richtext' | 'image' | 'images' | 'json' | 'relation' | 'color'
413
- | 'url' | 'email' | 'phone' | 'currency' | 'file' | 'markdown' | 'password' | 'array'
413
+ | 'url' | 'email' | 'phone' | 'currency' | 'percent' | 'file' | 'markdown' | 'password' | 'array'
414
414
  | 'tree-select' | 'treeselect' | 'cascader' | 'transfer' | 'rate' | 'rating' | 'avatar' | 'copy' | 'code';
415
415
  required?: boolean;
416
416
  searchable?: boolean;
@@ -430,6 +430,17 @@ export interface FieldDefinition {
430
430
  changeOnSelect?: boolean;
431
431
  separator?: string;
432
432
  defaultValue?: unknown;
433
+ // Numeric input contract
434
+ min?: number;
435
+ max?: number;
436
+ step?: number;
437
+ precision?: number;
438
+ currency?: string;
439
+ locale?: string;
440
+ scale?: '100' | '1';
441
+ // File upload contract
442
+ accept?: string;
443
+ maxSize?: number;
433
444
  // Relation support
434
445
  resource?: string; // related resource name
435
446
  optionLabel?: string; // field to use as label