更新 README 文档,添加 JSON 预览功能的示例图片;新增 JsonTreeNode 组件以支持树形结构展示 JSON 数据

This commit is contained in:
lirui
2026-02-09 20:48:22 +08:00
parent 7f3eb2db49
commit 23bf383c04
5 changed files with 155 additions and 16 deletions

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import JsonTreeNode from './JsonTreeNode.svelte';
let { json, onupdate }: {
json: Record<string, unknown>[];
onupdate?: (edited: string) => void;
@@ -6,20 +8,21 @@
const MAX_PREVIEW_CHARS = 2000;
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 parseError = $state('');
const jsonString = $derived(JSON.stringify(json, null, 2));
// Truncation for preview
// Truncation for raw 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);
@@ -27,9 +30,13 @@
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
$effect(() => {
if (!editing) {
if (viewMode !== 'edit') {
editText = jsonString;
}
});
@@ -48,12 +55,12 @@
function startEdit() {
editText = jsonString;
editing = true;
viewMode = 'edit';
parseError = '';
}
function cancelEdit() {
editing = false;
viewMode = 'tree';
parseError = '';
editText = jsonString;
}
@@ -62,7 +69,7 @@
try {
JSON.parse(editText);
parseError = '';
editing = false;
viewMode = 'tree';
onupdate?.(editText);
} catch {
parseError = 'JSON 格式错误,请检查语法';
@@ -71,13 +78,14 @@
</script>
<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">
<h3 class="text-sm font-semibold text-gray-700">
JSON 预览
<span class="ml-2 text-xs font-normal text-gray-400">{json.length}记录</span>
JSON
<span class="ml-1 text-xs font-normal text-gray-400">{json.length}</span>
</h3>
<div class="flex items-center gap-1.5">
{#if editing}
<div class="flex items-center gap-1">
{#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>
@@ -85,7 +93,24 @@
取消
</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">
<!-- 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>
{/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>
{/if}
<!-- Content -->
<div class="json-preview flex-1 overflow-hidden">
{#if editing}
<!-- Plain textarea for editing — no overlay highlighting to avoid lag -->
{#if viewMode === 'edit'}
<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 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">
&#x26A0;&#xFE0F; 树形视图仅展示前 {MAX_TREE_ITEMS} 条记录(共 {json.length.toLocaleString()} 条)。请下载或复制查看完整数据。
</div>
{/if}
</div>
{:else}
<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 class="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>

View 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">:&nbsp;</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>