feat(json-parser): 重构树视图为递归组件并支持节点选中

This commit is contained in:
mogumc committed 2026-07-23 14:59:51 +08:00
1 parent f3e3813f88
commit 297be1cc99
2 files changed
+416 -257

No files matched your search

+217 -257
View File
@@ -1,28 +1,32 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from '../composables/useI18n'
import JsonTreeNode from './JsonTreeNode.vue'
const { t } = useI18n()
const input = ref('')
const parsedData = ref(null)
const error = ref('')
const viewMode = ref('tree') // tree, table, raw
const viewMode = ref('tree')
const expandedKeys = ref(new Set())
const searchKeyword = ref('')
const selectedPath = ref('')
const selectedValue = ref(null)
// 解析 JSON
const parseJson = () => {
error.value = ''
parsedData.value = null
selectedPath.value = ''
selectedValue.value = null
if (!input.value.trim()) {
return
}
try {
parsedData.value = JSON.parse(input.value)
// 自动展开第一层
expandFirstLevel()
} catch (e) {
error.value = t('jsonparser.msg.parse_error') + e.message
@@ -33,13 +37,19 @@ const parseJson = () => {
const expandFirstLevel = () => {
expandedKeys.value.clear()
if (parsedData.value && typeof parsedData.value === 'object') {
expandedKeys.value.add('$')
if (Array.isArray(parsedData.value)) {
parsedData.value.forEach((_, index) => {
expandedKeys.value.add(`root-${index}`)
parsedData.value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
expandedKeys.value.add(`$[${index}]`)
}
})
} else {
Object.keys(parsedData.value).forEach(key => {
expandedKeys.value.add(`root-${key}`)
const val = parsedData.value[key]
if (typeof val === 'object' && val !== null) {
expandedKeys.value.add(`$.${key}`)
}
})
}
}
@@ -52,11 +62,34 @@ const toggleExpand = (key) => {
} else {
expandedKeys.value.add(key)
}
// 强制响应式更新
expandedKeys.value = new Set(expandedKeys.value)
}
// 检查是否展开
const isExpanded = (key) => {
return expandedKeys.value.has(key)
// 选中节点
const onSelectNode = (path) => {
selectedPath.value = path
selectedValue.value = resolvePath(parsedData.value, path)
}
// 根据路径字符串解析值
const resolvePath = (data, path) => {
if (path === '$') return data
const segments = path
.replace(/^\$\./, '')
.replace(/\[(\d+)\]/g, '.$1')
.replace(/\['(.+?)'\]/g, '.$1')
.split('.')
let current = data
for (const seg of segments) {
if (current == null) return null
if (Array.isArray(current)) {
current = current[parseInt(seg)]
} else {
current = current[seg]
}
}
return current
}
// 获取值类型
@@ -66,27 +99,15 @@ const getValueType = (value) => {
return typeof value
}
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
'string': '#67c23a',
'number': '#e6a23c',
'boolean': '#409eff',
'null': '#909399',
'object': '#f56c6c',
'array': '#9b59b6'
}
return colors[type] || '#909399'
}
// 格式化值显示
const formatValue = (value) => {
if (value === null) return 'null'
if (typeof value === 'string') return `"${value}"`
if (typeof value === 'object') return JSON.stringify(value, null, 2)
if (typeof value === 'string') return value
return String(value)
}
// 复制到剪贴板
// 复��到剪贴板
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
@@ -103,10 +124,21 @@ const copyJson = () => {
}
}
// 复制单个值
const copyValue = (value) => {
const text = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)
copyToClipboard(text)
// 复制选中路径
const copyPath = () => {
if (selectedPath.value) {
copyToClipboard(selectedPath.value)
}
}
// 复制选中值
const copySelectedValue = () => {
if (selectedValue.value !== null) {
const text = typeof selectedValue.value === 'object'
? JSON.stringify(selectedValue.value, null, 2)
: String(selectedValue.value)
copyToClipboard(text)
}
}
// 清空
@@ -115,36 +147,20 @@ const clearAll = () => {
parsedData.value = null
error.value = ''
expandedKeys.value.clear()
selectedPath.value = ''
selectedValue.value = null
}
// 统计信息
const stats = computed(() => {
if (!parsedData.value) return null
const count = {
objects: 0,
arrays: 0,
strings: 0,
numbers: 0,
booleans: 0,
nulls: 0
}
const count = { objects: 0, arrays: 0, strings: 0, numbers: 0, booleans: 0, nulls: 0 }
const traverse = (value) => {
if (value === null) {
count.nulls++
return
}
if (Array.isArray(value)) {
count.arrays++
value.forEach(traverse)
return
}
if (typeof value === 'object') {
count.objects++
Object.values(value).forEach(traverse)
return
}
if (value === null) { count.nulls++; return }
if (Array.isArray(value)) { count.arrays++; value.forEach(traverse); return }
if (typeof value === 'object') { count.objects++; Object.values(value).forEach(traverse); return }
switch (typeof value) {
case 'string': count.strings++; break
case 'number': count.numbers++; break
@@ -156,13 +172,15 @@ const stats = computed(() => {
return count
})
// 监听输入变化
// 监听输入��化
watch(input, () => {
if (input.value.trim()) {
parseJson()
} else {
parsedData.value = null
error.value = ''
selectedPath.value = ''
selectedValue.value = null
}
})
@@ -184,8 +202,9 @@ onUnmounted(() => {
</script>
<template>
<div class="json-parser">
<div class="toolbar">
<div class="json-parser-wrap">
<div class="json-parser">
<div class="toolbar">
<div class="toolbar-left">
<span class="toolbar-title">{{ t('jsonparser.title') }}</span>
<template v-if="stats">
@@ -221,138 +240,37 @@ onUnmounted(() => {
<div class="output-section">
<div class="section-header">{{ t('jsonparser.section.result') }}</div>
<!-- 树形视图 -->
<div v-if="viewMode === 'tree' && parsedData" class="tree-view">
<div class="tree-node root">
<div class="node-header" @click="toggleExpand('root')">
<span class="expand-icon">{{ isExpanded('root') ? '▼' : '▶' }}</span>
<span class="node-type" :style="{ color: getTypeColor(getValueType(parsedData)) }">
{{ getValueType(parsedData) }}
</span>
<span class="node-info" v-if="Array.isArray(parsedData)">
Array[{{ parsedData.length }}]
</span>
</div>
<div class="node-children" v-if="isExpanded('root')">
<template v-if="Array.isArray(parsedData)">
<div v-for="(item, index) in parsedData" :key="index" class="tree-node">
<div class="node-header" @click="toggleExpand(`root-${index}`)">
<span class="expand-icon">{{ isExpanded(`root-${index}`) ? '▼' : '▶' }}</span>
<span class="node-index">[{{ index }}]</span>
<span class="node-type" :style="{ color: getTypeColor(getValueType(item)) }">
{{ getValueType(item) }}
</span>
<span class="node-preview" v-if="typeof item !== 'object' || item === null">
{{ formatValue(item) }}
</span>
<span class="node-preview" v-else-if="Array.isArray(item)">
Array[{{ item.length }}]
</span>
<span class="node-preview" v-else>
Object{ {{ Object.keys(item).length }} }
</span>
<el-button size="small" text @click.stop="copyValue(item)">{{ t('jsonparser.btn.copy') }}</el-button>
</div>
<div class="node-children" v-if="isExpanded(`root-${index}`) && typeof item === 'object' && item !== null">
<template v-if="Array.isArray(item)">
<div v-for="(subItem, subIndex) in item" :key="subIndex" class="tree-node leaf">
<div class="node-header">
<span class="node-index">[{{ subIndex }}]</span>
<span class="node-type" :style="{ color: getTypeColor(getValueType(subItem)) }">
{{ getValueType(subItem) }}
</span>
<span class="node-value">{{ formatValue(subItem) }}</span>
</div>
</div>
</template>
<template v-else>
<div v-for="(subValue, subKey) in item" :key="subKey" class="tree-node leaf">
<div class="node-header">
<span class="node-key">{{ subKey }}:</span>
<span class="node-type" :style="{ color: getTypeColor(getValueType(subValue)) }">
{{ getValueType(subValue) }}
</span>
<span class="node-value">{{ formatValue(subValue) }}</span>
</div>
</div>
</template>
</div>
</div>
</template>
<template v-else>
<div v-for="(value, key) in parsedData" :key="key" class="tree-node">
<div class="node-header" @click="toggleExpand(`root-${key}`)">
<span class="expand-icon">{{ isExpanded(`root-${key}`) ? '▼' : '▶' }}</span>
<span class="node-key">{{ key }}:</span>
<span class="node-type" :style="{ color: getTypeColor(getValueType(value)) }">
{{ getValueType(value) }}
</span>
<span class="node-preview" v-if="typeof value !== 'object' || value === null">
{{ formatValue(value) }}
</span>
<span class="node-preview" v-else-if="Array.isArray(value)">
Array[{{ value.length }}]
</span>
<span class="node-preview" v-else>
Object{ {{ Object.keys(value).length }} }
</span>
<el-button size="small" text @click.stop="copyValue(value)">{{ t('jsonparser.btn.copy') }}</el-button>
</div>
<div class="node-children" v-if="isExpanded(`root-${key}`) && typeof value === 'object' && value !== null">
<template v-if="Array.isArray(value)">
<div v-for="(subItem, subIndex) in value" :key="subIndex" class="tree-node leaf">
<div class="node-header">
<span class="node-index">[{{ subIndex }}]</span>
<span class="node-type" :style="{ color: getTypeColor(getValueType(subItem)) }">
{{ getValueType(subItem) }}
</span>
<span class="node-value">{{ formatValue(subItem) }}</span>
</div>
</div>
</template>
<template v-else>
<div v-for="(subValue, subKey) in value" :key="subKey" class="tree-node leaf">
<div class="node-header">
<span class="node-key">{{ subKey }}:</span>
<span class="node-type" :style="{ color: getTypeColor(getValueType(subValue)) }">
{{ getValueType(subValue) }}
</span>
<span class="node-value">{{ formatValue(subValue) }}</span>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
<JsonTreeNode
:value="parsedData"
path="$"
:depth="0"
:expandedKeys="expandedKeys"
:selectedPath="selectedPath"
@toggle-expand="toggleExpand"
@select-node="onSelectNode"
/>
</div>
<!-- ���格视图 -->
<div v-if="viewMode === 'table' && parsedData" class="table-view">
<el-table :data="Array.isArray(parsedData) ? parsedData.map((item, index) => ({ _index: index, _value: item })) : Object.entries(parsedData).map(([key, value]) => ({ _key: key, _value: value }))" border stripe max-height="500">
<el-table-column v-if="!Array.isArray(parsedData)" prop="_key" :label="t('jsonparser.table.column.key')" width="200" />
<el-table-column v-else prop="_index" :label="t('jsonparser.table.column.index')" width="100" />
<el-table-column prop="_value" :label="t('jsonparser.table.column.value')">
<template #default="{ row }">
<div class="table-value">
<el-tag :color="getTypeColor(getValueType(row._value))" effect="dark" size="small" style="margin-right: 8px">
{{ getValueType(row._value) }}
</el-tag>
<span>{{ typeof row._value === 'object' ? JSON.stringify(row._value) : formatValue(row._value) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="t('jsonparser.table.column.action')" width="80">
<template #default="{ row }">
<el-button size="small" text @click="copyValue(row._value)">{{ t('jsonparser.btn.copy') }}</el-button>
</template>
</el-table-column>
<el-table
:data="Array.isArray(parsedData)
? parsedData.map((item, i) => ({ _key: i, _value: item }))
: Object.entries(parsedData).map(([k, v]) => ({ _key: k, _value: v }))"
border stripe max-height="500"
>
<el-table-column prop="_key" :label="Array.isArray(parsedData) ? t('jsonparser.table.column.index') : t('jsonparser.table.column.key')" width="180" />
<el-table-column prop="_value" :label="t('jsonparser.table.column.value')" min-width="200" />
</el-table>
</div>
<div v-if="viewMode === 'raw'" class="raw-view">
<!-- 原始视图 -->
<div v-if="viewMode === 'raw' && parsedData" class="raw-view">
<el-input
:value="parsedData ? JSON.stringify(parsedData, null, 2) : ''"
:model-value="JSON.stringify(parsedData, null, 2)"
type="textarea"
:rows="20"
readonly
@@ -360,36 +278,66 @@ onUnmounted(() => {
/>
</div>
<el-empty v-if="!parsedData && !error && viewMode !== 'raw'" :description="t('jsonparser.empty')" />
<el-empty v-if="!parsedData && !error" :description="t('jsonparser.empty')" />
</div>
</div>
<div v-if="error" class="error-message">
<el-alert :title="error" type="error" show-icon :closable="false" />
<!-- 键值选择面板 -->
<div v-if="selectedPath" class="key-value-panel">
<div class="kv-box">
<div class="kv-label">{{ t('jsonparser.panel.path') }}</div>
<div class="kv-content mono">
{{ selectedPath }}
<el-button size="small" text class="kv-copy-btn" @click="copyPath">{{ t('jsonparser.btn.copy') }}</el-button>
</div>
</div>
<div class="kv-box">
<div class="kv-label">{{ t('jsonparser.panel.value') }}</div>
<div class="kv-content mono">
{{ formatValue(selectedValue) }}
<el-button size="small" text class="kv-copy-btn" @click="copySelectedValue">{{ t('jsonparser.btn.copy') }}</el-button>
</div>
</div>
</div>
<div v-if="error" class="error-message">
<el-alert :title="error" type="error" show-icon :closable="false" />
</div>
</div>
</div>
</template>
<style scoped>
.json-parser-wrap {
position: absolute;
top: 16px;
right: 16px;
bottom: 16px;
left: 16px;
overflow: hidden;
}
.json-parser {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
gap: 8px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
padding: 8px 12px;
background-color: #f8f9fa;
border-radius: 4px;
flex-shrink: 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
}
.toolbar-title {
@@ -406,121 +354,133 @@ onUnmounted(() => {
.parser-content {
flex: 1;
display: flex;
gap: 16px;
gap: 12px;
overflow: hidden;
min-height: 0;
}
.input-section,
.output-section {
flex: 1;
min-width: 0;
width: 0;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
gap: 6px;
}
.section-header {
font-weight: 500;
color: #606266;
padding: 0 4px;
flex-shrink: 0;
}
/* 树形视图样式 */
/* 输入区滚动 */
.input-section :deep(.el-textarea) {
flex: 1;
min-height: 0;
}
.input-section :deep(.el-textarea__inner) {
height: 100% !important;
resize: none;
white-space: pre-wrap;
word-break: break-all;
overflow-wrap: break-word;
}
/* 树形视图 */
.tree-view {
flex: 1;
overflow: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 12px;
padding: 8px 10px;
background: #fafafa;
min-height: 0;
}
.tree-node {
margin-left: 20px;
/* 表格视图 */
.table-view {
flex: 1;
overflow: auto;
min-height: 0;
}
.tree-node.root {
margin-left: 0;
/* 原始视图 */
.raw-view {
flex: 1;
min-height: 0;
}
.tree-node.leaf {
margin-left: 20px;
.raw-view :deep(.el-textarea) {
height: 100%;
}
.node-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
.node-header:hover {
background-color: #e9ecef;
}
.expand-icon {
width: 16px;
font-size: 12px;
color: #909399;
}
.node-type {
font-size: 12px;
font-weight: 500;
padding: 2px 6px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.node-key {
font-weight: 500;
color: #303133;
}
.node-index {
color: #909399;
}
.node-preview {
color: #606266;
font-size: 13px;
}
.node-value {
color: #606266;
.raw-view :deep(.el-textarea__inner) {
height: 100% !important;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
white-space: pre-wrap;
word-break: break-all;
}
.node-children {
margin-left: 20px;
border-left: 1px dashed #dcdfe6;
padding-left: 8px;
}
/* 表格视图样式 */
.table-view {
flex: 1;
}
.table-value {
/* 键值选择面板 */
.key-value-panel {
display: flex;
align-items: center;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
gap: 10px;
flex-shrink: 0;
min-height: 0;
}
/* 原始视图样式 */
.raw-view {
.kv-box {
flex: 1;
min-width: 0;
width: 0;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
display: flex;
flex-direction: column;
}
.raw-view :deep(textarea) {
.kv-label {
font-size: 12px;
color: #909399;
padding: 4px 10px;
border-bottom: 1px solid #ebeef5;
flex-shrink: 0;
background: #f5f7fa;
}
.kv-content {
flex: 1;
padding: 6px 10px;
overflow: auto;
position: relative;
font-size: 13px;
color: #303133;
}
.kv-content:hover .kv-copy-btn {
opacity: 1;
}
.kv-copy-btn {
position: absolute;
top: 4px;
right: 6px;
opacity: 0;
transition: opacity 0.15s;
}
.mono {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
white-space: pre-wrap;
word-break: break-all;
}
.error-message {
margin-top: 8px;
flex-shrink: 0;
}
</style>
+199
View File
@@ -0,0 +1,199 @@
<script setup>
// 递归渲染 JSON 树节点,支持任意深度
import { ref } from 'vue'
const props = defineProps({
value: { required: true },
path: { type: String, required: true },
depth: { type: Number, default: 0 },
expandedKeys: { type: Object, required: true },
selectedPath: { type: String, default: '' }
})
const emit = defineEmits(['toggle-expand', 'select-node'])
const isExpanded = (key) => props.expandedKeys.has(key)
const getValueType = (value) => {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value
}
const getTypeColor = (type) => {
const colors = {
'string': '#67c23a',
'number': '#e6a23c',
'boolean': '#409eff',
'null': '#909399',
'object': '#f56c6c',
'array': '#9b59b6'
}
return colors[type] || '#909399'
}
const formatValue = (value) => {
if (value === null) return 'null'
if (typeof value === 'string') return `"${value}"`
return String(value)
}
// 切分子路径给下级
const childPath = (parentPath, key, index) => {
if (index !== null && index !== undefined) {
return `${parentPath}[${index}]`
}
// key 含特殊字符时用 ['key'] 格式
if (typeof key === 'string' && /[.\s\[\]]/.test(key)) {
return `${parentPath}['${key}']`
}
// 首层根键不加点
if (parentPath === '$') {
return `$.${key}`
}
return `${parentPath}.${key}`
}
const isSelected = (nodePath) => props.selectedPath === nodePath
const onToggle = (key) => {
emit('toggle-expand', key)
}
const onSelect = (nodePath) => {
emit('select-node', nodePath)
}
</script>
<template>
<div class="tree-node" :style="{ marginLeft: depth > 0 ? '18px' : '0' }">
<!-- 节点头 -->
<div
class="node-header"
:class="{ 'node-selected': isSelected(path) }"
@click="onSelect(path)"
>
<span
v-if="typeof value === 'object' && value !== null"
class="expand-icon"
:class="{ expanded: isExpanded(path) }"
@click.stop="onToggle(path)"
></span>
<span v-else class="expand-icon spacer"></span>
<span class="node-type" :style="{ color: getTypeColor(getValueType(value)) }">
{{ getValueType(value) }}
</span>
<span class="node-preview" v-if="typeof value !== 'object' || value === null">
{{ formatValue(value) }}
</span>
<span class="node-preview" v-else-if="Array.isArray(value)">
Array[{{ value.length }}]
</span>
<span class="node-preview" v-else>
Object{ {{ Object.keys(value).length }} }
</span>
</div>
<!-- 子节点 -->
<div v-if="isExpanded(path) && typeof value === 'object' && value !== null" class="node-children">
<template v-if="Array.isArray(value)">
<JsonTreeNode
v-for="(item, index) in value"
:key="index"
:value="item"
:path="childPath(path, `[${index}]`, index)"
:depth="depth + 1"
:expandedKeys="expandedKeys"
:selectedPath="selectedPath"
@toggle-expand="emit('toggle-expand', $event)"
@select-node="emit('select-node', $event)"
/>
</template>
<template v-else>
<JsonTreeNode
v-for="(v, k) in value"
:key="k"
:value="v"
:path="childPath(path, k)"
:depth="depth + 1"
:expandedKeys="expandedKeys"
:selectedPath="selectedPath"
@toggle-expand="emit('toggle-expand', $event)"
@select-node="emit('select-node', $event)"
/>
</template>
</div>
</div>
</template>
<style scoped>
.tree-node {
font-size: 13px;
}
.node-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
cursor: pointer;
border-radius: 3px;
transition: background-color 0.15s;
white-space: nowrap;
}
.node-header:hover {
background-color: #e9ecef;
}
.node-selected {
background-color: #d9ecff !important;
}
.expand-icon {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #909399;
font-size: 10px;
transition: transform 0.15s;
}
.expand-icon::before {
content: '▶';
}
.expand-icon.expanded::before {
content: '▼';
}
.expand-icon.spacer {
visibility: hidden;
}
.node-type {
font-size: 11px;
font-weight: 500;
padding: 1px 5px;
background-color: rgba(0, 0, 0, 0.06);
border-radius: 4px;
flex-shrink: 0;
}
.node-preview {
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-children {
border-left: 1px dashed #dcdfe6;
margin-left: 7px;
}
</style>