@svadmin/core 0.0.2 → 0.0.3
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 +1 -1
- package/src/data-transfer.ts +139 -0
- package/src/hooks.svelte.ts +74 -4
- package/src/i18n.svelte.ts +64 -0
- package/src/index.ts +3 -0
- package/src/notification.svelte.ts +45 -0
- package/src/types.ts +2 -0
- package/src/useCan.ts +37 -0
package/package.json
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Data transfer utilities — CSV export/import
|
|
2
|
+
|
|
3
|
+
import { getDataProvider } from './context';
|
|
4
|
+
import type { GetListResult } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* useExport — export all records from a resource as CSV download
|
|
8
|
+
*/
|
|
9
|
+
export function useExport(resource: string) {
|
|
10
|
+
let isExporting = $state(false);
|
|
11
|
+
|
|
12
|
+
async function exportCSV(opts?: { filename?: string; fields?: string[] }) {
|
|
13
|
+
const provider = getDataProvider();
|
|
14
|
+
isExporting = true;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// Fetch all records (up to 10000)
|
|
18
|
+
const result: GetListResult = await provider.getList({
|
|
19
|
+
resource,
|
|
20
|
+
pagination: { current: 1, pageSize: 10000 },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (result.data.length === 0) return;
|
|
24
|
+
|
|
25
|
+
const records = result.data as Record<string, unknown>[];
|
|
26
|
+
const fields = opts?.fields ?? Object.keys(records[0]);
|
|
27
|
+
|
|
28
|
+
// Build CSV
|
|
29
|
+
const header = fields.join(',');
|
|
30
|
+
const rows = records.map(record =>
|
|
31
|
+
fields.map(f => {
|
|
32
|
+
const val = record[f];
|
|
33
|
+
const str = val === null || val === undefined ? '' : String(val);
|
|
34
|
+
// Escape commas and quotes
|
|
35
|
+
return str.includes(',') || str.includes('"') || str.includes('\n')
|
|
36
|
+
? `"${str.replace(/"/g, '""')}"`
|
|
37
|
+
: str;
|
|
38
|
+
}).join(',')
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const csv = [header, ...rows].join('\n');
|
|
42
|
+
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
43
|
+
const url = URL.createObjectURL(blob);
|
|
44
|
+
const a = document.createElement('a');
|
|
45
|
+
a.href = url;
|
|
46
|
+
a.download = opts?.filename ?? `${resource}_export.csv`;
|
|
47
|
+
a.click();
|
|
48
|
+
URL.revokeObjectURL(url);
|
|
49
|
+
} finally {
|
|
50
|
+
isExporting = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
exportCSV,
|
|
56
|
+
get isExporting() { return isExporting; },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* useImport — import records from a CSV file
|
|
62
|
+
*/
|
|
63
|
+
export function useImport(resource: string) {
|
|
64
|
+
let isImporting = $state(false);
|
|
65
|
+
let importResult = $state<{ success: number; failed: number } | null>(null);
|
|
66
|
+
|
|
67
|
+
async function importCSV(file: File): Promise<{ success: number; failed: number }> {
|
|
68
|
+
const provider = getDataProvider();
|
|
69
|
+
isImporting = true;
|
|
70
|
+
let success = 0;
|
|
71
|
+
let failed = 0;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const text = await file.text();
|
|
75
|
+
const lines = text.split('\n').filter(l => l.trim() !== '');
|
|
76
|
+
if (lines.length < 2) return { success: 0, failed: 0 };
|
|
77
|
+
|
|
78
|
+
const headers = parseCSVLine(lines[0]);
|
|
79
|
+
|
|
80
|
+
for (let i = 1; i < lines.length; i++) {
|
|
81
|
+
const values = parseCSVLine(lines[i]);
|
|
82
|
+
const record: Record<string, unknown> = {};
|
|
83
|
+
headers.forEach((h, idx) => {
|
|
84
|
+
record[h] = values[idx] ?? '';
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await provider.create({ resource, variables: record });
|
|
89
|
+
success++;
|
|
90
|
+
} catch {
|
|
91
|
+
failed++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
importResult = { success, failed };
|
|
96
|
+
return { success, failed };
|
|
97
|
+
} finally {
|
|
98
|
+
isImporting = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
importCSV,
|
|
104
|
+
get isImporting() { return isImporting; },
|
|
105
|
+
get importResult() { return importResult; },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse a single CSV line respecting quoted fields */
|
|
110
|
+
function parseCSVLine(line: string): string[] {
|
|
111
|
+
const result: string[] = [];
|
|
112
|
+
let current = '';
|
|
113
|
+
let inQuotes = false;
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < line.length; i++) {
|
|
116
|
+
const ch = line[i];
|
|
117
|
+
if (inQuotes) {
|
|
118
|
+
if (ch === '"' && line[i + 1] === '"') {
|
|
119
|
+
current += '"';
|
|
120
|
+
i++;
|
|
121
|
+
} else if (ch === '"') {
|
|
122
|
+
inQuotes = false;
|
|
123
|
+
} else {
|
|
124
|
+
current += ch;
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
if (ch === '"') {
|
|
128
|
+
inQuotes = true;
|
|
129
|
+
} else if (ch === ',') {
|
|
130
|
+
result.push(current.trim());
|
|
131
|
+
current = '';
|
|
132
|
+
} else {
|
|
133
|
+
current += ch;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
result.push(current.trim());
|
|
138
|
+
return result;
|
|
139
|
+
}
|
package/src/hooks.svelte.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { toast } from './toast.svelte';
|
|
|
13
13
|
import { audit } from './audit';
|
|
14
14
|
import { navigate } from './router';
|
|
15
15
|
import { t } from './i18n.svelte';
|
|
16
|
+
import { readURLState, writeURLState } from './url-sync';
|
|
16
17
|
|
|
17
18
|
// ─── useList ────────────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -391,12 +392,42 @@ interface UseFormOptions {
|
|
|
391
392
|
onMutationSuccess?: (data: unknown) => void;
|
|
392
393
|
onMutationError?: (error: Error) => void;
|
|
393
394
|
meta?: Record<string, unknown>;
|
|
395
|
+
validate?: (values: Record<string, unknown>) => Record<string, string> | null;
|
|
394
396
|
}
|
|
395
397
|
|
|
396
398
|
export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
|
|
397
399
|
const provider = getDataProvider();
|
|
398
400
|
const queryClient = useQueryClient();
|
|
399
|
-
const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta } = options;
|
|
401
|
+
const { resource, action, id, redirect = 'list', onMutationSuccess, onMutationError, meta, validate } = options;
|
|
402
|
+
|
|
403
|
+
// Validation state
|
|
404
|
+
let errors = $state<Record<string, string>>({});
|
|
405
|
+
|
|
406
|
+
function setFieldError(field: string, message: string) {
|
|
407
|
+
errors = { ...errors, [field]: message };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function clearErrors() {
|
|
411
|
+
errors = {};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function clearFieldError(field: string) {
|
|
415
|
+
const next = { ...errors };
|
|
416
|
+
delete next[field];
|
|
417
|
+
errors = next;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function runValidation(values: Record<string, unknown>): boolean {
|
|
421
|
+
clearErrors();
|
|
422
|
+
if (validate) {
|
|
423
|
+
const result = validate(values);
|
|
424
|
+
if (result && Object.keys(result).length > 0) {
|
|
425
|
+
errors = result;
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
400
431
|
|
|
401
432
|
// Fetch existing data for edit/clone
|
|
402
433
|
const query = (action === 'edit' || action === 'clone') && id != null
|
|
@@ -450,6 +481,12 @@ export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
|
|
|
450
481
|
}
|
|
451
482
|
|
|
452
483
|
async function onFinish(values: Record<string, unknown>) {
|
|
484
|
+
// Run validation before submitting
|
|
485
|
+
if (!runValidation(values)) {
|
|
486
|
+
toast.warning(t('validation.required'));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
453
490
|
if (action === 'create' || action === 'clone') {
|
|
454
491
|
// @ts-expect-error $ rune prefix — Svelte compiler transforms this
|
|
455
492
|
await $createMut.mutateAsync(values);
|
|
@@ -467,6 +504,10 @@ export function useForm<T = Record<string, unknown>>(options: UseFormOptions) {
|
|
|
467
504
|
},
|
|
468
505
|
mutation: action === 'edit' ? updateMut : createMut,
|
|
469
506
|
onFinish,
|
|
507
|
+
get errors() { return errors; },
|
|
508
|
+
setFieldError,
|
|
509
|
+
clearErrors,
|
|
510
|
+
clearFieldError,
|
|
470
511
|
};
|
|
471
512
|
}
|
|
472
513
|
|
|
@@ -483,10 +524,27 @@ interface UseTableOptions {
|
|
|
483
524
|
}
|
|
484
525
|
|
|
485
526
|
export function useTable<T = Record<string, unknown>>(options: UseTableOptions) {
|
|
486
|
-
const { resource, meta } = options;
|
|
527
|
+
const { resource, meta, syncWithLocation = false } = options;
|
|
528
|
+
|
|
529
|
+
// Read initial state from URL if syncWithLocation
|
|
530
|
+
let initialPagination = options.pagination ?? { current: 1, pageSize: 10 };
|
|
531
|
+
let initialSorters = options.sorters ?? [];
|
|
532
|
+
|
|
533
|
+
if (syncWithLocation) {
|
|
534
|
+
const urlState = readURLState();
|
|
535
|
+
if (urlState.page || urlState.pageSize) {
|
|
536
|
+
initialPagination = {
|
|
537
|
+
current: urlState.page ?? initialPagination.current,
|
|
538
|
+
pageSize: urlState.pageSize ?? initialPagination.pageSize,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
if (urlState.sortField) {
|
|
542
|
+
initialSorters = [{ field: urlState.sortField, order: urlState.sortOrder ?? 'asc' }];
|
|
543
|
+
}
|
|
544
|
+
}
|
|
487
545
|
|
|
488
|
-
let pagination = $state<Pagination>(
|
|
489
|
-
let sorters = $state<Sort[]>(
|
|
546
|
+
let pagination = $state<Pagination>(initialPagination);
|
|
547
|
+
let sorters = $state<Sort[]>(initialSorters);
|
|
490
548
|
let filters = $state<Filter[]>(options.filters ?? []);
|
|
491
549
|
|
|
492
550
|
const query = useList<T>({ resource, pagination, sorters, filters, meta });
|
|
@@ -496,6 +554,18 @@ export function useTable<T = Record<string, unknown>>(options: UseTableOptions)
|
|
|
496
554
|
function setPage(page: number) { pagination = { ...pagination, current: page }; }
|
|
497
555
|
function setPageSize(size: number) { pagination = { ...pagination, pageSize: size, current: 1 }; }
|
|
498
556
|
|
|
557
|
+
// Sync state to URL when syncWithLocation is enabled
|
|
558
|
+
if (syncWithLocation) {
|
|
559
|
+
$effect(() => {
|
|
560
|
+
writeURLState({
|
|
561
|
+
page: pagination.current,
|
|
562
|
+
pageSize: pagination.pageSize,
|
|
563
|
+
sortField: sorters[0]?.field,
|
|
564
|
+
sortOrder: sorters[0]?.order,
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
499
569
|
return {
|
|
500
570
|
query,
|
|
501
571
|
pagination,
|
package/src/i18n.svelte.ts
CHANGED
|
@@ -56,6 +56,38 @@ const locales: Record<string, Locale> = {
|
|
|
56
56
|
'config.addToEnvFile': '请在 .env 文件中添加以下内容:',
|
|
57
57
|
'config.envFilePath': '文件路径: .env',
|
|
58
58
|
'config.reload': '配置完成后刷新页面',
|
|
59
|
+
// Auth pages
|
|
60
|
+
'auth.login': '登录',
|
|
61
|
+
'auth.register': '注册',
|
|
62
|
+
'auth.forgotPassword': '忘记密码',
|
|
63
|
+
'auth.resetPassword': '重置密码',
|
|
64
|
+
'auth.email': '邮箱',
|
|
65
|
+
'auth.password': '密码',
|
|
66
|
+
'auth.confirmPassword': '确认密码',
|
|
67
|
+
'auth.rememberMe': '记住我',
|
|
68
|
+
'auth.loginButton': '登录',
|
|
69
|
+
'auth.registerButton': '注册',
|
|
70
|
+
'auth.sendResetLink': '发送重置链接',
|
|
71
|
+
'auth.backToLogin': '返回登录',
|
|
72
|
+
'auth.noAccount': '还没有账号?',
|
|
73
|
+
'auth.hasAccount': '已有账号?',
|
|
74
|
+
'auth.forgotPasswordLink': '忘记密码?',
|
|
75
|
+
'auth.forgotPasswordDescription': '输入您的邮箱地址,我们将发送重置密码的链接。',
|
|
76
|
+
'auth.resetLinkSent': '重置链接已发送,请查看您的邮箱。',
|
|
77
|
+
'auth.passwordMismatch': '两次输入的密码不一致',
|
|
78
|
+
'auth.emailRequired': '请输入邮箱',
|
|
79
|
+
'auth.passwordRequired': '请输入密码',
|
|
80
|
+
'auth.registerSuccess': '注册成功',
|
|
81
|
+
'auth.welcomeBack': '欢迎回来',
|
|
82
|
+
'auth.welcomeMessage': '登录以继续使用管理后台',
|
|
83
|
+
'auth.createAccount': '创建账号',
|
|
84
|
+
'auth.createAccountMessage': '填写信息以创建您的账号',
|
|
85
|
+
// Validation
|
|
86
|
+
'validation.required': '此字段为必填项',
|
|
87
|
+
'validation.minLength': '最少 {min} 个字符',
|
|
88
|
+
'validation.maxLength': '最多 {max} 个字符',
|
|
89
|
+
'validation.invalidEmail': '请输入有效的邮箱地址',
|
|
90
|
+
'validation.invalidFormat': '格式不正确',
|
|
59
91
|
},
|
|
60
92
|
'en': {
|
|
61
93
|
// Common
|
|
@@ -110,6 +142,38 @@ const locales: Record<string, Locale> = {
|
|
|
110
142
|
'config.addToEnvFile': 'Add the following to your .env file:',
|
|
111
143
|
'config.envFilePath': 'File path: .env',
|
|
112
144
|
'config.reload': 'Reload after configuration',
|
|
145
|
+
// Auth pages
|
|
146
|
+
'auth.login': 'Login',
|
|
147
|
+
'auth.register': 'Register',
|
|
148
|
+
'auth.forgotPassword': 'Forgot Password',
|
|
149
|
+
'auth.resetPassword': 'Reset Password',
|
|
150
|
+
'auth.email': 'Email',
|
|
151
|
+
'auth.password': 'Password',
|
|
152
|
+
'auth.confirmPassword': 'Confirm Password',
|
|
153
|
+
'auth.rememberMe': 'Remember me',
|
|
154
|
+
'auth.loginButton': 'Sign In',
|
|
155
|
+
'auth.registerButton': 'Sign Up',
|
|
156
|
+
'auth.sendResetLink': 'Send Reset Link',
|
|
157
|
+
'auth.backToLogin': 'Back to Login',
|
|
158
|
+
'auth.noAccount': "Don't have an account?",
|
|
159
|
+
'auth.hasAccount': 'Already have an account?',
|
|
160
|
+
'auth.forgotPasswordLink': 'Forgot password?',
|
|
161
|
+
'auth.forgotPasswordDescription': 'Enter your email address and we will send you a reset link.',
|
|
162
|
+
'auth.resetLinkSent': 'Reset link sent! Check your email.',
|
|
163
|
+
'auth.passwordMismatch': 'Passwords do not match',
|
|
164
|
+
'auth.emailRequired': 'Email is required',
|
|
165
|
+
'auth.passwordRequired': 'Password is required',
|
|
166
|
+
'auth.registerSuccess': 'Registration successful',
|
|
167
|
+
'auth.welcomeBack': 'Welcome Back',
|
|
168
|
+
'auth.welcomeMessage': 'Sign in to continue to admin panel',
|
|
169
|
+
'auth.createAccount': 'Create Account',
|
|
170
|
+
'auth.createAccountMessage': 'Fill in your details to create an account',
|
|
171
|
+
// Validation
|
|
172
|
+
'validation.required': 'This field is required',
|
|
173
|
+
'validation.minLength': 'Minimum {min} characters',
|
|
174
|
+
'validation.maxLength': 'Maximum {max} characters',
|
|
175
|
+
'validation.invalidEmail': 'Please enter a valid email address',
|
|
176
|
+
'validation.invalidFormat': 'Invalid format',
|
|
113
177
|
},
|
|
114
178
|
};
|
|
115
179
|
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export { readURLState, writeURLState } from './url-sync';
|
|
|
22
22
|
export { setAccessControl, canAccess, canAccessAsync } from './permissions';
|
|
23
23
|
export { useLive } from './live';
|
|
24
24
|
export { toast } from './toast.svelte';
|
|
25
|
+
export { notify, closeNotification, setNotificationProvider, getNotificationProvider } from './notification.svelte';
|
|
25
26
|
export { t, setLocale, getLocale, getAvailableLocales, addTranslations } from './i18n.svelte';
|
|
26
27
|
export { audit, setAuditHandler } from './audit';
|
|
27
28
|
export { getTheme, setTheme, toggleTheme, getResolvedTheme, getColorTheme, setColorTheme, colorThemes } from './theme.svelte';
|
|
@@ -46,3 +47,5 @@ export type {
|
|
|
46
47
|
export type { LiveProvider, LiveEvent } from './live';
|
|
47
48
|
export type { Action, AccessControlResult, AccessControlFn } from './permissions';
|
|
48
49
|
export type { AuditEntry, AuditHandler } from './audit';
|
|
50
|
+
export { useCan } from './useCan';
|
|
51
|
+
export { useExport, useImport } from './data-transfer';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Notification Provider — pluggable notification system
|
|
2
|
+
// Falls back to built-in toast when no provider is set
|
|
3
|
+
|
|
4
|
+
import { toast } from './toast.svelte';
|
|
5
|
+
import type { NotificationProvider } from './types';
|
|
6
|
+
|
|
7
|
+
let notificationProvider: NotificationProvider | null = null;
|
|
8
|
+
|
|
9
|
+
export function setNotificationProvider(provider: NotificationProvider): void {
|
|
10
|
+
notificationProvider = provider;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getNotificationProvider(): NotificationProvider | null {
|
|
14
|
+
return notificationProvider;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Send a notification through the configured provider, or fall back to toast.
|
|
19
|
+
*/
|
|
20
|
+
export function notify(params: {
|
|
21
|
+
type: 'success' | 'error' | 'warning' | 'info';
|
|
22
|
+
message: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
key?: string;
|
|
25
|
+
}): void {
|
|
26
|
+
if (notificationProvider) {
|
|
27
|
+
notificationProvider.open(params);
|
|
28
|
+
} else {
|
|
29
|
+
// Fallback to built-in toast
|
|
30
|
+
const { type, message } = params;
|
|
31
|
+
switch (type) {
|
|
32
|
+
case 'success': toast.success(message); break;
|
|
33
|
+
case 'error': toast.error(message); break;
|
|
34
|
+
case 'warning': toast.warning(message); break;
|
|
35
|
+
case 'info': toast.info(message); break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Close a notification by key (only works with custom provider).
|
|
42
|
+
*/
|
|
43
|
+
export function closeNotification(key: string): void {
|
|
44
|
+
notificationProvider?.close(key);
|
|
45
|
+
}
|
package/src/types.ts
CHANGED
package/src/useCan.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// useCan — reactive permission check hook
|
|
2
|
+
|
|
3
|
+
import { canAccessAsync } from './permissions';
|
|
4
|
+
import type { Action, AccessControlResult } from './permissions';
|
|
5
|
+
|
|
6
|
+
interface UseCanResult {
|
|
7
|
+
allowed: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Reactive permission check. Calls canAccessAsync() and returns reactive state.
|
|
14
|
+
* Usage: const can = useCan('posts', 'delete');
|
|
15
|
+
* if (can.allowed) { ... }
|
|
16
|
+
*/
|
|
17
|
+
export function useCan(resource: string, action: Action, params?: Record<string, unknown>): UseCanResult {
|
|
18
|
+
let allowed = $state(true);
|
|
19
|
+
let reason = $state<string | undefined>(undefined);
|
|
20
|
+
let isLoading = $state(true);
|
|
21
|
+
|
|
22
|
+
// Run the async check
|
|
23
|
+
canAccessAsync(resource, action, params).then((result: AccessControlResult) => {
|
|
24
|
+
allowed = result.can;
|
|
25
|
+
reason = result.reason;
|
|
26
|
+
isLoading = false;
|
|
27
|
+
}).catch(() => {
|
|
28
|
+
allowed = true; // default to allowed on error
|
|
29
|
+
isLoading = false;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
get allowed() { return allowed; },
|
|
34
|
+
get reason() { return reason; },
|
|
35
|
+
get isLoading() { return isLoading; },
|
|
36
|
+
};
|
|
37
|
+
}
|