excel2json init

This commit is contained in:
lirui
2026-02-09 20:08:26 +08:00
commit 36f5d247b1
37 changed files with 4046 additions and 0 deletions

13
src/app.d.ts vendored Normal file
View 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
View 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
View 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);
});
});

View 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

View 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>

View 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>

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

35
src/lib/types.ts Normal file
View 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'
>[];

View 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
View 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
View File

@@ -0,0 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';

View 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();
});
});