更新 README 文档,添加 JSON 预览功能的示例图片;新增 JsonTreeNode 组件以支持树形结构展示 JSON 数据
This commit is contained in:
@@ -105,3 +105,10 @@ npm run preview
|
|||||||
- 日期字段会尽量兼容 Excel 序列日期(数字日期)
|
- 日期字段会尽量兼容 Excel 序列日期(数字日期)
|
||||||
- 当 `excludeIfEmpty = true` 时,空值字段不会出现在输出 JSON 中
|
- 当 `excludeIfEmpty = true` 时,空值字段不会出现在输出 JSON 中
|
||||||
- 若目标字段包含 `.`,会按路径写入嵌套对象
|
- 若目标字段包含 `.`,会按路径写入嵌套对象
|
||||||
|
|
||||||
|
## <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͼ
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|||||||
BIN
doc/images/image1.png
Normal file
BIN
doc/images/image1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
BIN
doc/images/image2.png
Normal file
BIN
doc/images/image2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 293 KiB |
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import JsonTreeNode from './JsonTreeNode.svelte';
|
||||||
|
|
||||||
let { json, onupdate }: {
|
let { json, onupdate }: {
|
||||||
json: Record<string, unknown>[];
|
json: Record<string, unknown>[];
|
||||||
onupdate?: (edited: string) => void;
|
onupdate?: (edited: string) => void;
|
||||||
@@ -6,20 +8,21 @@
|
|||||||
|
|
||||||
const MAX_PREVIEW_CHARS = 2000;
|
const MAX_PREVIEW_CHARS = 2000;
|
||||||
const MAX_PREVIEW_LINES = 50;
|
const MAX_PREVIEW_LINES = 50;
|
||||||
|
const MAX_TREE_ITEMS = 50;
|
||||||
|
|
||||||
let editing = $state(false);
|
type ViewMode = 'tree' | 'raw' | 'edit';
|
||||||
|
let viewMode = $state<ViewMode>('tree');
|
||||||
let editText = $state('');
|
let editText = $state('');
|
||||||
let parseError = $state('');
|
let parseError = $state('');
|
||||||
|
|
||||||
const jsonString = $derived(JSON.stringify(json, null, 2));
|
const jsonString = $derived(JSON.stringify(json, null, 2));
|
||||||
|
|
||||||
// Truncation for preview
|
// Truncation for raw preview
|
||||||
const truncated = $derived.by(() => {
|
const truncated = $derived.by(() => {
|
||||||
const lines = jsonString.split('\n');
|
const lines = jsonString.split('\n');
|
||||||
if (jsonString.length <= MAX_PREVIEW_CHARS && lines.length <= MAX_PREVIEW_LINES) {
|
if (jsonString.length <= MAX_PREVIEW_CHARS && lines.length <= MAX_PREVIEW_LINES) {
|
||||||
return { text: jsonString, isTruncated: false, totalChars: jsonString.length, totalLines: lines.length };
|
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');
|
let preview = lines.slice(0, MAX_PREVIEW_LINES).join('\n');
|
||||||
if (preview.length > MAX_PREVIEW_CHARS) {
|
if (preview.length > MAX_PREVIEW_CHARS) {
|
||||||
preview = preview.slice(0, MAX_PREVIEW_CHARS);
|
preview = preview.slice(0, MAX_PREVIEW_CHARS);
|
||||||
@@ -27,9 +30,13 @@
|
|||||||
return { text: preview, isTruncated: true, totalChars: jsonString.length, totalLines: lines.length };
|
return { text: preview, isTruncated: true, totalChars: jsonString.length, totalLines: lines.length };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tree view shows limited items for performance
|
||||||
|
const treeData = $derived(json.length > MAX_TREE_ITEMS ? json.slice(0, MAX_TREE_ITEMS) : json);
|
||||||
|
const treeTruncated = $derived(json.length > MAX_TREE_ITEMS);
|
||||||
|
|
||||||
// Sync from upstream when not editing
|
// Sync from upstream when not editing
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!editing) {
|
if (viewMode !== 'edit') {
|
||||||
editText = jsonString;
|
editText = jsonString;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -48,12 +55,12 @@
|
|||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
editText = jsonString;
|
editText = jsonString;
|
||||||
editing = true;
|
viewMode = 'edit';
|
||||||
parseError = '';
|
parseError = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editing = false;
|
viewMode = 'tree';
|
||||||
parseError = '';
|
parseError = '';
|
||||||
editText = jsonString;
|
editText = jsonString;
|
||||||
}
|
}
|
||||||
@@ -62,7 +69,7 @@
|
|||||||
try {
|
try {
|
||||||
JSON.parse(editText);
|
JSON.parse(editText);
|
||||||
parseError = '';
|
parseError = '';
|
||||||
editing = false;
|
viewMode = 'tree';
|
||||||
onupdate?.(editText);
|
onupdate?.(editText);
|
||||||
} catch {
|
} catch {
|
||||||
parseError = 'JSON 格式错误,请检查语法';
|
parseError = 'JSON 格式错误,请检查语法';
|
||||||
@@ -71,13 +78,14 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-hidden">
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2">
|
<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">
|
<h3 class="text-sm font-semibold text-gray-700">
|
||||||
JSON 预览
|
JSON
|
||||||
<span class="ml-2 text-xs font-normal text-gray-400">{json.length} 条记录</span>
|
<span class="ml-1 text-xs font-normal text-gray-400">{json.length} 条</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1">
|
||||||
{#if editing}
|
{#if viewMode === 'edit'}
|
||||||
<button onclick={applyEdit} class="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700 cursor-pointer">
|
<button onclick={applyEdit} class="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700 cursor-pointer">
|
||||||
应用
|
应用
|
||||||
</button>
|
</button>
|
||||||
@@ -85,7 +93,24 @@
|
|||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{: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">
|
<!-- View mode tabs -->
|
||||||
|
<div class="flex rounded border border-gray-300 text-xs">
|
||||||
|
<button
|
||||||
|
onclick={() => (viewMode = 'tree')}
|
||||||
|
class="px-2 py-1 cursor-pointer {viewMode === 'tree' ? 'bg-gray-200 text-gray-800 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'}"
|
||||||
|
style="border-radius: 3px 0 0 3px"
|
||||||
|
>
|
||||||
|
树形
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (viewMode = 'raw')}
|
||||||
|
class="border-l border-gray-300 px-2 py-1 cursor-pointer {viewMode === 'raw' ? 'bg-gray-200 text-gray-800 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'}"
|
||||||
|
style="border-radius: 0 3px 3px 0"
|
||||||
|
>
|
||||||
|
源码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onclick={startEdit} class="ml-1 rounded border border-gray-300 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 cursor-pointer">
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -96,21 +121,30 @@
|
|||||||
<div class="flex-shrink-0 bg-red-50 px-4 py-1.5 text-xs text-red-600">{parseError}</div>
|
<div class="flex-shrink-0 bg-red-50 px-4 py-1.5 text-xs text-red-600">{parseError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
<div class="json-preview flex-1 overflow-hidden">
|
<div class="json-preview flex-1 overflow-hidden">
|
||||||
{#if editing}
|
{#if viewMode === 'edit'}
|
||||||
<!-- Plain textarea for editing — no overlay highlighting to avoid lag -->
|
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={editText}
|
bind:value={editText}
|
||||||
oninput={() => { parseError = ''; }}
|
oninput={() => { parseError = ''; }}
|
||||||
spellcheck="false"
|
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"
|
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>
|
></textarea>
|
||||||
|
{:else if viewMode === 'tree'}
|
||||||
|
<div class="h-full overflow-auto px-4 py-3">
|
||||||
|
<JsonTreeNode value={treeData} defaultOpen={true} />
|
||||||
|
{#if treeTruncated}
|
||||||
|
<div class="mt-2 border-t border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
|
⚠️ 树形视图仅展示前 {MAX_TREE_ITEMS} 条记录(共 {json.length.toLocaleString()} 条)。请下载或复制查看完整数据。
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-full overflow-auto">
|
<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>
|
<pre class="json-content whitespace-pre font-mono text-xs leading-relaxed text-gray-500">{@html highlight(truncated.text)}</pre>
|
||||||
{#if truncated.isTruncated}
|
{#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">
|
<div class="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()} 行)。请点击下载或复制查看完整数据。
|
⚠️ 预览已截断(显示 {Math.min(MAX_PREVIEW_CHARS, truncated.text.length).toLocaleString()}/{truncated.totalChars.toLocaleString()} 字符,{Math.min(MAX_PREVIEW_LINES, truncated.totalLines)}/{truncated.totalLines.toLocaleString()} 行)。请下载或复制查看完整数据。
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
98
src/lib/components/JsonTreeNode.svelte
Normal file
98
src/lib/components/JsonTreeNode.svelte
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import JsonTreeNode from './JsonTreeNode.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value,
|
||||||
|
keyName = undefined,
|
||||||
|
depth = 0,
|
||||||
|
defaultOpen = false
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
keyName?: string | number;
|
||||||
|
depth?: number;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(defaultOpen);
|
||||||
|
|
||||||
|
const valueType = $derived(
|
||||||
|
value === null ? 'null'
|
||||||
|
: Array.isArray(value) ? 'array'
|
||||||
|
: typeof value === 'object' ? 'object'
|
||||||
|
: typeof value
|
||||||
|
);
|
||||||
|
|
||||||
|
const isExpandable = $derived(valueType === 'object' || valueType === 'array');
|
||||||
|
const entries = $derived(
|
||||||
|
isExpandable
|
||||||
|
? Object.entries(value as Record<string, unknown>)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const childCount = $derived(entries.length);
|
||||||
|
|
||||||
|
const preview = $derived(
|
||||||
|
valueType === 'array' ? `Array(${childCount})`
|
||||||
|
: valueType === 'object' ? `{${childCount}}`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tree-node" style="padding-left: {depth > 0 ? 16 : 0}px">
|
||||||
|
<div class="node-row flex items-baseline gap-1 py-0.5">
|
||||||
|
{#if isExpandable}
|
||||||
|
<button
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
class="toggle flex-shrink-0 cursor-pointer border-0 bg-transparent p-0 font-mono text-[10px] leading-none text-gray-400 hover:text-gray-700"
|
||||||
|
aria-label={open ? '折叠' : '展开'}
|
||||||
|
>
|
||||||
|
{open ? '▼' : '▶'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="w-[10px] flex-shrink-0"></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if keyName !== undefined}
|
||||||
|
<span class="node-key font-mono text-xs font-medium text-gray-800">
|
||||||
|
{typeof keyName === 'number' ? keyName : `"${keyName}"`}</span><span class="text-xs text-gray-400">: </span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isExpandable}
|
||||||
|
<button
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
class="cursor-pointer border-0 bg-transparent p-0 font-mono text-xs text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{#if open}
|
||||||
|
{valueType === 'array' ? '[' : '{'}
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400">{preview}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else if valueType === 'string'}
|
||||||
|
<span class="font-mono text-xs text-green-700">"{String(value)}"</span>
|
||||||
|
{:else if valueType === 'number'}
|
||||||
|
<span class="font-mono text-xs text-blue-700">{String(value)}</span>
|
||||||
|
{:else if valueType === 'boolean'}
|
||||||
|
<span class="font-mono text-xs text-red-600">{String(value)}</span>
|
||||||
|
{:else if valueType === 'null'}
|
||||||
|
<span class="font-mono text-xs italic text-gray-400">null</span>
|
||||||
|
{:else}
|
||||||
|
<span class="font-mono text-xs text-gray-600">{String(value)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isExpandable && open}
|
||||||
|
<div class="tree-children border-l border-gray-200">
|
||||||
|
{#each entries as [childKey, childVal], i (childKey)}
|
||||||
|
<JsonTreeNode
|
||||||
|
value={childVal}
|
||||||
|
keyName={valueType === 'array' ? i : childKey}
|
||||||
|
depth={depth + 1}
|
||||||
|
defaultOpen={depth < 1}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div style="padding-left: {depth > 0 ? 16 : 0}px">
|
||||||
|
<span class="font-mono text-xs text-gray-400">{valueType === 'array' ? ']' : '}'}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user