Files
ToolBox/src/components/JsonTreeNode.vue
2026-02-02 00:09:20 +08:00

397 lines
9.5 KiB
Vue

<template>
<div class="json-tree-node" :class="{ 'is-matched': isMatched, 'is-filtered': isFiltered }">
<div
class="node-line"
:class="{ 'has-children': hasChildren, 'is-expanded': isExpanded, 'is-matched': isMatched }"
@click="toggle"
>
<span v-if="hasChildren" class="expand-icon">
<i :class="isExpanded ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</span>
<span v-else class="expand-placeholder"></span>
<span v-if="showPath && nodePath" class="node-path">{{ nodePath }}:</span>
<span class="node-key" v-if="key !== null">
<span class="key-name" :class="{ 'matched': isMatched }">{{ key }}</span>:
</span>
<span class="node-value" :class="[valueType, { 'matched': isMatched }]">
<span v-if="valueType === 'string'">"{{ displayValue }}"</span>
<span v-else>{{ displayValue }}</span>
</span>
</div>
<div v-if="hasChildren && isExpanded && shouldShowChildren" class="node-children">
<JsonTreeNode
v-for="(child, index) in filteredChildren"
:key="index"
:data="child.value"
:key-name="child.key"
:path="child.path"
:expanded="expanded"
:matched-paths="matchedPaths"
:json-path-query="jsonPathQuery"
@toggle="$emit('toggle', $event)"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
data: {
type: [Object, Array, String, Number, Boolean, null],
required: true
},
keyName: {
type: [String, Number],
default: null
},
path: {
type: String,
default: ''
},
expanded: {
type: Set,
required: true
},
matchedPaths: {
type: Set,
default: () => new Set()
},
jsonPathQuery: {
type: String,
default: ''
},
showPath: {
type: Boolean,
default: false
},
nodePath: {
type: String,
default: ''
}
})
const emit = defineEmits(['toggle'])
const key = computed(() => props.keyName)
const currentPath = computed(() => {
if (props.path === '' && props.keyName === null) {
return 'root'
}
if (props.path === '') {
return String(props.keyName)
}
if (props.path === 'root') {
if (typeof props.keyName === 'number') {
return `root[${props.keyName}]`
}
return `root.${props.keyName}`
}
if (typeof props.keyName === 'number') {
return `${props.path}[${props.keyName}]`
}
return `${props.path}.${props.keyName}`
})
const hasChildren = computed(() => {
return (
(typeof props.data === 'object' && props.data !== null) ||
Array.isArray(props.data)
)
})
const isExpanded = computed(() => {
return props.expanded.has(currentPath.value)
})
// 判断当前节点是否匹配 JSONPath
const isMatched = computed(() => {
if (!props.jsonPathQuery || !props.jsonPathQuery.trim()) return false
return props.matchedPaths.has(currentPath.value)
})
// 判断是否有筛选
const hasFilter = computed(() => {
return props.jsonPathQuery && props.jsonPathQuery.trim() !== ''
})
// 判断是否应该显示子节点
const shouldShowChildren = computed(() => {
if (!hasFilter.value) return true
// 如果节点匹配,显示子节点(展开匹配节点的内容)
if (isMatched.value) return true
// 如果当前节点是匹配节点的子节点,显示所有子节点
if (isChildOfMatched.value) return true
// 检查是否有子节点匹配
return hasMatchingChildren.value
})
// 检查是否有子节点匹配
const hasMatchingChildren = computed(() => {
if (!hasFilter.value) return true
if (!hasChildren.value) return false
const basePath = props.path === '' && props.keyName === null ? 'root' : currentPath.value
if (Array.isArray(props.data)) {
return props.data.some((item, index) => {
const childPath = `${basePath}[${index}]`
return props.matchedPaths.has(childPath) || checkChildrenMatch(item, childPath)
})
}
if (typeof props.data === 'object' && props.data !== null) {
return Object.keys(props.data).some(key => {
const childPath = basePath === 'root' ? `root.${key}` : `${basePath}.${key}`
return props.matchedPaths.has(childPath) || checkChildrenMatch(props.data[key], childPath)
})
}
return false
})
// 递归检查子节点是否匹配
const checkChildrenMatch = (obj, path) => {
if (props.matchedPaths.has(path)) return true
if (Array.isArray(obj)) {
return obj.some((item, index) => {
const childPath = `${path}[${index}]`
return props.matchedPaths.has(childPath) || checkChildrenMatch(item, childPath)
})
}
if (typeof obj === 'object' && obj !== null) {
return Object.keys(obj).some(key => {
const childPath = `${path}.${key}`
return props.matchedPaths.has(childPath) || checkChildrenMatch(obj[key], childPath)
})
}
return false
}
const valueType = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) return 'array'
if (typeof props.data === 'object') return 'object'
return typeof props.data
})
const displayValue = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) {
return `Array(${props.data.length})`
}
if (typeof props.data === 'object') {
const keys = Object.keys(props.data)
return `Object(${keys.length})`
}
if (typeof props.data === 'string') {
return props.data
}
return String(props.data)
})
const children = computed(() => {
if (!hasChildren.value) return []
const basePath = props.path === '' && props.keyName === null ? 'root' : currentPath.value
if (Array.isArray(props.data)) {
return props.data.map((item, index) => ({
key: index,
value: item,
path: basePath
}))
}
if (typeof props.data === 'object' && props.data !== null) {
return Object.keys(props.data).map(key => ({
key: key,
value: props.data[key],
path: basePath
}))
}
return []
})
// 筛选后的子节点
const filteredChildren = computed(() => {
if (!hasFilter.value) return children.value
// 如果当前节点匹配,显示所有子节点(不进行过滤)
if (isMatched.value) return children.value
// 如果当前节点是匹配节点的子节点,显示所有子节点(不进行过滤)
if (isChildOfMatched.value) return children.value
// 否则,只显示匹配的子节点或其子节点匹配的子节点
return children.value.filter(child => {
const childPath = child.path === 'root'
? (typeof child.key === 'number' ? `root[${child.key}]` : `root.${child.key}`)
: (typeof child.key === 'number' ? `${child.path}[${child.key}]` : `${child.path}.${child.key}`)
// 如果子节点匹配,显示
if (props.matchedPaths.has(childPath)) return true
// 如果子节点的子节点匹配,也显示
return checkChildrenMatch(child.value, childPath)
})
})
// 检查当前节点是否是匹配节点的子节点
const isChildOfMatched = computed(() => {
if (!hasFilter.value || isMatched.value) return false
// 检查当前路径是否以任何匹配路径开头(作为前缀)
for (const matchedPath of props.matchedPaths) {
if (matchedPath === 'root') continue
// 如果当前路径以匹配路径开头,且后面跟着 . 或 [,说明是子节点
const prefix = matchedPath + '.'
const prefixBracket = matchedPath + '['
if (currentPath.value.startsWith(prefix) || currentPath.value.startsWith(prefixBracket)) {
return true
}
}
return false
})
// 判断是否被筛选(有筛选但当前节点不匹配且没有匹配的子节点,且不是匹配节点的子节点)
const isFiltered = computed(() => {
if (!hasFilter.value) return false
if (isMatched.value) return false
if (isChildOfMatched.value) return false
return !hasMatchingChildren.value
})
const toggle = () => {
if (hasChildren.value) {
emit('toggle', currentPath.value)
}
}
</script>
<style scoped>
.json-tree-node {
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.8;
}
.node-line {
display: flex;
align-items: center;
padding: 2px 0;
cursor: default;
user-select: text;
}
.node-line.has-children {
cursor: pointer;
}
.node-line.has-children:hover {
background: #f0f0f0;
}
.expand-icon {
display: inline-block;
width: 16px;
text-align: center;
color: #666;
font-size: 10px;
margin-right: 4px;
}
.expand-icon i {
font-size: 10px;
}
.expand-placeholder {
display: inline-block;
width: 16px;
margin-right: 4px;
}
.node-path {
color: #666666;
font-size: 0.8125rem;
margin-right: 8px;
font-family: 'Courier New', monospace;
opacity: 0.7;
}
.node-key {
margin-right: 6px;
}
.key-name {
color: #881391;
font-weight: 500;
}
.node-value {
color: #1a1aa6;
}
.node-value.string {
color: #0b7500;
}
.node-value.number {
color: #1a1aa6;
}
.node-value.boolean {
color: #1a1aa6;
}
.node-value.null {
color: #808080;
}
.node-value.object {
color: #1a1aa6;
font-style: italic;
}
.node-value.array {
color: #1a1aa6;
font-style: italic;
}
.node-children {
margin-left: 20px;
border-left: 1px solid #e0e0e0;
padding-left: 8px;
}
/* 匹配的节点高亮样式 */
.json-tree-node.is-matched .node-line {
background: #fff9e6;
border-left: 3px solid #ff9800;
padding-left: 5px;
margin-left: -8px;
}
.json-tree-node.is-matched .key-name.matched {
color: #ff6f00;
font-weight: 600;
}
.json-tree-node.is-matched .node-value.matched {
color: #ff6f00;
font-weight: 500;
}
/* 被筛选掉的节点隐藏 */
.json-tree-node.is-filtered {
display: none;
}
</style>