Files
utils/src/components/tools/JsonEditor.vue
zguiy 8400dbfab9
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
工具完成
2025-06-28 22:38:49 +08:00

843 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>