excel2json init
This commit is contained in:
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
src/app.html
Normal file
11
src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
7
src/demo.spec.ts
Normal file
7
src/demo.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('sum test', () => {
|
||||
it('adds 1 + 2 to equal 3', () => {
|
||||
expect(1 + 2).toBe(3);
|
||||
});
|
||||
});
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
107
src/lib/components/ColumnConfig.svelte
Normal file
107
src/lib/components/ColumnConfig.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import type { MappingConfig, DataType, DateFormat } from '$lib/types.js';
|
||||
|
||||
let { config = $bindable(), onclose }: { config: MappingConfig; onclose: () => void } = $props();
|
||||
|
||||
const dataTypes: { value: DataType; label: string }[] = [
|
||||
{ value: 'string', label: '字符串 (String)' },
|
||||
{ value: 'number', label: '数字 (Number)' },
|
||||
{ value: 'boolean', label: '布尔 (Boolean)' },
|
||||
{ value: 'date', label: '日期 (Date)' }
|
||||
];
|
||||
|
||||
const dateFormats: { value: DateFormat; label: string }[] = [
|
||||
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
||||
{ value: 'YYYY/MM/DD', label: 'YYYY/MM/DD' },
|
||||
{ value: 'YYYY-MM-DD HH:mm', label: 'YYYY-MM-DD HH:mm' },
|
||||
{ value: 'YYYY/MM/DD HH:mm', label: 'YYYY/MM/DD HH:mm' },
|
||||
{ value: 'YYYY-MM-DD HH:mm:ss', label: 'YYYY-MM-DD HH:mm:ss' },
|
||||
{ value: 'timestamp', label: 'Unix Timestamp' }
|
||||
];
|
||||
|
||||
function onTypeChange(e: Event) {
|
||||
const select = e.target as HTMLSelectElement;
|
||||
config.type = select.value as DataType;
|
||||
if (config.type === 'date' && !config.format) {
|
||||
config.format = 'YYYY-MM-DD';
|
||||
}
|
||||
if (config.type !== 'date') {
|
||||
config.format = undefined;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="absolute top-full left-0 z-50 mt-1 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-xl">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-700">列配置</h4>
|
||||
<button onclick={onclose} aria-label="关闭配置" class="text-gray-400 hover:text-gray-600 cursor-pointer">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Source header (read-only) -->
|
||||
<div>
|
||||
<label for="source-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">源字段</label>
|
||||
<input id="source-{config.source}" type="text" value={config.source} disabled
|
||||
class="w-full rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-500" />
|
||||
</div>
|
||||
|
||||
<!-- Target key -->
|
||||
<div>
|
||||
<label for="target-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">目标字段 (JSON Key)</label>
|
||||
<input id="target-{config.source}" type="text" bind:value={config.target}
|
||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500" />
|
||||
</div>
|
||||
|
||||
<!-- Data type -->
|
||||
<div>
|
||||
<label for="type-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">数据类型</label>
|
||||
<select id="type-{config.source}" value={config.type} onchange={onTypeChange}
|
||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
|
||||
{#each dataTypes as dt (dt.value)}
|
||||
<option value={dt.value}>{dt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date format (only for date type) -->
|
||||
{#if config.type === 'date'}
|
||||
<div>
|
||||
<label for="format-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">日期格式</label>
|
||||
<select id="format-{config.source}" bind:value={config.format}
|
||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
|
||||
{#each dateFormats as df (df.value)}
|
||||
<option value={df.value}>{df.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Exclude if empty -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="exclude-empty-{config.source}" bind:checked={config.excludeIfEmpty}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
<label for="exclude-empty-{config.source}" class="text-sm text-gray-600">空值时移除该字段</label>
|
||||
</div>
|
||||
|
||||
<!-- Default value -->
|
||||
{#if !config.excludeIfEmpty}
|
||||
<div>
|
||||
<label for="default-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">默认值 (空值时)</label>
|
||||
<input id="default-{config.source}" type="text" bind:value={config.defaultValue}
|
||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="留空则为 null" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Enable/disable column -->
|
||||
<div class="flex items-center gap-2 border-t border-gray-100 pt-3">
|
||||
<input type="checkbox" id="enabled-{config.source}" bind:checked={config.enabled}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
<label for="enabled-{config.source}" class="text-sm text-gray-600">包含此列到输出</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
94
src/lib/components/ExcelTable.svelte
Normal file
94
src/lib/components/ExcelTable.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import type { MappingConfig, RowData } from '$lib/types.js';
|
||||
import ColumnConfig from './ColumnConfig.svelte';
|
||||
|
||||
let {
|
||||
headers,
|
||||
rows,
|
||||
mappings = $bindable()
|
||||
}: {
|
||||
headers: string[];
|
||||
rows: RowData[];
|
||||
mappings: MappingConfig[];
|
||||
} = $props();
|
||||
|
||||
let activeConfigIndex = $state<number | null>(null);
|
||||
const maxPreviewRows = 100;
|
||||
|
||||
function toggleConfig(index: number) {
|
||||
activeConfigIndex = activeConfigIndex === index ? null : index;
|
||||
}
|
||||
|
||||
function displayValue(value: unknown): string {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
if (value instanceof Date) {
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(value.getDate()).padStart(2, '0');
|
||||
const H = String(value.getHours()).padStart(2, '0');
|
||||
const M = String(value.getMinutes()).padStart(2, '0');
|
||||
const S = String(value.getSeconds()).padStart(2, '0');
|
||||
if (H === '00' && M === '00' && S === '00') return `${y}-${m}-${d}`;
|
||||
return `${y}-${m}-${d} ${H}:${M}:${S}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex-shrink-0 border-b border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<h3 class="text-sm font-semibold text-gray-700">
|
||||
Excel 数据
|
||||
<span class="ml-2 text-xs font-normal text-gray-400">
|
||||
{rows.length} 行 × {headers.length} 列
|
||||
{#if rows.length > maxPreviewRows}
|
||||
(显示前 {maxPreviewRows} 行)
|
||||
{/if}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50">
|
||||
<tr>
|
||||
{#each headers as header, i (header)}
|
||||
<th class="relative border-b border-r border-gray-200 px-3 py-2 text-left font-medium text-gray-600">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="truncate" title={header}>{header}</span>
|
||||
<button
|
||||
onclick={() => toggleConfig(i)}
|
||||
class="ml-auto flex-shrink-0 rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600 cursor-pointer"
|
||||
title="配置此列"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if mappings[i] && mappings[i].target !== mappings[i].source}
|
||||
<span class="text-xs text-blue-500" title="映射为: {mappings[i].target}">→ {mappings[i].target}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if activeConfigIndex === i && mappings[i]}
|
||||
<ColumnConfig bind:config={mappings[i]} onclose={() => (activeConfigIndex = null)} />
|
||||
{/if}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows.slice(0, maxPreviewRows) as row, rowIdx (rowIdx)}
|
||||
<tr class={rowIdx % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}>
|
||||
{#each headers as header (header)}
|
||||
<td class="border-b border-r border-gray-100 px-3 py-1.5 text-gray-700" title={displayValue(row[header])}>
|
||||
<span class="block max-w-[200px] truncate">{displayValue(row[header])}</span>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
70
src/lib/components/JsonPreview.svelte
Normal file
70
src/lib/components/JsonPreview.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
let { json }: { json: Record<string, unknown>[] } = $props();
|
||||
|
||||
let collapsed = $state(false);
|
||||
|
||||
const jsonString = $derived(JSON.stringify(json, null, 2));
|
||||
|
||||
/**
|
||||
* Simple JSON syntax highlighting — light theme.
|
||||
*/
|
||||
function highlight(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"([^"]*)"(?=\s*:)/g, '<span class="json-key">"$1"</span>')
|
||||
.replace(/:\s*"([^"]*)"/g, ': <span class="json-string">"$1"</span>')
|
||||
.replace(/:\s*(\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
|
||||
.replace(/:\s*(true|false)/g, ': <span class="json-bool">$1</span>')
|
||||
.replace(/:\s*(null)/g, ': <span class="json-null">$1</span>');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<h3 class="text-sm font-semibold text-gray-700">
|
||||
JSON 预览
|
||||
<span class="ml-2 text-xs font-normal text-gray-400">{json.length} 条记录</span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => (collapsed = !collapsed)}
|
||||
class="rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 cursor-pointer"
|
||||
>
|
||||
{collapsed ? '展开' : '折叠'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="json-preview flex-1 overflow-auto p-4">
|
||||
{#if collapsed}
|
||||
<pre class="font-mono text-xs leading-relaxed text-gray-600">{JSON.stringify(json)}</pre>
|
||||
{:else}
|
||||
<pre class="font-mono text-xs leading-relaxed text-gray-500">{@html highlight(jsonString)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.json-preview {
|
||||
background: #fafbfc;
|
||||
}
|
||||
:global(.json-key) {
|
||||
color: #24292e;
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(.json-string) {
|
||||
color: #22863a;
|
||||
}
|
||||
:global(.json-number) {
|
||||
color: #005cc5;
|
||||
}
|
||||
:global(.json-bool) {
|
||||
color: #d73a49;
|
||||
}
|
||||
:global(.json-null) {
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
145
src/lib/converter.spec.ts
Normal file
145
src/lib/converter.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertData, createDefaultMappings, applyTemplate, exportTemplate } from '$lib/converter.js';
|
||||
import type { MappingConfig, RowData } from '$lib/types.js';
|
||||
|
||||
describe('createDefaultMappings', () => {
|
||||
it('creates mappings from headers with correct defaults', () => {
|
||||
const mappings = createDefaultMappings(['姓名', '年龄']);
|
||||
expect(mappings).toHaveLength(2);
|
||||
expect(mappings[0]).toEqual({
|
||||
source: '姓名',
|
||||
target: '姓名',
|
||||
type: 'string',
|
||||
format: undefined,
|
||||
excludeIfEmpty: false,
|
||||
defaultValue: '',
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-detects date columns from row data', () => {
|
||||
const rows: RowData[] = [
|
||||
{ name: 'Alice', birthday: new Date('2000-01-01') },
|
||||
{ name: 'Bob', birthday: new Date('1995-06-15') }
|
||||
];
|
||||
const mappings = createDefaultMappings(['name', 'birthday'], rows);
|
||||
expect(mappings[0].type).toBe('string');
|
||||
expect(mappings[1].type).toBe('date');
|
||||
expect(mappings[1].format).toBe('YYYY-MM-DD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertData', () => {
|
||||
const rows: RowData[] = [
|
||||
{ name: 'Alice', age: 30, active: 'true', notes: '' },
|
||||
{ name: 'Bob', age: 25, active: 'false', notes: 'some note' }
|
||||
];
|
||||
|
||||
it('converts with default string mappings', () => {
|
||||
const mappings = createDefaultMappings(['name', 'age']);
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ name: 'Alice', age: '30' });
|
||||
});
|
||||
|
||||
it('converts number type', () => {
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'age', target: 'age', type: 'number', excludeIfEmpty: false, defaultValue: '', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result[0]).toEqual({ age: 30 });
|
||||
});
|
||||
|
||||
it('converts boolean type', () => {
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'active', target: 'isActive', type: 'boolean', excludeIfEmpty: false, defaultValue: '', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result[0]).toEqual({ isActive: true });
|
||||
expect(result[1]).toEqual({ isActive: false });
|
||||
});
|
||||
|
||||
it('excludes empty fields when excludeIfEmpty is true', () => {
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'notes', target: 'notes', type: 'string', excludeIfEmpty: true, defaultValue: '', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result[0]).toEqual({});
|
||||
expect(result[1]).toEqual({ notes: 'some note' });
|
||||
});
|
||||
|
||||
it('uses default value for empty fields', () => {
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'notes', target: 'notes', type: 'string', excludeIfEmpty: false, defaultValue: 'N/A', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result[0]).toEqual({ notes: 'N/A' });
|
||||
});
|
||||
|
||||
it('returns null for empty fields with no default', () => {
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'notes', target: 'notes', type: 'string', excludeIfEmpty: false, defaultValue: '', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result[0]).toEqual({ notes: null });
|
||||
});
|
||||
|
||||
it('skips disabled columns', () => {
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'name', target: 'name', type: 'string', excludeIfEmpty: false, defaultValue: '', enabled: false },
|
||||
{ source: 'age', target: 'age', type: 'number', excludeIfEmpty: false, defaultValue: '', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result[0]).toEqual({ age: 30 });
|
||||
});
|
||||
|
||||
it('renames target keys', () => {
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'name', target: 'full_name', type: 'string', excludeIfEmpty: false, defaultValue: '', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result[0]).toEqual({ full_name: 'Alice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('date conversion', () => {
|
||||
it('formats Excel serial date numbers', () => {
|
||||
const rows: RowData[] = [{ date: 44927 }]; // 2023-01-01
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'date', target: 'date', type: 'date', format: 'YYYY-MM-DD', excludeIfEmpty: false, defaultValue: '', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(result[0].date).toBe('2023-01-01');
|
||||
});
|
||||
|
||||
it('returns unix timestamp when format is timestamp', () => {
|
||||
const rows: RowData[] = [{ date: '2023-01-01' }];
|
||||
const mappings: MappingConfig[] = [
|
||||
{ source: 'date', target: 'date', type: 'date', format: 'timestamp', excludeIfEmpty: false, defaultValue: '', enabled: true }
|
||||
];
|
||||
const result = convertData(rows, mappings);
|
||||
expect(typeof result[0].date).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('template system', () => {
|
||||
it('exports and applies templates', () => {
|
||||
const mappings = createDefaultMappings(['姓名', '入职日期']);
|
||||
mappings[0].target = 'userName';
|
||||
mappings[1].type = 'date';
|
||||
mappings[1].format = 'YYYY-MM-DD';
|
||||
mappings[1].target = 'joinDate';
|
||||
|
||||
const template = exportTemplate(mappings);
|
||||
expect(template).toHaveLength(2);
|
||||
expect(template[0].target).toBe('userName');
|
||||
expect(template[1].format).toBe('YYYY-MM-DD');
|
||||
|
||||
// Apply to fresh mappings
|
||||
const freshMappings = createDefaultMappings(['姓名', '入职日期', '备注']);
|
||||
const applied = applyTemplate(freshMappings, template);
|
||||
expect(applied[0].target).toBe('userName');
|
||||
expect(applied[1].type).toBe('date');
|
||||
expect(applied[2].target).toBe('备注'); // not in template, unchanged
|
||||
});
|
||||
});
|
||||
172
src/lib/converter.ts
Normal file
172
src/lib/converter.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import dayjs from 'dayjs';
|
||||
import type { MappingConfig, RowData, DataType, MappingTemplate } from './types.js';
|
||||
|
||||
/**
|
||||
* Check if a value is considered empty.
|
||||
*/
|
||||
function isEmpty(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw cell value to the specified data type.
|
||||
*/
|
||||
function convertValue(value: unknown, type: DataType, format?: string): unknown {
|
||||
if (isEmpty(value)) return undefined;
|
||||
|
||||
switch (type) {
|
||||
case 'number': {
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? value : num;
|
||||
}
|
||||
case 'boolean': {
|
||||
if (typeof value === 'boolean') return value;
|
||||
const str = String(value).toLowerCase().trim();
|
||||
if (['true', '1', 'yes', '是'].includes(str)) return true;
|
||||
if (['false', '0', 'no', '否'].includes(str)) return false;
|
||||
return Boolean(value);
|
||||
}
|
||||
case 'date': {
|
||||
return formatDate(value, format);
|
||||
}
|
||||
case 'string':
|
||||
default:
|
||||
if (value instanceof Date) return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value as a date string. Handles Excel serial date numbers.
|
||||
*/
|
||||
function formatDate(value: unknown, format?: string): string | number {
|
||||
let date: dayjs.Dayjs;
|
||||
|
||||
if (value instanceof Date) {
|
||||
date = dayjs(value);
|
||||
} else if (typeof value === 'number') {
|
||||
// Excel serial date: days since 1900-01-01 (with the 1900 leap year bug)
|
||||
// Use UTC to avoid timezone issues
|
||||
const excelEpochMs = Date.UTC(1899, 11, 30);
|
||||
const ms = excelEpochMs + value * 86400000;
|
||||
date = dayjs(new Date(ms));
|
||||
} else {
|
||||
date = dayjs(value as string);
|
||||
}
|
||||
|
||||
if (!date.isValid()) return String(value);
|
||||
|
||||
if (format === 'timestamp') {
|
||||
return date.unix();
|
||||
}
|
||||
|
||||
return date.format(format || 'YYYY-MM-DD');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw Excel rows to JSON objects based on mapping configs.
|
||||
*/
|
||||
export function convertData(
|
||||
rows: RowData[],
|
||||
mappings: MappingConfig[]
|
||||
): Record<string, unknown>[] {
|
||||
const enabledMappings = mappings.filter((m) => m.enabled);
|
||||
|
||||
return rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
|
||||
for (const mapping of enabledMappings) {
|
||||
const rawValue = row[mapping.source];
|
||||
const isEmptyVal = isEmpty(rawValue);
|
||||
|
||||
if (isEmptyVal && mapping.excludeIfEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEmptyVal) {
|
||||
obj[mapping.target] = mapping.defaultValue !== undefined && mapping.defaultValue !== ''
|
||||
? convertValue(mapping.defaultValue, mapping.type, mapping.format)
|
||||
: null;
|
||||
} else {
|
||||
obj[mapping.target] = convertValue(rawValue, mapping.type, mapping.format);
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default mapping configs from Excel headers.
|
||||
* Auto-detects date columns by sampling row data.
|
||||
*/
|
||||
export function createDefaultMappings(headers: string[], rows?: RowData[]): MappingConfig[] {
|
||||
return headers.map((header) => {
|
||||
const detectedType = rows ? detectColumnType(header, rows) : 'string';
|
||||
return {
|
||||
source: header,
|
||||
target: header,
|
||||
type: detectedType,
|
||||
format: detectedType === 'date' ? 'YYYY-MM-DD' : undefined,
|
||||
excludeIfEmpty: false,
|
||||
defaultValue: '',
|
||||
enabled: true
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the data type of a column by sampling its values.
|
||||
*/
|
||||
function detectColumnType(header: string, rows: RowData[]): DataType {
|
||||
const sample = rows.slice(0, 20);
|
||||
let dateCount = 0;
|
||||
let totalNonEmpty = 0;
|
||||
|
||||
for (const row of sample) {
|
||||
const val = row[header];
|
||||
if (val === undefined || val === null || val === '') continue;
|
||||
totalNonEmpty++;
|
||||
if (val instanceof Date) dateCount++;
|
||||
}
|
||||
|
||||
if (totalNonEmpty > 0 && dateCount / totalNonEmpty >= 0.5) return 'date';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a template to existing mappings. Matches by source header name.
|
||||
*/
|
||||
export function applyTemplate(
|
||||
currentMappings: MappingConfig[],
|
||||
template: MappingTemplate
|
||||
): MappingConfig[] {
|
||||
const templateMap = new Map(template.map((t) => [t.source, t]));
|
||||
|
||||
return currentMappings.map((mapping) => {
|
||||
const tmpl = templateMap.get(mapping.source);
|
||||
if (tmpl) {
|
||||
return {
|
||||
...mapping,
|
||||
target: tmpl.target,
|
||||
type: tmpl.type,
|
||||
format: tmpl.format,
|
||||
excludeIfEmpty: tmpl.excludeIfEmpty,
|
||||
defaultValue: tmpl.defaultValue ?? ''
|
||||
};
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current mappings as a template.
|
||||
*/
|
||||
export function exportTemplate(mappings: MappingConfig[]): MappingTemplate {
|
||||
return mappings.map(({ source, target, type, format, excludeIfEmpty, defaultValue }) => {
|
||||
const entry: MappingTemplate[number] = { source, target, type, excludeIfEmpty };
|
||||
if (type === 'date' && format) entry.format = format;
|
||||
if (defaultValue) entry.defaultValue = defaultValue;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
44
src/lib/excel.ts
Normal file
44
src/lib/excel.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { RowData } from './types.js';
|
||||
|
||||
export interface ParsedExcel {
|
||||
headers: string[];
|
||||
rows: RowData[];
|
||||
sheetNames: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an Excel/CSV file into headers and rows.
|
||||
*/
|
||||
export async function parseExcelFile(file: File): Promise<ParsedExcel> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array', cellDates: true });
|
||||
const sheetNames = workbook.SheetNames;
|
||||
|
||||
if (sheetNames.length === 0) {
|
||||
throw new Error('文件中没有找到任何工作表');
|
||||
}
|
||||
|
||||
return parseSheet(workbook, sheetNames[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a specific sheet from a workbook.
|
||||
*/
|
||||
export function parseSheet(workbook: XLSX.WorkBook, sheetName: string): ParsedExcel {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
if (!sheet) {
|
||||
throw new Error(`工作表 "${sheetName}" 不存在`);
|
||||
}
|
||||
|
||||
const jsonData = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, {
|
||||
defval: ''
|
||||
});
|
||||
|
||||
if (jsonData.length === 0) {
|
||||
throw new Error('工作表中没有数据');
|
||||
}
|
||||
|
||||
const headers = Object.keys(jsonData[0]);
|
||||
return { headers, rows: jsonData, sheetNames: workbook.SheetNames };
|
||||
}
|
||||
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
35
src/lib/types.ts
Normal file
35
src/lib/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type DataType = 'string' | 'number' | 'boolean' | 'date';
|
||||
|
||||
export type DateFormat =
|
||||
| 'YYYY-MM-DD'
|
||||
| 'YYYY/MM/DD'
|
||||
| 'YYYY-MM-DD HH:mm'
|
||||
| 'YYYY/MM/DD HH:mm'
|
||||
| 'YYYY-MM-DD HH:mm:ss'
|
||||
| 'timestamp';
|
||||
|
||||
export interface MappingConfig {
|
||||
/** Excel original header name (read-only) */
|
||||
source: string;
|
||||
/** Target key name in JSON output */
|
||||
target: string;
|
||||
/** Data type for conversion */
|
||||
type: DataType;
|
||||
/** Date format string (only used when type is 'date') */
|
||||
format?: DateFormat;
|
||||
/** If true, exclude this key from JSON when cell is empty */
|
||||
excludeIfEmpty: boolean;
|
||||
/** Default value when cell is empty and excludeIfEmpty is false */
|
||||
defaultValue?: string;
|
||||
/** Whether this column is included in output */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** A single row of raw Excel data, keyed by original header */
|
||||
export type RowData = Record<string, unknown>;
|
||||
|
||||
/** Template file structure for import/export */
|
||||
export type MappingTemplate = Pick<
|
||||
MappingConfig,
|
||||
'source' | 'target' | 'type' | 'format' | 'excludeIfEmpty' | 'defaultValue'
|
||||
>[];
|
||||
9
src/routes/+layout.svelte
Normal file
9
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
{@render children()}
|
||||
259
src/routes/+page.svelte
Normal file
259
src/routes/+page.svelte
Normal file
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import type { MappingConfig, RowData, MappingTemplate } from '$lib/types.js';
|
||||
import { parseExcelFile } from '$lib/excel.js';
|
||||
import { convertData, createDefaultMappings, applyTemplate, exportTemplate } from '$lib/converter.js';
|
||||
import ExcelTable from '$lib/components/ExcelTable.svelte';
|
||||
import JsonPreview from '$lib/components/JsonPreview.svelte';
|
||||
|
||||
// State
|
||||
let headers = $state<string[]>([]);
|
||||
let rows = $state<RowData[]>([]);
|
||||
let mappings = $state<MappingConfig[]>([]);
|
||||
let errorMessage = $state('');
|
||||
let fileName = $state('');
|
||||
let isDragOver = $state(false);
|
||||
let copySuccess = $state(false);
|
||||
|
||||
// Derived
|
||||
const hasData = $derived(headers.length > 0 && rows.length > 0);
|
||||
const convertedJson = $derived(hasData ? convertData(rows, mappings) : []);
|
||||
const jsonString = $derived(JSON.stringify(convertedJson, null, 2));
|
||||
|
||||
// File handling
|
||||
async function handleFile(file: File) {
|
||||
const validExtensions = ['.xlsx', '.xls', '.csv'];
|
||||
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
||||
if (!validExtensions.includes(ext)) {
|
||||
errorMessage = `不支持的文件格式: ${ext}。请上传 .xlsx, .xls 或 .csv 文件。`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
errorMessage = '';
|
||||
const result = await parseExcelFile(file);
|
||||
headers = result.headers;
|
||||
rows = result.rows;
|
||||
mappings = createDefaultMappings(result.headers, result.rows);
|
||||
fileName = file.name;
|
||||
} catch (e) {
|
||||
errorMessage = e instanceof Error ? e.message : '文件解析失败';
|
||||
headers = [];
|
||||
rows = [];
|
||||
mappings = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onFileInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragOver = false;
|
||||
const file = e.dataTransfer?.files[0];
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragOver = true;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
isDragOver = false;
|
||||
}
|
||||
|
||||
// Template handling
|
||||
function handleExportTemplate() {
|
||||
const template = exportTemplate(mappings);
|
||||
const blob = new Blob([JSON.stringify(template, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName ? fileName.replace(/\.[^.]+$/, '_mapping.json') : 'mapping_template.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handleImportTemplate() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const template: MappingTemplate = JSON.parse(text);
|
||||
if (!Array.isArray(template)) {
|
||||
errorMessage = '无效的模板格式';
|
||||
return;
|
||||
}
|
||||
mappings = applyTemplate(mappings, template);
|
||||
errorMessage = '';
|
||||
} catch {
|
||||
errorMessage = '模板文件解析失败';
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
// JSON operations
|
||||
function handleDownloadJson() {
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName ? fileName.replace(/\.[^.]+$/, '.json') : 'output.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function handleCopyJson() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonString);
|
||||
copySuccess = true;
|
||||
setTimeout(() => (copySuccess = false), 2000);
|
||||
} catch {
|
||||
errorMessage = '复制失败,请手动复制';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex h-screen flex-col bg-gray-100"
|
||||
ondrop={onDrop}
|
||||
ondragover={onDragOver}
|
||||
ondragleave={onDragLeave}
|
||||
role="application"
|
||||
>
|
||||
<!-- Header Toolbar -->
|
||||
<header class="flex flex-shrink-0 items-center gap-3 border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
|
||||
<h1 class="text-lg font-bold text-gray-800">Excel → JSON</h1>
|
||||
|
||||
<div class="mx-2 h-6 w-px bg-gray-200"></div>
|
||||
|
||||
<!-- File upload -->
|
||||
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
上传文件
|
||||
<input type="file" accept=".xlsx,.xls,.csv" onchange={onFileInput} class="hidden" />
|
||||
</label>
|
||||
|
||||
{#if fileName}
|
||||
<span class="text-sm text-gray-500">{fileName}</span>
|
||||
{/if}
|
||||
|
||||
<div class="mx-2 h-6 w-px bg-gray-200"></div>
|
||||
|
||||
<!-- Template operations -->
|
||||
<button
|
||||
onclick={handleImportTemplate}
|
||||
disabled={!hasData}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
导入配置
|
||||
</button>
|
||||
<button
|
||||
onclick={handleExportTemplate}
|
||||
disabled={!hasData}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
导出配置
|
||||
</button>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- JSON operations -->
|
||||
<button
|
||||
onclick={handleDownloadJson}
|
||||
disabled={!hasData}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
下载 JSON
|
||||
</button>
|
||||
<button
|
||||
onclick={handleCopyJson}
|
||||
disabled={!hasData}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{#if copySuccess}
|
||||
<svg class="h-4 w-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
已复制
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
复制 JSON
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Error message -->
|
||||
{#if errorMessage}
|
||||
<div class="flex items-center gap-2 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||
<svg class="h-4 w-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{errorMessage}
|
||||
<button onclick={() => (errorMessage = '')} aria-label="关闭错误" class="ml-auto text-red-500 hover:text-red-700 cursor-pointer">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main content -->
|
||||
{#if hasData}
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left: Excel Table -->
|
||||
<div class="w-1/2 overflow-hidden border-r border-gray-300">
|
||||
<ExcelTable {headers} {rows} bind:mappings />
|
||||
</div>
|
||||
<!-- Right: JSON Preview -->
|
||||
<div class="w-1/2 overflow-hidden">
|
||||
<JsonPreview json={convertedJson} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty state / drop zone -->
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div
|
||||
class="flex max-w-md flex-col items-center rounded-2xl border-2 border-dashed p-12 text-center transition-colors {isDragOver
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-300 bg-white'}"
|
||||
>
|
||||
<svg class="mb-4 h-16 w-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-700">拖拽 Excel 文件到此处</h2>
|
||||
<p class="mb-4 text-sm text-gray-500">支持 .xlsx, .xls, .csv 格式</p>
|
||||
<label class="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
选择文件
|
||||
<input type="file" accept=".xlsx,.xls,.csv" onchange={onFileInput} class="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
3
src/routes/layout.css
Normal file
3
src/routes/layout.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
21
src/routes/page.svelte.spec.ts
Normal file
21
src/routes/page.svelte.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { page } from 'vitest/browser';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render the app title', async () => {
|
||||
render(Page);
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
await expect.element(heading).toHaveTextContent('Excel → JSON');
|
||||
});
|
||||
|
||||
it('should show the drop zone when no file is loaded', async () => {
|
||||
render(Page);
|
||||
|
||||
const dropText = page.getByText('拖拽 Excel 文件到此处');
|
||||
await expect.element(dropText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user