增强 JSON 预览功能,支持手动编辑和面板拖动调整;优化 Excel 数据解析,过滤空列
This commit is contained in:
@@ -1,13 +1,39 @@
|
|||||||
<script lang="ts">
|
<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));
|
const jsonString = $derived(JSON.stringify(json, null, 2));
|
||||||
|
|
||||||
/**
|
// Truncation for preview
|
||||||
* Simple JSON syntax highlighting — light theme.
|
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 {
|
function highlight(str: string): string {
|
||||||
return str
|
return str
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -19,6 +45,29 @@
|
|||||||
.replace(/:\s*(true|false)/g, ': <span class="json-bool">$1</span>')
|
.replace(/:\s*(true|false)/g, ': <span class="json-bool">$1</span>')
|
||||||
.replace(/:\s*(null)/g, ': <span class="json-null">$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>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-hidden">
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
@@ -27,21 +76,44 @@
|
|||||||
JSON 预览
|
JSON 预览
|
||||||
<span class="ml-2 text-xs font-normal text-gray-400">{json.length} 条记录</span>
|
<span class="ml-2 text-xs font-normal text-gray-400">{json.length} 条记录</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1.5">
|
||||||
<button
|
{#if editing}
|
||||||
onclick={() => (collapsed = !collapsed)}
|
<button onclick={applyEdit} class="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700 cursor-pointer">
|
||||||
class="rounded px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 cursor-pointer"
|
应用
|
||||||
>
|
|
||||||
{collapsed ? '展开' : '折叠'}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="json-preview flex-1 overflow-auto p-4">
|
{#if parseError}
|
||||||
{#if collapsed}
|
<div class="flex-shrink-0 bg-red-50 px-4 py-1.5 text-xs text-red-600">{parseError}</div>
|
||||||
<pre class="font-mono text-xs leading-relaxed text-gray-600">{JSON.stringify(json)}</pre>
|
{/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}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +121,16 @@
|
|||||||
<style>
|
<style>
|
||||||
.json-preview {
|
.json-preview {
|
||||||
background: #fafbfc;
|
background: #fafbfc;
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
.json-content {
|
||||||
|
padding: 16px;
|
||||||
|
margin: 0;
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
.json-textarea {
|
||||||
|
padding: 16px;
|
||||||
|
tab-size: 2;
|
||||||
}
|
}
|
||||||
:global(.json-key) {
|
:global(.json-key) {
|
||||||
color: #24292e;
|
color: #24292e;
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ export function parseSheet(workbook: XLSX.WorkBook, sheetName: string): ParsedEx
|
|||||||
throw new Error('工作表中没有数据');
|
throw new Error('工作表中没有数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = Object.keys(jsonData[0]);
|
const allHeaders = Object.keys(jsonData[0]);
|
||||||
return { headers, rows: jsonData, sheetNames: workbook.SheetNames };
|
// 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 isDragOver = $state(false);
|
||||||
let copySuccess = $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
|
// Derived
|
||||||
const hasData = $derived(headers.length > 0 && rows.length > 0);
|
const hasData = $derived(headers.length > 0 && rows.length > 0);
|
||||||
|
|
||||||
@@ -34,7 +62,20 @@
|
|||||||
}, 150);
|
}, 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
|
// File handling
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
@@ -239,15 +280,56 @@
|
|||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
{#if hasData}
|
{#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 -->
|
<!-- 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 />
|
<ExcelTable {headers} {rows} bind:mappings />
|
||||||
</div>
|
</div>
|
||||||
<!-- Right: JSON Preview -->
|
|
||||||
<div class="w-1/2 overflow-hidden">
|
{#if !jsonCollapsed}
|
||||||
<JsonPreview json={convertedJson} />
|
<!-- 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>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Empty state / drop zone -->
|
<!-- Empty state / drop zone -->
|
||||||
|
|||||||
Reference in New Issue
Block a user