@svadmin/core 0.0.1
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 +60 -0
- package/src/audit.ts +29 -0
- package/src/context.ts +49 -0
- package/src/hooks.svelte.ts +513 -0
- package/src/i18n.svelte.ts +157 -0
- package/src/index.ts +48 -0
- package/src/live.ts +28 -0
- package/src/permissions.ts +42 -0
- package/src/router.ts +46 -0
- package/src/theme.svelte.ts +56 -0
- package/src/toast.svelte.ts +37 -0
- package/src/types.ts +236 -0
- package/src/url-sync.ts +55 -0
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@svadmin/core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Core SDK — hooks, types, context, i18n, permissions, router",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"src/**/*"
|
|
9
|
+
],
|
|
10
|
+
"main": "src/index.ts",
|
|
11
|
+
"types": "src/index.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"default": "./src/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./router": {
|
|
18
|
+
"types": "./src/router.ts",
|
|
19
|
+
"default": "./src/router.ts"
|
|
20
|
+
},
|
|
21
|
+
"./i18n": {
|
|
22
|
+
"types": "./src/i18n.svelte.ts",
|
|
23
|
+
"default": "./src/i18n.svelte.ts"
|
|
24
|
+
},
|
|
25
|
+
"./permissions": {
|
|
26
|
+
"types": "./src/permissions.ts",
|
|
27
|
+
"default": "./src/permissions.ts"
|
|
28
|
+
},
|
|
29
|
+
"./toast": {
|
|
30
|
+
"types": "./src/toast.svelte.ts",
|
|
31
|
+
"default": "./src/toast.svelte.ts"
|
|
32
|
+
},
|
|
33
|
+
"./audit": {
|
|
34
|
+
"types": "./src/audit.ts",
|
|
35
|
+
"default": "./src/audit.ts"
|
|
36
|
+
},
|
|
37
|
+
"./live": {
|
|
38
|
+
"types": "./src/live.ts",
|
|
39
|
+
"default": "./src/live.ts"
|
|
40
|
+
},
|
|
41
|
+
"./theme": {
|
|
42
|
+
"types": "./src/theme.svelte.ts",
|
|
43
|
+
"default": "./src/theme.svelte.ts"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"svelte": "^5.0.0",
|
|
48
|
+
"@tanstack/svelte-query": "^6.0.0"
|
|
49
|
+
},
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"author": "zuohuadong",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/zuohuadong/svadmin.git"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/zuohuadong/svadmin#readme",
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://github.com/zuohuadong/svadmin/issues"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Audit logging — record admin operations
|
|
2
|
+
|
|
3
|
+
export interface AuditEntry {
|
|
4
|
+
timestamp: string;
|
|
5
|
+
action: 'create' | 'update' | 'delete' | 'login' | 'logout';
|
|
6
|
+
resource?: string;
|
|
7
|
+
recordId?: string | number;
|
|
8
|
+
userId?: string;
|
|
9
|
+
details?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AuditHandler = (entry: AuditEntry) => void | Promise<void>;
|
|
13
|
+
|
|
14
|
+
let handler: AuditHandler = (entry) => {
|
|
15
|
+
console.info('[audit]', entry.action, entry.resource, entry.recordId);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function setAuditHandler(fn: AuditHandler): void {
|
|
19
|
+
handler = fn;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function audit(entry: Omit<AuditEntry, 'timestamp'>): void {
|
|
23
|
+
const fullEntry: AuditEntry = { ...entry, timestamp: new Date().toISOString() };
|
|
24
|
+
try {
|
|
25
|
+
handler(fullEntry);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
console.error('[audit] handler error:', e);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Svelte context — provides DataProvider, AuthProvider, and Resources to all components
|
|
2
|
+
|
|
3
|
+
import { getContext, setContext } from 'svelte';
|
|
4
|
+
import type { DataProvider, AuthProvider, ResourceDefinition } from './types';
|
|
5
|
+
|
|
6
|
+
const DATA_PROVIDER_KEY = Symbol('data-provider');
|
|
7
|
+
const AUTH_PROVIDER_KEY = Symbol('auth-provider');
|
|
8
|
+
const RESOURCES_KEY = Symbol('resources');
|
|
9
|
+
|
|
10
|
+
// ─── Setters (called once in App.svelte) ────────────────────────
|
|
11
|
+
|
|
12
|
+
export function setDataProvider(provider: DataProvider): void {
|
|
13
|
+
setContext(DATA_PROVIDER_KEY, provider);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function setAuthProvider(provider: AuthProvider): void {
|
|
17
|
+
setContext(AUTH_PROVIDER_KEY, provider);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setResources(resources: ResourceDefinition[]): void {
|
|
21
|
+
setContext(RESOURCES_KEY, resources);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Getters (called from any child component) ──────────────────
|
|
25
|
+
|
|
26
|
+
export function getDataProvider(): DataProvider {
|
|
27
|
+
const provider = getContext<DataProvider>(DATA_PROVIDER_KEY);
|
|
28
|
+
if (!provider) throw new Error('DataProvider not found. Did you call setDataProvider in App.svelte?');
|
|
29
|
+
return provider;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getAuthProvider(): AuthProvider {
|
|
33
|
+
const provider = getContext<AuthProvider>(AUTH_PROVIDER_KEY);
|
|
34
|
+
if (!provider) throw new Error('AuthProvider not found. Did you call setAuthProvider in App.svelte?');
|
|
35
|
+
return provider;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getResources(): ResourceDefinition[] {
|
|
39
|
+
const resources = getContext<ResourceDefinition[]>(RESOURCES_KEY);
|
|
40
|
+
if (!resources) throw new Error('Resources not found. Did you call setResources in App.svelte?');
|
|
41
|
+
return resources;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getResource(name: string): ResourceDefinition {
|
|
45
|
+
const resources = getResources();
|
|
46
|
+
const resource = resources.find(r => r.name === name);
|
|
47
|
+
if (!resource) throw new Error(`Resource "${name}" not found in resource definitions.`);
|
|
48
|
+
return resource;
|
|
49
|
+
}
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// Complete CRUD hooks — 100% Refine-compatible
|
|
2
|
+
// TanStack Query v6 (Svelte 5 runes-native) wrappers
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createQuery,
|
|
6
|
+
createMutation,
|
|
7
|
+
createInfiniteQuery,
|
|
8
|
+
useQueryClient,
|
|
9
|
+
} from '@tanstack/svelte-query';
|
|
10
|
+
import { getDataProvider } from './context';
|
|
11
|
+
import type { GetListResult, Sort, Filter, Pagination, MutationMode } from './types';
|
|
12
|
+
import { toast } from './toast.svelte';
|
|
13
|
+
import { audit } from './audit';
|
|
14
|
+
import { navigate } from './router';
|
|
15
|
+
import { t } from './i18n.svelte';
|
|
16
|
+
|
|
17
|
+
// ─── useList ────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
interface UseListOptions {
|
|
20
|
+
resource: string;
|
|
21
|
+
pagination?: Pagination;
|
|
22
|
+
sorters?: Sort[];
|
|
23
|
+
filters?: Filter[];
|
|
24
|
+
meta?: Record<string, unknown>;
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useList<T = Record<string, unknown>>(options: UseListOptions) {
|
|
29
|
+
const provider = getDataProvider();
|
|
30
|
+
const { resource, pagination, sorters, filters, meta, enabled = true } = options;
|
|
31
|
+
|
|
32
|
+
return createQuery<GetListResult<T>>(() => ({
|
|
33
|
+
queryKey: [resource, 'list', { pagination, sorters, filters }],
|
|
34
|
+
queryFn: () => provider.getList<T>({ resource, pagination, sorters, filters, meta }),
|
|
35
|
+
enabled,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── useInfiniteList ────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface UseInfiniteListOptions {
|
|
42
|
+
resource: string;
|
|
43
|
+
pageSize?: number;
|
|
44
|
+
sorters?: Sort[];
|
|
45
|
+
filters?: Filter[];
|
|
46
|
+
meta?: Record<string, unknown>;
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useInfiniteList<T = Record<string, unknown>>(options: UseInfiniteListOptions) {
|
|
51
|
+
const provider = getDataProvider();
|
|
52
|
+
const { resource, pageSize = 10, sorters, filters, meta, enabled = true } = options;
|
|
53
|
+
|
|
54
|
+
return createInfiniteQuery<GetListResult<T>>(() => ({
|
|
55
|
+
queryKey: [resource, 'infinite', { sorters, filters }],
|
|
56
|
+
queryFn: ({ pageParam = 1 }) =>
|
|
57
|
+
provider.getList<T>({
|
|
58
|
+
resource,
|
|
59
|
+
pagination: { current: pageParam as number, pageSize },
|
|
60
|
+
sorters,
|
|
61
|
+
filters,
|
|
62
|
+
meta,
|
|
63
|
+
}),
|
|
64
|
+
initialPageParam: 1,
|
|
65
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
66
|
+
const totalFetched = allPages.reduce((sum, p) => sum + p.data.length, 0);
|
|
67
|
+
return totalFetched < lastPage.total ? allPages.length + 1 : undefined;
|
|
68
|
+
},
|
|
69
|
+
enabled,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── useOne ─────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
interface UseOneOptions {
|
|
76
|
+
resource: string;
|
|
77
|
+
id: string | number;
|
|
78
|
+
meta?: Record<string, unknown>;
|
|
79
|
+
enabled?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function useOne<T = Record<string, unknown>>(options: UseOneOptions) {
|
|
83
|
+
const provider = getDataProvider();
|
|
84
|
+
const { resource, id, meta, enabled = true } = options;
|
|
85
|
+
|
|
86
|
+
return createQuery(() => ({
|
|
87
|
+
queryKey: [resource, 'one', id],
|
|
88
|
+
queryFn: async () => {
|
|
89
|
+
const result = await provider.getOne<T>({ resource, id, meta });
|
|
90
|
+
return result.data;
|
|
91
|
+
},
|
|
92
|
+
enabled: enabled && id != null,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── useShow (alias for useOne with route awareness) ────────────
|
|
97
|
+
|
|
98
|
+
export function useShow<T = Record<string, unknown>>(options: UseOneOptions) {
|
|
99
|
+
return useOne<T>(options);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── useSelect ──────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
interface UseSelectOptions {
|
|
105
|
+
resource: string;
|
|
106
|
+
optionLabel?: string;
|
|
107
|
+
optionValue?: string;
|
|
108
|
+
filters?: Filter[];
|
|
109
|
+
sorters?: Sort[];
|
|
110
|
+
enabled?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useSelect(options: UseSelectOptions) {
|
|
114
|
+
const provider = getDataProvider();
|
|
115
|
+
const {
|
|
116
|
+
resource, optionLabel = 'name', optionValue = 'id',
|
|
117
|
+
filters, sorters, enabled = true,
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
return createQuery(() => ({
|
|
121
|
+
queryKey: [resource, 'select', { optionLabel, optionValue, filters }],
|
|
122
|
+
queryFn: async () => {
|
|
123
|
+
const result = await provider.getList({ resource, pagination: { current: 1, pageSize: 1000 }, filters, sorters });
|
|
124
|
+
return result.data.map(item => ({
|
|
125
|
+
label: String(item[optionLabel] ?? ''),
|
|
126
|
+
value: String(item[optionValue] ?? ''),
|
|
127
|
+
}));
|
|
128
|
+
},
|
|
129
|
+
enabled,
|
|
130
|
+
staleTime: 10 * 60 * 1000,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── useMany ────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
interface UseManyOptions {
|
|
137
|
+
resource: string;
|
|
138
|
+
ids: (string | number)[];
|
|
139
|
+
meta?: Record<string, unknown>;
|
|
140
|
+
enabled?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function useMany<T = Record<string, unknown>>(options: UseManyOptions) {
|
|
144
|
+
const provider = getDataProvider();
|
|
145
|
+
const { resource, ids, meta, enabled = true } = options;
|
|
146
|
+
|
|
147
|
+
return createQuery(() => ({
|
|
148
|
+
queryKey: [resource, 'many', ids],
|
|
149
|
+
queryFn: async () => {
|
|
150
|
+
if (provider.getMany) {
|
|
151
|
+
const result = await provider.getMany<T>({ resource, ids, meta });
|
|
152
|
+
return result.data;
|
|
153
|
+
}
|
|
154
|
+
const results = await Promise.all(ids.map(id => provider.getOne<T>({ resource, id, meta })));
|
|
155
|
+
return results.map(r => r.data);
|
|
156
|
+
},
|
|
157
|
+
enabled: enabled && ids.length > 0,
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── useCustom ──────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
interface UseCustomQueryOptions<T = unknown> {
|
|
164
|
+
queryKey: unknown[];
|
|
165
|
+
queryFn: () => Promise<T>;
|
|
166
|
+
enabled?: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function useCustom<T = unknown>(options: UseCustomQueryOptions<T>) {
|
|
170
|
+
return createQuery(() => ({
|
|
171
|
+
queryKey: options.queryKey,
|
|
172
|
+
queryFn: options.queryFn,
|
|
173
|
+
enabled: options.enabled ?? true,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── useApiUrl ──────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export function useApiUrl(): string {
|
|
180
|
+
const provider = getDataProvider();
|
|
181
|
+
return provider.getApiUrl();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── useNavigation ──────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export function useNavigation() {
|
|
187
|
+
return {
|
|
188
|
+
list: (resource: string) => navigate(`/${resource}`),
|
|
189
|
+
create: (resource: string) => navigate(`/${resource}/create`),
|
|
190
|
+
edit: (resource: string, id: string | number) => navigate(`/${resource}/edit/${id}`),
|
|
191
|
+
show: (resource: string, id: string | number) => navigate(`/${resource}/show/${id}`),
|
|
192
|
+
goBack: () => history.back(),
|
|
193
|
+
push: (path: string) => navigate(path),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── useResource ────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export { getResource as useResource } from './context';
|
|
200
|
+
|
|
201
|
+
// ─── Mutation options ───────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
interface UseMutationOptions {
|
|
204
|
+
showToast?: boolean;
|
|
205
|
+
auditLog?: boolean;
|
|
206
|
+
mutationMode?: MutationMode;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── useCreate ──────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
export function useCreate<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
|
|
212
|
+
const provider = getDataProvider();
|
|
213
|
+
const queryClient = useQueryClient();
|
|
214
|
+
const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
|
|
215
|
+
|
|
216
|
+
return createMutation(() => ({
|
|
217
|
+
mutationFn: (variables: Record<string, unknown>) =>
|
|
218
|
+
provider.create<T>({ resource, variables }),
|
|
219
|
+
onMutate: mutationMode === 'optimistic'
|
|
220
|
+
? async (_variables: Record<string, unknown>) => {
|
|
221
|
+
await queryClient.cancelQueries({ queryKey: [resource] });
|
|
222
|
+
const previousData = queryClient.getQueryData([resource, 'list']);
|
|
223
|
+
return { previousData };
|
|
224
|
+
}
|
|
225
|
+
: undefined,
|
|
226
|
+
onSuccess: (data: { data: T }) => {
|
|
227
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
228
|
+
if (showToast) toast.success(t('common.createSuccess'));
|
|
229
|
+
if (auditLog) {
|
|
230
|
+
const record = data.data as Record<string, unknown>;
|
|
231
|
+
audit({ action: 'create', resource, recordId: record.id as string });
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
onError: (error: Error, _variables: Record<string, unknown>, context: { previousData?: unknown } | undefined) => {
|
|
235
|
+
if (mutationMode === 'optimistic' && context?.previousData) {
|
|
236
|
+
queryClient.setQueryData([resource, 'list'], context.previousData);
|
|
237
|
+
}
|
|
238
|
+
if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
|
|
239
|
+
},
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── useCreateMany ──────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export function useCreateMany<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
|
|
246
|
+
const provider = getDataProvider();
|
|
247
|
+
const queryClient = useQueryClient();
|
|
248
|
+
const { showToast = true, auditLog = true } = opts ?? {};
|
|
249
|
+
|
|
250
|
+
return createMutation(() => ({
|
|
251
|
+
mutationFn: async (variables: Record<string, unknown>[]) => {
|
|
252
|
+
if (provider.createMany) {
|
|
253
|
+
return provider.createMany<T>({ resource, variables });
|
|
254
|
+
}
|
|
255
|
+
const results = await Promise.all(variables.map(v => provider.create<T>({ resource, variables: v })));
|
|
256
|
+
return { data: results.map(r => r.data) };
|
|
257
|
+
},
|
|
258
|
+
onSuccess: () => {
|
|
259
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
260
|
+
if (showToast) toast.success(t('common.createSuccess'));
|
|
261
|
+
if (auditLog) audit({ action: 'create', resource, details: { batch: true } });
|
|
262
|
+
},
|
|
263
|
+
onError: (error: Error) => {
|
|
264
|
+
if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
|
|
265
|
+
},
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── useUpdate ──────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
interface UpdateVariables {
|
|
272
|
+
id: string | number;
|
|
273
|
+
variables: Record<string, unknown>;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function useUpdate<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
|
|
277
|
+
const provider = getDataProvider();
|
|
278
|
+
const queryClient = useQueryClient();
|
|
279
|
+
const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
|
|
280
|
+
|
|
281
|
+
return createMutation<{ data: T }, Error, UpdateVariables>(() => ({
|
|
282
|
+
mutationFn: ({ id, variables }: UpdateVariables) =>
|
|
283
|
+
provider.update<T>({ resource, id, variables }),
|
|
284
|
+
onMutate: mutationMode === 'optimistic'
|
|
285
|
+
? async ({ id }: UpdateVariables) => {
|
|
286
|
+
await queryClient.cancelQueries({ queryKey: [resource, 'one', id] });
|
|
287
|
+
const previousData = queryClient.getQueryData([resource, 'one', id]);
|
|
288
|
+
return { previousData };
|
|
289
|
+
}
|
|
290
|
+
: undefined,
|
|
291
|
+
onSuccess: (_data: { data: T }, vars: UpdateVariables) => {
|
|
292
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
293
|
+
if (showToast) toast.success(t('common.updateSuccess'));
|
|
294
|
+
if (auditLog) audit({ action: 'update', resource, recordId: vars.id });
|
|
295
|
+
},
|
|
296
|
+
onError: (error: Error) => {
|
|
297
|
+
if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
|
|
298
|
+
},
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── useUpdateMany ──────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
export function useUpdateMany<T = Record<string, unknown>>(resource: string, opts?: UseMutationOptions) {
|
|
305
|
+
const provider = getDataProvider();
|
|
306
|
+
const queryClient = useQueryClient();
|
|
307
|
+
const { showToast = true, auditLog = true } = opts ?? {};
|
|
308
|
+
|
|
309
|
+
return createMutation(() => ({
|
|
310
|
+
mutationFn: async ({ ids, variables }: { ids: (string | number)[]; variables: Record<string, unknown> }) => {
|
|
311
|
+
if (provider.updateMany) {
|
|
312
|
+
return provider.updateMany<T>({ resource, ids, variables });
|
|
313
|
+
}
|
|
314
|
+
const results = await Promise.all(ids.map(id => provider.update<T>({ resource, id, variables })));
|
|
315
|
+
return { data: results.map(r => r.data) };
|
|
316
|
+
},
|
|
317
|
+
onSuccess: () => {
|
|
318
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
319
|
+
if (showToast) toast.success(t('common.updateSuccess'));
|
|
320
|
+
if (auditLog) audit({ action: 'update', resource, details: { batch: true } });
|
|
321
|
+
},
|
|
322
|
+
onError: (error: Error) => {
|
|
323
|
+
if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
|
|
324
|
+
},
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── useDelete ──────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
export function useDelete(resource: string, opts?: UseMutationOptions) {
|
|
331
|
+
const provider = getDataProvider();
|
|
332
|
+
const queryClient = useQueryClient();
|
|
333
|
+
const { showToast = true, auditLog = true, mutationMode = 'pessimistic' } = opts ?? {};
|
|
334
|
+
|
|
335
|
+
return createMutation(() => ({
|
|
336
|
+
mutationFn: (id: string | number) =>
|
|
337
|
+
provider.deleteOne({ resource, id }),
|
|
338
|
+
onMutate: mutationMode === 'optimistic'
|
|
339
|
+
? async (_id: string | number) => {
|
|
340
|
+
await queryClient.cancelQueries({ queryKey: [resource] });
|
|
341
|
+
const previousData = queryClient.getQueryData([resource, 'list']);
|
|
342
|
+
return { previousData };
|
|
343
|
+
}
|
|
344
|
+
: undefined,
|
|
345
|
+
onSuccess: (_data: unknown, id: string | number) => {
|
|
346
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
347
|
+
if (showToast) toast.success(t('common.deleteSuccess'));
|
|
348
|
+
if (auditLog) audit({ action: 'delete', resource, recordId: id });
|
|
349
|
+
},
|
|
350
|
+
onError: (error: Error) => {
|
|
351
|
+
if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
|
|
352
|
+
},
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─── useDeleteMany ──────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
export function useDeleteMany(resource: string, opts?: UseMutationOptions) {
|
|
359
|
+
const provider = getDataProvider();
|
|
360
|
+
const queryClient = useQueryClient();
|
|
361
|
+
const { showToast = true, auditLog = true } = opts ?? {};
|
|
362
|
+
|
|
363
|
+
return createMutation(() => ({
|
|
364
|
+
mutationFn: async (ids: (string | number)[]) => {
|
|
365
|
+
if (provider.deleteMany) {
|
|
366
|
+
return provider.deleteMany({ resource, ids });
|
|
367
|
+
}
|
|
368
|
+
const results = await Promise.all(ids.map(id => provider.deleteOne({ resource, id })));
|
|
369
|
+
return { data: results.map(r => r.data) };
|
|
370
|
+
},
|
|
371
|
+
onSuccess: () => {
|
|
372
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
373
|
+
if (showToast) toast.success(t('common.deleteSuccess'));
|
|
374
|
+
if (auditLog) audit({ action: 'delete', resource, details: { batch: true } });
|
|
375
|
+
},
|
|
376
|
+
onError: (error: Error) => {
|
|
377
|
+
if (showToast) toast.error(t('common.operationFailed') + ': ' + error.message);
|
|
378
|
+
},
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── useForm ────────────────────────────────────────────────────
|
|
383
|
+
// Standalone form hook (like Refine's useForm)
|
|
384
|
+
|
|
385
|
+
interface UseFormOptions {
|
|
386
|
+
resource: string;
|
|
387
|
+
action: 'create' | 'edit' | 'clone';
|
|
388
|
+
id?: string | number;
|
|
389
|
+
redirect?: 'list' | 'edit' | 'show' | false;
|
|
390
|
+
mutationMode?: MutationMode;
|
|
391
|
+
onMutationSuccess?: (data: unknown) => void;
|
|
392
|
+
onMutationError?: (error: Error) => void;
|
|
393
|
+
meta?: Record<string, unknown>;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
|
|
397
|
+
const provider = getDataProvider();
|
|
398
|
+
const queryClient = useQueryClient();
|
|
399
|
+
const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta } = options;
|
|
400
|
+
|
|
401
|
+
// Fetch existing data for edit/clone
|
|
402
|
+
const query = (action === 'edit' || action === 'clone') && id != null
|
|
403
|
+
? createQuery(() => ({
|
|
404
|
+
queryKey: [resource, 'one', id],
|
|
405
|
+
queryFn: async () => {
|
|
406
|
+
const result = await provider.getOne<T>({ resource, id, meta });
|
|
407
|
+
return result.data;
|
|
408
|
+
},
|
|
409
|
+
enabled: id != null,
|
|
410
|
+
}))
|
|
411
|
+
: null;
|
|
412
|
+
|
|
413
|
+
const createMut = createMutation(() => ({
|
|
414
|
+
mutationFn: (variables: Record<string, unknown>) =>
|
|
415
|
+
provider.create<T>({ resource, variables }),
|
|
416
|
+
onSuccess: (data: { data: T }) => {
|
|
417
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
418
|
+
toast.success(t('common.createSuccess'));
|
|
419
|
+
const record = data.data as Record<string, unknown>;
|
|
420
|
+
audit({ action: 'create', resource, recordId: record.id as string });
|
|
421
|
+
onMutationSuccess?.(data);
|
|
422
|
+
handleRedirect();
|
|
423
|
+
},
|
|
424
|
+
onError: (error: Error) => {
|
|
425
|
+
toast.error(t('common.operationFailed') + ': ' + error.message);
|
|
426
|
+
onMutationError?.(error);
|
|
427
|
+
},
|
|
428
|
+
}));
|
|
429
|
+
|
|
430
|
+
const updateMut = createMutation(() => ({
|
|
431
|
+
mutationFn: (variables: Record<string, unknown>) =>
|
|
432
|
+
provider.update<T>({ resource, id: id!, variables }),
|
|
433
|
+
onSuccess: (data: { data: T }) => {
|
|
434
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
435
|
+
toast.success(t('common.updateSuccess'));
|
|
436
|
+
audit({ action: 'update', resource, recordId: id });
|
|
437
|
+
onMutationSuccess?.(data);
|
|
438
|
+
handleRedirect();
|
|
439
|
+
},
|
|
440
|
+
onError: (error: Error) => {
|
|
441
|
+
toast.error(t('common.operationFailed') + ': ' + error.message);
|
|
442
|
+
onMutationError?.(error);
|
|
443
|
+
},
|
|
444
|
+
}));
|
|
445
|
+
|
|
446
|
+
function handleRedirect() {
|
|
447
|
+
if (redirect === 'list') navigate(`/${resource}`);
|
|
448
|
+
else if (redirect === 'edit' && id) navigate(`/${resource}/edit/${id}`);
|
|
449
|
+
else if (redirect === 'show' && id) navigate(`/${resource}/show/${id}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function onFinish(values: Record<string, unknown>) {
|
|
453
|
+
if (action === 'create' || action === 'clone') {
|
|
454
|
+
// @ts-expect-error $ rune prefix — Svelte compiler transforms this
|
|
455
|
+
await $createMut.mutateAsync(values);
|
|
456
|
+
} else {
|
|
457
|
+
// @ts-expect-error $ rune prefix — Svelte compiler transforms this
|
|
458
|
+
await $updateMut.mutateAsync(values);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
query,
|
|
464
|
+
get formLoading() {
|
|
465
|
+
// @ts-expect-error $ rune prefix — Svelte compiler transforms this
|
|
466
|
+
return (query ? $query?.isLoading : false) || $createMut.isPending || $updateMut.isPending;
|
|
467
|
+
},
|
|
468
|
+
mutation: action === 'edit' ? updateMut : createMut,
|
|
469
|
+
onFinish,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── useTable ───────────────────────────────────────────────────
|
|
474
|
+
// Standalone table hook (like Refine's useTable)
|
|
475
|
+
|
|
476
|
+
interface UseTableOptions {
|
|
477
|
+
resource: string;
|
|
478
|
+
pagination?: Pagination;
|
|
479
|
+
sorters?: Sort[];
|
|
480
|
+
filters?: Filter[];
|
|
481
|
+
syncWithLocation?: boolean;
|
|
482
|
+
meta?: Record<string, unknown>;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function useTable<T = Record<string, unknown>>(options: UseTableOptions) {
|
|
486
|
+
const { resource, meta } = options;
|
|
487
|
+
|
|
488
|
+
let pagination = $state<Pagination>(options.pagination ?? { current: 1, pageSize: 10 });
|
|
489
|
+
let sorters = $state<Sort[]>(options.sorters ?? []);
|
|
490
|
+
let filters = $state<Filter[]>(options.filters ?? []);
|
|
491
|
+
|
|
492
|
+
const query = useList<T>({ resource, pagination, sorters, filters, meta });
|
|
493
|
+
|
|
494
|
+
function setSorters(newSorters: Sort[]) { sorters = newSorters; }
|
|
495
|
+
function setFilters(newFilters: Filter[]) { filters = newFilters; }
|
|
496
|
+
function setPage(page: number) { pagination = { ...pagination, current: page }; }
|
|
497
|
+
function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
query,
|
|
501
|
+
pagination,
|
|
502
|
+
sorters,
|
|
503
|
+
filters,
|
|
504
|
+
setSorters,
|
|
505
|
+
setFilters,
|
|
506
|
+
setPage,
|
|
507
|
+
setPageSize,
|
|
508
|
+
get totalPages() {
|
|
509
|
+
// @ts-expect-error $ rune prefix — Svelte compiler transforms this
|
|
510
|
+
return Math.ceil(($query.data?.total ?? 0) / (pagination.pageSize ?? 10));
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Minimal i18n — simple key-value translation
|
|
2
|
+
|
|
3
|
+
type Locale = Record<string, string>;
|
|
4
|
+
|
|
5
|
+
const locales: Record<string, Locale> = {
|
|
6
|
+
'zh-CN': {
|
|
7
|
+
// Common
|
|
8
|
+
'common.save': '保存',
|
|
9
|
+
'common.cancel': '取消',
|
|
10
|
+
'common.create': '新建',
|
|
11
|
+
'common.edit': '编辑',
|
|
12
|
+
'common.delete': '删除',
|
|
13
|
+
'common.search': '搜索...',
|
|
14
|
+
'common.confirm': '确定',
|
|
15
|
+
'common.loading': '加载中...',
|
|
16
|
+
'common.noData': '暂无数据',
|
|
17
|
+
'common.actions': '操作',
|
|
18
|
+
'common.total': '共 {total} 条',
|
|
19
|
+
'common.page': '{current} / {total}',
|
|
20
|
+
'common.export': '导出',
|
|
21
|
+
'common.import': '导入',
|
|
22
|
+
'common.selectAll': '全选',
|
|
23
|
+
'common.batchDelete': '批量删除 ({count})',
|
|
24
|
+
'common.unsavedChanges': '有未保存的修改,确定离开吗?',
|
|
25
|
+
'common.unsaved': '未保存',
|
|
26
|
+
'common.deleteConfirm': '确定要删除这条记录吗?此操作不可撤销。',
|
|
27
|
+
'common.batchDeleteConfirm': '确定要删除选中的 {count} 条记录吗?此操作不可撤销。',
|
|
28
|
+
'common.confirmAction': '确认操作',
|
|
29
|
+
'common.operationSuccess': '操作成功',
|
|
30
|
+
'common.operationFailed': '操作失败',
|
|
31
|
+
'common.createSuccess': '创建成功',
|
|
32
|
+
'common.updateSuccess': '更新成功',
|
|
33
|
+
'common.deleteSuccess': '删除成功',
|
|
34
|
+
'common.loginFailed': '登录失败',
|
|
35
|
+
'common.home': '首页',
|
|
36
|
+
'common.logout': '退出登录',
|
|
37
|
+
'common.toggleTheme': '切换主题',
|
|
38
|
+
'common.darkMode': '暗色模式',
|
|
39
|
+
'common.lightMode': '亮色模式',
|
|
40
|
+
'common.noData': '暂无数据',
|
|
41
|
+
'common.search': '搜索...',
|
|
42
|
+
'common.detail': '详情',
|
|
43
|
+
'common.loadFailed': '加载失败: {message}',
|
|
44
|
+
'common.pageNotFound': '页面未找到',
|
|
45
|
+
'common.error': '出错了',
|
|
46
|
+
'common.retry': '重试',
|
|
47
|
+
'common.yes': '是',
|
|
48
|
+
'common.no': '否',
|
|
49
|
+
// Field
|
|
50
|
+
'field.enterValue': '输入{label}',
|
|
51
|
+
'field.selectPlaceholder': '请选择',
|
|
52
|
+
'field.tagsPlaceholder': '输入标签后按回车...',
|
|
53
|
+
// Config errors
|
|
54
|
+
'config.missingEnvTitle': '环境配置缺失',
|
|
55
|
+
'config.missingEnvDescription': '应用无法启动,因为缺少必要的环境变量。',
|
|
56
|
+
'config.addToEnvFile': '请在 .env 文件中添加以下内容:',
|
|
57
|
+
'config.envFilePath': '文件路径: .env',
|
|
58
|
+
'config.reload': '配置完成后刷新页面',
|
|
59
|
+
},
|
|
60
|
+
'en': {
|
|
61
|
+
// Common
|
|
62
|
+
'common.save': 'Save',
|
|
63
|
+
'common.cancel': 'Cancel',
|
|
64
|
+
'common.create': 'Create',
|
|
65
|
+
'common.edit': 'Edit',
|
|
66
|
+
'common.delete': 'Delete',
|
|
67
|
+
'common.search': 'Search...',
|
|
68
|
+
'common.confirm': 'Confirm',
|
|
69
|
+
'common.loading': 'Loading...',
|
|
70
|
+
'common.noData': 'No data',
|
|
71
|
+
'common.actions': 'Actions',
|
|
72
|
+
'common.total': 'Total: {total}',
|
|
73
|
+
'common.page': '{current} / {total}',
|
|
74
|
+
'common.export': 'Export',
|
|
75
|
+
'common.import': 'Import',
|
|
76
|
+
'common.selectAll': 'Select All',
|
|
77
|
+
'common.batchDelete': 'Batch Delete ({count})',
|
|
78
|
+
'common.unsavedChanges': 'You have unsaved changes. Leave anyway?',
|
|
79
|
+
'common.unsaved': 'Unsaved',
|
|
80
|
+
'common.deleteConfirm': 'Are you sure you want to delete this record? This cannot be undone.',
|
|
81
|
+
'common.batchDeleteConfirm': 'Delete {count} selected records? This cannot be undone.',
|
|
82
|
+
'common.confirmAction': 'Confirm Action',
|
|
83
|
+
'common.operationSuccess': 'Operation successful',
|
|
84
|
+
'common.operationFailed': 'Operation failed',
|
|
85
|
+
'common.createSuccess': 'Created successfully',
|
|
86
|
+
'common.updateSuccess': 'Updated successfully',
|
|
87
|
+
'common.deleteSuccess': 'Deleted successfully',
|
|
88
|
+
'common.loginFailed': 'Login failed',
|
|
89
|
+
'common.home': 'Home',
|
|
90
|
+
'common.logout': 'Log out',
|
|
91
|
+
'common.toggleTheme': 'Toggle theme',
|
|
92
|
+
'common.darkMode': 'Dark mode',
|
|
93
|
+
'common.lightMode': 'Light mode',
|
|
94
|
+
'common.noData': 'No data',
|
|
95
|
+
'common.search': 'Search...',
|
|
96
|
+
'common.detail': 'Detail',
|
|
97
|
+
'common.loadFailed': 'Load failed: {message}',
|
|
98
|
+
'common.pageNotFound': 'Page not found',
|
|
99
|
+
'common.error': 'Something went wrong',
|
|
100
|
+
'common.retry': 'Retry',
|
|
101
|
+
'common.yes': 'Yes',
|
|
102
|
+
'common.no': 'No',
|
|
103
|
+
// Field
|
|
104
|
+
'field.enterValue': 'Enter {label}',
|
|
105
|
+
'field.selectPlaceholder': 'Select...',
|
|
106
|
+
'field.tagsPlaceholder': 'Type a tag and press Enter...',
|
|
107
|
+
// Config errors
|
|
108
|
+
'config.missingEnvTitle': 'Missing Configuration',
|
|
109
|
+
'config.missingEnvDescription': 'The app cannot start because required environment variables are missing.',
|
|
110
|
+
'config.addToEnvFile': 'Add the following to your .env file:',
|
|
111
|
+
'config.envFilePath': 'File path: .env',
|
|
112
|
+
'config.reload': 'Reload after configuration',
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Detect best locale from browser language */
|
|
117
|
+
function detectLocale(): string {
|
|
118
|
+
const browserLang = navigator.language || navigator.languages?.[0] || 'zh-CN';
|
|
119
|
+
// Exact match
|
|
120
|
+
if (locales[browserLang]) return browserLang;
|
|
121
|
+
// Prefix match (e.g., 'zh' → 'zh-CN', 'en-US' → 'en')
|
|
122
|
+
const prefix = browserLang.split('-')[0];
|
|
123
|
+
for (const key of Object.keys(locales)) {
|
|
124
|
+
if (key.startsWith(prefix)) return key;
|
|
125
|
+
}
|
|
126
|
+
return 'en';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let currentLocale = $state(detectLocale());
|
|
130
|
+
|
|
131
|
+
export function setLocale(locale: string): void {
|
|
132
|
+
currentLocale = locale;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getLocale(): string {
|
|
136
|
+
return currentLocale;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getAvailableLocales(): string[] {
|
|
140
|
+
return Object.keys(locales);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function t(key: string, params?: Record<string, string | number>): string {
|
|
144
|
+
const locale = locales[currentLocale] ?? locales['en'];
|
|
145
|
+
let text = locale[key] ?? key;
|
|
146
|
+
|
|
147
|
+
if (params) {
|
|
148
|
+
for (const [k, v] of Object.entries(params)) {
|
|
149
|
+
text = text.replace(`{${k}}`, String(v));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function addTranslations(locale: string, translations: Record<string, string>): void {
|
|
156
|
+
locales[locale] = { ...locales[locale], ...translations };
|
|
157
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Core barrel exports
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
setDataProvider, getDataProvider,
|
|
5
|
+
setAuthProvider, getAuthProvider,
|
|
6
|
+
setResources, getResources, getResource,
|
|
7
|
+
} from './context';
|
|
8
|
+
export {
|
|
9
|
+
useList, useInfiniteList,
|
|
10
|
+
useOne, useShow,
|
|
11
|
+
useSelect, useMany,
|
|
12
|
+
useCustom, useApiUrl,
|
|
13
|
+
useCreate, useCreateMany,
|
|
14
|
+
useUpdate, useUpdateMany,
|
|
15
|
+
useDelete, useDeleteMany,
|
|
16
|
+
useForm, useTable,
|
|
17
|
+
useNavigation,
|
|
18
|
+
useResource,
|
|
19
|
+
} from './hooks.svelte';
|
|
20
|
+
export { matchRoute, navigate, currentPath } from './router';
|
|
21
|
+
export { readURLState, writeURLState } from './url-sync';
|
|
22
|
+
export { setAccessControl, canAccess, canAccessAsync } from './permissions';
|
|
23
|
+
export { useLive } from './live';
|
|
24
|
+
export { toast } from './toast.svelte';
|
|
25
|
+
export { t, setLocale, getLocale, getAvailableLocales, addTranslations } from './i18n.svelte';
|
|
26
|
+
export { audit, setAuditHandler } from './audit';
|
|
27
|
+
export { getTheme, setTheme, toggleTheme, getResolvedTheme } from './theme.svelte';
|
|
28
|
+
export type { ThemeMode } from './theme.svelte';
|
|
29
|
+
|
|
30
|
+
export type {
|
|
31
|
+
DataProvider, AuthProvider, NotificationProvider, MutationMode,
|
|
32
|
+
GetListParams, GetListResult,
|
|
33
|
+
GetOneParams, GetOneResult,
|
|
34
|
+
GetManyParams, GetManyResult,
|
|
35
|
+
CreateParams, CreateResult,
|
|
36
|
+
CreateManyParams, CreateManyResult,
|
|
37
|
+
UpdateParams, UpdateResult,
|
|
38
|
+
UpdateManyParams, UpdateManyResult,
|
|
39
|
+
DeleteParams, DeleteResult,
|
|
40
|
+
DeleteManyParams, DeleteManyResult,
|
|
41
|
+
CustomParams, CustomResult,
|
|
42
|
+
Pagination, Sort, Filter, Identity,
|
|
43
|
+
ResourceDefinition, FieldDefinition,
|
|
44
|
+
AuthActionResult, CheckResult,
|
|
45
|
+
} from './types';
|
|
46
|
+
export type { LiveProvider, LiveEvent } from './live';
|
|
47
|
+
export type { Action, AccessControlResult, AccessControlFn } from './permissions';
|
|
48
|
+
export type { AuditEntry, AuditHandler } from './audit';
|
package/src/live.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// LiveProvider — Real-time subscription interface + useLive hook
|
|
2
|
+
|
|
3
|
+
import { useQueryClient } from '@tanstack/svelte-query';
|
|
4
|
+
|
|
5
|
+
export interface LiveProvider {
|
|
6
|
+
subscribe(params: { resource: string; callback: (event: LiveEvent) => void }): () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LiveEvent {
|
|
10
|
+
type: 'INSERT' | 'UPDATE' | 'DELETE';
|
|
11
|
+
resource: string;
|
|
12
|
+
payload: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Hook to auto-invalidate queries on real-time changes
|
|
16
|
+
export function useLive(liveProvider: LiveProvider, resource: string): void {
|
|
17
|
+
const queryClient = useQueryClient();
|
|
18
|
+
|
|
19
|
+
$effect(() => {
|
|
20
|
+
const unsubscribe = liveProvider.subscribe({
|
|
21
|
+
resource,
|
|
22
|
+
callback: () => {
|
|
23
|
+
queryClient.invalidateQueries({ queryKey: [resource] });
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
return unsubscribe;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Permission / Access Control
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export type Action = 'list' | 'create' | 'edit' | 'delete' | 'export';
|
|
5
|
+
|
|
6
|
+
export interface AccessControlResult {
|
|
7
|
+
can: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type AccessControlFn = (
|
|
12
|
+
resource: string,
|
|
13
|
+
action: Action,
|
|
14
|
+
params?: Record<string, unknown>,
|
|
15
|
+
) => AccessControlResult | Promise<AccessControlResult>;
|
|
16
|
+
|
|
17
|
+
let accessControlFn: AccessControlFn | null = null;
|
|
18
|
+
|
|
19
|
+
export function setAccessControl(fn: AccessControlFn): void {
|
|
20
|
+
accessControlFn = fn;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function canAccess(resource: string, action: Action, params?: Record<string, unknown>): AccessControlResult {
|
|
24
|
+
if (!accessControlFn) return { can: true };
|
|
25
|
+
const result = accessControlFn(resource, action, params);
|
|
26
|
+
if (result instanceof Promise) {
|
|
27
|
+
console.warn('[permissions] canAccess called synchronously but accessControlFn returned a Promise. Defaulting to { can: true }. Use canAccessAsync() instead.');
|
|
28
|
+
return { can: true };
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function canAccessAsync(resource: string, action: Action, params?: Record<string, unknown>): Promise<AccessControlResult> {
|
|
34
|
+
if (!accessControlFn) return { can: true };
|
|
35
|
+
return accessControlFn(resource, action, params);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Example usage:
|
|
39
|
+
// setAccessControl((resource, action) => {
|
|
40
|
+
// if (resource === 'users' && action === 'delete') return { can: false, reason: '无权删除用户' };
|
|
41
|
+
// return { can: true };
|
|
42
|
+
// });
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Minimal hash router for Svelte 5 runes mode
|
|
2
|
+
|
|
3
|
+
interface RouteMatch {
|
|
4
|
+
route: string;
|
|
5
|
+
params: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Parse route pattern like /products/edit/:id into regex
|
|
9
|
+
function compileRoute(pattern: string): { regex: RegExp; keys: string[] } {
|
|
10
|
+
const keys: string[] = [];
|
|
11
|
+
const regexStr = pattern
|
|
12
|
+
.replace(/:(\w+)/g, (_, key) => {
|
|
13
|
+
keys.push(key);
|
|
14
|
+
return '([^/]+)';
|
|
15
|
+
})
|
|
16
|
+
.replace(/\//g, '\\/');
|
|
17
|
+
return { regex: new RegExp(`^${regexStr}$`), keys };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function matchRoute(
|
|
21
|
+
hash: string,
|
|
22
|
+
routes: string[]
|
|
23
|
+
): RouteMatch | null {
|
|
24
|
+
const path = hash.replace(/^#/, '') || '/';
|
|
25
|
+
|
|
26
|
+
for (const pattern of routes) {
|
|
27
|
+
const { regex, keys } = compileRoute(pattern);
|
|
28
|
+
const match = path.match(regex);
|
|
29
|
+
if (match) {
|
|
30
|
+
const params: Record<string, string> = {};
|
|
31
|
+
keys.forEach((key, i) => {
|
|
32
|
+
params[key] = match[i + 1];
|
|
33
|
+
});
|
|
34
|
+
return { route: pattern, params };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function navigate(path: string): void {
|
|
41
|
+
window.location.hash = path;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function currentPath(): string {
|
|
45
|
+
return window.location.hash.replace(/^#/, '') || '/';
|
|
46
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Theme — dark/light/system mode management (Svelte 5 runes)
|
|
2
|
+
|
|
3
|
+
export type ThemeMode = 'light' | 'dark' | 'system';
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = 'svadmin-theme';
|
|
6
|
+
|
|
7
|
+
function getStoredTheme(): ThemeMode {
|
|
8
|
+
if (typeof localStorage === 'undefined') return 'system';
|
|
9
|
+
return (localStorage.getItem(STORAGE_KEY) as ThemeMode) ?? 'system';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getSystemPreference(): 'light' | 'dark' {
|
|
13
|
+
if (typeof window === 'undefined') return 'light';
|
|
14
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let mode = $state<ThemeMode>(getStoredTheme());
|
|
18
|
+
|
|
19
|
+
function applyTheme(m: ThemeMode): void {
|
|
20
|
+
const resolved = m === 'system' ? getSystemPreference() : m;
|
|
21
|
+
if (typeof document !== 'undefined') {
|
|
22
|
+
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Apply on init
|
|
27
|
+
applyTheme(mode);
|
|
28
|
+
|
|
29
|
+
// Listen for system preference changes
|
|
30
|
+
if (typeof window !== 'undefined') {
|
|
31
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
32
|
+
if (mode === 'system') applyTheme('system');
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getTheme(): ThemeMode {
|
|
37
|
+
return mode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setTheme(newMode: ThemeMode): void {
|
|
41
|
+
mode = newMode;
|
|
42
|
+
if (typeof localStorage !== 'undefined') {
|
|
43
|
+
localStorage.setItem(STORAGE_KEY, newMode);
|
|
44
|
+
}
|
|
45
|
+
applyTheme(newMode);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function toggleTheme(): void {
|
|
49
|
+
const resolved = mode === 'system' ? getSystemPreference() : mode;
|
|
50
|
+
setTheme(resolved === 'dark' ? 'light' : 'dark');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolved theme (always 'light' or 'dark', never 'system') */
|
|
54
|
+
export function getResolvedTheme(): 'light' | 'dark' {
|
|
55
|
+
return mode === 'system' ? getSystemPreference() : mode;
|
|
56
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Toast notification system — Svelte 5 runes-based
|
|
2
|
+
|
|
3
|
+
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
|
4
|
+
|
|
5
|
+
export interface Toast {
|
|
6
|
+
id: number;
|
|
7
|
+
type: ToastType;
|
|
8
|
+
message: string;
|
|
9
|
+
duration: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let nextId = 0;
|
|
13
|
+
let toasts = $state<Toast[]>([]);
|
|
14
|
+
|
|
15
|
+
export function getToasts(): Toast[] {
|
|
16
|
+
return toasts;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function addToast(type: ToastType, message: string, duration = 3000): void {
|
|
20
|
+
const id = nextId++;
|
|
21
|
+
toasts = [...toasts, { id, type, message, duration }];
|
|
22
|
+
if (duration > 0) {
|
|
23
|
+
setTimeout(() => removeToast(id), duration);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function removeToast(id: number): void {
|
|
28
|
+
toasts = toasts.filter(t => t.id !== id);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Convenience methods
|
|
32
|
+
export const toast = {
|
|
33
|
+
success: (msg: string, duration?: number) => addToast('success', msg, duration),
|
|
34
|
+
error: (msg: string, duration?: number) => addToast('error', msg, duration ?? 5000),
|
|
35
|
+
info: (msg: string, duration?: number) => addToast('info', msg, duration),
|
|
36
|
+
warning: (msg: string, duration?: number) => addToast('warning', msg, duration ?? 4000),
|
|
37
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Core type definitions — 100% Refine-compatible DataProvider + AuthProvider + Providers
|
|
2
|
+
|
|
3
|
+
// ─── DataProvider ─────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface Pagination {
|
|
6
|
+
current?: number;
|
|
7
|
+
pageSize?: number;
|
|
8
|
+
mode?: 'server' | 'client' | 'off';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Sort {
|
|
12
|
+
field: string;
|
|
13
|
+
order: 'asc' | 'desc';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Filter {
|
|
17
|
+
field: string;
|
|
18
|
+
operator: 'eq' | 'ne' | 'lt' | 'gt' | 'lte' | 'gte' | 'contains' | 'in' | 'null' | 'between';
|
|
19
|
+
value: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GetListParams {
|
|
23
|
+
resource: string;
|
|
24
|
+
pagination?: Pagination;
|
|
25
|
+
sorters?: Sort[];
|
|
26
|
+
filters?: Filter[];
|
|
27
|
+
meta?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GetListResult<T = Record<string, unknown>> {
|
|
31
|
+
data: T[];
|
|
32
|
+
total: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GetOneParams {
|
|
36
|
+
resource: string;
|
|
37
|
+
id: string | number;
|
|
38
|
+
meta?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GetOneResult<T = Record<string, unknown>> {
|
|
42
|
+
data: T;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GetManyParams {
|
|
46
|
+
resource: string;
|
|
47
|
+
ids: (string | number)[];
|
|
48
|
+
meta?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GetManyResult<T = Record<string, unknown>> {
|
|
52
|
+
data: T[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreateParams {
|
|
56
|
+
resource: string;
|
|
57
|
+
variables: Record<string, unknown>;
|
|
58
|
+
meta?: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CreateResult<T = Record<string, unknown>> {
|
|
62
|
+
data: T;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface CreateManyParams {
|
|
66
|
+
resource: string;
|
|
67
|
+
variables: Record<string, unknown>[];
|
|
68
|
+
meta?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CreateManyResult<T = Record<string, unknown>> {
|
|
72
|
+
data: T[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface UpdateParams {
|
|
76
|
+
resource: string;
|
|
77
|
+
id: string | number;
|
|
78
|
+
variables: Record<string, unknown>;
|
|
79
|
+
meta?: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface UpdateResult<T = Record<string, unknown>> {
|
|
83
|
+
data: T;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface UpdateManyParams {
|
|
87
|
+
resource: string;
|
|
88
|
+
ids: (string | number)[];
|
|
89
|
+
variables: Record<string, unknown>;
|
|
90
|
+
meta?: Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface UpdateManyResult<T = Record<string, unknown>> {
|
|
94
|
+
data: T[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface DeleteParams {
|
|
98
|
+
resource: string;
|
|
99
|
+
id: string | number;
|
|
100
|
+
variables?: Record<string, unknown>;
|
|
101
|
+
meta?: Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DeleteResult<T = Record<string, unknown>> {
|
|
105
|
+
data: T;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DeleteManyParams {
|
|
109
|
+
resource: string;
|
|
110
|
+
ids: (string | number)[];
|
|
111
|
+
variables?: Record<string, unknown>;
|
|
112
|
+
meta?: Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface DeleteManyResult<T = Record<string, unknown>> {
|
|
116
|
+
data: T[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface CustomParams {
|
|
120
|
+
url: string;
|
|
121
|
+
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
122
|
+
payload?: Record<string, unknown>;
|
|
123
|
+
query?: Record<string, unknown>;
|
|
124
|
+
headers?: Record<string, string>;
|
|
125
|
+
sorters?: Sort[];
|
|
126
|
+
filters?: Filter[];
|
|
127
|
+
meta?: Record<string, unknown>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface CustomResult<T = unknown> {
|
|
131
|
+
data: T;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface DataProvider {
|
|
135
|
+
// Required methods
|
|
136
|
+
getList: <T = Record<string, unknown>>(params: GetListParams) => Promise<GetListResult<T>>;
|
|
137
|
+
getOne: <T = Record<string, unknown>>(params: GetOneParams) => Promise<GetOneResult<T>>;
|
|
138
|
+
create: <T = Record<string, unknown>>(params: CreateParams) => Promise<CreateResult<T>>;
|
|
139
|
+
update: <T = Record<string, unknown>>(params: UpdateParams) => Promise<UpdateResult<T>>;
|
|
140
|
+
deleteOne: <T = Record<string, unknown>>(params: DeleteParams) => Promise<DeleteResult<T>>;
|
|
141
|
+
getApiUrl: () => string;
|
|
142
|
+
|
|
143
|
+
// Optional bulk methods
|
|
144
|
+
getMany?: <T = Record<string, unknown>>(params: GetManyParams) => Promise<GetManyResult<T>>;
|
|
145
|
+
createMany?: <T = Record<string, unknown>>(params: CreateManyParams) => Promise<CreateManyResult<T>>;
|
|
146
|
+
updateMany?: <T = Record<string, unknown>>(params: UpdateManyParams) => Promise<UpdateManyResult<T>>;
|
|
147
|
+
deleteMany?: <T = Record<string, unknown>>(params: DeleteManyParams) => Promise<DeleteManyResult<T>>;
|
|
148
|
+
|
|
149
|
+
// Optional custom method
|
|
150
|
+
custom?: <T = unknown>(params: CustomParams) => Promise<CustomResult<T>>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── AuthProvider ─────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export interface Identity {
|
|
156
|
+
id?: string;
|
|
157
|
+
name?: string;
|
|
158
|
+
email?: string;
|
|
159
|
+
avatar?: string;
|
|
160
|
+
[key: string]: unknown;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface AuthActionResult {
|
|
164
|
+
success: boolean;
|
|
165
|
+
redirectTo?: string;
|
|
166
|
+
error?: { message: string; name?: string };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface CheckResult {
|
|
170
|
+
authenticated: boolean;
|
|
171
|
+
redirectTo?: string;
|
|
172
|
+
error?: { message: string; name?: string };
|
|
173
|
+
logout?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface AuthProvider {
|
|
177
|
+
login: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
178
|
+
logout: (params?: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
179
|
+
check: (params?: Record<string, unknown>) => Promise<CheckResult>;
|
|
180
|
+
getIdentity: () => Promise<Identity | null>;
|
|
181
|
+
getPermissions?: (params?: Record<string, unknown>) => Promise<unknown>;
|
|
182
|
+
register?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
183
|
+
forgotPassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
184
|
+
updatePassword?: (params: Record<string, unknown>) => Promise<AuthActionResult>;
|
|
185
|
+
onError?: (error: unknown) => Promise<{ redirectTo?: string; logout?: boolean }>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── NotificationProvider ─────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export interface NotificationProvider {
|
|
191
|
+
open: (params: { type: 'success' | 'error' | 'warning' | 'info'; message: string; description?: string; key?: string }) => void;
|
|
192
|
+
close: (key: string) => void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── MutationMode ─────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export type MutationMode = 'pessimistic' | 'optimistic' | 'undoable';
|
|
198
|
+
|
|
199
|
+
// ─── ResourceDefinition ───────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export interface ResourceDefinition {
|
|
202
|
+
name: string;
|
|
203
|
+
label: string;
|
|
204
|
+
icon?: string;
|
|
205
|
+
primaryKey?: string;
|
|
206
|
+
fields: FieldDefinition[];
|
|
207
|
+
defaultSort?: Sort;
|
|
208
|
+
pageSize?: number;
|
|
209
|
+
canCreate?: boolean;
|
|
210
|
+
canEdit?: boolean;
|
|
211
|
+
canDelete?: boolean;
|
|
212
|
+
canShow?: boolean;
|
|
213
|
+
meta?: Record<string, unknown>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface FieldDefinition {
|
|
217
|
+
key: string;
|
|
218
|
+
label: string;
|
|
219
|
+
type: 'text' | 'number' | 'boolean' | 'date' | 'select' | 'multiselect' | 'tags'
|
|
220
|
+
| 'textarea' | 'richtext' | 'image' | 'images' | 'json' | 'relation' | 'color' | 'url' | 'email' | 'phone';
|
|
221
|
+
required?: boolean;
|
|
222
|
+
searchable?: boolean;
|
|
223
|
+
sortable?: boolean;
|
|
224
|
+
width?: string;
|
|
225
|
+
showInList?: boolean;
|
|
226
|
+
showInForm?: boolean;
|
|
227
|
+
showInCreate?: boolean;
|
|
228
|
+
showInEdit?: boolean;
|
|
229
|
+
showInShow?: boolean;
|
|
230
|
+
options?: { label: string; value: string | number }[];
|
|
231
|
+
defaultValue?: unknown;
|
|
232
|
+
// Relation support
|
|
233
|
+
resource?: string; // related resource name
|
|
234
|
+
optionLabel?: string; // field to use as label
|
|
235
|
+
optionValue?: string; // field to use as value
|
|
236
|
+
}
|
package/src/url-sync.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// URL state sync — sync pagination/sort/filters with hash params
|
|
2
|
+
|
|
3
|
+
export interface URLState {
|
|
4
|
+
page?: number;
|
|
5
|
+
pageSize?: number;
|
|
6
|
+
sortField?: string;
|
|
7
|
+
sortOrder?: 'asc' | 'desc';
|
|
8
|
+
search?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readURLState(): URLState {
|
|
12
|
+
const hash = window.location.hash;
|
|
13
|
+
const queryIdx = hash.indexOf('?');
|
|
14
|
+
if (queryIdx === -1) return {};
|
|
15
|
+
|
|
16
|
+
const params = new URLSearchParams(hash.slice(queryIdx + 1));
|
|
17
|
+
const state: URLState = {};
|
|
18
|
+
|
|
19
|
+
const page = params.get('page');
|
|
20
|
+
if (page) state.page = parseInt(page, 10);
|
|
21
|
+
|
|
22
|
+
const pageSize = params.get('pageSize');
|
|
23
|
+
if (pageSize) state.pageSize = parseInt(pageSize, 10);
|
|
24
|
+
|
|
25
|
+
const sortField = params.get('sort');
|
|
26
|
+
if (sortField) state.sortField = sortField;
|
|
27
|
+
|
|
28
|
+
const sortOrder = params.get('order');
|
|
29
|
+
if (sortOrder === 'asc' || sortOrder === 'desc') state.sortOrder = sortOrder;
|
|
30
|
+
|
|
31
|
+
const search = params.get('q');
|
|
32
|
+
if (search) state.search = search;
|
|
33
|
+
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeURLState(state: URLState): void {
|
|
38
|
+
const hash = window.location.hash;
|
|
39
|
+
const pathIdx = hash.indexOf('?');
|
|
40
|
+
const basePath = pathIdx === -1 ? hash : hash.slice(0, pathIdx);
|
|
41
|
+
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
if (state.page && state.page > 1) params.set('page', String(state.page));
|
|
44
|
+
if (state.pageSize && state.pageSize !== 10) params.set('pageSize', String(state.pageSize));
|
|
45
|
+
if (state.sortField) params.set('sort', state.sortField);
|
|
46
|
+
if (state.sortOrder) params.set('order', state.sortOrder);
|
|
47
|
+
if (state.search) params.set('q', state.search);
|
|
48
|
+
|
|
49
|
+
const qs = params.toString();
|
|
50
|
+
const newHash = qs ? `${basePath}?${qs}` : basePath;
|
|
51
|
+
|
|
52
|
+
if (window.location.hash !== newHash) {
|
|
53
|
+
history.replaceState(null, '', newHash);
|
|
54
|
+
}
|
|
55
|
+
}
|