843 lines
25 KiB
Vue
843 lines
25 KiB
Vue
<template>
|
||
<div class="space-y-6">
|
||
<!-- 工具栏 -->
|
||
<div class="card p-4">
|
||
<div class="flex flex-wrap gap-2">
|
||
<button
|
||
@click="formatJson"
|
||
:disabled="!jsonText.trim()"
|
||
class="btn-primary"
|
||
>
|
||
<FontAwesomeIcon :icon="['fas', 'code']" class="mr-2" />
|
||
格式化
|
||
</button>
|
||
|
||
<button
|
||
@click="compressJson"
|
||
:disabled="!jsonText.trim()"
|
||
class="btn-secondary"
|
||
>
|
||
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-2" />
|
||
压缩
|
||
</button>
|
||
|
||
<button
|
||
@click="validateJson"
|
||
:disabled="!jsonText.trim()"
|
||
class="btn-secondary"
|
||
>
|
||
<FontAwesomeIcon :icon="['fas', 'check']" class="mr-2" />
|
||
验证
|
||
</button>
|
||
|
||
<button
|
||
@click="copyJson"
|
||
:disabled="!jsonText.trim()"
|
||
class="btn-secondary"
|
||
>
|
||
<FontAwesomeIcon
|
||
:icon="copied ? ['fas', 'check'] : ['fas', 'copy']"
|
||
:class="['mr-2', copied && 'text-success']"
|
||
/>
|
||
复制
|
||
</button>
|
||
|
||
<button
|
||
@click="clearEditor"
|
||
class="btn-secondary"
|
||
>
|
||
<FontAwesomeIcon :icon="['fas', 'trash']" class="mr-2" />
|
||
清除
|
||
</button>
|
||
|
||
<button
|
||
@click="loadSample"
|
||
class="btn-secondary"
|
||
>
|
||
<FontAwesomeIcon :icon="['fas', 'file-import']" class="mr-2" />
|
||
示例
|
||
</button>
|
||
|
||
<div class="ml-auto flex items-center space-x-2">
|
||
<label class="text-sm text-secondary">视图:</label>
|
||
<select
|
||
v-model="viewMode"
|
||
class="select-field text-sm"
|
||
>
|
||
<option value="text">文本编辑</option>
|
||
<option value="tree">树形视图</option>
|
||
<option value="split">分屏视图</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid" :class="viewMode === 'split' ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'" style="gap: 1.5rem;">
|
||
<!-- 文本编辑器 -->
|
||
<div v-if="viewMode === 'text' || viewMode === 'split'" class="space-y-4">
|
||
<!-- 编辑器选项 -->
|
||
<div class="card p-4">
|
||
<h3 class="text-lg font-semibold text-primary mb-3">文本编辑器</h3>
|
||
|
||
<div class="flex flex-wrap gap-4 mb-4">
|
||
<div>
|
||
<label class="block text-sm text-secondary mb-1">缩进设置</label>
|
||
<select v-model="indentSize" class="select-field text-sm">
|
||
<option :value="2">2个空格</option>
|
||
<option :value="4">4个空格</option>
|
||
<option :value="'tab'">制表符</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm text-secondary mb-1">字体大小</label>
|
||
<select v-model="fontSize" class="select-field text-sm">
|
||
<option value="12">12px</option>
|
||
<option value="14">14px</option>
|
||
<option value="16">16px</option>
|
||
<option value="18">18px</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="flex items-center space-x-4">
|
||
<label class="flex items-center space-x-2">
|
||
<input
|
||
v-model="showLineNumbers"
|
||
type="checkbox"
|
||
class="form-checkbox"
|
||
>
|
||
<span class="text-sm text-secondary">显示行号</span>
|
||
</label>
|
||
|
||
<label class="flex items-center space-x-2">
|
||
<input
|
||
v-model="wordWrap"
|
||
type="checkbox"
|
||
class="form-checkbox"
|
||
>
|
||
<span class="text-sm text-secondary">自动换行</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- JSON输入区域 -->
|
||
<div class="relative">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="text-sm text-secondary">
|
||
行数: {{ lineCount }} | 字符数: {{ charCount }}
|
||
</div>
|
||
<div v-if="currentPath" class="text-sm text-tertiary">
|
||
当前路径: {{ currentPath }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="relative">
|
||
<!-- 行号 -->
|
||
<div
|
||
v-if="showLineNumbers"
|
||
class="absolute left-0 top-0 bottom-0 w-12 bg-block-hover border-r border-border text-xs text-tertiary font-mono flex flex-col z-10"
|
||
:style="{ fontSize: fontSize + 'px' }"
|
||
>
|
||
<div
|
||
v-for="n in lineCount"
|
||
:key="n"
|
||
class="h-6 flex items-center justify-end pr-2"
|
||
>
|
||
{{ n }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 编辑器 -->
|
||
<textarea
|
||
v-model="jsonText"
|
||
@input="handleTextInput"
|
||
@keydown="handleKeyDown"
|
||
@click="updateCursor"
|
||
@keyup="updateCursor"
|
||
:style="{
|
||
fontSize: fontSize + 'px',
|
||
paddingLeft: showLineNumbers ? '3rem' : '1rem',
|
||
whiteSpace: wordWrap ? 'pre-wrap' : 'pre'
|
||
}"
|
||
class="textarea-field font-mono resize-none transition-all"
|
||
:class="[
|
||
'h-96 w-full',
|
||
jsonError ? 'border-error' : 'border-border'
|
||
]"
|
||
placeholder="请输入JSON数据..."
|
||
spellcheck="false"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 错误信息 -->
|
||
<div v-if="jsonError" class="mt-2 p-3 bg-error bg-opacity-10 border border-error rounded-lg">
|
||
<div class="flex items-start space-x-2">
|
||
<FontAwesomeIcon :icon="['fas', 'exclamation-triangle']" class="text-error mt-0.5" />
|
||
<div>
|
||
<div class="font-medium text-error">JSON格式错误</div>
|
||
<div class="text-sm text-error opacity-80">{{ jsonError }}</div>
|
||
<div v-if="errorLine" class="text-xs text-error opacity-60 mt-1">
|
||
行 {{ errorLine }} | 列 {{ errorColumn }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 验证成功信息 -->
|
||
<div v-else-if="validationMessage" class="mt-2 p-3 bg-success bg-opacity-10 border border-success rounded-lg">
|
||
<div class="flex items-center space-x-2">
|
||
<FontAwesomeIcon :icon="['fas', 'check']" class="text-success" />
|
||
<span class="text-success">{{ validationMessage }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 树形视图 -->
|
||
<div v-if="viewMode === 'tree' || viewMode === 'split'" class="space-y-4">
|
||
<div class="card p-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h3 class="text-lg font-semibold text-primary">树形视图</h3>
|
||
<div class="flex space-x-2">
|
||
<button
|
||
@click="expandAll"
|
||
:disabled="!parsedJson"
|
||
class="btn-sm btn-secondary"
|
||
>
|
||
<FontAwesomeIcon :icon="['fas', 'expand']" class="mr-1" />
|
||
全部展开
|
||
</button>
|
||
<button
|
||
@click="collapseAll"
|
||
:disabled="!parsedJson"
|
||
class="btn-sm btn-secondary"
|
||
>
|
||
<FontAwesomeIcon :icon="['fas', 'compress']" class="mr-1" />
|
||
全部折叠
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="max-h-96 overflow-auto border border-border rounded-lg p-4 bg-block font-mono text-sm">
|
||
<JsonTreeNode
|
||
v-if="parsedJson !== null"
|
||
:data="parsedJson"
|
||
:path="[]"
|
||
:expanded="expandedNodes"
|
||
@toggle="toggleNode"
|
||
@select="selectNode"
|
||
/>
|
||
<div v-else class="text-tertiary text-center py-8">
|
||
请输入有效的JSON数据
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- JSON路径查询 -->
|
||
<div class="card p-4">
|
||
<h3 class="text-lg font-semibold text-primary mb-3">路径查询</h3>
|
||
|
||
<div class="space-y-3">
|
||
<div>
|
||
<label class="block text-sm text-secondary mb-1">JSON路径 (支持 . 和 [] 语法)</label>
|
||
<div class="flex space-x-2">
|
||
<input
|
||
v-model="jsonPath"
|
||
type="text"
|
||
class="input-field flex-1 font-mono text-sm"
|
||
placeholder="例如: user.name 或 users[0].email"
|
||
@keyup.enter="queryPath"
|
||
>
|
||
<button
|
||
@click="queryPath"
|
||
:disabled="!jsonPath.trim() || !parsedJson"
|
||
class="btn-secondary"
|
||
>
|
||
<FontAwesomeIcon :icon="['fas', 'search']" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 查询结果 -->
|
||
<div v-if="pathResult !== null" class="p-3 bg-block rounded-lg">
|
||
<div class="text-sm text-secondary mb-2">查询结果:</div>
|
||
<pre class="text-sm font-mono text-primary whitespace-pre-wrap">{{ pathResult }}</pre>
|
||
</div>
|
||
|
||
<div v-if="pathError" class="p-3 bg-error bg-opacity-10 border border-error rounded-lg">
|
||
<div class="text-sm text-error">{{ pathError }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- JSON统计信息 -->
|
||
<div v-if="parsedJson" class="card p-4">
|
||
<h3 class="text-lg font-semibold text-primary mb-3">统计信息</h3>
|
||
|
||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||
<div class="space-y-2">
|
||
<div class="flex justify-between">
|
||
<span class="text-secondary">总键数:</span>
|
||
<span class="text-primary font-medium">{{ jsonStats.totalKeys }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-secondary">总值数:</span>
|
||
<span class="text-primary font-medium">{{ jsonStats.totalValues }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-secondary">嵌套深度:</span>
|
||
<span class="text-primary font-medium">{{ jsonStats.maxDepth }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<div class="flex justify-between">
|
||
<span class="text-secondary">数组数量:</span>
|
||
<span class="text-primary font-medium">{{ jsonStats.arrayCount }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-secondary">对象数量:</span>
|
||
<span class="text-primary font-medium">{{ jsonStats.objectCount }}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-secondary">字符串数量:</span>
|
||
<span class="text-primary font-medium">{{ jsonStats.stringCount }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 状态栏 -->
|
||
<div v-if="statusMessage" class="card p-4">
|
||
<div :class="[
|
||
'flex items-center space-x-2',
|
||
statusType === 'success' ? 'text-success' : 'text-error'
|
||
]">
|
||
<FontAwesomeIcon
|
||
:icon="statusType === 'success' ? ['fas', 'check'] : ['fas', 'times']"
|
||
/>
|
||
<span>{{ statusMessage }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, nextTick } from 'vue'
|
||
import { useLanguage } from '@/composables/useLanguage'
|
||
|
||
const { t } = useLanguage()
|
||
|
||
// 响应式状态
|
||
const jsonText = ref('')
|
||
const viewMode = ref<'text' | 'tree' | 'split'>('text')
|
||
const indentSize = ref<number | string>(2)
|
||
const fontSize = ref('14')
|
||
const showLineNumbers = ref(true)
|
||
const wordWrap = ref(false)
|
||
const copied = ref(false)
|
||
const jsonError = ref('')
|
||
const validationMessage = ref('')
|
||
const statusMessage = ref('')
|
||
const statusType = ref<'success' | 'error'>('success')
|
||
const currentPath = ref('')
|
||
const jsonPath = ref('')
|
||
const pathResult = ref<any>(null)
|
||
const pathError = ref('')
|
||
const errorLine = ref<number | null>(null)
|
||
const errorColumn = ref<number | null>(null)
|
||
|
||
// 树形视图状态
|
||
const expandedNodes = ref<Set<string>>(new Set())
|
||
|
||
// 计算属性
|
||
const lineCount = computed(() => {
|
||
return jsonText.value ? jsonText.value.split('\n').length : 1
|
||
})
|
||
|
||
const charCount = computed(() => {
|
||
return jsonText.value.length
|
||
})
|
||
|
||
const parsedJson = computed(() => {
|
||
if (!jsonText.value.trim()) return null
|
||
|
||
try {
|
||
return JSON.parse(jsonText.value)
|
||
} catch {
|
||
return null
|
||
}
|
||
})
|
||
|
||
const jsonStats = computed(() => {
|
||
if (!parsedJson.value) {
|
||
return {
|
||
totalKeys: 0,
|
||
totalValues: 0,
|
||
maxDepth: 0,
|
||
arrayCount: 0,
|
||
objectCount: 0,
|
||
stringCount: 0
|
||
}
|
||
}
|
||
|
||
const stats = {
|
||
totalKeys: 0,
|
||
totalValues: 0,
|
||
maxDepth: 0,
|
||
arrayCount: 0,
|
||
objectCount: 0,
|
||
stringCount: 0
|
||
}
|
||
|
||
const analyze = (obj: any, depth: number = 0): void => {
|
||
stats.maxDepth = Math.max(stats.maxDepth, depth)
|
||
|
||
if (Array.isArray(obj)) {
|
||
stats.arrayCount++
|
||
stats.totalValues++
|
||
for (const item of obj) {
|
||
analyze(item, depth + 1)
|
||
}
|
||
} else if (typeof obj === 'object' && obj !== null) {
|
||
stats.objectCount++
|
||
stats.totalValues++
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
stats.totalKeys++
|
||
analyze(value, depth + 1)
|
||
}
|
||
} else if (typeof obj === 'string') {
|
||
stats.stringCount++
|
||
stats.totalValues++
|
||
} else {
|
||
stats.totalValues++
|
||
}
|
||
}
|
||
|
||
analyze(parsedJson.value)
|
||
return stats
|
||
})
|
||
|
||
// 处理文本输入
|
||
const handleTextInput = () => {
|
||
validateJson()
|
||
pathResult.value = null
|
||
pathError.value = ''
|
||
}
|
||
|
||
// 处理键盘事件
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
// Tab键缩进
|
||
if (event.key === 'Tab') {
|
||
event.preventDefault()
|
||
const textarea = event.target as HTMLTextAreaElement
|
||
const start = textarea.selectionStart
|
||
const end = textarea.selectionEnd
|
||
|
||
const indent = indentSize.value === 'tab' ? '\t' : ' '.repeat(Number(indentSize.value))
|
||
|
||
if (event.shiftKey) {
|
||
// Shift+Tab: 减少缩进
|
||
const lines = jsonText.value.split('\n')
|
||
const startLine = jsonText.value.substring(0, start).split('\n').length - 1
|
||
const endLine = jsonText.value.substring(0, end).split('\n').length - 1
|
||
|
||
for (let i = startLine; i <= endLine; i++) {
|
||
if (lines[i].startsWith(indent)) {
|
||
lines[i] = lines[i].substring(indent.length)
|
||
}
|
||
}
|
||
|
||
jsonText.value = lines.join('\n')
|
||
} else {
|
||
// Tab: 增加缩进
|
||
const value = jsonText.value
|
||
jsonText.value = value.substring(0, start) + indent + value.substring(end)
|
||
|
||
nextTick(() => {
|
||
textarea.selectionStart = textarea.selectionEnd = start + indent.length
|
||
})
|
||
}
|
||
}
|
||
|
||
// Ctrl+Enter: 格式化
|
||
if (event.ctrlKey && event.key === 'Enter') {
|
||
event.preventDefault()
|
||
formatJson()
|
||
}
|
||
}
|
||
|
||
// 更新光标位置
|
||
const updateCursor = (event: Event) => {
|
||
const textarea = event.target as HTMLTextAreaElement
|
||
const cursorPos = textarea.selectionStart
|
||
const textBeforeCursor = jsonText.value.substring(0, cursorPos)
|
||
const lines = textBeforeCursor.split('\n')
|
||
const currentLine = lines.length
|
||
const currentCol = lines[lines.length - 1].length + 1
|
||
|
||
currentPath.value = `第 ${currentLine} 行,第 ${currentCol} 列`
|
||
}
|
||
|
||
// 验证JSON
|
||
const validateJson = () => {
|
||
if (!jsonText.value.trim()) {
|
||
jsonError.value = ''
|
||
validationMessage.value = ''
|
||
errorLine.value = null
|
||
errorColumn.value = null
|
||
return
|
||
}
|
||
|
||
try {
|
||
JSON.parse(jsonText.value)
|
||
jsonError.value = ''
|
||
validationMessage.value = 'JSON格式正确'
|
||
errorLine.value = null
|
||
errorColumn.value = null
|
||
} catch (error) {
|
||
if (error instanceof SyntaxError) {
|
||
jsonError.value = error.message
|
||
|
||
// 尝试提取行号和列号
|
||
const match = error.message.match(/position (\d+)/)
|
||
if (match) {
|
||
const position = parseInt(match[1])
|
||
const lines = jsonText.value.substring(0, position).split('\n')
|
||
errorLine.value = lines.length
|
||
errorColumn.value = lines[lines.length - 1].length
|
||
}
|
||
} else {
|
||
jsonError.value = '未知错误'
|
||
}
|
||
validationMessage.value = ''
|
||
}
|
||
}
|
||
|
||
// 格式化JSON
|
||
const formatJson = () => {
|
||
if (!jsonText.value.trim()) {
|
||
showStatus('请输入JSON数据', 'error')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(jsonText.value)
|
||
const indent = indentSize.value === 'tab' ? '\t' : Number(indentSize.value)
|
||
jsonText.value = JSON.stringify(parsed, null, indent)
|
||
showStatus('格式化完成', 'success')
|
||
validateJson()
|
||
} catch (error) {
|
||
showStatus('JSON格式错误,无法格式化', 'error')
|
||
}
|
||
}
|
||
|
||
// 压缩JSON
|
||
const compressJson = () => {
|
||
if (!jsonText.value.trim()) {
|
||
showStatus('请输入JSON数据', 'error')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(jsonText.value)
|
||
jsonText.value = JSON.stringify(parsed)
|
||
showStatus('压缩完成', 'success')
|
||
validateJson()
|
||
} catch (error) {
|
||
showStatus('JSON格式错误,无法压缩', 'error')
|
||
}
|
||
}
|
||
|
||
// 复制JSON
|
||
const copyJson = async () => {
|
||
if (!jsonText.value.trim()) return
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(jsonText.value)
|
||
copied.value = true
|
||
setTimeout(() => {
|
||
copied.value = false
|
||
}, 2000)
|
||
showStatus('复制成功', 'success')
|
||
} catch (error) {
|
||
showStatus('复制失败', 'error')
|
||
}
|
||
}
|
||
|
||
// 清除编辑器
|
||
const clearEditor = () => {
|
||
jsonText.value = ''
|
||
jsonError.value = ''
|
||
validationMessage.value = ''
|
||
currentPath.value = ''
|
||
pathResult.value = null
|
||
pathError.value = ''
|
||
statusMessage.value = ''
|
||
}
|
||
|
||
// 加载示例
|
||
const loadSample = () => {
|
||
const sample = {
|
||
"user": {
|
||
"id": 1,
|
||
"name": "张三",
|
||
"email": "zhangsan@example.com",
|
||
"profile": {
|
||
"age": 30,
|
||
"city": "北京",
|
||
"skills": ["JavaScript", "Vue.js", "Node.js"]
|
||
}
|
||
},
|
||
"posts": [
|
||
{
|
||
"id": 1,
|
||
"title": "Vue.js入门指南",
|
||
"content": "这是一篇关于Vue.js的入门教程...",
|
||
"tags": ["vue", "javascript", "前端"],
|
||
"published": true
|
||
},
|
||
{
|
||
"id": 2,
|
||
"title": "JSON数据处理技巧",
|
||
"content": "本文介绍JSON数据的处理方法...",
|
||
"tags": ["json", "数据处理"],
|
||
"published": false
|
||
}
|
||
],
|
||
"metadata": {
|
||
"version": "1.0.0",
|
||
"created_at": "2024-01-01T00:00:00Z",
|
||
"updated_at": "2024-01-15T12:30:00Z"
|
||
}
|
||
}
|
||
|
||
const indent = indentSize.value === 'tab' ? '\t' : Number(indentSize.value)
|
||
jsonText.value = JSON.stringify(sample, null, indent)
|
||
validateJson()
|
||
}
|
||
|
||
// 树形视图相关
|
||
const toggleNode = (path: string[]) => {
|
||
const pathStr = path.join('.')
|
||
if (expandedNodes.value.has(pathStr)) {
|
||
expandedNodes.value.delete(pathStr)
|
||
} else {
|
||
expandedNodes.value.add(pathStr)
|
||
}
|
||
}
|
||
|
||
const selectNode = (path: string[]) => {
|
||
jsonPath.value = path.join('.')
|
||
queryPath()
|
||
}
|
||
|
||
const expandAll = () => {
|
||
const expand = (obj: any, path: string[] = []): void => {
|
||
if (typeof obj === 'object' && obj !== null) {
|
||
expandedNodes.value.add(path.join('.'))
|
||
for (const key in obj) {
|
||
expand(obj[key], [...path, key])
|
||
}
|
||
}
|
||
}
|
||
|
||
if (parsedJson.value) {
|
||
expand(parsedJson.value)
|
||
}
|
||
}
|
||
|
||
const collapseAll = () => {
|
||
expandedNodes.value.clear()
|
||
}
|
||
|
||
// 路径查询
|
||
const queryPath = () => {
|
||
if (!jsonPath.value.trim() || !parsedJson.value) {
|
||
pathResult.value = null
|
||
pathError.value = ''
|
||
return
|
||
}
|
||
|
||
try {
|
||
const result = getValueByPath(parsedJson.value, jsonPath.value)
|
||
pathResult.value = JSON.stringify(result, null, 2)
|
||
pathError.value = ''
|
||
} catch (error) {
|
||
pathResult.value = null
|
||
pathError.value = error instanceof Error ? error.message : '路径查询失败'
|
||
}
|
||
}
|
||
|
||
// 根据路径获取值
|
||
const getValueByPath = (obj: any, path: string): any => {
|
||
const keys = path.split(/[.\[\]]+/).filter(key => key)
|
||
let current = obj
|
||
|
||
for (const key of keys) {
|
||
if (current === null || current === undefined) {
|
||
throw new Error(`路径 "${path}" 中的 "${key}" 不存在`)
|
||
}
|
||
|
||
if (Array.isArray(current)) {
|
||
const index = parseInt(key)
|
||
if (isNaN(index) || index < 0 || index >= current.length) {
|
||
throw new Error(`数组索引 "${key}" 无效`)
|
||
}
|
||
current = current[index]
|
||
} else if (typeof current === 'object') {
|
||
if (!(key in current)) {
|
||
throw new Error(`属性 "${key}" 不存在`)
|
||
}
|
||
current = current[key]
|
||
} else {
|
||
throw new Error(`无法在基本类型上访问属性 "${key}"`)
|
||
}
|
||
}
|
||
|
||
return current
|
||
}
|
||
|
||
// 显示状态消息
|
||
const showStatus = (message: string, type: 'success' | 'error') => {
|
||
statusMessage.value = message
|
||
statusType.value = type
|
||
setTimeout(() => {
|
||
statusMessage.value = ''
|
||
}, 3000)
|
||
}
|
||
|
||
// 监听JSON文本变化
|
||
watch(() => jsonText.value, () => {
|
||
validateJson()
|
||
})
|
||
</script>
|
||
|
||
<!-- JSON树形节点组件 -->
|
||
<script lang="ts">
|
||
export default {
|
||
name: 'JsonTreeNode',
|
||
props: {
|
||
data: {
|
||
type: [Object, Array, String, Number, Boolean],
|
||
required: true
|
||
},
|
||
path: {
|
||
type: Array as () => string[],
|
||
required: true
|
||
},
|
||
expanded: {
|
||
type: Set as () => Set<string>,
|
||
required: true
|
||
}
|
||
},
|
||
emits: ['toggle', 'select'],
|
||
setup(props, { emit }) {
|
||
const isExpanded = computed(() => {
|
||
return props.expanded.has(props.path.join('.'))
|
||
})
|
||
|
||
const isObject = computed(() => {
|
||
return typeof props.data === 'object' && props.data !== null
|
||
})
|
||
|
||
const isArray = computed(() => {
|
||
return Array.isArray(props.data)
|
||
})
|
||
|
||
const dataType = computed(() => {
|
||
if (props.data === null) return 'null'
|
||
if (Array.isArray(props.data)) return 'array'
|
||
return typeof props.data
|
||
})
|
||
|
||
const displayValue = computed(() => {
|
||
if (props.data === null) return 'null'
|
||
if (typeof props.data === 'string') return `"${props.data}"`
|
||
if (typeof props.data === 'boolean') return props.data.toString()
|
||
if (typeof props.data === 'number') return props.data.toString()
|
||
return ''
|
||
})
|
||
|
||
const toggle = () => {
|
||
if (isObject.value) {
|
||
emit('toggle', props.path)
|
||
}
|
||
}
|
||
|
||
const select = () => {
|
||
emit('select', props.path)
|
||
}
|
||
|
||
return {
|
||
isExpanded,
|
||
isObject,
|
||
isArray,
|
||
dataType,
|
||
displayValue,
|
||
toggle,
|
||
select
|
||
}
|
||
},
|
||
template: `
|
||
<div class="json-node">
|
||
<div
|
||
class="flex items-center space-x-1 hover:bg-block-hover rounded px-1 cursor-pointer"
|
||
@click="select"
|
||
>
|
||
<button
|
||
v-if="isObject"
|
||
@click.stop="toggle"
|
||
class="w-4 h-4 flex items-center justify-center text-xs text-secondary hover:text-primary"
|
||
>
|
||
<FontAwesomeIcon
|
||
:icon="isExpanded ? ['fas', 'chevron-down'] : ['fas', 'chevron-right']"
|
||
/>
|
||
</button>
|
||
<div v-else class="w-4"></div>
|
||
|
||
<span
|
||
v-if="path.length > 0"
|
||
class="text-blue-400 font-medium"
|
||
>
|
||
{{ path[path.length - 1] }}:
|
||
</span>
|
||
|
||
<span
|
||
v-if="!isObject"
|
||
:class="{
|
||
'text-green-400': dataType === 'string',
|
||
'text-blue-400': dataType === 'number',
|
||
'text-purple-400': dataType === 'boolean',
|
||
'text-gray-400': dataType === 'null'
|
||
}"
|
||
>
|
||
{{ displayValue }}
|
||
</span>
|
||
|
||
<span v-if="isArray" class="text-gray-400">
|
||
[{{ data.length }}]
|
||
</span>
|
||
<span v-else-if="isObject && !isArray" class="text-gray-400">
|
||
{{{ Object.keys(data).length }}}
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="isObject && isExpanded" class="ml-4 border-l border-border pl-2 mt-1">
|
||
<JsonTreeNode
|
||
v-for="(value, key, index) in data"
|
||
:key="index"
|
||
:data="value"
|
||
:path="[...path, key.toString()]"
|
||
:expanded="expanded"
|
||
@toggle="$emit('toggle', $event)"
|
||
@select="$emit('select', $event)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
</script> |