增强 JSON 预览功能,支持手动编辑和面板拖动调整;优化 Excel 数据解析,过滤空列
This commit is contained in:
@@ -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, '&')
|
||||
@@ -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 ? '展开' : '折叠'}
|
||||
<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">
|
||||
⚠️ 预览已截断以提升性能(显示 {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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
{#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 -->
|
||||
|
||||
Reference in New Issue
Block a user