@svadmin/core 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/auth-hooks.svelte.ts +58 -24
- package/src/context.svelte.ts +185 -46
- package/src/data-transfer.svelte.ts +51 -41
- package/src/export-format.ts +115 -0
- package/src/form-hooks.svelte.ts +322 -58
- package/src/helpers.ts +11 -7
- package/src/hook-utils.svelte.ts +13 -12
- package/src/hooks.svelte.ts +29 -23
- package/src/http.ts +114 -33
- package/src/i18n.svelte.ts +423 -21
- package/src/index.ts +11 -8
- package/src/mutation-hooks.svelte.ts +27 -16
- package/src/permissions.svelte.ts +4 -3
- package/src/query-hooks.svelte.ts +35 -15
- package/src/router.ts +28 -8
- package/src/routing-hooks.svelte.ts +22 -18
- package/src/table-hooks.svelte.ts +5 -3
- package/src/table-state.svelte.ts +6 -3
- package/src/task-hooks.svelte.ts +27 -14
- package/src/theme.svelte.ts +248 -154
- package/src/toast.svelte.ts +1 -2
- package/src/types.ts +29 -4
- package/src/unsaved-changes.svelte.ts +4 -38
- package/src/url-sync.ts +47 -8
- package/src/useCan.ts +3 -2
- package/src/useParsed.svelte.ts +39 -15
- package/src/utility-hooks.svelte.ts +119 -7
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { captureAdminContext } from './context.svelte';
|
|
2
2
|
import { useParsed } from './useParsed.svelte';
|
|
3
3
|
import type { Sort, Filter, BaseRecord } from './types';
|
|
4
|
+
import { downloadData } from './export-format';
|
|
5
|
+
import type { ExportFormat } from './export-format';
|
|
6
|
+
export { downloadData, toCsv, toJson, toXlsx, escapeCsvField } from './export-format';
|
|
7
|
+
export type { ExportFormat } from './export-format';
|
|
4
8
|
|
|
5
9
|
export interface UseExportOptions<TData extends BaseRecord = BaseRecord> {
|
|
6
10
|
resource?: string;
|
|
@@ -10,16 +14,19 @@ export interface UseExportOptions<TData extends BaseRecord = BaseRecord> {
|
|
|
10
14
|
maxItemCount?: number;
|
|
11
15
|
pageSize?: number;
|
|
12
16
|
download?: boolean;
|
|
17
|
+
/** 导出格式,默认 csv(兼容旧版) */
|
|
18
|
+
format?: ExportFormat;
|
|
13
19
|
meta?: Record<string, unknown>;
|
|
14
20
|
dataProviderName?: string;
|
|
15
21
|
onError?: (error: Error) => void;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
|
-
* useExport — export records from a resource as CSV download
|
|
25
|
+
* useExport — export records from a resource as CSV/JSON/XLSX download
|
|
20
26
|
* Supports mapData, sorters, filters, maxItemCount, pageSize
|
|
21
27
|
*/
|
|
22
28
|
export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExportOptions<TData> = {}) {
|
|
29
|
+
const adminContext = captureAdminContext();
|
|
23
30
|
const parsed = useParsed();
|
|
24
31
|
const resource = $derived(options.resource ?? parsed.resource ?? '');
|
|
25
32
|
let isLoading = $state(false);
|
|
@@ -29,7 +36,7 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
29
36
|
isLoading = true;
|
|
30
37
|
|
|
31
38
|
try {
|
|
32
|
-
const provider = getDataProviderForResource(exportResource, options.dataProviderName);
|
|
39
|
+
const provider = adminContext.getDataProviderForResource(exportResource, options.dataProviderName);
|
|
33
40
|
const batchSize = options.pageSize ?? 20;
|
|
34
41
|
const maxItems = options.maxItemCount ?? Infinity;
|
|
35
42
|
let allRecords: TData[] = [];
|
|
@@ -57,29 +64,7 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
57
64
|
: allRecords.map(r => r as unknown as Record<string, unknown>);
|
|
58
65
|
|
|
59
66
|
if (options.download !== false && typeof document !== 'undefined') {
|
|
60
|
-
|
|
61
|
-
const escapeCsvField = (s: string) => {
|
|
62
|
-
let val = s;
|
|
63
|
-
if (/^[=+@\t\r]/.test(val)) val = "'" + val;
|
|
64
|
-
return val.includes(',') || val.includes('"') || val.includes('\n') || val.includes('\r') ? `"${val.replace(/"/g, '""')}"` : val;
|
|
65
|
-
};
|
|
66
|
-
const header = fields.map(escapeCsvField).join(',');
|
|
67
|
-
const rows = mapped.map(record =>
|
|
68
|
-
fields.map(f => {
|
|
69
|
-
const val = record[f];
|
|
70
|
-
const str = val === null || val === undefined ? '' : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
71
|
-
return escapeCsvField(str);
|
|
72
|
-
}).join(',')
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const csv = [header, ...rows].join('\n');
|
|
76
|
-
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
77
|
-
const url = URL.createObjectURL(blob);
|
|
78
|
-
const a = document.createElement('a');
|
|
79
|
-
a.href = url;
|
|
80
|
-
a.download = `${exportResource}_export.csv`;
|
|
81
|
-
a.click();
|
|
82
|
-
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
67
|
+
downloadData(mapped, exportResource, options.format ?? 'csv');
|
|
83
68
|
}
|
|
84
69
|
|
|
85
70
|
return allRecords;
|
|
@@ -99,6 +84,8 @@ export function useExport<TData extends BaseRecord = BaseRecord>(options: UseExp
|
|
|
99
84
|
|
|
100
85
|
export interface UseImportOptions<TData = Record<string, unknown>> {
|
|
101
86
|
resource?: string;
|
|
87
|
+
/** 指定导入文件格式;默认 auto = 根据文件扩展名推断。注意:import 仅支持 csv 和 json,不支持 xlsx 解析 */
|
|
88
|
+
format?: 'auto' | 'csv' | 'json';
|
|
102
89
|
mapData?: (item: Record<string, unknown>) => TData;
|
|
103
90
|
batchSize?: number;
|
|
104
91
|
onFinish?: (result: { succeeded: unknown[]; errored: { request: unknown; error: unknown }[] }) => void;
|
|
@@ -112,6 +99,7 @@ export interface UseImportOptions<TData = Record<string, unknown>> {
|
|
|
112
99
|
* Supports mapData, batchSize, onFinish, onProgress, meta
|
|
113
100
|
*/
|
|
114
101
|
export function useImport<TData = Record<string, unknown>>(options: UseImportOptions<TData> = {}) {
|
|
102
|
+
const adminContext = captureAdminContext();
|
|
115
103
|
const parsed = useParsed();
|
|
116
104
|
const resource = $derived(options.resource ?? parsed.resource ?? '');
|
|
117
105
|
let isLoading = $state(false);
|
|
@@ -124,28 +112,47 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
124
112
|
const errored: { request: unknown; error: unknown }[] = [];
|
|
125
113
|
|
|
126
114
|
try {
|
|
127
|
-
const provider = getDataProviderForResource(importResource, options.dataProviderName);
|
|
115
|
+
const provider = adminContext.getDataProviderForResource(importResource, options.dataProviderName);
|
|
128
116
|
let text = await info.file.text();
|
|
129
117
|
// Strip BOM
|
|
130
118
|
if (text.charCodeAt(0) === 0xFEFF) {
|
|
131
119
|
text = text.slice(1);
|
|
132
120
|
}
|
|
133
121
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
options.onFinish?.({ succeeded: [], errored: [] });
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
122
|
+
// 自动检测或使用指定格式
|
|
123
|
+
const format = options.format ?? 'auto';
|
|
124
|
+
const isJson = format === 'json' || (format === 'auto' && info.file.name.toLowerCase().endsWith('.json'));
|
|
140
125
|
|
|
141
|
-
|
|
142
|
-
const records: Record<string, unknown>[] = [];
|
|
126
|
+
let records: Record<string, unknown>[];
|
|
143
127
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
128
|
+
if (isJson) {
|
|
129
|
+
// JSON 格式:期望数组对象
|
|
130
|
+
const parsed = JSON.parse(text);
|
|
131
|
+
if (!Array.isArray(parsed)) {
|
|
132
|
+
mutationResult = { succeeded: [], errored: [{ request: null, error: new Error('JSON import requires an array of objects') }] };
|
|
133
|
+
options.onFinish?.(mutationResult);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
records = parsed.map((item: unknown) =>
|
|
137
|
+
options.mapData ? (options.mapData(item as Record<string, unknown>) as unknown as Record<string, unknown>) : (item as Record<string, unknown>)
|
|
138
|
+
);
|
|
139
|
+
} else {
|
|
140
|
+
// CSV 格式
|
|
141
|
+
const rows = parseCSV(text);
|
|
142
|
+
if (rows.length < 2) {
|
|
143
|
+
mutationResult = { succeeded: [], errored: [] };
|
|
144
|
+
options.onFinish?.({ succeeded: [], errored: [] });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const headers = rows[0];
|
|
149
|
+
records = [];
|
|
150
|
+
for (let i = 1; i < rows.length; i++) {
|
|
151
|
+
const values = rows[i];
|
|
152
|
+
const record: Record<string, unknown> = {};
|
|
153
|
+
headers.forEach((h, idx) => { record[h] = values[idx] ?? ''; });
|
|
154
|
+
records.push(options.mapData ? (options.mapData(record) as unknown as Record<string, unknown>) : record);
|
|
155
|
+
}
|
|
149
156
|
}
|
|
150
157
|
|
|
151
158
|
const batchSize = options.batchSize ?? 1;
|
|
@@ -198,13 +205,16 @@ export function useImport<TData = Record<string, unknown>>(options: UseImportOpt
|
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
return {
|
|
201
|
-
inputProps: { type: 'file' as const, accept: '.csv' },
|
|
208
|
+
inputProps: { type: 'file' as const, accept: '.csv,.json' },
|
|
202
209
|
handleChange,
|
|
203
210
|
get isLoading() { return isLoading; },
|
|
204
211
|
get mutationResult() { return mutationResult; },
|
|
205
212
|
};
|
|
206
213
|
}
|
|
207
214
|
|
|
215
|
+
|
|
216
|
+
// ─── 解析工具 ────────────────────────────────────────────────────
|
|
217
|
+
|
|
208
218
|
/** Parse full CSV text respecting quoted multi-line fields */
|
|
209
219
|
function parseCSV(text: string): string[][] {
|
|
210
220
|
const rows: string[][] = [];
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// 纯函数导出格式化工具 — 不依赖 Svelte runes 或浏览器 API(downloadData 除外)
|
|
2
|
+
// 可被 bun:test 直接测试
|
|
3
|
+
|
|
4
|
+
/** 支持的导出/导入格式 */
|
|
5
|
+
export type ExportFormat = 'csv' | 'json' | 'xlsx';
|
|
6
|
+
|
|
7
|
+
/** CSV 转义,含公式注入防护 */
|
|
8
|
+
export function escapeCsvField(s: string): string {
|
|
9
|
+
let val = s;
|
|
10
|
+
if (/^[=+@\t\r]/.test(val)) val = "'" + val;
|
|
11
|
+
return val.includes(',') || val.includes('"') || val.includes('\n') || val.includes('\r')
|
|
12
|
+
? `"${val.replace(/"/g, '""')}"`
|
|
13
|
+
: val;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 将记录数组转为 CSV 字符串 */
|
|
17
|
+
export function toCsv(records: Record<string, unknown>[]): string {
|
|
18
|
+
if (records.length === 0) return '';
|
|
19
|
+
const fields = Object.keys(records[0]);
|
|
20
|
+
const header = fields.map(escapeCsvField).join(',');
|
|
21
|
+
const rows = records.map(record =>
|
|
22
|
+
fields.map(f => {
|
|
23
|
+
const val = record[f];
|
|
24
|
+
const str = val === null || val === undefined ? '' : typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
25
|
+
return escapeCsvField(str);
|
|
26
|
+
}).join(',')
|
|
27
|
+
);
|
|
28
|
+
return [header, ...rows].join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 将记录数组转为格式化的 JSON 字符串 */
|
|
32
|
+
export function toJson(records: Record<string, unknown>[]): string {
|
|
33
|
+
return JSON.stringify(records, null, 2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 转义 XML 特殊字符 */
|
|
37
|
+
export function escapeXml(s: string): string {
|
|
38
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 生成最小可用的 XLSX(SpreadsheetML)——零依赖。
|
|
43
|
+
*/
|
|
44
|
+
export function toXlsx(records: Record<string, unknown>[]): string {
|
|
45
|
+
if (records.length === 0) return '';
|
|
46
|
+
const fields = Object.keys(records[0]);
|
|
47
|
+
|
|
48
|
+
const headerCells = fields.map(f => `<Cell><Data ss:Type="String">${escapeXml(f)}</Data></Cell>`).join('');
|
|
49
|
+
const dataRows = records.map(record =>
|
|
50
|
+
`<Row>${fields.map(f => {
|
|
51
|
+
const val = record[f];
|
|
52
|
+
if (val === null || val === undefined) return '<Cell><Data ss:Type="String"></Data></Cell>';
|
|
53
|
+
if (typeof val === 'number') return `<Cell><Data ss:Type="Number">${val}</Data></Cell>`;
|
|
54
|
+
if (typeof val === 'boolean') return `<Cell><Data ss:Type="Boolean">${val ? 1 : 0}</Data></Cell>`;
|
|
55
|
+
const str = typeof val === 'object' ? JSON.stringify(val) : String(val);
|
|
56
|
+
return `<Cell><Data ss:Type="String">${escapeXml(str)}</Data></Cell>`;
|
|
57
|
+
}).join('')}</Row>`
|
|
58
|
+
).join('\n');
|
|
59
|
+
|
|
60
|
+
return `<?xml version="1.0"?>
|
|
61
|
+
<?mso-application progid="Excel.Sheet"?>
|
|
62
|
+
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
|
63
|
+
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
|
64
|
+
<Worksheet ss:Name="Export">
|
|
65
|
+
<Table>
|
|
66
|
+
<Row>${headerCells}</Row>
|
|
67
|
+
${dataRows}
|
|
68
|
+
</Table>
|
|
69
|
+
</Worksheet>
|
|
70
|
+
</Workbook>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 将记录以指定格式下载为文件。
|
|
75
|
+
* 依赖浏览器 document API;在 Node/bun:test 中需 mock。
|
|
76
|
+
*/
|
|
77
|
+
export function downloadData(
|
|
78
|
+
records: Record<string, unknown>[],
|
|
79
|
+
resource: string,
|
|
80
|
+
format: ExportFormat = 'csv',
|
|
81
|
+
): void {
|
|
82
|
+
if (records.length === 0) return;
|
|
83
|
+
if (typeof document === 'undefined') return;
|
|
84
|
+
|
|
85
|
+
let content: string;
|
|
86
|
+
let mimeType: string;
|
|
87
|
+
let ext: string;
|
|
88
|
+
|
|
89
|
+
switch (format) {
|
|
90
|
+
case 'json':
|
|
91
|
+
content = toJson(records);
|
|
92
|
+
mimeType = 'application/json;charset=utf-8;';
|
|
93
|
+
ext = 'json';
|
|
94
|
+
break;
|
|
95
|
+
case 'xlsx':
|
|
96
|
+
content = toXlsx(records);
|
|
97
|
+
mimeType = 'application/vnd.ms-excel;charset=utf-8;';
|
|
98
|
+
ext = 'xls';
|
|
99
|
+
break;
|
|
100
|
+
case 'csv':
|
|
101
|
+
default:
|
|
102
|
+
content = toCsv(records);
|
|
103
|
+
mimeType = 'text/csv;charset=utf-8;';
|
|
104
|
+
ext = 'csv';
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const blob = new Blob(['\uFEFF' + content], { type: mimeType });
|
|
109
|
+
const url = URL.createObjectURL(blob);
|
|
110
|
+
const a = document.createElement('a');
|
|
111
|
+
a.href = url;
|
|
112
|
+
a.download = `${resource}_export.${ext}`;
|
|
113
|
+
a.click();
|
|
114
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
115
|
+
}
|