@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.
@@ -1,6 +1,10 @@
1
- import { getDataProviderForResource } from './context.svelte';
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
- const fields = Object.keys(mapped[0]);
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
- const rows = parseCSV(text);
135
- if (rows.length < 2) {
136
- mutationResult = { succeeded: [], errored: [] };
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
- const headers = rows[0];
142
- const records: Record<string, unknown>[] = [];
126
+ let records: Record<string, unknown>[];
143
127
 
144
- for (let i = 1; i < rows.length; i++) {
145
- const values = rows[i];
146
- const record: Record<string, unknown> = {};
147
- headers.forEach((h, idx) => { record[h] = values[idx] ?? ''; });
148
- records.push(options.mapData ? (options.mapData(record) as unknown as Record<string, unknown>) : record);
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ }