增强 JSON 预览功能,支持手动编辑和面板拖动调整;优化 Excel 数据解析,过滤空列

This commit is contained in:
lirui
2026-02-09 20:40:24 +08:00
parent 16164f3e5c
commit 7f3eb2db49
3 changed files with 201 additions and 25 deletions

View File

@@ -1,13 +1,39 @@
<script lang="ts">
let { json }: { json: Record<string, unknown>[] } = $props();
let { json, onupdate }: {
json: Record<string, unknown>[];
onupdate?: (edited: string) => void;
} = $props();
let collapsed = $state(false);
const MAX_PREVIEW_CHARS = 2000;
const MAX_PREVIEW_LINES = 50;
let editing = $state(false);
let editText = $state('');
let parseError = $state('');
const jsonString = $derived(JSON.stringify(json, null, 2));
/**
* Simple JSON syntax highlighting — light theme.
*/
// Truncation for preview
const truncated = $derived.by(() => {
const lines = jsonString.split('\n');
if (jsonString.length <= MAX_PREVIEW_CHARS && lines.length <= MAX_PREVIEW_LINES) {
return { text: jsonString, isTruncated: false, totalChars: jsonString.length, totalLines: lines.length };
}
// Truncate by line first, then by char
let preview = lines.slice(0, MAX_PREVIEW_LINES).join('\n');
if (preview.length > MAX_PREVIEW_CHARS) {
preview = preview.slice(0, MAX_PREVIEW_CHARS);
}
return { text: preview, isTruncated: true, totalChars: jsonString.length, totalLines: lines.length };
});
// Sync from upstream when not editing
$effect(() => {
if (!editing) {
editText = jsonString;
}
});
function highlight(str: string): string {
return str
.replace(/&/g, '&amp;')
@@ -19,6 +45,29 @@
.replace(/:\s*(true|false)/g, ': <span class="json-bool">$1</span>')
.replace(/:\s*(null)/g, ': <span class="json-null">$1</span>');
}
function startEdit() {
editText = jsonString;
editing = true;
parseError = '';
}
function cancelEdit() {
editing = false;
parseError = '';
editText = jsonString;
}
function applyEdit() {
try {
JSON.parse(editText);
parseError = '';
editing = false;
onupdate?.(editText);
} catch {
parseError = 'JSON 格式错误,请检查语法';
}
}
</script>
<div class="flex h-full flex-col overflow-hidden">
@@ -27,21 +76,44 @@
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 class="flex items-center gap-1.5">
{#if editing}
<button onclick={applyEdit} class="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700 cursor-pointer">
应用
</button>
<button onclick={cancelEdit} class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 cursor-pointer">
取消
</button>
{:else}
<button onclick={startEdit} class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 cursor-pointer">
编辑
</button>
{/if}
</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>
{#if parseError}
<div class="flex-shrink-0 bg-red-50 px-4 py-1.5 text-xs text-red-600">{parseError}</div>
{/if}
<div class="json-preview flex-1 overflow-hidden">
{#if editing}
<!-- Plain textarea for editing — no overlay highlighting to avoid lag -->
<textarea
bind:value={editText}
oninput={() => { parseError = ''; }}
spellcheck="false"
class="json-textarea h-full w-full resize-none overflow-auto border-0 bg-transparent font-mono text-xs leading-relaxed text-gray-700 outline-none"
></textarea>
{:else}
<pre class="font-mono text-xs leading-relaxed text-gray-500">{@html highlight(jsonString)}</pre>
<div class="h-full overflow-auto">
<pre class="json-content whitespace-pre font-mono text-xs leading-relaxed text-gray-500">{@html highlight(truncated.text)}</pre>
{#if truncated.isTruncated}
<div class="truncation-notice sticky bottom-0 border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-700">
&#x26A0;&#xFE0F; 预览已截断以提升性能(显示 {Math.min(MAX_PREVIEW_CHARS, truncated.text.length).toLocaleString()}/{truncated.totalChars.toLocaleString()} 字符,{Math.min(MAX_PREVIEW_LINES, truncated.totalLines)}/{truncated.totalLines.toLocaleString()} 行)。请点击下载或复制查看完整数据。
</div>
{/if}
</div>
{/if}
</div>
</div>
@@ -49,6 +121,16 @@
<style>
.json-preview {
background: #fafbfc;
content-visibility: auto;
}
.json-content {
padding: 16px;
margin: 0;
content-visibility: auto;
}
.json-textarea {
padding: 16px;
tab-size: 2;
}
:global(.json-key) {
color: #24292e;

View File

@@ -39,6 +39,18 @@ export function parseSheet(workbook: XLSX.WorkBook, sheetName: string): ParsedEx
throw new Error('工作表中没有数据');
}
const headers = Object.keys(jsonData[0]);
return { headers, rows: jsonData, sheetNames: workbook.SheetNames };
const allHeaders = Object.keys(jsonData[0]);
// Filter out SheetJS placeholder headers for empty columns (__EMPTY, __EMPTY_1, etc.)
const headers = allHeaders.filter((h) => !/^__EMPTY(_\d+)?$/.test(h));
// Strip empty-column keys from rows
const rows = headers.length < allHeaders.length
? jsonData.map((row) => {
const cleaned: Record<string, unknown> = {};
for (const h of headers) cleaned[h] = row[h];
return cleaned;
})
: jsonData;
return { headers, rows, sheetNames: workbook.SheetNames };
}

View File

@@ -14,6 +14,34 @@
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);
@@ -34,7 +62,20 @@
}, 150);
});
const jsonString = $derived(JSON.stringify(convertedJson, null, 2));
// 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) {
@@ -239,15 +280,56 @@
<!-- Main content -->
{#if hasData}
<div class="flex flex-1 overflow-hidden">
<!-- 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="w-1/2 overflow-hidden border-r border-gray-300">
<div class="overflow-hidden" style="width: {jsonCollapsed ? '100%' : `${splitPercent}%`}">
<ExcelTable {headers} {rows} bind:mappings />
</div>
<!-- Right: JSON Preview -->
<div class="w-1/2 overflow-hidden">
<JsonPreview json={convertedJson} />
</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 -->