Files
EXCEL2JSON/src/routes/+page.svelte

359 lines
13 KiB
Svelte

<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);
// Split panel
let splitPercent = $state(50);
let jsonCollapsed = $state(false);
let isDraggingSplit = $state(false);
let containerEl = $state<HTMLDivElement>();
function onSplitPointerDown(e: PointerEvent) {
e.preventDefault();
isDraggingSplit = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}
function onSplitPointerMove(e: PointerEvent) {
if (!isDraggingSplit || !containerEl) return;
const rect = containerEl.getBoundingClientRect();
const pct = ((e.clientX - rect.left) / rect.width) * 100;
splitPercent = Math.max(20, Math.min(80, pct));
}
function onSplitPointerUp() {
isDraggingSplit = false;
}
function toggleJsonPanel() {
jsonCollapsed = !jsonCollapsed;
if (!jsonCollapsed && splitPercent > 80) splitPercent = 50;
}
// Derived
const hasData = $derived(headers.length > 0 && rows.length > 0);
// Debounced conversion to avoid lag while editing mappings
let convertedJson = $state<Record<string, unknown>[]>([]);
let debounceTimer: ReturnType<typeof setTimeout>;
$effect(() => {
// $state.snapshot forces deep read of all nested properties,
// so changes to e.g. mappings[i].target will trigger this effect
const snapshotMappings = $state.snapshot(mappings);
const currentRows = rows;
const dataReady = hasData;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
convertedJson = dataReady ? convertData(currentRows, snapshotMappings) : [];
}, 150);
});
// Manual JSON editing — overrides converted output until next mapping/data change
let manualJson = $state<string | null>(null);
const jsonString = $derived(manualJson ?? JSON.stringify(convertedJson, null, 2));
function onJsonEdited(edited: string) {
manualJson = edited;
}
// Clear manual override when conversion changes
$effect(() => {
convertedJson;
manualJson = null;
});
// 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}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="relative flex flex-1 overflow-hidden"
onpointermove={onSplitPointerMove}
onpointerup={onSplitPointerUp}
>
<!-- Left: Excel Table -->
<div class="overflow-hidden" style="width: {jsonCollapsed ? '100%' : `${splitPercent}%`}">
<ExcelTable {headers} {rows} bind:mappings />
</div>
{#if !jsonCollapsed}
<!-- Drag handle -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group flex w-2 flex-shrink-0 cursor-col-resize items-center justify-center bg-gray-200 hover:bg-blue-300 active:bg-blue-400 transition-colors"
onpointerdown={onSplitPointerDown}
>
<div class="h-8 w-0.5 rounded-full bg-gray-400 group-hover:bg-blue-500"></div>
</div>
<!-- Right: JSON Preview -->
<div class="relative flex-1 overflow-hidden">
<button
onclick={toggleJsonPanel}
aria-label="收起 JSON 面板"
class="absolute top-2 right-2 z-10 rounded bg-white/80 p-1 text-gray-400 shadow hover:bg-white 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="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
<JsonPreview json={convertedJson} onupdate={onJsonEdited} />
</div>
{:else}
<!-- Collapsed JSON mini-panel -->
<div class="flex w-10 flex-shrink-0 flex-col items-center border-l border-gray-200 bg-gray-50 py-3">
<button
onclick={toggleJsonPanel}
aria-label="展开 JSON 面板"
class="rounded p-1 text-gray-400 hover:bg-gray-200 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="M11 19l-7-7 7-7M19 19l-7-7 7-7" />
</svg>
</button>
<span class="mt-2 text-xs text-gray-400" style="writing-mode: vertical-rl">JSON</span>
</div>
{/if}
</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>