1954 lines
51 KiB
Vue
1954 lines
51 KiB
Vue
<template>
|
||
<div class="tool-page">
|
||
<!-- 浮层提示 -->
|
||
<Transition name="toast">
|
||
<div v-if="toastMessage" class="toast-notification" :class="toastType">
|
||
<div class="toast-content">
|
||
<i v-if="toastType === 'error'" class="far fa-circle-xmark"></i>
|
||
<i v-else class="fas fa-circle-info"></i>
|
||
<span>{{ toastMessage }}</span>
|
||
</div>
|
||
<button @click="closeToast" class="toast-close-btn" title="关闭">
|
||
<i class="fas fa-xmark"></i>
|
||
</button>
|
||
</div>
|
||
</Transition>
|
||
<div class="main-container">
|
||
<!-- 左侧侧栏(历史记录) -->
|
||
<div class="sidebar" :class="{ 'sidebar-open': sidebarOpen }">
|
||
<div class="sidebar-header">
|
||
<h3>历史记录</h3>
|
||
<button @click="toggleSidebar" class="close-btn">×</button>
|
||
</div>
|
||
<div class="sidebar-content">
|
||
<div v-if="historyList.length === 0" class="empty-history">
|
||
暂无历史记录
|
||
</div>
|
||
<div
|
||
v-for="(item, index) in historyList"
|
||
:key="index"
|
||
class="history-item"
|
||
@click="loadHistory(item.json)"
|
||
>
|
||
<div class="history-time">{{ formatTime(item.time) }}</div>
|
||
<div class="history-preview">{{ truncateText(item.json, 50) }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区域 -->
|
||
<div class="content-wrapper" :class="{ 'sidebar-pushed': sidebarOpen }">
|
||
<!-- 左侧编辑器区域 -->
|
||
<div class="left-panel" :style="{ width: leftPanelWidth + '%' }">
|
||
<div class="panel-toolbar">
|
||
<div class="view-tabs">
|
||
<button class="view-tab active">编辑器 <span class="size-limit">(最大 5MB)</span></button>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<button @click="copyToClipboard" class="toolbar-icon-btn" title="复制">
|
||
<i class="far fa-copy"></i>
|
||
</button>
|
||
<button @click="pasteFromClipboard" class="toolbar-icon-btn" title="粘贴">
|
||
<i class="far fa-paste"></i>
|
||
</button>
|
||
<button @click="clearAll" class="toolbar-icon-btn" title="清空">
|
||
<i class="far fa-trash-can"></i>
|
||
</button>
|
||
<button @click="formatJson" class="toolbar-icon-btn" title="格式化">
|
||
<i class="fas fa-align-left"></i>
|
||
</button>
|
||
<button @click="minifyJson" class="toolbar-icon-btn" title="压缩">
|
||
<i class="fas fa-down-left-and-up-right-to-center"></i>
|
||
</button>
|
||
<button @click="escapeJson" class="toolbar-icon-btn" title="转义">
|
||
<i class="fas fa-code"></i>
|
||
</button>
|
||
<button @click="unescapeJson" class="toolbar-icon-btn" title="取消转义">
|
||
<svg viewBox="150 -50 1100 1250" xmlns="http://www.w3.org/2000/svg" width="17" height="17"><path d="M285.352637 0.003641h111.956538v114.687184h-111.956538v282.621991a110.682235 110.682235 0 0 1-33.313896 81.282266 110.682235 110.682235 0 0 1-81.282266 33.313896 110.682235 110.682235 0 0 1 81.282266 33.313897 110.682235 110.682235 0 0 1 33.313896 81.282266v282.621991h111.956538v114.687184h-111.956538a188.050574 188.050574 0 0 1-80.007964-40.049493 93.570179 93.570179 0 0 1-34.67922-74.637691v-226.643722a110.682235 110.682235 0 0 0-33.313896-81.282267 110.682235 110.682235 0 0 0-81.282267-33.313896H0v-111.956537h55.978269a110.682235 110.682235 0 0 0 81.282266-33.313897 110.682235 110.682235 0 0 0 33.313896-81.282266V114.690825A113.776969 113.776969 0 0 1 285.261616 0.003641z m794.61835 0a113.776969 113.776969 0 0 1 114.687184 114.687184v226.643722a113.776969 113.776969 0 0 0 114.687185 114.687184H1365.323624v111.956538h-55.978268a113.776969 113.776969 0 0 0-114.687185 114.687184v226.643722a113.776969 113.776969 0 0 1-114.687184 114.687184h-111.956537V909.309175h111.956537V626.687184a113.776969 113.776969 0 0 1 114.687184-114.687184 113.776969 113.776969 0 0 1-114.687184-114.687184V114.690825h-111.956537V0.003641h111.956537zM682.661812 682.665453a54.612945 54.612945 0 0 1 55.978269 55.978269 58.799937 58.799937 0 0 1-16.019797 41.323795 54.612945 54.612945 0 0 1-80.007965 0 58.799937 58.799937 0 0 1-16.019797-41.323795 54.612945 54.612945 0 0 1 55.978269-55.978269z m-226.643721 0a54.612945 54.612945 0 0 1 55.978268 55.978269 58.799937 58.799937 0 0 1-16.019797 41.323795 52.246384 52.246384 0 0 1-40.049493 17.294099 59.164024 59.164024 0 0 1-58.708916-58.708916 52.246384 52.246384 0 0 1 17.294099-40.049493 58.799937 58.799937 0 0 1 41.505839-15.837754z m453.287443 0a58.799937 58.799937 0 0 1 41.323795 16.019797 52.246384 52.246384 0 0 1 17.294099 40.049493 59.164024 59.164024 0 0 1-58.708916 58.708916 52.246384 52.246384 0 0 1-40.049493-17.294099 58.799937 58.799937 0 0 1-16.019797-41.323795 54.612945 54.612945 0 0 1 55.978269-55.978269z" fill="#666666" p-id="26339"></path></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div ref="editorContainerRef" class="editor-container">
|
||
<div class="editor-body">
|
||
<div class="line-numbers">
|
||
<div v-for="n in lineCount" :key="n" class="line-number">{{ n }}</div>
|
||
</div>
|
||
<textarea
|
||
ref="jsonEditorRef"
|
||
v-model="inputJson"
|
||
@paste="handlePaste"
|
||
@input="updateLineCount"
|
||
@focus="adjustEditorHeight"
|
||
placeholder='请输入或粘贴JSON数据,例如:{"name":"工具箱","version":1.0}'
|
||
class="json-editor"
|
||
></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-toggle">
|
||
<button @click="toggleSidebar" class="toggle-btn">
|
||
{{ sidebarOpen ? '◀' : '▶' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 可拖拽分割线 -->
|
||
<div
|
||
class="splitter"
|
||
@mousedown="startResize"
|
||
></div>
|
||
|
||
<!-- 右侧JSON树形展示区域 -->
|
||
<div class="right-panel" :style="{ width: rightPanelWidth + '%' }">
|
||
<div class="panel-toolbar">
|
||
<div class="view-tabs">
|
||
<button class="view-tab active">树形</button>
|
||
</div>
|
||
<div class="toolbar-actions">
|
||
<div class="jsonpath-input-wrapper">
|
||
<input
|
||
v-model="jsonPathQuery"
|
||
@input="handleJsonPathInput"
|
||
@focus="showJsonPathHistory = true"
|
||
@blur="handleJsonPathBlur"
|
||
type="text"
|
||
placeholder="输入 JSONPath,例如: $.key.subkey"
|
||
class="jsonpath-input"
|
||
title="JSONPath 筛选"
|
||
/>
|
||
<button
|
||
v-if="jsonPathQuery"
|
||
@click="clearJsonPath"
|
||
class="jsonpath-clear-btn"
|
||
title="清除筛选"
|
||
>
|
||
<i class="fas fa-xmark"></i>
|
||
</button>
|
||
<!-- JSONPath 历史记录下拉菜单 -->
|
||
<div
|
||
v-if="showJsonPathHistory && filteredJsonPathHistory.length > 0"
|
||
class="jsonpath-history-dropdown"
|
||
>
|
||
<div
|
||
v-for="(item, index) in filteredJsonPathHistory"
|
||
:key="index"
|
||
class="jsonpath-history-item"
|
||
@mousedown="selectJsonPathHistory(item)"
|
||
>
|
||
{{ item }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
v-if="jsonPathQuery && jsonPathQuery.trim() && matchedNodes.length > 0"
|
||
@click="copyMatchedResults"
|
||
class="toolbar-icon-btn"
|
||
title="复制筛选结果"
|
||
>
|
||
<i class="far fa-copy"></i>
|
||
</button>
|
||
<button @click="expandAll" class="toolbar-icon-btn" title="展开全部">
|
||
<i class="fas fa-chevron-down"></i>
|
||
</button>
|
||
<button @click="collapseAll" class="toolbar-icon-btn" title="折叠全部">
|
||
<i class="fas fa-chevron-up"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="tree-container">
|
||
<div class="tree-content">
|
||
<div v-if="!parsedData" class="empty-state">
|
||
在左侧输入或粘贴JSON数据,右侧将实时显示树形结构
|
||
</div>
|
||
<!-- 有 JSONPath 筛选时,直接显示匹配的节点列表 -->
|
||
<div v-else-if="jsonPathQuery && jsonPathQuery.trim() && matchedNodes.length > 0" class="matched-nodes-list">
|
||
<JsonTreeNode
|
||
v-for="(node, index) in matchedNodes"
|
||
:key="index"
|
||
:data="node.data"
|
||
:key-name="getPathKey(node.path)"
|
||
:path="getPathParent(node.path)"
|
||
:expanded="expandedNodes"
|
||
:matched-paths="matchedPaths"
|
||
:json-path-query="jsonPathQuery"
|
||
:show-path="true"
|
||
:node-path="node.path"
|
||
@toggle="toggleNode"
|
||
/>
|
||
</div>
|
||
<div v-else-if="jsonPathQuery && jsonPathQuery.trim() && matchedNodes.length === 0" class="empty-state">
|
||
未找到匹配的节点
|
||
</div>
|
||
<!-- 没有筛选时,显示完整的树形结构 -->
|
||
<JsonTreeNode
|
||
v-else
|
||
:data="parsedData"
|
||
:key-name="null"
|
||
:path="''"
|
||
:expanded="expandedNodes"
|
||
:matched-paths="matchedPaths"
|
||
:json-path-query="jsonPathQuery"
|
||
@toggle="toggleNode"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
||
import JsonTreeNode from '../components/JsonTreeNode.vue'
|
||
import { useLineNumberEditor } from '../composables/useLineNumberEditor'
|
||
|
||
// 最大输入限制:5MB(JSON格式化工具的限制)
|
||
// 主要考虑因素:
|
||
// 1. JSON.parse 可以处理更大的 JSON(10-50MB)
|
||
// 2. DOM 渲染是主要瓶颈:大型 JSON 会创建大量 DOM 节点
|
||
// 3. 路径遍历和展开/折叠状态管理也会消耗内存
|
||
const MAX_INPUT_BYTES = 5 * 1024 * 1024 // 5MB
|
||
|
||
const inputJson = ref('')
|
||
const sidebarOpen = ref(false)
|
||
const leftPanelWidth = ref(50)
|
||
const rightPanelWidth = ref(50)
|
||
const isResizing = ref(false)
|
||
const expandedNodes = ref(new Set())
|
||
const treeLineCount = ref(1)
|
||
const {
|
||
containerRef: editorContainerRef,
|
||
editorRef: jsonEditorRef,
|
||
lineCount,
|
||
updateLineCount,
|
||
adjustEditorHeight,
|
||
resetEditorScroll,
|
||
initEditor,
|
||
} = useLineNumberEditor(() => inputJson.value, {
|
||
onBeforeUpdate: () => applyInputLimit(),
|
||
})
|
||
|
||
// JSONPath 筛选
|
||
const jsonPathQuery = ref('')
|
||
const matchedPaths = ref(new Set())
|
||
const showJsonPathHistory = ref(false)
|
||
const jsonPathHistory = ref([])
|
||
const JSONPATH_HISTORY_KEY = 'jsonpath-history'
|
||
const MAX_JSONPATH_HISTORY = 10
|
||
|
||
// 提示消息
|
||
const toastMessage = ref('')
|
||
const toastType = ref('error') // 'error' 或 'info'
|
||
let toastTimer = null
|
||
|
||
// 显示提示
|
||
const showToast = (message, type = 'error', duration = 3000) => {
|
||
toastMessage.value = message
|
||
toastType.value = type
|
||
|
||
// 清除之前的定时器
|
||
if (toastTimer) {
|
||
clearTimeout(toastTimer)
|
||
}
|
||
|
||
// 设置自动隐藏
|
||
toastTimer = setTimeout(() => {
|
||
toastMessage.value = ''
|
||
}, duration)
|
||
}
|
||
|
||
// 关闭提示
|
||
const closeToast = () => {
|
||
if (toastTimer) {
|
||
clearTimeout(toastTimer)
|
||
toastTimer = null
|
||
}
|
||
toastMessage.value = ''
|
||
}
|
||
|
||
// 历史记录
|
||
const historyList = ref([])
|
||
const STORAGE_KEY = 'json-formatter-history'
|
||
const MAX_HISTORY = 50
|
||
|
||
// 解析后的JSON数据
|
||
const parsedData = computed(() => {
|
||
if (!inputJson.value.trim()) return null
|
||
try {
|
||
return JSON.parse(inputJson.value)
|
||
} catch (e) {
|
||
return null
|
||
}
|
||
})
|
||
|
||
// JSONPath 解析和匹配
|
||
const parseJsonPath = (jsonPath) => {
|
||
if (!jsonPath || !jsonPath.trim()) return null
|
||
|
||
const path = jsonPath.trim()
|
||
// 移除开头的 $ 或 $.
|
||
const normalizedPath = path.replace(/^\$\.?/, '')
|
||
if (!normalizedPath) return []
|
||
|
||
// 解析路径段:支持 .key 和 [index] 或 [*]
|
||
const segments = []
|
||
let current = normalizedPath
|
||
let i = 0
|
||
|
||
while (i < current.length) {
|
||
if (current[i] === '[') {
|
||
// 数组索引
|
||
const endIndex = current.indexOf(']', i)
|
||
if (endIndex === -1) break
|
||
const indexStr = current.substring(i + 1, endIndex)
|
||
if (indexStr === '*') {
|
||
segments.push({ type: 'wildcard', index: '*' })
|
||
} else {
|
||
const index = parseInt(indexStr, 10)
|
||
if (!isNaN(index)) {
|
||
segments.push({ type: 'index', index })
|
||
}
|
||
}
|
||
i = endIndex + 1
|
||
} else if (current[i] === '.') {
|
||
i++
|
||
} else {
|
||
// 对象键
|
||
let keyEnd = i
|
||
while (keyEnd < current.length && current[keyEnd] !== '.' && current[keyEnd] !== '[') {
|
||
keyEnd++
|
||
}
|
||
const key = current.substring(i, keyEnd)
|
||
if (key) {
|
||
segments.push({ type: 'key', key })
|
||
}
|
||
i = keyEnd
|
||
}
|
||
}
|
||
|
||
return segments
|
||
}
|
||
|
||
// 将路径转换为 JSONPath 格式(用于匹配)
|
||
const pathToJsonPath = (path) => {
|
||
if (path === 'root') return '$'
|
||
return '$' + path.replace(/^root/, '')
|
||
}
|
||
|
||
// 检查路径是否匹配 JSONPath
|
||
const pathMatchesJsonPath = (path, jsonPathSegments) => {
|
||
if (!jsonPathSegments || jsonPathSegments.length === 0) return true
|
||
|
||
// 将路径转换为段数组
|
||
const pathSegments = []
|
||
const pathStr = path === 'root' ? '' : path.replace(/^root\.?/, '')
|
||
|
||
if (!pathStr) {
|
||
return jsonPathSegments.length === 0
|
||
}
|
||
|
||
// 解析路径段
|
||
let current = pathStr
|
||
let i = 0
|
||
|
||
while (i < current.length) {
|
||
if (current[i] === '[') {
|
||
const endIndex = current.indexOf(']', i)
|
||
if (endIndex === -1) break
|
||
const indexStr = current.substring(i + 1, endIndex)
|
||
const index = parseInt(indexStr, 10)
|
||
if (!isNaN(index)) {
|
||
pathSegments.push({ type: 'index', index })
|
||
}
|
||
i = endIndex + 1
|
||
} else if (current[i] === '.') {
|
||
i++
|
||
} else {
|
||
let keyEnd = i
|
||
while (keyEnd < current.length && current[keyEnd] !== '.' && current[keyEnd] !== '[') {
|
||
keyEnd++
|
||
}
|
||
const key = current.substring(i, keyEnd)
|
||
if (key) {
|
||
pathSegments.push({ type: 'key', key })
|
||
}
|
||
i = keyEnd
|
||
}
|
||
}
|
||
|
||
// 精确匹配:路径段数必须等于 JSONPath 段数
|
||
if (pathSegments.length !== jsonPathSegments.length) return false
|
||
|
||
for (let i = 0; i < jsonPathSegments.length; i++) {
|
||
const jsonSeg = jsonPathSegments[i]
|
||
const pathSeg = pathSegments[i]
|
||
|
||
if (!pathSeg) return false
|
||
|
||
if (jsonSeg.type === 'wildcard') {
|
||
// 通配符匹配任何索引
|
||
if (pathSeg.type !== 'index') return false
|
||
} else if (jsonSeg.type === 'index') {
|
||
if (pathSeg.type !== 'index' || pathSeg.index !== jsonSeg.index) return false
|
||
} else if (jsonSeg.type === 'key') {
|
||
if (pathSeg.type !== 'key' || pathSeg.key !== jsonSeg.key) return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// 递归获取所有路径
|
||
const getAllPathsRecursive = (obj, prefix = 'root', paths = []) => {
|
||
paths.push(prefix)
|
||
|
||
if (Array.isArray(obj)) {
|
||
obj.forEach((item, index) => {
|
||
const path = prefix === 'root' ? `root[${index}]` : `${prefix}[${index}]`
|
||
getAllPathsRecursive(item, path, paths)
|
||
})
|
||
} else if (typeof obj === 'object' && obj !== null) {
|
||
Object.keys(obj).forEach(key => {
|
||
const path = prefix === 'root' ? `root.${key}` : `${prefix}.${key}`
|
||
getAllPathsRecursive(obj[key], path, paths)
|
||
})
|
||
}
|
||
|
||
return paths
|
||
}
|
||
|
||
// 获取路径的所有父路径
|
||
const getParentPaths = (path) => {
|
||
if (path === 'root') return ['root']
|
||
|
||
const parents = ['root']
|
||
const pathStr = path.replace(/^root\.?/, '')
|
||
let current = 'root'
|
||
let i = 0
|
||
|
||
while (i < pathStr.length) {
|
||
if (pathStr[i] === '[') {
|
||
const endIdx = pathStr.indexOf(']', i)
|
||
const idx = pathStr.substring(i + 1, endIdx)
|
||
current = `${current}[${idx}]`
|
||
parents.push(current)
|
||
i = endIdx + 1
|
||
} else if (pathStr[i] === '.') {
|
||
i++
|
||
} else {
|
||
let keyEnd = i
|
||
while (keyEnd < pathStr.length && pathStr[keyEnd] !== '.' && pathStr[keyEnd] !== '[') {
|
||
keyEnd++
|
||
}
|
||
const key = pathStr.substring(i, keyEnd)
|
||
current = current === 'root' ? `root.${key}` : `${current}.${key}`
|
||
parents.push(current)
|
||
i = keyEnd
|
||
}
|
||
}
|
||
|
||
return parents
|
||
}
|
||
|
||
// 根据路径获取数据
|
||
const getDataByPath = (obj, path) => {
|
||
if (path === 'root') return obj
|
||
|
||
const pathStr = path.replace(/^root\.?/, '')
|
||
let current = obj
|
||
let i = 0
|
||
|
||
while (i < pathStr.length && current !== undefined && current !== null) {
|
||
if (pathStr[i] === '[') {
|
||
const endIdx = pathStr.indexOf(']', i)
|
||
const idx = parseInt(pathStr.substring(i + 1, endIdx), 10)
|
||
current = current[idx]
|
||
i = endIdx + 1
|
||
} else if (pathStr[i] === '.') {
|
||
i++
|
||
} else {
|
||
let keyEnd = i
|
||
while (keyEnd < pathStr.length && pathStr[keyEnd] !== '.' && pathStr[keyEnd] !== '[') {
|
||
keyEnd++
|
||
}
|
||
const key = pathStr.substring(i, keyEnd)
|
||
current = current[key]
|
||
i = keyEnd
|
||
}
|
||
}
|
||
|
||
return current
|
||
}
|
||
|
||
// 获取路径的显示名称(最后一个键或索引)
|
||
const getPathDisplayName = (path) => {
|
||
if (path === 'root') return 'root'
|
||
|
||
const pathStr = path.replace(/^root\.?/, '')
|
||
const lastBracket = pathStr.lastIndexOf('[')
|
||
const lastDot = pathStr.lastIndexOf('.')
|
||
|
||
if (lastBracket > lastDot) {
|
||
// 最后一个是数组索引
|
||
const indexStr = pathStr.substring(lastBracket + 1, pathStr.indexOf(']', lastBracket))
|
||
const parentPath = pathStr.substring(0, lastBracket)
|
||
return { type: 'index', index: parseInt(indexStr, 10), parentPath: parentPath ? `root.${parentPath}` : 'root' }
|
||
} else if (lastDot !== -1) {
|
||
// 最后一个是对象键
|
||
const key = pathStr.substring(lastDot + 1)
|
||
const parentPath = pathStr.substring(0, lastDot)
|
||
return { type: 'key', key, parentPath: parentPath ? `root.${parentPath}` : 'root' }
|
||
} else {
|
||
// 只有一层
|
||
if (pathStr.includes('[')) {
|
||
const indexStr = pathStr.substring(pathStr.indexOf('[') + 1, pathStr.indexOf(']'))
|
||
return { type: 'index', index: parseInt(indexStr, 10), parentPath: 'root' }
|
||
} else {
|
||
return { type: 'key', key: pathStr, parentPath: 'root' }
|
||
}
|
||
}
|
||
}
|
||
|
||
// 从路径中提取最后一个键或索引
|
||
const getPathKey = (path) => {
|
||
if (path === 'root') return null
|
||
|
||
const pathStr = path.replace(/^root\.?/, '')
|
||
const lastBracket = pathStr.lastIndexOf('[')
|
||
const lastDot = pathStr.lastIndexOf('.')
|
||
|
||
if (lastBracket > lastDot) {
|
||
// 最后一个是数组索引
|
||
const indexStr = pathStr.substring(lastBracket + 1, pathStr.indexOf(']', lastBracket))
|
||
return parseInt(indexStr, 10)
|
||
} else if (lastDot !== -1) {
|
||
// 最后一个是对象键
|
||
return pathStr.substring(lastDot + 1)
|
||
} else {
|
||
// 只有一层
|
||
if (pathStr.includes('[')) {
|
||
const indexStr = pathStr.substring(pathStr.indexOf('[') + 1, pathStr.indexOf(']'))
|
||
return parseInt(indexStr, 10)
|
||
} else {
|
||
return pathStr
|
||
}
|
||
}
|
||
}
|
||
|
||
// 从路径中提取父路径
|
||
const getPathParent = (path) => {
|
||
if (path === 'root') return ''
|
||
|
||
const pathStr = path.replace(/^root\.?/, '')
|
||
const lastBracket = pathStr.lastIndexOf('[')
|
||
const lastDot = pathStr.lastIndexOf('.')
|
||
|
||
if (lastBracket > lastDot) {
|
||
// 最后一个是数组索引
|
||
const parentPath = pathStr.substring(0, lastBracket)
|
||
// 构建完整的父路径
|
||
if (!parentPath) {
|
||
return 'root'
|
||
}
|
||
// 需要将 parentPath 转换为正确的格式(处理可能的数组索引)
|
||
return `root.${parentPath}`
|
||
} else if (lastDot !== -1) {
|
||
// 最后一个是对象键
|
||
const parentPath = pathStr.substring(0, lastDot)
|
||
if (!parentPath) {
|
||
return 'root'
|
||
}
|
||
return `root.${parentPath}`
|
||
} else {
|
||
// 只有一层,父路径是 root
|
||
return 'root'
|
||
}
|
||
}
|
||
|
||
// 最终匹配的节点列表
|
||
const matchedNodes = computed(() => {
|
||
if (!jsonPathQuery.value.trim() || !parsedData.value) return []
|
||
|
||
const nodes = []
|
||
matchedPaths.value.forEach(path => {
|
||
const data = getDataByPath(parsedData.value, path)
|
||
nodes.push({
|
||
path,
|
||
data
|
||
})
|
||
})
|
||
return nodes
|
||
})
|
||
|
||
// 处理 JSONPath 输入
|
||
const handleJsonPathInput = () => {
|
||
if (!jsonPathQuery.value.trim()) {
|
||
matchedPaths.value.clear()
|
||
return
|
||
}
|
||
|
||
if (!parsedData.value) {
|
||
matchedPaths.value.clear()
|
||
return
|
||
}
|
||
|
||
try {
|
||
const segments = parseJsonPath(jsonPathQuery.value)
|
||
if (!segments || segments.length === 0) {
|
||
matchedPaths.value.clear()
|
||
return
|
||
}
|
||
|
||
// 获取所有路径并匹配,只保留最终匹配的节点(不添加父路径)
|
||
const allPaths = getAllPathsRecursive(parsedData.value)
|
||
const matched = new Set()
|
||
|
||
allPaths.forEach(path => {
|
||
if (pathMatchesJsonPath(path, segments)) {
|
||
// 只添加最终匹配的路径,不添加父路径
|
||
matched.add(path)
|
||
// 自动展开匹配的节点,以便显示其子节点
|
||
expandedNodes.value.add(path)
|
||
}
|
||
})
|
||
|
||
matchedPaths.value = matched
|
||
|
||
// 如果匹配成功,保存到历史记录
|
||
if (matched.size > 0) {
|
||
saveJsonPathHistory(jsonPathQuery.value.trim())
|
||
}
|
||
} catch (e) {
|
||
matchedPaths.value.clear()
|
||
// JSONPath 解析错误,忽略
|
||
}
|
||
}
|
||
|
||
// 清除 JSONPath 筛选
|
||
const clearJsonPath = () => {
|
||
jsonPathQuery.value = ''
|
||
matchedPaths.value.clear()
|
||
}
|
||
|
||
// 过滤后的 JSONPath 历史记录
|
||
const filteredJsonPathHistory = computed(() => {
|
||
if (!jsonPathQuery.value.trim()) {
|
||
return jsonPathHistory.value
|
||
}
|
||
const query = jsonPathQuery.value.toLowerCase()
|
||
return jsonPathHistory.value.filter(item =>
|
||
item.toLowerCase().includes(query)
|
||
)
|
||
})
|
||
|
||
// 加载 JSONPath 历史记录
|
||
const loadJsonPathHistory = () => {
|
||
try {
|
||
const stored = localStorage.getItem(JSONPATH_HISTORY_KEY)
|
||
if (stored) {
|
||
jsonPathHistory.value = JSON.parse(stored)
|
||
}
|
||
} catch (e) {
|
||
// 加载 JSONPath 历史记录失败,重置为空数组
|
||
jsonPathHistory.value = []
|
||
}
|
||
}
|
||
|
||
// 保存 JSONPath 历史记录
|
||
const saveJsonPathHistory = (jsonPath) => {
|
||
if (!jsonPath || !jsonPath.trim()) return
|
||
|
||
const trimmed = jsonPath.trim()
|
||
|
||
// 移除重复项
|
||
const index = jsonPathHistory.value.indexOf(trimmed)
|
||
if (index !== -1) {
|
||
jsonPathHistory.value.splice(index, 1)
|
||
}
|
||
|
||
// 添加到开头
|
||
jsonPathHistory.value.unshift(trimmed)
|
||
|
||
// 限制最多 10 条
|
||
if (jsonPathHistory.value.length > MAX_JSONPATH_HISTORY) {
|
||
jsonPathHistory.value = jsonPathHistory.value.slice(0, MAX_JSONPATH_HISTORY)
|
||
}
|
||
|
||
// 保存到 localStorage
|
||
try {
|
||
localStorage.setItem(JSONPATH_HISTORY_KEY, JSON.stringify(jsonPathHistory.value))
|
||
} catch (e) {
|
||
// 保存 JSONPath 历史记录失败,忽略错误
|
||
}
|
||
}
|
||
|
||
// 选择 JSONPath 历史记录
|
||
const selectJsonPathHistory = (jsonPath) => {
|
||
jsonPathQuery.value = jsonPath
|
||
showJsonPathHistory.value = false
|
||
handleJsonPathInput()
|
||
}
|
||
|
||
// 处理 JSONPath 输入框失焦
|
||
const handleJsonPathBlur = () => {
|
||
// 延迟隐藏,以便点击历史记录项时能触发选择
|
||
setTimeout(() => {
|
||
showJsonPathHistory.value = false
|
||
}, 200)
|
||
}
|
||
|
||
// 复制筛选结果
|
||
const copyMatchedResults = async () => {
|
||
if (!matchedNodes.value || matchedNodes.value.length === 0) {
|
||
showToast('没有可复制的结果', 'error', 2000)
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 将匹配的节点数据转换为 JSON 数组
|
||
const results = matchedNodes.value.map(node => node.data)
|
||
const jsonString = JSON.stringify(results, null, 2)
|
||
|
||
// 复制到剪贴板
|
||
await navigator.clipboard.writeText(jsonString)
|
||
showToast(`已复制 ${matchedNodes.value.length} 个匹配结果`, 'info', 2000)
|
||
} catch (e) {
|
||
showToast('复制失败:' + e.message, 'error', 3000)
|
||
}
|
||
}
|
||
|
||
// 获取字符串 UTF-8 字节长度
|
||
const getByteLength = (str) => new TextEncoder().encode(str).length
|
||
|
||
// 将字符串截断到最大字节数(避免切断多字节字符)
|
||
const truncateToMaxBytes = (str, maxBytes) => {
|
||
if (getByteLength(str) <= maxBytes) return str
|
||
let end = str.length
|
||
while (end > 0 && getByteLength(str.slice(0, end)) > maxBytes) end--
|
||
return str.slice(0, end)
|
||
}
|
||
|
||
// 应用输入大小限制,超出则截断并提示
|
||
const applyInputLimit = () => {
|
||
if (getByteLength(inputJson.value) <= MAX_INPUT_BYTES) return
|
||
inputJson.value = truncateToMaxBytes(inputJson.value, MAX_INPUT_BYTES)
|
||
showToast('内容已超过 5MB 限制,已自动截断', 'info', 3000)
|
||
updateLineCount()
|
||
}
|
||
|
||
// 获取所有路径
|
||
const getAllPaths = (obj, prefix = 'root') => {
|
||
const paths = []
|
||
if (typeof obj === 'object' && obj !== null) {
|
||
paths.push(prefix)
|
||
if (Array.isArray(obj)) {
|
||
obj.forEach((item, index) => {
|
||
const path = `${prefix}[${index}]`
|
||
paths.push(path)
|
||
if (typeof item === 'object' && item !== null) {
|
||
paths.push(...getAllPaths(item, path))
|
||
}
|
||
})
|
||
} else {
|
||
Object.keys(obj).forEach(key => {
|
||
const path = `${prefix}.${key}`
|
||
paths.push(path)
|
||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||
paths.push(...getAllPaths(obj[key], path))
|
||
}
|
||
})
|
||
}
|
||
}
|
||
return paths
|
||
}
|
||
|
||
// 监听输入变化,实时更新树形结构
|
||
watch(inputJson, () => {
|
||
updateLineCount()
|
||
if (inputJson.value.trim()) {
|
||
// 检查大小,如果超过限制则不解析(避免性能问题)
|
||
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
|
||
treeLineCount.value = 1
|
||
return
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(inputJson.value)
|
||
expandedNodes.value.clear()
|
||
// 默认展开所有节点
|
||
const allPaths = getAllPaths(parsed)
|
||
allPaths.forEach(path => expandedNodes.value.add(path))
|
||
// 估算树形视图行数(基于格式化后的JSON)
|
||
const jsonStr = JSON.stringify(parsed, null, 2)
|
||
treeLineCount.value = Math.max(1, jsonStr.split('\n').length)
|
||
} catch (e) {
|
||
// JSON格式错误时不显示提示,只在用户操作时提示
|
||
treeLineCount.value = 1
|
||
}
|
||
} else {
|
||
treeLineCount.value = 1
|
||
}
|
||
})
|
||
|
||
// 格式化JSON
|
||
const formatJson = () => {
|
||
if (!inputJson.value.trim()) {
|
||
showToast('请输入JSON数据')
|
||
return
|
||
}
|
||
|
||
// 检查输入大小
|
||
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
|
||
showToast(`输入内容超过 5MB 限制,无法格式化`, 'error', 4000)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(inputJson.value)
|
||
const formatted = JSON.stringify(parsed, null, 2)
|
||
|
||
// 检查格式化后的大小
|
||
if (getByteLength(formatted) > MAX_INPUT_BYTES) {
|
||
showToast('格式化后的内容超过 5MB 限制,无法显示', 'error', 4000)
|
||
return
|
||
}
|
||
|
||
inputJson.value = formatted
|
||
updateLineCount()
|
||
resetEditorScroll()
|
||
showToast('格式化成功', 'info', 2000)
|
||
} catch (e) {
|
||
showToast('JSON格式错误:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 压缩JSON
|
||
const minifyJson = () => {
|
||
if (!inputJson.value.trim()) {
|
||
showToast('请输入JSON数据')
|
||
return
|
||
}
|
||
|
||
// 检查输入大小
|
||
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
|
||
showToast(`输入内容超过 5MB 限制,无法压缩`, 'error', 4000)
|
||
return
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(inputJson.value)
|
||
const minified = JSON.stringify(parsed)
|
||
|
||
// 检查压缩后的大小(压缩后应该更小,但为了安全还是检查)
|
||
if (getByteLength(minified) > MAX_INPUT_BYTES) {
|
||
showToast('压缩后的内容超过 5MB 限制,无法显示', 'error', 4000)
|
||
return
|
||
}
|
||
|
||
inputJson.value = minified
|
||
updateLineCount()
|
||
resetEditorScroll()
|
||
showToast('压缩成功', 'info', 2000)
|
||
} catch (e) {
|
||
showToast('JSON格式错误:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 转义JSON
|
||
const escapeJson = () => {
|
||
if (!inputJson.value.trim()) {
|
||
showToast('请输入JSON数据')
|
||
return
|
||
}
|
||
|
||
// 检查输入大小
|
||
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
|
||
showToast(`输入内容超过 5MB 限制,无法转义`, 'error', 4000)
|
||
return
|
||
}
|
||
|
||
try {
|
||
let jsonToEscape = inputJson.value.trim()
|
||
|
||
// 检查是否已经是带引号的JSON字符串
|
||
const trimmed = jsonToEscape.trim()
|
||
const isQuotedString = (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||
|
||
if (!isQuotedString) {
|
||
// 没有前后引号的情况
|
||
try {
|
||
// 尝试解析为JSON对象/数组
|
||
const parsedJson = JSON.parse(jsonToEscape)
|
||
// 如果解析成功,先压缩JSON
|
||
jsonToEscape = JSON.stringify(parsedJson)
|
||
// 然后添加引号并转义(JSON.stringify会自动添加引号和转义)
|
||
inputJson.value = JSON.stringify(jsonToEscape)
|
||
} catch (e) {
|
||
// 解析失败,说明是普通字符串(没有引号)
|
||
// 直接添加引号并转义(JSON.stringify会自动添加引号和转义特殊字符)
|
||
inputJson.value = JSON.stringify(jsonToEscape)
|
||
}
|
||
} else {
|
||
// 已经有引号的情况,先解析去掉引号
|
||
try {
|
||
const parsed = JSON.parse(jsonToEscape)
|
||
// 如果解析后是对象/数组,先压缩
|
||
if (typeof parsed === 'object' && parsed !== null) {
|
||
jsonToEscape = JSON.stringify(parsed)
|
||
} else {
|
||
// 如果是基本类型,转换为字符串
|
||
jsonToEscape = String(parsed)
|
||
}
|
||
// 然后添加引号并转义
|
||
inputJson.value = JSON.stringify(jsonToEscape)
|
||
} catch (e) {
|
||
// 解析失败,可能是单引号字符串或其他格式,去掉首尾引号
|
||
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
||
jsonToEscape = trimmed.slice(1, -1)
|
||
} else if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||
jsonToEscape = trimmed.slice(1, -1)
|
||
}
|
||
// 去掉引号后,尝试解析为JSON对象/数组
|
||
try {
|
||
const parsed = JSON.parse(jsonToEscape)
|
||
if (typeof parsed === 'object' && parsed !== null) {
|
||
// 先压缩
|
||
jsonToEscape = JSON.stringify(parsed)
|
||
}
|
||
} catch (e2) {
|
||
// 不是JSON,保持原样
|
||
}
|
||
// 添加引号并转义
|
||
const escaped = JSON.stringify(jsonToEscape)
|
||
// 检查转义后的大小
|
||
if (getByteLength(escaped) > MAX_INPUT_BYTES) {
|
||
showToast('转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
|
||
return
|
||
}
|
||
inputJson.value = escaped
|
||
}
|
||
}
|
||
|
||
// 最后检查一次大小(防止前面的分支没有检查)
|
||
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
|
||
showToast('转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
|
||
return
|
||
}
|
||
|
||
updateLineCount()
|
||
resetEditorScroll()
|
||
showToast('转义成功', 'info', 2000)
|
||
} catch (e) {
|
||
showToast('转义失败:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 取消转义JSON
|
||
const unescapeJson = () => {
|
||
if (!inputJson.value.trim()) {
|
||
showToast('请输入JSON数据')
|
||
return
|
||
}
|
||
|
||
// 检查输入大小
|
||
if (getByteLength(inputJson.value) > MAX_INPUT_BYTES) {
|
||
showToast(`输入内容超过 5MB 限制,无法取消转义`, 'error', 4000)
|
||
return
|
||
}
|
||
|
||
try {
|
||
let jsonToParse = inputJson.value.trim()
|
||
|
||
// 检查是否已经有前后引号
|
||
const trimmed = jsonToParse.trim()
|
||
const hasQuotes = (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||
|
||
let unescaped = null
|
||
|
||
if (hasQuotes) {
|
||
// 有引号,直接解析
|
||
try {
|
||
unescaped = JSON.parse(jsonToParse)
|
||
} catch (e) {
|
||
// 如果是单引号,先去掉单引号,再添加双引号
|
||
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
||
jsonToParse = '"' + trimmed.slice(1, -1) + '"'
|
||
unescaped = JSON.parse(jsonToParse)
|
||
} else {
|
||
throw e
|
||
}
|
||
}
|
||
} else {
|
||
// 没有引号,先尝试直接解析(可能是JSON对象)
|
||
try {
|
||
unescaped = JSON.parse(jsonToParse)
|
||
} catch (e) {
|
||
// 直接解析失败,尝试添加引号后解析(可能是转义的字符串)
|
||
try {
|
||
jsonToParse = '"' + jsonToParse + '"'
|
||
unescaped = JSON.parse(jsonToParse)
|
||
} catch (e2) {
|
||
throw new Error('无法解析:既不是有效的JSON对象,也不是有效的转义字符串')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果取消转义后是字符串,尝试解析为JSON对象
|
||
if (typeof unescaped === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(unescaped)
|
||
// 如果解析成功,自动格式化
|
||
const formatted = JSON.stringify(parsed, null, 2)
|
||
// 检查格式化后的大小
|
||
if (getByteLength(formatted) > MAX_INPUT_BYTES) {
|
||
showToast('取消转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
|
||
return
|
||
}
|
||
inputJson.value = formatted
|
||
updateLineCount()
|
||
resetEditorScroll()
|
||
showToast('取消转义并格式化成功', 'info', 2000)
|
||
return
|
||
} catch (e) {
|
||
// 如果解析失败,说明只是普通字符串,保持原样
|
||
// 检查字符串大小
|
||
if (getByteLength(unescaped) > MAX_INPUT_BYTES) {
|
||
showToast('取消转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
|
||
return
|
||
}
|
||
inputJson.value = unescaped
|
||
updateLineCount()
|
||
resetEditorScroll()
|
||
showToast('取消转义成功', 'info', 2000)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 如果取消转义后是对象或数组,自动格式化
|
||
if (typeof unescaped === 'object' && unescaped !== null) {
|
||
const formatted = JSON.stringify(unescaped, null, 2)
|
||
// 检查格式化后的大小
|
||
if (getByteLength(formatted) > MAX_INPUT_BYTES) {
|
||
showToast('取消转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
|
||
return
|
||
}
|
||
inputJson.value = formatted
|
||
updateLineCount()
|
||
resetEditorScroll()
|
||
showToast('取消转义并格式化成功', 'info', 2000)
|
||
} else {
|
||
// 其他类型(数字、布尔值等),转换为字符串
|
||
const result = String(unescaped)
|
||
// 检查结果大小
|
||
if (getByteLength(result) > MAX_INPUT_BYTES) {
|
||
showToast('取消转义后的内容超过 5MB 限制,无法显示', 'error', 4000)
|
||
return
|
||
}
|
||
inputJson.value = result
|
||
updateLineCount()
|
||
resetEditorScroll()
|
||
showToast('取消转义成功', 'info', 2000)
|
||
}
|
||
} catch (e) {
|
||
showToast('取消转义失败:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 复制到剪贴板
|
||
const copyToClipboard = async () => {
|
||
if (!inputJson.value.trim()) {
|
||
showToast('编辑器内容为空,无法复制')
|
||
return
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(inputJson.value)
|
||
showToast('已复制到剪贴板', 'info', 2000)
|
||
} catch (e) {
|
||
showToast('复制失败:' + e.message)
|
||
}
|
||
}
|
||
|
||
// 从剪贴板粘贴
|
||
const pasteFromClipboard = async () => {
|
||
// 优先使用现代 Clipboard API(需要 HTTPS 或 localhost)
|
||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||
try {
|
||
let text = await navigator.clipboard.readText()
|
||
if (text.trim()) {
|
||
// 检查大小限制
|
||
if (getByteLength(text) > MAX_INPUT_BYTES) {
|
||
text = truncateToMaxBytes(text, MAX_INPUT_BYTES)
|
||
showToast('粘贴内容已超过 5MB 限制,已自动截断', 'info', 3000)
|
||
}
|
||
inputJson.value = text
|
||
updateLineCount()
|
||
// 粘贴后不重置滚动位置,保持在当前位置
|
||
// 检查是否是有效的JSON,如果是则保存到历史记录
|
||
try {
|
||
JSON.parse(text)
|
||
// 避免重复保存相同的JSON
|
||
const lastHistory = historyList.value[0]
|
||
if (!lastHistory || lastHistory.json !== text) {
|
||
saveToHistory(text)
|
||
}
|
||
} catch (e) {
|
||
// 如果不是有效JSON,不保存到历史记录
|
||
}
|
||
} else {
|
||
showToast('剪贴板内容为空')
|
||
}
|
||
return
|
||
} catch (e) {
|
||
// 如果 Clipboard API 失败,使用备用方法
|
||
}
|
||
}
|
||
|
||
// 备用方法:聚焦到编辑器,提示用户粘贴
|
||
// 让现有的 handlePaste 方法处理粘贴逻辑
|
||
if (jsonEditorRef.value) {
|
||
jsonEditorRef.value.focus()
|
||
showToast('请按 Ctrl+V 或 Cmd+V 粘贴内容', 'info', 3000)
|
||
} else {
|
||
showToast('无法访问编辑器,请手动粘贴内容')
|
||
}
|
||
}
|
||
|
||
// 清空
|
||
const clearAll = () => {
|
||
inputJson.value = ''
|
||
expandedNodes.value.clear()
|
||
treeLineCount.value = 1
|
||
updateLineCount()
|
||
showToast('已清空', 'info', 2000)
|
||
}
|
||
|
||
// 处理粘贴事件
|
||
const handlePaste = async (event) => {
|
||
// 获取粘贴的文本
|
||
const pastedText = event.clipboardData?.getData('text') || ''
|
||
|
||
if (pastedText.trim()) {
|
||
// 检查大小限制
|
||
if (getByteLength(pastedText) > MAX_INPUT_BYTES) {
|
||
event.preventDefault()
|
||
const truncated = truncateToMaxBytes(pastedText, MAX_INPUT_BYTES)
|
||
inputJson.value = truncated
|
||
showToast('粘贴内容已超过 5MB 限制,已自动截断', 'info', 3000)
|
||
updateLineCount()
|
||
return
|
||
}
|
||
|
||
// 等待下一个tick,确保inputJson已更新
|
||
await new Promise(resolve => setTimeout(resolve, 0))
|
||
|
||
// 粘贴后不重置滚动位置,保持在当前位置
|
||
|
||
// 检查是否是有效的JSON
|
||
try {
|
||
JSON.parse(inputJson.value)
|
||
// 避免重复保存相同的JSON
|
||
const lastHistory = historyList.value[0]
|
||
if (!lastHistory || lastHistory.json !== inputJson.value) {
|
||
saveToHistory(inputJson.value)
|
||
}
|
||
} catch (e) {
|
||
// 如果不是有效JSON,不保存
|
||
}
|
||
}
|
||
}
|
||
|
||
// 保存到历史记录
|
||
const saveToHistory = (json) => {
|
||
const historyItem = {
|
||
json: json,
|
||
time: Date.now()
|
||
}
|
||
|
||
// 从localStorage读取现有历史
|
||
let history = []
|
||
try {
|
||
const stored = localStorage.getItem(STORAGE_KEY)
|
||
if (stored) {
|
||
history = JSON.parse(stored)
|
||
}
|
||
} catch (e) {
|
||
// 读取历史记录失败,忽略错误
|
||
}
|
||
|
||
// 添加到开头
|
||
history.unshift(historyItem)
|
||
|
||
// 限制最多50条
|
||
if (history.length > MAX_HISTORY) {
|
||
history = history.slice(0, MAX_HISTORY)
|
||
}
|
||
|
||
// 保存到localStorage
|
||
try {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history))
|
||
loadHistoryList()
|
||
} catch (e) {
|
||
// 保存历史记录失败,忽略错误
|
||
}
|
||
}
|
||
|
||
// 加载历史记录列表
|
||
const loadHistoryList = () => {
|
||
try {
|
||
const stored = localStorage.getItem(STORAGE_KEY)
|
||
if (stored) {
|
||
historyList.value = JSON.parse(stored)
|
||
}
|
||
} catch (e) {
|
||
// 加载历史记录失败,重置为空数组
|
||
historyList.value = []
|
||
}
|
||
}
|
||
|
||
// 加载历史记录
|
||
const loadHistory = (json) => {
|
||
inputJson.value = json
|
||
expandedNodes.value.clear()
|
||
updateLineCount()
|
||
resetEditorScroll()
|
||
}
|
||
|
||
// 切换侧栏
|
||
const toggleSidebar = () => {
|
||
sidebarOpen.value = !sidebarOpen.value
|
||
}
|
||
|
||
// 格式化时间
|
||
const formatTime = (timestamp) => {
|
||
const date = new Date(timestamp)
|
||
const now = new Date()
|
||
const diff = now - date
|
||
|
||
if (diff < 60000) return '刚刚'
|
||
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
|
||
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
|
||
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})
|
||
}
|
||
|
||
// 截断文本
|
||
const truncateText = (text, maxLength) => {
|
||
if (text.length <= maxLength) return text
|
||
return text.substring(0, maxLength) + '...'
|
||
}
|
||
|
||
// 展开/折叠节点
|
||
const toggleNode = (path) => {
|
||
if (expandedNodes.value.has(path)) {
|
||
expandedNodes.value.delete(path)
|
||
} else {
|
||
expandedNodes.value.add(path)
|
||
}
|
||
}
|
||
|
||
// 展开全部
|
||
const expandAll = () => {
|
||
if (!parsedData.value) return
|
||
const allPaths = getAllPaths(parsedData.value)
|
||
allPaths.forEach(path => expandedNodes.value.add(path))
|
||
}
|
||
|
||
// 折叠全部
|
||
const collapseAll = () => {
|
||
expandedNodes.value.clear()
|
||
}
|
||
|
||
// 拖拽调整宽度
|
||
const startResize = (e) => {
|
||
isResizing.value = true
|
||
document.addEventListener('mousemove', handleResize)
|
||
document.addEventListener('mouseup', stopResize)
|
||
e.preventDefault()
|
||
}
|
||
|
||
const handleResize = (e) => {
|
||
if (!isResizing.value) return
|
||
|
||
const container = document.querySelector('.content-wrapper')
|
||
if (!container) return
|
||
|
||
const containerWidth = container.offsetWidth
|
||
const mouseX = e.clientX - container.getBoundingClientRect().left
|
||
const percentage = (mouseX / containerWidth) * 100
|
||
|
||
// 限制在20%到80%之间
|
||
const leftPercent = Math.max(20, Math.min(80, percentage))
|
||
const rightPercent = 100 - leftPercent
|
||
|
||
leftPanelWidth.value = leftPercent
|
||
rightPanelWidth.value = rightPercent
|
||
}
|
||
|
||
const stopResize = () => {
|
||
isResizing.value = false
|
||
document.removeEventListener('mousemove', handleResize)
|
||
document.removeEventListener('mouseup', stopResize)
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadHistoryList()
|
||
loadJsonPathHistory()
|
||
initEditor()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('mousemove', handleResize)
|
||
document.removeEventListener('mouseup', stopResize)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 浮层提示样式 */
|
||
.toast-notification {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 1000;
|
||
min-width: 300px;
|
||
max-width: 90%;
|
||
padding: 0.75rem 1rem;
|
||
border-radius: 6px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
display: flex;
|
||
align-items: center;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.toast-notification.error {
|
||
background: #fff5f5;
|
||
color: #c33;
|
||
border: 1px solid #ffcccc;
|
||
}
|
||
|
||
.toast-notification.info {
|
||
background: #f0f9ff;
|
||
color: #0369a1;
|
||
border: 1px solid #bae6fd;
|
||
}
|
||
|
||
.toast-content {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.toast-content svg,
|
||
.toast-content i {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toast-content span {
|
||
flex: 1;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.toast-close-btn {
|
||
flex-shrink: 0;
|
||
margin-left: 0.75rem;
|
||
padding: 0.25rem;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: inherit;
|
||
opacity: 0.6;
|
||
transition: all 0.2s;
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
|
||
.toast-close-btn:hover {
|
||
opacity: 1;
|
||
background: rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.toast-notification.error .toast-close-btn:hover {
|
||
background: rgba(204, 51, 51, 0.15);
|
||
}
|
||
|
||
.toast-notification.info .toast-close-btn:hover {
|
||
background: rgba(3, 105, 161, 0.15);
|
||
}
|
||
|
||
.toast-close-btn:active {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.toast-close-btn svg,
|
||
.toast-close-btn i {
|
||
display: block;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* 浮层提示动画 */
|
||
.toast-enter-active {
|
||
animation: slideUp 0.3s ease-out;
|
||
}
|
||
|
||
.toast-leave-active {
|
||
animation: slideDown 0.3s ease-in;
|
||
}
|
||
|
||
@keyframes slideUp {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateX(-50%) translateY(20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0);
|
||
}
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0);
|
||
}
|
||
to {
|
||
opacity: 0;
|
||
transform: translateX(-50%) translateY(20px);
|
||
}
|
||
}
|
||
|
||
.tool-page {
|
||
height: calc(100vh - 64px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin: -1rem;
|
||
padding: 0;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.main-container {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: #ffffff;
|
||
}
|
||
|
||
/* 侧栏样式 */
|
||
.sidebar {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 300px;
|
||
background: #ffffff;
|
||
border-right: 1px solid #e5e5e5;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.3s ease;
|
||
z-index: 10;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.sidebar-open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.content-wrapper.sidebar-pushed {
|
||
margin-left: 300px;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 1rem;
|
||
border-bottom: 1px solid #e5e5e5;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.sidebar-header h3 {
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
color: #1a1a1a;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.close-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
color: #666666;
|
||
padding: 0;
|
||
width: 24px;
|
||
height: 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.close-btn:hover {
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
.sidebar-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 0.5rem;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.empty-history {
|
||
padding: 2rem;
|
||
text-align: center;
|
||
color: #999999;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.history-item {
|
||
padding: 0.75rem;
|
||
margin-bottom: 0.5rem;
|
||
background: #ffffff;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
border: 1px solid #e5e5e5;
|
||
}
|
||
|
||
.history-item:hover {
|
||
background: #f5f5f5;
|
||
border-color: #d0d0d0;
|
||
}
|
||
|
||
.history-time {
|
||
font-size: 0.75rem;
|
||
color: #999999;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
|
||
.history-preview {
|
||
font-size: 0.875rem;
|
||
color: #1a1a1a;
|
||
font-family: 'Courier New', monospace;
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* 主内容区域 */
|
||
.content-wrapper {
|
||
flex: 1;
|
||
display: flex;
|
||
position: relative;
|
||
background: #ffffff;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.left-panel,
|
||
.right-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
background: #ffffff;
|
||
min-height: 0;
|
||
max-height: 100%;
|
||
}
|
||
|
||
.left-panel {
|
||
position: relative;
|
||
}
|
||
|
||
/* 面板工具栏 */
|
||
.panel-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0;
|
||
background: #ffffff;
|
||
border-bottom: 1px solid #e5e5e5;
|
||
min-height: 40px;
|
||
}
|
||
|
||
.view-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
padding-left: 0.5rem;
|
||
}
|
||
|
||
.view-tab {
|
||
padding: 0.5rem 1rem;
|
||
border: none;
|
||
background: transparent;
|
||
color: #666666;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
transition: all 0.2s;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
}
|
||
|
||
.view-tab:hover {
|
||
color: #1a1a1a;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.view-tab.active {
|
||
color: #1a1a1a;
|
||
background: #1a1a1a;
|
||
color: #ffffff;
|
||
border-bottom-color: #1a1a1a;
|
||
}
|
||
|
||
.view-tab .size-limit {
|
||
font-size: 0.75rem;
|
||
font-weight: 400;
|
||
opacity: 0.7;
|
||
margin-left: 0.25rem;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
padding: 0.25rem 0.5rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.toolbar-icon-btn {
|
||
padding: 0.375rem;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
color: #666666;
|
||
}
|
||
|
||
.toolbar-icon-btn:hover {
|
||
background: #f5f5f5;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
.toolbar-icon-btn:active {
|
||
background: #e5e5e5;
|
||
}
|
||
|
||
.toolbar-icon-btn svg,
|
||
.toolbar-icon-btn i {
|
||
display: block;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* JSONPath 输入框样式 */
|
||
.jsonpath-input-wrapper {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
margin-right: 0.5rem;
|
||
}
|
||
|
||
.jsonpath-input {
|
||
width: 250px;
|
||
padding: 0.375rem 0.75rem;
|
||
padding-right: 28px;
|
||
border: 1px solid #e5e5e5;
|
||
border-radius: 4px;
|
||
font-size: 0.875rem;
|
||
font-family: 'Courier New', monospace;
|
||
background: #ffffff;
|
||
color: #1a1a1a;
|
||
outline: none;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.jsonpath-input:focus {
|
||
border-color: #1a1a1a;
|
||
box-shadow: 0 0 0 2px rgba(26, 26, 26, 0.1);
|
||
}
|
||
|
||
.jsonpath-input::placeholder {
|
||
color: #999999;
|
||
}
|
||
|
||
.jsonpath-clear-btn {
|
||
position: absolute;
|
||
right: 6px;
|
||
padding: 0.25rem;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #666666;
|
||
opacity: 0.6;
|
||
transition: all 0.2s;
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
|
||
.jsonpath-clear-btn:hover {
|
||
opacity: 1;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.jsonpath-clear-btn:active {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.jsonpath-clear-btn svg,
|
||
.jsonpath-clear-btn i {
|
||
display: block;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* JSONPath 历史记录下拉菜单 */
|
||
.jsonpath-history-dropdown {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
margin-top: 4px;
|
||
background: #ffffff;
|
||
border: 1px solid #e5e5e5;
|
||
border-radius: 4px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.jsonpath-history-item {
|
||
padding: 0.5rem 0.75rem;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
font-family: 'Courier New', monospace;
|
||
color: #1a1a1a;
|
||
transition: background 0.2s;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.jsonpath-history-item:hover {
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.jsonpath-history-item:first-child {
|
||
border-radius: 4px 4px 0 0;
|
||
}
|
||
|
||
.jsonpath-history-item:last-child {
|
||
border-radius: 0 0 4px 4px;
|
||
}
|
||
|
||
.json-editor {
|
||
flex: 1;
|
||
width: 0;
|
||
min-width: 0;
|
||
padding: 1rem 1rem 1rem 3rem;
|
||
border: none;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 14px;
|
||
resize: none;
|
||
outline: none;
|
||
background: #ffffff;
|
||
color: #1a1a1a;
|
||
line-height: 1.6;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.json-editor:focus {
|
||
background: #ffffff;
|
||
}
|
||
|
||
.json-editor::placeholder {
|
||
color: #999999;
|
||
}
|
||
|
||
/* 侧栏切换按钮 */
|
||
.sidebar-toggle {
|
||
position: absolute;
|
||
left: 0;
|
||
bottom: 1rem;
|
||
z-index: 5;
|
||
}
|
||
|
||
.toggle-btn {
|
||
width: 32px;
|
||
height: 48px;
|
||
background: #1a1a1a;
|
||
color: #ffffff;
|
||
border: none;
|
||
border-radius: 0 4px 4px 0;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.toggle-btn:hover {
|
||
background: #333333;
|
||
}
|
||
|
||
/* 分割线 */
|
||
.splitter {
|
||
width: 1px;
|
||
background: #e5e5e5;
|
||
cursor: col-resize;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.splitter:hover {
|
||
background: #d0d0d0;
|
||
width: 2px;
|
||
}
|
||
|
||
.splitter::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: -2px;
|
||
right: -2px;
|
||
top: 0;
|
||
bottom: 0;
|
||
}
|
||
|
||
/* 右侧面板 */
|
||
.tree-container {
|
||
flex: 1;
|
||
display: flex;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: #ffffff;
|
||
min-height: 0;
|
||
}
|
||
|
||
.tree-content {
|
||
flex: 1 1 0;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding: 1rem;
|
||
min-height: 0;
|
||
box-sizing: border-box;
|
||
position: relative;
|
||
}
|
||
|
||
.empty-state {
|
||
padding: 2rem;
|
||
text-align: center;
|
||
color: #999999;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
/* 匹配节点列表样式 */
|
||
.matched-nodes-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.tool-page {
|
||
padding: 0;
|
||
margin: -1rem;
|
||
height: calc(100vh - 64px + 2rem);
|
||
}
|
||
|
||
.sidebar {
|
||
width: 80%;
|
||
max-width: 300px;
|
||
}
|
||
|
||
.left-panel,
|
||
.right-panel {
|
||
min-width: 0;
|
||
}
|
||
|
||
.view-tabs {
|
||
padding-left: 0.25rem;
|
||
}
|
||
|
||
.view-tab {
|
||
padding: 0.5rem 0.75rem;
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.json-editor {
|
||
padding-left: 2.5rem;
|
||
}
|
||
|
||
.tree-content {
|
||
padding-left: 1rem;
|
||
}
|
||
|
||
.jsonpath-input-wrapper {
|
||
margin-right: 0.25rem;
|
||
}
|
||
|
||
.jsonpath-input {
|
||
width: 180px;
|
||
font-size: 0.8125rem;
|
||
padding: 0.25rem 0.5rem;
|
||
padding-right: 24px;
|
||
}
|
||
|
||
.toast-notification {
|
||
bottom: 10px;
|
||
left: 1rem;
|
||
right: 1rem;
|
||
transform: none;
|
||
min-width: auto;
|
||
max-width: none;
|
||
}
|
||
|
||
@keyframes slideUp {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
to {
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
}
|
||
}
|
||
}
|
||
</style>
|