Files
ToolBox/src/views/JsonFormatter.vue
T
renjue 8d172a0d22
CI / docker (push) Has been cancelled
初始化 ToolBox 开发者工具箱。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 15:49:03 +08:00

1954 lines
51 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="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 可以处理更大的 JSON10-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>