@svadmin/core 0.22.0 → 0.23.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/src/helpers.ts CHANGED
@@ -4,122 +4,13 @@ import type { Filter, Sort, CrudOperator } from './types';
4
4
  import { t } from './i18n.svelte';
5
5
  import { getTextTransformers } from './options.svelte';
6
6
 
7
- // ─── Table Helpers ────────────────────────────────────────────
7
+ // ─── Re-export pure utilities (testable without Svelte runtime) ───
8
+ export {
9
+ getDefaultFilter, getDefaultSortOrder,
10
+ unionFilters, unionSorters,
11
+ file2Base64, parseCSV,
12
+ } from './helpers-pure';
8
13
 
9
- /**
10
- * Get the current filter value for a field from a filter array.
11
- * Useful for setting default values in filter inputs.
12
- */
13
- export function getDefaultFilter(
14
- columnName: string,
15
- filters?: Filter[],
16
- operatorType: CrudOperator = 'eq',
17
- ): unknown | undefined {
18
- if (!filters) return undefined;
19
- const filter = filters.find((f) => {
20
- if ('field' in f) {
21
- return f.field === columnName && f.operator === operatorType;
22
- }
23
- return false;
24
- });
25
- return filter && 'value' in filter ? filter.value : undefined;
26
- }
27
-
28
- /**
29
- * Get the current sort order for a field from a sorter array.
30
- * Returns 'asc', 'desc', or undefined.
31
- */
32
- export function getDefaultSortOrder(
33
- columnName: string,
34
- sorters?: Sort[],
35
- ): 'asc' | 'desc' | undefined {
36
- if (!sorters) return undefined;
37
- const sortItem = sorters.find((item) => item.field === columnName);
38
- return sortItem?.order as 'asc' | 'desc' | undefined;
39
- }
40
-
41
- /**
42
- * Merge two filter arrays, deduplicating by field+operator.
43
- * Filters in `newFilters` take priority over `prevFilters`.
44
- * `permanentFilter` items are always preserved.
45
- * Filters with undefined values are removed from the result.
46
- */
47
- export function unionFilters(
48
- permanentFilter: Filter[],
49
- newFilters: Filter[],
50
- prevFilters: Filter[] = [],
51
- ): Filter[] {
52
- const compareFilters = (a: Filter, b: Filter): boolean => {
53
- if ('field' in a && 'field' in b) {
54
- return a.field === b.field && a.operator === b.operator;
55
- }
56
- return false;
57
- };
58
-
59
- // Start with previous filters
60
- let result = [...prevFilters];
61
-
62
- // Apply new filters (override previous by field+operator)
63
- for (const newFilter of newFilters) {
64
- const idx = result.findIndex((f) => compareFilters(f, newFilter));
65
- if (idx >= 0) {
66
- result[idx] = newFilter;
67
- } else {
68
- result.push(newFilter);
69
- }
70
- }
71
-
72
- // Ensure permanent filters are always present
73
- for (const perm of permanentFilter) {
74
- const idx = result.findIndex((f) => compareFilters(f, perm));
75
- if (idx >= 0) {
76
- result[idx] = perm;
77
- } else {
78
- result.push(perm);
79
- }
80
- }
81
-
82
- // Remove filters with undefined values
83
- return result.filter((f) => !('value' in f && f.value === undefined));
84
- }
85
-
86
- /**
87
- * Merge two sorter arrays, deduplicating by field.
88
- * Sorters in `newSorters` take priority.
89
- */
90
- export function unionSorters(
91
- permanentSorters: Sort[],
92
- newSorters: Sort[],
93
- ): Sort[] {
94
- const result = [...newSorters];
95
-
96
- for (const perm of permanentSorters) {
97
- if (!result.some((s) => s.field === perm.field)) {
98
- result.push(perm);
99
- }
100
- }
101
-
102
- return result;
103
- }
104
-
105
- // ─── Upload Helpers ───────────────────────────────────────────
106
-
107
- /**
108
- * Convert a File (or file-like object) to a base64 data URL string.
109
- * Useful for image uploads and previews.
110
- */
111
- export function file2Base64(file: File | { uid?: string } & Blob): Promise<string> {
112
- return new Promise((resolve, reject) => {
113
- const reader = new FileReader();
114
- reader.readAsDataURL(file as Blob);
115
- reader.addEventListener('load', () => {
116
- resolve(reader.result as string);
117
- });
118
- reader.addEventListener('error', () => {
119
- reject(reader.error);
120
- });
121
- });
122
- }
123
14
 
124
15
  // ─── Document Title Helper ────────────────────────────────────
125
16
 
@@ -219,7 +110,7 @@ export function deriveValidator(
219
110
  case 'email': {
220
111
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
221
112
  if (typeof value === 'string' && !emailRegex.test(value)) {
222
- errors[field.key] = t('validation.email', { defaultValue: 'Invalid email address' });
113
+ errors[field.key] = t('validation.invalidEmail');
223
114
  }
224
115
  break;
225
116
  }
@@ -227,13 +118,13 @@ export function deriveValidator(
227
118
  try {
228
119
  new URL(value as string);
229
120
  } catch {
230
- errors[field.key] = t('validation.url', { defaultValue: 'Invalid URL' });
121
+ errors[field.key] = t('validation.invalidUrl');
231
122
  }
232
123
  break;
233
124
  }
234
125
  case 'number': {
235
126
  if (typeof value === 'string' && isNaN(Number(value))) {
236
- errors[field.key] = t('validation.number', { defaultValue: 'Must be a number' });
127
+ errors[field.key] = t('validation.invalidNumber');
237
128
  }
238
129
  break;
239
130
  }
@@ -3,7 +3,27 @@
3
3
 
4
4
  import { useQueryClient } from '@tanstack/svelte-query';
5
5
  import type { LiveProvider, LiveEvent, LiveMode } from './live.svelte';
6
- import { toast } from './toast.svelte';
6
+ import { getAuthProvider } from './context.svelte';
7
+ import { notify } from './notification.svelte';
8
+
9
+ // ─── Auth Error Delegate ────────────────────────────────────────
10
+ // Delegate auth errors (401/403) to authProvider.onError() — refine pattern
11
+
12
+ export async function checkError(error: unknown): Promise<void> {
13
+ try {
14
+ const authProvider = getAuthProvider({ optional: true });
15
+ if (!authProvider?.onError) return;
16
+ const result = await authProvider.onError(error);
17
+ if (result.logout) {
18
+ await authProvider.logout?.();
19
+ const { navigate } = await import('./router');
20
+ navigate(result.redirectTo ?? '/login');
21
+ } else if (result.redirectTo) {
22
+ const { navigate } = await import('./router');
23
+ navigate(result.redirectTo);
24
+ }
25
+ } catch { /* auth check failed silently */ }
26
+ }
7
27
 
8
28
  // ─── Overtime Tracker ───────────────────────────────────────────
9
29
  // Tracks elapsed time during loading, auto-cleans via $effect
@@ -71,7 +91,7 @@ export function createLiveSubscription(paramsFn: () => LiveSubscriptionParams):
71
91
  callback: (event: LiveEvent) => {
72
92
  params.onLiveEvent?.(event);
73
93
  if (liveMode === 'auto') {
74
- queryClient.invalidateQueries({ queryKey: [params.resource] });
94
+ queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === params.resource });
75
95
  }
76
96
  },
77
97
  });
@@ -98,10 +118,10 @@ export function fireSuccessNotification(
98
118
  if (!config && !defaultMessage) return;
99
119
  if (typeof config === 'function') {
100
120
  const result = config(data, values, resource);
101
- toast.success(result.message);
121
+ notify({ type: 'success', message: result.message, description: result.description });
102
122
  return;
103
123
  }
104
- toast.success(config || defaultMessage);
124
+ notify({ type: 'success', message: config || defaultMessage });
105
125
  }
106
126
 
107
127
  export function fireErrorNotification(
@@ -110,11 +130,12 @@ export function fireErrorNotification(
110
130
  error?: unknown,
111
131
  ): void {
112
132
  if (config === false) return;
133
+ if (!config && !defaultMessage) return;
113
134
  if (typeof config === 'function') {
114
135
  const result = config(error);
115
- toast.error(result.message);
136
+ notify({ type: 'error', message: result.message, description: result.description });
116
137
  return;
117
138
  }
118
139
  const errMsg = error instanceof Error ? error.message : String(error ?? '');
119
- toast.error(config || `${defaultMessage}${errMsg ? ': ' + errMsg : ''}`);
140
+ notify({ type: 'error', message: config || `${defaultMessage}${errMsg ? ': ' + errMsg : ''}` });
120
141
  }
@@ -23,10 +23,11 @@ export type { OvertimeResult, OvertimeOptions, NotificationConfig } from './hook
23
23
 
24
24
  import { createQuery, createInfiniteQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
25
25
  import { getAdminOptions } from './options.svelte';
26
- import { getDataProviderForResource, getDataProvider } from './context.svelte';
26
+ import { getDataProviderForResource, getDataProvider, getLiveProvider } from './context.svelte';
27
27
  import { useParsed } from './useParsed.svelte';
28
- import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
29
- import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
28
+ import { createOvertimeTracker, createLiveSubscription, fireSuccessNotification, fireErrorNotification, checkError } from './hook-utils.svelte';
29
+ import { invalidateByScopes } from './mutation-hooks.svelte';
30
+ import type { NotificationConfig, OvertimeOptions, LiveSubscriptionParams } from './hook-utils.svelte';
30
31
  import type { BaseRecord, HttpError, Pagination, Sort, Filter, DataProvider, KnownResources } from './types';
31
32
  import type { LiveMode, LiveEvent } from './live.svelte';
32
33
  import { useList } from './query-hooks.svelte';
@@ -52,13 +53,13 @@ export interface UseInfiniteListOptions<TData extends BaseRecord = BaseRecord, T
52
53
 
53
54
  export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError = HttpError>(options: UseInfiniteListOptions<TData, TError> = {}) {
54
55
  const parsed = useParsed();
55
- const resource = options.resource ?? parsed.resource ?? '';
56
56
  const adminOptions = getAdminOptions();
57
57
 
58
58
  const query = createInfiniteQuery<{ data: TData[]; total: number }, TError>(() => {
59
+ const resource = options.resource ?? parsed.resource ?? '';
59
60
  const provider = getDataProviderForResource(resource, options.dataProviderName);
60
61
  return {
61
- queryKey: [resource, 'infiniteList', options.sorters, options.filters, options.meta],
62
+ queryKey: [options.dataProviderName, resource, 'infiniteList', options.pagination?.pageSize, options.sorters, options.filters, options.meta],
62
63
  queryFn: async ({ pageParam = 1 }) => {
63
64
  const result = await provider.getList<TData>({
64
65
  resource,
@@ -82,6 +83,30 @@ export function useInfiniteList<TData extends BaseRecord = BaseRecord, TError =
82
83
 
83
84
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
84
85
 
86
+ createLiveSubscription((): LiveSubscriptionParams => ({
87
+ resource: options.resource ?? parsed.resource ?? '',
88
+ liveProvider: getLiveProvider(),
89
+ liveMode: options.liveMode ?? adminOptions.liveMode,
90
+ onLiveEvent: (e: LiveEvent) => {
91
+ options.onLiveEvent?.(e);
92
+ adminOptions.onLiveEvent?.(e);
93
+ },
94
+ enabled: options.queryOptions?.enabled ?? true,
95
+ }));
96
+
97
+ let lastSuccessAt = 0;
98
+ let lastErrorAt = 0;
99
+ $effect(() => {
100
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && options.successNotification) {
101
+ lastSuccessAt = query.dataUpdatedAt;
102
+ fireSuccessNotification(options.successNotification, '', query.data, undefined, options.resource ?? parsed.resource ?? '');
103
+ } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
104
+ lastErrorAt = query.errorUpdatedAt;
105
+ checkError(query.error);
106
+ fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
107
+ }
108
+ });
109
+
85
110
  return { query, get overtime() { return overtime; } };
86
111
  }
87
112
 
@@ -126,7 +151,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
126
151
  const query = createQuery<{ data: TData[]; total: number }>(() => {
127
152
  const provider = getDataProviderForResource(resource, dataProviderName);
128
153
  return {
129
- queryKey: [resource, 'select', allFilters, sorters, pagination, meta],
154
+ queryKey: [dataProviderName, resource, 'select', allFilters, sorters, pagination, meta],
130
155
  queryFn: () => provider.getList<TData>({ resource, sorters, filters: allFilters, pagination: { current: 1, pageSize: effectivePageSize }, meta }),
131
156
  enabled: options.queryOptions?.enabled ?? true,
132
157
  staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
@@ -139,7 +164,7 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
139
164
  ? createQuery<{ data: TData[] }>(() => {
140
165
  const provider = getDataProviderForResource(resource, dataProviderName);
141
166
  return {
142
- queryKey: [resource, 'select-defaults', defaultValueIds],
167
+ queryKey: [dataProviderName, resource, 'select-defaults', defaultValueIds],
143
168
  queryFn: async () => {
144
169
  if (provider.getMany) return provider.getMany<TData>({ resource, ids: defaultValueIds, meta });
145
170
  const results = await Promise.all(defaultValueIds.map(id => provider.getOne<TData>({ resource, id, meta })));
@@ -172,6 +197,19 @@ export function useSelect<TData extends BaseRecord = BaseRecord, TOption = { lab
172
197
 
173
198
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
174
199
 
200
+ let lastSuccessAt = 0;
201
+ let lastErrorAt = 0;
202
+ $effect(() => {
203
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && options.successNotification) {
204
+ lastSuccessAt = query.dataUpdatedAt;
205
+ fireSuccessNotification(options.successNotification, '', query.data, undefined, resource);
206
+ } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
207
+ lastErrorAt = query.errorUpdatedAt;
208
+ checkError(query.error);
209
+ fireErrorNotification(options.errorNotification, 'Fetch failed', query.error);
210
+ }
211
+ });
212
+
175
213
  function onSearchChange(value: string) {
176
214
  if (debounceTimer) clearTimeout(debounceTimer);
177
215
  debounceTimer = setTimeout(() => { searchText = value; }, debounceMs);
@@ -210,7 +248,7 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
210
248
  const query = createQuery<{ data: TData }, TError>(() => {
211
249
  const provider = getDataProvider(options.dataProviderName);
212
250
  return {
213
- queryKey: ['custom', options.url, options.method, options.config, options.meta],
251
+ queryKey: [options.dataProviderName, 'custom', options.url, options.method, options.config, options.meta],
214
252
  queryFn: async () => {
215
253
  if (!provider.custom) throw new Error('DataProvider does not support custom method');
216
254
  return provider.custom<TData>({
@@ -231,6 +269,19 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
231
269
 
232
270
  const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
233
271
 
272
+ let lastSuccessAt = 0;
273
+ let lastErrorAt = 0;
274
+ $effect(() => {
275
+ if (query.isSuccess && query.dataUpdatedAt > lastSuccessAt && options.successNotification) {
276
+ lastSuccessAt = query.dataUpdatedAt;
277
+ fireSuccessNotification(options.successNotification, '', query.data);
278
+ } else if (query.isError && query.errorUpdatedAt > lastErrorAt) {
279
+ lastErrorAt = query.errorUpdatedAt;
280
+ checkError(query.error);
281
+ fireErrorNotification(options.errorNotification, 'Custom request failed', query.error);
282
+ }
283
+ });
284
+
234
285
  return { query, get overtime() { return overtime; } };
235
286
  }
236
287
 
@@ -239,11 +290,19 @@ export function useCustom<TData = unknown, TError = HttpError>(options: UseCusto
239
290
  export function useCustomMutation<TData = unknown, TError = HttpError, TVariables = unknown>(dataProviderName?: string) {
240
291
  const queryClient = useQueryClient();
241
292
 
242
- const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; meta?: Record<string, unknown> }>(() => ({
293
+ const mutation = createMutation<{ data: TData }, TError, { url: string; method: 'get' | 'post' | 'put' | 'patch' | 'delete'; values?: TVariables; query?: Record<string, unknown>; headers?: Record<string, string>; sorters?: Sort[]; filters?: Filter[]; meta?: Record<string, unknown>; invalidates?: string[] | false; resource?: string }>(() => ({
243
294
  mutationFn: async (params) => {
244
295
  const provider = getDataProvider(dataProviderName);
245
296
  if (!provider.custom) throw new Error('DataProvider does not support custom method');
246
- return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, meta: params.meta });
297
+ return provider.custom<TData>({ url: params.url, method: params.method, payload: params.values, query: params.query, headers: params.headers, sorters: params.sorters, filters: params.filters, meta: params.meta });
298
+ },
299
+ onSuccess: (data, params) => {
300
+ if (params.resource && params.invalidates !== false) {
301
+ invalidateByScopes(queryClient, params.resource, params.invalidates, ['list', 'many']);
302
+ }
303
+ },
304
+ onError: (error) => {
305
+ checkError(error);
247
306
  },
248
307
  }));
249
308
 
@@ -271,7 +330,19 @@ export function useCreateMany<TData extends BaseRecord = BaseRecord, TError = Ht
271
330
  return { data: results.map(r => r.data) };
272
331
  } finally { isMutating = false; }
273
332
  },
274
- onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
333
+ onSuccess: (data, params) => {
334
+ const resName = params.resource ?? resource;
335
+ fireSuccessNotification(undefined, 'Created successfully', data.data, params.variables, resName);
336
+ audit({ action: 'create', resource: resName });
337
+ },
338
+ onError: (error, params) => {
339
+ checkError(error);
340
+ fireErrorNotification(undefined, 'Create many failed', error);
341
+ },
342
+ onSettled: (_d, _e, params) => {
343
+ const resName = params.resource ?? resource;
344
+ invalidateByScopes(queryClient, resName, undefined, ['list', 'many']);
345
+ },
275
346
  }));
276
347
 
277
348
  return { mutation, get overtime() { return overtime; } };
@@ -296,7 +367,22 @@ export function useUpdateMany<TData extends BaseRecord = BaseRecord, TError = Ht
296
367
  return { data: results.map(r => r.data) };
297
368
  } finally { isMutating = false; }
298
369
  },
299
- onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
370
+ onSuccess: (data, params) => {
371
+ const resName = params.resource ?? resource;
372
+ fireSuccessNotification(undefined, 'Updated successfully', data.data, params.variables, resName);
373
+ audit({ action: 'update', resource: resName });
374
+ },
375
+ onError: (error, params) => {
376
+ checkError(error);
377
+ fireErrorNotification(undefined, 'Update many failed', error);
378
+ },
379
+ onSettled: (_d, _e, params) => {
380
+ const resName = params.resource ?? resource;
381
+ invalidateByScopes(queryClient, resName, undefined, ['list', 'many', 'detail'], undefined);
382
+ for (const id of params.ids) {
383
+ queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === id });
384
+ }
385
+ },
300
386
  }));
301
387
 
302
388
  return { mutation, get overtime() { return overtime; } };
@@ -321,7 +407,22 @@ export function useDeleteMany<TData extends BaseRecord = BaseRecord, TError = Ht
321
407
  return { data: results.map(r => r.data) };
322
408
  } finally { isMutating = false; }
323
409
  },
324
- onSuccess: (_d, params) => { queryClient.invalidateQueries({ queryKey: [params.resource ?? resource] }); },
410
+ onSuccess: (data, params) => {
411
+ const resName = params.resource ?? resource;
412
+ fireSuccessNotification(undefined, 'Deleted successfully', data.data, undefined, resName);
413
+ audit({ action: 'delete', resource: resName });
414
+ },
415
+ onError: (error, params) => {
416
+ checkError(error);
417
+ fireErrorNotification(undefined, 'Delete many failed', error);
418
+ },
419
+ onSettled: (_d, _e, params) => {
420
+ const resName = params.resource ?? resource;
421
+ invalidateByScopes(queryClient, resName, undefined, ['list', 'many']);
422
+ for (const id of params.ids) {
423
+ queryClient.removeQueries({ predicate: (q) => q.queryKey[1] === resName && q.queryKey[2] === 'one' && q.queryKey[3] === id });
424
+ }
425
+ },
325
426
  }));
326
427
 
327
428
  return { mutation, get overtime() { return overtime; } };
@@ -338,12 +439,13 @@ export function useInvalidate() {
338
439
  return;
339
440
  }
340
441
  const scopes = params.invalidates || ['resourceAll'];
442
+ const res = params.resource;
341
443
  for (const scope of scopes) {
342
- if (scope === 'resourceAll') queryClient.invalidateQueries({ queryKey: [params.resource] });
343
- else if ((scope === 'detail' || scope === 'one') && params.id) queryClient.invalidateQueries({ queryKey: [params.resource, 'one', params.id] });
344
- else if (scope === 'one') queryClient.invalidateQueries({ queryKey: [params.resource, 'one'] });
345
- else if (scope === 'list') queryClient.invalidateQueries({ queryKey: [params.resource, 'list'] });
346
- else if (scope === 'many') queryClient.invalidateQueries({ queryKey: [params.resource, 'many'] });
444
+ if (scope === 'resourceAll') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res });
445
+ else if ((scope === 'detail' || scope === 'one') && params.id) queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res && q.queryKey[2] === 'one' && q.queryKey[3] === params.id });
446
+ else if (scope === 'one') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res && q.queryKey[2] === 'one' });
447
+ else if (scope === 'list') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res && (q.queryKey[2] === 'list' || q.queryKey[2] === 'infiniteList' || q.queryKey[2] === 'select') });
448
+ else if (scope === 'many') queryClient.invalidateQueries({ predicate: (q) => q.queryKey[1] === res && q.queryKey[2] === 'many' });
347
449
  }
348
450
  };
349
451
  }
@@ -2,14 +2,25 @@
2
2
 
3
3
 
4
4
 
5
- const locales: Record<string, Record<string, string>> = {
5
+ let locales = $state<Record<string, Record<string, string>>>({
6
6
  'zh-CN': {
7
+ 'common.list': '列表',
8
+ 'common.prev': '上一页',
9
+ 'common.pageOf': '第 {page} 页,共 {total} 页',
10
+ 'common.areYouSure': '确认操作?',
11
+ 'common.backToList': '返回列表',
12
+ 'common.refresh': '刷新',
13
+ 'common.importData': '导入数据',
14
+ 'common.upload': '上传',
15
+ 'common.returnHome': '返回首页',
7
16
  // Common
8
17
  'common.save': '保存',
9
18
  'common.cancel': '取消',
10
19
  'common.create': '新建',
11
20
  'common.edit': '编辑',
12
21
  'common.delete': '删除',
22
+ 'common.batchDeletePartialFail': '批量删除部分失败:{failed}/{total} 条未成功',
23
+ 'common.batchDeleteSuccess': '成功删除 {count} 条记录',
13
24
  'common.search': '搜索...',
14
25
  'common.confirm': '确定',
15
26
  'common.loading': '加载中...',
@@ -142,6 +153,8 @@ const locales: Record<string, Record<string, string>> = {
142
153
  'validation.minLength': '最少 {min} 个字符',
143
154
  'validation.maxLength': '最多 {max} 个字符',
144
155
  'validation.invalidEmail': '请输入有效的邮箱地址',
156
+ 'validation.invalidUrl': '请输入有效的 URL 地址',
157
+ 'validation.invalidNumber': '请输入有效的数字',
145
158
  'validation.invalidFormat': '格式不正确',
146
159
  // Settings
147
160
  'settings.title': '设置',
@@ -242,12 +255,23 @@ const locales: Record<string, Record<string, string>> = {
242
255
  'profile.updatePassword': '更新密码',
243
256
  },
244
257
  'en': {
258
+ 'common.list': 'List',
259
+ 'common.prev': 'Prev',
260
+ 'common.pageOf': 'Page {page} of {total}',
261
+ 'common.areYouSure': 'Are you sure?',
262
+ 'common.backToList': 'Back to List',
263
+ 'common.refresh': 'Refresh',
264
+ 'common.importData': 'Import data',
265
+ 'common.upload': 'Upload',
266
+ 'common.returnHome': 'Return to Home',
245
267
  // Common
246
268
  'common.save': 'Save',
247
269
  'common.cancel': 'Cancel',
248
270
  'common.create': 'Create',
249
271
  'common.edit': 'Edit',
250
272
  'common.delete': 'Delete',
273
+ 'common.batchDeletePartialFail': 'Batch delete partially failed: {failed}/{total} unsuccessful',
274
+ 'common.batchDeleteSuccess': 'Successfully deleted {count} records',
251
275
  'common.search': 'Search...',
252
276
  'common.confirm': 'Confirm',
253
277
  'common.loading': 'Loading...',
@@ -380,6 +404,8 @@ const locales: Record<string, Record<string, string>> = {
380
404
  'validation.minLength': 'Minimum {min} characters',
381
405
  'validation.maxLength': 'Maximum {max} characters',
382
406
  'validation.invalidEmail': 'Please enter a valid email address',
407
+ 'validation.invalidUrl': 'Please enter a valid URL',
408
+ 'validation.invalidNumber': 'Please enter a valid number',
383
409
  'validation.invalidFormat': 'Invalid format',
384
410
  // Settings
385
411
  'settings.title': 'Settings',
@@ -479,19 +505,18 @@ const locales: Record<string, Record<string, string>> = {
479
505
  'profile.passwordChanged': 'Password updated successfully',
480
506
  'profile.updatePassword': 'Update Password',
481
507
  },
482
- };
508
+ });
483
509
 
484
510
  /** Detect best locale from browser language */
485
511
  function detectLocale(): string {
486
- if (typeof navigator === 'undefined') return 'en'; // Safe default for SSR
512
+ if (typeof navigator === 'undefined') return 'en';
487
513
  const browserLang = navigator.language || navigator.languages?.[0] || 'zh-CN';
488
- // Exact match
489
514
  if (locales[browserLang]) return browserLang;
490
- // Prefix match (e.g., 'zh' → 'zh-CN', 'en-US' → 'en')
491
515
  const prefix = browserLang.split('-')[0];
492
516
  for (const key of Object.keys(locales)) {
493
- if (key.startsWith(prefix)) return key;
517
+ if (key === browserLang || key.startsWith(prefix + '-')) return key;
494
518
  }
519
+ if (locales[prefix]) return prefix;
495
520
  return 'en';
496
521
  }
497
522
 
@@ -510,8 +535,8 @@ export function getAvailableLocales(): string[] {
510
535
  }
511
536
 
512
537
  export function t(key: string, params?: Record<string, string | number>): string {
513
- const locale = locales[currentLocale] ?? locales['en'];
514
- let text = locale[key] ?? key;
538
+ const locale = locales[currentLocale];
539
+ let text = locale?.[key] ?? locales['en']?.[key] ?? key;
515
540
 
516
541
  if (params) {
517
542
  for (const [k, v] of Object.entries(params)) {
@@ -522,7 +547,11 @@ export function t(key: string, params?: Record<string, string | number>): string
522
547
  }
523
548
 
524
549
  export function addTranslations(locale: string, translations: Record<string, string>): void {
525
- locales[locale] = { ...locales[locale], ...translations };
550
+ locales = { ...locales, [locale]: { ...locales[locale], ...translations } };
551
+ }
552
+
553
+ export function resetI18n(): void {
554
+ currentLocale = detectLocale();
526
555
  }
527
556
 
528
557
  /**
@@ -530,14 +559,9 @@ export function addTranslations(locale: string, translations: Record<string, str
530
559
  * Tracks locale changes natively.
531
560
  */
532
561
  export function useTranslation() {
533
- const translate = $derived.by(() => {
534
- // Read currentLocale so this $derived depends on it
535
- const loc = currentLocale;
536
- return (key: string, params?: Record<string, string | number>): string => {
537
- // Intentionally passing the key to the main t() function which uses currentLocale
538
- return t(key, params);
539
- };
540
- });
562
+ const translate = (key: string, params?: Record<string, string | number>): string => {
563
+ return t(key, params);
564
+ };
541
565
 
542
566
  return {
543
567
  get t() { return translate; },
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ export {
6
6
  setResources, getResources, getResource,
7
7
  setRouterProvider, getRouterProvider,
8
8
  setLiveProvider, getLiveProvider,
9
+ resetContext,
9
10
  } from './context.svelte';
10
11
  export type { DataProviderInput } from './context.svelte';
11
12
  export {
@@ -26,7 +27,8 @@ export {
26
27
  useNotification, useDataProvider,
27
28
  useMenu, useBreadcrumb, useThemedLayoutContext,
28
29
  } from './hooks.svelte';
29
- export { matchRoute, navigate, currentPath, setActiveRouterProvider } from './router';
30
+ export { matchRoute, navigate, currentPath, setActiveRouterProvider, beforeEach, afterEach } from './router';
31
+ export type { RouteGuard } from './router';
30
32
  export { readURLState, writeURLState } from './url-sync';
31
33
  export { setAccessControlProvider, getAccessControlProvider, getAccessControlOptions, canAccessAsync } from './permissions';
32
34
  export { useLive, useSubscription, usePublish } from './live.svelte';
@@ -42,12 +44,14 @@ export type { ThemeMode, ColorTheme, ThemeStrategy, ThemeConfig, ColorPreset } f
42
44
  export { setUnsavedChanges, getUnsavedChanges, initUnsavedChangesNotifier } from './unsaved-changes.svelte';
43
45
  export { setAdminOptions, getAdminOptions, getTextTransformers } from './options.svelte';
44
46
  export type { AdminOptions, TextTransformers, OvertimeConfig } from './options.svelte';
47
+ export { checkError } from './hook-utils.svelte';
48
+ export type { NotificationConfig, OvertimeOptions, OvertimeResult, LiveSubscriptionParams } from './hook-utils.svelte';
45
49
 
46
50
  export * from './query-keys';
47
- export { HttpError } from './types';
51
+ export { HttpError, UndoError } from './types';
48
52
  export type {
49
53
  DataProvider, AuthProvider, NotificationProvider, MutationMode,
50
- ValidationErrors, CrudOperator, LogicalFilter,
54
+ ValidationErrors, CrudOperator, LogicalFilter, FieldFilter,
51
55
  CustomParams, CustomResult,
52
56
  GetListParams, GetListResult,
53
57
  GetOneParams, GetOneResult,
@@ -101,3 +105,6 @@ export {
101
105
  } from './helpers';
102
106
  export { TableState } from './table-state.svelte';
103
107
  export type { TableStateOptions } from './table-state.svelte';
108
+ export type { UseInfiniteListOptions } from './hooks.svelte';
109
+ export type { UseSelectOptions } from './hooks.svelte';
110
+ export type { UseFormReturn } from './form-hooks.svelte';